paradramorama/src/game.js
m3ultra 8c8bb8cebb Painted title screen + farm batch client
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>
2026-07-26 11:09:45 +10:00

967 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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); };