From 21dd748e8eed8d7c4a1b6f2f56b97c1eb97d6c78 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Sun, 26 Jul 2026 00:27:47 +1000 Subject: [PATCH] Vertical slice: possess/decay/bail loop, live pitch-duel, full floor Ranarama x Paradroid hybrid. Bare signal invisible to hardware, hunted by static; touch a machine for a picture-in-picture tune-to-lock duel while the world stays live; possessed bodies decay and double as ammo and disguise; gain=damage=noise aggro economy; eject leaves re-enterable husks; floor clear resolves the dissonant drone to a major chord. Vanilla JS + Canvas + Web Audio, no build step. Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + DESIGN.md | 111 ++++++ README.md | 46 +++ index.html | 17 + src/audio.js | 165 +++++++++ src/game.js | 940 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/world.js | 83 +++++ 7 files changed, 1363 insertions(+) create mode 100644 .gitignore create mode 100644 DESIGN.md create mode 100644 README.md create mode 100644 index.html create mode 100644 src/audio.js create mode 100644 src/game.js create mode 100644 src/world.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..2b4709c --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,111 @@ +# PARADRAMORAMA — design + +## Lineage + +Ranarama (Graftgold 1987) had three great ideas and shipped two of them: + +1. **One resource is everything** — Power was health, ammo and fuel at once. +2. **Context switching under pressure** — twitch action interrupted by a timed + logic duel (the anagram, lifted from Paradroid's transfer game). +3. **Weakness as camouflage** — Mervyn survived the massacre because warlocks + ignored a harmless frog. *The game never used this.* Enemies attacked the + frog anyway. The best idea in the premise sat unused for 39 years. + +Paradroid (same developer) had the idea Ranarama dropped: you don't kill what +you beat in the minigame — you **take its body**, and the body decays, forcing +a chain of hijacks. + +PARADRAMORAMA fuses all four: possession chain + decaying bodies + live duels ++ weakness-as-camouflage, skinned as audio because the thing everyone remembers +about Ranarama is the garbled digitised voice on the title screen. + +## Pillars + +- **The world never pauses.** 1987 froze the game for the duel sub-screen. + We run the duel picture-in-picture while your helpless real body stands in a + live room. That's the whole modernisation in one sentence. +- **Your body is a countdown.** Health = ammo = disguise timer. Every decision + is "spend the body or save it". +- **Loud is strong and strong is loud.** Gain scales damage AND aggro. There is + no silent power option. +- **Audio is mechanics, not garnish.** The duel is audible (two oscillators + beating against each other as you tune in). The floor's drone is the map + state — dissonant while infested, resolves to a major chord when cleansed. + +## The two-sided food chain + +| | sees bare Signal | sees possessed body | +|----------------|------------------|---------------------| +| **Hardware** | no — you're line noise | yes, once you make noise | +| **Static** | yes — hunts you | no — ignores machines | + +Bare = safe from guns, hunted by gremlins. Possessed = safe from gremlins, +at war with guns. There is no fully safe state. This is the frog insight, +finally load-bearing. + +## Entities (slice roster) + +| Unit | HP | Role | +|---|---|---| +| **Signal** (you) | 3 coherence | fast, unarmed, invisible to hardware | +| **Static** | 1 | gremlin, spawned by amps, hunts bare signals, interrupts duels | +| **AMP** (spawner) | 8 | stationary nest, not possessable, drops charge pickups | +| **DRUM** | 5 | possessable. Fires quantised bursts on the global 112 BPM clock | +| **SYNTH** | 7 | possessable. Charge-and-release heavy orb, filter-sweep voice | +| **SAMP** (post-slice) | 6 | captures the last projectile that hit it, fires copies back | + +## Possession duel (the Warlock fight, modernised) + +- Trigger: bare signal touches active hardware. +- PiP panel opens; **game keeps running**. Your signal stands frozen and + vulnerable at the machine — a static hit mid-duel = instant fail + damage. +- Mechanic (slice): **tune-to-lock.** Target tone plays; you sweep your + oscillator W/S in log-frequency space; within ±6% a lock meter fills + (~1s of continuous hold); leave tolerance and it drains. 5s total timer. + You tune by EAR as much as by eye — the beat-frequency between the two + oscillators disappears as you converge. +- Fail/timeout: 1 coherence damage, knockback, 2s lockout on that target. +- Post-slice: duel minigame varies by hardware family — beat-match for + drums (nudge a drifting metronome into phase), pitch for synths, a + gain-ride (keep a jumping fader inside a moving sweet spot) for amps if + we make them possessable, and a scratch duel via the scratchlab worklet + engine as the boss variant. + +## Decay economy + +| Drain | Cost | +|---|---| +| passive decay, any body | 0.35 HP/s | +| DRUM shot (auto, per 8th note held) | 0.18 HP | +| SYNTH orb | 0.4 + 0.8×gain HP | +| body destroyed while worn | eject + 1 coherence + stun | +| voluntary eject (E) | free; husk keeps remaining HP, decays 0.8 HP/s, re-enter without duel | + +Gain: hold fire to ramp 0→1. Damage scales with gain. Each shot adds noise; +noise decays slowly; aggro radius = 240px + noise×700px. Max noise = the whole +revealed floor knows where you are. + +Charge pickups (amp kills always, hardware kills 30%): +2.5 integrity to a worn +body, or +1 coherence when bare. + +**Anti-softlock:** if amps remain but no possessable hardware exists, the +system dispatches a repair DRUM from a living amp every few seconds. You can +always claw back a body. + +## Win state + +Floor cleansed = every amp and every machine dead (the body you're wearing +counts as cleansed — walk it home). Statics pop with the last amp. The +dissonant drone (detune scales with enemies remaining) resolves to a major +add9 pad and the floor shifts deep blue — Ranarama's victory colour, kept. + +## Roadmap + +1. **This slice** — one floor, DRUM/SYNTH/AMP/static, tune-duel, full loop. +2. Floors 2–8 with descent glyph, per-floor palette + drone key change. +3. SAMP body + per-family duel minigames. +4. Build-crafting between floors: spend "kernel shards"—no. Spend **patch + cables**: route body mods (eject refunds integrity as a stun burst, gain + ramps faster but caps noise higher, husks don't decay...). +5. Scratch duel boss on the worklet engine. Digitised voice taunts, + PARADRAMORAMA garble as the boss language. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e4f3f40 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# PARADRAMORAMA + +*Ranarama × Paradroid, finished properly this time.* + +You are a rogue **Signal** loose in a haunted studio complex. The hardware can't +see you — you're just noise on the line. Static gremlins CAN see you, and they +hunt signals. Touch a machine to invade it in a live pitch-matching duel (the +world does not pause), wear its body while it decays, burn its weapons, and bail +before it blows. Purge every amp and every machine on the floor and the drone +resolves to a chord. + +## Run it + +Any static server from the repo root (ES modules won't load off `file://`): + +``` +python3 -m http.server 8474 +``` + +then open http://localhost:8474 + +## Controls + +| Key | Action | +|---|---| +| WASD / arrows | move | +| touch hardware | start possession duel | +| W / S (in duel) | tune your oscillator to the target tone, hold the lock | +| SPACE | fire — **hold** to ride the gain (more damage, more noise) | +| E | eject from the body (leaves a husk you can re-enter, no duel) | +| P | pause · M | mute | + +## The loop + +1. **Bare signal** — fast, invisible to hardware, helpless. Static hunts you. +2. **Duel** — bump a machine → picture-in-picture pitch-match under a timer + while the room stays live. Static hits you mid-duel, you lose the duel. +3. **Possessed** — the body is your health, ammo and disguise, and it is + always draining. Firing drains it faster. Gain = damage = noise = every + machine on the floor hears you. +4. **Bail** — eject before it blows or eat coherence damage. Hide. Hunt the + next host. + +Full design: [DESIGN.md](DESIGN.md) + +Prototype is vanilla JS + Canvas + Web Audio. No dependencies, no build step. diff --git a/index.html b/index.html new file mode 100644 index 0000000..e584306 --- /dev/null +++ b/index.html @@ -0,0 +1,17 @@ + + + + + +PARADRAMORAMA + + + + + + + diff --git a/src/audio.js b/src/audio.js new file mode 100644 index 0000000..a0bcb8f --- /dev/null +++ b/src/audio.js @@ -0,0 +1,165 @@ +// PARADRAMORAMA — all sound is synthesized, no samples +let ctx = null, master = null, muted = false; +let drone = null; + +export function init() { + if (ctx) { if (ctx.state === 'suspended') ctx.resume(); return; } + ctx = new (window.AudioContext || window.webkitAudioContext)(); + master = ctx.createGain(); + master.gain.value = 0.5; + master.connect(ctx.destination); + startDrone(); +} + +export function toggleMute() { + if (!master) return muted; + muted = !muted; + master.gain.value = muted ? 0 : 0.5; + return muted; +} + +// ---- the floor drone: dissonance tracks infestation ---- +function startDrone() { + const filt = ctx.createBiquadFilter(); + filt.type = 'lowpass'; filt.frequency.value = 420; filt.Q.value = 1.1; + const g = ctx.createGain(); g.gain.value = 0.05; + filt.connect(g); g.connect(master); + const mk = f => { + const o = ctx.createOscillator(); + o.type = 'sawtooth'; o.frequency.value = f; + o.connect(filt); o.start(); + return o; + }; + drone = { filt, g, oscs: [mk(55), mk(82.5), mk(110)] }; + setTension(1); +} + +// t 0..1 — 1 = fully infested (sour detune, dark filter) +export function setTension(t) { + if (!drone) return; + const now = ctx.currentTime; + drone.oscs[1].detune.setTargetAtTime(65 * t, now, 0.6); + drone.oscs[2].detune.setTargetAtTime(-48 * t, now, 0.6); + drone.filt.frequency.setTargetAtTime(380 + 520 * (1 - t), now, 0.6); +} + +// floor cleansed: kill the drone, resolve to A major add9 +export function resolve() { + if (!ctx) return; + if (drone) { + const now = ctx.currentTime; + drone.g.gain.setTargetAtTime(0, now, 0.6); + drone.oscs.forEach(o => o.stop(now + 3)); + drone = null; + } + [110, 220, 277.18, 329.63, 493.88, 554.37].forEach((f, i) => pad(f, 0.05 + i * 0.13, 7)); + [880, 1108.7, 1318.5, 1760].forEach((f, i) => + setTimeout(() => blip(f, 0.5, 'sine', 0.05), 400 + i * 170)); +} + +function pad(f, delay, dur) { + const o = ctx.createOscillator(); o.type = 'triangle'; o.frequency.value = f; + const g = ctx.createGain(); + const t0 = ctx.currentTime + delay; + g.gain.setValueAtTime(0, t0); + g.gain.linearRampToValueAtTime(0.045, t0 + 1.4); + g.gain.setTargetAtTime(0, t0 + dur, 1.6); + o.connect(g); g.connect(master); + o.start(t0); o.stop(t0 + dur + 6); +} + +// ---- one-shots ---- +export function blip(f = 440, dur = 0.08, type = 'square', vol = 0.08, slideTo = 0) { + if (!ctx) return; + const o = ctx.createOscillator(); o.type = type; o.frequency.value = f; + if (slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(20, slideTo), ctx.currentTime + dur); + const g = ctx.createGain(); + g.gain.setValueAtTime(vol, ctx.currentTime); + g.gain.exponentialRampToValueAtTime(0.0005, ctx.currentTime + dur); + o.connect(g); g.connect(master); + o.start(); o.stop(ctx.currentTime + dur + 0.02); +} + +export function noiseBurst(dur = 0.3, vol = 0.15, cutoff = 900) { + if (!ctx) return; + const len = Math.ceil(ctx.sampleRate * dur); + const buf = ctx.createBuffer(1, len, ctx.sampleRate); + const d = buf.getChannelData(0); + for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1; + const src = ctx.createBufferSource(); src.buffer = buf; + const f = ctx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = cutoff; + const g = ctx.createGain(); + g.gain.setValueAtTime(vol, ctx.currentTime); + g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur); + src.connect(f); f.connect(g); g.connect(master); + src.start(); +} + +export function possessArp() { + [220, 330, 440, 660, 880].forEach((f, i) => + setTimeout(() => blip(f, 0.09, 'square', 0.07), i * 55)); +} + +export function duelFailBuzz() { blip(220, 0.35, 'sawtooth', 0.1, 60); } +export function hurt() { blip(110, 0.2, 'sawtooth', 0.12, 55); noiseBurst(0.15, 0.1, 500); } +export function ejectSfx() { blip(700, 0.18, 'triangle', 0.07, 180); } +export function revealSfx() { blip(70, 0.3, 'sine', 0.1, 45); } +export function pickupSfx() { blip(660, 0.07, 'sine', 0.07); setTimeout(() => blip(990, 0.09, 'sine', 0.07), 60); } + +// ---- possession duel: target tone vs your tone, beat frequency IS the feedback ---- +export function duelStart(targetF, startF) { + if (!ctx) return null; + const tO = ctx.createOscillator(); tO.type = 'square'; tO.frequency.value = targetF; + const tG = ctx.createGain(); tG.gain.value = 0.035; + tO.connect(tG); tG.connect(master); tO.start(); + const pO = ctx.createOscillator(); pO.type = 'sawtooth'; pO.frequency.value = startF; + const pG = ctx.createGain(); pG.gain.value = 0.045; + pO.connect(pG); pG.connect(master); pO.start(); + return { + setFreq(f) { pO.frequency.setTargetAtTime(f, ctx.currentTime, 0.02); }, + stop() { + [tO, pO].forEach(o => { try { o.stop(); } catch (e) { /* already stopped */ } }); + [tG, pG].forEach(g => g.disconnect()); + }, + }; +} + +// ---- the title voice: "PA-RA-DRA-MO-RA-MA", garbled like it's 1987 ---- +function crushCurve(steps) { + const n = 1024, curve = new Float32Array(n); + for (let i = 0; i < n; i++) { + const x = (i / (n - 1)) * 2 - 1; + curve[i] = Math.round(x * steps) / steps; + } + return curve; +} + +export function titleGarble() { + if (!ctx) return; + const sylls = [[660, 1000], [560, 1180], [520, 1350], [380, 850], [560, 1180], [350, 800]]; + const dur = 0.17, gap = 0.05; + const t0 = ctx.currentTime + 0.05; + const src = ctx.createOscillator(); src.type = 'sawtooth'; + src.frequency.setValueAtTime(96, t0); + src.frequency.linearRampToValueAtTime(72, t0 + sylls.length * (dur + gap)); + const crush = ctx.createWaveShaper(); crush.curve = crushCurve(5); + const bp1 = ctx.createBiquadFilter(); bp1.type = 'bandpass'; bp1.Q.value = 6; + const bp2 = ctx.createBiquadFilter(); bp2.type = 'bandpass'; bp2.Q.value = 8; + const g2 = ctx.createGain(); g2.gain.value = 0.7; + const outG = ctx.createGain(); outG.gain.value = 0; + src.connect(crush); + crush.connect(bp1); crush.connect(bp2); + bp1.connect(outG); bp2.connect(g2); g2.connect(outG); + outG.connect(master); + sylls.forEach((f, i) => { + const st = t0 + i * (dur + gap); + bp1.frequency.setValueAtTime(f[0], st); + bp2.frequency.setValueAtTime(f[1], st); + outG.gain.setValueAtTime(0, st); + outG.gain.linearRampToValueAtTime(0.6, st + 0.02); + outG.gain.setValueAtTime(0.6, st + dur - 0.05); + outG.gain.linearRampToValueAtTime(0, st + dur); + }); + src.start(t0); + src.stop(t0 + sylls.length * (dur + gap) + 0.1); +} diff --git a/src/game.js b/src/game.js new file mode 100644 index 0000000..25ffbce --- /dev/null +++ b/src/game.js @@ -0,0 +1,940 @@ +// 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); }; diff --git a/src/world.js b/src/world.js new file mode 100644 index 0000000..e992697 --- /dev/null +++ b/src/world.js @@ -0,0 +1,83 @@ +// PARADRAMORAMA — floor layout, tiles, fog of war +export const TILE = 20; +export const GRID_W = 64, GRID_H = 44; +export const WORLD_W = GRID_W * TILE, WORLD_H = GRID_H * TILE; + +// tile coords {x,y,w,h} +export const rooms = [ + { id: 'A', x: 2, y: 2, w: 12, h: 10 }, + { id: 'B', x: 20, y: 2, w: 14, h: 12 }, + { id: 'C', x: 40, y: 3, w: 16, h: 10 }, + { id: 'D', x: 4, y: 18, w: 14, h: 12 }, + { id: 'E', x: 24, y: 18, w: 16, h: 14 }, + { id: 'F', x: 46, y: 16, w: 14, h: 12 }, + { id: 'G', x: 8, y: 34, w: 20, h: 8 }, + { id: 'H', x: 34, y: 34, w: 24, h: 8 }, +].map(r => ({ ...r, revealed: false })); + +export const corridors = [ + { x: 14, y: 6, w: 6, h: 3 }, // A-B + { x: 34, y: 6, w: 6, h: 3 }, // B-C + { x: 6, y: 12, w: 3, h: 6 }, // A-D + { x: 26, y: 14, w: 3, h: 4 }, // B-E + { x: 48, y: 13, w: 3, h: 3 }, // C-F + { x: 18, y: 23, w: 6, h: 3 }, // D-E + { x: 40, y: 23, w: 6, h: 3 }, // E-F + { x: 12, y: 30, w: 3, h: 4 }, // D-G + { x: 37, y: 32, w: 3, h: 2 }, // E-H + { x: 28, y: 37, w: 6, h: 2 }, // G-H +]; + +const tiles = new Uint8Array(GRID_W * GRID_H); // 1 = wall + +function carve(rect) { + for (let y = rect.y; y < rect.y + rect.h; y++) + for (let x = rect.x; x < rect.x + rect.w; x++) + tiles[y * GRID_W + x] = 0; +} + +export function buildWorld() { + tiles.fill(1); + for (const r of rooms) { r.revealed = false; carve(r); } + for (const c of corridors) carve(c); +} + +export function isWall(tx, ty) { + if (tx < 0 || ty < 0 || tx >= GRID_W || ty >= GRID_H) return true; + return tiles[ty * GRID_W + tx] === 1; +} + +export function roomAt(px, py) { + const tx = px / TILE, ty = py / TILE; + for (const r of rooms) + if (tx >= r.x && tx < r.x + r.w && ty >= r.y && ty < r.y + r.h) return r; + return null; +} + +export function corridorAtTile(tx, ty) { + for (const c of corridors) + if (tx >= c.x && tx < c.x + c.w && ty >= c.y && ty < c.y + c.h) return true; + return false; +} + +// returns the room if this is a NEW reveal, else null +export function revealAt(px, py) { + const r = roomAt(px, py); + if (r && !r.revealed) { r.revealed = true; return r; } + return null; +} + +export function isRevealedFloor(tx, ty) { + if (isWall(tx, ty)) return false; + if (corridorAtTile(tx, ty)) return true; + for (const r of rooms) + if (tx >= r.x && tx < r.x + r.w && ty >= r.y && ty < r.y + r.h) return r.revealed; + return false; +} + +// random point inside a room, keeping a margin from walls (px) +export function roomPoint(room, margin = 2) { + const x = (room.x + margin + Math.random() * (room.w - margin * 2)) * TILE; + const y = (room.y + margin + Math.random() * (room.h - margin * 2)) * TILE; + return { x, y }; +}