From ae26e0ecaf47cd81942ac8cc3f8676f4531f208c Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:12:53 +0930 Subject: [PATCH 01/35] Migrate six legacy gameplay renderers to native Three.js scenes --- .github/workflows/inertia-zero.yml | 2 +- .github/workflows/native-renderers.yml | 29 ++ .gitignore | 2 + BLACKSTAR_AGA/README.md | 14 +- BLACKSTAR_AGA/app.js | 375 +--------------- BLACKSTAR_AGA/docs/DESIGN.md | 3 +- BLACKSTAR_AGA/docs/DETERMINISM.md | 2 +- BLACKSTAR_AGA/index.html | 6 +- BLACKSTAR_AGA/renderer.js | 444 +++++++++++++++++++ BLACKSTAR_AGA/tests/browser-smoke.js | 8 +- BLACKSTAR_AGA/tests/offline.test.js | 8 +- CARTRIDGE_ZERO/README.md | 14 +- CARTRIDGE_ZERO/app.js | 367 +--------------- CARTRIDGE_ZERO/docs/DETERMINISM.md | 2 +- CARTRIDGE_ZERO/index.html | 6 +- CARTRIDGE_ZERO/renderer.js | 420 ++++++++++++++++++ CARTRIDGE_ZERO/tests/browser-smoke.js | 16 +- PIXELWARFRONT/README.md | 9 +- PIXELWARFRONT/app.js | 367 +--------------- PIXELWARFRONT/docs/DETERMINISM.md | 2 +- PIXELWARFRONT/index.html | 6 +- PIXELWARFRONT/renderer.js | 353 +++++++++++++++ PIXELWARFRONT/tests/browser-smoke.js | 15 +- PIXELWARFRONT/tests/offline.test.js | 8 +- README.md | 33 +- SEEDSTORM/README.md | 10 +- SEEDSTORM/app.js | 454 +------------------- SEEDSTORM/docs/DESIGN.md | 2 +- SEEDSTORM/docs/DETERMINISM.md | 2 +- SEEDSTORM/index.html | 6 +- SEEDSTORM/renderer.js | 373 ++++++++++++++++ SEEDSTORM/tests/browser-smoke.js | 8 +- SEEDSTORM/tests/offline.test.js | 9 +- SUBSPACE/README.md | 18 +- SUBSPACE/app.js | 570 +------------------------ SUBSPACE/index.html | 8 +- SUBSPACE/renderer.js | 380 +++++++++++++++++ SUBSPACE/tests/browser-smoke.js | 6 +- SUBSPACE/tests/runtime-smoke.js | 43 +- VECTOR_ZERO/README.md | 14 +- VECTOR_ZERO/app.js | 351 +-------------- VECTOR_ZERO/docs/DETERMINISM.md | 2 +- VECTOR_ZERO/index.html | 6 +- VECTOR_ZERO/renderer.js | 275 ++++++++++++ VECTOR_ZERO/tests/browser-smoke.js | 8 +- VECTOR_ZERO/tests/offline.test.js | 7 +- docs/NATIVE_RENDERING.md | 120 ++++++ shared/qsol-native.css | 47 ++ shared/qsol-native.js | 378 ++++++++++++++++ shared/qsol-three-stage.js | 376 ---------------- shared/qsol-three.css | 24 -- tests/native-renderers.browser.js | 318 ++++++++++++++ tests/native-renderers.static.js | 49 +++ 53 files changed, 3362 insertions(+), 3013 deletions(-) create mode 100644 .github/workflows/native-renderers.yml create mode 100644 BLACKSTAR_AGA/renderer.js create mode 100644 CARTRIDGE_ZERO/renderer.js create mode 100644 PIXELWARFRONT/renderer.js create mode 100644 SEEDSTORM/renderer.js create mode 100644 SUBSPACE/renderer.js create mode 100644 VECTOR_ZERO/renderer.js create mode 100644 docs/NATIVE_RENDERING.md create mode 100644 shared/qsol-native.css create mode 100644 shared/qsol-native.js delete mode 100644 shared/qsol-three-stage.js delete mode 100644 shared/qsol-three.css create mode 100644 tests/native-renderers.browser.js create mode 100644 tests/native-renderers.static.js diff --git a/.github/workflows/inertia-zero.yml b/.github/workflows/inertia-zero.yml index 9c2a2ef..3dc8d89 100644 --- a/.github/workflows/inertia-zero.yml +++ b/.github/workflows/inertia-zero.yml @@ -33,4 +33,4 @@ jobs: run: | node SUBSPACE/tests/core.test.js node SUBSPACE/tests/runtime-smoke.js - node --check shared/qsol-three-stage.js + node --check shared/qsol-native.js diff --git a/.github/workflows/native-renderers.yml b/.github/workflows/native-renderers.yml new file mode 100644 index 0000000..65e37fc --- /dev/null +++ b/.github/workflows/native-renderers.yml @@ -0,0 +1,29 @@ +name: Native Three.js gameplay +on: + push: + paths: ['shared/**', 'tests/**', '**/renderer.js', '**/app.js', '**/index.html', '.github/workflows/native-renderers.yml'] + pull_request: + paths: ['shared/**', 'tests/**', '**/renderer.js', '**/app.js', '**/index.html', '.github/workflows/native-renderers.yml'] +permissions: + contents: read +jobs: + native-webgl: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: npm --prefix BLACKSTAR_AGA ci + - run: npx --prefix BLACKSTAR_AGA playwright install --with-deps chromium + - run: node tests/native-renderers.static.js + - name: Real WebGL scene, framebuffer, resize, motion, context and lifecycle checks + env: + NODE_PATH: ./BLACKSTAR_AGA/node_modules + run: node tests/native-renderers.browser.js + - uses: actions/upload-artifact@v4 + if: always() + with: + name: native-gameplay-screenshots + path: test-results/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 0e0ac9c..cadbb59 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ **/node_modules/ TERNARYDRIFT/build/ SIGNAL_BREACH/rust/target/ +test-results/ +*/tests/*.png diff --git a/BLACKSTAR_AGA/README.md b/BLACKSTAR_AGA/README.md index 32c6242..2a6b510 100644 --- a/BLACKSTAR_AGA/README.md +++ b/BLACKSTAR_AGA/README.md @@ -4,7 +4,7 @@ micro-release, rebuilt as an original deterministic offline browser FPS.** BLACKSTAR AGA is a compact grid-and-sector-flavoured first-person shooter with a -320×200 software framebuffer, chunky procedural pixel art, four-voice-inspired +native Three.js rooms, faceted enemies and a chunky AGA-inspired palette, four-voice-inspired layered Web Audio effects, four linked missions, and replay receipts that reproduce the fixed-tick simulation from its campaign seed and input ledger. @@ -70,15 +70,15 @@ tick. The telemetry panel publishes the current canonical state digest. See ## Architecture - `core.js` — pure deterministic simulation, campaign parsing, combat, replay and digest. -- `app.js` — fixed-step scheduler, raycaster, differentiated enemy/weapon sprites, - layered procedural sound effects, UI, and input. -- `../shared/qsol-three-stage.js` — optional local Three.js depth, particles, and - impact pulses behind the authoritative 320×200 software view. +- `app.js` — fixed-step scheduler, procedural sound effects, UI, and input. +- `renderer.js` — perspective rooms, collision-grid doors, faceted enemies, pickups, + exit console and camera-relative weapon geometry. +- `../shared/qsol-native.js` — the sole gameplay WebGL renderer and GPU lifecycle. - `style.css` — responsive recovered-hardware shell with no fetched fonts or images. - `index.html` — local-file-compatible application and strict offline CSP. - `tests/` — Node determinism/offline tests and a Playwright end-to-end smoke test. -The raycaster and all art/audio are original. The only runtime library is the +The scene geometry and all art/audio are original. The only runtime library is the repository's pinned local Three.js distribution; no network or third-party game assets, ROMs, WADs, maps, textures, sprites, or samples are used. See [`NOTICE.md`](NOTICE.md). @@ -101,3 +101,5 @@ the game runtime. Playable vertical slice / complete four-mission mini-campaign. Version 2 replay receipts are intentionally versioned rather than promised compatible with future simulation revisions. + +Native renderer architecture and verification: [migration audit](../docs/NATIVE_RENDERING.md). diff --git a/BLACKSTAR_AGA/app.js b/BLACKSTAR_AGA/app.js index a7148f6..09c6a67 100644 --- a/BLACKSTAR_AGA/app.js +++ b/BLACKSTAR_AGA/app.js @@ -3,21 +3,13 @@ const core = window.BlackstarCore; const canvas = document.querySelector("#game"); - const ctx = canvas.getContext("2d", { alpha: false }); - const threeStage = window.QsolThree - ? window.QsolThree.create({ host: document.querySelector("#viewport"), source: canvas, preset: "blackstar" }) - : { render() {}, pulse() {} }; + const presentation = new window.BlackstarRenderer(canvas, core); const WIDTH = canvas.width; const HEIGHT = canvas.height; - const VIEW_HEIGHT = 160; - const HUD_TOP = 160; const TICK_MS = 1000 / core.TICK_RATE; const MAX_FRAME_STEPS = 8; const FOV = 11264; - const RAY_STEP = 38; - const MAX_RAY_DISTANCE = 24 * core.FP; const TAU = Math.PI * 2; - const zBuffer = new Int32Array(WIDTH); const bootLayer = document.querySelector("#bootLayer"); const startButton = document.querySelector("#startButton"); @@ -301,7 +293,6 @@ if (event.type === "shot") { flash = Math.max(flash, event.weapon === 1 ? 7 : 4); shake = Math.max(shake, event.weapon === 1 ? 3 : 1); - threeStage.pulse("shot", event.weapon === 1 ? 0.7 : 0.35); } else if (event.type === "enemy-hit") { notice = `${core.ENEMY_TYPES[event.kind].name} // ${event.health}`; noticeTicks = 30; @@ -309,13 +300,11 @@ notice = `${core.ENEMY_TYPES[event.kind].name} ERASED`; noticeTicks = 55; shake = Math.max(shake, event.kind === "warden" ? 8 : 4); - threeStage.pulse("blast", event.kind === "warden" ? 1 : 0.65); } else if (event.type === "player-hit") { flash = Math.max(flash, 12); shake = Math.max(shake, 6); notice = `SUIT BREACH // -${event.amount}`; noticeTicks = 45; - threeStage.pulse("impact", 0.85); } else if (event.type === "pickup") { notice = `RECOVERED // ${event.kind.toUpperCase()}`; noticeTicks = 70; @@ -383,79 +372,6 @@ if (noticeTicks > 0) noticeTicks -= 1; } - function raycast(stateObject, angle) { - const p = stateObject.player; - const dirX = core.cosAngle(angle); - const dirY = core.sinAngle(angle); - let previousTileX = Math.floor(p.x / core.FP); - let previousTileY = Math.floor(p.y / core.FP); - for (let distance = 40; distance < MAX_RAY_DISTANCE; distance += RAY_STEP) { - const worldX = p.x + Math.trunc(dirX * distance / core.TRIG_SCALE); - const worldY = p.y + Math.trunc(dirY * distance / core.TRIG_SCALE); - const tileX = Math.floor(worldX / core.FP); - const tileY = Math.floor(worldY / core.FP); - if (core.isBlockingCell(stateObject, tileX, tileY)) { - const cell = core.cellAt(stateObject, tileX, tileY); - const localX = ((worldX % core.FP) + core.FP) % core.FP; - const localY = ((worldY % core.FP) + core.FP) % core.FP; - const vertical = tileX !== previousTileX; - const texture = vertical ? localY : localX; - return { distance, cell, texture, vertical, tileX, tileY }; - } - previousTileX = tileX; - previousTileY = tileY; - } - return { distance: MAX_RAY_DISTANCE, cell: "#", texture: 0, vertical: false, tileX: -1, tileY: -1 }; - } - - function wallColor(hit, palette, brightness, stripe) { - let index = 0; - if (hit.cell === "P") index = 1; - else if (hit.cell === "B") index = 3; - else if (hit.cell === "D" || hit.cell === "K") index = 2; - else if (hit.cell === "S") index = 1; - const base = palette.wall[index]; - const textureBand = ((hit.texture >> 7) + stripe) & 7; - const textureFactor = textureBand === 0 || textureBand === 7 ? 1.18 : textureBand & 1 ? 0.86 : 1; - return shade(base, brightness * textureFactor * (hit.vertical ? 1 : 0.78)); - } - - function drawWorld() { - const palette = PALETTES[state.blueprint.palette] || PALETTES.copper; - const horizon = 78 + Math.floor(Math.sin(state.player.bob / 64 * TAU) * 2); - ctx.fillStyle = palette.sky; - ctx.fillRect(0, 0, WIDTH, horizon); - for (let y = 0; y < horizon; y += 4) { - ctx.fillStyle = y & 8 ? "rgba(255,255,255,0.015)" : "rgba(0,0,0,0.02)"; - ctx.fillRect(0, y, WIDTH, 2); - } - ctx.fillStyle = palette.floor; - ctx.fillRect(0, horizon, WIDTH, VIEW_HEIGHT - horizon); - for (let y = horizon + 2; y < VIEW_HEIGHT; y += 4) { - const factor = (y - horizon) / Math.max(1, VIEW_HEIGHT - horizon); - ctx.fillStyle = `rgba(0,0,0,${0.08 + factor * 0.35})`; - ctx.fillRect(0, y, WIDTH, 2); - } - - for (let column = 0; column < WIDTH; column += 2) { - const rayAngle = state.player.angle - Math.floor(FOV / 2) + Math.floor(column * FOV / WIDTH); - const hit = raycast(state, rayAngle); - const correction = Math.max(1, core.cosAngle(rayAngle - state.player.angle)); - const corrected = Math.max(40, Math.trunc(hit.distance * correction / core.TRIG_SCALE)); - const wallHeight = clamp(Math.floor(102 * core.FP / corrected), 1, VIEW_HEIGHT * 2); - const top = Math.floor(horizon - wallHeight / 2); - const brightness = clamp(1.2 - corrected / (12 * core.FP), 0.26, 1.1); - ctx.fillStyle = wallColor(hit, palette, brightness, column >> 1); - ctx.fillRect(column, top, 2, wallHeight); - if (hit.cell === "D" || hit.cell === "K") { - ctx.fillStyle = shade(palette.accent, brightness * 0.65); - if (((hit.texture >> 6) & 3) === 0) ctx.fillRect(column, top, 2, wallHeight); - } - zBuffer[column] = corrected; - zBuffer[column + 1] = corrected; - } - } - function projectEntity(entity) { const dx = entity.x - state.player.x; const dy = entity.y - state.player.y; @@ -474,293 +390,8 @@ return { screenX, distance, depth, size }; } - function pixelRect(x, y, w, h, color) { - ctx.fillStyle = color; - ctx.fillRect(Math.floor(x), Math.floor(y), Math.ceil(w), Math.ceil(h)); - } - - function drawEnemySprite(enemy, projection) { - const { screenX, depth, size } = projection; - const left = Math.floor(screenX - size / 2); - const top = Math.floor(80 - size * 0.62 + Math.sin((state.tick + enemy.phase) * 0.08) * 1.2); - const centerColumn = clamp(Math.floor(screenX), 0, WIDTH - 1); - if (depth > zBuffer[centerColumn] + 140) return; - const type = enemy.kind; - const pain = enemy.pain > 0; - const base = pain ? "#f4d8a1" : type === "warden" ? "#893c38" : type === "bulwark" ? "#80604b" : type === "specter" ? "#567e78" : type === "drone" ? "#5b978c" : type === "trooper" ? "#9b6542" : "#677c6e"; - const dark = pain ? "#9d443b" : "#1a2522"; - const eye = type === "warden" ? "#ffd269" : "#dd6a4c"; - pixelRect(left + size * 0.28, top + size * 0.08, size * 0.44, size * 0.26, dark); - pixelRect(left + size * 0.2, top + size * 0.25, size * 0.6, size * 0.48, base); - pixelRect(left + size * 0.1, top + size * 0.32, size * 0.18, size * 0.34, dark); - pixelRect(left + size * 0.72, top + size * 0.32, size * 0.18, size * 0.34, dark); - pixelRect(left + size * 0.26, top + size * 0.72, size * 0.18, size * 0.25, dark); - pixelRect(left + size * 0.56, top + size * 0.72, size * 0.18, size * 0.25, dark); - pixelRect(left + size * 0.36, top + size * 0.15, size * 0.08, size * 0.06, eye); - pixelRect(left + size * 0.56, top + size * 0.15, size * 0.08, size * 0.06, eye); - if (type === "drone") { - pixelRect(left + size * 0.03, top + size * 0.38, size * 0.94, size * 0.1, base); - pixelRect(left + size * 0.43, top + size * 0.1, size * 0.14, size * 0.7, dark); - } - if (type === "specter") { - pixelRect(left + size * 0.12, top + size * 0.2, size * 0.76, size * 0.1, "#2c3d3b"); - pixelRect(left + size * 0.2, top + size * 0.62, size * 0.18, size * 0.28, dark); - pixelRect(left + size * 0.62, top + size * 0.62, size * 0.18, size * 0.28, dark); - pixelRect(left + size * 0.72, top + size * 0.06, size * 0.08, size * 0.48, "#a6c8b4"); - } - if (type === "bulwark") { - pixelRect(left + size * 0.02, top + size * 0.2, size * 0.28, size * 0.62, "#49372e"); - pixelRect(left + size * 0.7, top + size * 0.2, size * 0.28, size * 0.62, "#49372e"); - pixelRect(left + size * 0.18, top + size * 0.4, size * 0.64, size * 0.16, "#bc8352"); - } - if (type === "warden") { - pixelRect(left + size * 0.06, top + size * 0.2, size * 0.18, size * 0.6, "#512b2c"); - pixelRect(left + size * 0.76, top + size * 0.2, size * 0.18, size * 0.6, "#512b2c"); - const healthWidth = Math.max(1, Math.floor(size * enemy.health / enemy.maxHealth)); - pixelRect(left, top - 4, size, 2, "#301313"); - pixelRect(left, top - 4, healthWidth, 2, "#e09a57"); - } - if (enemy.muzzle > 0) { - const flare = Math.max(2, size * 0.16); - pixelRect(left + size * 0.5 - flare / 2, top + size * 0.08, flare, flare * 0.45, "#ffd477"); - } - } - - function drawPickupSprite(pickup, projection) { - const { screenX, depth, size } = projection; - const actualSize = Math.max(3, size * 0.42); - const x = screenX - actualSize / 2; - const y = 80 + size * 0.2 + Math.sin((state.tick + pickup.phase) * 0.09) * 2; - const centerColumn = clamp(Math.floor(screenX), 0, WIDTH - 1); - if (depth > zBuffer[centerColumn] + 120) return; - const colors = { - medkit: "#d7d1ad", armor: "#4f9b8d", cells: "#dd9b4f", shells: "#b05942", - breach: "#8d5b3c", vulcan: "#7b897d", key: "#f0c764", archive: "#d7be83", - }; - const color = colors[pickup.kind] || "#d7d1ad"; - pixelRect(x, y, actualSize, actualSize * 0.65, "#111817"); - pixelRect(x + actualSize * 0.15, y + actualSize * 0.12, actualSize * 0.7, actualSize * 0.42, color); - if (pickup.kind === "medkit") { - pixelRect(x + actualSize * 0.42, y + actualSize * 0.17, actualSize * 0.16, actualSize * 0.32, "#9c4037"); - pixelRect(x + actualSize * 0.32, y + actualSize * 0.27, actualSize * 0.36, actualSize * 0.12, "#9c4037"); - } - } - - function drawExitSprite(exit, projection) { - const { screenX, depth, size } = projection; - const actualSize = Math.max(7, size * 0.68); - const left = screenX - actualSize / 2; - const top = 80 - actualSize * 0.38; - const centerColumn = clamp(Math.floor(screenX), 0, WIDTH - 1); - if (depth > zBuffer[centerColumn] + 120) return; - const sealed = Boolean(core.missionObjectiveDenial(state)); - pixelRect(left, top, actualSize, actualSize * 0.86, "#111817"); - pixelRect(left + actualSize * 0.14, top + actualSize * 0.1, actualSize * 0.72, actualSize * 0.58, sealed ? "#71372f" : "#4f8f7f"); - pixelRect(left + actualSize * 0.25, top + actualSize * 0.19, actualSize * 0.5, actualSize * 0.23, sealed ? "#c06145" : "#afe2bd"); - pixelRect(left + actualSize * 0.38, top + actualSize * 0.71, actualSize * 0.24, actualSize * 0.1, "#c68c51"); - } - - function drawEntities() { - const entries = []; - for (const enemy of state.enemies) { - const projection = projectEntity(enemy); - if (projection) entries.push({ kind: "enemy", entity: enemy, projection }); - } - for (const pickup of state.pickups) { - const projection = projectEntity(pickup); - if (projection) entries.push({ kind: "pickup", entity: pickup, projection }); - } - const exit = { - x: state.blueprint.exit.x * core.FP + core.FP / 2, - y: state.blueprint.exit.y * core.FP + core.FP / 2, - }; - const exitProjection = projectEntity(exit); - if (exitProjection) entries.push({ kind: "exit", entity: exit, projection: exitProjection }); - entries.sort((a, b) => b.projection.depth - a.projection.depth); - for (const entry of entries) { - if (entry.kind === "enemy") drawEnemySprite(entry.entity, entry.projection); - else if (entry.kind === "pickup") drawPickupSprite(entry.entity, entry.projection); - else drawExitSprite(entry.entity, entry.projection); - } - } - - function drawWeapon() { - const p = state.player; - const bobX = Math.sin(p.bob / 64 * TAU) * 3; - const bobY = Math.abs(Math.cos(p.bob / 64 * TAU)) * 2; - const recoil = Math.max(0, p.cooldown > core.WEAPONS[p.weapon].gap - 6 ? 5 : 0); - const center = WIDTH / 2 + bobX; - const bottom = VIEW_HEIGHT + bobY + recoil; - const metal = p.weapon === 2 ? "#586962" : p.weapon === 1 ? "#74523a" : "#455750"; - pixelRect(center - 30, bottom - 21, 60, 22, "#121817"); - pixelRect(center - 20, bottom - 35, 40, 22, metal); - pixelRect(center - 9, bottom - 45, 18, 24, "#202b28"); - pixelRect(center - 5, bottom - 46, 10, 12, "#0a0d0c"); - if (p.weapon === 1) { - pixelRect(center - 21, bottom - 43, 17, 18, "#3b2d25"); - pixelRect(center + 4, bottom - 43, 17, 18, "#3b2d25"); - } else if (p.weapon === 2) { - const spin = state.tick & 3; - pixelRect(center - 15 + spin, bottom - 49, 6, 20, "#273631"); - pixelRect(center - 3, bottom - 52 + spin, 6, 23, "#273631"); - pixelRect(center + 9 - spin, bottom - 49, 6, 20, "#273631"); - pixelRect(center - 25, bottom - 30, 8, 11, "#9a7147"); - } else { - pixelRect(center - 13, bottom - 40, 7, 12, "#8ca493"); - pixelRect(center + 6, bottom - 40, 7, 12, "#8ca493"); - pixelRect(center - 3, bottom - 54, 6, 14, "#c99855"); - } - if (state.tick - state.lastShotTick < 3) { - pixelRect(center - 8, bottom - 57, 16, 10, "#f5d27a"); - pixelRect(center - 3, bottom - 65, 6, 9, "#fff1ad"); - } - } - - function drawHud() { - const p = state.player; - const weapon = core.WEAPONS[p.weapon]; - ctx.fillStyle = "#101614"; - ctx.fillRect(0, HUD_TOP, WIDTH, HEIGHT - HUD_TOP); - ctx.fillStyle = "#303a34"; - ctx.fillRect(0, HUD_TOP, WIDTH, 2); - ctx.fillStyle = "#0a0e0d"; - ctx.fillRect(4, 165, 68, 30); - ctx.fillRect(77, 165, 55, 30); - ctx.fillRect(137, 165, 80, 30); - ctx.fillRect(222, 165, 94, 30); - ctx.font = "7px monospace"; - ctx.textBaseline = "top"; - ctx.fillStyle = "#b8b89c"; - ctx.fillText("VITAL", 8, 168); - ctx.fillText("ARMOR", 81, 168); - ctx.fillText("WEAPON", 141, 168); - ctx.fillText("SCORE", 226, 168); - ctx.font = "bold 13px monospace"; - ctx.fillStyle = p.health < 30 ? "#d75d48" : "#e8dcae"; - ctx.fillText(String(p.health).padStart(3, "0"), 8, 178); - ctx.fillStyle = "#77a99a"; - ctx.fillText(String(p.armor).padStart(3, "0"), 81, 178); - ctx.fillStyle = "#e3a25c"; - const ammo = weapon.ammo ? p[weapon.ammo] : "∞"; - ctx.fillText(`${p.weapon + 1}:${String(ammo).padStart(3, "0")}`, 141, 178); - ctx.fillStyle = "#ddd7b7"; - ctx.fillText(String(state.score).padStart(7, "0"), 226, 178); - ctx.font = "6px monospace"; - ctx.fillStyle = "#78847b"; - ctx.fillText(`${state.blueprint.code} // ${weapon.name}`, 5, 197 - 7); - ctx.textAlign = "right"; - ctx.fillText(`K:${p.keys} A:${state.stats.archives}`, 315, 190); - ctx.textAlign = "left"; - } - - function drawCrosshair() { - ctx.fillStyle = "rgba(230,220,180,0.72)"; - ctx.fillRect(WIDTH / 2 - 6, 79, 4, 1); - ctx.fillRect(WIDTH / 2 + 3, 79, 4, 1); - ctx.fillRect(WIDTH / 2, 74, 1, 4); - ctx.fillRect(WIDTH / 2, 81, 1, 4); - } - - function drawAutomap() { - const scale = 6; - const mapWidth = state.blueprint.width * scale; - const mapHeight = state.blueprint.height * scale; - const left = Math.floor((WIDTH - mapWidth) / 2); - const top = Math.floor((VIEW_HEIGHT - mapHeight) / 2); - ctx.fillStyle = "rgba(4,7,6,0.92)"; - ctx.fillRect(left - 6, top - 6, mapWidth + 12, mapHeight + 12); - for (let y = 0; y < state.blueprint.height; y += 1) { - for (let x = 0; x < state.blueprint.width; x += 1) { - const cell = core.cellAt(state, x, y); - if ("#PB".includes(cell)) ctx.fillStyle = "#37443e"; - else if (cell === "D" || cell === "K") ctx.fillStyle = "#b57243"; - else if (cell === "S") ctx.fillStyle = "#776242"; - else ctx.fillStyle = "#111815"; - ctx.fillRect(left + x * scale, top + y * scale, scale - 1, scale - 1); - } - } - ctx.fillStyle = "#d45b48"; - for (const enemy of state.enemies) ctx.fillRect(left + enemy.x / core.FP * scale - 1, top + enemy.y / core.FP * scale - 1, 3, 3); - ctx.fillStyle = "#65b29c"; - ctx.fillRect(left + state.blueprint.exit.x * scale + 1, top + state.blueprint.exit.y * scale + 1, 3, 3); - const px = left + state.player.x / core.FP * scale; - const py = top + state.player.y / core.FP * scale; - ctx.fillStyle = "#e8d27d"; - ctx.fillRect(px - 2, py - 2, 4, 4); - ctx.strokeStyle = "#e8d27d"; - ctx.beginPath(); - ctx.moveTo(px, py); - ctx.lineTo(px + core.cosAngle(state.player.angle) / core.TRIG_SCALE * 7, py + core.sinAngle(state.player.angle) / core.TRIG_SCALE * 7); - ctx.stroke(); - ctx.font = "7px monospace"; - ctx.fillStyle = "#d6d1af"; - ctx.fillText("TACTICAL GRID // HOLD TAB", left, top - 11); - } - - function drawNotice() { - if (noticeTicks <= 0 || !notice) return; - ctx.font = "bold 7px monospace"; - const width = ctx.measureText(notice).width + 12; - ctx.fillStyle = "rgba(5,8,7,0.76)"; - ctx.fillRect(Math.floor((WIDTH - width) / 2), 8, width, 13); - ctx.fillStyle = "#e4d69f"; - ctx.textAlign = "center"; - ctx.fillText(notice, WIDTH / 2, 11); - ctx.textAlign = "left"; - } - - function drawStandby() { - ctx.fillStyle = "#080b0b"; - ctx.fillRect(0, 0, WIDTH, HEIGHT); - for (let y = 0; y < HEIGHT; y += 4) { - ctx.fillStyle = y & 4 ? "#0b100f" : "#070a09"; - ctx.fillRect(0, y, WIDTH, 2); - } - ctx.fillStyle = "#5d6b63"; - ctx.font = "8px monospace"; - ctx.fillText("BLACKSTAR RECOVERY MONITOR", 8, 12); - ctx.fillStyle = "#c07b48"; - ctx.fillText("WAITING FOR MASTER BOOT…", 8, 25); - } - function render() { - if (!state) { - drawStandby(); - threeStage.render({ tick: 0, activity: 0.08 }); - return; - } - const offsetX = shake > 0 ? ((state.tick * 13) % (shake * 2 + 1)) - shake : 0; - const offsetY = shake > 0 ? ((state.tick * 7) % (Math.max(1, shake) + 1)) - Math.floor(shake / 2) : 0; - ctx.save(); - ctx.translate(offsetX, offsetY); - drawWorld(); - drawEntities(); - drawWeapon(); - drawCrosshair(); - drawHud(); - if (automap) drawAutomap(); - drawNotice(); - ctx.restore(); - - if (state.player.hurt > 0) { - ctx.fillStyle = `rgba(150,30,25,${state.player.hurt / 42})`; - ctx.fillRect(0, 0, WIDTH, VIEW_HEIGHT); - } - if (flash > 0) { - ctx.fillStyle = `rgba(255,224,142,${flash / 30})`; - ctx.fillRect(0, 0, WIDTH, VIEW_HEIGHT); - } - if (paused) { - ctx.fillStyle = "rgba(0,0,0,0.22)"; - ctx.fillRect(0, 0, WIDTH, HEIGHT); - } - threeStage.render({ - tick: state.tick, - speed: Math.hypot(state.player.vx || 0, state.player.vy || 0) / core.FP, - danger: 1 - state.player.health / 100, - activity: Math.max(flash / 12, state.enemies.length ? 0.28 : 0.08), - heading: state.player.angle / core.ANGLE_MAX * TAU, - }); + presentation.render(state, { automap, paused, notice: noticeTicks > 0 ? notice : "", palette: state ? PALETTES[state.blueprint.palette] || PALETTES.copper : PALETTES.copper }); } function updateTelemetry(force) { @@ -928,6 +559,6 @@ }); audio = createAudio(); - drawStandby(); + render(); requestAnimationFrame(frame); })(); diff --git a/BLACKSTAR_AGA/docs/DESIGN.md b/BLACKSTAR_AGA/docs/DESIGN.md index 06a92ec..348d4c8 100644 --- a/BLACKSTAR_AGA/docs/DESIGN.md +++ b/BLACKSTAR_AGA/docs/DESIGN.md @@ -19,7 +19,8 @@ historical disk was actually recovered. ## Engine shape -The renderer is an original stepped raycaster over fixed-cell maps. Moving doors, +The renderer builds native Three.js rooms from fixed-cell maps. A perspective +camera and GPU depth buffer replace the original stepped Canvas raycaster. Doors, locked amber-cipher doors, hidden sectors, enemies and pickups add sector-shooter flavour while keeping collision and visibility deterministic. Enemies activate on line of sight or weapon noise, move through the same collision grid as the player, diff --git a/BLACKSTAR_AGA/docs/DETERMINISM.md b/BLACKSTAR_AGA/docs/DETERMINISM.md index 243c2dd..ffd8e96 100644 --- a/BLACKSTAR_AGA/docs/DETERMINISM.md +++ b/BLACKSTAR_AGA/docs/DETERMINISM.md @@ -1,7 +1,7 @@ # Determinism contract BLACKSTAR AGA separates its gameplay simulation from its browser presentation. -`core.js` is the contract surface. `app.js`, Canvas, Web Audio, CSS, animation +`core.js` is the contract surface. `app.js`, Three.js, Web Audio, CSS, animation frames, pointer lock, and monitor refresh rate are outside canonical state. ## Canonical inputs diff --git a/BLACKSTAR_AGA/index.html b/BLACKSTAR_AGA/index.html index 9a5fb14..2af6974 100644 --- a/BLACKSTAR_AGA/index.html +++ b/BLACKSTAR_AGA/index.html @@ -6,7 +6,7 @@ BLACKSTAR AGA — The Lost Amiga FPS - + @@ -158,9 +158,9 @@

REPLAY / RECEIPT

- - + + diff --git a/BLACKSTAR_AGA/renderer.js b/BLACKSTAR_AGA/renderer.js new file mode 100644 index 0000000..70f6fb5 --- /dev/null +++ b/BLACKSTAR_AGA/renderer.js @@ -0,0 +1,444 @@ +(function (root) { + "use strict"; + const T = root.THREE; + class BlackstarRenderer { + constructor(canvas, core) { + this.core = core; + this.stage = new root.QsolNative.Stage(canvas, { + width: 320, + height: 200, + perspective: true, + fov: 43, + background: "#101719", + }); + this.stage.scene.fog = new T.Fog("#101719", 7, 24); + this.lastTick = -1; + this.lastState = null; + this.bursts = []; + this.map = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + this.map.classList.add("native-map"); + this.map.hidden = true; + canvas.parentElement.appendChild(this.map); + this.stage.ownedNodes.push(this.map); + const reticle = document.createElement("span"); + reticle.className = "native-reticle"; + reticle.textContent = "+"; + canvas.parentElement.appendChild(reticle); + this.stage.ownedNodes.push(reticle); + } + render(state, ui = {}) { + const s = this.stage, + c = this.core; + s.begin(); + this.map.style.display = ui.automap && state ? "block" : "none"; + if (!state) { + s.text("BLACKSTAR RECOVERY MONITOR // READY"); + s.finish(); + return; + } + if (this.lastState !== state) { + this.lastState = state; + this.lastTick = -1; + this.bursts = []; + } + if (this.lastTick !== state.tick) { + this.bursts = this.bursts.filter((e) => state.tick - e.tick < 24); + for (const e of state.events) + if (e.type === "enemy-down") + this.bursts.push({ + x: e.x / c.FP, + z: e.y / c.FP, + tick: state.tick, + }); + this.lastTick = state.tick; + } + const palette = ui.palette, + p = state.player, + angle = (p.angle / c.ANGLE_MAX) * Math.PI * 2; + s.scene.background.set(palette.sky); + s.scene.fog.color.set(palette.sky); + s.camera.position.set(p.x / c.FP, 0.5, p.y / c.FP); + s.camera.lookAt( + p.x / c.FP + Math.cos(angle), + 0.5, + p.y / c.FP + Math.sin(angle), + ); + // The collision grid is authoritative, including opened doors and breached secrets. + for (let y = 0; y < state.blueprint.height; y++) + for (let x = 0; x < state.blueprint.width; x++) { + const cell = c.cellAt(state, x, y); + s.item( + "terrain", + "box", + x + 0.5, + -0.045, + y + 0.5, + 1, + 0.08, + 1, + palette.floor, + ); + if (c.isBlockingCell(state, x, y)) { + const door = cell === "D" || cell === "K", + metal = cell === "P", + barrier = cell === "B"; + const color = door + ? palette.wall[2] + : metal + ? palette.wall[1] + : barrier + ? palette.wall[3] + : palette.wall[0]; + s.item( + door ? "doors" : "terrain", + "box", + x + 0.5, + 0.5, + y + 0.5, + 1, + 1, + 1, + color, + ); + s.item( + "wall-trim", + "box", + x + 0.5, + 0.12, + y + 0.5, + 1.008, + 0.035, + 1.008, + palette.accent, + ); + s.item( + "wall-trim", + "box", + x + 0.5, + 0.9, + y + 0.5, + 1.008, + 0.025, + 1.008, + palette.wall[3], + ); + } else { + // Ceiling ribs give real perspective and depth without obscuring the playfield. + s.item( + "ceiling", + "box", + x + 0.5, + 1.08, + y + 0.5, + 1, + 0.06, + 0.06, + palette.wall[3], + ); + } + } + for (const e of state.enemies) { + const x = e.x / c.FP, + z = e.y / c.FP; + const heavy = + e.kind === "warden" ? 1.4 : e.kind === "bulwark" ? 1.15 : 1; + const colors = { + warden: "#893c38", + bulwark: "#80604b", + specter: "#567e78", + drone: "#5b978c", + trooper: "#9b6542", + }; + const color = e.pain > 0 ? "#f4d8a1" : colors[e.kind] || "#677c6e"; + if (e.kind === "drone") { + s.item("enemies", "sphere", x, 0.55, z, 0.21, 0.12, 0.21, color); + s.item("enemies", "box", x, 0.55, z, 0.62, 0.05, 0.06, "#344c49"); + } else { + s.item( + "enemies", + "box", + x, + 0.39, + z, + 0.32 * heavy, + 0.36 * heavy, + 0.22, + color, + ); + s.item( + "enemies", + "sphere", + x, + 0.65, + z, + 0.14 * heavy, + 0.16, + 0.13, + "#47554b", + ); + for (const side of [-1, 1]) { + s.item( + "enemies", + "box", + x + side * 0.12 * heavy, + 0.12, + z, + 0.1, + 0.24, + 0.13, + "#29352f", + ); + s.item( + "enemies", + "cylinder", + x + side * 0.25 * heavy, + 0.4, + z, + 0.065, + 0.31, + 0.065, + color, + ); + } + } + // Red eye and muzzle are actual world objects with wall depth occlusion. + s.item( + "enemies", + "sphere", + x, + 0.62, + z, + 0.06, + 0.035, + 0.15, + "#db7151", + 0, + 0, + 0, + "glow", + ); + if (e.muzzle > 0) + s.item( + "effects", + "diamond", + x, + 0.47, + z, + 0.12, + 0.12, + 0.12, + "#ffcc78", + 0, + 0, + 0, + "glow", + ); + if (e.kind === "specter") + s.item( + "enemies", + "box", + x + 0.23, + 0.58, + z, + 0.035, + 0.48, + 0.06, + "#a6c8b4", + ); + if (e.kind === "bulwark" || e.kind === "warden") + for (const side of [-1, 1]) + s.item( + "enemies", + "bevel", + x + side * 0.28 * heavy, + 0.48, + z, + 0.18, + 0.45, + 0.3, + "#553e32", + ); + if (e.kind === "warden") + s.item( + "indicators", + "box", + x, + 0.93, + z, + 0.6 * Math.max(0, e.health / e.maxHealth), + 0.04, + 0.04, + "#e6a353", + 0, + 0, + 0, + "glow", + ); + } + const colors = { + medkit: "#d7d1ad", + armor: "#4f9b8d", + cells: "#dd9b4f", + shells: "#b05942", + breach: "#8d5b3c", + vulcan: "#7b897d", + key: "#f0c764", + archive: "#d7be83", + }; + for (const e of state.pickups) { + const x = e.x / c.FP, + z = e.y / c.FP, + a = s.reducedMotion ? 0 : state.tick * 0.015; + s.item( + "pickups", + e.kind === "key" ? "ring" : "diamond", + x, + 0.24, + z, + 0.13, + 0.13, + 0.13, + colors[e.kind] || "#cfba8f", + 0, + a, + 0, + ); + if (e.kind === "medkit") { + s.item("pickups", "box", x, 0.24, z, 0.035, 0.16, 0.15, "#a84d3f"); + s.item("pickups", "box", x, 0.24, z, 0.12, 0.035, 0.15, "#a84d3f"); + } + } + const exit = state.blueprint.exit, + open = !c.missionObjectiveDenial(state); + s.item( + "exit", + "box", + exit.x + 0.5, + 0.4, + exit.y + 0.5, + 0.55, + 0.75, + 0.12, + "#293b35", + ); + s.item( + "exit", + "box", + exit.x + 0.5, + 0.5, + exit.y + 0.5, + 0.4, + 0.35, + 0.14, + open ? "#93d3ac" : "#b85840", + 0, + 0, + 0, + "glow", + ); + // Camera-relative weapon is still geometry in the same Three.js scene. + s.camera.updateMatrixWorld(); + const right = new T.Vector3().setFromMatrixColumn( + s.camera.matrixWorld, + 0, + ); + const forward = new T.Vector3(Math.cos(angle), 0, Math.sin(angle)); + const gun = s.camera.position + .clone() + .addScaledVector(forward, 0.34) + .addScaledVector(right, 0.1); + gun.y -= 0.15; + s.item( + "player", + "bevel", + gun.x, + gun.y, + gun.z, + 0.09, + 0.09, + 0.19, + p.weapon === 1 ? "#74523a" : "#52685c", + 0, + Math.PI / 2 - angle, + ); + const barrels = p.weapon === 2 ? 3 : p.weapon === 1 ? 2 : 1; + for (let i = 0; i < barrels; i++) { + const offset = (i - (barrels - 1) / 2) * 0.029, + bx = gun.x + right.x * offset + forward.x * 0.075, + bz = gun.z + right.z * offset + forward.z * 0.075; + s.item( + "player", + "cylinder", + bx, + gun.y + 0.02, + bz, + 0.013, + 0.17, + 0.013, + "#263d34", + Math.PI / 2, + 0, + Math.PI / 2 - angle, + ); + } + for (const e of this.bursts) { + const age = state.tick - e.tick, + life = 1 - age / 24; + for (let i = 0; i < (s.reducedMotion ? 1 : 9); i++) { + const a = i * 2.399, + r = age * 0.013; + s.item( + "effects", + "diamond", + e.x + Math.cos(a) * r, + 0.3 + Math.sin(i) * r, + e.z + Math.sin(a) * r, + 0.06 * life, + 0.06 * life, + 0.06 * life, + i % 2 ? "#b3a07b" : "#d9864d", + 0, + age * 0.1, + a, + ); + } + } + if (state.tick - state.lastShotTick < 3) + s.item( + "effects", + "diamond", + gun.x + forward.x * 0.13, + gun.y, + gun.z + forward.z * 0.13, + 0.035, + 0.035, + 0.035, + "#ffe8a2", + 0, + 0, + 0, + "glow", + ); + const weapon = c.WEAPONS[p.weapon]; + s.text( + `VITAL ${p.health} ARMOR ${p.armor} ${weapon.name} ${weapon.ammo ? p[weapon.ammo] : "∞"} SCORE ${state.score}\nKEYS ${p.keys} ARCHIVES ${state.stats.archives}${ui.notice ? " // " + ui.notice : ""}`, + ); + if (ui.automap) { + const w = state.blueprint.width, + h = state.blueprint.height; + this.map.setAttribute("viewBox", `0 0 ${w} ${h}`); + let markup = ""; + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) + if (c.isBlockingCell(state, x, y)) + markup += ``; + for (const e of state.enemies) + markup += ``; + markup += ``; + this.map.innerHTML = markup; + } + s.finish(); + } + } + root.BlackstarRenderer = BlackstarRenderer; +})(window); diff --git a/BLACKSTAR_AGA/tests/browser-smoke.js b/BLACKSTAR_AGA/tests/browser-smoke.js index a00649c..05c2c8e 100644 --- a/BLACKSTAR_AGA/tests/browser-smoke.js +++ b/BLACKSTAR_AGA/tests/browser-smoke.js @@ -9,8 +9,8 @@ const core = require("../core.js"); (async () => { const browser = await chromium.launch({ headless: true, - executablePath: process.env.BLACKSTAR_CHROMIUM || chromium.executablePath(), - args: ["--no-sandbox", "--disable-setuid-sandbox"], + executablePath: process.env.GAMES_CHROMIUM || process.env.BLACKSTAR_CHROMIUM || chromium.executablePath(), + args: ["--no-sandbox", "--disable-setuid-sandbox", "--enable-unsafe-swiftshader"], }); const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 }); const errors = []; @@ -19,7 +19,7 @@ const core = require("../core.js"); page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`)); page.on("request", (request) => requests.push(request.url())); - const url = pathToFileURL(path.join(__dirname, "..", "index.html")).href; + const url = process.env.GAMES_TEST_ORIGIN ? `${process.env.GAMES_TEST_ORIGIN}/BLACKSTAR_AGA/index.html` : pathToFileURL(path.join(__dirname, "..", "index.html")).href; await page.goto(url, { waitUntil: "load" }); assert.equal(await page.title(), "BLACKSTAR AGA — The Lost Amiga FPS"); assert.equal(await page.locator("#bootLayer").isVisible(), true); @@ -151,7 +151,7 @@ const core = require("../core.js"); assert.ok(mobile.launch.top >= mobile.gameView.top - 1, "mobile boot button must remain visible"); await page.screenshot({ path: path.join(__dirname, "blackstar-mobile.png"), fullPage: true }); - assert.deepEqual(requests.filter((requestUrl) => !requestUrl.startsWith("file://")), []); + assert.deepEqual(requests.filter((requestUrl) => !(process.env.GAMES_TEST_ORIGIN ? new URL(requestUrl).origin === process.env.GAMES_TEST_ORIGIN : requestUrl.startsWith("file://"))), []); assert.deepEqual(errors, []); await browser.close(); process.stdout.write("ok - boot, fixed-step FPS input, pause, terminal replay drain, telemetry, camera depth, offline boundary, and mobile layout\n"); diff --git a/BLACKSTAR_AGA/tests/offline.test.js b/BLACKSTAR_AGA/tests/offline.test.js index ea6d97a..29ebb14 100644 --- a/BLACKSTAR_AGA/tests/offline.test.js +++ b/BLACKSTAR_AGA/tests/offline.test.js @@ -19,9 +19,6 @@ assert.match(app, /requestAnimationFrame\(frame\)/); assert.match(app, /core\.tryRecordInput/); assert.match(app, /core\.encodeReplay/); assert.match(app, /core\.decodeReplay/); -assert.match(app, /function raycast/); -assert.match(app, /function drawEnemySprite/); -assert.match(app, /projection\.depth/); assert.doesNotMatch(app, /distance > zBuffer/); assert.match(app, /function createAudio/); assert.match(app, /function isInteractiveTarget/); @@ -33,3 +30,8 @@ for (const relative of ["core.js", "app.js", "style.css", "README.md", "NOTICE.m } process.stdout.write("ok - BLACKSTAR AGA is a self-contained offline browser game\n"); + +const renderer = fs.readFileSync(path.join(root, "renderer.js"), "utf8"); +assert.doesNotMatch(app + renderer, /getContext\s*\(\s*["']2d["']/); +assert.match(renderer, /QsolNative\.Stage/); +assert.match(renderer, /s\.finish\(\)/); diff --git a/CARTRIDGE_ZERO/README.md b/CARTRIDGE_ZERO/README.md index 8ebede1..95bbe02 100644 --- a/CARTRIDGE_ZERO/README.md +++ b/CARTRIDGE_ZERO/README.md @@ -48,23 +48,23 @@ exact tick count, and a run-length-encoded input ledger. Playback constructs a fresh state and drains the entire ledger, including input after a terminal tick, before publishing the final digest. -Canvas, CSS, browser timing, animation frames, audio, fullscreen state, color +Three.js, CSS, browser timing, animation frames, audio, fullscreen state, color switch position, and layout are presentation only. See [`docs/DETERMINISM.md`](docs/DETERMINISM.md). ## Files - `core.js` — all seven canonical simulations, AI, receipts, and digests. -- `app.js` — fixed-step host, renderers, controls, replay UI, synthesized audio, - and individual hit/clear/finish presentation passes for all seven programs. -- `../shared/qsol-three-stage.js` — pinned local Three.js cabinet depth, particles, - and event pulses outside canonical game state. +- `app.js` — fixed-step host, controls, replay UI and synthesized audio. +- `renderer.js` — seven native WebGL playfields, with beveled bricks, distinct + ship silhouettes, river banks, tank turrets and sky/water targets. +- `../shared/qsol-native.js` — shared native WebGL batching and lifecycle. - `index.html` / `style.css` — offline fictional console and responsive controls. - `docs/MANUAL.md` — recovered-cartridge fiction and complete game instructions. - `docs/DESIGN.md` — architectural and originality decisions. - `tests/` — deterministic, AI, receipt, offline, and browser regressions. -All game assets are generated from Canvas/Three.js primitives. All audio is +All game assets are generated from native Three.js geometry. All audio is opt-in and synthesized at runtime; no samples or remote assets are loaded. See [`NOTICE.md`](NOTICE.md). @@ -85,3 +85,5 @@ Playwright is pinned for browser testing and is not a game dependency. Playable seven-game anthology with versioned `CZ01` receipts. Version 1 receipts are intentionally versioned rather than promised compatible with later canonical simulation revisions. + +Native renderer architecture and verification: [migration audit](../docs/NATIVE_RENDERING.md). diff --git a/CARTRIDGE_ZERO/app.js b/CARTRIDGE_ZERO/app.js index bd84b42..ea2c380 100644 --- a/CARTRIDGE_ZERO/app.js +++ b/CARTRIDGE_ZERO/app.js @@ -3,10 +3,7 @@ const core = window.CartridgeZeroCore; const canvas = document.querySelector("#game"); - const ctx = canvas.getContext("2d", { alpha: false }); - const threeStage = window.QsolThree - ? window.QsolThree.create({ host: document.querySelector(".viewport"), source: canvas, preset: "cartridge" }) - : { render() {}, pulse() {} }; + const presentation = new window.CartridgeRenderer(canvas, core); const TICK_MS = 1000 / core.TICK_RATE; const MAX_FRAME_STEPS = 8; @@ -308,15 +305,12 @@ audio.events(events); for (const event of events) { if (["shot", "brick", "target", "impact", "hit"].includes(event.type)) { - threeStage.pulse(event.type === "impact" || event.type === "hit" ? "impact" : "shot", 0.28); } if (event.type === "level") { showNotice(`SIGNAL LEVEL ${event.level}`, 90); - threeStage.pulse("jump", 0.55); } else if (event.type === "life-lost") { showNotice(`${event.reason} // ${event.lives} LEFT`, 90); - threeStage.pulse("impact", 0.8); } else if (event.type === "fuel") showNotice("FUEL LATTICE CAPTURED", 70); else if (event.type === "dive") showNotice("TALON BREAKING FORMATION", 50); @@ -356,365 +350,8 @@ if (mode === "replay" && replayCursor.tick >= replay.ticks) finishReplay(); } - function clearScreen(palette) { - ctx.fillStyle = monochrome ? "#070907" : palette.bg; - ctx.fillRect(0, 0, core.WIDTH, core.HEIGHT); - ctx.imageSmoothingEnabled = false; - } - - function color(palette, key) { - if (!monochrome) return palette[key]; - if (key === "dim") return "#263126"; - if (key === "secondary") return "#91a891"; - if (key === "cool") return "#6f866f"; - return "#c9dcc1"; - } - - function drawShip(x, y, palette, hostile, scale) { - const size = scale || 1; - ctx.fillStyle = hostile ? color(palette, "secondary") : color(palette, "main"); - ctx.beginPath(); - ctx.moveTo(x, y - 5 * size); - ctx.lineTo(x - 5 * size, y + 4 * size); - ctx.lineTo(x, y + 2 * size); - ctx.lineTo(x + 5 * size, y + 4 * size); - ctx.closePath(); - ctx.fill(); - } - - function drawPrism() { - const palette = PALETTES[state.gameId]; - const game = state.game; - clearScreen(palette); - ctx.fillStyle = color(palette, "dim"); - ctx.fillRect(3, 7, 2, 178); - ctx.fillRect(155, 7, 2, 178); - for (const brick of game.bricks) { - ctx.fillStyle = monochrome ? (brick.band % 2 ? "#8ca08c" : "#c5d5bd") : palette.bands[brick.band]; - ctx.fillRect(logical(brick.x - brick.w / 2), logical(brick.y - brick.h / 2), logical(brick.w), logical(brick.h)); - if (brick.hp > 1) { - ctx.fillStyle = palette.bg; - ctx.fillRect(logical(brick.x) - 1, logical(brick.y) - 1, 3, 2); - } - } - ctx.fillStyle = color(palette, "main"); - ctx.fillRect(logical(game.paddleX) - 14, 176, 28, 4); - ctx.fillStyle = color(palette, "secondary"); - ctx.fillRect(logical(game.ball.x) - 1, logical(game.ball.y) - 1, 3, 3); - if (game.ball.stuck) { - ctx.fillStyle = color(palette, "cool"); - ctx.font = "6px monospace"; - ctx.textAlign = "center"; - ctx.fillText("FIRE TO SERVE", 80, 151); - } - } - - function gridLaneX(lane, y) { - const spread = 7 + Math.max(0, y - 22) * 0.22; - return 80 + (lane - 3) * spread; - } - - function drawGridburn() { - const palette = PALETTES[state.gameId]; - const game = state.game; - clearScreen(palette); - ctx.strokeStyle = color(palette, "dim"); - ctx.lineWidth = 1; - for (let boundary = -3.5; boundary <= 3.5; boundary += 1) { - ctx.beginPath(); - ctx.moveTo(80 + boundary * 7, 22); - ctx.lineTo(80 + boundary * 40, 191); - ctx.stroke(); - } - for (let row = 0; row < 11; row += 1) { - const phase = (state.tick * 0.65 + row * 18) % 180; - const y = 22 + Math.floor(phase * phase / 190); - const half = 25 + (y - 22) * 0.78; - ctx.beginPath(); - ctx.moveTo(80 - half, y); - ctx.lineTo(80 + half, y); - ctx.stroke(); - } - for (const enemy of game.enemies) { - const y = logical(enemy.y); - const x = gridLaneX(enemy.lane, y); - const size = Math.max(2, Math.floor(2 + y / 55)); - ctx.fillStyle = enemy.kind === 2 ? color(palette, "secondary") : color(palette, "cool"); - ctx.fillRect(Math.round(x - size), y - size, size * 2 + 1, size * 2 + 1); - if (enemy.kind === 1) ctx.fillRect(Math.round(x - size - 2), y, size * 2 + 5, 1); - } - ctx.fillStyle = color(palette, "main"); - for (const shot of game.shots) ctx.fillRect(Math.round(gridLaneX(shot.lane, logical(shot.y))), logical(shot.y), 1, 5); - ctx.fillStyle = color(palette, "secondary"); - for (const shot of game.enemyShots) ctx.fillRect(Math.round(gridLaneX(shot.lane, logical(shot.y))), logical(shot.y), 2, 3); - drawShip(gridLaneX(game.lane, logical((176 - game.row * 15) * core.FP)), 176 - game.row * 15, palette, false, 0.8); - } - - function drawOrbitalEnemy(enemy, palette) { - const x = logical(enemy.x); - const y = logical(enemy.y); - ctx.fillStyle = enemy.row < 2 ? color(palette, "secondary") : color(palette, "cool"); - ctx.fillRect(x - 4, y - 2, 9, 5); - ctx.fillRect(x - 2, y - 4, 5, 9); - if ((state.tick >> 4) % 2) { ctx.fillRect(x - 6, y + 2, 2, 2); ctx.fillRect(x + 5, y + 2, 2, 2); } - } - - function drawOrbital() { - const palette = PALETTES[state.gameId]; - const game = state.game; - clearScreen(palette); - ctx.fillStyle = color(palette, "dim"); - ctx.fillRect(0, 184, 160, 8); - for (const enemy of game.enemies) drawOrbitalEnemy(enemy, palette); - drawShip(logical(game.playerX), 178, palette, false, 0.9); - ctx.fillStyle = color(palette, "main"); - for (const shot of game.playerShots) ctx.fillRect(logical(shot.x), logical(shot.y), 1, 4); - ctx.fillStyle = color(palette, "secondary"); - for (const shot of game.enemyShots) ctx.fillRect(logical(shot.x), logical(shot.y), 2, 4); - } - - function drawTalonEnemy(enemy, palette) { - const x = logical(enemy.x); - const y = logical(enemy.y); - ctx.fillStyle = enemy.state === "diving" ? color(palette, "secondary") : color(palette, "cool"); - ctx.beginPath(); - ctx.moveTo(x, y + 4); - ctx.lineTo(x - 6, y - 2); - ctx.lineTo(x - 2, y); - ctx.lineTo(x, y - 4); - ctx.lineTo(x + 2, y); - ctx.lineTo(x + 6, y - 2); - ctx.closePath(); - ctx.fill(); - } - - function drawTalon() { - const palette = PALETTES[state.gameId]; - const game = state.game; - clearScreen(palette); - ctx.fillStyle = color(palette, "dim"); - for (let index = 0; index < 24; index += 1) { - const star = core.mix32(state.seed ^ index * 7717); - ctx.fillRect(star % 160, 8 + (star >>> 8) % 160, 1, 1); - } - for (const enemy of game.enemies) drawTalonEnemy(enemy, palette); - drawShip(logical(game.playerX), 178, palette, false, 0.9); - ctx.fillStyle = color(palette, "main"); - for (const shot of game.playerShots) ctx.fillRect(logical(shot.x), logical(shot.y), 1, 4); - ctx.fillStyle = color(palette, "secondary"); - for (const shot of game.enemyShots) ctx.fillRect(logical(shot.x), logical(shot.y), 2, 3); - } - - function drawRiverEntity(entity, palette) { - const x = logical(entity.x); - const y = logical(entity.y); - if (entity.kind === "fuel") { - ctx.fillStyle = color(palette, "main"); - ctx.fillRect(x - 3, y - 4, 7, 9); - ctx.fillStyle = palette.bg; - ctx.fillRect(x - 1, y - 2, 3, 5); - return; - } - ctx.fillStyle = entity.kind === "tower" ? color(palette, "secondary") : color(palette, "cool"); - ctx.fillRect(x - 5, y - 3, 11, 6); - ctx.fillRect(x - 1, y - 6, 3, 4); - } - - function drawRiver() { - const palette = PALETTES[state.gameId]; - const game = state.game; - clearScreen(palette); - ctx.fillStyle = color(palette, "dim"); - const leftPoints = [{ x: 0, y: -4 }]; - const rightPoints = [{ x: 160, y: -4 }]; - for (let index = 0; index < game.rows.length; index += 1) { - const row = game.rows[index]; - const y = index * 8 + game.offset / core.FP - 8; - leftPoints.push({ x: row.center - row.width / 2, y }); - rightPoints.push({ x: row.center + row.width / 2, y }); - } - leftPoints.push({ x: 0, y: 196 }); - rightPoints.push({ x: 160, y: 196 }); - ctx.beginPath(); - ctx.moveTo(0, -4); - for (const point of leftPoints.slice(1)) ctx.lineTo(point.x, point.y); - ctx.lineTo(0, 196); - ctx.closePath(); - ctx.fill(); - ctx.beginPath(); - ctx.moveTo(160, -4); - for (const point of rightPoints.slice(1)) ctx.lineTo(point.x, point.y); - ctx.lineTo(160, 196); - ctx.closePath(); - ctx.fill(); - ctx.strokeStyle = color(palette, "main"); - ctx.lineWidth = 1; - ctx.beginPath(); - leftPoints.slice(1, -1).forEach((point, index) => index ? ctx.lineTo(point.x, point.y) : ctx.moveTo(point.x, point.y)); - ctx.stroke(); - ctx.beginPath(); - rightPoints.slice(1, -1).forEach((point, index) => index ? ctx.lineTo(point.x, point.y) : ctx.moveTo(point.x, point.y)); - ctx.stroke(); - for (const entity of game.entities) drawRiverEntity(entity, palette); - drawShip(logical(game.playerX), logical(game.playerY), palette, false, 0.9); - ctx.fillStyle = color(palette, "main"); - for (const shot of game.shots) ctx.fillRect(logical(shot.x), logical(shot.y), 1, 4); - ctx.fillStyle = color(palette, "secondary"); - for (const shot of game.enemyShots) ctx.fillRect(logical(shot.x), logical(shot.y), 2, 3); - ctx.fillStyle = color(palette, "main"); - ctx.fillRect(5, 186, Math.floor(game.fuel * 50 / 1000), 3); - ctx.strokeStyle = color(palette, "cool"); - ctx.strokeRect(4.5, 185.5, 51, 4); - } - - function drawTank(tank, palette, hostile) { - const x = logical(tank.x); - const y = logical(tank.y); - const direction = core.DIR16[tank.angle]; - ctx.fillStyle = hostile ? color(palette, "secondary") : color(palette, "main"); - ctx.fillRect(x - 4, y - 4, 9, 9); - ctx.strokeStyle = ctx.fillStyle; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.moveTo(x, y); - ctx.lineTo(x + Math.round(direction.x * 8 / 1024), y + Math.round(direction.y * 8 / 1024)); - ctx.stroke(); - } - - function drawCircuit() { - const palette = PALETTES[state.gameId]; - const game = state.game; - clearScreen(palette); - ctx.strokeStyle = color(palette, "dim"); - ctx.strokeRect(4.5, 8.5, 151, 177); - ctx.fillStyle = color(palette, "dim"); - for (const wall of game.walls) ctx.fillRect(logical(wall.x - wall.w / 2), logical(wall.y - wall.h / 2), logical(wall.w), logical(wall.h)); - drawTank(game.player, palette, false); - for (const bot of game.bots) drawTank(bot, palette, true); - for (const shell of game.shells) { - ctx.fillStyle = shell.owner === "player" ? color(palette, "main") : color(palette, "secondary"); - ctx.fillRect(logical(shell.x) - 1, logical(shell.y) - 1, 3, 3); - } - } - - function drawSkyTarget(target, palette) { - const x = logical(target.x); - const y = logical(target.y); - ctx.fillStyle = target.kind === "sub" || target.kind === "skimmer" ? color(palette, "cool") : color(palette, "secondary"); - if (target.kind === "glider") { - ctx.fillRect(x - 6, y - 1, 13, 3); - ctx.fillRect(x - 1, y - 3, 4, 7); - } else if (target.kind === "rotor") { - ctx.fillRect(x - 4, y - 2, 9, 5); - ctx.fillRect(x - 7, y - 4, 15, 1); - } else if (target.kind === "skimmer") { - ctx.fillRect(x - 7, y - 2, 15, 5); - ctx.fillRect(x - 1, y - 5, 4, 4); - } else { - ctx.fillRect(x - 8, y - 2, 17, 4); - ctx.fillRect(x - 2, y - 5, 5, 4); - } - } - - function drawTurret(x, y, aim, palette, hostile) { - const direction = core.DIR16[aim]; - ctx.fillStyle = hostile ? color(palette, "secondary") : color(palette, "main"); - ctx.fillRect(x - 5, y - 3, 11, 5); - ctx.strokeStyle = ctx.fillStyle; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.moveTo(x, y - 2); - ctx.lineTo(x + Math.round(direction.x * 10 / 1024), y - 2 + Math.round(direction.y * 10 / 1024)); - ctx.stroke(); - } - - function drawSkywater() { - const palette = PALETTES[state.gameId]; - const game = state.game; - clearScreen(palette); - ctx.fillStyle = color(palette, "dim"); - ctx.fillRect(0, 151, 160, 41); - ctx.fillStyle = color(palette, "cool"); - for (let x = (state.tick % 12) - 12; x < 160; x += 12) ctx.fillRect(x, 154, 7, 1); - for (const target of game.targets) drawSkyTarget(target, palette); - drawTurret(24, 181, game.playerAim, palette, false); - drawTurret(136, 181, game.aiAim, palette, true); - for (const shell of game.shells) { - ctx.fillStyle = shell.owner === "player" ? color(palette, "main") : color(palette, "secondary"); - ctx.fillRect(logical(shell.x), logical(shell.y), 2, 2); - } - ctx.font = "6px monospace"; - ctx.textAlign = "center"; - ctx.fillStyle = color(palette, "main"); - ctx.fillText(`YOU ${state.score}`, 32, 10); - ctx.fillStyle = color(palette, "secondary"); - ctx.fillText(`AI ${game.aiScore}`, 128, 10); - ctx.fillStyle = color(palette, "cool"); - ctx.fillText(`${Math.ceil(game.timeLeft / core.TICK_RATE)} SEC`, 80, 10); - } - - const RENDERERS = Object.freeze({ - "prism-break": drawPrism, - gridburn: drawGridburn, - "orbital-siege": drawOrbital, - "star-talon": drawTalon, - "rift-runner": drawRiver, - "iron-circuit": drawCircuit, - "skywater-command": drawSkywater, - }); - - function renderStandby() { - const palette = PALETTES[selectedGameId]; - clearScreen(palette); - ctx.fillStyle = color(palette, "dim"); - for (let y = 20; y < 180; y += 12) ctx.fillRect(20 + (y % 24), y, 120 - (y % 24), 1); - ctx.fillStyle = color(palette, "main"); - ctx.font = "bold 12px monospace"; - ctx.textAlign = "center"; - ctx.fillText("CARTRIDGE ZERO", 80, 77); - ctx.font = "7px monospace"; - ctx.fillStyle = color(palette, "cool"); - ctx.fillText("SEVEN SIGNALS", 80, 91); - ctx.fillStyle = color(palette, "secondary"); - ctx.fillText("INSERTED // WAITING", 80, 119); - } - - function renderSignalFinish() { - const pulse = state ? 0.025 + ((state.tick >> 2) & 3) * 0.006 : 0.025; - const bloom = ctx.createRadialGradient(80, 96, 12, 80, 96, 116); - bloom.addColorStop(0, `rgba(232,196,119,${pulse})`); - bloom.addColorStop(0.62, "rgba(91,139,128,0.015)"); - bloom.addColorStop(1, "rgba(0,0,0,0.35)"); - ctx.fillStyle = bloom; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = "rgba(238,220,170,0.035)"; - const sweep = state ? state.tick % canvas.height : 0; - ctx.fillRect(0, sweep, canvas.width, 2); - if (paused && state) { - ctx.fillStyle = "rgba(3,7,6,0.62)"; - ctx.fillRect(42, 84, 76, 22); - ctx.strokeStyle = "#caa760"; - ctx.strokeRect(42.5, 84.5, 75, 21); - ctx.fillStyle = "#e2c477"; - ctx.font = "bold 8px monospace"; - ctx.textAlign = "center"; - ctx.fillText("SIGNAL HELD", 80, 92); - ctx.font = "6px monospace"; - ctx.fillText("PRESS P TO RESUME", 80, 100); - } - } - function render() { - if (!state) renderStandby(); - else RENDERERS[state.gameId](); - renderSignalFinish(); - threeStage.render({ - tick: state ? state.tick : 0, - speed: state && state.gameId === "rift-runner" ? 1.3 : 0.2, - danger: state && state.lives <= 1 ? 0.8 : 0, - activity: state ? 0.32 : 0.08, - heading: state ? state.tick * 0.002 : 0, - }); + presentation.render(state, { palette: PALETTES[state ? state.gameId : selectedGameId], monochrome, paused }); } function updateTelemetry(force) { diff --git a/CARTRIDGE_ZERO/docs/DETERMINISM.md b/CARTRIDGE_ZERO/docs/DETERMINISM.md index 9c4215e..4ea8bc9 100644 --- a/CARTRIDGE_ZERO/docs/DETERMINISM.md +++ b/CARTRIDGE_ZERO/docs/DETERMINISM.md @@ -1,7 +1,7 @@ # Determinism contract CARTRIDGE ZERO separates canonical arcade state from browser presentation. -`core.js` is the contract surface. Canvas, CSS, animation frames, monitor refresh, +`core.js` is the contract surface. Three.js, CSS, animation frames, monitor refresh, Web Audio, fullscreen state, touch event timing, and the color switch are not canonical. diff --git a/CARTRIDGE_ZERO/index.html b/CARTRIDGE_ZERO/index.html index 07e4fb6..a59b5c7 100644 --- a/CARTRIDGE_ZERO/index.html +++ b/CARTRIDGE_ZERO/index.html @@ -6,7 +6,7 @@ CARTRIDGE ZERO — Seven Deterministic Signals - + @@ -145,9 +145,9 @@

CZ01 Receipt

- - + + diff --git a/CARTRIDGE_ZERO/renderer.js b/CARTRIDGE_ZERO/renderer.js new file mode 100644 index 0000000..1c2f3eb --- /dev/null +++ b/CARTRIDGE_ZERO/renderer.js @@ -0,0 +1,420 @@ +(function (root) { + "use strict"; + class CartridgeRenderer { + constructor(canvas, core) { + this.core = core; + this.stage = new root.QsolNative.Stage(canvas, { + width: 160, + height: 192, + }); + this.stage.oblique(0.38, 0.6); + this.stage.shape("talon", [ + [0, 1], + [-1, -0.4], + [-0.35, 0], + [0, -1], + [0.35, 0], + [1, -0.4], + ]); + this.stage.shape("invader", [ + [-1, -0.3], + [-0.55, -0.3], + [-0.55, -0.85], + [0.55, -0.85], + [0.55, -0.3], + [1, -0.3], + [1, 0.6], + [0.5, 0.6], + [0.5, 1], + [-0.5, 1], + [-0.5, 0.6], + [-1, 0.6], + ]); + } + render(state, ui) { + const s = this.stage, + c = this.core, + p = ui.palette, + mono = ui.monochrome; + const col = (key) => + mono + ? { dim: "#263126", secondary: "#91a891", cool: "#6f866f" }[key] || + "#c9dcc1" + : p[key]; + s.begin(mono ? "#070907" : p.bg); + if (!state) { + s.text("CARTRIDGE ZERO // SEVEN SIGNALS"); + s.finish(); + return; + } + const g = state.game, + id = state.gameId, + fp = c.FP, + L = (n) => n / fp; + const box = (group, x, y, w, h, color, z = 0, d = 3) => + s.flat(group, "bevel", x, y, z, w, h, d, color); + const ship = ( + group, + x, + y, + color, + size = 5, + shape = "dart", + angle = 0, + ) => { + s.flat(group, shape, x, y, 6, size, size, size * 0.8, color, angle); + s.flat( + group, + "diamond", + x, + y, + 10, + size * 0.3, + size * 0.5, + size * 0.2, + col("cool"), + angle, + ); + }; + const shots = (list, hostile = false) => { + for (const e of list || []) + box( + "projectiles", + L(e.x), + L(e.y), + hostile ? 1.8 : 1, + 4, + hostile ? col("secondary") : col("main"), + 12, + 2, + ); + }; + const tank = (e, hostile) => { + const x = L(e.x), + y = L(e.y), + v = c.DIR16[e.angle], + a = Math.atan2(v.y, v.x), + color = col(hostile ? "secondary" : "main"); + box(hostile ? "enemies" : "player", x, y, 9, 9, color, 3, 5); + for (const dx of [-5, 5]) + box("treads", x + dx, y, 2, 11, col("dim"), 2, 3); + s.flat( + hostile ? "enemies" : "player", + "cylinder", + x, + y, + 7, + 3, + 3, + 3, + color, + ); + s.line( + "turrets", + x, + y, + x + (v.x * 8) / 1024, + y + (v.y * 8) / 1024, + 10, + color, + 2, + ); + }; + // Every cartridge keeps its own playfield, coordinate mapping and silhouettes. + if (id === "prism-break") { + box("terrain", 4, 96, 2, 178, col("dim"), -2, 8); + box("terrain", 156, 96, 2, 178, col("dim"), -2, 8); + for (const b of g.bricks) { + box( + "bricks", + L(b.x), + L(b.y), + L(b.w), + L(b.h), + mono ? (b.band % 2 ? "#8ca08c" : "#c5d5bd") : p.bands[b.band], + 2, + 5, + ); + if (b.hp > 1) + box("indicators", L(b.x), L(b.y), 3, 1, col("dim"), 5, 1); + } + box("player", L(g.paddleX), 178, 28, 4, col("main"), 3, 5); + s.flat( + "ball", + "sphere", + L(g.ball.x), + L(g.ball.y), + 7, + 1.7, + 1.7, + 1.7, + col("secondary"), + ); + s.text(g.ball.stuck ? "FIRE TO SERVE" : ui.paused ? "SIGNAL HELD" : ""); + } else if (id === "gridburn") { + const lane = (n, y) => 80 + (n - 3) * (7 + Math.max(0, y - 22) * 0.22); + for (let n = -0.5; n <= 6.5; n++) + s.line( + "terrain", + lane(n, 22), + 22, + lane(n, 191), + 191, + -6, + col("dim"), + 0.5, + ); + for (let row = 0; row < 11; row++) { + const phase = + ((s.reducedMotion ? 0 : state.tick * 0.65) + row * 18) % 180, + y = 22 + (phase * phase) / 190; + s.line( + "terrain", + lane(-0.5, y), + y, + lane(6.5, y), + y, + -6, + col("dim"), + 0.5, + ); + } + for (const e of g.enemies) { + const y = L(e.y), + size = 2 + y / 55; + ship( + "enemies", + lane(e.lane, y), + y, + col(e.kind === 2 ? "secondary" : "cool"), + size, + e.kind === 1 ? "talon" : "invader", + ); + } + for (const [list, hostile] of [ + [g.shots, false], + [g.enemyShots, true], + ]) + for (const e of list) + box( + "projectiles", + lane(e.lane, L(e.y)), + L(e.y), + hostile ? 2 : 1, + 5, + col(hostile ? "secondary" : "main"), + 12, + 2, + ); + const y = 176 - g.row * 15; + ship("player", lane(g.lane, y), y, col("main"), 4); + } else if (id === "orbital-siege" || id === "star-talon") { + if (id === "orbital-siege") + box("terrain", 80, 188, 160, 8, col("dim"), -8, 6); + for (const e of g.enemies) + ship( + "enemies", + L(e.x), + L(e.y), + col(e.state === "diving" || e.row < 2 ? "secondary" : "cool"), + 5, + id === "star-talon" ? "talon" : "invader", + ); + ship("player", L(g.playerX), 178, col("main")); + shots(g.playerShots); + shots(g.enemyShots, true); + } else if (id === "rift-runner") { + // Piecewise linear banks use the same row interpolation as the simulation. + for (let i = 0; i < g.rows.length - 1; i++) { + const a = g.rows[i], + b = g.rows[i + 1], + y = i * 8 + g.offset / fp - 8; + for (let k = 0; k < 8; k++) { + const t = (k + 0.5) / 8, + left = + (a.center - a.width / 2) * (1 - t) + + (b.center - b.width / 2) * t, + right = + (a.center + a.width / 2) * (1 - t) + + (b.center + b.width / 2) * t; + box("terrain", left / 2, y + k + 0.5, left, 1, col("dim"), -4, 9); + box( + "terrain", + (160 + right) / 2, + y + k + 0.5, + 160 - right, + 1, + col("dim"), + -4, + 9, + ); + } + s.line( + "banks", + a.center - a.width / 2, + y, + b.center - b.width / 2, + y + 8, + 1, + col("main"), + 0.65, + ); + s.line( + "banks", + a.center + a.width / 2, + y, + b.center + b.width / 2, + y + 8, + 1, + col("main"), + 0.65, + ); + } + for (const e of g.entities) { + if (e.kind === "fuel") + s.flat( + "pickups", + "diamond", + L(e.x), + L(e.y), + 5, + 3.5, + 4.5, + 4, + col("main"), + ); + else { + box( + "enemies", + L(e.x), + L(e.y), + 11, + 6, + col(e.kind === "tower" ? "secondary" : "cool"), + 3, + 6, + ); + box("enemies", L(e.x), L(e.y) - 3, 3, 5, col("secondary"), 7, 4); + } + } + ship("player", L(g.playerX), L(g.playerY), col("main")); + shots(g.shots); + shots(g.enemyShots, true); + s.health(30, 187, 16, 50, g.fuel / 1000, col("main")); + s.text(`FUEL ${Math.ceil(g.fuel / 10)}%`); + } else if (id === "iron-circuit") { + for (const wall of g.walls) + box( + "terrain", + L(wall.x), + L(wall.y), + L(wall.w), + L(wall.h), + col("dim"), + -1, + 9, + ); + for (const [x, y, w, h] of [ + [80, 8, 152, 1], + [80, 186, 152, 1], + [4, 97, 1, 178], + [156, 97, 1, 178], + ]) + box("terrain", x, y, w, h, col("cool"), -2, 5); + tank(g.player, false); + for (const b of g.bots) tank(b, true); + for (const e of g.shells) + s.flat( + "projectiles", + "sphere", + L(e.x), + L(e.y), + 12, + 1.5, + 1.5, + 1.5, + col(e.owner === "player" ? "main" : "secondary"), + ); + } else if (id === "skywater-command") { + box("terrain", 80, 171.5, 160, 41, col("dim"), -12, 4); + for (let x = 0; x < 160; x += 12) + s.line("water", x, 154, x + 7, 154, -8, col("cool"), 0.6); + for (const e of g.targets) { + const x = L(e.x), + y = L(e.y), + water = e.kind === "sub" || e.kind === "skimmer"; + if (water) { + box( + "enemies", + x, + y, + e.kind === "sub" ? 17 : 15, + 4, + col("cool"), + 2, + 4, + ); + box("enemies", x, y - 3, 5, 4, col("secondary"), 5, 3); + } else { + ship( + "enemies", + x, + y, + col("secondary"), + 5, + e.kind === "glider" ? "talon" : "invader", + Math.PI / 2, + ); + if (e.kind === "rotor") + s.line("rotors", x - 7, y - 4, x + 7, y - 4, 10, col("cool"), 1); + } + } + for (const [x, aim, hostile] of [ + [24, g.playerAim, false], + [136, g.aiAim, true], + ]) { + const v = c.DIR16[aim]; + box( + hostile ? "enemies" : "player", + x, + 181, + 11, + 5, + col(hostile ? "secondary" : "main"), + 3, + 5, + ); + s.line( + "turrets", + x, + 179, + x + (v.x * 10) / 1024, + 179 + (v.y * 10) / 1024, + 9, + col(hostile ? "secondary" : "main"), + 2, + ); + } + for (const e of g.shells) + s.flat( + "projectiles", + "sphere", + L(e.x), + L(e.y), + 12, + 1.3, + 1.3, + 1.3, + col(e.owner === "player" ? "main" : "secondary"), + ); + s.text( + `YOU ${state.score} AI ${g.aiScore} ${Math.ceil(g.timeLeft / c.TICK_RATE)}s`, + ); + } + if (!["prism-break", "rift-runner", "skywater-command"].includes(id)) + s.text(ui.paused ? "SIGNAL HELD // P TO RESUME" : ""); + s.finish(); + } + } + root.CartridgeRenderer = CartridgeRenderer; +})(window); diff --git a/CARTRIDGE_ZERO/tests/browser-smoke.js b/CARTRIDGE_ZERO/tests/browser-smoke.js index e4958cc..72e4d4c 100644 --- a/CARTRIDGE_ZERO/tests/browser-smoke.js +++ b/CARTRIDGE_ZERO/tests/browser-smoke.js @@ -16,8 +16,8 @@ function allNumbersAreFiniteIntegers(value) { (async () => { const browser = await chromium.launch({ headless: true, - executablePath: process.env.CARTRIDGE_ZERO_CHROMIUM || chromium.executablePath(), - args: ["--no-sandbox", "--disable-setuid-sandbox"], + executablePath: process.env.GAMES_CHROMIUM || process.env.CARTRIDGE_ZERO_CHROMIUM || chromium.executablePath(), + args: ["--no-sandbox", "--disable-setuid-sandbox", "--enable-unsafe-swiftshader"], }); const page = await browser.newPage({ viewport: { width: 1440, height: 1100 }, deviceScaleFactor: 1 }); const errors = []; @@ -26,7 +26,7 @@ function allNumbersAreFiniteIntegers(value) { page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`)); page.on("request", (request) => requests.push(request.url())); - const url = pathToFileURL(path.join(__dirname, "..", "index.html")).href; + const url = process.env.GAMES_TEST_ORIGIN ? `${process.env.GAMES_TEST_ORIGIN}/CARTRIDGE_ZERO/index.html` : pathToFileURL(path.join(__dirname, "..", "index.html")).href; await page.goto(url, { waitUntil: "load" }); assert.equal(await page.title(), "CARTRIDGE ZERO — Seven Deterministic Signals"); assert.equal(await page.locator("#bootLayer").isVisible(), true); @@ -67,7 +67,11 @@ function allNumbersAreFiniteIntegers(value) { ), game.id); renderMetrics[game.id] = await page.evaluate(() => { const canvas = document.querySelector("#game"); - const data = canvas.getContext("2d").getImageData(0, 0, canvas.width, canvas.height).data; + const stage = canvas.nativeStage; + stage.renderer.render(stage.scene, stage.camera); + const gl = stage.renderer.getContext(); + const data = new Uint8Array(gl.drawingBufferWidth * gl.drawingBufferHeight * 4); + gl.readPixels(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight, gl.RGBA, gl.UNSIGNED_BYTE, data); const colors = new Set(); let opaque = 0; for (let index = 0; index < data.length; index += 4) { @@ -121,7 +125,7 @@ function allNumbersAreFiniteIntegers(value) { await page.click("#colorButton"); assert.equal(await page.evaluate(() => document.activeElement.id), "game"); await page.keyboard.down("ArrowLeft"); - await page.waitForTimeout(180); + await page.waitForFunction((beforeX) => cartridgeZero.state.game.paddleX < beforeX, beforeSwitchInput); await page.keyboard.up("ArrowLeft"); assert.ok(await page.evaluate((beforeX) => cartridgeZero.state.game.paddleX < beforeX, beforeSwitchInput), "keyboard control must resume immediately after a console switch"); await page.click("#replayButton"); @@ -192,7 +196,7 @@ function allNumbersAreFiniteIntegers(value) { assert.ok(mobile.launch.bottom <= mobile.gameView.bottom + 1, "mobile launch control must fit without scrolling the boot layer"); await page.screenshot({ path: path.join(__dirname, "cartridge-zero-mobile.png"), fullPage: true }); - assert.deepEqual(requests.filter((requestUrl) => !requestUrl.startsWith("file://")), []); + assert.deepEqual(requests.filter((requestUrl) => !(process.env.GAMES_TEST_ORIGIN ? new URL(requestUrl).origin === process.env.GAMES_TEST_ORIGIN : requestUrl.startsWith("file://"))), []); assert.deepEqual(errors, []); await browser.close(); process.stdout.write("ok - seven-program boot, rendering, controls, pause, exact receipt playback, terminal ledger drain, offline boundary, and mobile console\n"); diff --git a/PIXELWARFRONT/README.md b/PIXELWARFRONT/README.md index 55a4093..e0a05b1 100644 --- a/PIXELWARFRONT/README.md +++ b/PIXELWARFRONT/README.md @@ -27,7 +27,7 @@ storage is required. serialized battlefield, checkpoint, screenshot, or save-state snapshot. - Draws every unit, building, battlefield tile, projectile, health bar, impact, and explosion procedurally, with stronger selection brackets, grounded unit - shadows, projectile trails, and a local Three.js battlefield-depth layer. + shadows, projectile trails, and native Three.js terrain, structures and units. - Synthesizes all interface, weapon, production, and ambient sounds at runtime through the Web Audio API. @@ -88,8 +88,9 @@ See [`docs/DETERMINISM.md`](docs/DETERMINISM.md) for the normative contract. | File | Responsibility | |---|---| | [`core.js`](core.js) | Procedural mission generation, economy, AI, combat, fixed-tick simulation, replay codec, and state digest | -| [`app.js`](app.js) | Selection and command controls, layered Canvas renderer, synthesized audio, replay UI, and frame scheduling | -| [`../shared/qsol-three-stage.js`](../shared/qsol-three-stage.js) | Local Three.js battlefield depth and combat pulses excluded from canonical state | +| [`app.js`](app.js) | Selection and command controls, scene synchronization, synthesized audio, replay UI, and frame scheduling | +| [`renderer.js`](renderer.js) | Native Three.js terrain, facilities, units, resources, projectiles and command indicators | +| [`../shared/qsol-native.js`](../shared/qsol-native.js) | Sole gameplay WebGL renderer, batching and lifecycle | | [`index.html`](index.html) | Offline application shell and accessible command surface | | [`style.css`](style.css) | Responsive industrial strategy interface | | [`tests/core.test.js`](tests/core.test.js) | Determinism, progression, harvesting, construction, production, combat, and replay regressions | @@ -120,3 +121,5 @@ game. Those titles are third-party trademarks; this project is not affiliated with or endorsed by their owners. Code is licensed under the repository's [MIT License](../LICENSE). + +Native renderer architecture and verification: [migration audit](../docs/NATIVE_RENDERING.md). diff --git a/PIXELWARFRONT/app.js b/PIXELWARFRONT/app.js index 2c6d8af..70febd2 100644 --- a/PIXELWARFRONT/app.js +++ b/PIXELWARFRONT/app.js @@ -6,10 +6,7 @@ const $ = (id) => document.getElementById(id); const canvas = $("game"); - const ctx = canvas.getContext("2d", { alpha: false }); - const threeStage = globalThis.QsolThree - ? globalThis.QsolThree.create({ host: $("canvasWrap"), source: canvas, preset: "warfront" }) - : { render() {}, pulse() {} }; + const presentation = new window.WarfrontRenderer(canvas, core); const STEP_MS = 1000 / core.TICK_RATE; const palettes = [ { ground: "#11181b", grid: "#243238", high: "#2b3739", low: "#0b1215", mineral: "#76b0ba", enemy: "#df6843" }, @@ -346,12 +343,10 @@ if (event.type === "hit") { hitUntil.set(event.id, state.tick + 3); addEffect("impact", event); - threeStage.pulse("impact", 0.24); } else if (event.type === "destroy") { addEffect("explosion", event); shake = Math.max(shake, event.kind === "hq" ? 16 : event.entityType === "building" ? 7 : 3); screenFlash = Math.max(screenFlash, event.kind === "hq" ? 12 : 2); - threeStage.pulse("blast", event.kind === "hq" ? 1 : 0.52); } else if (event.type === "wave") { setStatus("Hostile command pulse detected: attack wave mobilised."); } else if (event.type === "construction-complete") { @@ -544,364 +539,8 @@ if (!explicitRightClick && commandMode !== "move") setCommandMode("move"); } - function drawBackground(gameState, time) { - const seed = gameState ? gameState.seed : core.normalizeSeed("PIXEL-WARFRONT-ATTRACT"); - const blueprint = gameState ? gameState.blueprint : core.makeMissionBlueprint(seed, 1); - const palette = palettes[blueprint.biomeIndex % palettes.length]; - ctx.fillStyle = palette.ground; - ctx.fillRect(0, 0, core.WIDTH, core.HEIGHT); - const terrain = blueprint.terrain; - for (let row = 0; row < core.GRID_ROWS; row += 1) { - for (let column = 0; column < core.GRID_COLUMNS; column += 1) { - const tile = terrain[row * core.GRID_COLUMNS + column]; - const x = column * core.CELL_SIZE; - const y = row * core.CELL_SIZE + 12; - const hash = visualHash(seed, blueprint.levelSeed, row * 61 + column); - if (tile === 1) ctx.fillStyle = palette.high; - else if (tile === 2) ctx.fillStyle = palette.low; - else if (tile === 3) ctx.fillStyle = "#352922"; - else ctx.fillStyle = palette.ground; - ctx.globalAlpha = tile === 0 ? 0.22 : 0.55; - ctx.fillRect(x, y, core.CELL_SIZE, core.CELL_SIZE); - if (tile > 0 && (hash & 3) === 0) { - ctx.fillStyle = tile === 3 ? palette.enemy : palette.grid; - ctx.globalAlpha = 0.28; - ctx.fillRect(x + 5 + (hash % 9), y + 6 + ((hash >>> 8) % 9), 4 + ((hash >>> 16) % 11), 3); - } - } - } - ctx.globalAlpha = 0.42; - ctx.strokeStyle = palette.grid; - ctx.lineWidth = 1; - for (let x = 0; x <= core.WIDTH; x += core.CELL_SIZE) { - ctx.beginPath(); ctx.moveTo(x + 0.5, 0); ctx.lineTo(x + 0.5, core.HEIGHT); ctx.stroke(); - } - for (let y = 12; y <= core.HEIGHT; y += core.CELL_SIZE) { - ctx.beginPath(); ctx.moveTo(0, y + 0.5); ctx.lineTo(core.WIDTH, y + 0.5); ctx.stroke(); - } - ctx.globalAlpha = 1; - const vignette = ctx.createRadialGradient(core.WIDTH / 2, core.HEIGHT / 2, 170, core.WIDTH / 2, core.HEIGHT / 2, 620); - vignette.addColorStop(0, "rgba(0,0,0,0)"); - vignette.addColorStop(1, "rgba(0,0,0,0.5)"); - ctx.fillStyle = vignette; - ctx.fillRect(0, 0, core.WIDTH, core.HEIGHT); - } - - function healthBar(entity, x, y, width) { - if (entity.health >= entity.maxHealth || entity.maxHealth <= 0) return; - const ratio = Math.max(0, entity.health / entity.maxHealth); - ctx.fillStyle = "rgba(2,4,5,0.87)"; - ctx.fillRect(x - width / 2 - 1, y, width + 2, 5); - ctx.fillStyle = entity.team === core.PLAYER ? "#78a9b3" : "#e06945"; - ctx.fillRect(x - width / 2, y + 1, width * ratio, 3); - } - - function drawResource(resource, tick, palette) { - const x = resource.x / core.SCALE; - const y = resource.y / core.SCALE; - const ratio = resource.maxAmount ? resource.amount / resource.maxAmount : 0; - const pulse = 0.7 + ((tick + resource.phase) % 24) / 80; - ctx.save(); - ctx.translate(x, y); - ctx.globalAlpha = 0.3 + ratio * 0.7; - ctx.fillStyle = palette.mineral; - for (let index = 0; index < 6; index += 1) { - const hash = visualHash(resource.id, resource.phase, index); - const angle = (hash % 6283) / 1000; - const distance = 5 + ((hash >>> 12) % 17); - const size = 5 + ((hash >>> 21) % 7); - ctx.save(); - ctx.translate(Math.cos(angle) * distance, Math.sin(angle) * distance); - ctx.rotate(angle); - ctx.fillRect(-size / 2, -size / 2, size, size * pulse); - ctx.restore(); - } - ctx.globalAlpha = 0.24 * ratio; - ctx.fillStyle = "#dff8f4"; - ctx.beginPath(); ctx.arc(0, 0, 25 + ((tick + resource.phase) % 9), 0, Math.PI * 2); ctx.fill(); - ctx.restore(); - } - - function teamColors(team) { - return team === core.PLAYER - ? { body: "#526f77", edge: "#a4c2c7", glow: "#65a3af", dark: "#142127" } - : { body: "#7d3c31", edge: "#e58a69", glow: "#e06844", dark: "#271411" }; - } - - function drawBuilding(building, tick) { - const x = building.x / core.SCALE; - const y = building.y / core.SCALE; - const radius = building.radius / core.SCALE; - const color = teamColors(building.team); - ctx.save(); - ctx.fillStyle = "rgba(0,0,0,0.34)"; - ctx.beginPath(); - ctx.ellipse(x + radius * 0.18, y + radius * 0.34, radius * 1.05, radius * 0.48, -0.12, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - ctx.save(); - ctx.translate(x, y); - if (building.kind === "hq") { - ctx.fillStyle = color.dark; - ctx.fillRect(-radius, -radius * 0.65, radius * 2, radius * 1.3); - ctx.strokeStyle = color.edge; ctx.lineWidth = 3; - ctx.strokeRect(-radius + 2, -radius * 0.65 + 2, radius * 2 - 4, radius * 1.3 - 4); - ctx.fillStyle = color.body; - ctx.fillRect(-radius * 0.56, -radius * 0.92, radius * 1.12, radius * 1.84); - ctx.fillStyle = color.glow; - ctx.fillRect(-5, -radius * 0.72, 10, radius * 1.44); - } else if (building.kind === "relay") { - ctx.fillStyle = color.dark; ctx.fillRect(-19, -19, 38, 38); - ctx.strokeStyle = color.edge; ctx.lineWidth = 2; ctx.strokeRect(-18, -18, 36, 36); - ctx.strokeStyle = color.glow; ctx.lineWidth = 3; - ctx.beginPath(); ctx.moveTo(0, 15); ctx.lineTo(0, -27); ctx.stroke(); - ctx.globalAlpha = 0.35 + (tick % 20) / 60; - ctx.beginPath(); ctx.arc(0, -26, 7 + tick % 5, 0, Math.PI * 2); ctx.stroke(); - } else if (building.kind === "refinery") { - ctx.fillStyle = color.dark; ctx.fillRect(-28, -22, 56, 44); - ctx.strokeStyle = color.edge; ctx.lineWidth = 2; ctx.strokeRect(-27, -21, 54, 42); - ctx.fillStyle = color.body; ctx.fillRect(-21, -14, 22, 28); ctx.fillRect(7, -18, 13, 36); - ctx.fillStyle = "#73adb6"; ctx.fillRect(-15, -7, 10, 14); - } else if (building.kind === "factory") { - ctx.fillStyle = color.dark; ctx.fillRect(-33, -26, 66, 52); - ctx.strokeStyle = color.edge; ctx.lineWidth = 2; ctx.strokeRect(-32, -25, 64, 50); - ctx.fillStyle = color.body; ctx.fillRect(-24, -17, 48, 17); ctx.fillRect(-28, 8, 56, 11); - ctx.fillStyle = "#060809"; ctx.fillRect(-15, 0, 30, 25); - ctx.fillStyle = color.glow; ctx.fillRect(-10, 4, 20, 3); - } else if (building.kind === "turret") { - ctx.rotate((building.targetId || tick * 0.15) * 0.02); - ctx.fillStyle = color.dark; ctx.fillRect(-17, -17, 34, 34); - ctx.strokeStyle = color.edge; ctx.lineWidth = 2; ctx.strokeRect(-16, -16, 32, 32); - ctx.fillStyle = color.body; ctx.fillRect(-8, -8, 16, 16); - ctx.fillStyle = color.glow; ctx.fillRect(-3, -30, 6, 27); - } - - if (building.construction > 0) { - const ratio = 1 - building.construction / Math.max(1, building.constructionTotal); - ctx.globalAlpha = 0.5; - ctx.fillStyle = "#080b0d"; - ctx.fillRect(-radius, -radius, radius * 2, radius * 2 * (1 - ratio)); - ctx.globalAlpha = 0.8; - ctx.strokeStyle = "#d6a261"; - ctx.setLineDash([5, 4]); - ctx.strokeRect(-radius, -radius, radius * 2, radius * 2); - ctx.setLineDash([]); - } - ctx.restore(); - - if ((hitUntil.get(building.id) || -1) >= tick) { - ctx.save(); ctx.globalCompositeOperation = "screen"; ctx.globalAlpha = 0.35; - ctx.fillStyle = "#fff1cf"; ctx.beginPath(); ctx.arc(x, y, radius + 5, 0, Math.PI * 2); ctx.fill(); ctx.restore(); - } - healthBar(building, x, y - radius - 9, Math.max(32, radius * 1.65)); - if (building.queue.length) { - const queued = building.queue[0]; - const total = core.UNIT_TYPES[queued.kind].trainTicks; - const ratio = Math.max(0, 1 - queued.remaining / total); - ctx.fillStyle = "rgba(2,4,5,0.86)"; ctx.fillRect(x - 24, y + radius + 5, 48, 5); - ctx.fillStyle = "#d6a261"; ctx.fillRect(x - 23, y + radius + 6, 46 * ratio, 3); - } - } - - function drawUnit(unit, tick) { - const x = unit.x / core.SCALE; - const y = unit.y / core.SCALE; - const radius = unit.radius / core.SCALE; - const color = teamColors(unit.team); - ctx.save(); - ctx.fillStyle = "rgba(0,0,0,0.42)"; - ctx.beginPath(); - ctx.ellipse(x + 4, y + 7, radius * 0.9, radius * 0.42, 0, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - ctx.save(); - ctx.translate(x, y); - let angle = 0; - const target = unit.targetId && state ? [...state.units, ...state.buildings].find((entity) => entity.id === unit.targetId) : null; - if (target) angle = Math.atan2(target.y - unit.y, target.x - unit.x) + Math.PI / 2; - else if (unit.order === "move") angle = Math.atan2(unit.targetY - unit.y, unit.targetX - unit.x) + Math.PI / 2; - ctx.rotate(angle); - if (unit.kind === "drone") { - ctx.fillStyle = color.body; - ctx.fillRect(-7, -7, 14, 14); - ctx.strokeStyle = color.edge; ctx.lineWidth = 1; ctx.strokeRect(-7.5, -7.5, 15, 15); - ctx.fillStyle = unit.carry > 0 ? "#75b4bd" : color.glow; - ctx.fillRect(-3, -3, 6, 6); - ctx.fillStyle = color.dark; ctx.fillRect(-11, -3, 4, 6); ctx.fillRect(7, -3, 4, 6); - } else if (unit.kind === "ranger") { - ctx.fillStyle = color.body; - ctx.beginPath(); ctx.moveTo(0, -12); ctx.lineTo(9, 9); ctx.lineTo(0, 6); ctx.lineTo(-9, 9); ctx.closePath(); ctx.fill(); - ctx.strokeStyle = color.edge; ctx.lineWidth = 1.5; ctx.stroke(); - ctx.fillStyle = color.glow; ctx.fillRect(-2, -9, 4, 11); - } else { - ctx.fillStyle = color.dark; ctx.fillRect(-15, -12, 30, 24); - ctx.strokeStyle = color.edge; ctx.lineWidth = 2; ctx.strokeRect(-14, -11, 28, 22); - ctx.fillStyle = color.body; ctx.fillRect(-9, -9, 18, 18); - ctx.fillStyle = color.glow; ctx.fillRect(-3, -23, 6, 23); - ctx.fillStyle = "#090b0c"; ctx.fillRect(-18, -11, 5, 22); ctx.fillRect(13, -11, 5, 22); - } - ctx.restore(); - - if (selectedIds.has(unit.id)) { - const ring = radius + 7 + (tick % 4) * 0.3; - ctx.strokeStyle = "#e6ddc9"; ctx.lineWidth = 1; - ctx.beginPath(); ctx.arc(x, y, ring, 0, Math.PI * 2); ctx.stroke(); - ctx.strokeStyle = "rgba(104,174,178,0.8)"; - ctx.beginPath(); - ctx.moveTo(x - ring - 4, y); ctx.lineTo(x - ring + 3, y); - ctx.moveTo(x + ring - 3, y); ctx.lineTo(x + ring + 4, y); - ctx.moveTo(x, y - ring - 4); ctx.lineTo(x, y - ring + 3); - ctx.moveTo(x, y + ring - 3); ctx.lineTo(x, y + ring + 4); - ctx.stroke(); - } - if ((hitUntil.get(unit.id) || -1) >= tick) { - ctx.save(); ctx.globalCompositeOperation = "screen"; ctx.globalAlpha = 0.42; - ctx.fillStyle = "#fff1cf"; ctx.beginPath(); ctx.arc(x, y, radius + 5, 0, Math.PI * 2); ctx.fill(); ctx.restore(); - } - healthBar(unit, x, y - radius - 7, 24); - } - - function drawProjectile(projectile) { - const x = projectile.x / core.SCALE; - const y = projectile.y / core.SCALE; - ctx.fillStyle = projectile.team === core.PLAYER ? "#bfe7ea" : "#f07b50"; - const velocity = Math.max(1, Math.hypot(projectile.vx, projectile.vy)); - ctx.strokeStyle = projectile.team === core.PLAYER ? "rgba(104,188,194,0.58)" : "rgba(235,101,62,0.58)"; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.moveTo(x, y); - ctx.lineTo(x - projectile.vx / velocity * 10, y - projectile.vy / velocity * 10); - ctx.stroke(); - ctx.beginPath(); ctx.arc(x, y, Math.max(2, projectile.radius / core.SCALE), 0, Math.PI * 2); ctx.fill(); - ctx.strokeStyle = projectile.team === core.PLAYER ? "rgba(104,170,180,0.45)" : "rgba(234,100,62,0.45)"; - ctx.lineWidth = 3; ctx.stroke(); - } - - function drawImpact(effect, age) { - const progress = age / effect.duration; - const alpha = Math.max(0, 1 - progress); - ctx.save(); ctx.translate(effect.x, effect.y); ctx.globalCompositeOperation = "screen"; ctx.globalAlpha = alpha; - ctx.strokeStyle = "#fff0cc"; ctx.lineWidth = 1.5; - for (let index = 0; index < 5; index += 1) { - const hash = visualHash(effect.seed, index, 0x1a2b); - const angle = (hash % 6283) / 1000; - const distance = 2 + age * (0.7 + ((hash >>> 11) % 80) / 100); - ctx.beginPath(); ctx.moveTo(Math.cos(angle) * distance, Math.sin(angle) * distance); - ctx.lineTo(Math.cos(angle) * (distance + 5), Math.sin(angle) * (distance + 5)); ctx.stroke(); - } - ctx.fillStyle = "#fff"; ctx.beginPath(); ctx.arc(0, 0, 3 * alpha, 0, Math.PI * 2); ctx.fill(); ctx.restore(); - } - - function drawExplosion(effect, age) { - const progress = age / effect.duration; - const alpha = Math.max(0, 1 - progress); - const scale = effect.major ? 2.1 : Math.max(0.75, effect.radius / 25); - ctx.save(); ctx.translate(effect.x, effect.y); ctx.globalCompositeOperation = "screen"; - ctx.globalAlpha = alpha * 0.8; ctx.strokeStyle = "#f47b4e"; ctx.lineWidth = Math.max(1, 5 * (1 - progress)); - ctx.beginPath(); ctx.arc(0, 0, (4 + progress * (effect.major ? 68 : 32)) * scale, 0, Math.PI * 2); ctx.stroke(); - const count = effect.major ? 30 : 14; - for (let index = 0; index < count; index += 1) { - const hash = visualHash(effect.seed, index, 0xe710); - const angle = (hash % 6283) / 1000; - const distance = age * (0.6 + ((hash >>> 11) % 120) / 100) * scale; - const size = (2 + ((hash >>> 24) % 5)) * Math.max(0.45, alpha); - ctx.save(); ctx.translate(Math.cos(angle) * distance, Math.sin(angle) * distance + age * age * 0.01); - ctx.rotate(angle + age * (((hash >>> 8) & 1) ? 0.08 : -0.08)); - ctx.globalAlpha = alpha * 0.8; ctx.fillStyle = index % 3 === 0 ? "#e8dfc8" : index % 3 === 1 ? "#ed7348" : "#73a3aa"; - ctx.fillRect(-size / 2, -size / 2, size * 1.8, size); ctx.restore(); - } - ctx.restore(); - } - - function drawEffects(gameState) { - for (const effect of effects) { - const age = gameState.tick - effect.startTick; - if (age < 0 || age > effect.duration) continue; - if (effect.type === "impact") drawImpact(effect, age); - else drawExplosion(effect, age); - } - } - - function drawOrders(gameState) { - ctx.save(); ctx.setLineDash([5, 5]); ctx.lineWidth = 1; - for (const unit of gameState.units) { - if (!selectedIds.has(unit.id)) continue; - if (unit.order === "move") { - ctx.strokeStyle = "rgba(214,220,210,0.34)"; - ctx.beginPath(); ctx.moveTo(unit.x / core.SCALE, unit.y / core.SCALE); ctx.lineTo(unit.targetX / core.SCALE, unit.targetY / core.SCALE); ctx.stroke(); - } - } - ctx.setLineDash([]); ctx.restore(); - } - - function drawSelectionBox() { - if (!pointerDown || !dragStart || !dragCurrent) return; - const left = Math.min(dragStart.x, dragCurrent.x); - const top = Math.min(dragStart.y, dragCurrent.y); - const width = Math.abs(dragCurrent.x - dragStart.x); - const height = Math.abs(dragCurrent.y - dragStart.y); - if (width < 3 && height < 3) return; - ctx.fillStyle = "rgba(101,163,175,0.09)"; ctx.fillRect(left, top, width, height); - ctx.strokeStyle = "#9fc4c9"; ctx.lineWidth = 1; ctx.strokeRect(left + 0.5, top + 0.5, width, height); - } - - function drawOverlay(gameState) { - ctx.fillStyle = "rgba(4,7,8,0.74)"; ctx.fillRect(0, 0, core.WIDTH, 31); - ctx.fillStyle = "#d9d4c7"; ctx.font = "700 12px monospace"; ctx.textAlign = "left"; - ctx.fillText(`MISSION ${String(gameState.level).padStart(2, "0")} // ${gameState.blueprint.biome.code}`, 12, 19); - ctx.fillStyle = "#82949c"; ctx.font = "10px monospace"; ctx.textAlign = "center"; - ctx.fillText(`SEED ${core.seedHex(gameState.seed)} // SIG ${gameState.blueprint.signature}`, core.WIDTH / 2, 19); - ctx.textAlign = "right"; ctx.fillStyle = "#e2764d"; ctx.font = "700 12px monospace"; - ctx.fillText(`FLUX ${state.credits} // SCORE ${String(state.score).padStart(6, "0")}`, core.WIDTH - 12, 19); - - if (gameState.levelTick < 70) { - const alpha = Math.min(1, gameState.levelTick / 12, (70 - gameState.levelTick) / 16); - ctx.globalAlpha = Math.max(0, alpha); - ctx.fillStyle = "rgba(8,11,13,0.84)"; ctx.fillRect(285, 230, 390, 116); - ctx.strokeStyle = "#df6b44"; ctx.strokeRect(285.5, 230.5, 389, 115); - ctx.fillStyle = "#8e9aa0"; ctx.font = "11px monospace"; ctx.textAlign = "center"; - ctx.fillText(`PROCEDURAL THEATRE ${String(gameState.level).padStart(2, "0")}`, core.WIDTH / 2, 260); - ctx.fillStyle = "#e8e1d4"; ctx.font = "700 24px monospace"; ctx.fillText(gameState.blueprint.biome.name, core.WIDTH / 2, 294); - ctx.fillStyle = "#df6b44"; ctx.font = "10px monospace"; - ctx.fillText(`ENEMY RANK ${gameState.blueprint.difficulty.rank} // ${gameState.blueprint.signature}`, core.WIDTH / 2, 320); - ctx.globalAlpha = 1; - } - } - - function render(time) { - ctx.save(); - if (shake > 0) { - const x = ((visualHash(state ? state.tick : 0, shake, 1) % 9) - 4) * Math.min(1, shake / 8); - const y = ((visualHash(state ? state.tick : 0, shake, 2) % 9) - 4) * Math.min(1, shake / 8); - ctx.translate(x, y); shake -= 1; - } - drawBackground(state, time); - if (state) { - const palette = palettes[state.blueprint.biomeIndex % palettes.length]; - for (const resource of state.resources) if (resource.amount > 0) drawResource(resource, state.tick, palette); - drawOrders(state); - for (const building of state.buildings) drawBuilding(building, state.tick); - for (const unit of state.units) drawUnit(unit, state.tick); - for (const projectile of state.projectiles) drawProjectile(projectile); - drawEffects(state); - drawSelectionBox(); - drawOverlay(state); - } else { - ctx.fillStyle = "rgba(233,227,213,0.03)"; ctx.font = "900 120px sans-serif"; ctx.textAlign = "center"; - ctx.fillText("PW", core.WIDTH / 2, core.HEIGHT / 2 + 42); - } - if (screenFlash > 0) { - ctx.fillStyle = `rgba(239,224,200,${Math.min(0.25, screenFlash / 65)})`; - ctx.fillRect(0, 0, core.WIDTH, core.HEIGHT); screenFlash -= 1; - } - ctx.restore(); - threeStage.render({ - tick: state ? state.tick : 0, - speed: state ? Math.min(2, state.units.length / 18) : 0, - danger: state && state.gameOver ? 1 : 0, - activity: state ? Math.min(1, 0.18 + state.projectiles.length / 20) : 0.06, - heading: time * 0.00005, - }); + function render() { + presentation.render(state, { palette: state ? palettes[state.blueprint.biomeIndex % palettes.length] : palettes[0], selectedIds, effects, hitUntil, pointerDown, dragStart, dragCurrent, paused }); } function frame(time) { diff --git a/PIXELWARFRONT/docs/DETERMINISM.md b/PIXELWARFRONT/docs/DETERMINISM.md index 5140b67..fd93993 100644 --- a/PIXELWARFRONT/docs/DETERMINISM.md +++ b/PIXELWARFRONT/docs/DETERMINISM.md @@ -79,7 +79,7 @@ enforces the same ceiling as the decoder. ## Deliberately outside the contract -- Canvas pixels, CSS layout, selection-box styling, animation, interpolation, +- WebGL pixels, CSS layout, selection-box styling, animation, interpolation, impact sparks, explosions, and screen shake; - the transient `state.events` presentation queue and payload shape; - Web Audio timing, speaker output, and mute state; diff --git a/PIXELWARFRONT/index.html b/PIXELWARFRONT/index.html index 056fd2c..7af920f 100644 --- a/PIXELWARFRONT/index.html +++ b/PIXELWARFRONT/index.html @@ -8,7 +8,7 @@ PIXEL WARFRONT — Deterministic Command - + @@ -157,9 +157,9 @@

REPLAY CODE

- - + + diff --git a/PIXELWARFRONT/renderer.js b/PIXELWARFRONT/renderer.js new file mode 100644 index 0000000..2f3f058 --- /dev/null +++ b/PIXELWARFRONT/renderer.js @@ -0,0 +1,353 @@ +(function (root) { + "use strict"; + class WarfrontRenderer { + constructor(canvas, core) { + this.core = core; + this.stage = new root.QsolNative.Stage(canvas, { + width: core.WIDTH, + height: core.HEIGHT, + }); + this.stage.oblique(0.35, 0.5); + } + render(state, ui) { + const s = this.stage, + c = this.core; + s.begin(ui.palette?.ground || "#11181b"); + if (!state) { + s.text("PIXEL WARFRONT // COMMAND THEATRE READY"); + s.finish(); + return; + } + const p = ui.palette, + scale = c.SCALE; + for (let row = 0; row < c.GRID_ROWS; row++) + for (let col = 0; col < c.GRID_COLUMNS; col++) { + const tile = state.blueprint.terrain[row * c.GRID_COLUMNS + col], + size = c.CELL_SIZE; + s.flat( + "terrain", + "box", + (col + 0.5) * size, + (row + 0.5) * size + 12, + -12, + size - 1, + size - 1, + tile === 1 ? 10 : 3, + tile === 1 + ? p.high + : tile === 2 + ? p.low + : tile === 3 + ? "#241e19" + : p.ground, + ); + } + for (const e of state.resources) + if (e.amount > 0) { + const ratio = e.amount / e.maxAmount; + for (let i = 0; i < 6; i++) { + const a = i * 2.399 + e.phase, + r = 5 + i * 2; + s.item( + "resources", + "diamond", + e.x / scale + Math.cos(a) * r, + -e.y / scale - Math.sin(a) * r, + -1, + 4 + ratio * 3, + 5 + ratio * 4, + 8 + ratio * 8, + p.mineral, + 0.4, + 0.3, + a, + ); + } + } + const colors = (e) => + e.team === c.PLAYER + ? { + body: "#526f77", + edge: "#a4c2c7", + glow: "#65a3af", + dark: "#142127", + } + : { + body: "#7d3c31", + edge: "#e58a69", + glow: "#e06844", + dark: "#271411", + }; + for (const e of state.buildings) { + const x = e.x / scale, + y = e.y / scale, + r = e.radius / scale, + color = colors(e), + progress = + e.construction > 0 + ? 1 - e.construction / Math.max(1, e.constructionTotal) + : 1; + s.flat("buildings", "bevel", x, y, 0, r * 1.8, r * 1.8, 5, color.dark); + const h = 8 + progress * 22; + s.flat( + "shadows", + "box", + x + 7, + y + 10, + -3, + r * 1.8, + r * 1.7, + 1, + "#080d0e", + 0, + "glow", + ); + if (e.kind === "relay") { + s.item( + "buildings", + "cylinder", + x, + -y, + h / 2, + r * 0.5, + h, + r * 0.5, + color.body, + Math.PI / 2, + ); + s.item( + "buildings", + "ring", + x, + -y, + h + 4, + r * 0.75, + r * 0.75, + 2, + color.glow, + 0.65, + 0, + s.reducedMotion ? 0 : state.tick * 0.01, + "glow", + ); + } else if (e.kind === "refinery") { + for (const dx of [-r * 0.4, r * 0.4]) + s.item( + "buildings", + "cylinder", + x + dx, + -y, + h / 2, + r * 0.35, + h, + r * 0.35, + color.body, + Math.PI / 2, + ); + s.flat("buildings", "bevel", x, y, h, r * 1.4, 4, 3, color.glow); + } else if (e.kind === "turret") { + s.flat("buildings", "bevel", x, y, 8, r, r, 12, color.body); + const target = [...state.units, ...state.buildings].find( + (t) => t.id === e.targetId, + ), + angle = target ? Math.atan2(target.y - e.y, target.x - e.x) : 0; + s.line( + "turrets", + x, + y, + x + Math.cos(angle) * r * 1.7, + y + Math.sin(angle) * r * 1.7, + 18, + color.glow, + 5, + ); + } else { + s.flat( + "buildings", + "bevel", + x, + y, + h / 2, + r * 1.5, + r * 1.3, + h, + color.body, + ); + s.flat( + "buildings", + "bevel", + x, + y - r * 0.45, + h / 2 + 5, + r * 1.1, + r * 0.35, + h, + color.edge, + ); + s.flat( + "buildings", + "bevel", + x, + y + r * 0.3, + h / 2 + 2, + r * 0.6, + r * 0.65, + h + 1, + color.dark, + ); + if (e.kind === "hq") + s.item("buildings", "diamond", x, -y, h + 6, 9, 9, 12, color.glow); + } + if (e.construction > 0) + s.health(x, y + r + 7, 45, r * 2, progress, "#d6a261"); + if (e.queue.length) { + const q = e.queue[0]; + s.health( + x, + y + r + 13, + 45, + 48, + 1 - q.remaining / c.UNIT_TYPES[q.kind].trainTicks, + "#d6a261", + ); + } + if (e.health < e.maxHealth) + s.health(x, y - r - 9, 45, r * 2, e.health / e.maxHealth, color.glow); + if (ui.selectedIds.has(e.id)) + s.ring("selection", x, y, 1, r + 7, "#e6ddc9"); + if ((ui.hitUntil.get(e.id) || -1) >= state.tick) + s.ring("impacts", x, y, 30, r + 2, "#fff1cf"); + } + for (const e of state.units) { + const x = e.x / scale, + y = e.y / scale, + r = e.radius / scale, + color = colors(e); + const target = [...state.units, ...state.buildings].find( + (t) => t.id === e.targetId, + ); + const a = target + ? Math.atan2(target.y - e.y, target.x - e.x) + Math.PI / 2 + : e.order === "move" + ? Math.atan2(e.targetY - e.y, e.targetX - e.x) + Math.PI / 2 + : 0; + s.flat( + "shadows", + "sphere", + x + 5, + y + 7, + -3, + r * 1.15, + r * 0.7, + 1, + "#080d0e", + 0, + "glow", + ); + if (e.kind === "ranger") { + s.flat("units", "dart", x, y, 7, 9, 12, 14, color.body, a); + s.flat("units", "diamond", x, y, 13, 2, 6, 3, color.glow, a); + } else if (e.kind === "drone") { + s.item("units", "rock", x, -y, 8, 9, 9, 6, color.body, 0.3, 0.3, -a); + s.flat( + "units", + "bevel", + x, + y, + 15, + 5, + 5, + 3, + e.carry > 0 ? p.mineral : color.glow, + ); + } else { + s.flat("units", "bevel", x, y, 4, 27, 22, 8, color.dark, a); + s.flat("units", "bevel", x, y, 10, 18, 17, 8, color.body, a); + s.line( + "turrets", + x, + y, + x + Math.sin(a) * 23, + y - Math.cos(a) * 23, + 17, + color.glow, + 5, + ); + } + if (ui.selectedIds.has(e.id)) { + s.ring("selection", x, y, 0, r + 7, "#e6ddc9"); + if (e.order === "move") { + s.line( + "orders", + x, + y, + e.targetX / scale, + e.targetY / scale, + 0, + "#80978e", + 1, + ); + s.ring( + "orders", + e.targetX / scale, + e.targetY / scale, + 0, + 5, + "#b3cabf", + ); + } + } + if (e.health < e.maxHealth) + s.health(x, y - r - 7, 35, 24, e.health / e.maxHealth, color.glow); + if ((ui.hitUntil.get(e.id) || -1) >= state.tick) + s.ring("impacts", x, y, 24, r + 3, "#fff1cf"); + } + for (const e of state.projectiles) { + const x = e.x / scale, + y = e.y / scale, + v = Math.max(1, Math.hypot(e.vx, e.vy)), + color = e.team === c.PLAYER ? "#bfe7ea" : "#f07b50"; + s.line( + "projectiles", + x, + y, + x - (e.vx / v) * 10, + y - (e.vy / v) * 10, + 18, + color, + 2, + ); + s.flat( + "projectiles", + "sphere", + x, + y, + 18, + Math.max(2, e.radius / scale), + 2, + 2, + color, + 0, + "glow", + ); + } + s.effects(ui.effects, state.tick); + if (ui.pointerDown && ui.dragStart && ui.dragCurrent) { + const a = ui.dragStart, + b = ui.dragCurrent; + for (const [x1, y1, x2, y2] of [ + [a.x, a.y, b.x, a.y], + [b.x, a.y, b.x, b.y], + [b.x, b.y, a.x, b.y], + [a.x, b.y, a.x, a.y], + ]) + s.line("selection", x1, y1, x2, y2, 55, "#9fc4c9", 1); + } + s.text( + `MISSION ${state.level} // ${state.blueprint.biome.name} // ${state.blueprint.signature}`, + ); + s.finish(); + } + } + root.WarfrontRenderer = WarfrontRenderer; +})(window); diff --git a/PIXELWARFRONT/tests/browser-smoke.js b/PIXELWARFRONT/tests/browser-smoke.js index 4ab2f05..895c04d 100644 --- a/PIXELWARFRONT/tests/browser-smoke.js +++ b/PIXELWARFRONT/tests/browser-smoke.js @@ -8,8 +8,8 @@ const core = require("../core.js"); (async () => { const browser = await chromium.launch({ headless: true, - executablePath: process.env.PIXELWARFRONT_CHROMIUM || chromium.executablePath(), - args: ["--no-sandbox", "--disable-setuid-sandbox"], + executablePath: process.env.GAMES_CHROMIUM || process.env.PIXELWARFRONT_CHROMIUM || chromium.executablePath(), + args: ["--no-sandbox", "--disable-setuid-sandbox", "--enable-unsafe-swiftshader"], }); const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 }); const errors = []; @@ -18,7 +18,7 @@ const core = require("../core.js"); page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`)); page.on("request", (request) => requests.push(request.url())); - const url = `file://${path.join(__dirname, "..", "index.html")}`; + const url = process.env.GAMES_TEST_ORIGIN ? `${process.env.GAMES_TEST_ORIGIN}/PIXELWARFRONT/index.html` : `file://${path.join(__dirname, "..", "index.html")}`; await page.goto(url, { waitUntil: "load" }); assert.equal(await page.title(), "PIXEL WARFRONT — Deterministic Command"); assert.equal(await page.locator("#introLayer").isVisible(), true); @@ -44,8 +44,9 @@ const core = require("../core.js"); const box = await page.locator("#game").boundingBox(); assert.ok(box, "battlefield canvas must be visible"); const world = (x, y) => ({ x: box.x + x / core.WIDTH * box.width, y: box.y + y / core.HEIGHT * box.height }); - const selectStart = world(142, 250); - const selectEnd = world(220, 348); + // Include drones that have already begun their autonomous gather route. + const selectStart = world(100, 170); + const selectEnd = world(310, 455); await page.mouse.move(selectStart.x, selectStart.y); await page.mouse.down(); await page.mouse.move(selectEnd.x, selectEnd.y, { steps: 8 }); @@ -79,7 +80,7 @@ const core = require("../core.js"); await page.click("#watchReplayButton"); await page.waitForFunction(() => document.querySelector("#runMode").textContent.startsWith("REPLAY")); assert.equal(await page.locator("#replayDialog").evaluate((element) => element.open), false); - await page.waitForFunction(() => document.querySelector("#messageTitle").textContent === "REPLAY COMPLETE", null, { timeout: 8000 }); + await page.waitForFunction(() => document.querySelector("#messageTitle").textContent === "REPLAY COMPLETE", null, { timeout: Math.max(15000, decoded.ticks / core.TICK_RATE * 2000 + 5000) }); await page.locator("#messageLayer").waitFor({ state: "visible" }); await page.setViewportSize({ width: 390, height: 844 }); @@ -93,7 +94,7 @@ const core = require("../core.js"); assert.ok(mobile.content <= mobile.viewport + 1, `mobile layout overflows by ${mobile.content - mobile.viewport}px`); await page.screenshot({ path: path.join(__dirname, "pixelwarfront-mobile.png"), fullPage: true }); - const externalRequests = requests.filter((requestUrl) => !requestUrl.startsWith("file://")); + const externalRequests = requests.filter((requestUrl) => !(process.env.GAMES_TEST_ORIGIN ? new URL(requestUrl).origin === process.env.GAMES_TEST_ORIGIN : requestUrl.startsWith("file://"))); assert.deepEqual(externalRequests, []); assert.deepEqual(errors, []); await browser.close(); diff --git a/PIXELWARFRONT/tests/offline.test.js b/PIXELWARFRONT/tests/offline.test.js index d73ed70..b9baeef 100644 --- a/PIXELWARFRONT/tests/offline.test.js +++ b/PIXELWARFRONT/tests/offline.test.js @@ -21,9 +21,6 @@ assert.match(app, /core\.decodeReplay/); assert.match(app, /core\.tryRecordCommands/); assert.match(app, /paused \|\| state\.missionWon \|\| state\.gameOver/); assert.match(app, /const commandLocked = mode !== "live" \|\| state\.missionWon \|\| state\.gameOver/); -assert.match(app, /function drawBuilding/); -assert.match(app, /function drawUnit/); -assert.match(app, /function drawExplosion/); assert.match(app, /function isInteractiveTarget/); for (const relative of ["core.js", "app.js", "style.css"]) { @@ -31,3 +28,8 @@ for (const relative of ["core.js", "app.js", "style.css"]) { } process.stdout.write("ok - offline RTS shell has no remote runtime dependency\n"); + +const renderer = fs.readFileSync(path.join(root, "renderer.js"), "utf8"); +assert.doesNotMatch(app + renderer, /getContext\s*\(\s*["']2d["']/); +assert.match(renderer, /QsolNative\.Stage/); +assert.match(renderer, /s\.finish\(\)/); diff --git a/README.md b/README.md index 2742b0a..eb221ff 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,12 @@ Every project is designed for local, offline play with no account or telemetry. | Game | Runtime | Status | Launch / build | |---|---|---|---| | [SIGNAL BREACH](SIGNAL_BREACH/) | Offline Three.js + Rust/Wasm | Playable tactical twin-stick shooter | Open [`SIGNAL_BREACH/index.html`](SIGNAL_BREACH/index.html) | -| [CARTRIDGE ZERO: Seven Signals](CARTRIDGE_ZERO/) | Offline Canvas + Three.js | Polished deterministic seven-game anthology | Open [`CARTRIDGE_ZERO/index.html`](CARTRIDGE_ZERO/index.html) | -| [VECTOR ZERO](VECTOR_ZERO/) | Offline Canvas + Three.js | High-visibility deterministic 6DOF shooter | Open [`VECTOR_ZERO/index.html`](VECTOR_ZERO/index.html) | -| [BLACKSTAR AGA](BLACKSTAR_AGA/) | Offline Canvas + Three.js | Four-mission FPS with expanded arsenal and opposition | Open [`BLACKSTAR_AGA/index.html`](BLACKSTAR_AGA/index.html) | -| [PIXEL WARFRONT: Deterministic Command](PIXELWARFRONT/) | Offline Canvas + Three.js | Polished deterministic RTS | Open [`PIXELWARFRONT/index.html`](PIXELWARFRONT/index.html) | -| [SEEDSTORM: Deterministic Strike](SEEDSTORM/) | Offline Canvas + Three.js | Polished deterministic vertical shooter | Open [`SEEDSTORM/index.html`](SEEDSTORM/index.html) | -| [INERTIA ZERO](SUBSPACE/) | Offline Canvas + Three.js | Retuned near-Newtonian arena combat | Open [`SUBSPACE/index.html`](SUBSPACE/index.html) | +| [CARTRIDGE ZERO: Seven Signals](CARTRIDGE_ZERO/) | Offline native Three.js/WebGL | Polished deterministic seven-game anthology | Open [`CARTRIDGE_ZERO/index.html`](CARTRIDGE_ZERO/index.html) | +| [VECTOR ZERO](VECTOR_ZERO/) | Offline native Three.js/WebGL | High-visibility deterministic 6DOF shooter | Open [`VECTOR_ZERO/index.html`](VECTOR_ZERO/index.html) | +| [BLACKSTAR AGA](BLACKSTAR_AGA/) | Offline native Three.js/WebGL | Four-mission FPS with expanded arsenal and opposition | Open [`BLACKSTAR_AGA/index.html`](BLACKSTAR_AGA/index.html) | +| [PIXEL WARFRONT: Deterministic Command](PIXELWARFRONT/) | Offline native Three.js/WebGL | Polished deterministic RTS | Open [`PIXELWARFRONT/index.html`](PIXELWARFRONT/index.html) | +| [SEEDSTORM: Deterministic Strike](SEEDSTORM/) | Offline native Three.js/WebGL | Polished deterministic vertical shooter | Open [`SEEDSTORM/index.html`](SEEDSTORM/index.html) | +| [INERTIA ZERO](SUBSPACE/) | Offline native Three.js/WebGL | Retuned near-Newtonian arena combat | Open [`SUBSPACE/index.html`](SUBSPACE/index.html) | | [TERNARY DRIFT — Web Edition](TERNARYDRIFT/web/) | Offline Three.js / HTML / CSS / JavaScript | Playable space-trading and combat sandbox | Open [`TERNARYDRIFT/web/index.html`](TERNARYDRIFT/web/index.html) locally | | [TERNARY DRIFT — Native Edition](TERNARYDRIFT/) | C99 / Win32 | Playable floppy-sized vertical slice | [Build and run `TERNARY.EXE`](TERNARYDRIFT/README.md#build) | @@ -57,11 +57,22 @@ contains no HTML, JavaScript, WebAssembly, SDL, or external game runtime. ## Shared rendering boundary -The original deterministic Canvas simulations remain authoritative. A reusable -Three.js stage under [`shared/`](shared/) adds depth, atmosphere, and event pulses -without changing replay state. SIGNAL BREACH and the Ternary Drift web edition -use Three.js as their primary renderer; SIGNAL BREACH also packages the bounded -Rust/WebAssembly sampling approach from QSOLKCB/GALAXY for direct-file play. +All eight browser titles use Three.js for their **primary gameplay world**. +The six former Canvas games now use one existing gameplay canvas, a WebGLRenderer, +a Scene and a game-specific camera. Their simulation state drives native meshes, +instanced geometry and world-space effects; there is no Canvas gameplay renderer, +decorative second canvas, or Canvas texture wrapped in a plane. + +The shared native infrastructure in [`shared/qsol-native.js`](shared/qsol-native.js) +handles GPU batches, resize, pixel ratio, reduced motion, context recovery and +resource disposal. Each game's `renderer.js` owns its visual design and the mapping +from authoritative state to scene entities. HTML HUDs and SVG tactical maps remain +conventional interface elements. No 2D canvas is used by these six games. + +See [the per-game audit and migration architecture](docs/NATIVE_RENDERING.md) +for scene inventories, camera choices, validation commands and the removal test. +SIGNAL BREACH and Ternary Drift's web edition retain their existing native Three.js +renderers. The native Windows edition remains a separate software-rendered target. ## License diff --git a/SEEDSTORM/README.md b/SEEDSTORM/README.md index e4adf12..ea44205 100644 --- a/SEEDSTORM/README.md +++ b/SEEDSTORM/README.md @@ -25,7 +25,8 @@ storage is required. - Records only the seed and run-length-encoded input masks. Replays do not contain serialized world state, checkpoints, screenshots, or save-state snapshots. - Synthesizes its sound effects and sparse score at runtime with the Web Audio API. - All visual assets are drawn procedurally on the Canvas 2D surface. + Aircraft silhouettes are extruded Three.js geometry; projectiles, pickups, + health bars, impacts and industrial terrain are native WebGL objects. ## Controls @@ -64,8 +65,9 @@ limitations. | File | Responsibility | |---|---| | [`core.js`](core.js) | Pure deterministic level generation, fixed-tick simulation, replay codec, and state digest | -| [`app.js`](app.js) | Browser controls, layered Canvas 2D renderer, synthesized audio, replay UI, and frame scheduling | -| [`../shared/qsol-three-stage.js`](../shared/qsol-three-stage.js) | Local Three.js star-depth and event pulses outside canonical simulation state | +| [`app.js`](app.js) | Browser controls, scene synchronization, synthesized audio, replay UI, and frame scheduling | +| [`renderer.js`](renderer.js) | Native aircraft, enemies, bullets, pickups, world indicators and layered terrain | +| [`../shared/qsol-native.js`](../shared/qsol-native.js) | Sole gameplay WebGL renderer, batching and lifecycle | | [`index.html`](index.html) | Offline application shell and accessibility structure | | [`style.css`](style.css) | Responsive industrial arcade interface | | [`tests/core.test.js`](tests/core.test.js) | Golden digest, determinism, collision events, progression, replay, and input regressions | @@ -109,3 +111,5 @@ third-party trademark; this project is not affiliated with or endorsed by its owners. Code is licensed under the repository's [MIT License](../LICENSE). + +Native renderer architecture and verification: [migration audit](../docs/NATIVE_RENDERING.md). diff --git a/SEEDSTORM/app.js b/SEEDSTORM/app.js index 1af53c1..a48ee96 100644 --- a/SEEDSTORM/app.js +++ b/SEEDSTORM/app.js @@ -6,10 +6,7 @@ const $ = (id) => document.getElementById(id); const canvas = $("game"); - const ctx = canvas.getContext("2d", { alpha: false }); - const threeStage = globalThis.QsolThree - ? globalThis.QsolThree.create({ host: $("canvasWrap"), source: canvas, preset: "seedstorm" }) - : { render() {}, pulse() {} }; + const presentation = new window.SeedstormRenderer(canvas, core); const STEP_MS = 1000 / core.TICK_RATE; const palettes = [ { sky: "#091015", deep: "#111a20", grid: "#293943", ground: "#7d573f", accent: "#d46a43" }, @@ -333,21 +330,17 @@ if (event.type === "enemy-hit") { enemyHitUntil.set(event.id, state.tick + 4); addVisualEffect("impact", event); - threeStage.pulse("impact", 0.22); } else if (event.type === "enemy-down") { addVisualEffect("explosion", event); visualFlash = Math.max(visualFlash, 2); shake = Math.max(shake, 2); - threeStage.pulse("blast", 0.42); } else if (event.type === "boss-down") { addVisualEffect("explosion", event); visualFlash = Math.max(visualFlash, 14); shake = Math.max(shake, 18); - threeStage.pulse("blast", 1); } else if (event.type === "bomb") { visualFlash = Math.max(visualFlash, 18); shake = Math.max(shake, 12); - threeStage.pulse("blast", 0.9); } else if (event.type === "player-hit") { visualFlash = 12; shake = 16; @@ -416,449 +409,8 @@ } } - function drawBackground(gameState, time) { - const level = gameState ? gameState.level : 1; - const seed = gameState ? gameState.seed : core.normalizeSeed("SEEDSTORM-ATTRACT"); - const levelTick = gameState ? gameState.levelTick : Math.floor(time / 16); - const blueprint = gameState ? gameState.blueprint : core.makeLevelBlueprint(seed, 1); - const palette = palettes[blueprint.biomeIndex % palettes.length]; - const gradient = ctx.createLinearGradient(0, 0, 0, core.HEIGHT); - gradient.addColorStop(0, palette.sky); - gradient.addColorStop(1, palette.deep); - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, core.WIDTH, core.HEIGHT); - - const scroll = Math.floor(levelTick * (1.1 + Math.min(level, 12) * 0.06)); - const cell = 72; - const firstRow = Math.floor(scroll / cell) - 1; - const offset = scroll % cell; - - ctx.lineWidth = 1; - for (let row = firstRow; row < firstRow + 13; row += 1) { - const y = (row - firstRow) * cell + offset - cell * 2; - const hash = visualHash(seed, blueprint.levelSeed, row); - ctx.strokeStyle = palette.grid; - ctx.globalAlpha = 0.4 + ((hash >>> 4) % 30) / 100; - ctx.beginPath(); - ctx.moveTo(36, y); - ctx.lineTo(core.WIDTH - 36, y); - ctx.stroke(); - - const side = hash & 1 ? 1 : -1; - const structureWidth = 20 + ((hash >>> 8) % 72); - const structureX = side > 0 ? core.WIDTH - structureWidth - 12 : 12; - ctx.fillStyle = palette.ground; - ctx.globalAlpha = 0.11 + ((hash >>> 16) % 14) / 100; - ctx.fillRect(structureX, y + 9, structureWidth, 34 + ((hash >>> 20) % 26)); - - if ((hash >>> 3) % 4 === 0) { - ctx.strokeStyle = palette.accent; - ctx.globalAlpha = 0.22; - ctx.strokeRect(structureX + 4, y + 14, Math.max(5, structureWidth - 8), 8); - } - } - - ctx.globalAlpha = 0.24; - ctx.strokeStyle = palette.grid; - ctx.setLineDash([14, 18]); - ctx.beginPath(); - ctx.moveTo(120, 0); - ctx.lineTo(120, core.HEIGHT); - ctx.moveTo(core.WIDTH - 120, 0); - ctx.lineTo(core.WIDTH - 120, core.HEIGHT); - ctx.stroke(); - ctx.setLineDash([]); - ctx.globalAlpha = 1; - - const vignette = ctx.createRadialGradient(core.WIDTH / 2, core.HEIGHT / 2, 120, core.WIDTH / 2, core.HEIGHT / 2, 430); - vignette.addColorStop(0, "rgba(0,0,0,0)"); - vignette.addColorStop(1, "rgba(0,0,0,0.55)"); - ctx.fillStyle = vignette; - ctx.fillRect(0, 0, core.WIDTH, core.HEIGHT); - } - - function pathPolygon(points, fill, stroke, lineWidth) { - ctx.beginPath(); - ctx.moveTo(points[0][0], points[0][1]); - for (let i = 1; i < points.length; i += 1) ctx.lineTo(points[i][0], points[i][1]); - ctx.closePath(); - ctx.fillStyle = fill; - ctx.fill(); - if (stroke) { - ctx.strokeStyle = stroke; - ctx.lineWidth = lineWidth || 1; - ctx.stroke(); - } - } - - function drawPlayer(player, tick) { - if (player.invulnerable > 0 && Math.floor(tick / 4) % 2 === 0) ctx.globalAlpha = 0.34; - const x = player.x / core.SCALE; - const y = player.y / core.SCALE; - ctx.save(); - ctx.globalCompositeOperation = "screen"; - const trail = 22 + (tick % 5) * 2; - const engineGradient = ctx.createLinearGradient(x, y + 8, x, y + trail + 22); - engineGradient.addColorStop(0, "rgba(232,159,78,0.52)"); - engineGradient.addColorStop(1, "rgba(82,135,137,0)"); - ctx.fillStyle = engineGradient; - ctx.beginPath(); - ctx.moveTo(x - 8, y + 8); ctx.lineTo(x + 8, y + 8); ctx.lineTo(x, y + trail + 22); ctx.closePath(); - ctx.fill(); - ctx.restore(); - ctx.save(); - ctx.translate(x, y); - pathPolygon([[0, -18], [7, -5], [18, 7], [7, 8], [4, 17], [0, 12], [-4, 17], [-7, 8], [-18, 7], [-7, -5]], "#d9e0dc", "#10171b", 2); - pathPolygon([[0, -12], [5, 4], [0, 10], [-5, 4]], "#527b88", "#99b7bd", 1); - ctx.fillStyle = "#ef7047"; - ctx.fillRect(-7, 12, 4, 9 + (tick % 4)); - ctx.fillRect(3, 12, 4, 9 + ((tick + 2) % 4)); - if (heldInput & core.INPUT.FOCUS) { - ctx.strokeStyle = "rgba(233,227,213,0.7)"; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(0, 0, 10, 0, Math.PI * 2); - ctx.stroke(); - } - ctx.restore(); - ctx.globalAlpha = 1; - } - - function drawEnemy(enemy, tick) { - const x = enemy.x / core.SCALE; - const y = enemy.y / core.SCALE; - const radius = enemy.radius / core.SCALE; - ctx.save(); - ctx.fillStyle = "rgba(0,0,0,0.34)"; - ctx.beginPath(); - ctx.ellipse(x + 5, y + radius * 0.35, radius * 0.82, radius * 0.34, 0, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - ctx.save(); - ctx.translate(x, y); - if (enemy.kind === "scout") { - pathPolygon([[0, 14], [12, -9], [4, -6], [0, -15], [-4, -6], [-12, -9]], "#ad5339", "#ef916e", 1); - } else if (enemy.kind === "wing") { - pathPolygon([[0, 14], [18, -4], [8, -8], [0, -14], [-8, -8], [-18, -4]], "#8f9aa0", "#d7d1c4", 1); - ctx.fillStyle = "#d05d3b"; - ctx.fillRect(-3, -3, 6, 9); - } else if (enemy.kind === "turret") { - ctx.rotate((enemy.pathSeed % 8) * Math.PI / 4); - pathPolygon([[0, -16], [12, -12], [16, 0], [12, 12], [0, 16], [-12, 12], [-16, 0], [-12, -12]], "#4f5e64", "#9ca9ad", 2); - ctx.fillStyle = "#d86642"; - ctx.fillRect(-4, -4, 8, 12); - } else if (enemy.kind === "bomber") { - pathPolygon([[0, 20], [22, 8], [18, -11], [7, -8], [0, -20], [-7, -8], [-18, -11], [-22, 8]], "#6b5146", "#c18b72", 2); - ctx.fillStyle = "#d9d1c2"; - ctx.fillRect(-5, -6, 10, 15); - } else if (enemy.kind === "spinner") { - ctx.rotate((tick + enemy.id * 7) * 0.025); - ctx.strokeStyle = "#d8d3c7"; - ctx.lineWidth = 4; - ctx.beginPath(); - ctx.moveTo(-18, 0); - ctx.lineTo(18, 0); - ctx.moveTo(0, -18); - ctx.lineTo(0, 18); - ctx.stroke(); - ctx.fillStyle = "#ce6040"; - ctx.beginPath(); - ctx.arc(0, 0, 8, 0, Math.PI * 2); - ctx.fill(); - } else if (enemy.kind === "boss") { - ctx.rotate(Math.sin(tick * 0.015) * 0.04); - pathPolygon([[0, radius], [48, 30], [radius, -6], [44, -22], [25, -50], [0, -radius], [-25, -50], [-44, -22], [-radius, -6], [-48, 30]], "#343f45", "#a5b0b2", 3); - pathPolygon([[0, 38], [24, 8], [13, -28], [0, -42], [-13, -28], [-24, 8]], "#8e3f30", "#ef7b54", 2); - ctx.fillStyle = "#e7dfcf"; - ctx.fillRect(-7, -7, 14, 25); - } - ctx.restore(); - - if (enemy.kind === "boss" || radius >= 19) { - ctx.save(); - ctx.globalAlpha = 0.35 + Math.sin((tick + enemy.id) * 0.08) * 0.12; - ctx.strokeStyle = enemy.kind === "boss" ? "#e58a62" : "#b9c7c2"; - ctx.setLineDash([4, 5]); - ctx.beginPath(); - ctx.arc(x, y, radius + 8, 0, Math.PI * 2); - ctx.stroke(); - ctx.setLineDash([]); - ctx.restore(); - } - - if ((enemyHitUntil.get(enemy.id) || -1) >= tick) { - ctx.save(); - ctx.globalCompositeOperation = "screen"; - ctx.globalAlpha = 0.34; - ctx.fillStyle = "#fff4dc"; - ctx.beginPath(); - ctx.arc(x, y, radius + 4, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - } - - drawEnemyHealth(enemy, x, y, radius); - } - - function drawEnemyHealth(enemy, x, y, radius) { - if (enemy.kind === "boss" || enemy.health >= enemy.maxHealth || enemy.maxHealth <= 0) return; - const width = Math.max(24, Math.min(52, radius * 2.25)); - const ratio = Math.max(0, Math.min(1, enemy.health / enemy.maxHealth)); - const left = x - width / 2; - const top = y - radius - 10; - ctx.fillStyle = "rgba(3,5,6,0.88)"; - ctx.fillRect(left - 1, top - 1, width + 2, 5); - ctx.fillStyle = ratio > 0.5 ? "#8fb18a" : ratio > 0.25 ? "#d9a25b" : "#e36543"; - ctx.fillRect(left, top, width * ratio, 3); - ctx.strokeStyle = "rgba(225,224,214,0.45)"; - ctx.lineWidth = 1; - ctx.strokeRect(left - 0.5, top - 0.5, width + 1, 4); - } - - function drawImpact(effect, age) { - const progress = age / effect.duration; - const alpha = Math.max(0, 1 - progress); - const count = 6; - ctx.save(); - ctx.translate(effect.x, effect.y); - ctx.globalCompositeOperation = "screen"; - ctx.globalAlpha = alpha; - ctx.strokeStyle = "#fff1cc"; - ctx.lineWidth = 1.5; - for (let index = 0; index < count; index += 1) { - const hash = visualHash(effect.seed, index, 0x1a2b3c); - const angle = (hash % 6283) / 1000; - const speed = 0.7 + ((hash >>> 12) % 90) / 100; - const distance = 2 + age * speed; - const length = 3 + ((hash >>> 22) % 5); - const x = Math.cos(angle) * distance; - const y = Math.sin(angle) * distance; - ctx.beginPath(); - ctx.moveTo(x, y); - ctx.lineTo(x + Math.cos(angle) * length, y + Math.sin(angle) * length); - ctx.stroke(); - } - ctx.fillStyle = "#ffffff"; - ctx.beginPath(); - ctx.arc(0, 0, Math.max(1, 4 * alpha), 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - } - - function drawExplosion(effect, age) { - const progress = age / effect.duration; - const alpha = Math.max(0, 1 - progress); - const scale = effect.boss ? 2.35 : Math.max(0.8, effect.radius / 16); - const particleCount = effect.boss ? 30 : 14; - ctx.save(); - ctx.translate(effect.x, effect.y); - ctx.globalCompositeOperation = "screen"; - - const ringRadius = (5 + progress * (effect.boss ? 92 : 34)) * scale; - ctx.globalAlpha = alpha * 0.82; - ctx.strokeStyle = "#ff8254"; - ctx.lineWidth = Math.max(1, (1 - progress) * 6 * scale); - ctx.beginPath(); - ctx.arc(0, 0, ringRadius, 0, Math.PI * 2); - ctx.stroke(); - - ctx.globalAlpha = Math.max(0, 0.72 - progress * 1.2); - ctx.fillStyle = "#fff2cf"; - ctx.beginPath(); - ctx.arc(0, 0, (1 - progress) * 15 * scale, 0, Math.PI * 2); - ctx.fill(); - - for (let index = 0; index < particleCount; index += 1) { - const hash = visualHash(effect.seed, index, 0xe710de); - const angle = (hash % 6283) / 1000; - const speed = (0.65 + ((hash >>> 11) % 130) / 100) * scale; - const distance = age * speed; - const x = Math.cos(angle) * distance; - const y = Math.sin(angle) * distance + age * age * 0.008; - const size = (2 + ((hash >>> 24) % 5)) * Math.max(0.45, alpha); - ctx.save(); - ctx.translate(x, y); - ctx.rotate(angle + age * (((hash >>> 8) & 1) ? 0.08 : -0.08)); - ctx.globalAlpha = alpha * (0.45 + ((hash >>> 20) % 50) / 100); - ctx.fillStyle = index % 3 === 0 ? "#e8dfc9" : index % 3 === 1 ? "#ff7548" : "#7da7ad"; - ctx.fillRect(-size / 2, -size / 2, size * 1.8, size); - ctx.restore(); - } - - if (effect.boss) { - for (let ring = 0; ring < 3; ring += 1) { - const delayed = Math.max(0, Math.min(1, progress * 1.7 - ring * 0.2)); - if (delayed <= 0) continue; - ctx.globalAlpha = (1 - delayed) * 0.42; - ctx.strokeStyle = ring % 2 ? "#dce6df" : "#df6843"; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.arc(0, 0, delayed * (50 + ring * 28), 0, Math.PI * 2); - ctx.stroke(); - } - } - ctx.restore(); - } - - function drawVisualEffects(gameState) { - for (const effect of visualEffects) { - const age = gameState.tick - effect.startTick; - if (age < 0 || age > effect.duration) continue; - if (effect.type === "impact") drawImpact(effect, age); - else drawExplosion(effect, age); - } - } - - function drawEntities(gameState) { - for (const pickup of gameState.pickups) { - const x = pickup.x / core.SCALE; - const y = pickup.y / core.SCALE; - ctx.save(); - ctx.translate(x, y); - ctx.rotate((gameState.tick + pickup.id) * 0.035); - ctx.fillStyle = pickup.kind === "power" ? "#6fa4b2" : "#df6c45"; - ctx.fillRect(-9, -9, 18, 18); - ctx.fillStyle = "#0a0d0f"; - ctx.font = "bold 12px monospace"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText(pickup.kind === "power" ? "P" : "B", 0, 1); - ctx.restore(); - } - - ctx.lineCap = "round"; - for (const bullet of gameState.playerBullets) { - const x = bullet.x / core.SCALE; - const y = bullet.y / core.SCALE; - ctx.strokeStyle = "#d8edf0"; - ctx.lineWidth = 3; - ctx.beginPath(); - ctx.moveTo(x, y + 8); - ctx.lineTo(x - bullet.vx / 64, y - 8); - ctx.stroke(); - } - - for (const enemy of gameState.enemies) drawEnemy(enemy, gameState.tick); - - for (const bullet of gameState.enemyBullets) { - const x = bullet.x / core.SCALE; - const y = bullet.y / core.SCALE; - const r = bullet.radius / core.SCALE; - ctx.fillStyle = bullet.grazed ? "#d8cbb5" : "#e25f3b"; - ctx.beginPath(); - ctx.arc(x, y, Math.max(2.5, r * 0.72), 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = "rgba(255,180,132,0.65)"; - ctx.lineWidth = 1; - ctx.stroke(); - } - - drawVisualEffects(gameState); - drawPlayer(gameState.player, gameState.tick); - } - - function drawGameOverlay(gameState) { - ctx.fillStyle = "rgba(5,7,9,0.72)"; - ctx.fillRect(0, 0, core.WIDTH, 42); - ctx.fillStyle = "#d9d4c7"; - ctx.font = "700 12px monospace"; - ctx.textAlign = "left"; - ctx.fillText(`LV ${String(gameState.level).padStart(2, "0")} ${gameState.blueprint.biome.code}`, 12, 17); - ctx.fillStyle = "#87949b"; - ctx.font = "10px monospace"; - ctx.fillText(`SEED ${core.seedHex(gameState.seed)} SIG ${gameState.blueprint.signature}`, 12, 33); - ctx.textAlign = "right"; - ctx.fillStyle = "#f07950"; - ctx.font = "700 14px monospace"; - ctx.fillText(String(gameState.score).padStart(8, "0"), core.WIDTH - 12, 20); - - const boss = gameState.enemies.find((enemy) => enemy.kind === "boss" && !enemy.dead); - if (boss) { - const width = 300; - const ratio = Math.max(0, boss.health / boss.maxHealth); - ctx.fillStyle = "rgba(0,0,0,0.75)"; - ctx.fillRect((core.WIDTH - width) / 2, 52, width, 10); - ctx.fillStyle = "#dc603d"; - ctx.fillRect((core.WIDTH - width) / 2 + 2, 54, (width - 4) * ratio, 6); - ctx.fillStyle = "#c8c0b2"; - ctx.font = "9px monospace"; - ctx.textAlign = "center"; - ctx.fillText(`COMMAND CORE // ${Math.ceil(ratio * 100)}%`, core.WIDTH / 2, 75); - } - - if (gameState.levelTick < 150) { - const alpha = Math.min(1, gameState.levelTick / 20, (150 - gameState.levelTick) / 25); - ctx.globalAlpha = Math.max(0, alpha); - ctx.fillStyle = "rgba(8,11,13,0.82)"; - ctx.fillRect(58, 285, core.WIDTH - 116, 105); - ctx.strokeStyle = "#df6b44"; - ctx.strokeRect(58.5, 285.5, core.WIDTH - 117, 104); - ctx.fillStyle = "#8e9aa0"; - ctx.font = "11px monospace"; - ctx.textAlign = "center"; - ctx.fillText(`PROCEDURAL SECTOR ${String(gameState.level).padStart(2, "0")}`, core.WIDTH / 2, 317); - ctx.fillStyle = "#e8e1d4"; - ctx.font = "700 25px monospace"; - ctx.fillText(gameState.blueprint.biome.name.toUpperCase(), core.WIDTH / 2, 351); - ctx.fillStyle = "#df6b44"; - ctx.font = "10px monospace"; - ctx.fillText(`RANK ${gameState.blueprint.difficulty.rank} // ${gameState.blueprint.signature}`, core.WIDTH / 2, 374); - ctx.globalAlpha = 1; - } - - const warningStart = gameState.blueprint.bossTick - 180; - if (!gameState.bossDefeated && gameState.levelTick >= warningStart && gameState.levelTick < gameState.blueprint.bossTick) { - ctx.globalAlpha = Math.floor(gameState.tick / 10) % 2 ? 0.95 : 0.35; - ctx.fillStyle = "#df5f3b"; - ctx.fillRect(0, 94, core.WIDTH, 36); - ctx.fillStyle = "#080a0c"; - ctx.font = "900 19px monospace"; - ctx.textAlign = "center"; - ctx.fillText("COMMAND SIGNATURE DETECTED", core.WIDTH / 2, 119); - ctx.globalAlpha = 1; - } - - if (gameState.bombPulse > 0) { - const ratio = gameState.bombPulse / 45; - ctx.strokeStyle = `rgba(239,224,200,${ratio})`; - ctx.lineWidth = 8 * ratio + 1; - ctx.beginPath(); - ctx.arc(gameState.player.x / core.SCALE, gameState.player.y / core.SCALE, (1 - ratio) * 520, 0, Math.PI * 2); - ctx.stroke(); - } - } - - function render(time) { - ctx.save(); - if (shake > 0) { - const x = ((visualHash(state ? state.tick : 0, shake, 1) % 7) - 3) * Math.min(1, shake / 8); - const y = ((visualHash(state ? state.tick : 0, shake, 2) % 7) - 3) * Math.min(1, shake / 8); - ctx.translate(x, y); - shake -= 1; - } - drawBackground(state, time); - if (state) { - drawEntities(state); - drawGameOverlay(state); - } else { - ctx.fillStyle = "rgba(233,227,213,0.025)"; - ctx.font = "900 84px sans-serif"; - ctx.textAlign = "center"; - ctx.fillText("S", core.WIDTH / 2, core.HEIGHT / 2 + 25); - } - if (visualFlash > 0) { - ctx.fillStyle = `rgba(239,224,200,${Math.min(0.28, visualFlash / 70)})`; - ctx.fillRect(0, 0, core.WIDTH, core.HEIGHT); - visualFlash -= 1; - } - ctx.restore(); - threeStage.render({ - tick: state ? state.tick : 0, - speed: state ? 1 + state.level * 0.08 : 0.25, - danger: state ? Math.max(0, 1 - state.player.health / state.player.maxHealth) : 0, - activity: state ? Math.min(1, 0.24 + (state.playerBullets.length + state.enemyBullets.length) / 24) : 0.08, - heading: 0, - }); + function render() { + presentation.render(state, { palette: state ? palettes[state.blueprint.biomeIndex % palettes.length] : palettes[0], heldInput, effects: visualEffects, enemyHitUntil, paused }); } function frame(time) { diff --git a/SEEDSTORM/docs/DESIGN.md b/SEEDSTORM/docs/DESIGN.md index 74239ba..ad6e616 100644 --- a/SEEDSTORM/docs/DESIGN.md +++ b/SEEDSTORM/docs/DESIGN.md @@ -36,7 +36,7 @@ flash, and a compact health bar after an enemy has been wounded. Destruction emits a final snapshot before the entity is removed. The renderer uses it to continue a procedural debris burst at the correct position, including -a longer multi-ring sequence for command craft. These Canvas effects and their +a longer multi-ring sequence for command craft. These native WebGL effects and their audio cues never feed back into simulation state, so a replay produces the same collisions, health, score, and digest regardless of display frame rate. diff --git a/SEEDSTORM/docs/DETERMINISM.md b/SEEDSTORM/docs/DETERMINISM.md index 15590af..39068af 100644 --- a/SEEDSTORM/docs/DETERMINISM.md +++ b/SEEDSTORM/docs/DETERMINISM.md @@ -92,7 +92,7 @@ security primitives. ## Deliberately outside the contract -- Canvas pixels, CSS layout, scanline overlay, animation interpolation, and screen +- WebGL pixels, CSS layout, scanline overlay, animation interpolation, and screen shake, health-bar styling, impact sparks, and explosion particles; - the transient `state.events` presentation queue and its payload shape. Enemy hit and destruction records are derived from authoritative collision state, but the diff --git a/SEEDSTORM/index.html b/SEEDSTORM/index.html index b6f2fee..94dcfad 100644 --- a/SEEDSTORM/index.html +++ b/SEEDSTORM/index.html @@ -8,7 +8,7 @@ SEEDSTORM — Deterministic Strike - + @@ -130,9 +130,9 @@

REPLAY CODE

- - + + diff --git a/SEEDSTORM/renderer.js b/SEEDSTORM/renderer.js new file mode 100644 index 0000000..07f97ca --- /dev/null +++ b/SEEDSTORM/renderer.js @@ -0,0 +1,373 @@ +(function (root) { + "use strict"; + class SeedstormRenderer { + constructor(canvas, core) { + this.core = core; + this.stage = new root.QsolNative.Stage(canvas, { + width: core.WIDTH, + height: core.HEIGHT, + }); + const shapes = { + player: [ + [0, -18], + [7, -5], + [18, 7], + [7, 8], + [4, 17], + [0, 12], + [-4, 17], + [-7, 8], + [-18, 7], + [-7, -5], + ], + scout: [ + [0, 14], + [12, -9], + [4, -6], + [0, -15], + [-4, -6], + [-12, -9], + ], + wing: [ + [0, 14], + [18, -4], + [8, -8], + [0, -14], + [-8, -8], + [-18, -4], + ], + bomber: [ + [0, 20], + [22, 8], + [18, -11], + [7, -8], + [0, -20], + [-7, -8], + [-18, -11], + [-22, 8], + ], + boss: [ + [0, 1], + [0.65, 0.4], + [1, -0.08], + [0.6, -0.3], + [0.34, -0.67], + [0, -1], + [-0.34, -0.67], + [-0.6, -0.3], + [-1, -0.08], + [-0.65, 0.4], + ], + }; + for (const [name, points] of Object.entries(shapes)) + this.stage.shape(name, points); + } + render(state, ui) { + const s = this.stage, + c = this.core; + s.begin("#0c1519"); + if (!state) { + s.text("SEEDSTORM // STRIKE SYSTEM READY"); + s.finish(); + return; + } + const scale = c.SCALE, + tick = state.tick, + p = state.player; + const palette = ui.palette || { ground: "#152329", detail: "#34505a" }; + // Industrial canyon: segmented embankments and buried pipework sit well + // beneath the aircraft. Leave the central fire lane quiet and readable. + const scroll = s.reducedMotion + ? 0 + : state.levelTick * (1.1 + Math.min(state.level, 12) * 0.06); + for (let row = -2; row < 13; row++) { + const y = row * 72 + (scroll % 72); + const phase = Math.floor(scroll / 72) + row, + hash = Math.imul(phase + state.seed, 2654435761) >>> 0; + for (const side of [-1, 1]) { + const width = 28 + (hash % 47), + x = side < 0 ? width / 2 - 8 : c.WIDTH - width / 2 + 8; + s.flat("terrain", "bevel", x, y, -35, width, 69, 28, palette.deep); + s.flat( + "terrain", + "bevel", + x - side * 9, + y, + -17, + width * 0.55, + 54, + 15, + palette.grid, + ); + s.line( + "pipework", + x + side * width * 0.3, + y - 34, + x + side * width * 0.3, + y + 34, + -6, + palette.grid, + 4, + ); + if ((hash & 3) === 0) + s.flat( + "terrain", + "box", + x, + y, + -5, + 7, + 4, + 2, + palette.accent, + 0, + "glow", + ); + } + s.line("substructure", 50, y, c.WIDTH - 50, y, -70, "#16242a", 1); + } + for (const x of [120, c.WIDTH - 120]) + s.line("substructure", x, 0, x, c.HEIGHT, -80, palette.grid, 1); + for (const e of state.pickups) { + const x = e.x / scale, + y = e.y / scale, + color = e.kind === "power" ? "#6fa4b2" : "#df6c45"; + s.item( + "pickups", + "diamond", + x, + -y, + 12, + 10, + 10, + 9, + color, + 0.2, + 0.3, + s.reducedMotion ? 0 : tick * 0.035, + ); + if (e.kind === "power") { + s.line( + "pickup-symbols", + x - 4, + y + 4, + x - 4, + y - 4, + 24, + "#e5e7cd", + 2, + ); + s.line( + "pickup-symbols", + x - 4, + y - 4, + x + 3, + y - 4, + 24, + "#e5e7cd", + 2, + ); + s.line("pickup-symbols", x + 3, y - 4, x + 3, y, 24, "#e5e7cd", 2); + s.line("pickup-symbols", x - 4, y, x + 3, y, 24, "#e5e7cd", 2); + } else + s.flat( + "pickup-symbols", + "sphere", + x, + y, + 24, + 3, + 3, + 3, + "#e5e7cd", + 0, + "glow", + ); + } + for (const e of state.enemies) { + const x = e.x / scale, + y = e.y / scale, + r = e.radius / scale, + hit = (ui.enemyHitUntil.get(e.id) || -1) >= tick; + s.flat( + "shadows", + s.geometries.has(e.kind) ? e.kind : "sphere", + x + 10, + y + 14, + -5, + e.kind === "boss" ? r : s.geometries.has(e.kind) ? 1 : r, + e.kind === "boss" ? r : s.geometries.has(e.kind) ? 1 : r, + 0.1, + "#050b0f", + 0, + "glow", + ); + const color = hit + ? "#fff4dc" + : { + scout: "#ad5339", + wing: "#8f9aa0", + turret: "#4f5e64", + bomber: "#6b5146", + spinner: "#ce6040", + boss: "#343f45", + }[e.kind]; + if (e.kind === "turret") { + s.item( + "enemies", + "cylinder", + x, + -y, + 6, + 16, + 9, + 16, + color, + Math.PI / 2, + ); + s.flat( + "enemies", + "box", + x, + y + 4, + 13, + 7, + 18, + 7, + "#d86642", + ((e.pathSeed % 8) * Math.PI) / 4, + ); + } else if (e.kind === "spinner") { + const a = s.reducedMotion ? 0 : (tick + e.id * 7) * 0.025; + s.flat("enemies", "box", x, y, 9, 38, 5, 6, "#b4bebc", a); + s.flat("enemies", "box", x, y, 9, 5, 38, 6, "#b4bebc", a); + s.flat("enemies", "sphere", x, y, 14, 8, 8, 7, color); + } else { + const size = e.kind === "boss" ? r : 1; + s.flat( + "enemies", + e.kind, + x, + y, + 8, + size, + size, + e.kind === "boss" ? 30 : 22, + color, + ); + s.flat( + "enemies", + "diamond", + x, + y, + 19, + e.kind === "boss" ? 15 : 4, + e.kind === "boss" ? 27 : 8, + 6, + "#d96a47", + ); + } + if (e.health < e.maxHealth || e.kind === "boss") + s.health( + x, + y - r - 9, + 42, + Math.min(110, r * 2), + e.health / e.maxHealth, + "#df7b50", + ); + } + for (const e of state.playerBullets) + s.line( + "projectiles", + e.x / scale, + e.y / scale + 8, + e.x / scale - e.vx / 64, + e.y / scale - 8, + 23, + "#d8edf0", + 3, + ); + for (const e of state.enemyBullets) { + const r = Math.max(2.5, (e.radius / scale) * 0.72); + s.flat( + "enemy-projectiles", + "sphere", + e.x / scale, + e.y / scale, + 23, + r, + r, + r, + e.grazed ? "#d8cbb5" : "#e25f3b", + 0, + "glow", + ); + } + const x = p.x / scale, + y = p.y / scale; + s.flat( + "shadows", + "player", + x + 12, + y + 18, + -6, + 1, + 1, + 0.1, + "#050b0f", + 0, + "glow", + ); + s.flat( + "player", + "player", + x, + y, + 10, + 1, + 1, + 24, + p.invulnerable > 0 ? "#b7d6ce" : "#d9e0dc", + ); + s.flat("player", "diamond", x, y, 24, 4, 10, 4, "#527b88"); + if (!s.reducedMotion) { + s.flat( + "exhaust", + "dart", + x, + y + 28, + 5, + 5, + 14, + 3, + "#df8950", + Math.PI, + "glow", + ); + } + if (p.invulnerable > 0) s.ring("indicators", x, y, 20, 22, "#9ec5be"); + if (ui.heldInput & c.INPUT.FOCUS) + s.ring("indicators", x, y, 29, 10, "#e9e3d5"); + if (state.bombPulse > 0) + s.ring( + "effects", + x, + y, + 32, + (1 - state.bombPulse / 45) * 520 + 1, + "#efe0c8", + ); + s.effects(ui.effects, tick); + const warning = + !state.bossDefeated && + state.levelTick >= state.blueprint.bossTick - 180 && + state.levelTick < state.blueprint.bossTick; + s.text( + `LV ${state.level} // ${state.blueprint.biome.name}${warning ? "\nCOMMAND SIGNATURE DETECTED" : ""}`, + ); + s.finish(); + } + } + root.SeedstormRenderer = SeedstormRenderer; +})(window); diff --git a/SEEDSTORM/tests/browser-smoke.js b/SEEDSTORM/tests/browser-smoke.js index d91dcb7..9cb2cfa 100644 --- a/SEEDSTORM/tests/browser-smoke.js +++ b/SEEDSTORM/tests/browser-smoke.js @@ -8,8 +8,8 @@ const core = require("../core.js"); (async () => { const browser = await chromium.launch({ headless: true, - executablePath: process.env.SEEDSTORM_CHROMIUM || chromium.executablePath(), - args: ["--no-sandbox", "--disable-setuid-sandbox"], + executablePath: process.env.GAMES_CHROMIUM || process.env.SEEDSTORM_CHROMIUM || chromium.executablePath(), + args: ["--no-sandbox", "--disable-setuid-sandbox", "--enable-unsafe-swiftshader"], }); const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }); await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") }); @@ -21,7 +21,7 @@ const core = require("../core.js"); page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`)); page.on("request", (request) => requests.push(request.url())); - const url = `file://${path.join(__dirname, "..", "index.html")}`; + const url = process.env.GAMES_TEST_ORIGIN ? `${process.env.GAMES_TEST_ORIGIN}/SEEDSTORM/index.html` : `file://${path.join(__dirname, "..", "index.html")}`; await page.goto(url, { waitUntil: "load" }); await page.clock.pauseAt(new Date("2026-01-01T01:00:00Z")); assert.equal(await page.title(), "SEEDSTORM — Deterministic Strike"); @@ -116,7 +116,7 @@ const core = require("../core.js"); assert.ok(mobileLayout.content <= mobileLayout.viewport + 1, `mobile layout overflows by ${mobileLayout.content - mobileLayout.viewport}px`); await page.screenshot({ path: path.join(__dirname, "seedstorm-mobile.png"), fullPage: true }); - const externalRequests = requests.filter((requestUrl) => !requestUrl.startsWith("file://")); + const externalRequests = requests.filter((requestUrl) => !(process.env.GAMES_TEST_ORIGIN ? new URL(requestUrl).origin === process.env.GAMES_TEST_ORIGIN : requestUrl.startsWith("file://"))); assert.deepEqual(externalRequests, []); assert.deepEqual(errors, []); diff --git a/SEEDSTORM/tests/offline.test.js b/SEEDSTORM/tests/offline.test.js index b1e7e9a..1733940 100644 --- a/SEEDSTORM/tests/offline.test.js +++ b/SEEDSTORM/tests/offline.test.js @@ -19,10 +19,6 @@ assert.match(app, /core\.encodeReplay/); assert.match(app, /core\.decodeReplay/); assert.match(app, /core\.tryRecordInput/); assert.match(app, /function isInteractiveTarget/); -assert.match(app, /function drawEnemyHealth/); -assert.match(app, /function drawImpact/); -assert.match(app, /function drawExplosion/); -assert.match(app, /function drawVisualEffects/); assert.match(app, /resetRun\(\) \{\s*this\.lastShotTick = -100;\s*this\.lastHitTick = -100;\s*\}/); assert.equal((app.match(/audio\.resetRun\(\);/g) || []).length, 2); assert.match(app, /event\.type === "bomb"[\s\S]*?visualFlash = Math\.max\(visualFlash, 18\);\s*shake = Math\.max\(shake, 12\);/); @@ -32,3 +28,8 @@ for (const relative of ["core.js", "app.js", "style.css"]) { } process.stdout.write("ok - offline browser shell has no remote runtime dependency\n"); + +const renderer = fs.readFileSync(path.join(root, "renderer.js"), "utf8"); +assert.doesNotMatch(app + renderer, /getContext\s*\(\s*["']2d["']/); +assert.match(renderer, /QsolNative\.Stage/); +assert.match(renderer, /s\.finish\(\)/); diff --git a/SUBSPACE/README.md b/SUBSPACE/README.md index 014d4f2..10a347e 100644 --- a/SUBSPACE/README.md +++ b/SUBSPACE/README.md @@ -25,7 +25,7 @@ Chrome/Chromium, Firefox, and current Safari are the primary targets. Keyboard c ## What is included -- Full-screen Canvas 2D combat with inertial acceleration, rotation, reverse thrust, wall rebound, and an energy-draining afterburner. +- Full-screen native Three.js/WebGL combat with inertial acceleration, rotation, reverse thrust, wall rebound, and an energy-draining afterburner. - Near-conservative coasting with explicit counter-thrust: turning changes the hull axis but never rotates the ship's existing travel vector. Flight assist now bleeds velocity slowly instead of imposing arcade-style drag. @@ -40,9 +40,9 @@ Chrome/Chromium, Firefox, and current Safari are the primary targets. Keyboard c - Procedurally synthesized Web Audio effects with no bundled sound assets. - A fixed 60 Hz simulation clock separated from display refresh. - Direct `file://` operation using classic scripts rather than browser modules. -- A pinned local Three.js layer adds Rust/Wasm-sampled star depth, arena - structure, and combat pulses while the fixed-step Canvas simulation remains - authoritative. The sampler follows the bounded QSOLKCB/GALAXY browser ABI. +- A native Three.js scene owns all eight ship hulls, obstacles, objectives, + ordnance, pickups and combat effects. The fixed-step simulation remains + authoritative. Tactical radar uses SVG; there is no Canvas 2D renderer. ## Controls @@ -136,7 +136,7 @@ SUBSPACE/ └── tests/ ├── index.html # Browser-openable core verification page ├── core.test.js # Determinism, geometry, pathfinding, and generation tests - ├── runtime-smoke.js # Headless DOM/canvas simulation smoke harness + ├── runtime-smoke.js # Headless DOM simulation smoke harness └── browser-smoke.js # Optional Playwright end-to-end browser smoke test ``` @@ -164,7 +164,11 @@ The core suite checks: - A* navigation; and - spawn/objective clearance across 750 generated arenas. -The dependency-free runtime smoke harness boots the real app against a lightweight DOM and Canvas API stub, launches live combat, advances 600 simulation ticks, exercises weapons and AI, renders a full frame, verifies finite entity state, checks every hull's weapon/system command paths, covers all four objective transitions and survival reinforcement, checks the live core blocker and reachable pickup placement, and covers terminal/pause/resume/hangar transitions. Developers with the optional `@napi-rs/canvas` package can also set `INERTIA_ZERO_RENDER_PATH=/tmp/inertia-zero.png` to write a native PNG during the same run. +The dependency-free runtime harness tests the real simulation against a lightweight +DOM and an explicitly stubbed presentation boundary. It covers 600 combat ticks, +all hull systems, AI, objectives, finite state, pause/resume and terminal paths. +It makes no rendering claim. Actual WebGL rendering is exercised by +`../tests/native-renderers.browser.js` and this game's Playwright smoke suite. ## Independent implementation and trademark notice @@ -185,3 +189,5 @@ These references are documentation only and are not runtime dependencies. Copyright © 2026 QSOL-IMC / Trent Slade. Released under the repository's [MIT License](../LICENSE). + +Native renderer architecture and verification: [migration audit](../docs/NATIVE_RENDERING.md). diff --git a/SUBSPACE/app.js b/SUBSPACE/app.js index e328130..992d48e 100644 --- a/SUBSPACE/app.js +++ b/SUBSPACE/app.js @@ -269,8 +269,7 @@ class InertiaZeroGame { constructor() { - this.ctx = nodes.canvas.getContext("2d", { alpha: false }); - this.radarCtx = nodes.radar.getContext("2d"); + this.presentation = new window.InertiaRenderer(nodes.canvas, Core, nodes.radar); this.audio = new AudioEngine(); this.input = new InputController(this); this.mode = "menu"; @@ -316,9 +315,6 @@ this.feedEntries = []; this.attractTime = 0; this.resize(); - this.threeStage = window.QsolThree - ? window.QsolThree.create({ host: nodes.app, source: nodes.canvas, preset: "inertia" }) - : { render() {}, pulse() {} }; this.buildMenu(); this.bindUI(); this.restoreSettings(); @@ -457,11 +453,9 @@ this.width = window.innerWidth; this.height = window.innerHeight; this.dpr = Math.min(2, window.devicePixelRatio || 1); - nodes.canvas.width = Math.floor(this.width * this.dpr); - nodes.canvas.height = Math.floor(this.height * this.dpr); nodes.canvas.style.width = `${this.width}px`; nodes.canvas.style.height = `${this.height}px`; - this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + this.presentation.stage.resize(); } startRun() { @@ -1228,7 +1222,6 @@ } this.spawnParticles(projectile.x, projectile.y, projectile.color, Math.min(32, 12 + Math.floor(radius / 10)), 260); this.spawnPulse(projectile.x, projectile.y, radius, projectile.color); - this.threeStage.pulse("blast", Math.min(1, radius / 140)); if (this.player && Math.hypot(this.player.x - projectile.x, this.player.y - projectile.y) < 600) { this.camera.shake = Math.max(this.camera.shake, Math.min(16, radius * 0.075)); this.audio.play("explode"); @@ -1264,7 +1257,6 @@ ship.respawnTimer = ship.isPlayer ? 2.35 : 0; this.spawnParticles(ship.x, ship.y, ship.team === PLAYER_TEAM ? "#e8bd68" : "#d36f62", 42, 390); this.spawnPulse(ship.x, ship.y, 155, ship.team === PLAYER_TEAM ? "#e8bd68" : "#d36f62"); - this.threeStage.pulse("blast", ship.isPlayer ? 1 : 0.72); this.camera.shake = Math.max(this.camera.shake, ship.isPlayer ? 19 : 11); this.audio.play("explode"); if (ship.isPlayer) { @@ -1780,444 +1772,7 @@ } render(interpolation) { - const ctx = this.ctx; - ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); - ctx.fillStyle = "#03090b"; - ctx.fillRect(0, 0, this.width, this.height); - if (this.mode === "menu" || !this.arena) { - this.renderMenuBackdrop(ctx); - this.threeStage.render({ tick: 0, speed: 0.12, activity: 0.08, heading: this.attractTime * 0.03 }); - return; - } - - this.renderSpaceBackdrop(ctx); - const shakeX = Math.sin(this.attractTime * 47.3) * this.camera.shake; - const shakeY = Math.cos(this.attractTime * 39.7) * this.camera.shake * 0.72; - ctx.save(); - ctx.translate(this.width * 0.5 - this.camera.x + shakeX, this.height * 0.5 - this.camera.y + shakeY); - this.drawWorldGrid(ctx); - this.drawObjective(ctx); - for (const obstacle of this.arena.obstacles) this.drawObstacle(ctx, obstacle); - for (const pickup of this.pickups) this.drawPickup(ctx, pickup); - for (const projectile of this.projectiles) this.drawProjectile(ctx, projectile, interpolation); - for (const ship of this.ships) { - if (!ship.alive) continue; - if (!ship.isPlayer && ship.cloak > 0 && this.player?.alive && Core.distanceSquared(ship, this.player) > 250 * 250) continue; - this.drawShip(ctx, ship, interpolation); - } - for (const particle of this.particles) this.drawParticle(ctx, particle); - ctx.restore(); - this.drawOffscreenIndicators(ctx); - this.drawRespawnStatus(ctx); - this.drawRadar(); - this.threeStage.render({ - tick: Math.round(this.simulationTime * 60), - speed: this.player ? Math.hypot(this.player.vx, this.player.vy) / 260 : 0, - danger: this.player ? 1 - this.player.energy / this.player.config.maxEnergy : 0, - activity: Math.min(1, 0.18 + this.projectiles.length / 45), - heading: this.player ? this.player.angle : 0, - }); - } - - renderMenuBackdrop(ctx) { - const gradient = ctx.createRadialGradient(this.width * 0.72, this.height * 0.18, 40, this.width * 0.72, this.height * 0.18, Math.max(this.width, this.height)); - gradient.addColorStop(0, "#122629"); - gradient.addColorStop(0.38, "#071315"); - gradient.addColorStop(1, "#030709"); - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, this.width, this.height); - - ctx.save(); - for (const star of this.menuArena.stars) { - const x = ((star.x / this.menuArena.width * this.width) - this.attractTime * 4 * star.layer + this.width) % this.width; - const y = star.y / this.menuArena.height * this.height; - ctx.globalAlpha = star.alpha * 0.55; - ctx.fillStyle = star.size > 1.5 ? "#d6c28e" : "#91aaa5"; - ctx.fillRect(x, y, star.size, star.size); - } - ctx.globalAlpha = 1; - ctx.strokeStyle = "rgba(114,185,173,.055)"; - ctx.lineWidth = 1; - const spacing = 84; - const offset = (this.attractTime * 5) % spacing; - for (let x = -spacing + offset; x < this.width + spacing; x += spacing) { - ctx.beginPath(); - ctx.moveTo(x, 0); - ctx.lineTo(x - this.height * 0.13, this.height); - ctx.stroke(); - } - for (let y = 0; y < this.height; y += spacing) { - ctx.beginPath(); - ctx.moveTo(0, y); - ctx.lineTo(this.width, y); - ctx.stroke(); - } - - const orbitX = this.width * 0.79; - const orbitY = this.height * 0.57; - ctx.translate(orbitX, orbitY); - ctx.rotate(this.attractTime * 0.06); - ctx.strokeStyle = "rgba(232,189,104,.12)"; - ctx.lineWidth = 1; - for (const radius of [115, 205, 310]) { - ctx.beginPath(); - ctx.ellipse(0, 0, radius, radius * 0.42, 0, 0, Core.TAU); - ctx.stroke(); - } - ctx.rotate(-this.attractTime * 0.17); - this.drawDecorativeHull(ctx, 0, 0, 86); - ctx.restore(); - } - - drawDecorativeHull(ctx, x, y, size) { - ctx.save(); - ctx.translate(x, y); - ctx.rotate(-0.24); - ctx.beginPath(); - ctx.moveTo(size, 0); - ctx.lineTo(-size * 0.65, -size * 0.56); - ctx.lineTo(-size * 0.35, 0); - ctx.lineTo(-size * 0.65, size * 0.56); - ctx.closePath(); - ctx.fillStyle = "rgba(5,13,15,.84)"; - ctx.strokeStyle = "rgba(232,189,104,.28)"; - ctx.lineWidth = 1.3; - ctx.fill(); - ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(size * 0.7, 0); - ctx.lineTo(-size * 0.38, 0); - ctx.strokeStyle = "rgba(114,185,173,.25)"; - ctx.stroke(); - ctx.restore(); - } - - renderSpaceBackdrop(ctx) { - const gradient = ctx.createRadialGradient(this.width * 0.5, this.height * 0.45, 20, this.width * 0.5, this.height * 0.45, Math.max(this.width, this.height) * 0.78); - gradient.addColorStop(0, "#081417"); - gradient.addColorStop(1, "#020708"); - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, this.width, this.height); - - const left = this.camera.x - this.width * 0.5; - const top = this.camera.y - this.height * 0.5; - for (const nebula of this.arena.nebulae) { - const x = nebula.x - left; - const y = nebula.y - top; - if (x < -nebula.radius || y < -nebula.radius || x > this.width + nebula.radius || y > this.height + nebula.radius) continue; - const glow = ctx.createRadialGradient(x, y, 0, x, y, nebula.radius); - glow.addColorStop(0, `hsla(${nebula.hue},35%,32%,${nebula.alpha})`); - glow.addColorStop(1, `hsla(${nebula.hue},35%,16%,0)`); - ctx.fillStyle = glow; - ctx.fillRect(x - nebula.radius, y - nebula.radius, nebula.radius * 2, nebula.radius * 2); - } - for (const star of this.arena.stars) { - const parallaxX = (star.x - this.camera.x * star.layer) % this.arena.width; - const parallaxY = (star.y - this.camera.y * star.layer) % this.arena.height; - const x = ((parallaxX + this.arena.width) % this.arena.width) / this.arena.width * this.width; - const y = ((parallaxY + this.arena.height) % this.arena.height) / this.arena.height * this.height; - ctx.globalAlpha = star.alpha; - ctx.fillStyle = star.size > 1.5 ? "#d9c78f" : "#91aaa5"; - ctx.fillRect(x, y, star.size, star.size); - } - ctx.globalAlpha = 1; - } - - drawWorldGrid(ctx) { - ctx.save(); - ctx.strokeStyle = "rgba(103,145,137,.07)"; - ctx.lineWidth = 1; - ctx.setLineDash([3, 15]); - for (let x = 0; x <= this.arena.width; x += 320) { - ctx.beginPath(); - ctx.moveTo(x, 0); - ctx.lineTo(x, this.arena.height); - ctx.stroke(); - } - for (let y = 0; y <= this.arena.height; y += 275) { - ctx.beginPath(); - ctx.moveTo(0, y); - ctx.lineTo(this.arena.width, y); - ctx.stroke(); - } - ctx.setLineDash([]); - ctx.strokeStyle = "rgba(232,189,104,.22)"; - ctx.lineWidth = 3; - ctx.strokeRect(1.5, 1.5, this.arena.width - 3, this.arena.height - 3); - ctx.fillStyle = "rgba(132,159,153,.35)"; - ctx.font = "9px monospace"; - for (let x = 160, index = 0; x < this.arena.width; x += 320, index += 1) { - ctx.fillText(String.fromCharCode(65 + index), x, 17); - } - for (let y = 145, index = 1; y < this.arena.height; y += 275, index += 1) { - ctx.fillText(String(index), 8, y); - } - ctx.restore(); - } - - drawObjective(ctx) { - const objective = this.arena.objective; - const state = this.objectiveState; - if (!state) return; - ctx.save(); - ctx.translate(objective.x, objective.y); - if (state.type === "control") { - const pulse = 1 + Math.sin(this.simulationTime * 2.4) * 0.025; - ctx.strokeStyle = state.contested ? "rgba(211,111,98,.72)" : "rgba(232,189,104,.55)"; - ctx.lineWidth = 2; - ctx.setLineDash([14, 9]); - ctx.beginPath(); - ctx.arc(0, 0, objective.radius * pulse, -Math.PI / 2, -Math.PI / 2 + Core.TAU * Math.max(0.035, state.progress)); - ctx.stroke(); - ctx.setLineDash([]); - ctx.strokeStyle = "rgba(114,185,173,.18)"; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(0, 0, objective.radius * 0.72, 0, Core.TAU); - ctx.stroke(); - ctx.fillStyle = "rgba(232,189,104,.65)"; - ctx.fillRect(-3, -3, 6, 6); - } else if (state.type === "core") { - const flash = state.flash > 0; - const radius = objective.radius; - ctx.rotate(this.simulationTime * 0.08); - ctx.beginPath(); - for (let index = 0; index < 6; index += 1) { - const angle = index / 6 * Core.TAU; - const x = Math.cos(angle) * radius; - const y = Math.sin(angle) * radius; - if (index === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); - } - ctx.closePath(); - ctx.fillStyle = flash ? "rgba(217,124,108,.32)" : "rgba(27,54,55,.72)"; - ctx.strokeStyle = flash ? "#e18a78" : "rgba(114,185,173,.66)"; - ctx.lineWidth = 3; - ctx.fill(); - ctx.stroke(); - ctx.rotate(-this.simulationTime * 0.22); - for (const ring of [0.38, 0.62]) { - ctx.beginPath(); - ctx.arc(0, 0, radius * ring, 0, Core.TAU); - ctx.strokeStyle = `rgba(232,189,104,${0.22 + ring * 0.2})`; - ctx.lineWidth = 1; - ctx.stroke(); - } - ctx.fillStyle = "rgba(232,189,104,.8)"; - ctx.beginPath(); - ctx.arc(0, 0, 10 + Math.sin(this.simulationTime * 4) * 2, 0, Core.TAU); - ctx.fill(); - } else { - ctx.strokeStyle = "rgba(114,185,173,.12)"; - ctx.setLineDash([5, 12]); - ctx.beginPath(); - ctx.arc(0, 0, objective.radius, 0, Core.TAU); - ctx.stroke(); - ctx.setLineDash([]); - } - ctx.restore(); - } - - drawObstacle(ctx, obstacle) { - if (!this.inView(obstacle.x, obstacle.y, obstacle.type === "circle" ? obstacle.r + 80 : Math.max(obstacle.w, obstacle.h) + 80)) return; - ctx.save(); - if (obstacle.type === "circle") { - ctx.translate(obstacle.x, obstacle.y); - ctx.rotate(obstacle.phase + this.simulationTime * obstacle.spin * 0.07); - const sides = 8 + (Math.floor(obstacle.r) % 5); - ctx.beginPath(); - for (let index = 0; index < sides; index += 1) { - const angle = index / sides * Core.TAU; - const variation = 0.86 + Math.sin(index * 7.17 + obstacle.phase) * 0.11; - const x = Math.cos(angle) * obstacle.r * variation; - const y = Math.sin(angle) * obstacle.r * variation; - if (index === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); - } - ctx.closePath(); - const crystal = obstacle.kind === "crystal"; - ctx.fillStyle = crystal ? "rgba(20,43,45,.94)" : "rgba(16,25,26,.96)"; - ctx.strokeStyle = crystal ? "rgba(109,177,170,.55)" : "rgba(111,134,129,.36)"; - ctx.lineWidth = crystal ? 1.6 : 1.2; - ctx.fill(); - ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(-obstacle.r * 0.4, -obstacle.r * 0.15); - ctx.lineTo(obstacle.r * 0.22, obstacle.r * 0.34); - ctx.lineTo(obstacle.r * 0.48, -obstacle.r * 0.22); - ctx.strokeStyle = crystal ? "rgba(232,189,104,.2)" : "rgba(137,155,151,.13)"; - ctx.stroke(); - } else { - ctx.translate(obstacle.x, obstacle.y); - const citadel = obstacle.kind === "citadel"; - ctx.fillStyle = citadel ? "rgba(13,29,31,.98)" : "rgba(10,24,26,.98)"; - ctx.strokeStyle = citadel ? "rgba(232,189,104,.34)" : "rgba(114,185,173,.32)"; - ctx.lineWidth = 1.5; - ctx.fillRect(0, 0, obstacle.w, obstacle.h); - ctx.strokeRect(0, 0, obstacle.w, obstacle.h); - ctx.strokeStyle = "rgba(147,175,169,.12)"; - ctx.lineWidth = 1; - const segments = Math.max(1, Math.floor((obstacle.w > obstacle.h ? obstacle.w : obstacle.h) / 56)); - for (let index = 1; index < segments; index += 1) { - ctx.beginPath(); - if (obstacle.w > obstacle.h) { - const x = obstacle.w * index / segments; - ctx.moveTo(x, 0); ctx.lineTo(x, obstacle.h); - } else { - const y = obstacle.h * index / segments; - ctx.moveTo(0, y); ctx.lineTo(obstacle.w, y); - } - ctx.stroke(); - } - ctx.fillStyle = "rgba(232,189,104,.35)"; - for (let index = 0; index < segments; index += 2) { - if (obstacle.w > obstacle.h) ctx.fillRect(obstacle.w * (index + 0.5) / segments - 1, obstacle.h * 0.5 - 1, 2, 2); - else ctx.fillRect(obstacle.w * 0.5 - 1, obstacle.h * (index + 0.5) / segments - 1, 2, 2); - } - } - ctx.restore(); - } - - drawPickup(ctx, pickup) { - if (!this.inView(pickup.x, pickup.y, 40)) return; - ctx.save(); - ctx.translate(pickup.x, pickup.y); - ctx.rotate(pickup.phase); - const scale = 1 + Math.sin(pickup.phase * 1.7) * 0.12; - ctx.scale(scale, scale); - ctx.strokeStyle = pickup.type === "energy" ? "#8fc5a6" : "#e8bd68"; - ctx.fillStyle = pickup.type === "energy" ? "rgba(100,181,145,.18)" : "rgba(232,189,104,.16)"; - ctx.lineWidth = 1.4; - ctx.beginPath(); - ctx.moveTo(0, -9); ctx.lineTo(7, 0); ctx.lineTo(0, 9); ctx.lineTo(-7, 0); ctx.closePath(); - ctx.fill(); ctx.stroke(); - ctx.fillStyle = ctx.strokeStyle; - ctx.fillRect(-1.5, -1.5, 3, 3); - ctx.restore(); - } - - drawProjectile(ctx, projectile, interpolation) { - const x = Core.lerp(projectile.previousX, projectile.x, interpolation); - const y = Core.lerp(projectile.previousY, projectile.y, interpolation); - if (!this.inView(x, y, (projectile.explosionRadius || 20) + 20)) return; - const speed = Math.max(1, Math.hypot(projectile.vx, projectile.vy)); - const nx = projectile.vx / speed; - const ny = projectile.vy / speed; - ctx.save(); - ctx.globalCompositeOperation = "lighter"; - ctx.strokeStyle = projectile.color; - ctx.fillStyle = projectile.color; - if (projectile.kind === "gun" || projectile.kind === "shard") { - ctx.globalAlpha = 0.82; - ctx.lineWidth = projectile.kind === "gun" ? 2 : 1.2; - ctx.beginPath(); - ctx.moveTo(x - nx * 16, y - ny * 16); - ctx.lineTo(x + nx * 4, y + ny * 4); - ctx.stroke(); - } else if (projectile.kind === "mine") { - ctx.globalAlpha = projectile.arm > 0 ? 0.42 : 0.85; - ctx.translate(x, y); - ctx.rotate(this.simulationTime * 1.8 + projectile.id); - ctx.beginPath(); - for (let index = 0; index < 6; index += 1) { - const angle = index / 6 * Core.TAU; - const radius = index % 2 ? 5 : 10; - const px = Math.cos(angle) * radius; - const py = Math.sin(angle) * radius; - if (index === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); - } - ctx.closePath(); - ctx.stroke(); - } else { - ctx.globalAlpha = 0.34; - ctx.beginPath(); - ctx.arc(x, y, projectile.radius * 1.9 + Math.sin(this.simulationTime * 8 + projectile.id) * 1.5, 0, Core.TAU); - ctx.fill(); - ctx.globalAlpha = 0.95; - ctx.beginPath(); - ctx.arc(x, y, projectile.radius * 0.72, 0, Core.TAU); - ctx.fill(); - ctx.globalAlpha = 0.55; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.moveTo(x - nx * 25, y - ny * 25); - ctx.lineTo(x, y); - ctx.stroke(); - } - ctx.restore(); - } - - drawShip(ctx, ship, interpolation) { - const x = Core.lerp(ship.previousX, ship.x, interpolation); - const y = Core.lerp(ship.previousY, ship.y, interpolation); - if (!this.inView(x, y, 95)) return; - const radius = ship.config.radius; - const teamColor = ship.team === PLAYER_TEAM ? "#edca78" : "#d97668"; - const cloakAlpha = ship.cloak > 0 ? 0.23 + Math.sin(this.simulationTime * 19 + ship.id) * 0.09 : 1; - ctx.save(); - ctx.globalAlpha = cloakAlpha; - ctx.translate(x, y); - ctx.rotate(ship.angle); - - if (ship.control.thrust || ship.control.boost) { - const length = ship.control.boost ? radius * 1.8 : radius * (0.75 + Math.sin(this.simulationTime * 28 + ship.id) * 0.16); - const flame = ctx.createLinearGradient(-radius - length, 0, -radius * 0.5, 0); - flame.addColorStop(0, "rgba(114,185,173,0)"); - flame.addColorStop(0.65, "rgba(114,185,173,.55)"); - flame.addColorStop(1, "rgba(232,189,104,.8)"); - ctx.fillStyle = flame; - ctx.beginPath(); - ctx.moveTo(-radius * 0.65, -radius * 0.26); - ctx.lineTo(-radius - length, 0); - ctx.lineTo(-radius * 0.65, radius * 0.26); - ctx.closePath(); - ctx.fill(); - } - - const vertices = this.shipVertices(ship.type); - ctx.beginPath(); - vertices.forEach((point, index) => { - const px = point[0] * radius; - const py = point[1] * radius; - if (index === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); - }); - ctx.closePath(); - ctx.fillStyle = ship.flash > 0 ? "rgba(239,214,170,.9)" : "rgba(7,16,18,.96)"; - ctx.strokeStyle = teamColor; - ctx.lineWidth = ship.isPlayer ? 1.8 : 1.35; - ctx.fill(); - ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(radius * 0.68, 0); - ctx.lineTo(-radius * 0.45, 0); - ctx.strokeStyle = ship.config.accent; - ctx.globalAlpha *= 0.62; - ctx.stroke(); - ctx.globalAlpha = cloakAlpha; - - if (ship.invulnerable > 0 || ship.barrier > 0) { - ctx.rotate(-ship.angle); - ctx.strokeStyle = ship.barrier > 0 ? "rgba(114,185,173,.75)" : "rgba(232,189,104,.52)"; - ctx.lineWidth = ship.barrier > 0 ? 2.2 : 1.2; - ctx.setLineDash(ship.invulnerable > 0 ? [5, 5] : []); - ctx.beginPath(); - ctx.arc(0, 0, radius + 8 + Math.sin(this.simulationTime * 5) * 1.5, 0, Core.TAU); - ctx.stroke(); - ctx.setLineDash([]); - } - ctx.restore(); - - if (!ship.isPlayer && ship.cloak <= 0 && Core.distanceSquared(ship, this.player) < 720 * 720) { - ctx.save(); - ctx.font = "7px monospace"; - ctx.textAlign = "center"; - ctx.fillStyle = "rgba(215,226,223,.5)"; - ctx.fillText(ship.callsign, x, y - radius - 15); - const width = 34; - ctx.fillStyle = "rgba(255,255,255,.08)"; - ctx.fillRect(x - width / 2, y + radius + 8, width, 2); - ctx.fillStyle = "rgba(217,118,104,.76)"; - ctx.fillRect(x - width / 2, y + radius + 8, width * Core.clamp(ship.energy / ship.config.maxEnergy, 0, 1), 2); - ctx.restore(); - } + this.presentation.render(this, interpolation); } shipVertices(type) { @@ -2234,29 +1789,6 @@ return shapes[type] || shapes.warbird; } - drawParticle(ctx, particle) { - const progress = Core.clamp(particle.life / particle.maxLife, 0, 1); - ctx.save(); - ctx.globalCompositeOperation = "lighter"; - ctx.globalAlpha = progress * 0.75; - ctx.strokeStyle = particle.color; - ctx.fillStyle = particle.color; - if (particle.type === "spark") { - ctx.lineWidth = particle.size * progress; - ctx.beginPath(); - ctx.moveTo(particle.previousX, particle.previousY); - ctx.lineTo(particle.x, particle.y); - ctx.stroke(); - } else { - const radius = particle.radius * (1 - progress * 0.82); - ctx.lineWidth = 1 + progress * 2; - ctx.beginPath(); - ctx.arc(particle.x, particle.y, radius, 0, Core.TAU); - ctx.stroke(); - } - ctx.restore(); - } - inView(x, y, padding) { const margin = padding || 0; return x >= this.camera.x - this.width * 0.5 - margin @@ -2265,102 +1797,6 @@ && y <= this.camera.y + this.height * 0.5 + margin; } - drawOffscreenIndicators(ctx) { - if (!this.player?.alive) return; - const marginX = 38; - const marginTop = 160; - const marginBottom = 100; - for (const ship of this.ships) { - if (!ship.alive || ship.team !== ENEMY_TEAM || ship.cloak > 0) continue; - const screenX = ship.x - this.camera.x + this.width * 0.5; - const screenY = ship.y - this.camera.y + this.height * 0.5; - if (screenX > marginX && screenX < this.width - marginX && screenY > marginTop && screenY < this.height - marginBottom) continue; - const centreX = this.width * 0.5; - const centreY = this.height * 0.5; - const angle = Math.atan2(screenY - centreY, screenX - centreX); - const x = Core.clamp(screenX, marginX, this.width - marginX); - const y = Core.clamp(screenY, marginTop, this.height - marginBottom); - ctx.save(); - ctx.translate(x, y); - ctx.rotate(angle); - ctx.fillStyle = "rgba(217,118,104,.78)"; - ctx.beginPath(); - ctx.moveTo(8, 0); ctx.lineTo(-5, -4); ctx.lineTo(-3, 0); ctx.lineTo(-5, 4); ctx.closePath(); - ctx.fill(); - ctx.restore(); - } - } - - drawRespawnStatus(ctx) { - if (this.player?.alive || this.mode !== "playing") return; - const value = Math.max(0, this.player.respawnTimer); - ctx.save(); - ctx.fillStyle = "rgba(3,9,11,.72)"; - ctx.fillRect(this.width * 0.5 - 125, this.height * 0.5 - 32, 250, 64); - ctx.strokeStyle = "rgba(232,189,104,.34)"; - ctx.strokeRect(this.width * 0.5 - 125, this.height * 0.5 - 32, 250, 64); - ctx.textAlign = "center"; - ctx.fillStyle = "#e8bd68"; - ctx.font = "700 10px monospace"; - ctx.fillText(this.lives > 0 ? "RECONSTRUCTING PILOT HULL" : "SIGNAL TERMINATED", this.width * 0.5, this.height * 0.5 - 4); - ctx.fillStyle = "rgba(215,226,223,.72)"; - ctx.font = "9px monospace"; - ctx.fillText(this.lives > 0 ? `${value.toFixed(1)} SECONDS` : "NO RESERVE HULLS", this.width * 0.5, this.height * 0.5 + 15); - ctx.restore(); - } - - drawRadar() { - if (!this.arena || nodes.hud.hidden) return; - const ctx = this.radarCtx; - const width = nodes.radar.width; - const height = nodes.radar.height; - const scaleX = width / this.arena.width; - const scaleY = height / this.arena.height; - ctx.clearRect(0, 0, width, height); - ctx.fillStyle = "rgba(3,10,12,.96)"; - ctx.fillRect(0, 0, width, height); - ctx.strokeStyle = "rgba(111,151,143,.1)"; - ctx.lineWidth = 1; - for (let x = width / 5; x < width; x += width / 5) { - ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke(); - } - for (let y = height / 4; y < height; y += height / 4) { - ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke(); - } - ctx.fillStyle = "rgba(120,143,139,.22)"; - for (const obstacle of this.arena.obstacles) { - if (obstacle.type === "circle") { - ctx.beginPath(); - ctx.arc(obstacle.x * scaleX, obstacle.y * scaleY, Math.max(1, obstacle.r * Math.min(scaleX, scaleY)), 0, Core.TAU); - ctx.fill(); - } else { - ctx.fillRect(obstacle.x * scaleX, obstacle.y * scaleY, Math.max(1, obstacle.w * scaleX), Math.max(1, obstacle.h * scaleY)); - } - } - ctx.strokeStyle = "rgba(232,189,104,.62)"; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(this.arena.objective.x * scaleX, this.arena.objective.y * scaleY, Math.max(3, this.arena.objective.radius * Math.min(scaleX, scaleY)), 0, Core.TAU); - ctx.stroke(); - for (const pickup of this.pickups) { - ctx.fillStyle = pickup.type === "energy" ? "rgba(127,193,158,.7)" : "rgba(232,189,104,.7)"; - ctx.fillRect(pickup.x * scaleX - 1, pickup.y * scaleY - 1, 2, 2); - } - for (const ship of this.ships) { - if (!ship.alive) continue; - if (ship.team === ENEMY_TEAM && ship.cloak > 0 && (!this.player.alive || Core.distanceSquared(ship, this.player) > 220 * 220)) continue; - ctx.fillStyle = ship.team === PLAYER_TEAM ? "#edca78" : "#d97668"; - const size = ship.isPlayer ? 4 : 3; - ctx.fillRect(ship.x * scaleX - size / 2, ship.y * scaleY - size / 2, size, size); - } - const viewX = (this.camera.x - this.width * 0.5) * scaleX; - const viewY = (this.camera.y - this.height * 0.5) * scaleY; - ctx.strokeStyle = "rgba(215,226,223,.28)"; - ctx.strokeRect(viewX, viewY, this.width * scaleX, this.height * scaleY); - ctx.strokeStyle = "rgba(232,189,104,.32)"; - ctx.strokeRect(0.5, 0.5, width - 1, height - 1); - } - objectiveLabel() { const type = this.objectiveState?.type || this.arena?.objective.type; if (type === "elimination") return "ELIMINATE THE HOSTILE WING"; diff --git a/SUBSPACE/index.html b/SUBSPACE/index.html index 549b0a6..b6f2121 100644 --- a/SUBSPACE/index.html +++ b/SUBSPACE/index.html @@ -6,7 +6,7 @@ QSOL INERTIA ZERO — Offline Arena - + @@ -46,7 +46,7 @@ FPS 60 PING 0 ms - +
@@ -238,9 +238,9 @@

FLIGHT & COMBAT

- - + + diff --git a/SUBSPACE/renderer.js b/SUBSPACE/renderer.js new file mode 100644 index 0000000..c99698b --- /dev/null +++ b/SUBSPACE/renderer.js @@ -0,0 +1,380 @@ +(function (root) { + "use strict"; + class InertiaRenderer { + constructor(canvas, core, radar) { + this.core = core; + this.radar = radar; + this.stage = new root.QsolNative.Stage(canvas, { + width: 1280, + height: 720, + }); + this.stage.hud.style.bottom = "17%"; + this.stage.hud.style.left = "35%"; + this.stage.hud.style.right = "35%"; + } + render(game, interpolation) { + const s = this.stage, + c = this.core, + arena = game.arena || game.menuArena, + menu = game.mode === "menu" || !game.arena; + s.begin("#03090b"); + const left = menu ? 0 : game.camera.x - game.width / 2, + top = menu ? 0 : game.camera.y - game.height / 2; + s.view(game.width, game.height, left, top); + for (const star of arena.stars) { + const parallax = s.reducedMotion ? 0 : star.layer; + const x = + left + + (((((star.x - (menu ? 0 : game.camera.x) * parallax) % arena.width) + + arena.width) % + arena.width) / + arena.width) * + game.width; + const y = + top + + (((((star.y - (menu ? 0 : game.camera.y) * parallax) % arena.height) + + arena.height) % + arena.height) / + arena.height) * + game.height; + s.flat( + "stars", + "sphere", + x, + y, + -200 * star.layer, + star.size, + star.size, + star.size, + star.size > 1.5 ? "#8d8160" : "#435b59", + 0, + "glow", + ); + } + if (menu) { + s.flat( + "menu-hull", + "dart", + game.width * 0.78, + game.height * 0.55, + 0, + 85, + 85, + 75, + "#86724d", + 0.4, + ); + s.text(""); + s.finish(); + this.radar.innerHTML = ""; + return; + } + for (let x = 0; x <= arena.width; x += 320) + s.line("grid", x, 0, x, arena.height, -90, "#132b2c", 1); + for (let y = 0; y <= arena.height; y += 275) + s.line("grid", 0, y, arena.width, y, -90, "#132b2c", 1); + for (const [x1, y1, x2, y2] of [ + [0, 0, arena.width, 0], + [arena.width, 0, arena.width, arena.height], + [arena.width, arena.height, 0, arena.height], + [0, arena.height, 0, 0], + ]) + s.line("boundary", x1, y1, x2, y2, -5, "#a18753", 3); + for (const e of arena.obstacles) { + if (e.type === "circle") { + const crystal = e.kind === "crystal"; + s.item( + "obstacles", + crystal ? "diamond" : "rock", + e.x, + -e.y, + -5, + e.r, + e.r, + e.r * 0.65, + crystal ? "#355e5f" : "#394644", + 0.25, + 0.3, + s.reducedMotion + ? e.phase + : e.phase + game.simulationTime * e.spin * 0.07, + ); + // The ring is the exact collision radius; relief does not change physics. + s.ring( + "collision-rims", + e.x, + e.y, + 1, + e.r, + crystal ? "#638f87" : "#5c6c62", + ); + } else { + s.flat( + "obstacles", + "box", + e.x + e.w / 2, + e.y + e.h / 2, + -12, + e.w, + e.h, + 30, + e.kind === "citadel" ? "#344c49" : "#253f42", + ); + s.line("obstacle-seams", e.x, e.y, e.x + e.w, e.y, 4, "#a18c59", 2); + } + } + const objective = arena.objective, + status = game.objectiveState; + if (status) { + const color = + status.contested || status.flash > 0 ? "#d36f62" : "#b5a473"; + s.ring( + "objective", + objective.x, + objective.y, + -1, + objective.radius, + color, + ); + if (status.type === "core") + s.item( + "objective", + "diamond", + objective.x, + -objective.y, + 0, + objective.radius * 0.7, + objective.radius * 0.7, + 35, + "#487572", + 0.3, + 0.2, + s.reducedMotion ? 0 : game.simulationTime * 0.08, + ); + else + s.flat( + "objective", + "diamond", + objective.x, + objective.y, + 0, + 8, + 8, + 14, + color, + ); + s.health( + objective.x, + objective.y + objective.radius + 14, + 30, + 100, + status.progress || 0, + color, + ); + } + for (const e of game.pickups) + s.item( + "pickups", + "diamond", + e.x, + -e.y, + 7, + 7, + 9, + 7, + e.type === "energy" ? "#8fc5a6" : "#e8bd68", + 0.2, + 0.3, + s.reducedMotion ? 0 : e.phase, + ); + for (const e of game.projectiles) { + const x = c.lerp(e.previousX, e.x, interpolation), + y = c.lerp(e.previousY, e.y, interpolation), + v = Math.max(1, Math.hypot(e.vx, e.vy)); + s.line( + "projectiles", + x, + y, + x - (e.vx / v) * (e.kind === "gun" ? 16 : 9), + y - (e.vy / v) * (e.kind === "gun" ? 16 : 9), + 18, + e.color, + e.kind === "gun" ? 2 : 3, + ); + if (e.kind !== "gun" && e.kind !== "shard") + s.flat( + "projectiles", + "sphere", + x, + y, + 19, + e.radius || 5, + e.radius || 5, + 4, + e.color, + ); + } + for (const e of game.ships) { + if (!e.alive) continue; + if ( + !e.isPlayer && + e.cloak > 0 && + game.player?.alive && + c.distanceSquared(e, game.player) > 250 * 250 + ) + continue; + if (!s.geometries.has(e.type)) + s.shape(e.type, game.shipVertices(e.type)); + const x = c.lerp(e.previousX, e.x, interpolation), + y = c.lerp(e.previousY, e.y, interpolation), + r = e.config.radius, + color = e.team === 1 ? "#edca78" : "#d97668"; + s.flat( + e.isPlayer ? "player" : "enemies", + e.type, + x, + y, + 9, + r, + r, + r * 1.4, + e.flash > 0 ? "#efd6aa" : e.cloak > 0 ? "#334c48" : color, + e.angle, + ); + s.line( + "hull-detail", + x - Math.cos(e.angle) * r * 0.4, + y - Math.sin(e.angle) * r * 0.4, + x + Math.cos(e.angle) * r * 0.7, + y + Math.sin(e.angle) * r * 0.7, + 20, + e.config.accent, + 2, + ); + if ((e.control.thrust || e.control.boost) && !s.reducedMotion) { + const length = e.control.boost ? r * 1.8 : r * 0.9; + s.line( + "exhaust", + x - Math.cos(e.angle) * r * 0.7, + y - Math.sin(e.angle) * r * 0.7, + x - Math.cos(e.angle) * (r + length), + y - Math.sin(e.angle) * (r + length), + 5, + "#c9a56a", + 5, + ); + } + if (e.invulnerable > 0 || e.barrier > 0) + s.ring( + "shields", + x, + y, + 25, + r + 8, + e.barrier > 0 ? "#72b9ad" : "#e8bd68", + ); + if ( + !e.isPlayer && + e.cloak <= 0 && + game.player && + c.distanceSquared(e, game.player) < 720 * 720 + ) + s.health( + x, + y + r + 8, + 30, + 34, + e.energy / e.config.maxEnergy, + "#d97668", + ); + if (!e.isPlayer && e.cloak <= 0 && game.player?.alive) { + const px = x - left, + py = y - top; + if ( + px < 38 || + px > game.width - 38 || + py < 160 || + py > game.height - 100 + ) { + const angle = Math.atan2(py - game.height / 2, px - game.width / 2); + s.flat( + "indicators", + "dart", + left + c.clamp(px, 38, game.width - 38), + top + c.clamp(py, 160, Math.max(160, game.height - 100)), + 80, + 6, + 8, + 3, + "#d97668", + angle + Math.PI / 2, + "glow", + ); + } + } + } + for (const e of game.particles) { + const life = c.clamp(e.life / e.maxLife, 0, 1); + if (e.type === "spark") { + if (!s.reducedMotion) + s.line( + "effects", + e.previousX, + e.previousY, + e.x, + e.y, + 22, + e.color, + Math.max(0.3, e.size * life), + ); + } else + s.ring( + "effects", + e.x, + e.y, + 25, + e.radius * (1 - life * 0.82), + e.color, + ); + } + s.text( + !game.player?.alive && game.mode === "playing" + ? game.lives > 0 + ? `RECONSTRUCTING PILOT HULL\n${Math.max(0, game.player.respawnTimer).toFixed(1)} SECONDS` + : "SIGNAL TERMINATED" + : "", + ); + this.drawRadar(game); + s.finish(); + } + drawRadar(game) { + const a = game.arena, + c = this.core; + if (!a) return; + this.radar.setAttribute("viewBox", `0 0 ${a.width} ${a.height}`); + let html = ``; + for (const o of a.obstacles) + html += + o.type === "circle" + ? `` + : ``; + const o = a.objective; + html += ``; + for (const e of game.pickups) + html += ``; + for (const e of game.ships) { + if (!e.alive) continue; + if ( + !e.isPlayer && + e.cloak > 0 && + (!game.player.alive || c.distanceSquared(e, game.player) > 220 * 220) + ) + continue; + html += ``; + } + html += ``; + this.radar.innerHTML = html; + } + } + root.InertiaRenderer = InertiaRenderer; +})(window); diff --git a/SUBSPACE/tests/browser-smoke.js b/SUBSPACE/tests/browser-smoke.js index 16d34bb..664f26f 100644 --- a/SUBSPACE/tests/browser-smoke.js +++ b/SUBSPACE/tests/browser-smoke.js @@ -7,7 +7,7 @@ const { chromium } = require("playwright"); (async () => { let browser; try { - browser = await chromium.launch({ headless: true }); + browser = await chromium.launch({ headless: true, executablePath: process.env.GAMES_CHROMIUM || chromium.executablePath(), args: ["--no-sandbox", "--enable-unsafe-swiftshader"] }); const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 }); const errors = []; page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`)); @@ -15,7 +15,7 @@ const { chromium } = require("playwright"); if (message.type() === "error") errors.push(`console: ${message.text()}`); }); - const gameUrl = pathToFileURL(path.resolve(__dirname, "../index.html")).href; + const gameUrl = process.env.GAMES_TEST_ORIGIN ? `${process.env.GAMES_TEST_ORIGIN}/SUBSPACE/index.html` : pathToFileURL(path.resolve(__dirname, "../index.html")).href; await page.goto(gameUrl, { waitUntil: "load" }); await page.waitForFunction(() => Boolean(window.inertiaZero)); await page.screenshot({ path: "/tmp/inertia-zero-menu.png", fullPage: true }); @@ -80,7 +80,7 @@ const { chromium } = require("playwright"); await page.click("#resume-button"); await page.waitForFunction(() => window.inertiaZero.mode === "playing"); - const testsUrl = pathToFileURL(path.resolve(__dirname, "index.html")).href; + const testsUrl = process.env.GAMES_TEST_ORIGIN ? `${process.env.GAMES_TEST_ORIGIN}/SUBSPACE/tests/index.html` : pathToFileURL(path.resolve(__dirname, "index.html")).href; await page.goto(testsUrl, { waitUntil: "load" }); await page.waitForFunction(() => document.querySelectorAll("#results article").length > 0); const browserTests = await page.locator("#summary").textContent(); diff --git a/SUBSPACE/tests/runtime-smoke.js b/SUBSPACE/tests/runtime-smoke.js index 519492a..257a68b 100644 --- a/SUBSPACE/tests/runtime-smoke.js +++ b/SUBSPACE/tests/runtime-smoke.js @@ -1,8 +1,8 @@ "use strict"; -// Lightweight DOM/canvas smoke harness for environments without an installed browser. +// Simulation-only DOM harness. Real rendering is tested in tests/native-renderers.browser.js. // It executes the real app bootstrap, launches a sector, advances the fixed-step -// simulation, exercises player input, AI, weapons, HUD updates, and rendering. +// simulation, exercises player input, AI, weapons, and HUD updates. const fs = require("fs"); const path = require("path"); @@ -72,24 +72,6 @@ class FakeElement { const index = this.parentElement.children.indexOf(this); if (index >= 0) this.parentElement.children.splice(index, 1); } - getContext() { return this._context || createCanvasContext(); } -} - -function createCanvasContext() { - const gradient = { addColorStop() {} }; - const target = { - createRadialGradient: () => gradient, - createLinearGradient: () => gradient, - measureText: () => ({ width: 10 }), - setLineDash() {} - }; - return new Proxy(target, { - get(object, property) { - if (property in object) return object[property]; - return () => undefined; - }, - set(object, property, value) { object[property] = value; return true; } - }); } const ids = [ @@ -110,19 +92,6 @@ elements.get("game-canvas").width = 0; elements.get("game-canvas").height = 0; elements.get("radar-canvas").width = 208; elements.get("radar-canvas").height = 144; -let gameBackingCanvas = null; -if (process.env.INERTIA_ZERO_RENDER_PATH) { - try { - const { createCanvas } = require("@napi-rs/canvas"); - gameBackingCanvas = createCanvas(1440, 900); - const radarBackingCanvas = createCanvas(208, 144); - elements.get("game-canvas")._context = gameBackingCanvas.getContext("2d"); - elements.get("radar-canvas")._context = radarBackingCanvas.getContext("2d"); - } catch (error) { - throw new Error("INERTIA_ZERO_RENDER_PATH requires the optional @napi-rs/canvas development package"); - } -} - const documentListeners = new Map(); const document = { querySelector(selector) { return selector.startsWith("#") ? elements.get(selector.slice(1)) || null : null; }, @@ -133,6 +102,8 @@ const windowListeners = new Map(); const localValues = new Map(); const fakeWindow = { InertiaZeroCore: Core, + // Explicit injected presentation seam: this harness makes no rendering claims. + InertiaRenderer: class { constructor() { this.stage = { resize() {} }; } render() {} }, innerWidth: 1440, innerHeight: 900, devicePixelRatio: 1, @@ -180,9 +151,7 @@ game.input.keys.delete("KeyW"); game.input.keys.delete("Space"); for (let tick = 0; tick < 420; tick += 1) game.update(1 / 60); game.render(0.5); -if (process.env.INERTIA_ZERO_RENDER_PATH && gameBackingCanvas) { - fs.writeFileSync(process.env.INERTIA_ZERO_RENDER_PATH, gameBackingCanvas.toBuffer("image/png")); -} + const allFinite = game.ships.every((ship) => [ship.x, ship.y, ship.vx, ship.vy, ship.energy].every(Number.isFinite)) && game.projectiles.every((projectile) => [projectile.x, projectile.y, projectile.vx, projectile.vy, projectile.life].every(Number.isFinite)); @@ -324,7 +293,7 @@ if (game.mode !== "menu" || elements.get("menu").hidden) throw new Error("Hangar console.log("PASS real app bootstrap and eight-hull menu"); console.log("PASS seeded fortress sector launch"); -console.log("PASS 600-tick player, AI, weapon, HUD, and render smoke run"); +console.log("PASS 600-tick player, AI, weapon, HUD simulation smoke run"); console.log("PASS finite entity invariants under live combat"); console.log("PASS all eight primary, ordnance, special, and repel system paths"); console.log("PASS terminal-step freeze and zero-hull restart protection"); diff --git a/VECTOR_ZERO/README.md b/VECTOR_ZERO/README.md index a8dc640..83ecbe3 100644 --- a/VECTOR_ZERO/README.md +++ b/VECTOR_ZERO/README.md @@ -63,7 +63,7 @@ count, and run-length-encoded input ledger. Loading it creates a fresh campaign and applies every input word, including ledger entries after a terminal event. The interface displays a canonical state digest during live flight and replay. -Rendering, animation-frame timing, Canvas, CSS, pointer lock, audio, and browser +Rendering, animation-frame timing, Three.js, CSS, pointer lock, audio, and browser layout do not feed back into simulation state. See [`docs/DETERMINISM.md`](docs/DETERMINISM.md). @@ -71,11 +71,11 @@ layout do not feed back into simulation state. See - `core.js` — mine generation, fixed-point 6DOF flight, collision, combat, objectives, replay receipts, and state digests. -- `app.js` — fixed-step scheduler, polygon projection and near-plane clipping, - high-contrast target brackets and fire-solution cues, vector enemies and - pickups, cockpit HUD, input, telemetry, and synthesized audio. -- `../shared/qsol-three-stage.js` — a local Three.js depth layer that reinforces - mine geometry and combat pulses without entering canonical simulation state. +- `app.js` — fixed-step scheduler, mine surface extraction, controls, telemetry + and synthesized audio. +- `renderer.js` — real perspective mine surfaces, depth-buffered enemies and + pickups, projectiles, exit lattice and six-axis camera/cockpit. +- `../shared/qsol-native.js` — shared native WebGL batching and lifecycle. - `style.css` — responsive recovered-flight-computer presentation. - `index.html` — local-file-compatible application with a strict offline CSP. - `tests/` — deterministic, offline-boundary, and Playwright browser tests. @@ -100,3 +100,5 @@ Playwright is pinned for browser testing and is not a runtime dependency. Playable three-mine mini-campaign. Version 2 receipts are versioned rather than promised compatible with future simulation revisions. + +Native renderer architecture and verification: [migration audit](../docs/NATIVE_RENDERING.md). diff --git a/VECTOR_ZERO/app.js b/VECTOR_ZERO/app.js index 0ba23d6..e594714 100644 --- a/VECTOR_ZERO/app.js +++ b/VECTOR_ZERO/app.js @@ -3,10 +3,7 @@ const core = window.VectorZeroCore; const canvas = document.querySelector("#game"); - const ctx = canvas.getContext("2d", { alpha: false }); - const threeStage = window.QsolThree - ? window.QsolThree.create({ host: document.querySelector("#viewport"), source: canvas, preset: "vector" }) - : { render() {}, pulse() {} }; + const presentation = new window.VectorRenderer(canvas, core); const WIDTH = canvas.width; const HEIGHT = canvas.height; const FOCAL = 310; @@ -272,26 +269,6 @@ }; } - function clipNear(points) { - const output = []; - for (let index = 0; index < points.length; index += 1) { - const current = points[index]; - const previous = points[(index + points.length - 1) % points.length]; - const currentInside = current.z >= NEAR; - const previousInside = previous.z >= NEAR; - if (currentInside !== previousInside) { - const ratio = (NEAR - previous.z) / (current.z - previous.z); - output.push({ - x: previous.x + (current.x - previous.x) * ratio, - y: previous.y + (current.y - previous.y) * ratio, - z: NEAR, - }); - } - if (currentInside) output.push(current); - } - return output; - } - function screenPoint(point) { return { x: WIDTH / 2 + point.x * FOCAL / point.z, @@ -306,330 +283,9 @@ return camera.z >= NEAR ? { ...screenPoint(camera), depth: camera.z, camera } : null; } - function faceDrawable(face, basis, palette) { - if (core.distanceApprox(face.center.x - state.player.x, face.center.y - state.player.y, face.center.z - state.player.z) > core.CELL_SIZE * 10) return null; - const camera = face.vertices.map((vertex) => cameraPoint(vertex, basis)); - const clipped = clipNear(camera); - if (clipped.length < 3) return null; - const points = clipped.map(screenPoint); - const depth = clipped.reduce((sum, point) => sum + point.z, 0) / clipped.length; - const fog = clamp(1.15 - depth / (core.CELL_SIZE * 11), 0.2, 1); - const axisFactor = face.axis === "y" ? 0.86 : face.axis === "z" ? 0.94 : 1; - return { type: "face", depth, points, fill: shade(palette.wall, face.light * fog * axisFactor), edge: shade(palette.edge, fog * 0.75), phase: face.phase }; - } - - function entityDrawable(type, entity, basis, radius, color) { - const camera = cameraPoint(entity, basis); - if (camera.z < NEAR) return null; - const point = screenPoint(camera); - const size = clamp(radius * FOCAL / camera.z, 2, 92); - if (point.x < -size * 2 || point.x > WIDTH + size * 2 || point.y < -size * 2 || point.y > HEIGHT + size * 2) return null; - return { type, depth: camera.z, x: point.x, y: point.y, size, entity, color }; - } - - function drawFace(drawable) { - ctx.beginPath(); - ctx.moveTo(drawable.points[0].x, drawable.points[0].y); - for (let index = 1; index < drawable.points.length; index += 1) ctx.lineTo(drawable.points[index].x, drawable.points[index].y); - ctx.closePath(); - ctx.fillStyle = drawable.fill; - ctx.fill(); - ctx.strokeStyle = drawable.edge; - ctx.lineWidth = 0.7; - ctx.stroke(); - if (drawable.points.length === 4 && drawable.phase % 3 === 0) { - ctx.beginPath(); - ctx.moveTo((drawable.points[0].x + drawable.points[3].x) / 2, (drawable.points[0].y + drawable.points[3].y) / 2); - ctx.lineTo((drawable.points[1].x + drawable.points[2].x) / 2, (drawable.points[1].y + drawable.points[2].y) / 2); - ctx.strokeStyle = "rgba(8,14,12,0.28)"; - ctx.stroke(); - } - } - - function drawEnemy(drawable) { - const { x, y, size, entity } = drawable; - const pain = entity.pain > 0; - const color = pain ? "#f2dfb3" : drawable.color; - ctx.save(); - ctx.translate(x, y); - ctx.rotate(((state.tick + entity.phase) & 255) / 255 * Math.PI * 2); - ctx.fillStyle = "rgba(7,13,12,0.78)"; - ctx.strokeStyle = color; - ctx.shadowColor = color; - ctx.shadowBlur = Math.max(5, size * 0.38); - ctx.lineWidth = Math.max(1.5, size * 0.095); - ctx.beginPath(); - if (entity.kind === "sentinel" || entity.kind === "custodian") { - ctx.rect(-size * 0.48, -size * 0.48, size * 0.96, size * 0.96); - ctx.moveTo(-size * 0.7, 0); ctx.lineTo(size * 0.7, 0); - ctx.moveTo(0, -size * 0.7); ctx.lineTo(0, size * 0.7); - } else { - ctx.moveTo(0, -size * 0.72); - ctx.lineTo(size * 0.7, 0); - ctx.lineTo(0, size * 0.72); - ctx.lineTo(-size * 0.7, 0); - ctx.closePath(); - ctx.moveTo(-size * 0.62, -size * 0.3); ctx.lineTo(size * 0.62, size * 0.3); - ctx.moveTo(size * 0.62, -size * 0.3); ctx.lineTo(-size * 0.62, size * 0.3); - } - ctx.fill(); - ctx.stroke(); - ctx.fillStyle = entity.kind === "custodian" ? "#f0b35d" : "#d8654c"; - ctx.fillRect(-size * 0.12, -size * 0.12, size * 0.24, size * 0.24); - ctx.restore(); - ctx.save(); - ctx.strokeStyle = pain ? "#f8e5ae" : color; - ctx.lineWidth = Math.max(1, size * 0.055); - ctx.globalAlpha = 0.72 + Math.sin((state.tick + entity.phase) * 0.09) * 0.18; - const bracket = size * 1.08; - const corner = Math.max(3, size * 0.28); - ctx.beginPath(); - ctx.moveTo(x - bracket, y - bracket + corner); ctx.lineTo(x - bracket, y - bracket); ctx.lineTo(x - bracket + corner, y - bracket); - ctx.moveTo(x + bracket - corner, y - bracket); ctx.lineTo(x + bracket, y - bracket); ctx.lineTo(x + bracket, y - bracket + corner); - ctx.moveTo(x - bracket, y + bracket - corner); ctx.lineTo(x - bracket, y + bracket); ctx.lineTo(x - bracket + corner, y + bracket); - ctx.moveTo(x + bracket - corner, y + bracket); ctx.lineTo(x + bracket, y + bracket); ctx.lineTo(x + bracket, y + bracket - corner); - ctx.stroke(); - if (size > 6) { - ctx.font = "bold 7px monospace"; - ctx.textAlign = "center"; - ctx.fillStyle = pain ? "#fff0be" : color; - ctx.fillText(`${core.ENEMY_TYPES[entity.kind].name} // ${Math.max(0, entity.health)}`, x, y + bracket + 9); - } - ctx.restore(); - if (entity.kind === "custodian") { - const width = Math.max(10, size * 1.4); - ctx.fillStyle = "#160b0b"; - ctx.fillRect(x - width / 2, y - size - 5, width, 2); - ctx.fillStyle = "#d88b53"; - ctx.fillRect(x - width / 2, y - size - 5, width * Math.max(0, entity.health) / entity.maxHealth, 2); - } - } - - function drawPickup(drawable) { - const { x, y, size, entity } = drawable; - const pulse = 0.82 + Math.sin((state.tick + entity.id) * 0.08) * 0.16; - ctx.save(); - ctx.translate(x, y); - ctx.rotate((state.tick + entity.id * 17) * 0.025); - ctx.strokeStyle = drawable.color; - ctx.lineWidth = Math.max(1, size * 0.09); - if (entity.kind === "core") { - ctx.strokeRect(-size * pulse, -size * pulse, size * 2 * pulse, size * 2 * pulse); - ctx.rotate(Math.PI / 4); - ctx.strokeRect(-size * 0.55, -size * 0.55, size * 1.1, size * 1.1); - ctx.fillStyle = "rgba(235,176,94,0.32)"; - ctx.fillRect(-size * 0.3, -size * 0.3, size * 0.6, size * 0.6); - } else { - ctx.beginPath(); - ctx.arc(0, 0, size * 0.8, 0, Math.PI * 2); - ctx.moveTo(-size, 0); ctx.lineTo(size, 0); - ctx.moveTo(0, -size); ctx.lineTo(0, size); - ctx.stroke(); - } - ctx.restore(); - } - - function drawExit(drawable) { - const { x, y, size } = drawable; - const open = state.coresCollected >= state.coresRequired; - ctx.save(); - ctx.translate(x, y); - ctx.rotate(state.tick * 0.012); - ctx.strokeStyle = open ? "#83c8b3" : "#b25a45"; - ctx.lineWidth = Math.max(1, size * 0.08); - for (let index = 0; index < 3; index += 1) { - ctx.rotate(Math.PI / 3); - ctx.strokeRect(-size * (0.5 + index * 0.16), -size * (0.5 + index * 0.16), size * (1 + index * 0.32), size * (1 + index * 0.32)); - } - ctx.restore(); - } - - function drawProjectile(drawable) { - const radius = Math.max(1.2, drawable.size * 0.28); - ctx.fillStyle = drawable.color; - ctx.shadowColor = drawable.color; - ctx.shadowBlur = 6; - ctx.beginPath(); - ctx.arc(drawable.x, drawable.y, radius, 0, Math.PI * 2); - ctx.fill(); - ctx.shadowBlur = 0; - } - - function drawBackdrop(palette) { - const gradient = ctx.createRadialGradient(WIDTH / 2, HEIGHT / 2, 10, WIDTH / 2, HEIGHT / 2, WIDTH * 0.72); - gradient.addColorStop(0, palette.deep); - gradient.addColorStop(1, palette.fog); - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, WIDTH, HEIGHT); - ctx.fillStyle = "rgba(164,191,176,0.12)"; - for (let index = 0; index < 70; index += 1) { - const x = (index * 97 + 31) % WIDTH; - const y = (index * 53 + 19) % HEIGHT; - ctx.fillRect(x, y, index % 9 === 0 ? 2 : 1, 1); - } - } - - function drawCockpit(palette, targetLock) { - const player = state.player; - const speed = core.distanceApprox(player.vx, player.vy, player.vz); - ctx.strokeStyle = targetLock ? "rgba(244,184,91,0.98)" : "rgba(219,208,173,0.78)"; - ctx.lineWidth = targetLock ? 1.6 : 1; - ctx.beginPath(); - ctx.moveTo(WIDTH / 2 - 12, HEIGHT / 2); ctx.lineTo(WIDTH / 2 - 4, HEIGHT / 2); - ctx.moveTo(WIDTH / 2 + 4, HEIGHT / 2); ctx.lineTo(WIDTH / 2 + 12, HEIGHT / 2); - ctx.moveTo(WIDTH / 2, HEIGHT / 2 - 12); ctx.lineTo(WIDTH / 2, HEIGHT / 2 - 4); - ctx.moveTo(WIDTH / 2, HEIGHT / 2 + 4); ctx.lineTo(WIDTH / 2, HEIGHT / 2 + 12); - ctx.stroke(); - if (targetLock) { - ctx.font = "bold 7px monospace"; - ctx.textAlign = "center"; - ctx.fillStyle = "#efb45e"; - ctx.fillText("FIRE SOLUTION", WIDTH / 2, HEIGHT / 2 + 23); - ctx.textAlign = "left"; - } - ctx.fillStyle = "rgba(4,8,7,0.8)"; - ctx.fillRect(0, 0, WIDTH, 16); - ctx.fillRect(0, HEIGHT - 27, WIDTH, 27); - ctx.font = "8px monospace"; - ctx.textBaseline = "middle"; - ctx.fillStyle = palette.accent; - ctx.fillText(`MINE 0${state.sector} // ${state.blueprint.signature}`, 7, 8); - ctx.textAlign = "center"; - ctx.fillStyle = "#d5d1b6"; - ctx.fillText(`CORES ${state.coresCollected}/${state.coresRequired}`, WIDTH / 2, 8); - ctx.textAlign = "right"; - ctx.fillText(String(state.score).padStart(7, "0"), WIDTH - 7, 8); - ctx.textAlign = "left"; - ctx.fillStyle = "#6e8178"; - ctx.fillText("SHIELD", 7, HEIGHT - 19); - ctx.fillText("ENERGY", 7, HEIGHT - 8); - ctx.fillStyle = "#15201d"; - ctx.fillRect(48, HEIGHT - 22, 104, 5); - ctx.fillRect(48, HEIGHT - 11, 104, 5); - ctx.fillStyle = player.shield < 28 ? "#d96750" : "#77b4a5"; - ctx.fillRect(48, HEIGHT - 22, 104 * player.shield / player.maxShield, 5); - ctx.fillStyle = "#d8a15f"; - ctx.fillRect(48, HEIGHT - 11, 104 * player.energy / 1000, 5); - ctx.fillStyle = "#9aa79e"; - ctx.fillText(`MISSILES ${String(player.missiles).padStart(2, "0")}`, 174, HEIGHT - 14); - ctx.fillText(`VECTOR ${String(speed).padStart(3, "0")}`, 279, HEIGHT - 14); - ctx.fillText(`HOSTILES ${String(state.enemies.length).padStart(2, "0")}`, 382, HEIGHT - 14); - if (noticeTicks > 0) { - ctx.textAlign = "center"; - ctx.fillStyle = "rgba(4,8,7,0.82)"; - ctx.fillRect(WIDTH / 2 - 105, 24, 210, 16); - ctx.fillStyle = palette.accent; - ctx.fillText(notice, WIDTH / 2, 32); - } - ctx.textAlign = "left"; - } - - function drawAutomap() { - const current = core.worldCell(state.player); - const cellSize = 15; - const mapWidth = state.blueprint.width * cellSize; - const mapHeight = state.blueprint.depth * cellSize; - const left = WIDTH / 2 - mapWidth / 2; - const top = HEIGHT / 2 - mapHeight / 2; - ctx.fillStyle = "rgba(2,6,5,0.9)"; - ctx.fillRect(left - 18, top - 27, mapWidth + 36, mapHeight + 48); - ctx.strokeStyle = "#536e65"; - ctx.strokeRect(left - 18, top - 27, mapWidth + 36, mapHeight + 48); - ctx.font = "8px monospace"; - ctx.fillStyle = "#d5a15f"; - ctx.fillText(`MINE SCHEMATIC // LAYER ${current.y + 1}/${state.blueprint.height}`, left - 8, top - 14); - for (const cell of state.blueprint.cells) { - if (cell.y !== current.y) continue; - ctx.fillStyle = "#14231f"; - ctx.fillRect(left + cell.x * cellSize + 1, top + cell.z * cellSize + 1, cellSize - 2, cellSize - 2); - } - for (const pickup of state.pickups) { - const cell = core.worldCell(pickup); - if (cell.y !== current.y || pickup.kind !== "core") continue; - ctx.fillStyle = "#d8a15f"; - ctx.fillRect(left + cell.x * cellSize + 5, top + cell.z * cellSize + 5, 5, 5); - } - for (const enemy of state.enemies) { - const cell = core.worldCell(enemy); - if (cell.y !== current.y) continue; - ctx.fillStyle = "#c2604b"; - ctx.fillRect(left + cell.x * cellSize + 6, top + cell.z * cellSize + 6, 3, 3); - } - ctx.fillStyle = "#79b9aa"; - ctx.beginPath(); - ctx.arc(left + current.x * cellSize + cellSize / 2, top + current.z * cellSize + cellSize / 2, 4, 0, Math.PI * 2); - ctx.fill(); - } - function render() { - if (!state) { - ctx.fillStyle = "#06100e"; - ctx.fillRect(0, 0, WIDTH, HEIGHT); - ctx.fillStyle = "#52675f"; - ctx.font = "9px monospace"; - ctx.fillText("VECTOR ZERO FLIGHT COMPUTER // STANDBY", 12, 18); - threeStage.render({ tick: 0, speed: 0.08, activity: 0.07 }); - return; - } buildMineFaces(); - const palette = PALETTES[state.sector - 1] || PALETTES[0]; - drawBackdrop(palette); - const basis = core.getBasis(state.player.yaw, state.player.pitch, state.player.roll); - const drawables = []; - for (const face of mineFaces) { - const drawable = faceDrawable(face, basis, palette); - if (drawable) drawables.push(drawable); - } - const enemyColors = { drone: "#8aa698", hunter: "#b98252", sentinel: "#9c6255", custodian: "#d28c55" }; - for (const enemy of state.enemies) { - const radius = enemy.kind === "custodian" ? 560 : enemy.kind === "sentinel" ? 430 : 360; - const drawable = entityDrawable("enemy", enemy, basis, radius, enemyColors[enemy.kind]); - if (drawable) drawables.push(drawable); - } - const pickupColors = { core: "#e7ad62", energy: "#74b9ad", shield: "#aec39b", missiles: "#c87955" }; - for (const pickup of state.pickups) { - const drawable = entityDrawable("pickup", pickup, basis, pickup.kind === "core" ? 235 : 170, pickupColors[pickup.kind]); - if (drawable) drawables.push(drawable); - } - const exit = entityDrawable("exit", state.blueprint.exit, basis, 510, palette.accent); - if (exit) drawables.push(exit); - for (const projectile of state.projectiles) { - const color = projectile.kind === "laser" ? "#f4d477" : projectile.kind === "missile" ? "#e77a52" : "#73b8a8"; - const drawable = entityDrawable("projectile", projectile, basis, projectile.kind === "missile" ? 130 : 72, color); - if (drawable) drawables.push(drawable); - } - drawables.sort((first, second) => second.depth - first.depth); - const offsetX = shake > 0 ? ((state.tick * 17) % (shake * 2 + 1)) - shake : 0; - const offsetY = shake > 0 ? ((state.tick * 11) % (shake * 2 + 1)) - shake : 0; - ctx.save(); - ctx.translate(offsetX, offsetY); - for (const drawable of drawables) { - if (drawable.type === "face") drawFace(drawable); - else if (drawable.type === "enemy") drawEnemy(drawable); - else if (drawable.type === "pickup") drawPickup(drawable); - else if (drawable.type === "exit") drawExit(drawable); - else drawProjectile(drawable); - } - const targetLock = drawables.some((drawable) => drawable.type === "enemy" && - Math.hypot(drawable.x - WIDTH / 2, drawable.y - HEIGHT / 2) < Math.max(13, drawable.size * 0.72)); - drawCockpit(palette, targetLock); - if (automap) drawAutomap(); - ctx.restore(); - if (state.player.hurt > 0 || flash > 0) { - ctx.fillStyle = `rgba(151,45,33,${Math.max(state.player.hurt / 50, flash / 30)})`; - ctx.fillRect(0, 0, WIDTH, HEIGHT); - } - if (paused) { - ctx.fillStyle = "rgba(0,0,0,0.22)"; - ctx.fillRect(0, 0, WIDTH, HEIGHT); - } - threeStage.render({ - tick: state.tick, - speed: Math.hypot(state.player.vx || 0, state.player.vy || 0, state.player.vz || 0) / core.FP, - danger: 1 - state.player.shield / Math.max(1, state.player.maxShield || 100), - activity: Math.max(flash / 10, state.projectiles.length ? 0.42 : 0.16), - heading: state.player.yaw / core.ANGLE_MAX * Math.PI * 2, - }); + presentation.render(state, mineFaces, { automap, paused, notice: noticeTicks > 0 ? notice : "", palette: state ? PALETTES[state.sector - 1] || PALETTES[0] : PALETTES[0] }); } function showMessage(kicker, title, body, button, action) { @@ -687,7 +343,6 @@ if (event.type === "shot") { flash = Math.max(flash, event.kind === "missile" ? 8 : 3); shake = Math.max(shake, event.kind === "missile" ? 3 : 1); - threeStage.pulse("shot", event.kind === "missile" ? 0.72 : 0.3); } else if (event.type === "enemy-hit") { notice = `${core.ENEMY_TYPES[event.kind].name} // ${event.health}`; noticeTicks = 35; @@ -695,12 +350,10 @@ notice = `${core.ENEMY_TYPES[event.kind].name} ERASED`; noticeTicks = 60; shake = Math.max(shake, event.kind === "custodian" ? 7 : 4); - threeStage.pulse("blast", event.kind === "custodian" ? 0.9 : 0.58); } else if (event.type === "player-hit") { notice = `SHIELD FRACTURE // -${event.amount}`; noticeTicks = 50; shake = Math.max(shake, 5); - threeStage.pulse("impact", 0.82); } else if (event.type === "pickup") { notice = event.kind === "core" ? `VECTOR CORE ${event.cores}/${state.coresRequired}` : `${event.kind.toUpperCase()} RECOVERED`; noticeTicks = 75; diff --git a/VECTOR_ZERO/docs/DETERMINISM.md b/VECTOR_ZERO/docs/DETERMINISM.md index dc5ef18..7e19d8f 100644 --- a/VECTOR_ZERO/docs/DETERMINISM.md +++ b/VECTOR_ZERO/docs/DETERMINISM.md @@ -1,7 +1,7 @@ # Determinism contract VECTOR ZERO separates its canonical gameplay simulation from browser -presentation. `core.js` is the contract surface; Canvas, CSS, Web Audio, pointer +presentation. `core.js` is the contract surface; Three.js, CSS, Web Audio, pointer lock, animation frames, and monitor refresh rate are non-canonical. ## Canonical inputs and update diff --git a/VECTOR_ZERO/index.html b/VECTOR_ZERO/index.html index 4a187cc..35696c7 100644 --- a/VECTOR_ZERO/index.html +++ b/VECTOR_ZERO/index.html @@ -7,7 +7,7 @@ VECTOR ZERO — Deterministic 6DOF Mine Shooter - + @@ -131,9 +131,9 @@

PAUSED

- - + + diff --git a/VECTOR_ZERO/renderer.js b/VECTOR_ZERO/renderer.js new file mode 100644 index 0000000..ce2563e --- /dev/null +++ b/VECTOR_ZERO/renderer.js @@ -0,0 +1,275 @@ +(function (root) { + "use strict"; + const T = root.THREE; + class VectorRenderer { + constructor(canvas, core) { + this.core = core; + this.stage = new root.QsolNative.Stage(canvas, { + width: 480, + height: 270, + perspective: true, + fov: (2 * Math.atan(135 / 310) * 180) / Math.PI, + }); + this.stage.scene.fog = new T.Fog("#071310", 18, 65); + this.signature = ""; + this.mine = null; + this.edges = null; + this.target = new T.Vector3(); + this.projected = new T.Vector3(); + this.cockpitPoint = new T.Vector3(); + this.map = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + this.map.classList.add("native-map"); + canvas.parentElement.appendChild(this.map); + this.stage.ownedNodes.push(this.map); + const reticle = document.createElement("span"); + reticle.className = "native-reticle"; + reticle.textContent = "+"; + canvas.parentElement.appendChild(reticle); + this.stage.ownedNodes.push(reticle); + } + build(state, faces, palette) { + if (this.signature === state.blueprint.signature) return; + this.signature = state.blueprint.signature; + for (const mesh of [this.mine, this.edges]) + if (mesh) { + mesh.removeFromParent(); + mesh.geometry.dispose(); + mesh.material.dispose(); + } + const vertices = [], + colors = [], + color = new T.Color(palette.wall), + fp = this.core.FP; + for (const face of faces) + for (const i of [0, 1, 2, 0, 2, 3]) { + const v = face.vertices[i]; + vertices.push(v.x / fp, v.y / fp, v.z / fp); + colors.push( + color.r * face.light, + color.g * face.light, + color.b * face.light, + ); + } + const geometry = new T.BufferGeometry(); + geometry.setAttribute( + "position", + new T.Float32BufferAttribute(vertices, 3), + ); + geometry.setAttribute("color", new T.Float32BufferAttribute(colors, 3)); + geometry.computeVertexNormals(); + this.mine = new T.Mesh( + geometry, + new T.MeshStandardMaterial({ + vertexColors: true, + side: T.DoubleSide, + roughness: 0.85, + }), + ); + this.mine.name = "mine-surfaces"; + this.mine.userData.ownedGeometry = true; + this.edges = new T.LineSegments( + new T.EdgesGeometry(geometry), + new T.LineBasicMaterial({ color: palette.edge }), + ); + this.edges.name = "mine-seams"; + this.edges.userData.ownedGeometry = true; + this.stage.group("terrain").add(this.mine, this.edges); + } + render(state, faces, ui = {}) { + const s = this.stage, + c = this.core; + s.begin(); + this.map.style.display = ui.automap && state ? "block" : "none"; + if (!state) { + s.text("VECTOR ZERO FLIGHT COMPUTER // READY"); + s.finish(); + return; + } + this.build(state, faces, ui.palette); + const p = state.player, + b = c.getBasis(p.yaw, p.pitch, p.roll), + fp = c.FP; + s.camera.position.set(p.x / fp, p.y / fp, p.z / fp); + s.camera.up.set(b.up.x, b.up.y, b.up.z).normalize(); + this.target + .set(b.forward.x, b.forward.y, b.forward.z) + .normalize() + .add(s.camera.position); + s.camera.lookAt(this.target); + s.camera.updateMatrixWorld(); + let targetLock = null; + s.scene.background.set(ui.palette.fog); + s.scene.fog.color.set(ui.palette.fog); + for (const e of state.enemies) { + const r = + (e.kind === "custodian" ? 560 : e.kind === "sentinel" ? 430 : 360) / + fp, + x = e.x / fp, + y = e.y / fp, + z = e.z / fp; + this.projected.set(x, y, z).project(s.camera); + if ( + this.projected.z > -1 && + this.projected.z < 1 && + Math.hypot(this.projected.x, this.projected.y) < 0.11 + ) + targetLock = e; + const color = e.pain + ? "#ffe0a2" + : { + drone: "#8aa698", + hunter: "#b98252", + sentinel: "#9c6255", + custodian: "#d28c55", + }[e.kind]; + s.item( + "enemies", + "diamond", + x, + y, + z, + r, + r * 0.7, + r, + color, + 0, + s.reducedMotion ? 0 : state.tick * 0.013, + e.phase || 0, + ); + s.item( + "enemy-cages", + e.kind === "custodian" ? "sphere" : "box", + x, + y, + z, + r * 2, + r * 1.5, + r * 2, + color, + 0, + s.reducedMotion ? 0 : -state.tick * 0.009, + 0, + "wire", + ); + s.item( + "enemies", + "sphere", + x, + y, + z, + r * 0.35, + r * 0.35, + r * 0.35, + "#d96a4a", + 0, + 0, + 0, + "glow", + ); + } + const colors = { + core: "#e7ad62", + energy: "#74b9ad", + shield: "#aec39b", + missiles: "#c87955", + }; + for (const e of state.pickups) { + const r = (e.kind === "core" ? 235 : 170) / fp; + s.item( + "pickups", + e.kind === "core" ? "diamond" : "ring", + e.x / fp, + e.y / fp, + e.z / fp, + r, + r, + r, + colors[e.kind], + 0, + s.reducedMotion ? 0 : state.tick * 0.018, + ); + } + const exit = state.blueprint.exit, + open = state.coresCollected >= state.coresRequired; + for (let i = 0; i < 3; i++) + s.item( + "exit", + "ring", + exit.x / fp, + exit.y / fp, + exit.z / fp, + (510 + i * 90) / fp, + (510 + i * 90) / fp, + 1, + open ? "#83c8b3" : "#b25a45", + i * 0.65, + i * 0.5, + 0, + "glow", + ); + for (const e of state.projectiles) + s.item( + "projectiles", + "sphere", + e.x / fp, + e.y / fp, + e.z / fp, + e.kind === "missile" ? 0.13 : 0.07, + 0.07, + 0.07, + e.kind === "laser" + ? "#f4d477" + : e.kind === "missile" + ? "#e77a52" + : "#73b8a8", + 0, + 0, + 0, + "glow", + ); + // A real cockpit frame moves with all six camera axes. + s.camera.updateMatrixWorld(); + for (const side of [-1, 1]) { + const point = this.cockpitPoint + .set(side * 0.12, -0.08, -0.3) + .applyMatrix4(s.camera.matrixWorld); + const mesh = s.item( + "player", + "box", + point.x, + point.y, + point.z, + 0.025, + 0.02, + 0.18, + "#425e54", + ); + const batch = [...s.batches.values()].find((b) => b.mesh === mesh); + this.stage.scratch.quaternion.copy(s.camera.quaternion); + this.stage.scratch.updateMatrix(); + mesh.setMatrixAt(batch.used - 1, this.stage.scratch.matrix); + } + s.text( + `${targetLock ? "FIRE SOLUTION // " + c.ENEMY_TYPES[targetLock.kind].name + " " + targetLock.health + "\n" : ""}MINE ${state.sector} // CORES ${state.coresCollected}/${state.coresRequired} // SCORE ${state.score}\nSHIELD ${p.shield} ENERGY ${p.energy} MISSILES ${p.missiles} HOSTILES ${state.enemies.length}${ui.notice ? "\n" + ui.notice : ""}`, + ); + if (ui.automap) { + const current = c.worldCell(p), + bp = state.blueprint; + this.map.setAttribute("viewBox", `0 0 ${bp.width} ${bp.depth}`); + let html = ""; + for (const cell of bp.cells) + if (cell.y === current.y) + html += ``; + for (const e of [...state.pickups, ...state.enemies]) { + const cell = c.worldCell(e); + if (cell.y === current.y) + html += ``; + } + html += ``; + this.map.innerHTML = html; + } + s.finish(); + } + } + root.VectorRenderer = VectorRenderer; +})(window); diff --git a/VECTOR_ZERO/tests/browser-smoke.js b/VECTOR_ZERO/tests/browser-smoke.js index 06f2a50..7c5c8e3 100644 --- a/VECTOR_ZERO/tests/browser-smoke.js +++ b/VECTOR_ZERO/tests/browser-smoke.js @@ -9,8 +9,8 @@ const core = require("../core.js"); (async () => { const browser = await chromium.launch({ headless: true, - executablePath: process.env.VECTOR_ZERO_CHROMIUM || chromium.executablePath(), - args: ["--no-sandbox", "--disable-setuid-sandbox"], + executablePath: process.env.GAMES_CHROMIUM || process.env.VECTOR_ZERO_CHROMIUM || chromium.executablePath(), + args: ["--no-sandbox", "--disable-setuid-sandbox", "--enable-unsafe-swiftshader"], }); const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 }); const errors = []; @@ -19,7 +19,7 @@ const core = require("../core.js"); page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`)); page.on("request", (request) => requests.push(request.url())); - const url = pathToFileURL(path.join(__dirname, "..", "index.html")).href; + const url = process.env.GAMES_TEST_ORIGIN ? `${process.env.GAMES_TEST_ORIGIN}/VECTOR_ZERO/index.html` : pathToFileURL(path.join(__dirname, "..", "index.html")).href; await page.goto(url, { waitUntil: "load" }); assert.equal(await page.title(), "VECTOR ZERO — Deterministic 6DOF Mine Shooter"); assert.equal(await page.locator("#bootLayer").isVisible(), true); @@ -166,7 +166,7 @@ const core = require("../core.js"); assert.ok(mobile.launch.top >= mobile.gameView.top - 1, "mobile launch button must remain visible"); await page.screenshot({ path: path.join(__dirname, "vector-zero-mobile.png"), fullPage: true }); - assert.deepEqual(requests.filter((requestUrl) => !requestUrl.startsWith("file://")), []); + assert.deepEqual(requests.filter((requestUrl) => !(process.env.GAMES_TEST_ORIGIN ? new URL(requestUrl).origin === process.env.GAMES_TEST_ORIGIN : requestUrl.startsWith("file://"))), []); assert.deepEqual(errors, []); await browser.close(); process.stdout.write("ok - boot, six-axis flight, combat, projection, pause, terminal replay drain, telemetry, offline boundary, and mobile layout\n"); diff --git a/VECTOR_ZERO/tests/offline.test.js b/VECTOR_ZERO/tests/offline.test.js index c9a7682..0fa344b 100644 --- a/VECTOR_ZERO/tests/offline.test.js +++ b/VECTOR_ZERO/tests/offline.test.js @@ -19,9 +19,7 @@ assert.match(app, /requestAnimationFrame\(frame\)/); assert.match(app, /core\.tryRecordInput/); assert.match(app, /core\.encodeReplay/); assert.match(app, /core\.decodeReplay/); -assert.match(app, /function clipNear/); assert.match(app, /function buildMineFaces/); -assert.match(app, /function drawEnemy/); assert.match(app, /function createAudio/); assert.match(notice, /independently implemented/i); assert.match(notice, /No source code/i); @@ -31,3 +29,8 @@ for (const relative of ["core.js", "app.js", "style.css", "index.html", "README. } process.stdout.write("ok - VECTOR ZERO is a self-contained offline 6DOF browser game\n"); + +const renderer = fs.readFileSync(path.join(root, "renderer.js"), "utf8"); +assert.doesNotMatch(app + renderer, /getContext\s*\(\s*["']2d["']/); +assert.match(renderer, /QsolNative\.Stage/); +assert.match(renderer, /s\.finish\(\)/); diff --git a/docs/NATIVE_RENDERING.md b/docs/NATIVE_RENDERING.md new file mode 100644 index 0000000..bf02c44 --- /dev/null +++ b/docs/NATIVE_RENDERING.md @@ -0,0 +1,120 @@ +# Native gameplay rendering migration + +The previous stage added an independent translucent WebGL canvas while each game +continued to draw its complete playable world with Canvas 2D. That architecture +has been removed. `shared/qsol-three-stage.js` and its blending CSS are retired. + +Every migrated title now constructs its own renderer adapter, backed by +`QsolNative.Stage`: one `THREE.WebGLRenderer` bound to the **existing** game canvas, +one `THREE.Scene`, and a real camera. The simulation's scheduler calls the adapter +after its existing fixed-step updates. The adapter reads state and uploads instance +transforms, colors and counts; it never calls the simulation RNG or changes state. +All six `core.js` files remain unchanged. + +## Per-game audit and resulting scene + +| Game | Simulation | Primary gameplay renderer | Camera | Native Three.js entities | Legacy Canvas gameplay renderer | +|---|---|---|---|---|---| +| BLACKSTAR_AGA | Existing fixed-point campaign and input receipts | Three.js/WebGL | Perspective FPS | Collision-grid rooms, doors, ceiling ribs, faceted opposition, pickups, exit console, camera-relative weapon, muzzle effects | No | +| CARTRIDGE_ZERO | All seven existing canonical programs and CZ01 receipts | Three.js/WebGL | Orthographic with cabinet projection | Bricks, paddle, ball; lanes and ships; formations and diving craft; river banks, fuel and towers; walls, tanks and shells; aircraft, boats, subs and turrets | No | +| PIXELWARFRONT | Existing fixed-tick RTS and command receipts | Three.js/WebGL | Orthographic with cabinet projection | Terrain, resources, HQ, relays, refinery tanks, factories, turrets, drones, rangers, crawlers, bullets, health/construction bars, selection and order indicators | No | +| VECTOR_ZERO | Existing fixed-point 6DOF mines and VZ02 receipts | Three.js/WebGL | Perspective, oriented by simulation basis | BufferGeometry mine surfaces, LineSegments seams, faceted and wireframe enemies, cores, supplies, projectiles, exit rings and cockpit geometry | No | +| SEEDSTORM | Existing fixed-tick shooter and input receipts | Three.js/WebGL | Orthographic | Extruded strike craft and six enemy silhouettes, bullets, pickups, focus and invulnerability rings, boss/health bars, bomb wave, impacts and industrial canyon strata | No | +| SUBSPACE / INERTIA ZERO | Existing 60 Hz inertial combat and campaign logic | Three.js/WebGL | Following orthographic | Eight extruded hulls, asteroids, crystals, fortifications, projectiles, pickups, objectives, shields, engine trails, explosion waves and offscreen threats | No | + +BLACKSTAR's old `raycast`, column drawing, sprite painter and software z-buffer +are gone. Its fixed-point map, blocking-cell rules, enemy AI and hitscan remain +in the core. VECTOR retains surface extraction and diagnostic projection helpers; +its Canvas near-plane clipping and painter's algorithm are gone. Three.js performs +clipping and depth testing on the actual mine geometry. + +CARTRIDGE's seven `draw*` pipelines are replaced individually. GRIDBURN retains its +lane/depth mapping, RIFT RUNNER interpolates the canonical river rows, IRON CIRCUIT +uses DIR16 for turret direction, and SKYWATER retains both player and AI turrets. +Each mode has its own scene inventory and palette, including monochrome mode. + +PIXELWARFRONT's resources, buildings, units, selection box and order paths now live +in GPU batches. Its ground-plane projection remains identical to simulation/input +coordinates; a cabinet projection reveals elevated surfaces. SEEDSTORM keeps its +original craft outlines as extruded geometry with faceted cores and separate combat +and terrain depths. SUBSPACE retains fixed-step interpolation and cloak visibility +rules, renders its eight original silhouettes as geometry, and keeps SVG radar as +an auxiliary tactical display. + +## UI boundary + +Menus, receipts, scoring panels and conventional text HUDs remain HTML. BLACKSTAR +and VECTOR automaps and SUBSPACE radar are SVG UI. World-space health bars, +selection rings, projectile trails, focus rings, objective geometry and other +in-world indicators are WebGL objects. **There is no remaining 2D-canvas use**, not +even texture generation, in these six titles. No CanvasTexture or image of the old +game renderer is used. The local Three.js distribution is still bundled for offline +classic-script loading; these games no longer load the unused Wasm overlay sampler. + +## Performance and lifecycle + +Repeated primitives are `InstancedMesh` batches keyed by semantic category, shape +and material. Buffers double only when a population first exceeds capacity; normal +frames update transforms/colors/counts in place. Empty populations have zero draw +count, so removed entities cannot leave stale meshes. Shapes and materials are +shared. VECTOR rebuilds its static mine surface and edge geometry only when the +blueprint signature changes and disposes the replaced resources. + +The renderer uses the CSS canvas bounds, caps device pixel ratio at 2, and keeps +logical simulation dimensions independent of its drawing buffer. Reduced-motion +changes are observed live: decorative rotation, scrolling and exhaust/spark motion +are reduced without freezing gameplay. Context loss is reported in the HUD and +restoration resumes rendering from current state. Non-persisted pagehide disposes +renderer, geometries, instance resources, materials and listeners; bfcache preserves +the live instance. Explicit disposal is idempotent. + +## Verification + +- `node tests/native-renderers.static.js`: walks each entrypoint's local script + dependencies, rejects context acquisition/CanvasTexture/OffscreenCanvas outside + the vendored renderer, and checks the one-canvas native adapter boundary. +- `node tests/native-renderers.browser.js`: real Chromium/WebGL; throws on every + 2D-context request, checks renderer/scene/camera object types, scene populations, + finite instance data, canonical-state-to-instance positions and nonblank GPU + pixels. Hiding all gameplay groups must remove substantial framebuffer content. + Tests resizing, live motion preference changes, context loss/recovery, repeated + scene synchronization without geometry growth/state mutation, and disposal. +- Existing per-game core, offline and browser smoke suites cover controls, combat, + all seven cartridge modes, pause/restart, receipts, RTS selection/commands and + mobile layouts. SUBSPACE's DOM-only harness is explicitly simulation-only. +- Browser tests accept `GAMES_CHROMIUM` for an installed Chromium executable and + `GAMES_TEST_ORIGIN` for an isolated HTTP test server. Without the latter they + exercise direct-file startup. Neither setting changes production code. + +Development example (Playwright is a development-only dependency): + +```sh +npm --prefix BLACKSTAR_AGA ci +npx --prefix BLACKSTAR_AGA playwright install chromium +NODE_PATH=./BLACKSTAR_AGA/node_modules node tests/native-renderers.browser.js +``` + +The `Native Three.js gameplay` workflow runs the static and real-WebGL gates and +retains screenshots as build artifacts. See the PR for the exact executed results. + +## Removal gate + +For **each of the six titles**, removing the sole Three.js canvas removes the +rendered gameplay world: player, opposition, projectiles and world geometry. +Only conventional interface elements remain. There is no complete playable visual +scene underneath or above WebGL. + +## Audited source boundaries + +| Title | State read by presentation | Replaced routines | UI retained | +|---|---|---|---| +| BLACKSTAR_AGA | `player`, `blueprint`, doors via `isBlockingCell`, `enemies`, `pickups`, `events` | `drawWorld`, `drawEnemySprite`, `drawEntities`, `drawWeapon`, software HUD/map | DOM controls, receipts and telemetry; DOM vital/ammo HUD and SVG map | +| CARTRIDGE_ZERO | `gameId`, `game` with each program's bricks/ball/lanes/rows/entities/tanks/targets/shots | `drawPrism`, `drawGridburn`, `drawOrbital`, `drawTalon`, `drawRiver`, `drawCircuit`, `drawSkywater` | Existing console UI, color switch, receipts; text-only in-game status | +| PIXELWARFRONT | `blueprint.terrain`, `resources`, `buildings`, `units`, `projectiles`; selected IDs and visual effects | `drawBackground`, `drawResource`, `drawBuilding`, `drawUnit`, `drawProjectile`, selection/order/effect painters | Existing selection/production/mission UI and receipts | +| VECTOR_ZERO | `blueprint.cells/exit`, `player` basis, `enemies`, `pickups`, `projectiles` | `faceDrawable`, `drawFace`, `drawEnemy`, `drawPickup`, `drawProjectile`, cockpit painter | DOM cockpit vitals and target notice; SVG layer map; existing controls/receipts | +| SEEDSTORM | `player`, `enemies`, `playerBullets`, `enemyBullets`, `pickups`, `bombPulse`; hit/effect records | `drawBackground`, `drawPlayer`, `drawEnemy`, `drawEntities`, impact/explosion and world-HUD painters | Existing score/lives/bombs/sector panels and receipts | +| SUBSPACE | `arena`, `ships`, `projectiles`, `pickups`, `particles`, `objectiveState`, interpolated positions | Canvas `render`, all world `draw*` methods, software radar | Existing HTML HUD, menu, help, score storage and audio; SVG radar | + +Input packing, collision, AI, scores, progression, audio scheduling, replay +recording/draining, and SUBSPACE's save/high-score code remain simulation/UI work. +Only presentation and its explicitly identified test boundary are replaced. diff --git a/shared/qsol-native.css b/shared/qsol-native.css new file mode 100644 index 0000000..2103c8b --- /dev/null +++ b/shared/qsol-native.css @@ -0,0 +1,47 @@ +/* Text-only conventional HUD; the sole gameplay canvas is opaque WebGL. */ +.native-hud { + position: absolute; + left: 2%; + right: 2%; + bottom: 2%; + z-index: 2; + pointer-events: none; + white-space: pre-line; + color: #e5d5b0; + font: 600 clamp(10px, 1.1vw, 15px)/1.55 monospace; + text-shadow: 0 2px 3px #000; + background: rgba(5, 10, 11, 0.78); + padding: 0.45em 0.7em; + border-left: 2px solid #b58c51; +} +.native-hud:empty { + display: none; +} +.native-error { + position: absolute; + inset: 25% 10%; + z-index: 20; + background: #141b1d; + color: #e5d5b0; + padding: 2em; +} +.native-map { + position: absolute; + inset: 12% 25%; + width: 50%; + height: 65%; + z-index: 3; + pointer-events: none; + background: #081211ed; +} +.native-reticle { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + color: #dbc89b; + font: 22px monospace; + pointer-events: none; + z-index: 2; + text-shadow: 0 1px 3px #000; +} diff --git a/shared/qsol-native.js b/shared/qsol-native.js new file mode 100644 index 0000000..3a808d8 --- /dev/null +++ b/shared/qsol-native.js @@ -0,0 +1,378 @@ +/* Native gameplay infrastructure. One existing canvas, no secondary renderer or 2D surface. */ +(function (root) { + "use strict"; + const T = root.THREE; + class Stage { + constructor( + canvas, + { + width = canvas.width, + height = canvas.height, + perspective = false, + fov = 55, + background = "#080d10", + } = {}, + ) { + this.canvas = canvas; + this.width = width; + this.height = height; + this.scene = new T.Scene(); + this.scene.background = new T.Color(background); + this.camera = perspective + ? new T.PerspectiveCamera(fov, width / height, 0.03, 200) + : new T.OrthographicCamera(0, width, 0, -height, 0.1, 6000); + if (!perspective) this.camera.position.z = 2000; + try { + this.renderer = new T.WebGLRenderer({ + canvas, + antialias: true, + alpha: false, + }); + } catch (error) { + const message = document.createElement("p"); + message.className = "native-error"; + message.textContent = + "This game requires WebGL. Enable hardware acceleration and reload."; + canvas.parentElement.appendChild(message); + throw error; + } + this.renderer.outputColorSpace = T.SRGBColorSpace; + this.scene.add(new T.HemisphereLight(0xdce9e5, 0x25303a, 1.5)); + const light = new T.DirectionalLight(0xffdcb2, 2.2); + light.position.set(-300, 500, 800); + this.scene.add(light); + this.groups = new Map(); + this.geometries = new Map(); + this.batches = new Map(); + this.scratch = new T.Object3D(); + this.color = new T.Color(); + this.geometries.set("box", new T.BoxGeometry(1, 1, 1)); + const slab = new T.Shape(); + slab.moveTo(-0.46, -0.46); + slab.lineTo(0.46, -0.46); + slab.lineTo(0.46, 0.46); + slab.lineTo(-0.46, 0.46); + slab.closePath(); + const bevel = new T.ExtrudeGeometry(slab, { + depth: 0.86, + bevelEnabled: true, + bevelThickness: 0.07, + bevelSize: 0.04, + bevelSegments: 1, + steps: 1, + }); + bevel.translate(0, 0, -0.43); + this.geometries.set("bevel", bevel); + this.geometries.set("sphere", new T.IcosahedronGeometry(1, 1)); + this.geometries.set("rock", new T.IcosahedronGeometry(1, 0)); + this.geometries.set("diamond", new T.OctahedronGeometry(1)); + this.geometries.set("ring", new T.TorusGeometry(1, 0.045, 4, 40)); + this.geometries.set("cylinder", new T.CylinderGeometry(1, 1, 1, 10)); + this.shape("dart", [ + [0, -1], + [-0.9, 0.75], + [0, 0.35], + [0.9, 0.75], + ]); + this.material = new T.MeshStandardMaterial({ + roughness: 0.7, + metalness: 0.25, + flatShading: true, + }); + this.glow = new T.MeshBasicMaterial({ toneMapped: false }); + this.wire = new T.MeshBasicMaterial({ wireframe: true }); + this.ownedNodes = []; + this.hud = document.createElement("div"); + this.hud.className = "native-hud"; + canvas.parentElement.appendChild(this.hud); + this.media = root.matchMedia("(prefers-reduced-motion: reduce)"); + this.reducedMotion = this.media.matches; + this.onMotion = (event) => { + this.reducedMotion = event.matches; + }; + this.media.addEventListener("change", this.onMotion); + this.lost = false; + this.disposed = false; + this.onLost = (event) => { + event.preventDefault(); + this.lost = true; + this.hud.textContent = "WebGL context lost — waiting for recovery"; + }; + this.onRestored = () => { + this.lost = false; + }; + canvas.addEventListener("webglcontextlost", this.onLost); + canvas.addEventListener("webglcontextrestored", this.onRestored); + this.onPageHide = (event) => { + if (!event.persisted) this.dispose(); + }; + root.addEventListener("pagehide", this.onPageHide); + // Read-only diagnostic entry point also used by the actual-WebGL regression suite. + canvas.nativeStage = this; + } + shape(name, points) { + if (this.geometries.has(name)) return; + const shape = new T.Shape(); + points.forEach(([x, y], i) => + i ? shape.lineTo(x, -y) : shape.moveTo(x, -y), + ); + shape.closePath(); + const geometry = new T.ExtrudeGeometry(shape, { + depth: 0.35, + bevelEnabled: true, + bevelThickness: 0.08, + bevelSize: 0.06, + bevelSegments: 1, + steps: 1, + }); + geometry.translate(0, 0, -0.175); + this.geometries.set(name, geometry); + } + group(name) { + if (!this.groups.has(name)) { + const group = new T.Group(); + group.name = name; + this.groups.set(name, group); + this.scene.add(group); + } + return this.groups.get(name); + } + begin(background) { + if (background) this.scene.background.set(background); + for (const batch of this.batches.values()) batch.used = 0; + } + item( + group, + shape, + x, + y, + z, + sx, + sy, + sz, + color, + rx = 0, + ry = 0, + rz = 0, + style = "solid", + ) { + const key = `${group}/${shape}/${style}`; + let batch = this.batches.get(key); + if (!batch) { + const material = + style === "glow" + ? this.glow + : style === "wire" + ? this.wire + : this.material; + const mesh = new T.InstancedMesh( + this.geometries.get(shape), + material, + 32, + ); + mesh.name = key; + mesh.count = 0; + mesh.frustumCulled = false; + mesh.instanceMatrix.setUsage(T.DynamicDrawUsage); + this.group(group).add(mesh); + batch = { mesh, used: 0, capacity: 32 }; + this.batches.set(key, batch); + } + if (batch.used === batch.capacity) { + const old = batch.mesh, + mesh = new T.InstancedMesh( + old.geometry, + old.material, + batch.capacity * 2, + ); + mesh.name = key; + mesh.frustumCulled = false; + mesh.instanceMatrix.setUsage(T.DynamicDrawUsage); + mesh.instanceMatrix.array.set(old.instanceMatrix.array); + if (old.instanceColor) { + mesh.setColorAt(0, this.color); + mesh.instanceColor.array.set(old.instanceColor.array); + } + old.parent.add(mesh); + old.parent.remove(old); + old.dispose(); + batch.mesh = mesh; + batch.capacity *= 2; + } + const object = this.scratch; + object.position.set(x, y, z); + object.scale.set( + Math.max(0.00001, sx), + Math.max(0.00001, sy), + Math.max(0.00001, sz), + ); + object.rotation.set(rx, ry, rz); + object.updateMatrix(); + batch.mesh.setMatrixAt(batch.used, object.matrix); + batch.mesh.setColorAt(batch.used, this.color.set(color)); + batch.used++; + return batch.mesh; + } + flat(group, shape, x, y, z, sx, sy, sz, color, angle = 0, style = "solid") { + return this.item( + group, + shape, + x, + -y, + z, + sx, + sy, + sz, + color, + 0, + 0, + -angle, + style, + ); + } + line(group, x1, y1, x2, y2, z, color, width = 1) { + this.flat( + group, + "box", + (x1 + x2) / 2, + (y1 + y2) / 2, + z, + Math.hypot(x2 - x1, y2 - y1), + width, + width, + color, + Math.atan2(y2 - y1, x2 - x1), + "glow", + ); + } + ring(group, x, y, z, r, color) { + this.flat(group, "ring", x, y, z, r, r, 1, color, 0, "glow"); + } + health(x, y, z, width, ratio, color) { + this.flat("indicators", "box", x, y, z, width, 3, 1, "#10191b"); + const w = width * Math.max(0, Math.min(1, ratio)); + this.flat( + "indicators", + "box", + x - width / 2 + w / 2, + y, + z + 1, + w, + 2, + 1, + color, + 0, + "glow", + ); + } + effects(effects, tick) { + for (const e of effects) { + const age = tick - e.startTick, + life = 1 - age / e.duration; + if (life <= 0 || age < 0) continue; + const r = (e.major ? 65 : 24) * (1 - life) + 3; + this.ring("effects", e.x, e.y, 25, r, "#d89459"); + if (!this.reducedMotion) + for (let i = 0; i < 10; i++) { + const a = i * 2.399 + ((e.seed || 0) % 17), + d = r * (0.5 + i / 10); + this.flat( + "effects", + "diamond", + e.x + Math.cos(a) * d, + e.y + Math.sin(a) * d, + 20 + i * 2, + 2 * life, + 2 * life, + 4 * life, + i % 2 ? "#bbc8c1" : "#db7649", + a, + ); + } + } + } + text(value) { + if (!this.lost && this.hud.textContent !== value) + this.hud.textContent = value; + } + resize() { + if (this.disposed) return; + const rect = this.canvas.getBoundingClientRect(); + const w = Math.max(1, Math.round(rect.width || this.width)), + h = Math.max(1, Math.round(rect.height || this.height)); + const dpr = Math.min(2, root.devicePixelRatio || 1); + if (this.cssWidth !== w || this.cssHeight !== h || this.dpr !== dpr) { + this.cssWidth = w; + this.cssHeight = h; + this.dpr = dpr; + this.renderer.setPixelRatio(dpr); + this.renderer.setSize(w, h, false); + } + } + oblique(x, y) { + // Cabinet projection: z=0 retains exact simulation/input coordinates. + // Only raised geometry shifts, revealing thickness in the retro playfields. + this.skew = { x, y }; + this.projectCamera(); + } + projectCamera() { + this.camera.updateProjectionMatrix(); + if (this.skew && this.camera.isOrthographicCamera) { + const x = (2 * this.skew.x) / (this.camera.right - this.camera.left); + const y = (2 * this.skew.y) / (this.camera.top - this.camera.bottom); + const m = this.camera.projectionMatrix.elements; + m[8] += x; + m[9] += y; + m[12] += x * this.camera.position.z; + m[13] += y * this.camera.position.z; + this.camera.projectionMatrixInverse + .copy(this.camera.projectionMatrix) + .invert(); + } + } + view(width, height, x = 0, y = 0) { + this.width = width; + this.height = height; + Object.assign(this.camera, { + left: x, + right: x + width, + top: -y, + bottom: -y - height, + }); + this.projectCamera(); + } + finish() { + if (this.disposed || this.lost) return; + for (const b of this.batches.values()) { + b.mesh.count = b.used; + b.mesh.instanceMatrix.needsUpdate = true; + if (b.mesh.instanceColor) b.mesh.instanceColor.needsUpdate = true; + } + this.resize(); + this.renderer.render(this.scene, this.camera); + } + dispose() { + if (this.disposed) return; + this.disposed = true; + this.media.removeEventListener("change", this.onMotion); + root.removeEventListener("pagehide", this.onPageHide); + this.canvas.removeEventListener("webglcontextlost", this.onLost); + this.canvas.removeEventListener("webglcontextrestored", this.onRestored); + for (const b of this.batches.values()) b.mesh.dispose(); + for (const g of this.geometries.values()) g.dispose(); + this.material.dispose(); + this.glow.dispose(); + this.wire.dispose(); + this.scene.traverse((o) => { + if (o.userData.ownedGeometry) { + o.geometry.dispose(); + o.material.dispose(); + } + }); + this.renderer.dispose(); + this.hud.remove(); + this.ownedNodes.forEach((node) => node.remove()); + } + } + root.QsolNative = { Stage }; +})(window); diff --git a/shared/qsol-three-stage.js b/shared/qsol-three-stage.js deleted file mode 100644 index 9d21a94..0000000 --- a/shared/qsol-three-stage.js +++ /dev/null @@ -1,376 +0,0 @@ -(function installQsolThree(root) { - "use strict"; - - const PRESETS = Object.freeze({ - blackstar: Object.freeze({ ink: 0xd69a58, signal: 0x84b8aa, danger: 0xb7503d, stars: 150, speed: 0.75, opacity: 0.42 }), - cartridge: Object.freeze({ ink: 0xd8b66e, signal: 0x668f88, danger: 0xb95f43, stars: 90, speed: 0.28, opacity: 0.34 }), - warfront: Object.freeze({ ink: 0xd0b36d, signal: 0x6f9b8c, danger: 0xc45d40, stars: 75, speed: 0.18, opacity: 0.38 }), - seedstorm: Object.freeze({ ink: 0xe1b868, signal: 0x6c9890, danger: 0xd15d42, stars: 210, speed: 1.35, opacity: 0.5 }), - inertia: Object.freeze({ ink: 0xcab77e, signal: 0x72aaa0, danger: 0xc96348, stars: 260, speed: 0.62, opacity: 0.4 }), - vector: Object.freeze({ ink: 0xcdb777, signal: 0x6b9d93, danger: 0xd06045, stars: 230, speed: 1.05, opacity: 0.48 }), - ternary: Object.freeze({ ink: 0xd5b56c, signal: 0x6ca294, danger: 0xcb5c43, stars: 360, speed: 0.7, opacity: 0.56 }), - }); - - const NOOP = Object.freeze({ - supported: false, - render() {}, - pulse() {}, - dispose() {}, - }); - - function finite(value, fallback) { - return Number.isFinite(value) ? value : fallback; - } - - function clamp(value, low, high) { - return value < low ? low : value > high ? high : value; - } - - function hashText(text) { - let hash = 2166136261; - for (let index = 0; index < text.length; index += 1) { - hash ^= text.charCodeAt(index); - hash = Math.imul(hash, 16777619); - } - return hash >>> 0; - } - - function makeRng(seedText) { - let state = hashText(seedText) || 0x9e3779b9; - return function random() { - state ^= state << 13; - state ^= state >>> 17; - state ^= state << 5; - return (state >>> 0) / 4294967296; - }; - } - - function createStars(THREE, config, random) { - const positions = new Float32Array(config.stars * 3); - const origins = new Float32Array(config.stars * 3); - for (let index = 0; index < config.stars; index += 1) { - const offset = index * 3; - const radius = 3 + random() * 13; - const angle = random() * Math.PI * 2; - positions[offset] = Math.cos(angle) * radius * (0.5 + random()); - positions[offset + 1] = Math.sin(angle) * radius * (0.35 + random() * 0.7); - positions[offset + 2] = -2 - random() * 22; - origins.set(positions.subarray(offset, offset + 3), offset); - } - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - const material = new THREE.PointsMaterial({ - color: config.signal, - size: 0.055, - sizeAttenuation: true, - transparent: true, - opacity: 0.56, - depthWrite: false, - blending: THREE.AdditiveBlending, - }); - const points = new THREE.Points(geometry, material); - points.frustumCulled = false; - return { points, positions, origins, geometry, material }; - } - - function decodeBase64(text) { - const binary = root.atob(text); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); - return bytes; - } - - async function applyGalaxySample(stars, config, preset, layer) { - if (!root.GALAXY_WASM_BASE64 || !root.WebAssembly || !root.atob) return; - try { - const result = await root.WebAssembly.instantiate(decodeBase64(root.GALAXY_WASM_BASE64), {}); - const wasm = result.instance.exports; - if (!wasm.generate || wasm.abi_version() < 2 || wasm.generate(65536, config.stars, hashText(`QSOL-${preset}`)) !== config.stars) return; - const sample = new Float32Array(wasm.memory.buffer, wasm.buffer_ptr(), wasm.buffer_len()); - for (let index = 0; index < config.stars; index += 1) { - const target = index * 3; - const source = index * 8; - const radius = 3 + sample[source] * 13; - const angle = sample[source + 1] * Math.PI * 2; - stars.positions[target] = Math.cos(angle) * radius * (0.5 + sample[source + 2]); - stars.positions[target + 1] = Math.sin(angle) * radius * (0.35 + sample[source + 3] * 0.7); - stars.positions[target + 2] = -2 - sample[source + 4] * 22; - } - stars.origins.set(stars.positions); - stars.geometry.attributes.position.needsUpdate = true; - layer.dataset.sampler = "galaxy-rust-wasm"; - } catch (_error) { - layer.dataset.sampler = "javascript-fallback"; - } - } - - function wireMaterial(THREE, color, opacity) { - return new THREE.MeshBasicMaterial({ - color, - wireframe: true, - transparent: true, - opacity, - depthWrite: false, - blending: THREE.AdditiveBlending, - }); - } - - function lineMaterial(THREE, color, opacity) { - return new THREE.LineBasicMaterial({ - color, - transparent: true, - opacity, - depthWrite: false, - blending: THREE.AdditiveBlending, - }); - } - - function addRing(THREE, group, material, radius, z, tilt) { - const mesh = new THREE.Mesh(new THREE.TorusGeometry(radius, 0.018, 4, 64), material.clone()); - mesh.position.z = z; - mesh.rotation.x = tilt || 0; - group.add(mesh); - return mesh; - } - - function addWireMesh(THREE, group, geometry, material, x, y, z, scale) { - const mesh = new THREE.Mesh(geometry, material.clone()); - mesh.position.set(x, y, z); - mesh.scale.setScalar(scale || 1); - group.add(mesh); - return mesh; - } - - function buildMotif(THREE, preset, config) { - const group = new THREE.Group(); - const primary = wireMaterial(THREE, config.ink, 0.26); - const signal = wireMaterial(THREE, config.signal, 0.22); - const meshes = []; - - if (preset === "blackstar") { - for (let index = 0; index < 6; index += 1) { - const mesh = addWireMesh(THREE, group, new THREE.BoxGeometry(7.8, 4.5, 2.6), index & 1 ? signal : primary, 0, 0.1, -3.5 - index * 3.6, 1); - meshes.push(mesh); - } - } else if (preset === "cartridge") { - const shapes = [ - new THREE.TetrahedronGeometry(0.8), new THREE.OctahedronGeometry(0.72), new THREE.IcosahedronGeometry(0.67), - new THREE.BoxGeometry(1.1, 1.1, 1.1), new THREE.ConeGeometry(0.72, 1.3, 5), new THREE.TorusGeometry(0.62, 0.16, 5, 16), - new THREE.CylinderGeometry(0.55, 0.75, 1.1, 6), - ]; - shapes.forEach((shape, index) => { - const angle = index / shapes.length * Math.PI * 2; - meshes.push(addWireMesh(THREE, group, shape, index & 1 ? signal : primary, Math.cos(angle) * 4.1, Math.sin(angle) * 2.2, -2 - (index % 3), 1)); - }); - meshes.push(addRing(THREE, group, primary, 3.1, -4.5, 0.55)); - } else if (preset === "warfront") { - const grid = new THREE.GridHelper(18, 18, config.ink, config.signal); - grid.rotation.x = Math.PI / 2; - grid.position.set(0, -0.8, -7); - grid.material.transparent = true; - grid.material.opacity = 0.17; - grid.material.depthWrite = false; - group.add(grid); - for (let index = 0; index < 8; index += 1) { - const x = (index % 4 - 1.5) * 2.2; - const y = (Math.floor(index / 4) - 0.5) * 2.4; - meshes.push(addWireMesh(THREE, group, new THREE.CylinderGeometry(0.34, 0.56, 1.2, 6), index % 3 ? signal : primary, x, y, -4.5, 1)); - } - } else if (preset === "seedstorm") { - for (let index = 0; index < 9; index += 1) meshes.push(addRing(THREE, group, index % 3 ? signal : primary, 2.2 + index * 0.65, -5 - index * 2.2, index * 0.11)); - meshes.push(addWireMesh(THREE, group, new THREE.ConeGeometry(0.75, 2, 5), primary, 0, -2.3, -2.8, 1)); - } else if (preset === "inertia") { - meshes.push(addWireMesh(THREE, group, new THREE.TorusKnotGeometry(2.8, 0.12, 96, 8, 2, 3), signal, 3.8, -1.6, -7, 1)); - meshes.push(addWireMesh(THREE, group, new THREE.IcosahedronGeometry(1.4, 1), primary, -4.2, 2.1, -5, 1)); - meshes.push(addRing(THREE, group, primary, 5.8, -10, 0.9)); - } else if (preset === "vector") { - for (let index = 0; index < 11; index += 1) meshes.push(addRing(THREE, group, index % 2 ? signal : primary, 2.5 + index * 0.42, -3 - index * 2.15, index * 0.16)); - meshes.push(addWireMesh(THREE, group, new THREE.IcosahedronGeometry(1.1, 1), primary, 0, 0, -5, 1)); - } else { - meshes.push(addRing(THREE, group, primary, 5.2, -9, 0.95)); - meshes.push(addRing(THREE, group, signal, 3.6, -6, -0.6)); - meshes.push(addWireMesh(THREE, group, new THREE.OctahedronGeometry(1.35, 1), signal, -3.7, 1.8, -5.5, 1)); - meshes.push(addWireMesh(THREE, group, new THREE.TorusKnotGeometry(1.4, 0.12, 64, 6, 2, 3), primary, 4.2, -1.8, -7, 1)); - } - - primary.dispose(); - signal.dispose(); - return { group, meshes }; - } - - function createBurst(THREE, config, random) { - const count = 48; - const positions = new Float32Array(count * 3); - const vectors = new Float32Array(count * 3); - for (let index = 0; index < count; index += 1) { - const offset = index * 3; - const angle = random() * Math.PI * 2; - const elevation = (random() - 0.5) * 1.5; - const speed = 0.5 + random() * 1.5; - vectors[offset] = Math.cos(angle) * speed; - vectors[offset + 1] = Math.sin(angle) * speed; - vectors[offset + 2] = elevation * speed; - } - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - const material = new THREE.PointsMaterial({ - color: config.danger, - size: 0.075, - transparent: true, - opacity: 0, - depthWrite: false, - blending: THREE.AdditiveBlending, - }); - const points = new THREE.Points(geometry, material); - points.position.z = -1; - return { points, positions, vectors, geometry, material }; - } - - function create(options) { - const THREE = root.THREE; - const host = options && options.host; - const source = options && options.source; - const preset = options && PRESETS[options.preset] ? options.preset : "ternary"; - if (!THREE || !host || !source || !root.document) return NOOP; - // Headless automation is normally backed by a software renderer. Keep the - // deterministic Canvas simulation authoritative instead of spending its - // frame budget compiling a decorative WebGL scene. - if (root.navigator && root.navigator.webdriver) return NOOP; - - const layer = root.document.createElement("canvas"); - layer.className = "qsol-three-layer"; - layer.setAttribute("aria-hidden", "true"); - layer.tabIndex = -1; - const attributes = { alpha: true, antialias: false, depth: true, stencil: false, premultipliedAlpha: true }; - const context = layer.getContext("webgl2", attributes) || layer.getContext("webgl", attributes); - if (!context) return NOOP; - // SwiftShader/llvmpipe can make a decorative layer starve the authoritative - // fixed-step Canvas simulation. Prefer the existing 2D game on software GL. - const debugInfo = context.getExtension("WEBGL_debug_renderer_info"); - const rendererName = debugInfo ? String(context.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) || "") : ""; - if (/swiftshader|llvmpipe|software raster/i.test(rendererName)) return NOOP; - - let renderer; - try { - renderer = new THREE.WebGLRenderer({ canvas: layer, context, alpha: true, antialias: false, powerPreference: "high-performance" }); - } catch (_error) { - return NOOP; - } - - const config = PRESETS[preset]; - const random = makeRng(`QSOL-THREE-${preset}`); - host.classList.add("qsol-three-host"); - layer.style.setProperty("--qsol-three-opacity", String(config.opacity)); - source.insertAdjacentElement("afterend", layer); - - renderer.setClearColor(0x000000, 0); - renderer.setPixelRatio(Math.min(1, root.devicePixelRatio || 1)); - renderer.outputColorSpace = THREE.SRGBColorSpace; - const scene = new THREE.Scene(); - const camera = new THREE.PerspectiveCamera(56, 1, 0.1, 80); - camera.position.z = 8; - const stars = createStars(THREE, config, random); - scene.add(stars.points); - applyGalaxySample(stars, config, preset, layer); - const motif = buildMotif(THREE, preset, config); - scene.add(motif.group); - const burst = createBurst(THREE, config, random); - scene.add(burst.points); - - let width = 0; - let height = 0; - let pulse = 0; - let burstAge = 2; - let disposed = false; - let lastTime = root.performance && root.performance.now ? root.performance.now() : 0; - - function resize() { - const bounds = host.getBoundingClientRect(); - const nextWidth = Math.max(1, Math.round(bounds.width)); - const nextHeight = Math.max(1, Math.round(bounds.height)); - if (nextWidth === width && nextHeight === height) return; - width = nextWidth; - height = nextHeight; - camera.aspect = width / height; - camera.updateProjectionMatrix(); - renderer.setSize(width, height, false); - } - - function render(telemetry) { - if (disposed) return; - resize(); - const now = root.performance && root.performance.now ? root.performance.now() : lastTime + 16.67; - const delta = clamp((now - lastTime) / 1000, 0, 0.08); - lastTime = now; - const data = telemetry || {}; - const tick = finite(data.tick, 0); - const speed = clamp(Math.abs(finite(data.speed, 0)), 0, 4); - const danger = clamp(finite(data.danger, 0), 0, 1); - const activity = clamp(finite(data.activity, 0), 0, 1); - const heading = finite(data.heading, 0); - const travel = delta * config.speed * (1 + speed * 1.7 + activity * 0.8); - const positions = stars.positions; - for (let index = 0; index < positions.length; index += 3) { - positions[index + 2] += travel; - if (positions[index + 2] > 3) positions[index + 2] = -24; - } - stars.geometry.attributes.position.needsUpdate = true; - stars.material.opacity = 0.38 + activity * 0.26 + danger * 0.18; - stars.material.size = 0.045 + speed * 0.018; - - const visualTime = now * 0.001; - motif.group.rotation.z = heading * 0.08 + Math.sin(visualTime * 0.19) * 0.035; - motif.group.rotation.x = Math.sin(visualTime * 0.13 + tick * 0.0007) * 0.045; - motif.meshes.forEach((mesh, index) => { - mesh.rotation.x += delta * (0.035 + (index % 3) * 0.012); - mesh.rotation.y += delta * (0.045 + (index % 4) * 0.013); - if (mesh.material && "opacity" in mesh.material) mesh.material.opacity = 0.13 + activity * 0.12 + danger * (index % 2 ? 0.08 : 0.16); - }); - - pulse = Math.max(0, pulse - delta * 2.4); - burstAge += delta; - if (burstAge < 1) { - for (let index = 0; index < burst.positions.length; index += 3) { - const distance = burstAge * 2.7; - burst.positions[index] = burst.vectors[index] * distance; - burst.positions[index + 1] = burst.vectors[index + 1] * distance; - burst.positions[index + 2] = burst.vectors[index + 2] * distance; - } - burst.geometry.attributes.position.needsUpdate = true; - burst.material.opacity = (1 - burstAge) * 0.9; - } else burst.material.opacity = 0; - - camera.position.x = Math.sin(visualTime * 0.31) * 0.08 + Math.sin(tick * 0.17) * pulse * 0.06; - camera.position.y = Math.cos(visualTime * 0.27) * 0.06 + Math.cos(tick * 0.13) * pulse * 0.05; - camera.position.z = 8 - pulse * 0.2; - layer.style.setProperty("--qsol-three-opacity", String(config.opacity * (0.72 + activity * 0.23 + danger * 0.16))); - renderer.render(scene, camera); - } - - function trigger(kind, strength) { - const value = clamp(finite(strength, 0.65), 0, 1); - pulse = Math.max(pulse, value); - if (kind === "impact" || kind === "blast" || kind === "jump") burstAge = 0; - } - - function dispose() { - if (disposed) return; - disposed = true; - stars.geometry.dispose(); - stars.material.dispose(); - burst.geometry.dispose(); - burst.material.dispose(); - motif.group.traverse((object) => { - if (object.geometry) object.geometry.dispose(); - if (object.material && object.material.dispose) object.material.dispose(); - }); - renderer.dispose(); - layer.remove(); - host.classList.remove("qsol-three-host"); - } - - return Object.freeze({ supported: true, render, pulse: trigger, dispose }); - } - - root.QsolThree = Object.freeze({ version: "1.0.0", presets: Object.keys(PRESETS), create }); -})(typeof window !== "undefined" ? window : globalThis); diff --git a/shared/qsol-three.css b/shared/qsol-three.css deleted file mode 100644 index 9479106..0000000 --- a/shared/qsol-three.css +++ /dev/null @@ -1,24 +0,0 @@ -.qsol-three-host { - isolation: isolate; - overflow: hidden; -} - -.qsol-three-layer { - position: absolute !important; - inset: 0; - z-index: 1; - display: block; - width: 100% !important; - height: 100% !important; - max-width: none !important; - pointer-events: none; - opacity: var(--qsol-three-opacity, 0.5); - mix-blend-mode: screen; - filter: saturate(0.78) contrast(1.08); -} - -@media (prefers-reduced-motion: reduce) { - .qsol-three-layer { - --qsol-three-opacity: 0.2; - } -} diff --git a/tests/native-renderers.browser.js b/tests/native-renderers.browser.js new file mode 100644 index 0000000..65090f7 --- /dev/null +++ b/tests/native-renderers.browser.js @@ -0,0 +1,318 @@ +"use strict"; +const assert = require("node:assert/strict"); +const path = require("node:path"); +const fs = require("node:fs"); +const { chromium } = require("playwright"); +const { pathToFileURL } = require("node:url"); +const root = path.resolve(__dirname, ".."); +const titles = [ + ["BLACKSTAR_AGA", "BlackstarRenderer", "terrain", "enemies"], + ["CARTRIDGE_ZERO", "CartridgeRenderer", "bricks", "ball"], + ["PIXELWARFRONT", "WarfrontRenderer", "terrain", "units"], + ["VECTOR_ZERO", "VectorRenderer", "terrain", "enemies"], + ["SEEDSTORM", "SeedstormRenderer", "terrain", "enemies"], + ["SUBSPACE", "InertiaRenderer", "obstacles", "enemies"], +]; +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.GAMES_CHROMIUM || chromium.executablePath(), + args: ["--no-sandbox", "--enable-unsafe-swiftshader"], + }); + try { + for (const [game, rendererName, worldGroup, actorGroup] of titles) { + const page = await browser.newPage({ + viewport: { width: 1280, height: 900 }, + }), + errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + page.on("console", (e) => { + if (e.type() === "error") errors.push(e.text()); + }); + await page.addInitScript( + ({ rendererName }) => { + const getContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function (type, ...args) { + if (type === "2d") + throw new Error("Canvas 2D is forbidden in native gameplay"); + return getContext.call(this, type, ...args); + }; + // Observe the real adapter boundary without changing or advancing the simulation. + Object.defineProperty(window, rendererName, { + configurable: true, + set(Renderer) { + Object.defineProperty(window, rendererName, { + value: class extends Renderer { + render(state, ...args) { + window.__renderState = state; + window.__renderArgs = args; + window.__presentation = this; + return super.render(state, ...args); + } + }, + writable: true, + configurable: true, + }); + }, + }); + }, + { rendererName }, + ); + const url = process.env.GAMES_TEST_ORIGIN + ? `${process.env.GAMES_TEST_ORIGIN}/${game}/index.html` + : pathToFileURL(path.join(root, game, "index.html")).href; + await page.goto(url); + if (game === "SUBSPACE") await page.click("#launch-button"); + else await page.click("#startButton"); + await page.waitForFunction( + () => + window.__renderState && + (window.__renderState.tick > 15 || + window.__renderState.simulationTime > 0.4), + ); + if (game === "SEEDSTORM") + await page.waitForFunction( + () => window.__renderState.enemies.length > 0, + ); + const report = await page.evaluate( + ({ game, worldGroup, actorGroup }) => { + const s = document.querySelector("canvas").nativeStage, + state = window.__renderState; + const count = (name) => { + let n = 0; + const group = s.scene.getObjectByName(name); + if (group) + group.traverse((o) => { + if (o.isMesh || o.isLineSegments) + n += o.isInstancedMesh ? o.count : 1; + }); + return n; + }; + const capture = () => { + s.renderer.render(s.scene, s.camera); + const gl = s.renderer.getContext(), + data = new Uint8Array( + gl.drawingBufferWidth * gl.drawingBufferHeight * 4, + ); + gl.readPixels( + 0, + 0, + gl.drawingBufferWidth, + gl.drawingBufferHeight, + gl.RGBA, + gl.UNSIGNED_BYTE, + data, + ); + return data; + }; + const pixels = capture(), + colors = new Set(); + for (let i = 0; i < pixels.length; i += 4) + colors.add(`${pixels[i]},${pixels[i + 1]},${pixels[i + 2]}`); + // Remove actual gameplay meshes, leaving atmosphere intact: the image must change. + const keep = new Set(["stars", "grid", "menu-hull"]); + const hidden = []; + for (const [name, group] of s.groups) + if (!keep.has(name)) { + hidden.push(group); + group.visible = false; + } + const absent = capture(); + let changed = 0; + for (let i = 0; i < pixels.length; i += 4) + if ( + pixels[i] !== absent[i] || + pixels[i + 1] !== absent[i + 1] || + pixels[i + 2] !== absent[i + 2] + ) + changed++; + hidden.forEach((g) => (g.visible = true)); + s.renderer.render(s.scene, s.camera); + const instances = [...s.batches.values()]; + const finite = instances.every((b) => + Array.from(b.mesh.instanceMatrix.array.slice(0, b.used * 16)).every( + Number.isFinite, + ), + ); + return { + renderer: s.renderer instanceof THREE.WebGLRenderer, + scene: s.scene instanceof THREE.Scene, + camera: s.camera.isCamera, + canvases: document.querySelectorAll("canvas").length, + world: count(worldGroup), + actors: count(actorGroup), + player: count(game === "PIXELWARFRONT" ? "units" : "player"), + colors: colors.size, + changed, + finite, + geometries: s.renderer.info.memory.geometries, + triangles: s.renderer.info.render.triangles, + }; + }, + { game, worldGroup, actorGroup }, + ); + console.log(`FRAME ${game}: ${JSON.stringify(report)}`); + assert.ok( + report.renderer && report.scene && report.camera, + `${game}: real Three.js runtime`, + ); + assert.equal(report.canvases, 1, `${game}: one sole gameplay canvas`); + assert.ok( + report.world > 0 && report.actors > 0 && report.player > 0, + `${game}: scene population ${JSON.stringify(report)}`, + ); + assert.ok( + report.colors > 20 && + report.changed > 100 && + report.triangles > 0 && + report.finite, + `${game}: nonblank gameplay framebuffer ${JSON.stringify(report)}`, + ); + // Validate actual state coordinates against uploaded instance transforms. + const aligned = await page.evaluate((game) => { + const s = __presentation.stage, + state = __renderState, + m = new THREE.Matrix4(); + let group, point; + if (game === "BLACKSTAR_AGA") { + group = "enemies"; + point = [ + state.enemies[0].x / BlackstarCore.FP, + state.enemies[0].y / BlackstarCore.FP, + ]; + } else if (game === "VECTOR_ZERO") { + group = "enemies"; + point = [ + state.enemies[0].x / VectorZeroCore.FP, + state.enemies[0].y / VectorZeroCore.FP, + state.enemies[0].z / VectorZeroCore.FP, + ]; + } else if (game === "CARTRIDGE_ZERO") { + group = "ball"; + point = [ + state.game.ball.x / CartridgeZeroCore.FP, + -state.game.ball.y / CartridgeZeroCore.FP, + ]; + } else if (game === "PIXELWARFRONT") { + group = "units"; + point = [ + state.units[0].x / PixelWarfrontCore.SCALE, + -state.units[0].y / PixelWarfrontCore.SCALE, + ]; + } else if (game === "SEEDSTORM") { + group = "player"; + point = [ + state.player.x / SeedStormCore.SCALE, + -state.player.y / SeedStormCore.SCALE, + ]; + } else { + group = "player"; + point = null; + } + if (!point) return true; + for (const [key, b] of s.batches) + if (key.startsWith(group + "/")) + for (let i = 0; i < b.used; i++) { + b.mesh.getMatrixAt(i, m); + const e = m.elements; + if (game === "BLACKSTAR_AGA") { + if ( + Math.abs(e[12] - point[0]) < 0.001 && + Math.abs(e[14] - point[1]) < 0.001 + ) + return true; + } else if ( + Math.abs(e[12] - point[0]) < 0.001 && + Math.abs(e[13] - point[1]) < 0.001 && + (!point[2] || Math.abs(e[14] - point[2]) < 0.001) + ) + return true; + } + return false; + }, game); + assert.ok(aligned, `${game}: simulation-to-instance coordinates`); + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.waitForFunction(() => __presentation.stage.reducedMotion); + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.waitForFunction(() => !__presentation.stage.reducedMotion); + await page.setViewportSize({ width: 820, height: 720 }); + await page.waitForFunction(() => { + const s = __presentation.stage, + r = s.canvas.getBoundingClientRect(); + return ( + Math.abs(s.renderer.domElement.width - Math.round(r.width) * s.dpr) <= + 2 + ); + }); + const tickBefore = await page.evaluate( + () => __renderState.tick || __renderState.simulationTime, + ); + await page.keyboard.down(game === "SEEDSTORM" ? "KeyZ" : "Space"); + await page.waitForTimeout(160); + await page.keyboard.up(game === "SEEDSTORM" ? "KeyZ" : "Space"); + assert.ok( + await page.evaluate( + (before) => + (__renderState.tick || __renderState.simulationTime) > before, + tickBefore, + ), + `${game}: simulation continues after resize`, + ); + // Re-render a frozen state repeatedly: no GPU geometry growth, no state mutation. + const stable = await page.evaluate(() => { + const p = __presentation, + s = p.stage, + was = JSON.stringify(__renderState, (k, v) => + k === "presentation" || k === "input" || k === "audio" + ? undefined + : v, + ); + const n = s.renderer.info.memory.geometries; + for (let i = 0; i < 120; i++) p.render(__renderState, ...__renderArgs); + return { + n, + after: s.renderer.info.memory.geometries, + unchanged: + was === + JSON.stringify(__renderState, (k, v) => + k === "presentation" || k === "input" || k === "audio" + ? undefined + : v, + ), + }; + }); + assert.equal(stable.n, stable.after); + assert.ok(stable.unchanged); + fs.mkdirSync(path.join(root, "test-results"), { recursive: true }); + await page.screenshot({ + path: path.join(root, "test-results", `${game}.png`), + fullPage: true, + }); + // Context loss and restoration must retain the only gameplay renderer. + await page.evaluate(() => + __presentation.stage.renderer.forceContextLoss(), + ); + await page.waitForFunction(() => __presentation.stage.lost); + await page.evaluate(() => + __presentation.stage.renderer.forceContextRestore(), + ); + await page.waitForFunction(() => !__presentation.stage.lost); + await page.waitForFunction( + () => __presentation.stage.renderer.info.render.triangles > 0, + ); + await page.evaluate(() => __presentation.stage.dispose()); + assert.equal( + await page.evaluate(() => __presentation.stage.disposed), + true, + ); + assert.deepEqual(errors, [], `${game}: console/startup errors`); + console.log(`PASS ${game}: ${JSON.stringify(report)}`); + await page.close(); + } + } finally { + await browser.close(); + } +})().catch((e) => { + console.error(e); + process.exitCode = 1; +}); diff --git a/tests/native-renderers.static.js b/tests/native-renderers.static.js new file mode 100644 index 0000000..a9c4ac3 --- /dev/null +++ b/tests/native-renderers.static.js @@ -0,0 +1,49 @@ +"use strict"; +const assert = require("node:assert/strict"), + fs = require("node:fs"), + path = require("node:path"); +const root = path.resolve(__dirname, ".."); +const games = [ + "BLACKSTAR_AGA", + "CARTRIDGE_ZERO", + "PIXELWARFRONT", + "VECTOR_ZERO", + "SEEDSTORM", + "SUBSPACE", +]; +for (const game of games) { + const html = fs.readFileSync(path.join(root, game, "index.html"), "utf8"); + const scripts = [...html.matchAll(/ + path.resolve(root, game, m[1]), + ); + assert.ok(scripts.includes(path.join(root, "shared/qsol-native.js"))); + assert.ok(scripts.includes(path.join(root, game, "renderer.js"))); + assert.equal( + (html.match(/ Date: Sun, 27 Sep 2026 13:16:51 +0930 Subject: [PATCH 02/35] Correct remaining renderer labels --- BLACKSTAR_AGA/index.html | 2 +- VECTOR_ZERO/index.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/BLACKSTAR_AGA/index.html b/BLACKSTAR_AGA/index.html index 2af6974..c7cb149 100644 --- a/BLACKSTAR_AGA/index.html +++ b/BLACKSTAR_AGA/index.html @@ -28,7 +28,7 @@
- BLACKSTAR AGA requires a browser with Canvas support. + BLACKSTAR AGA requires a browser with WebGL support. diff --git a/VECTOR_ZERO/index.html b/VECTOR_ZERO/index.html index 35696c7..36588b2 100644 --- a/VECTOR_ZERO/index.html +++ b/VECTOR_ZERO/index.html @@ -23,7 +23,7 @@

VECTOR ZERO

-
VZ/01 FLIGHT COMPUTER480 × 270 SOFTWARE VECTOR DISPLAY60 HZ
+
VZ/01 FLIGHT COMPUTERWEBGL VECTOR DISPLAY60 HZ
@@ -110,7 +110,7 @@

PAUSED

PROTOTYPE PROVENANCE

-

Original software renderer, fixed-point flight core, procedural geometry, vector craft, and synthesized audio. Runs directly from local files.

+

Native Three.js renderer, fixed-point flight core, procedural geometry, vector craft, and synthesized audio. Runs directly from local files.

From 1b94343c304f5ded76a545f2d97587769180c05e Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:24:31 +0930 Subject: [PATCH 03/35] Fix perspective camera projection on resize --- shared/qsol-native.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/shared/qsol-native.js b/shared/qsol-native.js index 3a808d8..9d43f6e 100644 --- a/shared/qsol-native.js +++ b/shared/qsol-native.js @@ -307,6 +307,10 @@ this.dpr = dpr; this.renderer.setPixelRatio(dpr); this.renderer.setSize(w, h, false); + if (this.camera.isPerspectiveCamera) { + this.camera.aspect = w / h; + this.camera.updateProjectionMatrix(); + } } } oblique(x, y) { From f0e5b69f28a50299ace86e185fd9ab94fe1b942d Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:24:34 +0930 Subject: [PATCH 04/35] Keep Pixel Warfront overlays above native HUD --- PIXELWARFRONT/style.css | 1 + 1 file changed, 1 insertion(+) diff --git a/PIXELWARFRONT/style.css b/PIXELWARFRONT/style.css index cf38de6..0ddee5f 100644 --- a/PIXELWARFRONT/style.css +++ b/PIXELWARFRONT/style.css @@ -159,6 +159,7 @@ canvas { .screen-layer { position: absolute; inset: 0; + z-index: 3; display: grid; place-items: center; padding: 1rem; From bde57f45a489aabbe96c048cf3daef613290829a Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:24:37 +0930 Subject: [PATCH 05/35] Cover perspective camera aspect during resize --- tests/native-renderers.browser.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/native-renderers.browser.js b/tests/native-renderers.browser.js index 65090f7..464d2bb 100644 --- a/tests/native-renderers.browser.js +++ b/tests/native-renderers.browser.js @@ -244,6 +244,20 @@ const titles = [ 2 ); }); + const perspectiveProjection = await page.evaluate(() => { + const s = __presentation.stage, + r = s.canvas.getBoundingClientRect(); + return s.camera.isPerspectiveCamera + ? { actual: s.camera.aspect, expected: r.width / r.height } + : null; + }); + if (perspectiveProjection) + assert.ok( + Math.abs( + perspectiveProjection.actual - perspectiveProjection.expected, + ) < 1e-6, + `${game}: perspective projection follows resized canvas aspect`, + ); const tickBefore = await page.evaluate( () => __renderState.tick || __renderState.simulationTime, ); From a7923f2d0952628644e1bca651b066c0154d9171 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:24:40 +0930 Subject: [PATCH 06/35] Cover Pixel Warfront overlay stacking --- PIXELWARFRONT/tests/browser-smoke.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/PIXELWARFRONT/tests/browser-smoke.js b/PIXELWARFRONT/tests/browser-smoke.js index 895c04d..436ff13 100644 --- a/PIXELWARFRONT/tests/browser-smoke.js +++ b/PIXELWARFRONT/tests/browser-smoke.js @@ -23,6 +23,13 @@ const core = require("../core.js"); assert.equal(await page.title(), "PIXEL WARFRONT — Deterministic Command"); assert.equal(await page.locator("#introLayer").isVisible(), true); assert.equal(await page.locator("#startButton").isEnabled(), true); + const overlayStack = await page.evaluate(() => ({ + intro: Number.parseInt(getComputedStyle(document.querySelector("#introLayer")).zIndex, 10), + message: Number.parseInt(getComputedStyle(document.querySelector("#messageLayer")).zIndex, 10), + hud: Number.parseInt(getComputedStyle(document.querySelector(".native-hud")).zIndex, 10), + })); + assert.ok(overlayStack.intro > overlayStack.hud, "intro overlay must cover the native HUD"); + assert.ok(overlayStack.message > overlayStack.hud, "message overlay must cover the native HUD"); await page.screenshot({ path: path.join(__dirname, "pixelwarfront-intro.png"), fullPage: true }); await page.fill("#seedInput", "BROWSER-WARFRONT"); From 28feeeabd7ee721458ab13d5f9a6047ef5a0c925 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:25:06 +0930 Subject: [PATCH 07/35] Match resize regression to rounded renderer dimensions --- tests/native-renderers.browser.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/native-renderers.browser.js b/tests/native-renderers.browser.js index 464d2bb..c132d7a 100644 --- a/tests/native-renderers.browser.js +++ b/tests/native-renderers.browser.js @@ -248,7 +248,12 @@ const titles = [ const s = __presentation.stage, r = s.canvas.getBoundingClientRect(); return s.camera.isPerspectiveCamera - ? { actual: s.camera.aspect, expected: r.width / r.height } + ? { + actual: s.camera.aspect, + expected: + Math.max(1, Math.round(r.width)) / + Math.max(1, Math.round(r.height)), + } : null; }); if (perspectiveProjection) From 00696b15ed76eeaf0a1c7549848b29ec7fa1056b Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:45:04 +0930 Subject: [PATCH 08/35] Fix BLACKSTAR automap visibility --- BLACKSTAR_AGA/renderer.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/BLACKSTAR_AGA/renderer.js b/BLACKSTAR_AGA/renderer.js index 70f6fb5..faa3eac 100644 --- a/BLACKSTAR_AGA/renderer.js +++ b/BLACKSTAR_AGA/renderer.js @@ -17,7 +17,6 @@ this.bursts = []; this.map = document.createElementNS("http://www.w3.org/2000/svg", "svg"); this.map.classList.add("native-map"); - this.map.hidden = true; canvas.parentElement.appendChild(this.map); this.stage.ownedNodes.push(this.map); const reticle = document.createElement("span"); @@ -30,7 +29,7 @@ const s = this.stage, c = this.core; s.begin(); - this.map.style.display = ui.automap && state ? "block" : "none"; + this.map.hidden = !(ui.automap && state); if (!state) { s.text("BLACKSTAR RECOVERY MONITOR // READY"); s.finish(); From 9a12e11b1081de7aecec0706754680fdc32e0ed9 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:45:07 +0930 Subject: [PATCH 09/35] Cover BLACKSTAR automap hold behavior --- BLACKSTAR_AGA/tests/browser-smoke.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/BLACKSTAR_AGA/tests/browser-smoke.js b/BLACKSTAR_AGA/tests/browser-smoke.js index 05c2c8e..a9be315 100644 --- a/BLACKSTAR_AGA/tests/browser-smoke.js +++ b/BLACKSTAR_AGA/tests/browser-smoke.js @@ -35,6 +35,14 @@ const core = require("../core.js"); assert.match(await page.locator("#seedReadout").innerText(), /^[0-9A-F]{8}$/); assert.match(await page.locator("#digestReadout").innerText(), /^[0-9A-F]{8}$/); + const automap = page.locator(".native-map"); + assert.equal(await automap.isVisible(), false); + await page.keyboard.down("Tab"); + await automap.waitFor({ state: "visible" }); + assert.equal(await automap.evaluate((element) => element.hidden), false); + await page.keyboard.up("Tab"); + await automap.waitFor({ state: "hidden" }); + const before = await page.evaluate(() => ({ tick: blackstarAGA.state.tick, x: blackstarAGA.state.player.x })); await page.keyboard.down("KeyW"); await page.waitForTimeout(260); From 3b200401e8f63fb2b1ecf16c97c4b524d3580688 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:45:12 +0930 Subject: [PATCH 10/35] Render Pixel Warfront drag box on input plane --- PIXELWARFRONT/renderer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PIXELWARFRONT/renderer.js b/PIXELWARFRONT/renderer.js index 2f3f058..c816229 100644 --- a/PIXELWARFRONT/renderer.js +++ b/PIXELWARFRONT/renderer.js @@ -341,7 +341,7 @@ [b.x, b.y, a.x, b.y], [a.x, b.y, a.x, a.y], ]) - s.line("selection", x1, y1, x2, y2, 55, "#9fc4c9", 1); + s.line("selection", x1, y1, x2, y2, 0, "#9fc4c9", 1); } s.text( `MISSION ${state.level} // ${state.blueprint.biome.name} // ${state.blueprint.signature}`, From c1944b5917728c638d28092c9ce55f145de36721 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:45:15 +0930 Subject: [PATCH 11/35] Cover Pixel Warfront drag box input plane --- PIXELWARFRONT/tests/browser-smoke.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/PIXELWARFRONT/tests/browser-smoke.js b/PIXELWARFRONT/tests/browser-smoke.js index 436ff13..af22d7a 100644 --- a/PIXELWARFRONT/tests/browser-smoke.js +++ b/PIXELWARFRONT/tests/browser-smoke.js @@ -57,6 +57,23 @@ const core = require("../core.js"); await page.mouse.move(selectStart.x, selectStart.y); await page.mouse.down(); await page.mouse.move(selectEnd.x, selectEnd.y, { steps: 8 }); + await page.waitForFunction(() => { + const stage = document.querySelector("#game").nativeStage; + const batch = stage && stage.batches.get("selection/box/glow"); + return batch && batch.used === 4; + }); + const selectionDepths = await page.evaluate(() => { + const stage = document.querySelector("#game").nativeStage; + const batch = stage.batches.get("selection/box/glow"); + const matrix = new THREE.Matrix4(); + const depths = []; + for (let i = 0; i < batch.used; i += 1) { + batch.mesh.getMatrixAt(i, matrix); + depths.push(matrix.elements[14]); + } + return depths; + }); + assert.deepEqual(selectionDepths, [0, 0, 0, 0], "drag box must stay on the z=0 input plane"); await page.mouse.up(); await page.waitForFunction(() => !document.querySelector("#selectionName").textContent.startsWith("NO")); From abce16579b0dc01e81985eaedd02c3e28cb66dea Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:45:27 +0930 Subject: [PATCH 12/35] Honor boss effects in shared native renderer --- shared/qsol-native.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/qsol-native.js b/shared/qsol-native.js index 9d43f6e..5aa4338 100644 --- a/shared/qsol-native.js +++ b/shared/qsol-native.js @@ -270,7 +270,7 @@ const age = tick - e.startTick, life = 1 - age / e.duration; if (life <= 0 || age < 0) continue; - const r = (e.major ? 65 : 24) * (1 - life) + 3; + const r = (e.major || e.boss ? 65 : 24) * (1 - life) + 3; this.ring("effects", e.x, e.y, 25, r, "#d89459"); if (!this.reducedMotion) for (let i = 0; i < 10; i++) { From 4e56326ce22915e86129b2633528fd4b319c0e89 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:45:32 +0930 Subject: [PATCH 13/35] Cover SEEDSTORM boss effect marker --- tests/native-renderers.browser.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/native-renderers.browser.js b/tests/native-renderers.browser.js index c132d7a..cf5da17 100644 --- a/tests/native-renderers.browser.js +++ b/tests/native-renderers.browser.js @@ -231,6 +231,33 @@ const titles = [ return false; }, game); assert.ok(aligned, `${game}: simulation-to-instance coordinates`); + if (game === "SEEDSTORM") { + const bossEffectRadius = await page.evaluate(() => { + const s = __presentation.stage, + matrix = new THREE.Matrix4(); + s.begin(); + s.effects( + [ + { + x: 0, + y: 0, + startTick: 0, + duration: 10, + seed: 1, + boss: true, + }, + ], + 1, + ); + const batch = s.batches.get("effects/ring/glow"); + batch.mesh.getMatrixAt(0, matrix); + return new THREE.Vector3().setFromMatrixScale(matrix).x; + }); + assert.ok( + bossEffectRadius > 9, + `${game}: boss marker must select the major shared effect radius`, + ); + } await page.emulateMedia({ reducedMotion: "reduce" }); await page.waitForFunction(() => __presentation.stage.reducedMotion); await page.emulateMedia({ reducedMotion: "no-preference" }); From df7e7d19e1696ace539391a00c414b5a167c2744 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:46:52 +0930 Subject: [PATCH 14/35] Use display state for BLACKSTAR SVG automap --- BLACKSTAR_AGA/renderer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BLACKSTAR_AGA/renderer.js b/BLACKSTAR_AGA/renderer.js index faa3eac..4822597 100644 --- a/BLACKSTAR_AGA/renderer.js +++ b/BLACKSTAR_AGA/renderer.js @@ -29,7 +29,7 @@ const s = this.stage, c = this.core; s.begin(); - this.map.hidden = !(ui.automap && state); + this.map.style.display = ui.automap && state ? "block" : "none"; if (!state) { s.text("BLACKSTAR RECOVERY MONITOR // READY"); s.finish(); From 58377da00dab1a124d9db03c287309d19e142fb4 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:46:55 +0930 Subject: [PATCH 15/35] Test BLACKSTAR automap by rendered visibility --- BLACKSTAR_AGA/tests/browser-smoke.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BLACKSTAR_AGA/tests/browser-smoke.js b/BLACKSTAR_AGA/tests/browser-smoke.js index a9be315..bef9a11 100644 --- a/BLACKSTAR_AGA/tests/browser-smoke.js +++ b/BLACKSTAR_AGA/tests/browser-smoke.js @@ -39,7 +39,7 @@ const core = require("../core.js"); assert.equal(await automap.isVisible(), false); await page.keyboard.down("Tab"); await automap.waitFor({ state: "visible" }); - assert.equal(await automap.evaluate((element) => element.hidden), false); + assert.notEqual(await automap.evaluate((element) => getComputedStyle(element).display), "none"); await page.keyboard.up("Tab"); await automap.waitFor({ state: "hidden" }); From 173b934202e8580bcc26bf76c01621796b81253a Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:04:47 +0930 Subject: [PATCH 16/35] Scale native explosion effects by entity radius --- shared/qsol-native.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/shared/qsol-native.js b/shared/qsol-native.js index 5aa4338..96502d7 100644 --- a/shared/qsol-native.js +++ b/shared/qsol-native.js @@ -270,7 +270,13 @@ const age = tick - e.startTick, life = 1 - age / e.duration; if (life <= 0 || age < 0) continue; - const r = (e.major || e.boss ? 65 : 24) * (1 - life) + 3; + const major = Boolean(e.major || e.boss), + eventRadius = Number.isFinite(e.radius) ? Math.max(0, e.radius) : 24, + scale = + major || e.type === "impact" + ? 1 + : Math.max(0.75, eventRadius / 24), + r = (major ? 65 : 24 * scale) * (1 - life) + 3 * scale; this.ring("effects", e.x, e.y, 25, r, "#d89459"); if (!this.reducedMotion) for (let i = 0; i < 10; i++) { From 71460e348e8799a030a957f2514dc891afede45d Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:04:50 +0930 Subject: [PATCH 17/35] Restore SEEDSTORM native flash and camera shake --- SEEDSTORM/renderer.js | 47 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/SEEDSTORM/renderer.js b/SEEDSTORM/renderer.js index 07f97ca..8da8483 100644 --- a/SEEDSTORM/renderer.js +++ b/SEEDSTORM/renderer.js @@ -1,5 +1,16 @@ (function (root) { "use strict"; + const T = root.THREE; + + function visualHash(a, b, c) { + let x = (a ^ Math.imul(b, 0x9e3779b1) ^ Math.imul(c, 0x85ebca6b)) >>> 0; + x ^= x >>> 16; + x = Math.imul(x, 0x7feb352d); + x ^= x >>> 15; + x = Math.imul(x, 0x846ca68b); + return (x ^ (x >>> 16)) >>> 0; + } + class SeedstormRenderer { constructor(canvas, core) { this.core = core; @@ -61,10 +72,44 @@ }; for (const [name, points] of Object.entries(shapes)) this.stage.shape(name, points); + this.flash = new T.Mesh( + new T.PlaneGeometry(core.WIDTH, core.HEIGHT), + new T.MeshBasicMaterial({ + color: 0xefe0c8, + transparent: true, + opacity: 0, + depthTest: false, + depthWrite: false, + toneMapped: false, + }), + ); + this.flash.name = "combat-flash"; + this.flash.frustumCulled = false; + this.flash.renderOrder = 10000; + this.flash.visible = false; + this.flash.userData.ownedGeometry = true; + this.stage.scene.add(this.flash); } render(state, ui) { const s = this.stage, - c = this.core; + c = this.core, + shake = Math.max(0, Number(ui.shake) || 0), + flash = Math.max(0, Number(ui.flash) || 0); + let shakeX = 0, + shakeY = 0; + if (state && shake > 0 && !s.reducedMotion) { + const amount = Math.min(1, shake / 8); + shakeX = ((visualHash(state.tick, shake, 1) % 7) - 3) * amount; + shakeY = ((visualHash(state.tick, shake, 2) % 7) - 3) * amount; + } + s.camera.position.set(-shakeX, shakeY, 2000); + this.flash.material.opacity = Math.min(0.28, flash / 70); + this.flash.visible = this.flash.material.opacity > 0; + this.flash.position.set( + s.camera.position.x + c.WIDTH / 2, + s.camera.position.y - c.HEIGHT / 2, + s.camera.position.z - 1, + ); s.begin("#0c1519"); if (!state) { s.text("SEEDSTORM // STRIKE SYSTEM READY"); From 065b3003710291dea675434ab91cd2b93e825766 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:04:53 +0930 Subject: [PATCH 18/35] Forward and decay SEEDSTORM combat feedback --- SEEDSTORM/app.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/SEEDSTORM/app.js b/SEEDSTORM/app.js index a48ee96..33d2fbf 100644 --- a/SEEDSTORM/app.js +++ b/SEEDSTORM/app.js @@ -410,7 +410,17 @@ } function render() { - presentation.render(state, { palette: state ? palettes[state.blueprint.biomeIndex % palettes.length] : palettes[0], heldInput, effects: visualEffects, enemyHitUntil, paused }); + presentation.render(state, { + palette: state ? palettes[state.blueprint.biomeIndex % palettes.length] : palettes[0], + heldInput, + effects: visualEffects, + enemyHitUntil, + paused, + flash: visualFlash, + shake, + }); + if (visualFlash > 0) visualFlash -= 1; + if (shake > 0) shake -= 1; } function frame(time) { From 790b22681ad17692d3b6cf591399536368edde83 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:04:57 +0930 Subject: [PATCH 19/35] Cover per-event native explosion radii --- tests/native-renderers.browser.js | 43 ++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/tests/native-renderers.browser.js b/tests/native-renderers.browser.js index cf5da17..0be261d 100644 --- a/tests/native-renderers.browser.js +++ b/tests/native-renderers.browser.js @@ -232,29 +232,58 @@ const titles = [ }, game); assert.ok(aligned, `${game}: simulation-to-instance coordinates`); if (game === "SEEDSTORM") { - const bossEffectRadius = await page.evaluate(() => { + const effectRadii = await page.evaluate(() => { const s = __presentation.stage, - matrix = new THREE.Matrix4(); + matrix = new THREE.Matrix4(), + scale = new THREE.Vector3(); s.begin(); s.effects( [ { + type: "explosion", x: 0, y: 0, + radius: 4, startTick: 0, duration: 10, seed: 1, + }, + { + type: "explosion", + x: 0, + y: 0, + radius: 60, + startTick: 0, + duration: 10, + seed: 2, + }, + { + type: "explosion", + x: 0, + y: 0, + radius: 4, + startTick: 0, + duration: 10, + seed: 3, boss: true, }, ], - 1, + 5, ); - const batch = s.batches.get("effects/ring/glow"); - batch.mesh.getMatrixAt(0, matrix); - return new THREE.Vector3().setFromMatrixScale(matrix).x; + const batch = s.batches.get("effects/ring/glow"), + radii = []; + for (let i = 0; i < 3; i++) { + batch.mesh.getMatrixAt(i, matrix); + radii.push(scale.setFromMatrixScale(matrix).x); + } + return radii; }); assert.ok( - bossEffectRadius > 9, + effectRadii[1] > effectRadii[0] * 2, + `${game}: ordinary explosion radius must scale with entity radius`, + ); + assert.ok( + effectRadii[2] > effectRadii[0], `${game}: boss marker must select the major shared effect radius`, ); } From db59fd5b57e2ec058680d8896e4d544b5a937dc2 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:05:00 +0930 Subject: [PATCH 20/35] Cover SEEDSTORM native combat flash and shake --- SEEDSTORM/tests/browser-smoke.js | 49 ++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/SEEDSTORM/tests/browser-smoke.js b/SEEDSTORM/tests/browser-smoke.js index 9cb2cfa..8eef399 100644 --- a/SEEDSTORM/tests/browser-smoke.js +++ b/SEEDSTORM/tests/browser-smoke.js @@ -45,6 +45,55 @@ const core = require("../core.js"); await page.locator("#messageLayer").waitFor({ state: "hidden" }); assert.equal(await page.locator("#pauseButton").innerText(), "PAUSE"); assert.equal(await page.locator("#runMode").innerText(), "LIVE INPUT"); + + await page.keyboard.down("KeyX"); + const feedbackFrames = []; + for (let frame = 0; frame < 6; frame += 1) { + await page.clock.runFor(17); + feedbackFrames.push( + await page.evaluate(() => { + const stage = document.querySelector("#game").nativeStage, + flash = stage.scene.getObjectByName("combat-flash"); + return { + flash: flash.material.opacity, + visible: flash.visible, + cameraX: stage.camera.position.x, + cameraY: stage.camera.position.y, + }; + }), + ); + } + await page.keyboard.up("KeyX"); + assert.ok( + feedbackFrames.some((frame) => frame.visible && frame.flash > 0), + "bomb input must produce a native combat flash", + ); + assert.ok( + feedbackFrames.some( + (frame) => Math.abs(frame.cameraX) > 0.001 || Math.abs(frame.cameraY) > 0.001, + ), + "bomb input must produce native camera shake", + ); + await page.clock.runFor(400); + const settledFeedback = await page.evaluate(() => { + const stage = document.querySelector("#game").nativeStage, + flash = stage.scene.getObjectByName("combat-flash"); + return { + flash: flash.material.opacity, + visible: flash.visible, + cameraX: stage.camera.position.x, + cameraY: stage.camera.position.y, + }; + }); + assert.equal(settledFeedback.visible, false, "combat flash must decay away"); + assert.equal(settledFeedback.flash, 0, "combat flash opacity must decay to zero"); + assert.ok( + Math.abs(settledFeedback.cameraX) < 0.001 && + Math.abs(settledFeedback.cameraY) < 0.001, + "camera shake must decay back to the neutral view", + ); + await page.click("#restartButton"); + // Keep browser input and the real frame loop, but advance every animation frame // explicitly so slow CI rendering cannot shorten the combat sequence. await page.keyboard.down("KeyZ"); From 36c921e5a191869a95f08e3d1492ce5354be102d Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:05:41 +0930 Subject: [PATCH 21/35] Refocus SEEDSTORM canvas after feedback regression --- SEEDSTORM/tests/browser-smoke.js | 1 + 1 file changed, 1 insertion(+) diff --git a/SEEDSTORM/tests/browser-smoke.js b/SEEDSTORM/tests/browser-smoke.js index 8eef399..5eadd76 100644 --- a/SEEDSTORM/tests/browser-smoke.js +++ b/SEEDSTORM/tests/browser-smoke.js @@ -93,6 +93,7 @@ const core = require("../core.js"); "camera shake must decay back to the neutral view", ); await page.click("#restartButton"); + await page.focus("#game"); // Keep browser input and the real frame loop, but advance every animation frame // explicitly so slow CI rendering cannot shorten the combat sequence. From 13a3d4c4ff3f7ef198df173d27206f7a95506778 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:32:38 +0930 Subject: [PATCH 22/35] Add shared native flash and camera shake feedback --- shared/qsol-native.js | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/shared/qsol-native.js b/shared/qsol-native.js index 96502d7..4e9e482 100644 --- a/shared/qsol-native.js +++ b/shared/qsol-native.js @@ -82,6 +82,10 @@ this.glow = new T.MeshBasicMaterial({ toneMapped: false }); this.wire = new T.MeshBasicMaterial({ wireframe: true }); this.ownedNodes = []; + this.flashLayer = document.createElement("div"); + this.flashLayer.className = "native-flash"; + canvas.parentElement.appendChild(this.flashLayer); + this.ownedNodes.push(this.flashLayer); this.hud = document.createElement("div"); this.hud.className = "native-hud"; canvas.parentElement.appendChild(this.hud); @@ -297,6 +301,39 @@ } } } + screenFlash(color, opacity) { + const amount = Math.max(0, Math.min(1, Number(opacity) || 0)); + this.flashLayer.style.background = color || "#efe0c8"; + this.flashLayer.style.opacity = String(amount); + this.flashLayer.style.display = amount > 0 ? "block" : "none"; + } + shakeView(x = 0, y = 0) { + this.resize(); + const sx = this.reducedMotion ? 0 : Number(x) || 0, + sy = this.reducedMotion ? 0 : Number(y) || 0, + w = this.cssWidth || this.width, + h = this.cssHeight || this.height, + scaleX = w / Math.max(1, this.width), + scaleY = h / Math.max(1, this.height); + if ((sx || sy) && this.camera.setViewOffset) { + this.camera.setViewOffset( + w, + h, + -sx * scaleX, + -sy * scaleY, + w, + h, + ); + } else if ( + this.camera.clearViewOffset && + this.camera.view && + this.camera.view.enabled + ) { + this.camera.clearViewOffset(); + } + this.camera.userData.nativeShake = { x: sx, y: sy }; + this.projectCamera(); + } text(value) { if (!this.lost && this.hud.textContent !== value) this.hud.textContent = value; From 84670e51c3d6479991274b15c1a88ef34906df8d Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:32:41 +0930 Subject: [PATCH 23/35] Style native combat flash overlay --- shared/qsol-native.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/shared/qsol-native.css b/shared/qsol-native.css index 2103c8b..5e2d9e2 100644 --- a/shared/qsol-native.css +++ b/shared/qsol-native.css @@ -1,4 +1,12 @@ /* Text-only conventional HUD; the sole gameplay canvas is opaque WebGL. */ +.native-flash { + position: absolute; + inset: 0; + z-index: 1; + display: none; + opacity: 0; + pointer-events: none; +} .native-hud { position: absolute; left: 2%; From d91ba108fc840a6b5fa150d8452adcfa8f124c58 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:33:13 +0930 Subject: [PATCH 24/35] Forward BLACKSTAR combat feedback to native renderer --- BLACKSTAR_AGA/app.js | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/BLACKSTAR_AGA/app.js b/BLACKSTAR_AGA/app.js index 09c6a67..9e2b114 100644 --- a/BLACKSTAR_AGA/app.js +++ b/BLACKSTAR_AGA/app.js @@ -391,7 +391,27 @@ } function render() { - presentation.render(state, { automap, paused, notice: noticeTicks > 0 ? notice : "", palette: state ? PALETTES[state.blueprint.palette] || PALETTES.copper : PALETTES.copper }); + const shakeX = + state && shake > 0 + ? ((state.tick * 13) % (shake * 2 + 1)) - shake + : 0, + shakeY = + state && shake > 0 + ? ((state.tick * 7) % (Math.max(1, shake) + 1)) - + Math.floor(shake / 2) + : 0; + presentation.render(state, { + automap, + paused, + notice: noticeTicks > 0 ? notice : "", + palette: state + ? PALETTES[state.blueprint.palette] || PALETTES.copper + : PALETTES.copper, + flash, + shakeX, + shakeY, + hurt: state?.player?.hurt || 0, + }); } function updateTelemetry(force) { From 908a6c3402dc421906cc5f6d9ef76bdb97c7c444 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:33:17 +0930 Subject: [PATCH 25/35] Restore BLACKSTAR feedback and automap navigation --- BLACKSTAR_AGA/renderer.js | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/BLACKSTAR_AGA/renderer.js b/BLACKSTAR_AGA/renderer.js index 4822597..3c9681c 100644 --- a/BLACKSTAR_AGA/renderer.js +++ b/BLACKSTAR_AGA/renderer.js @@ -31,6 +31,8 @@ s.begin(); this.map.style.display = ui.automap && state ? "block" : "none"; if (!state) { + s.shakeView(0, 0); + s.screenFlash("#ffe08e", 0); s.text("BLACKSTAR RECOVERY MONITOR // READY"); s.finish(); return; @@ -53,7 +55,14 @@ } const palette = ui.palette, p = state.player, - angle = (p.angle / c.ANGLE_MAX) * Math.PI * 2; + angle = (p.angle / c.ANGLE_MAX) * Math.PI * 2, + hurt = Math.max(0, Number(ui.hurt) || 0), + flash = Math.max(0, Number(ui.flash) || 0); + s.shakeView(ui.shakeX, ui.shakeY); + s.screenFlash( + hurt > 0 ? "#961e19" : "#ffe08e", + hurt > 0 ? Math.max(hurt / 42, flash / 30) : flash / 30, + ); s.scene.background.set(palette.sky); s.scene.fog.color.set(palette.sky); s.camera.position.set(p.x / c.FP, 0.5, p.y / c.FP); @@ -433,7 +442,14 @@ markup += ``; for (const e of state.enemies) markup += ``; - markup += ``; + const exit = state.blueprint.exit, + px = p.x / c.FP, + py = p.y / c.FP, + dx = c.cosAngle(p.angle) / c.TRIG_SCALE, + dy = c.sinAngle(p.angle) / c.TRIG_SCALE; + markup += ``; + markup += ``; + markup += ``; this.map.innerHTML = markup; } s.finish(); From 8a86d4c2aa20edd153e8183f69462f96106c5f11 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:33:48 +0930 Subject: [PATCH 26/35] Forward VECTOR ZERO combat feedback --- VECTOR_ZERO/app.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/VECTOR_ZERO/app.js b/VECTOR_ZERO/app.js index e594714..ceca219 100644 --- a/VECTOR_ZERO/app.js +++ b/VECTOR_ZERO/app.js @@ -285,7 +285,24 @@ function render() { buildMineFaces(); - presentation.render(state, mineFaces, { automap, paused, notice: noticeTicks > 0 ? notice : "", palette: state ? PALETTES[state.sector - 1] || PALETTES[0] : PALETTES[0] }); + const shakeX = + state && shake > 0 + ? ((state.tick * 17) % (shake * 2 + 1)) - shake + : 0, + shakeY = + state && shake > 0 + ? ((state.tick * 11) % (shake * 2 + 1)) - shake + : 0; + presentation.render(state, mineFaces, { + automap, + paused, + notice: noticeTicks > 0 ? notice : "", + palette: state ? PALETTES[state.sector - 1] || PALETTES[0] : PALETTES[0], + flash, + shakeX, + shakeY, + hurt: state?.player?.hurt || 0, + }); } function showMessage(kicker, title, body, button, action) { From 0ef3d31b6bd6ee12f468384d6487566303243183 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:33:52 +0930 Subject: [PATCH 27/35] Restore VECTOR feedback and classify automap contacts --- VECTOR_ZERO/renderer.js | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/VECTOR_ZERO/renderer.js b/VECTOR_ZERO/renderer.js index ce2563e..060c39e 100644 --- a/VECTOR_ZERO/renderer.js +++ b/VECTOR_ZERO/renderer.js @@ -81,6 +81,8 @@ s.begin(); this.map.style.display = ui.automap && state ? "block" : "none"; if (!state) { + s.shakeView(0, 0); + s.screenFlash("#972d21", 0); s.text("VECTOR ZERO FLIGHT COMPUTER // READY"); s.finish(); return; @@ -88,7 +90,11 @@ this.build(state, faces, ui.palette); const p = state.player, b = c.getBasis(p.yaw, p.pitch, p.roll), - fp = c.FP; + fp = c.FP, + hurt = Math.max(0, Number(ui.hurt) || 0), + flash = Math.max(0, Number(ui.flash) || 0); + s.shakeView(ui.shakeX, ui.shakeY); + s.screenFlash("#972d21", Math.max(hurt / 50, flash / 30)); s.camera.position.set(p.x / fp, p.y / fp, p.z / fp); s.camera.up.set(b.up.x, b.up.y, b.up.z).normalize(); this.target @@ -260,10 +266,21 @@ for (const cell of bp.cells) if (cell.y === current.y) html += ``; - for (const e of [...state.pickups, ...state.enemies]) { + const pickupColors = { + core: "#e7ad62", + energy: "#74b9ad", + shield: "#aec39b", + missiles: "#c87955", + }; + for (const e of state.pickups) { const cell = c.worldCell(e); if (cell.y === current.y) - html += ``; + html += ``; + } + for (const e of state.enemies) { + const cell = c.worldCell(e); + if (cell.y === current.y) + html += ``; } html += ``; this.map.innerHTML = html; From e66f733fccb346a1869fa1a0755a461c774d0c91 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:33:55 +0930 Subject: [PATCH 28/35] Forward and decay Pixel Warfront destruction feedback --- PIXELWARFRONT/app.js | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/PIXELWARFRONT/app.js b/PIXELWARFRONT/app.js index 70febd2..cd145a3 100644 --- a/PIXELWARFRONT/app.js +++ b/PIXELWARFRONT/app.js @@ -540,7 +540,31 @@ } function render() { - presentation.render(state, { palette: state ? palettes[state.blueprint.biomeIndex % palettes.length] : palettes[0], selectedIds, effects, hitUntil, pointerDown, dragStart, dragCurrent, paused }); + const shakeX = + state && shake > 0 + ? ((visualHash(state.tick, shake, 1) % 9) - 4) * + Math.min(1, shake / 8) + : 0, + shakeY = + state && shake > 0 + ? ((visualHash(state.tick, shake, 2) % 9) - 4) * + Math.min(1, shake / 8) + : 0; + presentation.render(state, { + palette: state ? palettes[state.blueprint.biomeIndex % palettes.length] : palettes[0], + selectedIds, + effects, + hitUntil, + pointerDown, + dragStart, + dragCurrent, + paused, + screenFlash, + shakeX, + shakeY, + }); + if (shake > 0) shake -= 1; + if (screenFlash > 0) screenFlash -= 1; } function frame(time) { From 06280420c9a19f385321b78036348977d8c1c6fe Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:33:58 +0930 Subject: [PATCH 29/35] Restore Pixel Warfront native destruction feedback --- PIXELWARFRONT/renderer.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/PIXELWARFRONT/renderer.js b/PIXELWARFRONT/renderer.js index c816229..56c6d12 100644 --- a/PIXELWARFRONT/renderer.js +++ b/PIXELWARFRONT/renderer.js @@ -14,12 +14,19 @@ c = this.core; s.begin(ui.palette?.ground || "#11181b"); if (!state) { + s.shakeView(0, 0); + s.screenFlash("#efe0c8", 0); s.text("PIXEL WARFRONT // COMMAND THEATRE READY"); s.finish(); return; } const p = ui.palette, scale = c.SCALE; + s.shakeView(ui.shakeX, ui.shakeY); + s.screenFlash( + "#efe0c8", + Math.min(0.25, Math.max(0, Number(ui.screenFlash) || 0) / 65), + ); for (let row = 0; row < c.GRID_ROWS; row++) for (let col = 0; col < c.GRID_COLUMNS; col++) { const tile = state.blueprint.terrain[row * c.GRID_COLUMNS + col], From 16cfaf6bd5f5218bd9455e36f67293efbf4b1d47 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:34:01 +0930 Subject: [PATCH 30/35] Apply INERTIA ZERO camera shake to native view --- SUBSPACE/renderer.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/SUBSPACE/renderer.js b/SUBSPACE/renderer.js index c99698b..f5a4151 100644 --- a/SUBSPACE/renderer.js +++ b/SUBSPACE/renderer.js @@ -19,8 +19,17 @@ menu = game.mode === "menu" || !game.arena; s.begin("#03090b"); const left = menu ? 0 : game.camera.x - game.width / 2, - top = menu ? 0 : game.camera.y - game.height / 2; + top = menu ? 0 : game.camera.y - game.height / 2, + shakeX = + menu || s.reducedMotion + ? 0 + : Math.sin(game.attractTime * 47.3) * game.camera.shake, + shakeY = + menu || s.reducedMotion + ? 0 + : Math.cos(game.attractTime * 39.7) * game.camera.shake * 0.72; s.view(game.width, game.height, left, top); + s.shakeView(shakeX, shakeY); for (const star of arena.stars) { const parallax = s.reducedMotion ? 0 : star.layer; const x = From 1ae353e706498e3e99f8ee1b0b6cbc79a0e40a31 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:34:55 +0930 Subject: [PATCH 31/35] Cover BLACKSTAR automap navigation markers --- BLACKSTAR_AGA/tests/browser-smoke.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BLACKSTAR_AGA/tests/browser-smoke.js b/BLACKSTAR_AGA/tests/browser-smoke.js index bef9a11..4f990ee 100644 --- a/BLACKSTAR_AGA/tests/browser-smoke.js +++ b/BLACKSTAR_AGA/tests/browser-smoke.js @@ -40,6 +40,8 @@ const core = require("../core.js"); await page.keyboard.down("Tab"); await automap.waitFor({ state: "visible" }); assert.notEqual(await automap.evaluate((element) => getComputedStyle(element).display), "none"); + assert.equal(await automap.locator('[data-marker="exit"]').count(), 1); + assert.equal(await automap.locator('[data-marker="heading"]').count(), 1); await page.keyboard.up("Tab"); await automap.waitFor({ state: "hidden" }); From 46a61bfd2ac032d91b1ebf1afa6af3129802d4aa Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:34:58 +0930 Subject: [PATCH 32/35] Cover INERTIA ZERO native camera shake --- SUBSPACE/tests/browser-smoke.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/SUBSPACE/tests/browser-smoke.js b/SUBSPACE/tests/browser-smoke.js index 664f26f..876a586 100644 --- a/SUBSPACE/tests/browser-smoke.js +++ b/SUBSPACE/tests/browser-smoke.js @@ -53,6 +53,20 @@ const { chromium } = require("playwright"); throw new Error(`Unexpected launch state: ${JSON.stringify(before)}`); } + await page.evaluate(() => { + window.inertiaZero.camera.shake = 12; + window.inertiaZero.attractTime = 0.137; + }); + await page.waitForFunction(() => { + const shake = document.querySelector("#game").nativeStage.camera.userData.nativeShake; + return shake && (Math.abs(shake.x) > 0.01 || Math.abs(shake.y) > 0.01); + }); + await page.evaluate(() => { window.inertiaZero.camera.shake = 0; }); + await page.waitForFunction(() => { + const shake = document.querySelector("#game").nativeStage.camera.userData.nativeShake; + return shake && Math.abs(shake.x) < 0.001 && Math.abs(shake.y) < 0.001; + }); + await page.keyboard.down("KeyW"); await page.keyboard.down("Space"); await page.waitForTimeout(550); From a17a42d4fd163e069b2488c968801ca6a3cdfd93 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:35:02 +0930 Subject: [PATCH 33/35] Pin native combat feedback and automap contracts --- tests/native-renderers.static.js | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/native-renderers.static.js b/tests/native-renderers.static.js index a9c4ac3..abb63d3 100644 --- a/tests/native-renderers.static.js +++ b/tests/native-renderers.static.js @@ -40,6 +40,47 @@ for (const game of games) { `${game}: simulation must drive its scene adapter`, ); } +const feedbackContracts = { + BLACKSTAR_AGA: [/flash,/, /shakeX,/, /shakeY,/, /hurt:/], + VECTOR_ZERO: [/flash,/, /shakeX,/, /shakeY,/, /hurt:/], + PIXELWARFRONT: [ + /screenFlash,/, + /shakeX,/, + /shakeY,/, + /if \(shake > 0\) shake -= 1;/, + /if \(screenFlash > 0\) screenFlash -= 1;/, + ], +}; +for (const [game, patterns] of Object.entries(feedbackContracts)) { + const app = fs.readFileSync(path.join(root, game, "app.js"), "utf8"); + for (const pattern of patterns) + assert.match(app, pattern, `${game}: native combat feedback contract`); +} +const sharedNative = fs.readFileSync( + path.join(root, "shared/qsol-native.js"), + "utf8", +); +assert.match(sharedNative, /screenFlash\(color, opacity\)/); +assert.match(sharedNative, /shakeView\(x = 0, y = 0\)/); +const blackstarRenderer = fs.readFileSync( + path.join(root, "BLACKSTAR_AGA", "renderer.js"), + "utf8", +); +assert.match(blackstarRenderer, /data-marker="exit"/); +assert.match(blackstarRenderer, /data-marker="heading"/); +const vectorRenderer = fs.readFileSync( + path.join(root, "VECTOR_ZERO", "renderer.js"), + "utf8", +); +assert.match(vectorRenderer, /data-contact="pickup-\$\{e\.kind\}"/); +assert.match(vectorRenderer, /data-contact="enemy"/); +const inertiaRenderer = fs.readFileSync( + path.join(root, "SUBSPACE", "renderer.js"), + "utf8", +); +assert.match(inertiaRenderer, /game\.camera\.shake/); +assert.match(inertiaRenderer, /s\.shakeView\(shakeX, shakeY\)/); + assert.equal( fs.existsSync(path.join(root, "shared/qsol-three-stage.js")), false, From 74b5a1047667fe145668e16584b246145a122282 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:35:04 +0930 Subject: [PATCH 34/35] Exercise native combat feedback and VECTOR contacts --- tests/native-renderers.browser.js | 92 +++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/native-renderers.browser.js b/tests/native-renderers.browser.js index 0be261d..bf620b3 100644 --- a/tests/native-renderers.browser.js +++ b/tests/native-renderers.browser.js @@ -231,6 +231,98 @@ const titles = [ return false; }, game); assert.ok(aligned, `${game}: simulation-to-instance coordinates`); + if ( + game === "BLACKSTAR_AGA" || + game === "VECTOR_ZERO" || + game === "PIXELWARFRONT" || + game === "SUBSPACE" + ) { + const feedback = await page.evaluate((game) => { + const p = __presentation, + s = p.stage, + state = __renderState, + originalArgs = __renderArgs.slice(); + let opacity = 0, + shake = { x: 0, y: 0 }, + vectorContacts = null; + if (game === "BLACKSTAR_AGA") { + p.render(state, { + ...originalArgs[0], + flash: 8, + hurt: 0, + shakeX: 3, + shakeY: -2, + }); + } else if (game === "VECTOR_ZERO") { + p.render(state, originalArgs[0], { + ...originalArgs[1], + flash: 8, + hurt: 0, + shakeX: 3, + shakeY: -2, + }); + const clone = structuredClone(state), + pickup = clone.pickups.find((item) => item.kind !== "core"), + enemy = clone.enemies[0]; + if (pickup && enemy) { + pickup.y = clone.player.y; + enemy.y = clone.player.y; + p.render(clone, originalArgs[0], { + ...originalArgs[1], + automap: true, + flash: 0, + shakeX: 0, + shakeY: 0, + }); + const map = p.map, + supply = map.querySelector('[data-contact^="pickup-"]:not([data-contact="pickup-core"])'), + hostile = map.querySelector('[data-contact="enemy"]'); + vectorContacts = { + supply: supply && supply.getAttribute("fill"), + hostile: hostile && hostile.getAttribute("fill"), + }; + } + } else if (game === "PIXELWARFRONT") { + p.render(state, { + ...originalArgs[0], + screenFlash: 12, + shakeX: 3, + shakeY: -2, + }); + } else { + const oldShake = state.camera.shake, + oldTime = state.attractTime; + state.camera.shake = 12; + state.attractTime = 0.137; + p.render(state, ...originalArgs); + state.camera.shake = oldShake; + state.attractTime = oldTime; + } + opacity = Number.parseFloat(s.flashLayer.style.opacity || "0"); + shake = { ...(s.camera.userData.nativeShake || { x: 0, y: 0 }) }; + p.render(state, ...originalArgs); + return { opacity, shake, vectorContacts }; + }, game); + assert.ok( + Math.abs(feedback.shake.x) > 0.01 || + Math.abs(feedback.shake.y) > 0.01, + `${game}: combat feedback must displace the native view`, + ); + if (game !== "SUBSPACE") + assert.ok( + feedback.opacity > 0, + `${game}: combat feedback must flash the native view`, + ); + if (game === "VECTOR_ZERO") { + assert.ok(feedback.vectorContacts?.supply); + assert.equal(feedback.vectorContacts.hostile, "#be7153"); + assert.notEqual( + feedback.vectorContacts.supply, + feedback.vectorContacts.hostile, + "VECTOR ZERO supplies must not look hostile on the automap", + ); + } + } if (game === "SEEDSTORM") { const effectRadii = await page.evaluate(() => { const s = __presentation.stage, From 39e360fee90c95d4f5ff8734db79a3d2cb5bfc9b Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:35:54 +0930 Subject: [PATCH 35/35] Capture VECTOR feedback before automap reset --- tests/native-renderers.browser.js | 36 ++++++++++++++++--------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/tests/native-renderers.browser.js b/tests/native-renderers.browser.js index bf620b3..7b7fff7 100644 --- a/tests/native-renderers.browser.js +++ b/tests/native-renderers.browser.js @@ -261,6 +261,25 @@ const titles = [ shakeX: 3, shakeY: -2, }); + } else if (game === "PIXELWARFRONT") { + p.render(state, { + ...originalArgs[0], + screenFlash: 12, + shakeX: 3, + shakeY: -2, + }); + } else { + const oldShake = state.camera.shake, + oldTime = state.attractTime; + state.camera.shake = 12; + state.attractTime = 0.137; + p.render(state, ...originalArgs); + state.camera.shake = oldShake; + state.attractTime = oldTime; + } + opacity = Number.parseFloat(s.flashLayer.style.opacity || "0"); + shake = { ...(s.camera.userData.nativeShake || { x: 0, y: 0 }) }; + if (game === "VECTOR_ZERO") { const clone = structuredClone(state), pickup = clone.pickups.find((item) => item.kind !== "core"), enemy = clone.enemies[0]; @@ -282,24 +301,7 @@ const titles = [ hostile: hostile && hostile.getAttribute("fill"), }; } - } else if (game === "PIXELWARFRONT") { - p.render(state, { - ...originalArgs[0], - screenFlash: 12, - shakeX: 3, - shakeY: -2, - }); - } else { - const oldShake = state.camera.shake, - oldTime = state.attractTime; - state.camera.shake = 12; - state.attractTime = 0.137; - p.render(state, ...originalArgs); - state.camera.shake = oldShake; - state.attractTime = oldTime; } - opacity = Number.parseFloat(s.flashLayer.style.opacity || "0"); - shake = { ...(s.camera.userData.nativeShake || { x: 0, y: 0 }) }; p.render(state, ...originalArgs); return { opacity, shake, vectorContacts }; }, game);