diff --git a/Archevolt.slnx b/Archevolt.slnx index 84c5461..cb4bcc6 100644 --- a/Archevolt.slnx +++ b/Archevolt.slnx @@ -3,6 +3,7 @@ + diff --git a/samples/Archeoids.Browser/Archeoids.Browser.csproj b/samples/Archeoids.Browser/Archeoids.Browser.csproj new file mode 100644 index 0000000..147efc4 --- /dev/null +++ b/samples/Archeoids.Browser/Archeoids.Browser.csproj @@ -0,0 +1,18 @@ + + + Exe + browser-wasm + true + false + false + false + + + + + + + + + + diff --git a/samples/Archeoids.Browser/Program.cs b/samples/Archeoids.Browser/Program.cs new file mode 100644 index 0000000..60b8b72 --- /dev/null +++ b/samples/Archeoids.Browser/Program.cs @@ -0,0 +1,61 @@ +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.JavaScript; +using System.Runtime.Versioning; + +namespace Archeoids; + +[SupportedOSPlatform("browser")] +internal static partial class Program +{ + private static readonly Game Game = new(); + private static readonly FrameGeometry Geometry = new(); + private static double _accumulator; + private static double _seconds; + private static bool _paused; + + public static void Main() { } + + [JSExport] + public static void Frame(double deltaSeconds, int turn, bool thrust, bool fire) + { + if (!_paused) + { + _accumulator += Math.Clamp(deltaSeconds, 0, 0.1); + while (_accumulator >= Game.TickSeconds) + { + Game.Step(new Controls(turn, thrust, fire)); + _seconds += Game.TickSeconds; + _accumulator -= Game.TickSeconds; + } + } + + Game.Capture(); + Session state = Game.State; + Geometry.Build(Game.Frame, state, _seconds, _paused); + Render(MemoryMarshal.AsBytes(Geometry.Vertices), state.Score, state.Wave, state.Lives, + Game.Frame.Sprites.Count, Game.UpdateMilliseconds, _paused); + } + + [JSExport] + public static void TogglePause() + { + _paused = !_paused; + ResetClock(); + } + + [JSExport] + public static void Restart() + { + Game.Restart(); + _paused = false; + _seconds = 0; + ResetClock(); + } + + [JSExport] + public static void ResetClock() => _accumulator = 0; + + [JSImport("render", "archeoids")] + private static partial void Render([JSMarshalAs] Span vertices, + int score, int wave, int lives, int entities, double milliseconds, bool paused); +} diff --git a/samples/Archeoids.Browser/wwwroot/fonts/Barlow-Regular.ttf b/samples/Archeoids.Browser/wwwroot/fonts/Barlow-Regular.ttf new file mode 100644 index 0000000..bb3faec Binary files /dev/null and b/samples/Archeoids.Browser/wwwroot/fonts/Barlow-Regular.ttf differ diff --git a/samples/Archeoids.Browser/wwwroot/fonts/Barlow-SemiBold.ttf b/samples/Archeoids.Browser/wwwroot/fonts/Barlow-SemiBold.ttf new file mode 100644 index 0000000..7c974a4 Binary files /dev/null and b/samples/Archeoids.Browser/wwwroot/fonts/Barlow-SemiBold.ttf differ diff --git a/samples/Archeoids.Browser/wwwroot/fonts/OFL.txt b/samples/Archeoids.Browser/wwwroot/fonts/OFL.txt new file mode 100644 index 0000000..2f22ba6 --- /dev/null +++ b/samples/Archeoids.Browser/wwwroot/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/samples/Archeoids.Browser/wwwroot/index.html b/samples/Archeoids.Browser/wwwroot/index.html new file mode 100644 index 0000000..ce39b99 --- /dev/null +++ b/samples/Archeoids.Browser/wwwroot/index.html @@ -0,0 +1,22 @@ + + + + + + Archeoids | Archevolt + WebGPU + + + + + +
+ + Archeoids requires a browser with WebGPU support. + + +
Loading Archeoids…
+
+

Click the game or press Tab to focus. A/D or Left/Right: turn. + W or Up: thrust. Space: fire. P: pause or resume. R: restart.

+ + diff --git a/samples/Archeoids.Browser/wwwroot/main.js b/samples/Archeoids.Browser/wwwroot/main.js new file mode 100644 index 0000000..6982e00 --- /dev/null +++ b/samples/Archeoids.Browser/wwwroot/main.js @@ -0,0 +1,68 @@ +import { createRenderer } from "./renderer.js"; + +const canvas = document.querySelector("#game"); +const status = document.querySelector("#status"); +let failed = false; +let animation; + +function fail(error) { + if (failed) return; + failed = true; + cancelAnimationFrame(animation); + console.error(error); + status.textContent = `Archeoids could not continue. ${error.message || error}`; + status.hidden = false; +} + +try { + const render = await createRenderer(canvas, document.querySelector("#hud"), fail); + const { dotnet } = await import("./_framework/dotnet.js"); + const { setModuleImports, getAssemblyExports, getConfig, runMain } = await dotnet.create(); + setModuleImports("archeoids", { render }); + const config = getConfig(); + const exports = await getAssemblyExports(config.mainAssemblyName); + await runMain(); + const game = exports.Archeoids.Program; + const keys = new Set(); + const gameKeys = new Set(["KeyA", "KeyD", "ArrowLeft", "ArrowRight", "KeyW", "ArrowUp", "Space", "KeyP", "KeyR"]); + let lastTime; + + function resetInput() { + keys.clear(); + lastTime = undefined; + game.ResetClock(); + } + canvas.addEventListener("pointerdown", () => canvas.focus({ preventScroll: true })); + canvas.addEventListener("keydown", event => { + if (failed || !gameKeys.has(event.code) || event.ctrlKey || event.metaKey || event.altKey) return; + event.preventDefault(); + keys.add(event.code); + if (event.repeat) return; + if (event.code === "KeyP") { game.TogglePause(); lastTime = undefined; } + if (event.code === "KeyR") { game.Restart(); lastTime = undefined; } + }); + window.addEventListener("keyup", event => { keys.delete(event.code); }); + canvas.addEventListener("blur", resetInput); + canvas.addEventListener("focus", resetInput); + window.addEventListener("blur", resetInput); + window.addEventListener("focus", resetInput); + document.addEventListener("visibilitychange", resetInput); + + function frame(time) { + if (failed) return; + try { + const active = document.activeElement === canvas && document.hasFocus() && !document.hidden; + const delta = active && lastTime !== undefined ? (time - lastTime) / 1000 : 0; + lastTime = active ? time : undefined; + const left = keys.has("KeyA") || keys.has("ArrowLeft"); + const right = keys.has("KeyD") || keys.has("ArrowRight"); + game.Frame(delta, Number(right) - Number(left), keys.has("KeyW") || keys.has("ArrowUp"), keys.has("Space")); + animation = requestAnimationFrame(frame); + } catch (error) { fail(error); } + } + if (!failed) { + status.hidden = true; + canvas.focus({ preventScroll: true }); + animation = requestAnimationFrame(frame); + } +} catch (error) { fail(error); } diff --git a/samples/Archeoids.Browser/wwwroot/renderer.js b/samples/Archeoids.Browser/wwwroot/renderer.js new file mode 100644 index 0000000..aa8697b --- /dev/null +++ b/samples/Archeoids.Browser/wwwroot/renderer.js @@ -0,0 +1,140 @@ +const width = 1280; +const height = 800; +const dim = "#4f6e7d"; +const white = "#dbeeed"; +const amber = "#ffa642"; +const cyan = "#66f2e6"; + +export async function createRenderer(canvas, overlay, fail) { + if (!navigator.gpu) + throw new Error("WebGPU is unavailable. Open this game in a WebGPU-capable browser over HTTPS or localhost."); + const adapter = await navigator.gpu.requestAdapter(); + if (!adapter) + throw new Error("No WebGPU adapter is available. Check your browser's hardware acceleration and graphics driver."); + const device = await adapter.requestDevice(); + device.lost.then(info => fail(new Error(`The WebGPU device was lost (${info.reason}). Reload to try again.`))); + device.addEventListener("uncapturederror", event => fail(event.error)); + + const context = canvas.getContext("webgpu"); + const text = overlay.getContext("2d"); + if (!context || !text) throw new Error("The browser could not create the game canvases."); + const format = navigator.gpu.getPreferredCanvasFormat(); + context.configure({ device, format, alphaMode: "opaque" }); + + const shader = device.createShaderModule({ code: ` + struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) color: vec4f, + } + @vertex fn vertex(@location(0) position: vec2f, @location(1) color: vec4f) -> VertexOutput { + var result: VertexOutput; + result.position = vec4f(position.x / 640.0 - 1.0, 1.0 - position.y / 400.0, 0.0, 1.0); + result.color = color; + return result; + } + @fragment fn fragment(input: VertexOutput) -> @location(0) vec4f { + return input.color; + } + ` }); + const pipeline = await device.createRenderPipelineAsync({ + layout: "auto", + vertex: { + module: shader, entryPoint: "vertex", + buffers: [{ arrayStride: 24, attributes: [ + { shaderLocation: 0, offset: 0, format: "float32x2" }, + { shaderLocation: 1, offset: 8, format: "float32x4" }, + ] }], + }, + fragment: { + module: shader, entryPoint: "fragment", + targets: [{ format, blend: { + color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha" }, + alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha" }, + } }], + }, + primitive: { topology: "triangle-list" }, + }); + + try { + const fonts = await Promise.all([ + document.fonts.load('400 16px "Barlow"'), + document.fonts.load('600 32px "Barlow"'), + ]); + if (fonts.some(faces => faces.length === 0)) throw new Error("Font faces are missing."); + } catch (cause) { + throw new Error("The bundled HUD font could not be loaded. Reload to try again.", { cause }); + } + + let staging = new Uint8Array(0); + let vertexBuffer; + function label(value, x, y, size, color, weight = 400, align = "left") { + text.font = `${weight} ${size}px Barlow`; + text.fillStyle = color; + const measured = text.measureText(value).width; + text.fillText(value, x - (align === "center" ? measured / 2 : align === "right" ? measured : 0), y); + } + + return function render(vertices, score, wave, lives, entities, milliseconds, paused) { + // The MemoryView expires when this synchronous import returns. Copy only the active + // bytes into reusable storage; never retain a view into a movable WASM heap. + const byteLength = vertices.length; + if (byteLength > staging.length) { + const capacity = 2 ** Math.ceil(Math.log2(Math.max(byteLength, 65536))); + staging = new Uint8Array(capacity); + vertexBuffer?.destroy(); + vertexBuffer = device.createBuffer({ size: capacity, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST }); + } + vertices.copyTo(staging); + device.queue.writeBuffer(vertexBuffer, 0, staging, 0, byteLength); + + // Read DPR each frame so moving between monitors and browser zoom need no special listener. + const bounds = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const limit = device.limits.maxTextureDimension2D; + const pixelScale = Math.min(dpr, limit / Math.max(bounds.width, 1), limit / Math.max(bounds.height, 1)); + const w = Math.max(1, Math.floor(bounds.width * pixelScale)); + const h = Math.max(1, Math.floor(bounds.height * pixelScale)); + if (canvas.width !== w || canvas.height !== h) { + canvas.width = overlay.width = w; + canvas.height = overlay.height = h; + } + const scale = Math.min(w / width, h / height); + const vw = Math.max(1, Math.floor(width * scale)); + const vh = Math.max(1, Math.floor(height * scale)); + const x = Math.floor((w - vw) / 2); + const y = Math.floor((h - vh) / 2); + const encoder = device.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [{ + view: context.getCurrentTexture().createView(), + clearValue: { r: 0.018, g: 0.032, b: 0.049, a: 1 }, + loadOp: "clear", storeOp: "store", + }] }); + pass.setPipeline(pipeline); + pass.setViewport(x, y, vw, vh, 0, 1); + pass.setVertexBuffer(0, vertexBuffer); + pass.draw(byteLength / 24); + pass.end(); + device.queue.submit([encoder.finish()]); + + text.resetTransform(); + text.clearRect(0, 0, w, h); + text.setTransform(vw / width, 0, 0, vh / height, x, y); + text.textBaseline = "top"; + label("ARCHEOIDS", 34, 18, 42, amber, 600); + label("ARCHEVOLT / WEBGPU", 36, 70, 15, dim); + label("SCORE", 590, 25, 14, dim); + label(String(score).padStart(6, "0"), 590, 46, 32, white, 600); + label("SECTOR", 830, 25, 14, dim); + label(String(Math.max(wave, 1)).padStart(2, "0"), 830, 46, 32, white, 600); + label("HULL", 1070, 25, 14, dim); + label("A/D TURN W THRUST SPACE FIRE P PAUSE R RESTART", 36, 764, 15, dim); + label(`${entities} ENTITIES / ${milliseconds.toFixed(2)} MS`, 1246, 764, 15, cyan, 400, "right"); + if (paused || lives === 0) { + label(paused ? "PAUSED" : "SIGNAL LOST", 640, 343, 64, paused ? white : amber, 600, "center"); + label(paused ? "PRESS P TO RESUME" : "PRESS R TO LAUNCH AGAIN", 640, 430, 21, dim, 400, "center"); + } else if (wave === 0) { + label("CLEAR THE SECTOR", 640, 245, 30, white, 600, "center"); + label("THRUST. DRIFT. SURVIVE.", 640, 292, 16, dim, 400, "center"); + } + }; +} diff --git a/samples/Archeoids.Browser/wwwroot/style.css b/samples/Archeoids.Browser/wwwroot/style.css new file mode 100644 index 0000000..95fb951 --- /dev/null +++ b/samples/Archeoids.Browser/wwwroot/style.css @@ -0,0 +1,20 @@ +@font-face { + font-family: Barlow; + src: url("./fonts/Barlow-Regular.ttf") format("truetype"); + font-weight: 400; +} +@font-face { + font-family: Barlow; + src: url("./fonts/Barlow-SemiBold.ttf") format("truetype"); + font-weight: 600; +} +:root { color-scheme: dark; background: #05080c; color: #c1d3d6; font-family: Barlow, sans-serif; } +* { box-sizing: border-box; } +body { margin: 0; } +main { position: relative; width: 100%; height: calc(100dvh - 52px); min-height: 240px; } +canvas { position: absolute; width: 100%; height: 100%; display: block; } +#game:focus-visible { outline: 2px solid #66f2e6; outline-offset: -2px; } +#hud { pointer-events: none; } +#status { position: absolute; inset: 0; display: grid; place-content: center; padding: 32px; background: #05080cf5; text-align: center; } +#status[hidden] { display: none; } +#controls { margin: 0; min-height: 52px; padding: 12px 20px; text-align: center; font-size: 15px; } diff --git a/samples/Archeoids/FrameGeometry.cs b/samples/Archeoids/FrameGeometry.cs new file mode 100644 index 0000000..a915743 --- /dev/null +++ b/samples/Archeoids/FrameGeometry.cs @@ -0,0 +1,131 @@ +using System.Numerics; +using System.Runtime.InteropServices; + +namespace Archeoids; + +// Shared by both hosts. Vertices are x, y, r, g, b, a in logical canvas coordinates. +internal sealed class FrameGeometry +{ + public const float CanvasHeight = 800; + private static readonly Vector2 PlayfieldOffset = new(0, 104); + private static readonly Vector4 Dim = new(0.31f, 0.43f, 0.49f, 1); + private readonly List _vertices = new(160_000); + private readonly (Vector2 Position, float Brightness)[] _stars; + public Span Vertices => CollectionsMarshal.AsSpan(_vertices); + + public FrameGeometry() + { + var random = new Random(1982); + _stars = Enumerable.Range(0, 180).Select(_ => + (new Vector2(random.NextSingle() * Arena.Width, random.NextSingle() * CanvasHeight), + random.NextSingle() * 0.5f + 0.15f)).ToArray(); + } + + public void Build(RenderFrame frame, Session state, double seconds, bool paused) + { + _vertices.Clear(); + + for (int x = 0; x <= Arena.Width; x += 80) + Line(new(x, 100), new(x, 744), 0.6f, new(0.16f, 0.27f, 0.32f, 0.13f)); + for (int y = 104; y < 744; y += 80) + Line(new(0, y), new(Arena.Width, y), 0.6f, new(0.16f, 0.27f, 0.32f, 0.13f)); + foreach (var star in _stars) + { + float flicker = 0.8f + 0.2f * MathF.Sin((float)seconds * 0.7f + star.Position.X); + Rect(star.Position.X, star.Position.Y, 1.4f, 1.4f, new(0.6f, 0.8f, 0.9f, star.Brightness * flicker)); + } + + foreach (var sprite in frame.Sprites) + { + DrawSprite(sprite, PlayfieldOffset); + float r = sprite.Appearance.Radius + 4; + // Draw across seams as well as colliding across them. + float dx = sprite.Position.X < r ? Arena.Width : sprite.Position.X > Arena.Width - r ? -Arena.Width : 0; + float dy = sprite.Position.Y < r ? Arena.Height : sprite.Position.Y > Arena.Height - r ? -Arena.Height : 0; + if (dx != 0) DrawSprite(sprite, PlayfieldOffset + new Vector2(dx, 0)); + if (dy != 0) DrawSprite(sprite, PlayfieldOffset + new Vector2(0, dy)); + if (dx != 0 && dy != 0) DrawSprite(sprite, PlayfieldOffset + new Vector2(dx, dy)); + } + + Rect(0, 0, Arena.Width, 101, new(0.025f, 0.045f, 0.062f, 0.97f)); + Rect(0, 744, Arena.Width, 56, new(0.025f, 0.045f, 0.062f, 0.97f)); + Line(new(32, 100), new(1248, 100), 1, new(0.3f, 0.46f, 0.5f, 0.55f)); + Line(new(32, 744), new(1248, 744), 1, new(0.3f, 0.46f, 0.5f, 0.55f)); + + for (int i = 0; i < 3; i++) + DrawSprite(new(new(1082 + i * 45, 65), -MathF.PI / 2, + new(Shape.Ship, 11, 0, i < state.Lives ? Factory.Cyan : Dim), i < state.Lives ? 1 : 0.2f), Vector2.Zero); + + if (paused || state.GameOver) + Rect(0, 101, Arena.Width, 643, new(0.018f, 0.032f, 0.049f, 0.82f)); + } + + private void DrawSprite(Sprite sprite, Vector2 offset) + { + Vector2 p = sprite.Position + offset; + Appearance a = sprite.Appearance; + Vector4 color = a.Color with { W = a.Color.W * sprite.Opacity }; + Vector2 forward = Arena.Direction(sprite.Angle); + Vector2 side = new(-forward.Y, forward.X); + if (a.Shape == Shape.Ship) + { + Vector2 tip = p + forward * a.Radius; + Vector2 left = p - forward * a.Radius * 0.8f + side * a.Radius * 0.72f; + Vector2 right = p - forward * a.Radius * 0.8f - side * a.Radius * 0.72f; + Vector2 tail = p - forward * a.Radius * 0.4f; + Stroke(tip, left, color); Stroke(left, tail, color); + Stroke(tail, right, color); Stroke(right, tip, color); + } + else if (a.Shape == Shape.Rock) + { + uint seed = a.Seed; + Span points = stackalloc Vector2[11]; + for (int i = 0; i < points.Length; i++) + { + seed = seed * 1664525 + 1013904223; + float radius = a.Radius * (0.76f + (seed >> 24) / 255f * 0.24f); + points[i] = p + Arena.Direction(sprite.Angle + i * MathF.Tau / points.Length) * radius; + } + for (int i = 0; i < points.Length; i++) Stroke(points[i], points[(i + 1) % points.Length], color); + } + else if (a.Shape == Shape.Bullet) Stroke(p - forward * a.Radius, p + forward * a.Radius, color, 2); + else + { + Rect(p.X - a.Radius * 2, p.Y - a.Radius * 2, a.Radius * 4, a.Radius * 4, + color with { W = color.W * 0.09f }); + Rect(p.X - a.Radius / 2, p.Y - a.Radius / 2, a.Radius, a.Radius, color); + } + } + + private void Stroke(Vector2 a, Vector2 b, Vector4 color, float width = 1.25f) + { + Line(a, b, width * 4, color with { W = color.W * 0.075f }); + Line(a, b, width, color); + } + + private void Line(Vector2 a, Vector2 b, float width, Vector4 color) + { + Vector2 delta = b - a; + if (delta.LengthSquared() < 0.0001f) return; + Vector2 n = Vector2.Normalize(new Vector2(-delta.Y, delta.X)) * (width / 2); + Triangle(a + n, b + n, b - n, color); + Triangle(a + n, b - n, a - n, color); + } + + public void Rect(float x, float y, float width, float height, Vector4 color) + { + Triangle(new(x, y), new(x + width, y), new(x + width, y + height), color); + Triangle(new(x, y), new(x + width, y + height), new(x, y + height), color); + } + + private void Triangle(Vector2 a, Vector2 b, Vector2 c, Vector4 color) + { + Vertex(a, color); Vertex(b, color); Vertex(c, color); + } + + private void Vertex(Vector2 point, Vector4 color) + { + _vertices.Add(point.X); _vertices.Add(point.Y); + _vertices.Add(color.X); _vertices.Add(color.Y); _vertices.Add(color.Z); _vertices.Add(color.W); + } +} diff --git a/samples/Archeoids/README.md b/samples/Archeoids/README.md index 4424b06..60b7330 100644 --- a/samples/Archeoids/README.md +++ b/samples/Archeoids/README.md @@ -1,10 +1,10 @@ # Archeoids -A small Asteroids game built with Archevolt and [Silk.NET](https://dotnet.github.io/Silk.NET/). Fly through wrapping sectors, split rocks, and survive with three lives. Ships, rocks, bullets, and particles are ECS entities. All shapes and text are generated in code. +A small Asteroids game built with Archevolt. Fly through wrapping sectors, split rocks, and survive with three lives. Ships, rocks, bullets, and particles are ECS entities. The desktop host uses [Silk.NET](https://dotnet.github.io/Silk.NET/) and OpenGL. The browser host uses plain .NET WebAssembly and WebGPU, with browser-rendered text. ![Archeoids gameplay with a cyan ship, drifting asteroids, and a score and lives HUD.](archeoids.png) -## Run +## Run on desktop From the repository root: @@ -25,18 +25,45 @@ Use the .NET SDK pinned in `global.json`. The desktop game needs a graphical ses The HUD shows score, sector, remaining lives, visible entity count, and smoothed simulation time per tick. A new ship flashes during its temporary invulnerability. Clear every rock to start the next sector. +## Run in a browser + +From the repository root, using the SDK selected by `global.json`: + +```powershell +dotnet run --project samples/Archeoids.Browser -c Release +``` + +Open the HTTP localhost URL printed by the development host. Use a browser with WebGPU enabled and a compatible graphics adapter. WebGPU requires HTTPS or localhost. If WebGPU or the adapter is unavailable, the page displays an error. + +The browser project uses `Microsoft.NET.Sdk.WebAssembly` with `browser-wasm`. It explicitly disables the samples' inherited `PublishAot` setting, browser `RunAOTCompilation`, and threads. The SDK restores the managed runtime packs through NuGet. This interpreter configuration does not require a separate workload, JavaScript packages, or a bundler. If you opt into WebAssembly AOT or native runtime relinking, install the workload matching the selected SDK first: + +```powershell +dotnet workload install wasm-tools +``` + +Browser AOT uses `RunAOTCompilation`; desktop `PublishAot` is a different setting. Neither host changes Archevolt's public API. + +## Publish the browser game + +```powershell +dotnet publish samples/Archeoids.Browser -c Release -o artifacts/archeoids-browser +``` + ## Read the sample - [GameModel.cs](GameModel.cs) defines unmanaged components, spawn bundles, resources, messages, and the wrapping arena. - [GameSystems.cs](GameSystems.cs) contains generated queries, systems, and explicit schedule dependencies. Movement uses partitionable `[Each]`; this small sample runs serially. - [Game.cs](Game.cs) owns the world and schedules. `Step` advances one fixed 1/120-second tick. `Capture` extracts a reusable list of sprites after simulation commands flush. - [Program.cs](Program.cs) connects Silk.NET's window and keyboard to the fixed-step loop. -- [VectorRenderer.cs](VectorRenderer.cs) batches procedural shapes and a tiny bitmap font into OpenGL triangles. It reads the extracted frame and never accesses the world. +- [FrameGeometry.cs](FrameGeometry.cs) builds the reusable coloured triangle batch: stars, sprites, translucent effects, HUD panels, hull icons, and pause/game-over shading. It reads the extracted frame and never accesses the world. +- [VectorRenderer.cs](VectorRenderer.cs) uploads that batch to OpenGL and appends the desktop bitmap text. +- [Archeoids.Browser.csproj](../Archeoids.Browser/Archeoids.Browser.csproj) links the same `Game.cs`, `GameModel.cs`, `GameSystems.cs`, and `FrameGeometry.cs` source files. There is one simulation implementation. Silk.NET and desktop verification stay outside the browser dependency graph. +- The browser [Program.cs](../Archeoids.Browser/Program.cs) receives animation frames through `[JSExport]`, runs the fixed-step accumulator, and sends a `Span` through `[JSImport]`. The [page host](../Archeoids.Browser/wwwroot/main.js) owns keyboard and focus events. The [renderer](../Archeoids.Browser/wwwroot/renderer.js) copies the active binary batch into reusable staging storage and uploads it to a reusable vertex buffer. One WGSL pipeline draws the triangles with alpha blending, followed by the Canvas 2D HUD. There is one call into C# and one render call back to JavaScript per frame. - [Verification.cs](Verification.cs) exercises the real simulation and captures the OpenGL framebuffer during a smoke run. Structural changes use `Commands`. A hit marks its rock immediately before queuing despawns, preventing overlapping bullets from destroying it twice. Three independent readers consume `RockDestroyed` to split rocks, award points, and create particles. Adding and removing `Invulnerable` changes whether a ship matches the damage query. Expiration runs before collision detection, and both collisions and drawing handle screen seams. -The simulation uses a seeded random resource and a fixed timestep. Restart replaces the world and its schedules, clearing entities and message cursors. The playfield has its own area below the HUD, and resizing preserves its aspect ratio. Collision detection uses simple bullet-versus-rock loops; this sample is intended for a small arena. +The simulation uses a seeded random resource and a fixed timestep. Both hosts cap incoming elapsed time at 100 ms and advance at 120 ticks per second. Restart replaces the world and its schedules, clearing entities and message cursors. The playfield has its own area below the HUD, and resizing preserves its aspect ratio. Collision detection uses simple bullet-versus-rock loops; this sample is intended for a small arena. ## Verify diff --git a/samples/Archeoids/VectorRenderer.cs b/samples/Archeoids/VectorRenderer.cs index c4dee68..28dc2ab 100644 --- a/samples/Archeoids/VectorRenderer.cs +++ b/samples/Archeoids/VectorRenderer.cs @@ -1,19 +1,15 @@ using System.Numerics; -using System.Runtime.InteropServices; using Silk.NET.OpenGL; namespace Archeoids; internal sealed class VectorRenderer : IDisposable { - private const float CanvasHeight = 800; - private static readonly Vector2 PlayfieldOffset = new(0, 104); private readonly GL _gl; private readonly uint _program; private readonly uint _vao; private readonly uint _vbo; - private readonly List _vertices = new(160_000); - private readonly (Vector2 Position, float Brightness)[] _stars; + private readonly FrameGeometry _geometry = new(); private static readonly Vector4 Dim = new(0.31f, 0.43f, 0.49f, 1); private static readonly Vector4 White = new(0.86f, 0.93f, 0.93f, 1); @@ -54,11 +50,6 @@ void main() { _gl.EnableVertexAttribArray(1); _gl.Enable(EnableCap.Blend); _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); - - var random = new Random(1982); - _stars = Enumerable.Range(0, 180).Select(_ => - (new Vector2(random.NextSingle() * Arena.Width, random.NextSingle() * CanvasHeight), - random.NextSingle() * 0.5f + 0.15f)).ToArray(); } public void Draw(RenderFrame frame, Session state, double milliseconds, double seconds, bool paused, int width, int height) @@ -67,38 +58,11 @@ public void Draw(RenderFrame frame, Session state, double milliseconds, double s _gl.ClearColor(0.018f, 0.032f, 0.049f, 1); _gl.Clear(ClearBufferMask.ColorBufferBit); if (width <= 0 || height <= 0) return; - float scale = Math.Min(width / Arena.Width, height / CanvasHeight); + float scale = Math.Min(width / Arena.Width, height / FrameGeometry.CanvasHeight); int viewportWidth = (int)(Arena.Width * scale); - int viewportHeight = (int)(CanvasHeight * scale); + int viewportHeight = (int)(FrameGeometry.CanvasHeight * scale); _gl.Viewport((width - viewportWidth) / 2, (height - viewportHeight) / 2, (uint)viewportWidth, (uint)viewportHeight); - _vertices.Clear(); - - for (int x = 0; x <= Arena.Width; x += 80) - Line(new(x, 100), new(x, 744), 0.6f, new(0.16f, 0.27f, 0.32f, 0.13f)); - for (int y = 104; y < 744; y += 80) - Line(new(0, y), new(Arena.Width, y), 0.6f, new(0.16f, 0.27f, 0.32f, 0.13f)); - foreach (var star in _stars) - { - float flicker = 0.8f + 0.2f * MathF.Sin((float)seconds * 0.7f + star.Position.X); - Rect(star.Position.X, star.Position.Y, 1.4f, 1.4f, new(0.6f, 0.8f, 0.9f, star.Brightness * flicker)); - } - - foreach (var sprite in frame.Sprites) - { - DrawSprite(sprite, PlayfieldOffset); - float r = sprite.Appearance.Radius + 4; - // Draw across seams as well as colliding across them. - float dx = sprite.Position.X < r ? Arena.Width : sprite.Position.X > Arena.Width - r ? -Arena.Width : 0; - float dy = sprite.Position.Y < r ? Arena.Height : sprite.Position.Y > Arena.Height - r ? -Arena.Height : 0; - if (dx != 0) DrawSprite(sprite, PlayfieldOffset + new Vector2(dx, 0)); - if (dy != 0) DrawSprite(sprite, PlayfieldOffset + new Vector2(0, dy)); - if (dx != 0 && dy != 0) DrawSprite(sprite, PlayfieldOffset + new Vector2(dx, dy)); - } - - Rect(0, 0, Arena.Width, 101, new(0.025f, 0.045f, 0.062f, 0.97f)); - Rect(0, 744, Arena.Width, 56, new(0.025f, 0.045f, 0.062f, 0.97f)); - Line(new(32, 100), new(1248, 100), 1, new(0.3f, 0.46f, 0.5f, 0.55f)); - Line(new(32, 744), new(1248, 744), 1, new(0.3f, 0.46f, 0.5f, 0.55f)); + _geometry.Build(frame, state, seconds, paused); Text("ARCHEOIDS", 34, 24, 4.8f, Factory.Amber); Text("ARCHEVOLT / SILK.NET", 36, 70, 1.5f, Dim); @@ -107,17 +71,12 @@ public void Draw(RenderFrame frame, Session state, double milliseconds, double s Text("SECTOR", 830, 27, 1.4f, Dim); Text(Math.Max(state.Wave, 1).ToString("D2"), 830, 49, 3.4f, White); Text("HULL", 1070, 27, 1.4f, Dim); - for (int i = 0; i < 3; i++) - DrawSprite(new(new(1082 + i * 45, 65), -MathF.PI / 2, - new(Shape.Ship, 11, 0, i < state.Lives ? Factory.Cyan : Dim), i < state.Lives ? 1 : 0.2f), Vector2.Zero); - Text("A/D TURN W THRUST SPACE FIRE P PAUSE R RESTART", 36, 766, 1.3f, Dim); string stats = $"{frame.Sprites.Count} ENTITIES / {milliseconds:F2} MS"; Text(stats, 1246 - TextWidth(stats, 1.3f), 766, 1.3f, Factory.Cyan * new Vector4(1, 1, 1, 0.8f)); if (paused || state.GameOver) { - Rect(0, 101, Arena.Width, 643, new(0.018f, 0.032f, 0.049f, 0.82f)); string title = paused ? "PAUSED" : "SIGNAL LOST"; CenterText(title, 350, 7, paused ? White : Factory.Amber); CenterText(paused ? "PRESS P TO RESUME" : "PRESS R TO LAUNCH AGAIN", 430, 2, Dim); @@ -130,83 +89,14 @@ public void Draw(RenderFrame frame, Session state, double milliseconds, double s Flush(); } - private void DrawSprite(Sprite sprite, Vector2 offset) - { - Vector2 p = sprite.Position + offset; - Appearance a = sprite.Appearance; - Vector4 color = a.Color with { W = a.Color.W * sprite.Opacity }; - Vector2 forward = Arena.Direction(sprite.Angle); - Vector2 side = new(-forward.Y, forward.X); - if (a.Shape == Shape.Ship) - { - Vector2 tip = p + forward * a.Radius; - Vector2 left = p - forward * a.Radius * 0.8f + side * a.Radius * 0.72f; - Vector2 right = p - forward * a.Radius * 0.8f - side * a.Radius * 0.72f; - Vector2 tail = p - forward * a.Radius * 0.4f; - Stroke(tip, left, color); Stroke(left, tail, color); - Stroke(tail, right, color); Stroke(right, tip, color); - } - else if (a.Shape == Shape.Rock) - { - uint seed = a.Seed; - Span points = stackalloc Vector2[11]; - for (int i = 0; i < points.Length; i++) - { - seed = seed * 1664525 + 1013904223; - float radius = a.Radius * (0.76f + (seed >> 24) / 255f * 0.24f); - points[i] = p + Arena.Direction(sprite.Angle + i * MathF.Tau / points.Length) * radius; - } - for (int i = 0; i < points.Length; i++) Stroke(points[i], points[(i + 1) % points.Length], color); - } - else if (a.Shape == Shape.Bullet) Stroke(p - forward * a.Radius, p + forward * a.Radius, color, 2); - else - { - Rect(p.X - a.Radius * 2, p.Y - a.Radius * 2, a.Radius * 4, a.Radius * 4, - color with { W = color.W * 0.09f }); - Rect(p.X - a.Radius / 2, p.Y - a.Radius / 2, a.Radius, a.Radius, color); - } - } - - private void Stroke(Vector2 a, Vector2 b, Vector4 color, float width = 1.25f) - { - Line(a, b, width * 4, color with { W = color.W * 0.075f }); - Line(a, b, width, color); - } - - private void Line(Vector2 a, Vector2 b, float width, Vector4 color) - { - Vector2 delta = b - a; - if (delta.LengthSquared() < 0.0001f) return; - Vector2 n = Vector2.Normalize(new Vector2(-delta.Y, delta.X)) * (width / 2); - Triangle(a + n, b + n, b - n, color); - Triangle(a + n, b - n, a - n, color); - } - - private void Rect(float x, float y, float width, float height, Vector4 color) - { - Triangle(new(x, y), new(x + width, y), new(x + width, y + height), color); - Triangle(new(x, y), new(x + width, y + height), new(x, y + height), color); - } - - private void Triangle(Vector2 a, Vector2 b, Vector2 c, Vector4 color) - { - Vertex(a, color); Vertex(b, color); Vertex(c, color); - } - - private void Vertex(Vector2 point, Vector4 color) - { - _vertices.Add(point.X); _vertices.Add(point.Y); - _vertices.Add(color.X); _vertices.Add(color.Y); _vertices.Add(color.Z); _vertices.Add(color.W); - } - private unsafe void Flush() { _gl.UseProgram(_program); _gl.BindVertexArray(_vao); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); - ReadOnlySpan vertices = CollectionsMarshal.AsSpan(_vertices); + ReadOnlySpan vertices = _geometry.Vertices; _gl.BufferData(BufferTargetARB.ArrayBuffer, vertices, BufferUsageARB.StreamDraw); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)(_vertices.Count / 6)); + _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)(vertices.Length / 6)); } private uint Compile(ShaderType type, string source) @@ -231,7 +121,7 @@ private void Text(string text, float x, float y, float size, Vector4 color) for (int row = 0; row < rows.Length; row++) for (int col = 0; col < 5; col++) if ((rows[row] & (1 << (4 - col))) != 0) - Rect(x + col * size, y + row * size, size * 0.85f, size * 0.85f, color); + _geometry.Rect(x + col * size, y + row * size, size * 0.85f, size * 0.85f, color); x += size * 6; } }