feat(game): add neon isometric relay interface
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
# VANTA//RELAY
|
||||||
|
|
||||||
|
A neon isometric puzzle game about being your own infrastructure.
|
||||||
|
|
||||||
|
The vault will not accept the current you as a power source. Walk onto a lower-case signal node, **anchor** a frozen echo, then use the reset present-self to cross the matching upper-case gate. Each chamber asks you to sustain more impossible versions of yourself at once.
|
||||||
|
|
||||||
|
## Play
|
||||||
|
|
||||||
|
This project is fully static and has no package installation or build step.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /root/vanta-relay-20260727-095415
|
||||||
|
python3 -m http.server 4173
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://127.0.0.1:4173`.
|
||||||
|
|
||||||
|
- `WASD` or arrow keys: move
|
||||||
|
- `Space`: anchor an echo while standing on a lower-case node
|
||||||
|
- `Z`: remove the most recently anchored echo
|
||||||
|
- `R`: reset the current relay
|
||||||
|
- `M`: toggle synthesized audio
|
||||||
|
- `H`: show the protocol card
|
||||||
|
|
||||||
|
The on-screen D-pad and buttons provide touch/click alternatives.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
- **Three authored chambers** — from one echo to three simultaneous signal witnesses.
|
||||||
|
- **Actual game rules, separate from rendering** — `game-engine.js` is a deterministic, DOM-free state model.
|
||||||
|
- **Pseudo-3D canvas world** — an isometric grid, extruded null-walls, glowing gates, particles, animated portal rings, and procedural starfield; no images, fonts, CDN, or network runtime calls.
|
||||||
|
- **Accessible controls** — semantic buttons, canvas label, visible objective/status readouts, and keyboard controls.
|
||||||
|
- **Optional local progress** — the highest unlocked chamber is stored locally only when browser storage is available.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
Node 22+ is sufficient; there are no dependencies to install.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm test
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
The suite covers engine behavior, invalid maps, closed-gate negative paths, echo capacity/undo/reset, authored-map reachability, staged gates, and a static zero-external-asset shell check.
|
||||||
|
|
||||||
|
## Project map
|
||||||
|
|
||||||
|
```text
|
||||||
|
index.html interface, responsive chrome, and inline style system
|
||||||
|
app.js canvas renderer, controls, particles, audio, smoke-test surface
|
||||||
|
game-engine.js deterministic relay state machine
|
||||||
|
levels.js authored chambers plus graph reachability helper
|
||||||
|
tests/ Node test suite
|
||||||
|
```
|
||||||
|
|
||||||
|
No credentials are stored in this repository.
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
import {
|
||||||
|
anchor,
|
||||||
|
createGame,
|
||||||
|
getActiveGateIds,
|
||||||
|
move,
|
||||||
|
removeLastEcho,
|
||||||
|
reset,
|
||||||
|
} from './game-engine.js';
|
||||||
|
import { LEVELS } from './levels.js';
|
||||||
|
|
||||||
|
const canvas = document.querySelector('#relay-canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const ui = {
|
||||||
|
levelCode: document.querySelector('#level-code'),
|
||||||
|
levelTitle: document.querySelector('#level-title'),
|
||||||
|
briefing: document.querySelector('#briefing'),
|
||||||
|
echoCount: document.querySelector('#echo-count'),
|
||||||
|
echoCapacity: document.querySelector('#echo-capacity'),
|
||||||
|
moveCount: document.querySelector('#move-count'),
|
||||||
|
gateCount: document.querySelector('#gate-count'),
|
||||||
|
coordinate: document.querySelector('#coordinate-readout'),
|
||||||
|
cycle: document.querySelector('#cycle-readout'),
|
||||||
|
objective: document.querySelector('#objective-text'),
|
||||||
|
event: document.querySelector('#event-feed'),
|
||||||
|
protocol: document.querySelector('#protocol-list'),
|
||||||
|
modal: document.querySelector('#signal-modal'),
|
||||||
|
modalTitle: document.querySelector('#modal-title'),
|
||||||
|
modalCopy: document.querySelector('#modal-copy'),
|
||||||
|
nextLevel: document.querySelector('#next-level'),
|
||||||
|
help: document.querySelector('#help-card'),
|
||||||
|
mute: document.querySelector('[data-action="mute"]'),
|
||||||
|
};
|
||||||
|
|
||||||
|
let levelIndex = 0;
|
||||||
|
let game;
|
||||||
|
let muted = false;
|
||||||
|
let audioContext;
|
||||||
|
let completionQueued = false;
|
||||||
|
let lastFrame = 0;
|
||||||
|
let elapsed = 0;
|
||||||
|
let particles = [];
|
||||||
|
let activeEvent = 'AWAITING INPUT';
|
||||||
|
let highestUnlocked = readNumber('vanta-relay-unlocked', 0);
|
||||||
|
|
||||||
|
function readNumber(key, fallback) {
|
||||||
|
try {
|
||||||
|
const value = Number.parseInt(localStorage.getItem(key) || '', 10);
|
||||||
|
return Number.isInteger(value) ? value : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeNumber(key, value) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, String(value));
|
||||||
|
} catch {
|
||||||
|
// Progress is optional. The relay remains fully playable without storage.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelCapacity(index) {
|
||||||
|
return Math.min(index + 1, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLevel(index) {
|
||||||
|
levelIndex = index;
|
||||||
|
game = createGame(LEVELS[index].map, { maxEchoes: levelCapacity(index) });
|
||||||
|
completionQueued = false;
|
||||||
|
particles = [];
|
||||||
|
activeEvent = index === 0 ? 'FIND A LOWER-CASE SIGNAL NODE' : 'NEW MEMORY CHAMBER SYNCHRONIZED';
|
||||||
|
document.documentElement.style.setProperty('--cyan', LEVELS[index].accent);
|
||||||
|
updateHud();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHud() {
|
||||||
|
const definition = LEVELS[levelIndex];
|
||||||
|
const activeGates = getActiveGateIds(game);
|
||||||
|
ui.levelCode.textContent = definition.code;
|
||||||
|
ui.levelTitle.textContent = definition.title;
|
||||||
|
ui.briefing.textContent = definition.briefing;
|
||||||
|
ui.echoCount.textContent = String(game.echoes.length).padStart(2, '0');
|
||||||
|
ui.echoCapacity.textContent = String(game.maxEchoes).padStart(2, '0');
|
||||||
|
ui.moveCount.textContent = String(game.moves).padStart(3, '0');
|
||||||
|
ui.gateCount.textContent = `${String(activeGates.length).padStart(2, '0')} ACTIVE`;
|
||||||
|
ui.coordinate.textContent = `POS // ${String(game.player.x).padStart(2, '0')}.${String(game.player.y).padStart(2, '0')}`;
|
||||||
|
ui.cycle.textContent = `CYCLE // ${String(game.moves).padStart(3, '0')}`;
|
||||||
|
ui.objective.textContent = game.status === 'won'
|
||||||
|
? 'Extraction signature accepted. The next chamber is listening.'
|
||||||
|
: `Only echoes power signal nodes. ${game.echoes.length}/${game.maxEchoes} memories anchored.`;
|
||||||
|
ui.event.textContent = activeEvent;
|
||||||
|
|
||||||
|
ui.protocol.innerHTML = LEVELS.map((level, index) => {
|
||||||
|
const state = index === levelIndex ? 'current' : index <= highestUnlocked ? 'active' : '';
|
||||||
|
const tag = index === levelIndex ? 'LIVE' : index <= highestUnlocked ? 'ARCHIVED' : 'SEALED';
|
||||||
|
return `<div class="protocol-node ${state}"><span>${level.code}</span><span>${tag}</span></div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTone(frequency, duration, kind = 'sine', volume = 0.025) {
|
||||||
|
if (muted) return;
|
||||||
|
try {
|
||||||
|
audioContext ||= new AudioContext();
|
||||||
|
const oscillator = audioContext.createOscillator();
|
||||||
|
const gain = audioContext.createGain();
|
||||||
|
oscillator.type = kind;
|
||||||
|
oscillator.frequency.setValueAtTime(frequency, audioContext.currentTime);
|
||||||
|
gain.gain.setValueAtTime(0.0001, audioContext.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(volume, audioContext.currentTime + 0.012);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, audioContext.currentTime + duration);
|
||||||
|
oscillator.connect(gain).connect(audioContext.destination);
|
||||||
|
oscillator.start();
|
||||||
|
oscillator.stop(audioContext.currentTime + duration + 0.02);
|
||||||
|
} catch {
|
||||||
|
muted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventLabel(event) {
|
||||||
|
const labels = {
|
||||||
|
anchor: 'ECHO FROZEN // GATE SIGNATURE POWERED',
|
||||||
|
'anchor-required': 'ANCHOR REJECTED // STAND ON A VACANT SIGNAL NODE',
|
||||||
|
sealed: 'GATE REJECTED // ONLY AN ECHO MAY SUPPLY CURRENT',
|
||||||
|
wall: 'HARD NULL // PATH TERMINATED',
|
||||||
|
boundary: 'OUTSIDE RELAY GEOMETRY',
|
||||||
|
invalid: 'INVALID VECTOR',
|
||||||
|
undo: 'LAST WITNESS UNWOUND',
|
||||||
|
'nothing-to-undo': 'NO WITNESS TO UNWIND',
|
||||||
|
ready: 'RELAY PRIMED',
|
||||||
|
extract: 'EXTRACTION SIGNATURE ACCEPTED',
|
||||||
|
};
|
||||||
|
return labels[event] || 'PULSE REGISTERED';
|
||||||
|
}
|
||||||
|
|
||||||
|
function project(position) {
|
||||||
|
const { width, height } = game.level;
|
||||||
|
const tileWidth = Math.min(92, 1250 / (width + height));
|
||||||
|
const tileHeight = tileWidth * 0.46;
|
||||||
|
return {
|
||||||
|
x: canvas.width / 2 + (position.x - position.y) * tileWidth * 0.5,
|
||||||
|
y: 172 + (position.x + position.y) * tileHeight * 0.5,
|
||||||
|
tileWidth,
|
||||||
|
tileHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function diamond(x, y, width, height) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y - height / 2);
|
||||||
|
ctx.lineTo(x + width / 2, y);
|
||||||
|
ctx.lineTo(x, y + height / 2);
|
||||||
|
ctx.lineTo(x - width / 2, y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function worldBurst(position, color, count = 18, force = 1) {
|
||||||
|
const point = project(position);
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const angle = (Math.PI * 2 * index) / count + Math.random() * 0.34;
|
||||||
|
const speed = (0.7 + Math.random() * 1.5) * force;
|
||||||
|
particles.push({
|
||||||
|
x: point.x,
|
||||||
|
y: point.y - 30,
|
||||||
|
vx: Math.cos(angle) * speed * 80,
|
||||||
|
vy: Math.sin(angle) * speed * 80 - 24,
|
||||||
|
life: 0.55 + Math.random() * 0.6,
|
||||||
|
age: 0,
|
||||||
|
color,
|
||||||
|
size: 1 + Math.random() * 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function perform(action) {
|
||||||
|
if (game.status === 'won') return;
|
||||||
|
const before = game;
|
||||||
|
if (action === 'anchor') game = anchor(game);
|
||||||
|
if (action === 'undo') game = removeLastEcho(game);
|
||||||
|
if (action === 'reset') game = reset(game);
|
||||||
|
if (/^[UDLR]$/.test(action)) game = move(game, action);
|
||||||
|
|
||||||
|
activeEvent = eventLabel(game.lastEvent);
|
||||||
|
if (game.lastEvent === 'anchor') {
|
||||||
|
worldBurst(before.player, '#ff62d1', 34, 1.45);
|
||||||
|
makeTone(188, 0.16, 'triangle', 0.045);
|
||||||
|
setTimeout(() => makeTone(380, 0.15, 'sine', 0.035), 80);
|
||||||
|
} else if (game.lastEvent === 'sealed') {
|
||||||
|
worldBurst(before.player, '#a98bff', 10, 0.5);
|
||||||
|
makeTone(92, 0.11, 'square', 0.018);
|
||||||
|
} else if (game.lastEvent === 'move') {
|
||||||
|
worldBurst(game.player, '#65f9ff', 5, 0.36);
|
||||||
|
makeTone(220 + game.moves * 3, 0.04, 'sine', 0.013);
|
||||||
|
} else if (game.lastEvent === 'extract') {
|
||||||
|
worldBurst(game.player, '#fff3a1', 80, 2.1);
|
||||||
|
makeTone(260, 0.24, 'triangle', 0.05);
|
||||||
|
setTimeout(() => makeTone(520, 0.28, 'sine', 0.045), 130);
|
||||||
|
queueCompletion();
|
||||||
|
} else if (game.lastEvent === 'undo' || action === 'reset') {
|
||||||
|
particles = [];
|
||||||
|
makeTone(130, 0.08, 'sine', 0.02);
|
||||||
|
}
|
||||||
|
updateHud();
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueCompletion() {
|
||||||
|
if (completionQueued) return;
|
||||||
|
completionQueued = true;
|
||||||
|
highestUnlocked = Math.max(highestUnlocked, Math.min(levelIndex + 1, LEVELS.length - 1));
|
||||||
|
writeNumber('vanta-relay-unlocked', highestUnlocked);
|
||||||
|
setTimeout(() => {
|
||||||
|
const lastRelay = levelIndex === LEVELS.length - 1;
|
||||||
|
ui.modalTitle.textContent = lastRelay ? 'YOU ARE THE RELAY' : 'SIGNAL HOLDS';
|
||||||
|
ui.modalCopy.textContent = lastRelay
|
||||||
|
? 'Three versions of you held the void open. The vault has no owner now — only an exit.'
|
||||||
|
: 'A new chamber has acknowledged your impossible alibi.';
|
||||||
|
ui.nextLevel.textContent = lastRelay ? 'REBOOT THE RELAY' : 'ENTER NEXT CHAMBER';
|
||||||
|
ui.modal.hidden = false;
|
||||||
|
}, 650);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBackground(time) {
|
||||||
|
const gradient = ctx.createRadialGradient(canvas.width * 0.5, 250, 20, canvas.width * 0.5, 370, 890);
|
||||||
|
gradient.addColorStop(0, '#1b3b62');
|
||||||
|
gradient.addColorStop(0.33, '#101c3a');
|
||||||
|
gradient.addColorStop(1, '#050711');
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
for (let index = 0; index < 82; index += 1) {
|
||||||
|
const x = ((Math.sin(index * 643.13) + 1) * 0.5) * canvas.width;
|
||||||
|
const y = ((Math.sin(index * 93.77 + 1.1) + 1) * 0.5) * 480;
|
||||||
|
const alpha = 0.12 + ((Math.sin(time * 0.001 + index) + 1) * 0.08);
|
||||||
|
ctx.fillStyle = `rgba(151, 223, 255, ${alpha})`;
|
||||||
|
ctx.fillRect(x, y, index % 5 === 0 ? 2 : 1, index % 5 === 0 ? 2 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const horizon = 132;
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = 'rgba(90, 231, 255, 0.11)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let index = -18; index <= 18; index += 1) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(canvas.width / 2, horizon);
|
||||||
|
ctx.lineTo(canvas.width / 2 + index * 92, canvas.height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let row = 0; row < 12; row += 1) {
|
||||||
|
const progress = row / 12;
|
||||||
|
const y = horizon + progress * progress * 720;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(canvas.width, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawFloor(point, tile) {
|
||||||
|
const { x, y, tileWidth, tileHeight } = point;
|
||||||
|
const fill = ctx.createLinearGradient(x - tileWidth / 2, y - tileHeight / 2, x + tileWidth / 2, y + tileHeight / 2);
|
||||||
|
fill.addColorStop(0, tile === '#' ? '#15213b' : '#123550');
|
||||||
|
fill.addColorStop(1, tile === '#' ? '#0a1021' : '#071424');
|
||||||
|
diamond(x, y, tileWidth, tileHeight);
|
||||||
|
ctx.fillStyle = fill;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = tile === '#' ? 'rgba(115, 164, 210, .25)' : 'rgba(98, 238, 255, .24)';
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWall(point, depth) {
|
||||||
|
const { x, y, tileWidth, tileHeight } = point;
|
||||||
|
const left = x - tileWidth / 2;
|
||||||
|
const right = x + tileWidth / 2;
|
||||||
|
const bottom = y + tileHeight / 2;
|
||||||
|
ctx.fillStyle = '#081226';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(left, y);
|
||||||
|
ctx.lineTo(x, bottom);
|
||||||
|
ctx.lineTo(x, bottom + depth);
|
||||||
|
ctx.lineTo(left, y + depth);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#112a46';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(right, y);
|
||||||
|
ctx.lineTo(x, bottom);
|
||||||
|
ctx.lineTo(x, bottom + depth);
|
||||||
|
ctx.lineTo(right, y + depth);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = 'rgba(91, 232, 255, .14)';
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawNode(point, id, active, time) {
|
||||||
|
const { x, y, tileWidth } = point;
|
||||||
|
const pulse = 1 + Math.sin(time * 0.006 + id.charCodeAt(0)) * 0.16;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(x, y - 4);
|
||||||
|
ctx.scale(1, 0.48);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, tileWidth * 0.23 * pulse, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = active ? 'rgba(255, 98, 209, .46)' : 'rgba(246, 196, 66, .24)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.strokeStyle = active ? '#ff62d1' : '#ffc45a';
|
||||||
|
ctx.shadowBlur = active ? 26 : 10;
|
||||||
|
ctx.shadowColor = ctx.strokeStyle;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
ctx.fillStyle = active ? '#fff0fb' : '#ffe1a0';
|
||||||
|
ctx.font = '700 13px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(id.toUpperCase(), x, y + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGate(point, id, active, time) {
|
||||||
|
const { x, y, tileWidth, tileHeight } = point;
|
||||||
|
const height = tileWidth * 0.84;
|
||||||
|
const glow = active ? '#3df2ff' : '#a98bff';
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = active ? 0.72 : 0.46;
|
||||||
|
const fade = ctx.createLinearGradient(x, y - height, x, y + tileHeight / 2);
|
||||||
|
fade.addColorStop(0, active ? 'rgba(152,255,255,.24)' : 'rgba(185,135,255,.1)');
|
||||||
|
fade.addColorStop(1, active ? 'rgba(61,242,255,.05)' : 'rgba(169,139,255,.03)');
|
||||||
|
ctx.fillStyle = fade;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x - tileWidth * 0.28, y);
|
||||||
|
ctx.lineTo(x, y - height);
|
||||||
|
ctx.lineTo(x + tileWidth * 0.28, y);
|
||||||
|
ctx.lineTo(x, y + tileHeight * 0.16);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = glow;
|
||||||
|
ctx.lineWidth = active ? 2.5 : 2;
|
||||||
|
ctx.shadowBlur = active ? 28 : 12;
|
||||||
|
ctx.shadowColor = glow;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.globalAlpha = active ? 0.34 + Math.sin(time * 0.008) * 0.1 : 0.3;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x - tileWidth * 0.2, y - 2);
|
||||||
|
ctx.lineTo(x + tileWidth * 0.2, y - height + 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
ctx.fillStyle = active ? '#dfffff' : '#d7bdff';
|
||||||
|
ctx.font = '700 14px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(id, x, y - height * 0.46);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawExtraction(point, time) {
|
||||||
|
const { x, y, tileWidth } = point;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(x, y - tileWidth * 0.42);
|
||||||
|
ctx.scale(1, 0.43);
|
||||||
|
for (let ring = 0; ring < 4; ring += 1) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, tileWidth * (0.15 + ring * 0.07) + Math.sin(time * 0.008 + ring) * 2, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = `hsla(${52 + ring * 18}, 100%, 70%, ${0.62 - ring * 0.1})`;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.shadowBlur = 16;
|
||||||
|
ctx.shadowColor = '#fff39c';
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawEntity(position, kind, time, index = 0) {
|
||||||
|
const point = project(position);
|
||||||
|
const bob = Math.sin(time * 0.005 + index) * 5;
|
||||||
|
const color = kind === 'echo' ? '#ff62d1' : '#64faff';
|
||||||
|
const core = kind === 'echo' ? '#ffe4fa' : '#ecffff';
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(point.x, point.y - point.tileWidth * 0.55 + bob);
|
||||||
|
ctx.scale(1, 0.42);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, point.tileWidth * 0.55 / 0.42, point.tileWidth * 0.18, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,.32)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
const radius = point.tileWidth * (kind === 'echo' ? 0.16 : 0.19);
|
||||||
|
const gradient = ctx.createRadialGradient(point.x - radius * 0.25, point.y - point.tileWidth * 0.55 + bob - radius * 0.25, 1, point.x, point.y - point.tileWidth * 0.55 + bob, radius);
|
||||||
|
gradient.addColorStop(0, core);
|
||||||
|
gradient.addColorStop(0.35, color);
|
||||||
|
gradient.addColorStop(1, kind === 'echo' ? 'rgba(255,98,209,.08)' : 'rgba(61,242,255,.08)');
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(point.x, point.y - point.tileWidth * 0.55 + bob, radius, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.shadowBlur = kind === 'echo' ? 30 : 36;
|
||||||
|
ctx.shadowColor = color;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
if (kind === 'echo') {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(point.x, point.y - point.tileWidth * 0.55 + bob, radius * 1.45 + Math.sin(time * 0.008) * 2, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = 'rgba(255, 161, 225, .52)';
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawParticles(delta) {
|
||||||
|
particles = particles.filter((particle) => {
|
||||||
|
particle.age += delta;
|
||||||
|
particle.x += particle.vx * delta;
|
||||||
|
particle.y += particle.vy * delta;
|
||||||
|
particle.vy += 56 * delta;
|
||||||
|
const ratio = 1 - particle.age / particle.life;
|
||||||
|
if (ratio <= 0) return false;
|
||||||
|
ctx.fillStyle = particle.color;
|
||||||
|
ctx.globalAlpha = ratio * ratio;
|
||||||
|
ctx.fillRect(particle.x, particle.y, particle.size, particle.size);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBoard(time, delta) {
|
||||||
|
const level = game.level;
|
||||||
|
const activeGates = new Set(getActiveGateIds(game));
|
||||||
|
const cells = [];
|
||||||
|
for (let y = 0; y < level.height; y += 1) {
|
||||||
|
for (let x = 0; x < level.width; x += 1) cells.push({ x, y, tile: level.grid[y][x] });
|
||||||
|
}
|
||||||
|
cells.sort((left, right) => (left.x + left.y) - (right.x + right.y));
|
||||||
|
|
||||||
|
for (const cell of cells) {
|
||||||
|
const point = project(cell);
|
||||||
|
drawFloor(point, cell.tile);
|
||||||
|
if (cell.tile === '#') drawWall(point, point.tileWidth * 0.52);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, position] of level.pads) drawNode(project(position), id, activeGates.has(id.toUpperCase()), time);
|
||||||
|
for (const [id, position] of level.gates) drawGate(project(position), id, activeGates.has(id), time);
|
||||||
|
drawExtraction(project(level.goal), time);
|
||||||
|
|
||||||
|
game.echoes.forEach((echo, index) => drawEntity(echo.position, 'echo', time, index));
|
||||||
|
drawEntity(game.player, 'player', time, game.echoes.length);
|
||||||
|
drawParticles(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame(timestamp) {
|
||||||
|
const delta = Math.min((timestamp - lastFrame) / 1000 || 0, 0.05);
|
||||||
|
lastFrame = timestamp;
|
||||||
|
elapsed += delta;
|
||||||
|
drawBackground(timestamp);
|
||||||
|
drawBoard(timestamp, delta);
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMute() {
|
||||||
|
muted = !muted;
|
||||||
|
ui.mute.textContent = muted ? '◌ AUDIO: OFF' : '◌ AUDIO: ON';
|
||||||
|
activeEvent = muted ? 'AUDIO LINK MUTED' : 'AUDIO LINK RESTORED';
|
||||||
|
updateHud();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const button of document.querySelectorAll('[data-move]')) {
|
||||||
|
button.addEventListener('click', () => perform(button.dataset.move));
|
||||||
|
}
|
||||||
|
for (const button of document.querySelectorAll('[data-action]')) {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const action = button.dataset.action;
|
||||||
|
if (action === 'mute') toggleMute();
|
||||||
|
else if (action === 'help') ui.help.hidden = !ui.help.hidden;
|
||||||
|
else perform(action);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.nextLevel.addEventListener('click', () => {
|
||||||
|
ui.modal.hidden = true;
|
||||||
|
startLevel(levelIndex === LEVELS.length - 1 ? 0 : levelIndex + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('keydown', (event) => {
|
||||||
|
const keys = {
|
||||||
|
ArrowUp: 'U', ArrowDown: 'D', ArrowLeft: 'L', ArrowRight: 'R',
|
||||||
|
w: 'U', W: 'U', s: 'D', S: 'D', a: 'L', A: 'L', d: 'R', D: 'R',
|
||||||
|
' ': 'anchor', z: 'undo', Z: 'undo', r: 'reset', R: 'reset',
|
||||||
|
};
|
||||||
|
if (!(event.key in keys)) {
|
||||||
|
if (event.key === 'm' || event.key === 'M') toggleMute();
|
||||||
|
if (event.key === 'h' || event.key === 'H') ui.help.hidden = !ui.help.hidden;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
perform(keys[event.key]);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.__VANTA_TEST__ = {
|
||||||
|
getState: () => ({
|
||||||
|
level: LEVELS[levelIndex].id,
|
||||||
|
player: { ...game.player },
|
||||||
|
echoes: game.echoes.map((echo) => ({ id: echo.id, position: { ...echo.position } })),
|
||||||
|
gates: getActiveGateIds(game),
|
||||||
|
status: game.status,
|
||||||
|
moves: game.moves,
|
||||||
|
}),
|
||||||
|
act: (action) => perform(action),
|
||||||
|
loadLevel: (index) => startLevel(index),
|
||||||
|
};
|
||||||
|
|
||||||
|
startLevel(0);
|
||||||
|
requestAnimationFrame(frame);
|
||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="theme-color" content="#050711">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath fill='%233df2ff' d='M16 2 30 16 16 30 2 16Z'/%3E%3Cpath fill='%23050711' d='m16 8 8 8-8 8-8-8Z'/%3E%3C/svg%3E">
|
||||||
|
<title>VANTA//RELAY — Echo puzzle</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--ink: #e6fbff;
|
||||||
|
--muted: #7f9bab;
|
||||||
|
--cyan: #3df2ff;
|
||||||
|
--violet: #a98bff;
|
||||||
|
--pink: #ff62d1;
|
||||||
|
--line: rgba(116, 229, 255, .22);
|
||||||
|
--panel: rgba(6, 12, 26, .67);
|
||||||
|
--shadow: rgba(0, 0, 0, .56);
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { min-height: 100%; margin: 0; }
|
||||||
|
body {
|
||||||
|
overflow-x: hidden;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% -20%, #25347a 0, transparent 35rem),
|
||||||
|
radial-gradient(circle at 105% 80%, #321442 0, transparent 32rem),
|
||||||
|
#050711;
|
||||||
|
}
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
opacity: .24;
|
||||||
|
background-image: linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.018) 1px, transparent 1px);
|
||||||
|
background-size: 4px 4px, 4px 4px;
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
button { font: inherit; color: inherit; }
|
||||||
|
button:focus-visible { outline: 2px solid var(--cyan); outline-offset: 3px; }
|
||||||
|
.app-shell { width: min(1540px, 100%); min-height: 100vh; margin: 0 auto; padding: 20px; position: relative; }
|
||||||
|
.masthead { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 14px; }
|
||||||
|
.brand { display: flex; align-items: baseline; gap: 10px; letter-spacing: .08em; }
|
||||||
|
.brand-mark { font-weight: 900; font-size: clamp(1.2rem, 3vw, 2rem); text-shadow: 0 0 18px var(--cyan); }
|
||||||
|
.brand-sub, .eyebrow, .mini-label { color: var(--muted); font-size: .66rem; letter-spacing: .16em; text-transform: uppercase; }
|
||||||
|
.system-status { display: flex; gap: 9px; align-items: center; color: #a7eeb7; font-size: .69rem; letter-spacing: .1em; }
|
||||||
|
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #66ff9a; box-shadow: 0 0 15px #66ff9a; animation: blink 1.4s infinite ease-in-out; }
|
||||||
|
.game-grid { display: grid; grid-template-columns: minmax(180px, 230px) minmax(0, 1fr) minmax(190px, 250px); gap: 14px; align-items: stretch; }
|
||||||
|
.panel, .stage-frame {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: linear-gradient(145deg, rgba(14, 27, 51, .79), var(--panel));
|
||||||
|
box-shadow: 0 18px 60px var(--shadow), inset 0 1px 0 rgba(255,255,255,.045);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.panel::before, .stage-frame::before { content: ""; position: absolute; inset: 0; pointer-events: none; border: 1px solid rgba(255,255,255,.025); }
|
||||||
|
.panel { min-height: 626px; padding: 17px; }
|
||||||
|
.panel h2 { margin: 5px 0 14px; font-size: .92rem; letter-spacing: .03em; line-height: 1.35; }
|
||||||
|
.panel-section { border-top: 1px solid rgba(104, 227, 255, .16); padding-top: 14px; margin-top: 15px; }
|
||||||
|
.panel-section:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
|
||||||
|
.readout { display: grid; gap: 8px; }
|
||||||
|
.readout-row { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; font-size: .73rem; }
|
||||||
|
.readout-row span:last-child { color: var(--cyan); text-align: right; }
|
||||||
|
.big-count { color: #fff; text-shadow: 0 0 14px var(--pink); font-size: 1.5rem; letter-spacing: -.08em; }
|
||||||
|
.mission-copy { color: #adbfca; font: 400 .76rem/1.58 ui-monospace, monospace; margin: 0; }
|
||||||
|
.legend { display: grid; gap: 8px; font-size: .7rem; color: #a9c1ce; }
|
||||||
|
.legend-item { display: flex; gap: 9px; align-items: center; }
|
||||||
|
.glyph { width: 12px; height: 12px; transform: rotate(45deg); border: 1px solid var(--cyan); box-shadow: 0 0 10px var(--cyan); }
|
||||||
|
.glyph.echo { border-radius: 50%; border-color: var(--pink); box-shadow: 0 0 10px var(--pink); }
|
||||||
|
.glyph.gate { background: linear-gradient(135deg, var(--violet), transparent); border-color: var(--violet); box-shadow: 0 0 10px var(--violet); }
|
||||||
|
.stage-frame { min-height: 626px; isolation: isolate; background: #080c1b; }
|
||||||
|
#relay-canvas { display: block; width: 100%; height: 100%; min-height: 626px; position: relative; z-index: 0; cursor: crosshair; }
|
||||||
|
.stage-corners::before, .stage-corners::after { content: ""; position: absolute; z-index: 2; width: 36px; height: 36px; pointer-events: none; border-color: var(--cyan); opacity: .7; }
|
||||||
|
.stage-corners::before { left: 10px; top: 10px; border-left: 2px solid; border-top: 2px solid; }
|
||||||
|
.stage-corners::after { right: 10px; bottom: 10px; border-right: 2px solid; border-bottom: 2px solid; }
|
||||||
|
.stage-readout { position: absolute; z-index: 2; left: 18px; right: 18px; top: 15px; display: flex; justify-content: space-between; pointer-events: none; font-size: .63rem; color: rgba(216, 250, 255, .74); letter-spacing: .14em; }
|
||||||
|
.objective-band { position: absolute; z-index: 2; left: 18px; right: 18px; bottom: 16px; display: flex; align-items: end; justify-content: space-between; gap: 14px; pointer-events: none; }
|
||||||
|
.objective { background: rgba(4, 8, 18, .68); border-left: 2px solid var(--cyan); padding: 9px 11px; max-width: 70%; color: #c8e3ec; font-size: .7rem; line-height: 1.45; }
|
||||||
|
.event-feed { color: var(--cyan); text-shadow: 0 0 10px var(--cyan); font-size: .63rem; text-align: right; letter-spacing: .08em; max-width: 34%; }
|
||||||
|
.protocol-list { display: grid; gap: 7px; margin-top: 10px; }
|
||||||
|
.protocol-node { display: flex; justify-content: space-between; gap: 6px; padding: 7px 8px; border: 1px solid rgba(116, 229, 255, .16); color: #7796a4; background: rgba(1, 5, 14, .26); font-size: .67rem; }
|
||||||
|
.protocol-node.active { color: #edfeff; border-color: rgba(61, 242, 255, .74); box-shadow: inset 3px 0 0 var(--cyan), 0 0 15px rgba(61, 242, 255, .12); }
|
||||||
|
.protocol-node.current { color: var(--pink); border-color: rgba(255, 98, 209, .62); }
|
||||||
|
.control-stack { display: grid; gap: 9px; margin-top: 10px; }
|
||||||
|
.action-button, .tiny-button, .dpad button, .modal-button {
|
||||||
|
border: 1px solid rgba(112, 230, 255, .34);
|
||||||
|
background: linear-gradient(160deg, rgba(33, 70, 101, .75), rgba(7, 14, 30, .9));
|
||||||
|
min-height: 38px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease;
|
||||||
|
}
|
||||||
|
.action-button:hover, .tiny-button:hover, .dpad button:hover, .modal-button:hover { transform: translateY(-2px); border-color: var(--cyan); box-shadow: 0 0 20px rgba(61, 242, 255, .2); }
|
||||||
|
.action-button { letter-spacing: .11em; font-size: .72rem; }
|
||||||
|
.action-button.primary { color: #07131d; background: linear-gradient(135deg, #a8fbff, #3df2ff); border-color: #d0ffff; font-weight: 800; box-shadow: 0 0 24px rgba(61, 242, 255, .25); }
|
||||||
|
.small-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.tiny-button { min-height: 31px; font-size: .62rem; color: #a9c2cd; }
|
||||||
|
.dpad { display: grid; grid-template-columns: repeat(3, 38px); grid-template-rows: repeat(2, 34px); justify-content: center; gap: 4px; margin-top: 13px; }
|
||||||
|
.dpad button { min-height: 34px; font-size: .72rem; }
|
||||||
|
.dpad [data-move="U"] { grid-column: 2; }
|
||||||
|
.dpad [data-move="L"] { grid-column: 1; }
|
||||||
|
.dpad [data-move="D"] { grid-column: 2; }
|
||||||
|
.dpad [data-move="R"] { grid-column: 3; }
|
||||||
|
.key-hints { text-align: center; margin-top: 11px; color: #66838f; font-size: .58rem; line-height: 1.65; }
|
||||||
|
kbd { border: 1px solid #395365; padding: 1px 3px; color: #c5e6ee; background: #0b1726; }
|
||||||
|
.signal-modal { position: fixed; z-index: 10; inset: 0; display: grid; place-items: center; padding: 22px; background: rgba(1, 3, 11, .78); backdrop-filter: blur(9px); }
|
||||||
|
.signal-modal[hidden], .help-card[hidden] { display: none; }
|
||||||
|
.modal-card { width: min(440px, 100%); padding: 28px; border: 1px solid var(--cyan); background: linear-gradient(145deg, #12264b, #070d1c 70%); box-shadow: 0 0 80px rgba(61, 242, 255, .28); text-align: center; }
|
||||||
|
.modal-card h2 { font-size: clamp(1.45rem, 5vw, 2.3rem); letter-spacing: .09em; margin: 10px 0; text-shadow: 0 0 20px var(--cyan); }
|
||||||
|
.modal-card p { color: #b4cad2; font: .78rem/1.6 ui-monospace, monospace; margin: 0 0 20px; }
|
||||||
|
.modal-button { width: 100%; min-height: 44px; letter-spacing: .12em; color: #07131d; background: var(--cyan); font-weight: 800; }
|
||||||
|
.help-card { position: fixed; z-index: 9; right: 25px; bottom: 25px; max-width: 330px; padding: 16px; border: 1px solid var(--violet); background: #0a1023; box-shadow: 0 18px 60px #000; font: .72rem/1.55 ui-monospace, monospace; color: #c3d5df; }
|
||||||
|
.help-card strong { color: var(--cyan); }
|
||||||
|
@keyframes blink { 50% { opacity: .35; transform: scale(.7); } }
|
||||||
|
@media (max-width: 1040px) { .game-grid { grid-template-columns: 200px minmax(0, 1fr); } .right-panel { grid-column: 1 / -1; min-height: auto; display: grid; grid-template-columns: 1fr 1fr; gap: 17px; } .right-panel .panel-section:first-child { border-top: 0; padding-top: 0; } }
|
||||||
|
@media (max-width: 700px) { .app-shell { padding: 10px; } .masthead { align-items: flex-start; } .brand-sub { display: none; } .game-grid { display: flex; flex-direction: column; } .panel { min-height: auto; } .left-panel { order: 2; } .right-panel { order: 3; display: block; } .stage-frame { order: 1; min-height: 480px; } #relay-canvas { min-height: 480px; } .objective { max-width: 78%; font-size: .62rem; } .event-feed { display: none; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="app-shell">
|
||||||
|
<header class="masthead">
|
||||||
|
<div class="brand"><span class="brand-mark">VANTA//RELAY</span><span class="brand-sub">a memory-routing experiment</span></div>
|
||||||
|
<div class="system-status"><span class="status-dot"></span><span id="system-status">NEURAL LINK STABLE</span></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="game-grid" aria-label="Vanta Relay game console">
|
||||||
|
<aside class="panel left-panel" aria-label="Mission and controls">
|
||||||
|
<section class="panel-section">
|
||||||
|
<div class="eyebrow" id="level-code">01 / COLD BOOT</div>
|
||||||
|
<h2 id="level-title">Wake the first witness</h2>
|
||||||
|
<div class="readout">
|
||||||
|
<div class="readout-row"><span>ECHOS</span><span><b class="big-count" id="echo-count">00</b> / <span id="echo-capacity">01</span></span></div>
|
||||||
|
<div class="readout-row"><span>STEPS</span><span id="move-count">000</span></div>
|
||||||
|
<div class="readout-row"><span>GATES</span><span id="gate-count">00 ACTIVE</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="panel-section">
|
||||||
|
<div class="eyebrow">Mission note</div>
|
||||||
|
<p class="mission-copy" id="briefing">The exit only remembers a body that is no longer there. Leave one echo on the amber node.</p>
|
||||||
|
</section>
|
||||||
|
<section class="panel-section">
|
||||||
|
<div class="eyebrow">Matter legend</div>
|
||||||
|
<div class="legend">
|
||||||
|
<div class="legend-item"><i class="glyph"></i><span>YOU / volatile present</span></div>
|
||||||
|
<div class="legend-item"><i class="glyph echo"></i><span>ECHO / frozen witness</span></div>
|
||||||
|
<div class="legend-item"><i class="glyph gate"></i><span>GATE / needs an echo</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="stage-frame" aria-label="Interactive relay board">
|
||||||
|
<canvas id="relay-canvas" width="1380" height="860" aria-label="Isometric neon relay puzzle board"></canvas>
|
||||||
|
<div class="stage-corners" aria-hidden="true"></div>
|
||||||
|
<div class="stage-readout"><span id="coordinate-readout">POS // 01.01</span><span id="cycle-readout">CYCLE // 000</span></div>
|
||||||
|
<div class="objective-band"><div class="objective" id="objective-text">Walk onto a lower-case signal node, then press ANCHOR. Your new echo stays there while you restart.</div><div class="event-feed" id="event-feed">AWAITING INPUT</div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="panel right-panel" aria-label="Relay controls">
|
||||||
|
<section class="panel-section">
|
||||||
|
<div class="eyebrow">Signal protocol</div>
|
||||||
|
<div class="protocol-list" id="protocol-list"></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel-section">
|
||||||
|
<div class="eyebrow">Temporal controls</div>
|
||||||
|
<div class="control-stack">
|
||||||
|
<button class="action-button primary" type="button" data-action="anchor">✦ ANCHOR ECHO</button>
|
||||||
|
<div class="small-actions"><button class="tiny-button" type="button" data-action="undo">↶ UNDO ECHO</button><button class="tiny-button" type="button" data-action="reset">⌁ RESET RELAY</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="dpad" aria-label="Movement controls">
|
||||||
|
<button type="button" data-move="U" aria-label="Move up">▲</button>
|
||||||
|
<button type="button" data-move="L" aria-label="Move left">◀</button>
|
||||||
|
<button type="button" data-move="D" aria-label="Move down">▼</button>
|
||||||
|
<button type="button" data-move="R" aria-label="Move right">▶</button>
|
||||||
|
</div>
|
||||||
|
<div class="key-hints"><kbd>WASD</kbd> / <kbd>ARROWS</kbd> MOVE<br><kbd>SPACE</kbd> ANCHOR · <kbd>Z</kbd> UNDO · <kbd>R</kbd> RESET</div>
|
||||||
|
</section>
|
||||||
|
<section class="panel-section">
|
||||||
|
<div class="small-actions"><button class="tiny-button" type="button" data-action="mute">◌ AUDIO: ON</button><button class="tiny-button" type="button" data-action="help">? PROTOCOL</button></div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<section class="signal-modal" id="signal-modal" hidden aria-live="polite" aria-modal="true" role="dialog" aria-labelledby="modal-title">
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="eyebrow">relay status // verified</div>
|
||||||
|
<h2 id="modal-title">SIGNAL HOLDS</h2>
|
||||||
|
<p id="modal-copy">The next memory chamber is now responsive.</p>
|
||||||
|
<button class="modal-button" id="next-level" type="button">CONTINUE</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<aside class="help-card" id="help-card" hidden><strong>HOW THE RELAY WORKS</strong><br>Only frozen echoes power lower-case signal nodes. Reach a node, anchor a copy of yourself, and you snap back to the entrance. Gates use the matching upper-case letter. The current you never powers a gate.</aside>
|
||||||
|
|
||||||
|
<script type="module" src="./app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
export const LEVELS = [
|
||||||
|
{
|
||||||
|
id: 'cold-boot',
|
||||||
|
code: '01 / COLD BOOT',
|
||||||
|
title: 'Wake the first witness',
|
||||||
|
briefing: 'The exit only remembers a body that is no longer there. Leave one echo on the amber node.',
|
||||||
|
accent: '#3df2ff',
|
||||||
|
map: [
|
||||||
|
'#########',
|
||||||
|
'#@..aA.X#',
|
||||||
|
'#.#######',
|
||||||
|
'#.#######',
|
||||||
|
'#########',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prism-drift',
|
||||||
|
code: '02 / PRISM DRIFT',
|
||||||
|
title: 'Hold two impossible places',
|
||||||
|
briefing: 'One self is a memory. Two selves are an alibi. Sustain both locks at once.',
|
||||||
|
accent: '#ff62d1',
|
||||||
|
map: [
|
||||||
|
'#############',
|
||||||
|
'#@..aA...B.X#',
|
||||||
|
'#.###########',
|
||||||
|
'#.....b######',
|
||||||
|
'#############',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cathedral-null',
|
||||||
|
code: '03 / CATHEDRAL NULL',
|
||||||
|
title: 'Outnumber the silence',
|
||||||
|
briefing: 'The vault was built for one mind. Fill three vacant memories and walk through its contradiction.',
|
||||||
|
accent: '#b48cff',
|
||||||
|
map: [
|
||||||
|
'###############',
|
||||||
|
'#@..aA..B..C.X#',
|
||||||
|
'#.#############',
|
||||||
|
'#.....b########',
|
||||||
|
'#.#############',
|
||||||
|
'#.....c########',
|
||||||
|
'###############',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function key(position) {
|
||||||
|
return `${position.x},${position.y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWalkable(level, position, openGates) {
|
||||||
|
if (position.x < 0 || position.y < 0 || position.x >= level.width || position.y >= level.height) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const tile = level.grid[position.y][position.x];
|
||||||
|
if (tile === '#') return false;
|
||||||
|
return !level.gates.has(tile) || openGates.has(tile);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canReachTarget(level, target, openGates = new Set()) {
|
||||||
|
const queue = [{ ...level.start }];
|
||||||
|
const visited = new Set([key(level.start)]);
|
||||||
|
const vectors = [
|
||||||
|
{ x: 0, y: -1 },
|
||||||
|
{ x: 1, y: 0 },
|
||||||
|
{ x: 0, y: 1 },
|
||||||
|
{ x: -1, y: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift();
|
||||||
|
if (current.x === target.x && current.y === target.y) return true;
|
||||||
|
|
||||||
|
for (const vector of vectors) {
|
||||||
|
const next = { x: current.x + vector.x, y: current.y + vector.y };
|
||||||
|
if (!isWalkable(level, next, openGates) || visited.has(key(next))) continue;
|
||||||
|
visited.add(key(next));
|
||||||
|
queue.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
+1
-1
@@ -5,6 +5,6 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test tests/*.test.mjs",
|
"test": "node --test tests/*.test.mjs",
|
||||||
"check": "node --check game-engine.js"
|
"check": "node --check game-engine.js && node --check levels.js && node --check app.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { LEVELS, canReachTarget } from '../levels.js';
|
||||||
|
import { parseLevel } from '../game-engine.js';
|
||||||
|
|
||||||
|
test('every authored relay has reachable signal pads before any echo is anchored', () => {
|
||||||
|
for (const definition of LEVELS) {
|
||||||
|
const level = parseLevel(definition.map);
|
||||||
|
for (const [padId, position] of level.pads) {
|
||||||
|
assert.equal(
|
||||||
|
canReachTarget(level, position, new Set()),
|
||||||
|
true,
|
||||||
|
`${definition.id}: pad ${padId} should be initially reachable`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each relay keeps extraction sealed until its full gate sequence is sustained', () => {
|
||||||
|
for (const definition of LEVELS) {
|
||||||
|
const level = parseLevel(definition.map);
|
||||||
|
const allGates = new Set(level.gates.keys());
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
canReachTarget(level, level.goal, new Set()),
|
||||||
|
false,
|
||||||
|
`${definition.id}: extraction must not be reachable with all gates closed`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
canReachTarget(level, level.goal, allGates),
|
||||||
|
true,
|
||||||
|
`${definition.id}: extraction should be reachable with all gates active`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-gate relays preserve their intended staged locks', () => {
|
||||||
|
const double = parseLevel(LEVELS.find((level) => level.id === 'prism-drift').map);
|
||||||
|
const triple = parseLevel(LEVELS.find((level) => level.id === 'cathedral-null').map);
|
||||||
|
|
||||||
|
assert.equal(canReachTarget(double, double.goal, new Set(['A'])), false);
|
||||||
|
assert.equal(canReachTarget(double, double.goal, new Set(['A', 'B'])), true);
|
||||||
|
|
||||||
|
assert.equal(canReachTarget(triple, triple.goal, new Set(['A'])), false);
|
||||||
|
assert.equal(canReachTarget(triple, triple.goal, new Set(['A', 'B'])), false);
|
||||||
|
assert.equal(canReachTarget(triple, triple.goal, new Set(['A', 'B', 'C'])), true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const root = new URL('../', import.meta.url);
|
||||||
|
const readProjectFile = (name) => readFileSync(new URL(name, root), 'utf8');
|
||||||
|
|
||||||
|
test('the playable shell uses a local canvas entry point and no external runtime assets', () => {
|
||||||
|
const html = readProjectFile('index.html');
|
||||||
|
|
||||||
|
assert.match(html, /<canvas[^>]+id="relay-canvas"/);
|
||||||
|
assert.match(html, /<script\s+type="module"\s+src="\.\/app\.js"><\/script>/);
|
||||||
|
assert.match(html, /data-action="anchor"/);
|
||||||
|
assert.doesNotMatch(html, /<(?:script|link|img)\b[^>]+(?:src|href)\s*=\s*["']https?:\/\//i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the runtime exposes a narrow smoke-test surface without network calls', () => {
|
||||||
|
const app = readProjectFile('app.js');
|
||||||
|
|
||||||
|
assert.match(app, /window\.__VANTA_TEST__/);
|
||||||
|
assert.doesNotMatch(app, /\bfetch\s*\(|XMLHttpRequest|WebSocket/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user