// 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; // ---- 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; // drifting waveform ctx.strokeStyle = '#1c2a44'; 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); ctx.fillStyle = '#5a6a80'; 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)); if (Math.floor(t * 2) % 2) { ctx.fillStyle = '#cfe3ff'; ctx.font = 'bold 17px ui-monospace, Menlo, monospace'; ctx.fillText('PRESS ENTER', VW / 2, 440); } ctx.fillStyle = '#3a4560'; ctx.fillText('purge every machine · the drone resolves when the floor is clean', VW / 2, 560); 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); };