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 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-26 00:27:47 +10:00
commit 21dd748e8e
7 changed files with 1363 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.DS_Store

111
DESIGN.md Normal file
View File

@ -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 28 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.

46
README.md Normal file
View File

@ -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.

17
index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PARADRAMORAMA</title>
<style>
html,body{margin:0;padding:0;background:#05060a;height:100%;overflow:hidden}
body{display:flex;align-items:center;justify-content:center}
canvas{background:#07080f;box-shadow:0 0 60px #0af2, 0 0 140px #70f1;image-rendering:pixelated;max-width:100vw;max-height:100vh}
</style>
</head>
<body>
<canvas id="game" width="960" height="600"></canvas>
<script type="module" src="src/game.js"></script>
</body>
</html>

165
src/audio.js Normal file
View File

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

940
src/game.js Normal file
View File

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

83
src/world.js Normal file
View File

@ -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 };
}