feat(game): add neon isometric relay interface
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user