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:
commit
21dd748e8e
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
.DS_Store
|
||||
111
DESIGN.md
Normal file
111
DESIGN.md
Normal 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 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.
|
||||
46
README.md
Normal file
46
README.md
Normal 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
17
index.html
Normal 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
165
src/audio.js
Normal 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
940
src/game.js
Normal 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
83
src/world.js
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user