Title backdrop generated on MODELBEAST (flux2-klein, 80s airbrush direction): the possessed kit glowing in the studio murk with static gremlins circling. Text carries its own shadow instead of a heavier scrim, so the art stays visible behind it. tools/mbgen.py is the reusable farm batch client — fetches each output by parent_job, never newest-asset (concurrent jobs crossed outputs on 2026-07-16). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
967 lines
33 KiB
JavaScript
967 lines
33 KiB
JavaScript
// PARADRAMORAMA — vertical slice
|
||
import {
|
||
TILE, WORLD_W, WORLD_H, rooms, buildWorld,
|
||
isWall, roomAt, revealAt, isRevealedFloor, roomPoint, GRID_W, GRID_H,
|
||
} from './world.js';
|
||
import * as audio from './audio.js';
|
||
|
||
const cvs = document.getElementById('game');
|
||
const ctx = cvs.getContext('2d');
|
||
const VW = cvs.width, VH = cvs.height;
|
||
|
||
const titleArt = new Image();
|
||
titleArt.src = 'art/title.jpg';
|
||
|
||
// ---- balance ----
|
||
const BAL = {
|
||
bpm: 112,
|
||
signalSpeed: 195, signalR: 6, maxCoherence: 3,
|
||
staticSpeed: 112, staticR: 7, staticChaseDist: 280, staticCap: 10,
|
||
passiveDecay: 0.35, huskDecay: 0.8,
|
||
duelTime: 5.0, duelLockTime: 1.0, duelTol: 0.084, // ~6% in log2
|
||
noiseDecay: 0.12, aggroBase: 240, aggroPerNoise: 700,
|
||
};
|
||
|
||
const TYPES = {
|
||
drum: { hp: 5, speed: 150, r: 12, color: '#ff9d3c', possessable: true },
|
||
synth: { hp: 7, speed: 120, r: 13, color: '#b06cff', possessable: true },
|
||
amp: { hp: 8, speed: 0, r: 14, color: '#ff4560', possessable: false },
|
||
};
|
||
|
||
const SPAWNS = [
|
||
{ room: 'B', type: 'drum' }, { room: 'B', type: 'amp' },
|
||
{ room: 'C', type: 'synth' }, { room: 'C', type: 'static' }, { room: 'C', type: 'static' },
|
||
{ room: 'D', type: 'drum' },
|
||
{ room: 'E', type: 'amp' }, { room: 'E', type: 'drum' }, { room: 'E', type: 'synth' },
|
||
{ room: 'F', type: 'synth' }, { room: 'F', type: 'amp' },
|
||
{ room: 'G', type: 'drum' },
|
||
{ room: 'H', type: 'synth' }, { room: 'H', type: 'drum' }, { room: 'H', type: 'amp' },
|
||
];
|
||
|
||
// ---- state ----
|
||
let state = 'title'; // title | play | clear | gameover
|
||
let paused = false;
|
||
let player, enemies, statics, projectiles, particles, pickups, duel;
|
||
let cam = { x: 0, y: 0 };
|
||
let shake = 0, noise = 0;
|
||
let beatAcc = 0, tick8 = 0, onEighth = false;
|
||
let runTime = 0, bodiesWorn = 0, shotsFired = 0;
|
||
let totalTargets = 0;
|
||
let msg = null, msgT = 0;
|
||
let hints = {};
|
||
let repairT = 0;
|
||
let last = performance.now();
|
||
|
||
function showMsg(t, dur = 4) { msg = t; msgT = dur; }
|
||
function hint(key, text) { if (!hints[key]) { hints[key] = true; showMsg(text, 5); } }
|
||
|
||
function reset() {
|
||
buildWorld();
|
||
const start = rooms.find(r => r.id === 'A');
|
||
player = {
|
||
x: (start.x + start.w / 2) * TILE, y: (start.y + start.h / 2) * TILE,
|
||
r: BAL.signalR, coherence: BAL.maxCoherence, invuln: 0, stun: 0,
|
||
body: null, gain: 0, firing: false, trail: [],
|
||
};
|
||
enemies = []; statics = []; projectiles = []; particles = []; pickups = [];
|
||
duel = null; noise = 0; shake = 0; beatAcc = 0; tick8 = 0;
|
||
runTime = 0; bodiesWorn = 0; shotsFired = 0; repairT = 0;
|
||
hints = {}; msg = null;
|
||
for (const s of SPAWNS) {
|
||
const room = rooms.find(r => r.id === s.room);
|
||
const p = roomPoint(room);
|
||
if (s.type === 'static') statics.push(mkStatic(p.x, p.y));
|
||
else enemies.push(mkEnemy(s.type, p.x, p.y, room));
|
||
}
|
||
totalTargets = enemies.length;
|
||
revealAt(player.x, player.y);
|
||
showMsg('You are the SIGNAL. Hardware cannot see you. Static can.', 6);
|
||
}
|
||
|
||
function mkEnemy(type, x, y, room) {
|
||
const t = TYPES[type];
|
||
return {
|
||
type, x, y, room, r: t.r, hp: t.hp, maxHp: t.hp,
|
||
dead: false, inert: false, possessed: false,
|
||
hostileT: 0, fireCd: 0, duelCd: 0, lastTick: -1,
|
||
wander: null, wanderPause: Math.random() * 2,
|
||
};
|
||
}
|
||
function mkStatic(x, y) {
|
||
return { x, y, r: BAL.staticR, hp: 1, dead: false, vx: 0, vy: 0, seed: Math.random() * 99 };
|
||
}
|
||
|
||
// ---- input ----
|
||
const keys = Object.create(null);
|
||
window.addEventListener('keydown', e => {
|
||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) e.preventDefault();
|
||
if (!e.repeat) press(e.code);
|
||
keys[e.code] = true;
|
||
});
|
||
window.addEventListener('keyup', e => { keys[e.code] = false; release(e.code); });
|
||
cvs.addEventListener('pointerdown', () => { if (state !== 'play') press('Enter'); });
|
||
|
||
function press(code) {
|
||
if (code === 'KeyM') { audio.toggleMute(); return; }
|
||
if (state === 'title' && code === 'Enter') {
|
||
audio.init(); audio.titleGarble();
|
||
reset(); state = 'play';
|
||
return;
|
||
}
|
||
if ((state === 'gameover' || state === 'clear') && code === 'Enter') {
|
||
audio.init(); reset(); state = 'play'; audio.setTension(1);
|
||
return;
|
||
}
|
||
if (state !== 'play') return;
|
||
if (code === 'KeyP') { paused = !paused; return; }
|
||
if (paused) return;
|
||
if (code === 'KeyE' && player.body && !duel) eject(false);
|
||
}
|
||
|
||
function release(code) {
|
||
if (state !== 'play' || paused || duel) return;
|
||
if (code === 'Space' && player.body && player.body.type === 'synth' && player.gain > 0) {
|
||
fireOrb();
|
||
}
|
||
}
|
||
|
||
function axis() {
|
||
const dx = (keys.KeyD || keys.ArrowRight ? 1 : 0) - (keys.KeyA || keys.ArrowLeft ? 1 : 0);
|
||
const dy = (keys.KeyS || keys.ArrowDown ? 1 : 0) - (keys.KeyW || keys.ArrowUp ? 1 : 0);
|
||
const m = Math.hypot(dx, dy) || 1;
|
||
return { dx: dx / m, dy: dy / m };
|
||
}
|
||
|
||
// ---- physics ----
|
||
function moveCircle(o, dx, dy) {
|
||
o.x += dx; resolveAxis(o, dx > 0 ? 1 : -1, true);
|
||
o.y += dy; resolveAxis(o, dy > 0 ? 1 : -1, false);
|
||
o.x = Math.max(o.r, Math.min(WORLD_W - o.r, o.x));
|
||
o.y = Math.max(o.r, Math.min(WORLD_H - o.r, o.y));
|
||
}
|
||
function resolveAxis(o, sign, horiz) {
|
||
const r = o.r;
|
||
const x0 = Math.floor((o.x - r) / TILE), x1 = Math.floor((o.x + r - 0.01) / TILE);
|
||
const y0 = Math.floor((o.y - r) / TILE), y1 = Math.floor((o.y + r - 0.01) / TILE);
|
||
for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) {
|
||
if (!isWall(tx, ty)) continue;
|
||
if (horiz) o.x = sign > 0 ? tx * TILE - r : (tx + 1) * TILE + r;
|
||
else o.y = sign > 0 ? ty * TILE - r : (ty + 1) * TILE + r;
|
||
return;
|
||
}
|
||
}
|
||
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
||
|
||
// ---- combat helpers ----
|
||
function makeNoise(x, y, amount) {
|
||
noise = Math.min(1, noise + amount);
|
||
const radius = BAL.aggroBase + noise * BAL.aggroPerNoise;
|
||
for (const e of enemies) {
|
||
if (e.dead || e.inert || e === player.body || e.type === 'amp') continue;
|
||
if (!e.room.revealed) continue;
|
||
if (Math.hypot(e.x - x, e.y - y) < radius) e.hostileT = 6;
|
||
}
|
||
}
|
||
|
||
function shoot(x, y, tx, ty, opts) {
|
||
const a = Math.atan2(ty - y, tx - x) + (opts.spread || 0) * (Math.random() - 0.5);
|
||
projectiles.push({
|
||
x, y, vx: Math.cos(a) * opts.speed, vy: Math.sin(a) * opts.speed,
|
||
r: opts.r, dmg: opts.dmg, from: opts.from, color: opts.color, life: 3,
|
||
});
|
||
}
|
||
|
||
function hurtPlayer(n, srcX, srcY) {
|
||
if (player.invuln > 0) return;
|
||
player.coherence -= n;
|
||
player.invuln = 1.5;
|
||
audio.hurt(); shake = Math.max(shake, 7);
|
||
if (srcX !== undefined) {
|
||
const a = Math.atan2(player.y - srcY, player.x - srcX);
|
||
moveCircle(player, Math.cos(a) * 26, Math.sin(a) * 26);
|
||
}
|
||
if (duel) failDuel();
|
||
if (player.coherence <= 0) {
|
||
boom(player.x, player.y, '#3cf2ff', 26);
|
||
state = 'gameover';
|
||
}
|
||
}
|
||
|
||
function boom(x, y, color, n = 18) {
|
||
for (let i = 0; i < n; i++) {
|
||
const a = Math.random() * Math.PI * 2, s = 40 + Math.random() * 160;
|
||
particles.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, life: 0.4 + Math.random() * 0.5, color, size: 2 + Math.random() * 3 });
|
||
}
|
||
audio.noiseBurst(0.35, 0.16, 700);
|
||
shake = Math.max(shake, 9);
|
||
}
|
||
|
||
function damageEnemy(e, dmg) {
|
||
e.hp -= dmg;
|
||
e.hostileT = 6;
|
||
audio.blip(e.type === 'amp' ? 140 : 330, 0.05, 'square', 0.05);
|
||
if (e.hp <= 0) killEnemy(e);
|
||
}
|
||
|
||
function killEnemy(e) {
|
||
e.dead = true;
|
||
boom(e.x, e.y, TYPES[e.type].color);
|
||
if (e.type === 'amp' || Math.random() < 0.3)
|
||
pickups.push({ x: e.x, y: e.y, t: 0 });
|
||
const alive = enemies.filter(x => !x.dead && x !== player.body).length;
|
||
audio.setTension(alive / totalTargets);
|
||
if (alive === 0) win();
|
||
}
|
||
|
||
function win() {
|
||
if (state !== 'play') return;
|
||
state = 'clear';
|
||
for (const s of statics) if (!s.dead) { s.dead = true; boom(s.x, s.y, '#aab', 6); }
|
||
audio.resolve();
|
||
}
|
||
|
||
// ---- possession ----
|
||
function startDuel(target) {
|
||
const tf = 200 * Math.pow(2, Math.random() * 2); // 200..800
|
||
let off = (0.35 + Math.random() * 0.35) * (Math.random() < 0.5 ? -1 : 1);
|
||
const pf = tf * Math.pow(2, off);
|
||
duel = {
|
||
target, targetF: tf, playerF: Math.max(150, Math.min(1000, pf)),
|
||
lock: 0, t: BAL.duelTime,
|
||
sound: audio.duelStart(tf, pf),
|
||
};
|
||
hint('duel', 'TUNE with W/S — hold the lock when the tones merge.');
|
||
}
|
||
|
||
function failDuel() {
|
||
if (!duel) return;
|
||
duel.sound && duel.sound.stop();
|
||
duel.target.duelCd = 2;
|
||
audio.duelFailBuzz();
|
||
const t = duel.target;
|
||
const a = Math.atan2(player.y - t.y, player.x - t.x);
|
||
moveCircle(player, Math.cos(a) * 30, Math.sin(a) * 30);
|
||
duel = null;
|
||
}
|
||
|
||
function winDuel() {
|
||
const t = duel.target;
|
||
duel.sound && duel.sound.stop();
|
||
duel = null;
|
||
possess(t);
|
||
audio.possessArp();
|
||
bodiesWorn++;
|
||
hint('possessed', 'SPACE: fire (hold = gain). E: eject. The body is always dying.');
|
||
}
|
||
|
||
function possess(e) {
|
||
e.possessed = true; e.inert = false; e.hostileT = 0;
|
||
player.body = e;
|
||
player.x = e.x; player.y = e.y;
|
||
player.gain = 0;
|
||
}
|
||
|
||
function eject(destroyed) {
|
||
const b = player.body;
|
||
b.possessed = false;
|
||
player.body = null;
|
||
player.gain = 0;
|
||
player.x = b.x; player.y = b.y;
|
||
player.invuln = Math.max(player.invuln, destroyed ? 2 : 0.8);
|
||
if (destroyed) {
|
||
b.dead = true;
|
||
player.coherence -= 1;
|
||
audio.hurt(); shake = Math.max(shake, 8);
|
||
if (player.coherence <= 0) { boom(player.x, player.y, '#3cf2ff', 26); state = 'gameover'; }
|
||
} else {
|
||
b.inert = true;
|
||
b.duelCd = 0.6; // grace so eject doesn't instantly re-possess
|
||
audio.ejectSfx();
|
||
}
|
||
}
|
||
|
||
function fireOrb() {
|
||
const b = player.body;
|
||
const g = player.gain;
|
||
const aim = aimDir(b);
|
||
shoot(b.x, b.y, b.x + aim.dx * 10, b.y + aim.dy * 10, {
|
||
speed: 200, r: 6 + g * 5, dmg: 2 + 3 * g, from: 'player', color: '#d9a6ff',
|
||
});
|
||
b.hp -= 0.4 + 0.8 * g;
|
||
makeNoise(b.x, b.y, 0.15 + 0.3 * g);
|
||
audio.blip(180 + g * 160, 0.25, 'sawtooth', 0.09, 60);
|
||
shotsFired++;
|
||
player.gain = 0;
|
||
if (b.hp <= 0) { boom(b.x, b.y, TYPES[b.type].color); killBody(); }
|
||
}
|
||
|
||
function killBody() {
|
||
const b = player.body;
|
||
const alive = enemies.filter(x => !x.dead && x !== b).length;
|
||
eject(true);
|
||
audio.setTension(alive / totalTargets);
|
||
if (alive === 0 && state === 'play') win();
|
||
}
|
||
|
||
// facing: last nonzero movement dir, default right
|
||
let face = { dx: 1, dy: 0 };
|
||
function aimDir(b) {
|
||
// aim at nearest hostile enemy if any, else facing
|
||
let best = null, bd = 1e9;
|
||
for (const e of enemies) {
|
||
if (e.dead || e === b || e.inert) continue;
|
||
if (!e.room.revealed) continue;
|
||
const d = Math.hypot(e.x - b.x, e.y - b.y);
|
||
if (d < bd) { bd = d; best = e; }
|
||
}
|
||
if (best && bd < 420) {
|
||
const m = bd || 1;
|
||
return { dx: (best.x - b.x) / m, dy: (best.y - b.y) / m };
|
||
}
|
||
return face;
|
||
}
|
||
|
||
// ---- update ----
|
||
function update(dt) {
|
||
runTime += dt;
|
||
if (msgT > 0) { msgT -= dt; if (msgT <= 0) msg = null; }
|
||
player.invuln = Math.max(0, player.invuln - dt);
|
||
noise = Math.max(0, noise - BAL.noiseDecay * dt);
|
||
shake = Math.max(0, shake - dt * 30);
|
||
|
||
// beat clock
|
||
onEighth = false;
|
||
const spe = 60 / BAL.bpm / 2;
|
||
beatAcc += dt;
|
||
while (beatAcc >= spe) { beatAcc -= spe; tick8++; onEighth = true; }
|
||
|
||
// movement
|
||
const a = axis();
|
||
if (a.dx || a.dy) face = { dx: a.dx, dy: a.dy };
|
||
if (!duel) {
|
||
if (player.body) {
|
||
const b = player.body;
|
||
moveCircle(b, a.dx * TYPES[b.type].speed * dt, a.dy * TYPES[b.type].speed * dt);
|
||
player.x = b.x; player.y = b.y;
|
||
} else {
|
||
moveCircle(player, a.dx * BAL.signalSpeed * dt, a.dy * BAL.signalSpeed * dt);
|
||
player.trail.push({ x: player.x, y: player.y });
|
||
if (player.trail.length > 14) player.trail.shift();
|
||
}
|
||
}
|
||
|
||
const newRoom = revealAt(player.x, player.y);
|
||
if (newRoom) {
|
||
audio.revealSfx();
|
||
if (newRoom.id === 'B') hint('firstHW', 'TOUCH a machine to invade it.');
|
||
}
|
||
|
||
// possessed body upkeep
|
||
if (player.body) {
|
||
const b = player.body;
|
||
b.hp -= BAL.passiveDecay * dt;
|
||
if (keys.Space) {
|
||
const ramp = b.type === 'drum' ? 2 : 1.2;
|
||
player.gain = Math.min(1, player.gain + dt / ramp);
|
||
if (b.type === 'drum' && onEighth) {
|
||
const aim = aimDir(b);
|
||
shoot(b.x, b.y, b.x + aim.dx * 10, b.y + aim.dy * 10, {
|
||
speed: 280, r: 3.5, dmg: 1 + player.gain, from: 'player',
|
||
color: '#ffd27a', spread: 0.16,
|
||
});
|
||
b.hp -= 0.18;
|
||
makeNoise(b.x, b.y, 0.08);
|
||
audio.blip(500 + player.gain * 300, 0.05, 'square', 0.06);
|
||
shotsFired++;
|
||
}
|
||
} else if (b.type === 'drum') player.gain = Math.max(0, player.gain - dt * 2);
|
||
if (b.hp <= 0) { boom(b.x, b.y, TYPES[b.type].color); killBody(); if (state !== 'play') return; }
|
||
}
|
||
|
||
// duel
|
||
if (duel) {
|
||
const d = duel;
|
||
const rate = Math.pow(2, 0.9 * dt);
|
||
if (keys.KeyW || keys.ArrowUp) d.playerF = Math.min(1000, d.playerF * rate);
|
||
if (keys.KeyS || keys.ArrowDown) d.playerF = Math.max(150, d.playerF / rate);
|
||
d.sound && d.sound.setFreq(d.playerF);
|
||
const err = Math.abs(Math.log2(d.playerF / d.targetF));
|
||
if (err < BAL.duelTol) d.lock = Math.min(1, d.lock + dt / BAL.duelLockTime);
|
||
else d.lock = Math.max(0, d.lock - dt * 1.5);
|
||
d.t -= dt;
|
||
if (d.lock >= 1) { winDuel(); }
|
||
else if (d.t <= 0) { failDuel(); hurtPlayer(1, duel ? duel.target.x : undefined, duel ? duel.target.y : undefined); }
|
||
}
|
||
|
||
// touch hardware -> duel / repossess
|
||
if (!player.body && !duel && player.stun <= 0) {
|
||
for (const e of enemies) {
|
||
if (e.dead || e.type === 'amp') continue;
|
||
if (!e.room.revealed) continue;
|
||
e.duelCd = Math.max(0, e.duelCd - dt);
|
||
if (dist(player, e) < player.r + e.r + 2) {
|
||
if (e.inert) { if (e.duelCd <= 0) { possess(e); audio.possessArp(); } }
|
||
else if (e.duelCd <= 0) startDuel(e);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// enemies
|
||
for (const e of enemies) {
|
||
if (e.dead || e === player.body) continue;
|
||
if (!e.room.revealed) continue;
|
||
if (e.inert) {
|
||
e.hp -= BAL.huskDecay * dt;
|
||
if (e.hp <= 0) { e.dead = true; boom(e.x, e.y, '#667', 8); killCheck(); }
|
||
continue;
|
||
}
|
||
e.hostileT = Math.max(0, e.hostileT - dt);
|
||
e.fireCd = Math.max(0, e.fireCd - dt);
|
||
|
||
if (e.type === 'amp') {
|
||
if (onEighth && tick8 % 6 === 0 && statics.filter(s => !s.dead).length < BAL.staticCap) {
|
||
statics.push(mkStatic(e.x + (Math.random() - 0.5) * 30, e.y + (Math.random() - 0.5) * 30));
|
||
audio.blip(90, 0.1, 'sawtooth', 0.03);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// wander
|
||
if (!e.wander || dist(e, e.wander) < 8) {
|
||
e.wanderPause -= dt;
|
||
if (e.wanderPause <= 0) { e.wander = roomPoint(e.room); e.wanderPause = 1 + Math.random() * 2; }
|
||
} else {
|
||
const m = dist(e, e.wander) || 1;
|
||
const sp = TYPES[e.type].speed * (e.hostileT > 0 ? 0.9 : 0.4);
|
||
moveCircle(e, (e.wander.x - e.x) / m * sp * dt, (e.wander.y - e.y) / m * sp * dt);
|
||
}
|
||
|
||
// hostile fire at possessed body
|
||
if (e.hostileT > 0 && player.body && !player.body.dead) {
|
||
const b = player.body;
|
||
if (e.type === 'drum') {
|
||
if (onEighth && tick8 % 8 < 4 && tick8 !== e.lastTick && dist(e, b) < 480) {
|
||
e.lastTick = tick8;
|
||
shoot(e.x, e.y, b.x, b.y, { speed: 260, r: 3.5, dmg: 1, from: 'enemy', color: '#ffb08a', spread: 0.12 });
|
||
audio.blip(420, 0.04, 'square', 0.04);
|
||
}
|
||
} else if (e.type === 'synth' && e.fireCd <= 0 && dist(e, b) < 480) {
|
||
e.fireCd = 2.3;
|
||
shoot(e.x, e.y, b.x, b.y, { speed: 145, r: 7, dmg: 2, from: 'enemy', color: '#c896ff' });
|
||
audio.blip(160, 0.3, 'sawtooth', 0.06, 420);
|
||
}
|
||
}
|
||
}
|
||
|
||
// anti-softlock: no possessable body left but amps alive -> dispatch repair drum
|
||
const ampsAlive = enemies.some(e => !e.dead && e.type === 'amp');
|
||
const bodyAvailable = player.body ||
|
||
enemies.some(e => !e.dead && TYPES[e.type].possessable && (!e.inert || e.hp > 0));
|
||
if (ampsAlive && !bodyAvailable) {
|
||
repairT += dt;
|
||
if (repairT > 3) {
|
||
repairT = 0;
|
||
const amp = enemies.find(e => !e.dead && e.type === 'amp');
|
||
const d = mkEnemy('drum', amp.x + 30, amp.y, amp.room);
|
||
enemies.push(d); totalTargets++;
|
||
boom(amp.x, amp.y, '#ff9d3c', 8);
|
||
showMsg('The system dispatched a repair unit.', 3);
|
||
}
|
||
} else repairT = 0;
|
||
|
||
// statics
|
||
for (const s of statics) {
|
||
if (s.dead) continue;
|
||
const room = roomAt(s.x, s.y);
|
||
const canSee = !player.body && (!room || room.revealed) && dist(s, player) < BAL.staticChaseDist;
|
||
if (canSee) {
|
||
const m = dist(s, player) || 1;
|
||
s.vx = (player.x - s.x) / m * BAL.staticSpeed;
|
||
s.vy = (player.y - s.y) / m * BAL.staticSpeed;
|
||
} else {
|
||
s.seed += dt;
|
||
s.vx += (Math.sin(s.seed * 3.1) * 60 - s.vx) * dt;
|
||
s.vy += (Math.cos(s.seed * 2.3) * 60 - s.vy) * dt;
|
||
}
|
||
moveCircle(s, s.vx * dt, s.vy * dt);
|
||
if (!player.body && dist(s, player) < s.r + player.r) hurtPlayer(1, s.x, s.y);
|
||
}
|
||
|
||
// projectiles
|
||
for (const p of projectiles) {
|
||
p.life -= dt;
|
||
p.x += p.vx * dt; p.y += p.vy * dt;
|
||
if (p.life <= 0 || isWall(Math.floor(p.x / TILE), Math.floor(p.y / TILE))) { p.dead = true; continue; }
|
||
if (p.from === 'player') {
|
||
for (const e of enemies) {
|
||
if (e.dead || e === player.body || e.inert) continue;
|
||
if (!e.room.revealed) continue;
|
||
if (Math.hypot(p.x - e.x, p.y - e.y) < p.r + e.r) { damageEnemy(e, p.dmg); p.dead = true; break; }
|
||
}
|
||
if (!p.dead) for (const s of statics) {
|
||
if (s.dead) continue;
|
||
if (Math.hypot(p.x - s.x, p.y - s.y) < p.r + s.r) { s.dead = true; boom(s.x, s.y, '#aab', 5); p.dead = true; break; }
|
||
}
|
||
} else if (player.body && !player.body.dead) {
|
||
const b = player.body;
|
||
if (Math.hypot(p.x - b.x, p.y - b.y) < p.r + b.r) {
|
||
p.dead = true;
|
||
b.hp -= p.dmg;
|
||
audio.blip(200, 0.08, 'square', 0.06);
|
||
shake = Math.max(shake, 4);
|
||
if (b.hp <= 0) { boom(b.x, b.y, TYPES[b.type].color); killBody(); }
|
||
}
|
||
}
|
||
}
|
||
projectiles = projectiles.filter(p => !p.dead);
|
||
|
||
// pickups
|
||
for (const pk of pickups) {
|
||
pk.t += dt;
|
||
const tgt = player.body || player;
|
||
if (Math.hypot(pk.x - tgt.x, pk.y - tgt.y) < 16 + tgt.r) {
|
||
pk.dead = true;
|
||
audio.pickupSfx();
|
||
if (player.body) player.body.hp = Math.min(player.body.maxHp, player.body.hp + 2.5);
|
||
else player.coherence = Math.min(BAL.maxCoherence, player.coherence + 1);
|
||
}
|
||
}
|
||
pickups = pickups.filter(p => !p.dead);
|
||
|
||
// particles
|
||
for (const pa of particles) {
|
||
pa.life -= dt; pa.x += pa.vx * dt; pa.y += pa.vy * dt;
|
||
pa.vx *= 0.94; pa.vy *= 0.94;
|
||
}
|
||
particles = particles.filter(p => p.life > 0);
|
||
|
||
// win check every frame — possession can zero the target count without a kill event
|
||
if (!enemies.some(e => !e.dead && e !== player.body)) { win(); return; }
|
||
|
||
// camera
|
||
const targX = Math.max(0, Math.min(WORLD_W - VW, player.x - VW / 2));
|
||
const targY = Math.max(0, Math.min(WORLD_H - VH, player.y - VH / 2));
|
||
cam.x += (targX - cam.x) * Math.min(1, dt * 6);
|
||
cam.y += (targY - cam.y) * Math.min(1, dt * 6);
|
||
}
|
||
|
||
function killCheck() {
|
||
const alive = enemies.filter(x => !x.dead && x !== player.body).length;
|
||
audio.setTension(alive / totalTargets);
|
||
if (alive === 0 && state === 'play') win();
|
||
}
|
||
|
||
// ---- render ----
|
||
function render() {
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
ctx.fillStyle = '#07080f';
|
||
ctx.fillRect(0, 0, VW, VH);
|
||
|
||
if (state === 'title') { drawTitle(); return; }
|
||
|
||
const sx = (Math.random() - 0.5) * shake, sy = (Math.random() - 0.5) * shake;
|
||
ctx.save();
|
||
ctx.translate(-Math.round(cam.x + sx), -Math.round(cam.y + sy));
|
||
|
||
drawTiles();
|
||
drawPickups();
|
||
drawEnemies();
|
||
drawStatics();
|
||
drawProjectiles();
|
||
drawPlayer();
|
||
drawParticles();
|
||
ctx.restore();
|
||
|
||
drawHUD();
|
||
if (duel) drawDuel();
|
||
drawMinimap();
|
||
drawScanlines();
|
||
|
||
if (state === 'clear') overlay('#9ff7ff', 'SECTOR CLEANSED',
|
||
`time ${runTime.toFixed(1)}s · bodies worn ${bodiesWorn} · shots ${shotsFired}`, 'ENTER — again');
|
||
if (state === 'gameover') overlay('#ff6a7a', 'SIGNAL LOST',
|
||
`survived ${runTime.toFixed(1)}s · bodies worn ${bodiesWorn}`, 'ENTER — retry');
|
||
if (paused && state === 'play') overlay('#ccd', 'PAUSED', '', 'P — resume');
|
||
}
|
||
|
||
function drawTiles() {
|
||
const cleared = state === 'clear';
|
||
const x0 = Math.floor(cam.x / TILE), x1 = Math.ceil((cam.x + VW) / TILE);
|
||
const y0 = Math.floor(cam.y / TILE), y1 = Math.ceil((cam.y + VH) / TILE);
|
||
for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) {
|
||
if (tx < 0 || ty < 0 || tx >= GRID_W || ty >= GRID_H) continue;
|
||
if (isWall(tx, ty)) {
|
||
// draw walls only when touching revealed floor
|
||
let vis = false;
|
||
for (let dy = -1; dy <= 1 && !vis; dy++) for (let dx = -1; dx <= 1; dx++)
|
||
if (isRevealedFloor(tx + dx, ty + dy)) { vis = true; break; }
|
||
if (vis) {
|
||
ctx.fillStyle = cleared ? '#14284a' : '#1d2438';
|
||
ctx.fillRect(tx * TILE, ty * TILE, TILE, TILE);
|
||
ctx.fillStyle = cleared ? '#1d3a66' : '#2a3450';
|
||
ctx.fillRect(tx * TILE, ty * TILE, TILE, 3);
|
||
}
|
||
} else if (isRevealedFloor(tx, ty)) {
|
||
ctx.fillStyle = cleared ? '#0a1430' : '#0e1120';
|
||
ctx.fillRect(tx * TILE, ty * TILE, TILE, TILE);
|
||
ctx.fillStyle = cleared ? '#0d1a3c' : '#12162a';
|
||
ctx.fillRect(tx * TILE, ty * TILE, 1, TILE);
|
||
ctx.fillRect(tx * TILE, ty * TILE, TILE, 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
function glowCircle(x, y, r, color, blur = 14) {
|
||
ctx.save();
|
||
ctx.shadowColor = color; ctx.shadowBlur = blur;
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawPlayer() {
|
||
if (player.body) return; // body drawn as enemy w/ highlight
|
||
if (player.invuln > 0 && Math.floor(player.invuln * 12) % 2) return;
|
||
ctx.strokeStyle = '#3cf2ff44';
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
player.trail.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
|
||
ctx.stroke();
|
||
glowCircle(player.x, player.y, player.r, '#3cf2ff', 18);
|
||
glowCircle(player.x, player.y, 2.5, '#ffffff', 6);
|
||
}
|
||
|
||
function drawEnemies() {
|
||
for (const e of enemies) {
|
||
if (e.dead || !e.room.revealed) continue;
|
||
const t = TYPES[e.type];
|
||
const mine = e === player.body;
|
||
const col = e.inert ? '#565d70' : t.color;
|
||
ctx.save();
|
||
ctx.shadowColor = col; ctx.shadowBlur = mine ? 22 : 10;
|
||
if (e.type === 'amp') {
|
||
const pulse = 1 + Math.sin(performance.now() / 300) * 0.12;
|
||
ctx.strokeStyle = col; ctx.lineWidth = 3;
|
||
ctx.beginPath(); ctx.arc(e.x, e.y, e.r * pulse, 0, Math.PI * 2); ctx.stroke();
|
||
ctx.fillStyle = col;
|
||
ctx.beginPath(); ctx.arc(e.x, e.y, 5, 0, Math.PI * 2); ctx.fill();
|
||
} else if (e.type === 'drum') {
|
||
ctx.fillStyle = col;
|
||
ctx.fillRect(e.x - e.r, e.y - e.r, e.r * 2, e.r * 2);
|
||
ctx.fillStyle = '#0009';
|
||
ctx.beginPath(); ctx.arc(e.x, e.y, e.r * 0.55, 0, Math.PI * 2); ctx.fill();
|
||
ctx.strokeStyle = '#fff5'; ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.arc(e.x, e.y, e.r * 0.55, 0, Math.PI * 2); ctx.stroke();
|
||
} else { // synth
|
||
ctx.fillStyle = col;
|
||
ctx.fillRect(e.x - e.r, e.y - e.r * 0.7, e.r * 2, e.r * 1.4);
|
||
ctx.fillStyle = '#fff';
|
||
for (let k = 0; k < 5; k++)
|
||
ctx.fillRect(e.x - e.r + 2 + k * (e.r * 2 - 4) / 5, e.y, (e.r * 2 - 4) / 5 - 1, e.r * 0.6);
|
||
}
|
||
ctx.restore();
|
||
// hp bar
|
||
if (!e.inert && (mine || e.hp < e.maxHp)) {
|
||
const w = e.r * 2;
|
||
ctx.fillStyle = '#000a';
|
||
ctx.fillRect(e.x - e.r, e.y - e.r - 8, w, 4);
|
||
ctx.fillStyle = mine ? '#3cf2ff' : (e.hostileT > 0 ? '#ff5a5a' : '#7a8');
|
||
ctx.fillRect(e.x - e.r, e.y - e.r - 8, w * Math.max(0, e.hp / e.maxHp), 4);
|
||
}
|
||
if (mine) {
|
||
ctx.strokeStyle = '#3cf2ff';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.setLineDash([4, 4]);
|
||
ctx.beginPath(); ctx.arc(e.x, e.y, e.r + 6, performance.now() / 400, performance.now() / 400 + Math.PI * 2); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
if (e.hostileT > 0 && !mine) {
|
||
ctx.fillStyle = '#ff5a5a';
|
||
ctx.font = 'bold 11px monospace';
|
||
ctx.fillText('!', e.x - 2, e.y - e.r - 12);
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawStatics() {
|
||
for (const s of statics) {
|
||
if (s.dead) continue;
|
||
const room = roomAt(s.x, s.y);
|
||
if (room && !room.revealed) continue;
|
||
for (let i = 0; i < 6; i++) {
|
||
const a = Math.random() * Math.PI * 2, d = Math.random() * s.r;
|
||
ctx.fillStyle = Math.random() < 0.5 ? '#c8cede' : '#7d8494';
|
||
ctx.fillRect(s.x + Math.cos(a) * d - 1.5, s.y + Math.sin(a) * d - 1.5, 3, 3);
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawProjectiles() {
|
||
for (const p of projectiles) glowCircle(p.x, p.y, p.r, p.color, 10);
|
||
}
|
||
|
||
function drawPickups() {
|
||
for (const pk of pickups) {
|
||
const bob = Math.sin(pk.t * 4) * 3;
|
||
ctx.save();
|
||
ctx.shadowColor = '#6fff9e'; ctx.shadowBlur = 12;
|
||
ctx.fillStyle = '#6fff9e';
|
||
ctx.translate(pk.x, pk.y + bob);
|
||
ctx.rotate(Math.PI / 4);
|
||
ctx.fillRect(-5, -5, 10, 10);
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
function drawParticles() {
|
||
for (const pa of particles) {
|
||
ctx.globalAlpha = Math.max(0, pa.life * 2);
|
||
ctx.fillStyle = pa.color;
|
||
ctx.fillRect(pa.x - pa.size / 2, pa.y - pa.size / 2, pa.size, pa.size);
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
function drawHUD() {
|
||
ctx.font = '12px ui-monospace, Menlo, monospace';
|
||
// coherence
|
||
for (let i = 0; i < BAL.maxCoherence; i++) {
|
||
ctx.save();
|
||
ctx.translate(24 + i * 22, 24);
|
||
ctx.rotate(Math.PI / 4);
|
||
ctx.fillStyle = i < player.coherence ? '#3cf2ff' : '#22303d';
|
||
ctx.shadowColor = '#3cf2ff'; ctx.shadowBlur = i < player.coherence ? 10 : 0;
|
||
ctx.fillRect(-6, -6, 12, 12);
|
||
ctx.restore();
|
||
}
|
||
ctx.fillStyle = '#5a6a80';
|
||
ctx.fillText('COHERENCE', 14, 48);
|
||
|
||
if (player.body) {
|
||
const b = player.body;
|
||
// integrity
|
||
ctx.fillStyle = '#5a6a80'; ctx.fillText(`BODY: ${b.type.toUpperCase()}`, 14, 70);
|
||
ctx.fillStyle = '#000a'; ctx.fillRect(14, 76, 140, 8);
|
||
const frac = Math.max(0, b.hp / b.maxHp);
|
||
ctx.fillStyle = frac < 0.25 ? '#ff5a5a' : '#ffd27a';
|
||
ctx.fillRect(14, 76, 140 * frac, 8);
|
||
ctx.fillStyle = '#5a6a80'; ctx.fillText('INTEGRITY', 160, 84);
|
||
// gain
|
||
ctx.fillStyle = '#000a'; ctx.fillRect(14, 90, 140, 6);
|
||
ctx.fillStyle = '#d9a6ff'; ctx.fillRect(14, 90, 140 * player.gain, 6);
|
||
ctx.fillStyle = '#5a6a80'; ctx.fillText('GAIN', 160, 97);
|
||
// noise
|
||
ctx.fillStyle = '#000a'; ctx.fillRect(14, 102, 140, 6);
|
||
ctx.fillStyle = noise > 0.7 ? '#ff5a5a' : '#8fa3c8';
|
||
ctx.fillRect(14, 102, 140 * noise, 6);
|
||
ctx.fillStyle = '#5a6a80'; ctx.fillText('NOISE', 160, 109);
|
||
} else {
|
||
ctx.fillStyle = '#3cf2ff';
|
||
ctx.fillText('BARE SIGNAL — hardware cannot see you', 14, 70);
|
||
}
|
||
|
||
const alive = enemies.filter(e => !e.dead && e !== player.body).length;
|
||
ctx.fillStyle = '#5a6a80';
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(`TARGETS ${alive}`, VW - 152, 24);
|
||
ctx.textAlign = 'left';
|
||
|
||
// beat pulse
|
||
const ph = beatAcc / (60 / BAL.bpm / 2);
|
||
ctx.fillStyle = tick8 % 2 === 0 ? '#3cf2ff' : '#265060';
|
||
ctx.globalAlpha = 1 - ph * 0.7;
|
||
ctx.fillRect(VW - 140, 14, 8, 8);
|
||
ctx.globalAlpha = 1;
|
||
|
||
if (msg) {
|
||
ctx.font = '14px ui-monospace, Menlo, monospace';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillStyle = '#0009';
|
||
const w = ctx.measureText(msg).width + 24;
|
||
ctx.fillRect(VW / 2 - w / 2, VH - 54, w, 26);
|
||
ctx.fillStyle = '#cfe3ff';
|
||
ctx.fillText(msg, VW / 2, VH - 36);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
}
|
||
|
||
function drawDuel() {
|
||
const W = 300, H = 170, X = VW - W - 16, Y = VH - H - 16;
|
||
ctx.fillStyle = '#0b0e1acc';
|
||
ctx.strokeStyle = '#3cf2ff';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.fillRect(X, Y, W, H);
|
||
ctx.strokeRect(X, Y, W, H);
|
||
ctx.font = '12px ui-monospace, Menlo, monospace';
|
||
ctx.fillStyle = '#9fd8ff';
|
||
ctx.fillText(`INVADING ${duel.target.type.toUpperCase()} — TUNE W/S`, X + 12, Y + 20);
|
||
|
||
// frequency lane (log scale, 150..1000)
|
||
const laneX = X + 40, laneW = W - 120, laneY = Y + 40, laneH = 80;
|
||
ctx.fillStyle = '#121828';
|
||
ctx.fillRect(laneX, laneY, laneW, laneH);
|
||
const fy = f => laneY + laneH - (Math.log2(f / 150) / Math.log2(1000 / 150)) * laneH;
|
||
// tolerance band
|
||
const tTop = fy(duel.targetF * Math.pow(2, BAL.duelTol));
|
||
const tBot = fy(duel.targetF / Math.pow(2, BAL.duelTol));
|
||
ctx.fillStyle = '#3cf2ff22';
|
||
ctx.fillRect(laneX, tTop, laneW, tBot - tTop);
|
||
// target line
|
||
ctx.strokeStyle = '#3cf2ff';
|
||
ctx.beginPath(); ctx.moveTo(laneX, fy(duel.targetF)); ctx.lineTo(laneX + laneW, fy(duel.targetF)); ctx.stroke();
|
||
// player line
|
||
ctx.strokeStyle = '#ff6ad5';
|
||
ctx.lineWidth = 2.5;
|
||
const py = fy(duel.playerF);
|
||
ctx.beginPath(); ctx.moveTo(laneX, py); ctx.lineTo(laneX + laneW, py); ctx.stroke();
|
||
ctx.lineWidth = 1.5;
|
||
|
||
// lock meter (right)
|
||
ctx.fillStyle = '#121828';
|
||
ctx.fillRect(X + W - 60, laneY, 20, laneH);
|
||
ctx.fillStyle = duel.lock > 0.6 ? '#6fff9e' : '#ffd27a';
|
||
const lh = laneH * duel.lock;
|
||
ctx.fillRect(X + W - 60, laneY + laneH - lh, 20, lh);
|
||
ctx.fillStyle = '#5a6a80';
|
||
ctx.fillText('LOCK', X + W - 64, laneY + laneH + 16);
|
||
|
||
// timer
|
||
ctx.fillStyle = '#000a';
|
||
ctx.fillRect(X + 12, Y + H - 24, W - 24, 8);
|
||
ctx.fillStyle = duel.t < 1.5 ? '#ff5a5a' : '#9fd8ff';
|
||
ctx.fillRect(X + 12, Y + H - 24, (W - 24) * (duel.t / BAL.duelTime), 8);
|
||
}
|
||
|
||
function drawMinimap() {
|
||
const S = 2.4, X = VW - GRID_W * S - 16, Y = 36;
|
||
ctx.fillStyle = '#0b0e1aaa';
|
||
ctx.fillRect(X - 4, Y - 4, GRID_W * S + 8, GRID_H * S + 8);
|
||
for (const r of rooms) {
|
||
if (!r.revealed) continue;
|
||
ctx.fillStyle = state === 'clear' ? '#1d3a66' : '#243050';
|
||
ctx.fillRect(X + r.x * S, Y + r.y * S, r.w * S, r.h * S);
|
||
}
|
||
for (const e of enemies) {
|
||
if (e.dead || !e.room.revealed || e === player.body || e.inert) continue;
|
||
ctx.fillStyle = e.type === 'amp' ? '#ff4560' : '#ffb08a';
|
||
ctx.fillRect(X + e.x / TILE * S - 1.5, Y + e.y / TILE * S - 1.5, 3, 3);
|
||
}
|
||
ctx.fillStyle = '#3cf2ff';
|
||
ctx.fillRect(X + player.x / TILE * S - 2, Y + player.y / TILE * S - 2, 4, 4);
|
||
}
|
||
|
||
function drawScanlines() {
|
||
ctx.fillStyle = '#00000022';
|
||
for (let y = 0; y < VH; y += 3) ctx.fillRect(0, y, VW, 1);
|
||
const g = ctx.createRadialGradient(VW / 2, VH / 2, VH / 3, VW / 2, VH / 2, VH);
|
||
g.addColorStop(0, '#0000');
|
||
g.addColorStop(1, '#000a');
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(0, 0, VW, VH);
|
||
}
|
||
|
||
function overlay(color, big, small, prompt) {
|
||
ctx.fillStyle = '#05060acc';
|
||
ctx.fillRect(0, 0, VW, VH);
|
||
ctx.textAlign = 'center';
|
||
ctx.font = 'bold 42px ui-monospace, Menlo, monospace';
|
||
ctx.save();
|
||
ctx.shadowColor = color; ctx.shadowBlur = 24;
|
||
ctx.fillStyle = color;
|
||
ctx.fillText(big, VW / 2, VH / 2 - 20);
|
||
ctx.restore();
|
||
ctx.font = '15px ui-monospace, Menlo, monospace';
|
||
ctx.fillStyle = '#8fa3c8';
|
||
if (small) ctx.fillText(small, VW / 2, VH / 2 + 18);
|
||
ctx.fillStyle = '#cfe3ff';
|
||
ctx.fillText(prompt, VW / 2, VH / 2 + 56);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
|
||
function drawTitle() {
|
||
const t = performance.now() / 1000;
|
||
if (titleArt.complete && titleArt.naturalWidth) {
|
||
// cover-fit the painted backdrop, then knock it back so the logo reads
|
||
const s = Math.max(VW / titleArt.naturalWidth, VH / titleArt.naturalHeight);
|
||
const w = titleArt.naturalWidth * s, h = titleArt.naturalHeight * s;
|
||
ctx.drawImage(titleArt, (VW - w) / 2, (VH - h) / 2, w, h);
|
||
const g = ctx.createLinearGradient(0, 0, 0, VH);
|
||
g.addColorStop(0, 'rgba(5,6,10,.86)');
|
||
g.addColorStop(0.45, 'rgba(5,6,10,.52)');
|
||
g.addColorStop(1, 'rgba(5,6,10,.92)');
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(0, 0, VW, VH);
|
||
}
|
||
// drifting waveform
|
||
ctx.strokeStyle = '#1c2a4499';
|
||
ctx.lineWidth = 2;
|
||
for (let k = 0; k < 3; k++) {
|
||
ctx.beginPath();
|
||
for (let x = 0; x < VW; x += 6) {
|
||
const y = VH / 2 + 40 + Math.sin(x / (60 + k * 25) + t * (1 + k * 0.4)) * (26 - k * 6)
|
||
+ Math.sin(x / 17 + t * 2.2) * 5;
|
||
x ? ctx.lineTo(x, y + k * 40) : ctx.moveTo(x, y + k * 40);
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
ctx.textAlign = 'center';
|
||
ctx.save();
|
||
ctx.shadowColor = '#3cf2ff'; ctx.shadowBlur = 28;
|
||
ctx.fillStyle = '#3cf2ff';
|
||
ctx.font = 'bold 54px ui-monospace, Menlo, monospace';
|
||
ctx.fillText('PARADRAMORAMA', VW / 2, 170);
|
||
ctx.restore();
|
||
ctx.fillStyle = '#8fa3c8';
|
||
ctx.font = '15px ui-monospace, Menlo, monospace';
|
||
ctx.fillText('a possession roguelite — Ranarama × Paradroid', VW / 2, 205);
|
||
// the painted backdrop is busy — text carries its own shadow rather than
|
||
// washing the art out with a heavier scrim
|
||
ctx.save();
|
||
ctx.shadowColor = '#05060a';
|
||
ctx.shadowBlur = 8;
|
||
ctx.fillStyle = '#93a9c4';
|
||
ctx.font = '13px ui-monospace, Menlo, monospace';
|
||
const lines = [
|
||
'You are a rogue SIGNAL. Hardware cannot see you. Static can.',
|
||
'TOUCH a machine to invade it — tune W/S to its tone while the world keeps moving.',
|
||
'Its body is your health, your ammo, your disguise. It is always dying.',
|
||
'SPACE fire (hold = gain = loud) · E eject · WASD move · M mute',
|
||
];
|
||
lines.forEach((l, i) => {
|
||
ctx.fillText(l, VW / 2, 300 + i * 24);
|
||
ctx.fillText(l, VW / 2, 300 + i * 24); // double pass = denser shadow
|
||
});
|
||
if (Math.floor(t * 2) % 2) {
|
||
ctx.fillStyle = '#eaf4ff';
|
||
ctx.font = 'bold 17px ui-monospace, Menlo, monospace';
|
||
ctx.fillText('PRESS ENTER', VW / 2, 440);
|
||
ctx.fillText('PRESS ENTER', VW / 2, 440);
|
||
}
|
||
ctx.fillStyle = '#6b7a90';
|
||
ctx.font = '13px ui-monospace, Menlo, monospace';
|
||
ctx.fillText('purge every machine · the drone resolves when the floor is clean', VW / 2, 560);
|
||
ctx.restore();
|
||
ctx.textAlign = 'left';
|
||
}
|
||
|
||
// ---- loop ----
|
||
function frame(now) {
|
||
const dt = Math.min(0.05, (now - last) / 1000);
|
||
last = now;
|
||
if (state === 'play' && !paused) update(dt);
|
||
render();
|
||
requestAnimationFrame(frame);
|
||
}
|
||
requestAnimationFrame(frame);
|
||
|
||
// debug handle (inspection only)
|
||
window.DBG = () => ({ state, player, enemies, statics, duel, noise, rooms });
|
||
window.DBGstep = secs => {
|
||
const step = 1 / 60;
|
||
for (let t = 0; t < secs; t += step) if (state === 'play' && !paused) update(step);
|
||
render();
|
||
};
|
||
window.DBGteleport = (x, y) => { const o = player.body || player; o.x = x; o.y = y; player.x = x; player.y = y; };
|
||
window.DBGkey = (code, down) => { keys[code] = down; if (down) press(code); else release(code); };
|