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(/