commit 626367213454e103f41baa8318046d75f2d604f9 Author: type-two Date: Sun Jul 19 15:36:54 2026 +1000 Molly Cool: arcade science-action vertical slice A nanoscale arcade game. Shoot charge across the room, wrestle atoms together until they snap, and contain a chain reaction before it eats the field. Vanilla JS + Canvas 2D, no dependencies, no build step. Core systems: - Charge: give/take as the only two verbs. Opposites attract, likes repel. - Laserhands: ranged proton/vacuum bolts with real travel time. Bolts are charged bodies, so they curve through the field — bank shots are real. Give spends from an 8-proton ring, take earns them back. - The wrestle: bonds can't form on their own. Every pair has an activation barrier that parks them at arm's length; Molly's hands are the only way over it. Hold, squeeze, release -> SNAP. - Radicals: the enemy, with no mind. Every 0.32s one grabs its nearest neighbour, completes itself, and hands the wound on. Can't be shot dead — terminated by wrestling two together (barrierless, so 0.38s vs 1.56s). Two that drift into contact self-quench, which gives outbreaks a natural equilibrium instead of unbounded growth. - Overload: keep feeding one atom and it goes critical. The blast tips its neighbours, so density decides whether you get one pop (34 atoms) or 84 detonations from a single seed (116 atoms). - CASCADE: eight arenas of escalating density with finite ignition waves. Clear the field to advance; sustained neglect melts it down. - Membrane, sour/soapy zones, and the seven wordless tutorial rooms. Non-obvious constraints, documented at each constant: - BARRIER_FORCE must exceed MAX_CHARGE_FORCE, or atoms bond themselves and the wrestle (the whole game) gets skipped. - Bond lock distance scales with atom radius. A flat value meant two carbons could never bond at all, since collision held them further apart than the lock — invisible because hydrogen worked fine. - RAD_SPLIT_R must exceed the field's natural packing distance (set by the barrier), or outbreaks can only crawl and never bloom. Testing: 39 headless sim tests run the real physics in Node — no browser, no canvas. The sim draws all randomness from a seeded RNG so runs are reproducible. Perf is 0.32ms/sim-step at 116 atoms. Dev server strips a /v/ path prefix that index.html adds to its entry import, because browsers cache the *parsed* ES module by URL and no-cache headers alone don't touch it. Co-Authored-By: Claude Opus 4.8 diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..51bf041 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "mollycool", + "runtimeExecutable": "python3", + "runtimeArgs": ["serve.py", "5178"], + "port": 5178 + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb06056 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# python +__pycache__/ +*.py[cod] + +# macos +.DS_Store + +# editors +.vscode/ +.idea/ +*.swp + +# claude local overrides (launch.json IS committed — it's how the dev server starts) +.claude/settings.local.json + +# scratch +test/tmp/ +*.log diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md new file mode 100644 index 0000000..19124c5 --- /dev/null +++ b/BUILD_PLAN.md @@ -0,0 +1,310 @@ +# Molly Cool — Vertical Slice Build Plan + +**For the executing agent (Opus 4.8). Read this whole file before writing code.** + +You are building a playable **2D browser prototype** of the first ten minutes of *Molly Cool*. This is a **feel-first vertical slice**: the entire point is to prove that the core mechanics are *fun to hold a controller for*. If the moment-to-moment feel isn't satisfying, nothing else matters. Prioritize game feel over completeness at every fork. + +This is **not** a science app. There is no real chemistry validation, no accuracy requirement, no molecule database, no pedagogy. "Protons," "charge," "bonds," "acidity" are flavor for mechanics — nothing more. Do not add educational text, quizzes, or explanations. **The game teaches entirely through play, with zero words on screen.** + +--- + +## 1. The design in one screen + +Molly is a nanoscale character in a permanently shaking fluid world. She has two hands: + +- **Give** — push a proton into a thing → it goes **positive** (hot pink). +- **Take** — rip a proton out → it goes **negative** (cyan). +- Leave it alone → **neutral** (dim gray-white), and it drifts free. + +From those two verbs everything follows: + +- **Opposites attract, likes repel.** Charged things lunge at their opposite and stick. +- **Bonds** aren't built with a button — you *drag two atoms together, wrestle them through the resistance, and let go.* They snap. Big juice. +- **The membrane wall** blocks anything charged and ignores anything neutral. No guard, no switch — you get through by *becoming neutral*. +- **Zones** (sour / soapy / neutral) re-tune your hands: a sour zone keeps shoving things positive, so neutral won't stay neutral; soapy drains charge away. +- **Heat** shakes everything in a radius (crowbar). **Light** flips one thing (scalpel). Some setups react to them in *opposite* directions. + +**The heist:** you're too small to open doors, so you change what things *are*. Strip a thing neutral → it drifts through the wall → the sour room on the far side charges it back up → now it's trapped. You never opened the vault. You made the loot un-leavable. + +--- + +## 2. Non-negotiable pillars + +1. **The snap must feel incredible.** The bond hold-and-release (§8) is the heartbeat of the game. Spend disproportionate effort here. If you only get one thing feeling perfect, make it this. +2. **Zero on-screen text during play.** No tutorials, no labels, no tooltips. Every lesson is a thing that happens to the player. (A single title card at the start and an end card are fine.) +3. **Two buttons carry the whole game.** Give + Take, plus Grab, plus two openers (Heat, Light). Resist adding mechanics. Depth comes from combining these, not from more verbs. +4. **The world is always alive.** Everything jitters constantly (Brownian shimmer). Nothing sits perfectly still, including Molly at rest. No coasting — there's drag on everything. +5. **Readable at a glance.** Charge state is legible instantly by both **color AND shape** (see §6), so it reads even for colorblind players and even in motion. + +--- + +## 3. Explicit non-goals (do not build these) + +- No 3D, no WebGL, no molecular rendering libraries, no Babylon/Three. +- No real chemistry engine, no force fields, no molecule data files, no PubChem. +- No accuracy claims, no correct bond angles, no periodic table. Charge is an integer; that's the whole model. +- No build framework (no React, no bundler required). No npm dependencies. See §4. +- No menus beyond a start screen, no settings, no save system, no networking, no analytics. +- No art assets, no audio files. Everything is drawn with Canvas primitives and synthesized with WebAudio. + +--- + +## 4. Tech stack + +- **Vanilla JavaScript, ES modules.** No dependencies, no bundler, no build step. +- **HTML5 Canvas 2D** for all rendering. +- **WebAudio API** for all sound (synthesized at runtime — no files). +- **Gamepad API** for controller + haptic rumble (the hold-resistance tell). +- Runs by opening `index.html` through any static file server (e.g. `python3 -m http.server`). Must work in current Chrome/Edge/Safari. Must hold **60 fps** on modest hardware. + +Logical resolution **1280×720**, letterboxed/scaled to fit the window (integer or fractional scale, preserve aspect). All coordinates in logical pixels. + +--- + +## 5. Project structure (suggested — adapt if you have a better idea) + +``` +index.html # canvas + module entry, title card, start button +src/ + main.js # boot, game loop, state machine (title → play → end) + config.js # ALL tunable feel constants live here. One file. + input.js # keyboard + mouse + gamepad → unified InputState, rumble + camera.js # follow + screenshake (trauma model) + world↔screen + audio.js # WebAudio synth: pop, hum, snap, ambience, bounce + world.js # holds particles, membranes, zones; steps the sim + particle.js # Particle entity + charge/jitter/bond state + molly.js # player: movement, hands, grab, self-charge + physics.js # forces: jitter, charge interaction, zones, membrane, bonds + bonds.js # the hold-and-release mechanic + bond constraints + render.js # draw world, particles, glow/shape by charge, effects + fx.js # particles/sparks, hitstop, flashes, trauma helpers + rooms.js # room definitions (data) + RoomManager + rooms.data.js # the ten scripted beats as data +``` + +Keep `config.js` authoritative for every feel number. Feel is tuned by editing that one file; don't scatter magic numbers through the code. + +--- + +## 6. Core data model + +**Particle** (a mote / atom): +``` +{ + pos: {x, y}, vel: {x, y}, + charge: -1 | 0 | +1, // slice uses three states; allow ±2 later + displayCharge: float, // lerps toward `charge` for smooth color/shape + radius: ~10, + slots: 1..4, // "valence" dots drawn around it (visual only in slice) + bonds: [particleRef, ...], // spring-linked partners + zoneTimer: float, // accumulates while in a re-tuning zone + grabbedBy: null | 'L' | 'R', // which hand is holding it + fixed: bool, // scenery anchors (raft targets, etc.) + tag: string // e.g. 'key', 'debris', for room scripting +} +``` + +**Molly** (player): +``` +{ pos, vel, charge: -1|0|+1, displayCharge, aim:{x,y}, radius:~14, + handL: {holding: particleRef|null}, handR: {holding: particleRef|null} } +``` + +**Membrane:** a line segment `{a:{x,y}, b:{x,y}, thickness}` that repels charged things, ignores neutral ones. + +**Zone:** `{shape:'rect'|'circle', ...bounds, type:'sour'|'soapy'|'neutral', tint}`. + +**Room:** `{ id, molly:{x,y}, particles:[...], membranes:[...], zones:[...], + exit:{rect}, ambient:{hum, tint}, onEnter?:fn, onExit?:fn }`. + +**Charge → appearance** (both color and shape, always redundant): + +| Charge | Color | Shape signature | Halo | +|---|---|---|---| +| **+1 positive** | hot pink `#FF2D9B` | **spiky** outline (outward points) | tight bright rim | +| **0 neutral** | dim gray-white `#C8D0D8` | smooth circle, flat | none, slightly translucent | +| **−1 negative** | cyan `#2DB8FF` | smooth circle, **soft pulsing** halo | wide gentle glow | + +Positive = aggressive/spiky, negative = smooth/glowing, neutral = quiet/flat. This makes charge readable without color, and just reads better in motion. `displayCharge` lerps over ~150 ms so transitions feel physical, not instant. + +--- + +## 7. The simulation (physics.js) — update order per frame + +Run at a fixed timestep (e.g. accumulate real time, step at `dt = 1/120 s`, render interpolated). Order each step: + +1. **Jitter (the storm).** Every non-grabbed particle gets a random impulse: `vel += randUnit() * JITTER_ACCEL * dt`. Molly gets a much smaller idle jitter so she never sits dead still. Tune `JITTER_ACCEL` (start ~400 px/s²) so the world *shimmers* — alive, not chaotic. +2. **Charge interaction.** For each pair of *charged* particles within `CHARGE_RANGE` (~180 px): Coulomb-ish force `F = K_CHARGE * q1 * q2 / max(d, D_MIN)²`, attractive for opposite signs, repulsive for like. Cap magnitude to avoid explosions. Skip neutral–neutral pairs (cheap). Start `K_CHARGE` so two opposites within ~120 px visibly lunge together over ~0.3 s. +3. **Zone re-tuning.** For each particle inside a zone: + - **sour:** if `charge < +1`, accrue `zoneTimer += dt`; when it exceeds `ZONE_FLIP_TIME` (~1.2 s), bump `charge += 1` and reset. (So neutral won't *stay* neutral here — the sour air keeps shoving it positive. This IS the "your take hand has to fight the current" feel.) + - **soapy:** if `charge != 0`, accrue timer; on threshold move `charge` one step toward 0. + - **neutral zone / outside:** decay `zoneTimer` toward 0. + - Reset a particle's `zoneTimer` whenever the player just acted on it, so player action feels responsive and the zone "reclaims" it afterward. +4. **Membrane repulsion.** For each *charged* particle (and Molly if charged) near a membrane within `thickness`: apply a strong force along the segment normal, pushing it back to its current side. Neutral particles get **zero** force — they pass freely. Tune so charged things clearly bounce and cannot muscle through; neutral things ignore it entirely. +5. **Bond springs.** For each bonded pair: spring toward `BOND_LENGTH` (~24 px), stiff, damped, so molecules hold shape but wobble slightly in the storm. +6. **Grab springs.** Grabbed particles are pulled toward their hand anchor (§8). +7. **Integrate.** `vel *= damping` (drag — start so speed halves ~every 0.1 s at rest), `pos += vel * dt`. Clamp to world bounds. + +Keep it cheap: a few dozen particles per room max. No spatial hashing needed at this scale. + +--- + +## 8. THE SIGNATURE MECHANIC: bond hold-and-release (bonds.js) + +This is the game. Get it feeling *physical and earned*. The challenge is the **wrestle**, not the click. + +**Grab.** Hold the Grab input near a particle → it tethers to a *hand anchor* that tracks the aim reticle via a stiff spring. Molly can carry it. Grab again → second particle on the other hand (hold up to two). The two anchors sit slightly apart so the player positions each. + +**The activation barrier (this is what makes it fun).** When two grabbed opposite-charge particles approach: +- Beyond ~60 px: mild attraction, easy. +- Between ~25–45 px: a **repulsive bump** they must be shoved *through* — plus the constant jitter knocking them off line. This is the resistance. The player has to *force* them together and hold against the push-back. +- Below `BOND_LOCK_DIST` (~22 px): they **snap** — bond forms, both auto-release from the hands, and the snap event fires (§9). + +**Feedback while wrestling:** +- **Rumble ramps** with effort — low as they approach, peaking at the barrier. (Gamepad haptics; on keyboard, substitute a rising audio hum + a subtle screen tremor.) +- A **rising hum** (audio.js) climbs in pitch/gain as they near the barrier. +- The particles visibly *shove back* against the player's drag at the barrier. + +**Release / fail.** Let go of Grab while still fighting → the particles fly apart on the jitter, no bond, no punishment. Just try again. Failure is instant and cheap. + +**The payoff — snap event:** +- **Hitstop** ~50 ms (freeze the sim, keep rendering). +- **Screenshake:** add ~0.6 trauma (§9). +- **Bass thunk + noise burst** (audio.js). +- **Spark burst** at the bond point, colored by the two charges. +- **Molly knockback:** shove her velocity away from the bond point. +- The new bond glows at the seam for ~0.5 s. + +Tune the whole sequence until you'd do it fifty times just because it feels good. That's the bar. + +--- + +## 9. Juice spec (fx.js, camera.js) + +Feel lives here. Starting values — tune freely. + +- **Screenshake (trauma model, à la "The Art of Screenshake").** Keep a `trauma` float 0..1. Offset = `MAX_SHAKE * trauma² * noise()`, plus small rotation `MAX_ROT * trauma² * noise()`. `MAX_SHAKE` ~20 px, `MAX_ROT` ~2°. Decay `trauma -= 1.5 * dt`. Snap adds ~0.6; membrane bounce ~0.15; charge pop ~0.05. +- **Hitstop.** On big events set `freeze = 0.05 s`; during freeze `dt = 0` for the sim but rendering + effects continue. Sells impact. +- **Particles/sparks.** Cheap circles/lines with velocity + fade. Bursts on snap, charge change, membrane bounce, heat pulse. +- **Charge transitions.** `displayCharge` lerps ~150 ms; color and shape morph with it. A quick bright flash on the moment of change. +- **Trails.** Fast-moving motes leave a short fading trail (a few ghost positions or additive alpha). Sells speed and the fluid. +- **Ambient bloom.** Charged particles have an additive glow. Use `globalCompositeOperation = 'lighter'` for glows/sparks over a dark ground. + +**Audio (all synthesized, no files):** +- **Charge pop:** short sine blip; pitch *up* for Give, *down* for Take. +- **Hold hum:** oscillator whose gain + pitch ramp with bond proximity; kill on release/snap. +- **Snap:** sine sweeping 80→50 Hz over ~120 ms + a filtered white-noise burst + short decay tail. The money sound — make it satisfying. +- **Membrane bounce:** dull filtered thud. +- **Zone ambience:** low drone / filtered noise, timbre tinted per zone; crossfade on zone change. + +--- + +## 10. Zones & membrane recap (behavior already specced in §7) + +- **Zones** apply a subtle full-screen **tint** (sour = yellow-green, soapy = blue-violet, neutral = none) and swap the **ambient hum**. The gameplay effect is the recharge/discharge timer in §7.3. Keep the visual subtle — a wash, not a filter that hurts readability. +- **Membrane** renders as a shimmering oily band. When a charged thing bounces, ripple + bounce thud. When a neutral thing passes, a soft "phase-through" shimmer. Molly passes only while neutral — desaturate the screen and drop the hum while she's neutral to sell "gone quiet." + +--- + +## 11. Controls + +**Keyboard + mouse (must be fully playable):** +- Move: WASD / arrows. Aim: mouse reticle. +- **Give (right hand):** Left Mouse. **Take (left hand):** Right Mouse. +- **Grab (hold):** Space (or hold Left Mouse variant — pick what feels best). +- **Heat:** Q. **Light:** E. +- **Self-charge:** if the reticle isn't over a particle, Give/Take act on **Molly herself** (this is how she strips neutral to cross the wall). Clean and discoverable. + +**Gamepad (support + rumble):** +- Move: left stick. Aim: right stick (reticle offset from Molly). +- Give: RT. Take: LT. Grab: A/× (hold). Heat: LB. Light: RB. +- Rumble ramps during the bond wrestle (the core use of haptics). + +Unify both into a single `InputState` each frame so game code never checks device type. + +--- + +## 12. Heat & Light (openers) + +- **Heat:** area impulse at the reticle, radius ~150 px — kicks all particles outward + spikes their jitter briefly; can break a bond if it stretches past `BOND_BREAK_DIST`. Loud, indiscriminate. Big trauma, big noise. +- **Light:** single target under the reticle — flips its "mood." In the slice, Light toggles a target's charge sign (or triggers a scripted state on a special object). Silent, precise, one thing. +- **The opposite-directions hook:** include at least one scripted "lock" object that moves its state one way under Heat and the other way under Light. It can be a stretch beat (Milestone G) — wire the hook even if only one puzzle uses it. + +--- + +## 13. The scripted sequence (rooms.data.js) + +Ten beats. Each is a small hand-authored room (data). Camera follows Molly; reaching a room's `exit` rect loads the next. Some beats can merge if it plays better — use judgment. **No text in any of them.** + +| # | Room | What the player learns | How it's taught (no words) | +|---|---|---|---| +| 0 | **Wake** | Give/Take = push/pull | Dark, one drifting mote. Poking it charges it and it pushes away / pulls back. Fade in from black. | +| 1 | **First toy** | Opposites grab | A cluster; charge one +, one −, they lunge together and stick. A gap to reach behind them. | +| 2 | **The raft** | Bonds = hold-and-release | Two raft-halves drift apart, jittering. Wrestle them together → SNAP → ride the raft across a gap. | +| 3 | **The wall** | The membrane reads charge | Throw charged debris → it bounces, no reaction. Drift a neutral bit → it passes through. | +| 4 | **Go quiet** | Neutral = sneak | Strip Molly neutral (Take on herself) → screen desaturates → drift through the membrane. | +| 5 | **Sour** | Zones re-tune your hands | World tints yellow-green. A mote you neutralize keeps snapping back to +. | +| 6 | **The vault** | (setup) | A second membrane with the exit behind it, and a "key" object. Sour air present. | +| 7 | **The heist** | Combine everything | Key is charged → bounces. Strip it neutral → it drifts through → sour air on the far side charges it → **trapped.** Exit opens. | +| 8 | **Scale** | (tease) | Zoom/parallax reveal: this was one droplet; thousands more; something huge pulsing far off. Fade to end card. | + +Give each room optional `onEnter`/`onExit` hooks for the few scripted moments (Room 0 fade-in, Room 8 reveal). Don't build a scripting engine — just callbacks. + +--- + +## 14. Build order — milestones (each independently runnable & testable) + +Build in this order. **After each milestone, stop and verify the stated test before moving on.** Do not build later milestones until the current one feels right. + +- **A — Grey box & life.** Canvas, fixed-timestep loop, letterbox scaling. Molly moves with drag + idle jitter; camera follows. A dozen particles drift with the storm jitter. No charge yet. + *Test: does moving around and watching the shimmer feel alive and fluid, not floaty or dead?* + +- **B — Give/Take + charge + opposites-grab.** The core toy. Reticle targeting, Give/Take change charge, color+shape by charge (§6), charge interaction force (§7.2), charge pop audio. + *Test (the kill-test): poke a mote and it charges; make two opposites lunge together and stick. Is just this fun to mess with for a minute?* + +- **C — Bond hold-and-release + full snap juice.** Grab/anchor springs, the activation barrier, rumble/hum ramp, hitstop, screenshake, sparks, knockback, snap audio (§8, §9). **Spend the most time here.** + *Test: would you trigger the snap fifty times for fun? If not, keep tuning before proceeding.* + +- **D — Membrane.** Charged repulsion, neutral pass-through, Molly self-charge to cross, bounce/phase FX. + *Test: charged things bounce, neutral things pass, and going neutral to sneak through feels sneaky.* + +- **E — Zones.** Sour/soapy/neutral recharge fields, tint wash, ambient hum crossfade. + *Test: in a sour zone a neutralized mote visibly refuses to stay neutral.* + +- **F — Rooms & the sequence.** RoomManager, `rooms.data.js`, exit triggers, room transitions, wire beats 0–7. The heist must be solvable using only mechanics the earlier rooms taught. + *Test: play start→heist with no instructions and no text. Does the heist click as "my own idea"?* + +- **G — Heat & Light + the opposite-directions lock.** The two openers and at least one puzzle using the heat-vs-light hook. + *Test: heat feels like a crowbar, light like a scalpel; the opposite-reaction lock reads.* + +- **H — Bookends & polish.** Title card + start, Room 8 scale reveal, end card, audio mix pass, one full feel-tuning pass across `config.js`. + *Test: the whole ten minutes plays clean, start to finish, 60 fps.* + +If time is short, **A–F is the real slice** (it contains the whole thesis). G and H are enhancement. + +--- + +## 15. Definition of done + +- All beats 0–7 are playable start to finish, **with zero text on screen** during play. +- The **bond snap feels genuinely good** (the fifty-times bar). +- Charge state is readable instantly by **color and shape**, even in motion. +- Keyboard+mouse fully playable; gamepad supported with rumble during the wrestle. +- Holds **60 fps** in Chrome on modest hardware. +- **All feel constants live in `config.js`** and can be tuned without touching logic. +- Runs from a plain static server with no build step and no dependencies. + +--- + +## 16. Tuning notes for whoever plays it next + +The feel lives in `config.js`. The three knobs that matter most, in order: +1. **`JITTER_ACCEL`** — the life of the world. Too low = dead; too high = unplayable soup. +2. **The bond barrier** (`BOND_ATTEMPT_RANGE`, barrier strength, `BOND_LOCK_DIST`) — the wrestle. This is the whole game's feel in three numbers. +3. **Snap juice** (`MAX_SHAKE`, hitstop duration, snap audio envelope) — the payoff. + +Tune those first, by feel, before touching anything else. + +--- + +*This is a prototype to answer one question: is the core loop fun? Build the smallest thing that answers it honestly. Everything else waits.* diff --git a/NEXT_LEVEL.md b/NEXT_LEVEL.md new file mode 100644 index 0000000..2de37d5 --- /dev/null +++ b/NEXT_LEVEL.md @@ -0,0 +1,148 @@ +# MOLLY COOL — NEXT LEVEL + +## 1. The diagnosis + +What you built is genuinely good and it is genuinely quiet. The charge system, the bond wrestle, the membrane and the zones all work and all feel great — the problem is that nothing in the world ever pushes back, nothing on screen has a name, and Molly has to physically swim over and hug everything she wants to touch. Three colours, eighteen objects, no opposition, no range: that's not an art problem or a taste problem, it's four missing systems, and the previous read of "make it more austere" pointed the fix in exactly the wrong direction. The abstraction also quietly ate the chemistry — you can't tell you're doing science any more because charge became the only noun, and one scalar can't be spectacular. **Give it teeth, give it hands that reach across the room, give it a periodic table for a palette, and the exact same physics becomes an arcade game.** + +## 2. The direction — **MOLLY COOL: CRITICAL** + +You are fourteen angstroms tall with a proton in one hand and a vacuum in the other, and you have just worked out that one hydroxyl radical dropped into a dense enough field of matter will eat an entire room in eleven seconds. You don't swim over and hug atoms any more — you **shoot**. Right hand fires a hot-pink proton bolt that visibly curves through the charge field; left hand fires a cyan vacuum bolt that rips a proton out and drags it screaming back into your palm. But the bolts aren't the weapon. The weapon is **ignition**: overload something past +3 and it goes white, screams up a sawtooth, and detonates — and everything it splashes goes radical. A radical has one unpaired electron and no mind at all. Every 0.32 seconds it grabs the nearest atom, completes itself, and hands the wound to its victim. One becomes two becomes forty. And the only way to stop it is the mechanic you already built: radical–radical recombination is barrierless, so you cross the streams, tether one radical in each fist, and crush them together in 0.4 seconds while a third one closes on your back. **Light the fuse, ride it as long as you dare, and terminate it half a second before it terminates you.** Geometry Wars if the grid were made of methane. Everything you already love gets promoted, not replaced — the wrestle stops being a puzzle payoff and becomes the kill move. + +## 3. LASERHANDS + +The insight: give and take are already a perfect asymmetric dual-wield. They've just been trapped at melee range. **Tap = bolt. Hold = beam. That's it — the verb count stays at two.** + +**TAP RMB — PROTON BOLT.** Hot pink, r4, **940 px/s** (travel time, never hitscan — travel is what makes it read as *pyew*), 6-frame tracer, 0.16s cooldown. On hit: target **+1 charge**, 45ms hitstop, 0.15 trauma, 8 sparks along the impact normal. Molly recoils **40 px/s** backward, the hand sprite kicks 6px along −aim for 80ms, the camera kicks 4px opposite the shot. The bolt is itself a charged body, so run it through the existing Coulomb solver at **0.3× K_CHARGE** — it *curves* around charged atoms. Bank shots are a skill. Costs one pip. + +**TAP LMB — VACUUM BOLT.** Cyan, same speed, inverted trail. Rips one proton out (**−1**) and the stolen proton flies back at 1300 px/s and joins your ring. Pulls Molly 30 px/s *toward* the target. **Give spends, take earns** — you physically cannot spam one hand. + +**THE AMMO RING.** 8 hot-pink pips orbiting the right hand at r18, 1.2 rad/s, spinning faster as it empties (nervous). Empty = dry click and an impotent puff. Zero HUD, zero text. The left hand always works, always, for free — you can never be locked out. + +**HOLD EITHER >110ms — TETHER.** Beam to **280px**, latches the first body within 22px of the line, springs it (**k=380, damp=22**) to a point 90px ahead of the hand. Right beam is a jagged 5-segment pink lightning polyline re-jittered ±6px every frame. Left beam is a smooth cyan hose with beads travelling *backward* into Molly. Tethered bodies carry real momentum — whip the mouse and you sling them. + +**TAP THE OTHER HAND WHILE TETHERED — PUNT.** **1400 px/s** along aim, 110ms hitstop, 0.4 trauma, bass thunk, 120 px/s recoil on Molly. The punted body is **HOT for 0.6s**: on impact it dumps its own charge sign into whatever it hits and knocks a proton loose. A +3 oxygen punted into a crowd is a charge grenade. Every object on screen is now ammo. (This is the gravity-gun lesson: grabbing is a chore, *throwing* is a weapon.) + +**BOTH HANDS TETHERED, PULL TOGETHER — CROSS THE STREAMS.** The bond wrestle, unchanged, now fed by beam tension instead of body proximity. `barrierHump()` runs exactly as written. Beams whiten and physically **shorten**, dragging Molly into the middle of her own snap so the tactility survives. Both hands committed = you cannot shoot = you are naked. **0.85s ranged / 0.32s melee** (the hug stays, and stays fastest — safe-and-showy vs fast-and-dangerous) / **0.40s for radical–radical** / 0.28s inside a catalyst field. That radical-pair number is load-bearing: 1.8s of squeezing is a wonderful puzzle payoff and a fatal combat move. **Ship it in the same commit as the radical or you'll conclude the whole direction failed on a tuning bug.** + +**SNAP CANCEL WINDOW.** For **0.28s** after a snap, bolts are free and do **+2**, and beams re-latch with no hold delay. Hands flare white for exactly that window. Snap → free +2 bolt → target hits +3 → unstable → detonation. That's a learnable, executable string, and it's ten lines. + +**HITSTOP LADDER, written before any of this ships:** bolt 45ms, snap 130ms, detonation 160ms, hard-capped at **200ms accumulated per 500ms window**. Trauma 0.15 / 0.45 / 0.7, clamped to 1.0. Without the cap a cascade feels like the game crashed. + +**The bolt never deals damage. Its payload is CHARGE.** The moment a bolt does HP, the chemistry becomes set dressing and the simulation stops being the weapon. Also: bolts and beams are charged, so **the membrane blocks them** — shots splash on it in an oily rainbow bloom. But a punted *neutral* object sails straight through. The heist room is now a trick shot from across the room. + +## 4. The threat + +**THE RADICAL (•)** — one enemy, one boolean, no bestiary. A particle with a permanently unpaired slot: the only pure-white thing in the game, jagged corona, 3× jitter, hard motion trail. No AI, no pathing, no plan. Every **0.32s on a deterministic tick** (never per-frame random — the Brownian storm already supplies chaos and stacking randomness on top reads as *unfair* rather than *dangerous*) it finds the nearest bondable neighbour within 70px, telegraphs with a **0.2s white flicker + a thin red hunt-thread**, then takes it. It completes itself and goes dim; the **victim becomes the radical**. Population is conserved per hop — but the wound *moves*, and in a dense pocket it takes two at once and the branch factor crosses 1.0. Hard cap 120. You cannot shoot it dead. You **terminate** it: two radicals wrestled together annihilate in a white flash 3× a normal snap, with a quench ring that puts out everything it touches. + +**OVERCHARGE DETONATION.** Charge clamp widens to −3..+3. Push a body past +3 and it goes white-hot, spikes double, a sawtooth rises over 0.9s, then it detonates: 130px radial pulse, +1 to everything caught, 160ms hitstop, a white ring that physically scatters particles. Anything already at +3 in the blast goes unstable with a shortened 0.45s fuse. **Chains cascade.** The biggest payoff in the game is expressed entirely in the currency the game already had. + +**YOUR OWN TOOLS ARE THE INITIATORS.** Q stops being a crowbar and becomes the igniter: 40% of bonds it breaks split **homolytically into two radicals** instead of separating cleanly (bonds about to go flicker white for 0.2s, so you can always read the risk before you commit). There is no free action in this game. That single change costs six lines and does more for "rad" than any art pass. + +**MEMBRANE BURN.** Rebuild the membrane as ~40 lipid segments. A radical touching one unzips it in **both directions at 110 px/s**, segments blackening and curling. Burnt segments stop blocking charge. Run one out and the room's rules dissolve. This is real lipid peroxidation, and it means the best object in the game can finally be threatened. + +**MOLLY'S HEALTH IS THREE ELECTRON SLOTS**, drawn as three dots orbiting her core. No bar, no number. Radical contact steals one (0.7s i-frames, detuned hit, shake). At zero **she becomes a radical herself**: white, spiky, and **contagious for 4 seconds** — everything she touches propagates, including what she was protecting. She can still act, and she can still terminate by *ramming*. Most dangerous and most powerful state in the game. If it expires she **denatures**: strands unfold over 0.6s, hard cut, playable again in under 400ms. No menu, no message, no "try again." + +**THE OXIDATIVE FRONT** — the late-arena pressure. A slow crimson shimmer sweeping the room, 12%/sec radical conversion for anything inside. Unfightable. You survive it positionally, hopping the dim olive **tocopherol pools** that quench radicals on contact. + +No boss in v1. **The cascade is the boss.** + +## 5. Cool shapes and colours + +**One law, never violated: ELEMENT OWNS THE FILL. CHARGE OWNS THE EDGE AND THE LIGHT. #FFFFFF IS RESERVED FOR RADICALS AND NOTHING ELSE.** That third rule is what lets 120 sparks on screen stay readable. + +`drawBody()` currently fills `#0b1118` and strokes with `chargeColor()`. **Swap which channel gets which** — six lines, one atomic commit, and it frees hue for element identity while charge keeps the two *shape* channels it already half-uses: spiky hot corona (+), smooth pulsing halo (−), flat hairline (neutral). Charge then survives greyscale and deuteranopia, which the current colour-only coding does not. + +**Palette from real emission spectra, not CPK** (CPK's black carbon and white hydrogen both die on a dark ground, and white is spoken for): + +| | | | | +|---|---|---|---| +| H `#EAF4FF` | C `#9B8CFF` | N `#4D7BFF` | O `#2BE86B` | +| Na `#FF9500` | Cl `#D8FF3B` | S `#FFE23D` | Mg `#D9DEE6` | +| Fe `#9FB4C4` | Cu `#23D9C0` | K `#C77DFF` | Ne `#FF5E3A` | + +Fills are **flat, matte, ~35% luminance, never bloom**. Charge is `#FF2D9B` positive / `#2DB8FF` negative and it is the *only* thing that goes additive. Ground is **not black** — `#0B0716` deep ink-indigo with a `#1A1030` graph lattice at 32px. Near-black reads as a void; ink-indigo reads as a medium. + +**Free slots become OPEN SOCKETS** — small rings with dark centres, pulsing at 2Hz, leaning toward the nearest compatible partner within 120px. Carbon with four open sockets reads as visibly *hungry*. Completed methane reads as *satisfied*. That's valence made legible in peripheral vision at speed. + +**Molecules render as ONE object** — flood-fill the bond adjacency each frame (it's cheap), stroke a smoothed hull 6px out with a shared aura. Water is a thing, not three circles near each other. On completion the atoms **snap to real VSEPR geometry over 120ms with a hard ease-out** (weak angular springs at 15% of BOND_SPRING hold them there), the full silhouette flashes solid white for 4 frames, and it rings like a struck FM bell — bigger molecule, lower note. Water visibly bent at **104.5°**. CO₂ a straight bar. Methane a caltrop. **Benzene a hexagon with a counter-rotating delocalised inner ring** — the most beautiful object in the game and the most recognisable silhouette in science. + +**The background stops being empty: the FIELD GRID.** A half-res lattice of short segments displaced by the summed Coulomb field — which you already compute. Positives bulge it, negatives dent it, a snap sends a visible ring across it. Real field lines, near-free, and it turns the void into a charged medium. Highest visual-impact-per-line item on this whole page. + +**Post chain (`post.js`, all `drawImage`):** bloom (glow layer only, offscreen at ¼ res, two downscale/upscale passes, composited `lighter`) → event-driven chromatic split decaying over 120ms → 256px baked grain tile at `overlay` 0.06 → vignette. **Impact frames** on every snap/bolt/detonation: affected objects drawn solid `#FFFFFF` for exactly 2 frames, then one inverted frame, then hitstop. Cheapest rad in existence. Cap inversions to one per 500ms and ship a reduce-flashing toggle day one. + +**Static world printed, chemistry lit:** membrane, grid and walls get a 1px riso misregistration (drawn twice, 1px offset, `#FF3B6B` and `#2BE86B` at 25% alpha) and **never bloom**. Charged matter blooms and never misregisters. Nobody else's game looks like this, and it dodges the synthwave-neon default entirely. + +**PERMANENCE** — the single cheapest fix for austerity. Scorch marks stay. Soot stays. Spent proton husks fall and linger 10s. Broken bond fragments drift. The room accumulates evidence of what you did to it instead of resetting to tasteful emptiness. + +**Sound is 60% of perceived tone.** 130 BPM acid techno, live-synthesised: 909 kick, 16th hats, a TB-303 saw through a resonant lowpass whose **cutoff tracks total radical count on screen**, dub pad. Stems gate on activity. Every player impact quantises to the nearest 16th; the world stays deliberately off-grid — that contrast is what makes you feel like the coolest thing in the room. The wrestle rumble is a rising resonant sweep that **resolves** on the snap. Ban elastic/bounce easing globally. Ban five-point-star particles — hard shards and rings only. No fanfare, no praise, no confetti. A successful bond gets a bass thunk and silence. + +## 6. The science, made visible again + +Twelve elements. Each is a fill colour, a valence count, a radius, and **a verb**. + +- **H** `#EAF4FF` · 1 slot · r7 · lightest, jitters fastest. **The tinder** — radicals eat it first, so H-dense fields are where cascades run away. +- **C** `#9B8CFF` · 4 slots · r13. **The hub.** Four open sockets. Chains, rings, benzene. The builder. +- **N** `#4D7BFF` · 3 slots · r11. N₂'s triple bond is the toughest wrestle in the game; cracking one releases a huge shock. +- **O** `#2BE86B` · 2 slots · r11. **The oxidiser.** O₂ thrown into a fire makes every fire on screen twice as big. It is not the extinguisher. It will kill you once and you will never forget it. +- **Na** `#FF9500` · 1 slot · r14. Punt sodium into a water pool → water tears apart into hydrogen → the heat lights the hydrogen. **A two-stage explosion you set up from across the room.** Best spectacle in the build; worth hand-authoring as a special case. +- **Cl** `#D8FF3B` · 1 slot · r13. Electron thief — drags charge off bonded neighbours. Gas-wisp trail. +- **S** `#FFE23D` · 2 slots · r13. Burns slow, leaves a lingering acid haze that keeps shoving things positive. +- **Mg** `#D9DEE6` · 2 slots · r13. **Flashbang** — burns blinding white and stuns radicals. And magnesium keeps burning *inside* CO₂, so the extinguisher you just deployed is the fuel. +- **Fe** `#9FB4C4` · 3 slots · r15. Heaviest, magnetic, best punt ammo — a punted iron is a wrecking ball. +- **Cu** `#23D9C0` · 2 slots · r14. **Conducts** — passes charge to everything it's bonded to. Build a copper chain and one bolt overcharges six things. +- **K** `#C77DFF` · 1 slot · r15. Potassium. Sodium's worse cousin. Do not take it near water unless you mean it. +- **Ne** `#FF5E3A` · **0 slots** · r9. Noble. Refuses to bond no matter how hard you wrestle. Permanent hard neon glow nothing else has. It's a pinball, it's a punt-shield, and **Molly stripped neutral looks like this** — inert stops meaning "dim grey" and starts meaning "Vegas sign." + +**Build-to-use, never build-to-score.** A molecule is a tool with its real property: **H₂O** douses burning membrane and quenches radicals · **CO₂** sinks and smothers fire but does nothing to radicals · **O₂** doubles every fire on screen · **NH₃** neutralises a sour zone · **C₆H₆** benzene is rigid enough to survive a slam — a shield and a platform · **C₆₀** buckyball is a cage: shove a radical inside and it's gone forever, and now you're carrying a bomb. Learning chemistry pays out as **power**, never as a certificate. That's the whole anti-edutainment test, passed. + +**Unlocks are molecules, not menus.** Build NaCl and sodium lights up on the table. Close a benzene ring and carbon unlocks. Finish an arena having formed zero bonds and you get Neon. Nothing is ever explained; the tile just lights up. + +**The accuracy licence, written down now so nobody re-derails us:** directionally real, gameplay-tuned, fictional entries permitted where the game needs a verb. Real colours, real geometry, real names, knowingly fake energetics. **When the fun number and the accurate number disagree, ship the fun number and do not litigate it.** + +## 7. Structure + +**The seven scripted rooms ship completely unchanged as the cold open.** Six or seven minutes, contemplative, wordless, no radicals, no arcade layer. They're the tutorial *and* they're the contrast that makes everything after them land — escalation only reads as escalation if there's a floor. Gate the entire arcade layer behind them; a stray cascade in the heist room would soft-lock the lesson. + +**The scale reveal becomes the tonal cut.** Camera pulls back, the acid track drops in for the first time, and the first radical is born from a visible heat flicker. + +**Then CASCADE.** Eight arenas of 90–120s, then endless. Every arena runs the same curve, communicated entirely in bass pitch and background tint, no numbers: **0–8s** the room is clean, one radical is born, you learn its rhythm and terminate it. **8–33s** fuel density rises, branch factor climbs past 1.0, you're terminating pairs under pressure. **33–40s** you get the last one and the room goes silent — a deliberate false calm, and it's what makes the next part land. **40–90s** a heat spike initiates five at once inside a hydrogen field and the oxidative front starts moving. + +Progression is the periodic table, one element per arena, each adding a coloured pip to a ring around Molly. Arena 1–2: H, O. Arena 3: **C** and organic chemistry blows the fuel densities open. 4: N. 5: Na + Cl — salt punted into water dissociates into free Na⁺ and Cl⁻ with visible solvation shells, and that's your ammo economy. 6: S, Mg. 7–8: the metals. + +**Score is chain length**, shown as orbiting pips up to 8 and then a single hard-condensed numeral. This is the one deliberate break of the zero-text rule and you should commit to it rather than fudge it — an arcade game with a hidden score is an art piece, which is the exact accusation you're making about the current build. Banking a chain of 8+ refunds a quench charge. One persistent number at the end of a run: best chain. No label, no comparison, no "NEW RECORD." + +**Run length 12–15 minutes. Death to playable in under 400ms.** The one-more-go hook isn't the score — it's that you lost to a fire *you started*, and you're now certain you know where to put the seed next time. That's the KSP trick: losing is louder, funnier and more shareable than winning. + +**Plus SANDBOX** — open arena, element palette unlocked by arena progress, no goals, reset key. Near-free once the sim exists, and it's where the clips come from. + +## 8. What we keep, what we cut + +**KEEP, UNTOUCHED.** The Brownian jitter storm (`JITTER_STRENGTH 90`) — it's the life of the world and it's also what makes grabbing a radical genuinely hard. The Coulomb solver (`K_CHARGE 1.2e7`). `barrierHump()` and `BARRIER_FORCE 3400` — the single best idea in the codebase; every new system feeds it rather than replacing it. The whole snap payoff path: `SNAP_TRAUMA 0.62`, `HITSTOP 0.05`, `SNAP_SHOCKWAVE 620` — reuse it verbatim for detonations at 1.5×. The zones. Trauma camera. WebAudio synthesis. All seven tutorial rooms, verbatim. + +**KEEP, PROMOTED.** `Particle.slots` / `freeSlots` / `drawSlots()` is already a complete, working, enforced valence system with no element names attached — "real molecules" is a **naming pass, not a rewrite**. `grace` is already a per-particle i-frame timer. The `step()` pipeline takes a `radicals(world, dt)` pass cleanly between zones and membranes. The melee grab survives at `GRAB_RANGE 130` as the fast-and-risky option so range never orphans the tactility. The membrane keeps its blocking rule and gains destructibility. + +**CHANGE — one line each.** `barrierHump()` gets an early return at 0.15× for radical–radical pairs. `drawBody()` swaps fill and stroke channels. The charge clamp widens from ±1 to ±3. + +**CUT.** The `E` light scalpel keybind — the bolt *is* the light scalpel now, at range, with travel time; one less key, one clearer mental model. Q as a crowbar (it's the igniter). Melee-only as the *primary* verb. The 1–18 particle sparseness. The "nothing can go wrong" state — that is the #1 structural signal of a kids' toy and it's the build's biggest flaw. + +**DELIBERATELY NOT DOING (yet).** No 30-entry reaction table — hand-author the three that matter (Na+H₂O, Mg+CO₂, H₂+O₂) and skip the engine that would take months to tune. No pH axis — element hue and charge shape already own the channels, and a fourth state variable is how the screen becomes soup. No wand/modifier loadout. No roguelite map. No boss. No formula text anywhere in the world. **No face on Molly. Ever.** + +## 9. The build order + +**1 — TONIGHT: THE BOLTS.** New file `src/bolts.js`, ~80 lines. Tap RMB → pink projectile at 940 px/s, `+1` on hit, 45ms hitstop, 8 sparks, muzzle flash, 6px hand kickback, 40 px/s recoil on Molly. Tap LMB → cyan, `−1`, proton flies home. Write the **hitstop/trauma accumulator cap in this same file** before anything else can stack on it. Two hours. Load room 2 (opposites-grab) unchanged and shoot the atoms across the room. It will feel like a completely different game before midnight. + +**2 — THE COLOUR SWAP.** `src/elements.js` — a twelve-entry data table. Add `this.el` to Particle, derive slots/radius/mass/colour from it, and do the `drawBody()` fill/stroke swap. Convert `rooms.data.js` from `{x,y,slots:2}` to `{x,y,el:'O'}`. Check it in a deuteranopia simulator before building anything on top. One evening, and the screenshot goes from three colours to twelve. + +**3 — TETHER + PUNT.** Hold past 110ms to beam out to 280px, spring-latch, tap the other hand to punt at 1400 px/s with the 0.6s HOT flag. Add the ammo ring. Now every object in the room is ammo and Molly is an operator instead of a swimmer. + +**4 — CROSS THE STREAMS.** Feed the existing bond system from beam tension instead of body proximity. Retune: 0.85s ranged, 0.32s melee. Beams whiten and shorten so you're dragged into your own snap. Add the 0.28s snap-cancel window while you're in there — it's ten lines and it's the whole combo system. + +**5 — THE RADICAL. This is the go/no-go.** `src/radicals.js`, ~40 lines. One flag on Particle, deterministic 0.32s tick, nearest bondable neighbour within 70px, 0.2s white telegraph + red hunt-thread, victim becomes the radical. Hard cap 120. Draw it pure white with a jagged corona. **Ship the `barrierHump()` early return in the same commit** so terminating a pair takes 0.40s. Add Molly's three electron slots and the 400ms death. Then spawn 60 particles and one radical and play with it for twenty minutes. If grabbing two flailing sparks out of the storm while a third closes on you is thrilling, everything downstream is proven. If it's fiddly, you found out in one evening. + +**6 — IGNITION.** Charge clamp to ±3, overcharge detonation with the 0.9s sawtooth fuse, chain counter, the cascade colour grade (desaturate everything except radicals and charged bodies, timescale 1.0 → 0.62), one pentatonic step per link. Q's 40% homolytic split. Now the game has its screen-clearing payoff and its worst mistake, in the same system. + +**7 — DENSITY.** Uniform spatial hash (cell = 2× CHARGE_RANGE) on the Coulomb broad-phase, **pre-rendered glow sprites blitted with `drawImage`** — never `shadowBlur`, never per-frame `createRadialGradient`. Then go to 180 interactive bodies plus 600 non-interactive solvent motes that only get advected by the field. Add the field grid and permanence (scorch, husks, soot). This is where austere finally dies. + +**8 — THE ARENA.** `post.js` (bloom → chromatic split → grain → vignette, plus impact frames), the 130 BPM acid transport with player impacts quantised to 16ths, molecule detection + VSEPR snap + silhouette flash, build-to-use payloads, membrane burn, the eight arenas, sandbox mode. + +Steps 1–5 are one week of evenings and they are already a game. Everything after is escalation. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..aeb340b --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# MOLLY COOL + +You are fourteen angstroms tall with a proton in one hand and a vacuum in the +other. Shoot charge across the room, wrestle atoms together until they snap, +and try to put out a chain reaction before it eats the field — and you. + +**Play:** `python3 serve.py` → http://localhost:5178 + +No dependencies. No build step. Vanilla JS + Canvas 2D. + +--- + +## Controls + +| | | +|---|---| +| **WASD** | move | +| **mouse** | aim | +| **left click** | proton bolt — pushes charge in (**+**) | +| **right click** | vacuum bolt — rips charge out (**−**) | +| **space** (hold) | grab two things and squeeze them together | +| **Q** | heat — area shove, breaks bonds | +| **R** | retry | +| **shift+A** | jump straight to CASCADE | + +Aim at your own feet to charge **yourself** — that's how you go neutral and +slip through a membrane. + +## The rules, none of which the game will tell you + +- **Opposites attract, likes repel.** Bolts are charged too, so they *curve* + through the field. Bank shots are real. +- **Give spends, take earns.** Eight protons orbit your hand. Fire them all + and you're dry — the left hand always works, for free. +- **Bonds can't form on their own.** Every pair has an activation barrier that + parks them at arm's length. Your hands are the only way over it. +- **The membrane blocks anything charged and ignores anything neutral.** It has + no guard and no keyhole. You get through by becoming the right kind of thing. +- **Sour air keeps shoving protons onto everything.** Including you. +- **Keep feeding one atom and it goes critical.** A fuse, then a blast that + charges everything nearby — which can tip *those* over. In a dense field one + seed can take the whole screen. +- **Radicals are the only pure white thing in the game.** They have no mind: + every 0.32s a radical grabs its nearest neighbour, completes itself, and + hands the wound on. You can't shoot one dead. You **terminate** it by + wrestling two together — radical pairs recombine barrierlessly, so it's fast. +- **Two radicals that drift into each other quench on their own.** Herding is + a legitimate tactic. + +## Structure + +Seven wordless tutorial rooms (the cold open), then **CASCADE** — eight arenas +of escalating density. Each arena spends a fixed number of ignition waves; put +the fire out and it clears. Let it burn and the field melts down. + +Density is the difficulty dial. Everything else follows from it. + +## Layout + +``` +src/config.js ALL feel constants. Tune the game here. +src/physics.js storm, charge, the activation barrier, zones, membrane +src/bonds.js the wrestle — grab, squeeze, SNAP +src/bolts.js laserhands +src/radicals.js the enemy +src/overload.js fuses, detonations, the cascade +src/arena.js CASCADE — waves, meltdown, clear +src/elements.js twelve elements: colour, valence, radius, mass +src/juice.js hitstop/trauma budget (a cascade must never freeze the screen) +test/*.test.mjs headless sim tests — `node test/sim.test.mjs` +``` + +## Tests + +``` +node test/sim.test.mjs core physics, radicals, molly, regression +node test/arena.test.mjs arena waves, win/lose, difficulty ramp +node test/overload.test.mjs fuses, cascade, juice budget, perf +``` + +The sim is seedable (`seedRandom`), so runs are reproducible. Tests drive the +real physics headlessly in Node — no browser, no canvas. + +## Tuning + +The three knobs that matter, in order: + +1. **`JITTER_STRENGTH`** — the life of the world. +2. **`SQUEEZE_RATE` / `BARRIER_FORCE`** — the wrestle. The whole game's feel in + two numbers. +3. **`SNAP_TRAUMA` / `HITSTOP`** — the payoff. + +Two constants have non-obvious constraints, documented where they live: +`BARRIER_FORCE` must stay above `MAX_CHARGE_FORCE` (or atoms bond themselves +and the wrestle is skipped), and `RAD_SPLIT_R` must stay above the field's +natural packing distance (or outbreaks never bloom). + +## Dev note + +`serve.py` strips a `/v/` prefix off request paths, and `index.html` +imports the entry under a fresh one each load. Browsers cache the *parsed* ES +module by URL, so without this you can edit a file, hard-reload, and still be +running yesterday's code. diff --git a/index.html b/index.html new file mode 100644 index 0000000..17c7129 --- /dev/null +++ b/index.html @@ -0,0 +1,69 @@ + + + + + +Molly Cool + + + + + +
+
+

MOLLY COOL

+

vertical slice

+ +
+
WASD move  ·  mouse aim
+
left click proton bolt  ·  right click vacuum bolt
+
space hold two things & squeeze  ·  Q heat  ·  R retry
+
aim at your own feet to charge yourself
+
keep feeding one atom and it goes critical
+
+
+
+ + + + diff --git a/serve.py b/serve.py new file mode 100644 index 0000000..a40a157 --- /dev/null +++ b/serve.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Dev server for Molly Cool. + +Plain http.server caches ES modules hard enough that you edit a file, +reload, and still run the old code — which costs more debugging time +than it has any right to. This just turns caching off. +""" +import sys +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer + + +import re + +VERSIONED = re.compile(r"^/v\d+/") + + +class NoCache(SimpleHTTPRequestHandler): + def translate_path(self, path): + # /v1234567/src/main.js -> /src/main.js + # + # index.html imports the entry under a fresh /v/ prefix each + # load, so every relative import inside the graph resolves to a URL the + # browser has never seen. No-cache headers alone don't do it: browsers + # cache the *parsed* module by URL, so an edited file can keep running + # its old code until the URL itself changes. + return super().translate_path(VERSIONED.sub("/", path)) + + def end_headers(self): + self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + super().end_headers() + + def log_message(self, fmt, *args): + pass # quiet + + +if __name__ == "__main__": + port = int(sys.argv[1]) if len(sys.argv) > 1 else 5178 + print(f"molly cool -> http://localhost:{port}") + ThreadingHTTPServer(("", port), NoCache).serve_forever() diff --git a/src/arena.js b/src/arena.js new file mode 100644 index 0000000..92dabc3 --- /dev/null +++ b/src/arena.js @@ -0,0 +1,181 @@ +import { CFG } from './config.js'; +import { rand, random, clamp } from './util.js'; +import { FX } from './fx.js'; +import { Audio } from './audio.js'; +import { Juice } from './juice.js'; +import { Radicals } from './radicals.js'; + +// ============================================================ +// CASCADE — the arena. +// +// The seven tutorial rooms are the cold open and the contrast. +// This is the game: a field of matter, an outbreak, and a curve +// that goes calm -> pressure -> false calm -> bloom. +// +// Density is the difficulty dial. Everything else follows from it. +// ============================================================ + +const FUEL = ['H', 'H', 'C', 'O', 'N', 'C', 'O', 'H']; +const SPICE = ['Na', 'Cl', 'S', 'Mg', 'Cu', 'Fe', 'K']; + +// waves = how many ignition events. A wave has a beginning and an end, so +// the arena is a fight you can finish rather than a faucet you stand under. +export const ARENAS = [ + { n: 34, spice: 0.00, born: 1, every: 8.0, waves: 3, name: 'ignition' }, + { n: 44, spice: 0.08, born: 1, every: 7.0, waves: 4 }, + { n: 54, spice: 0.14, born: 2, every: 6.5, waves: 4 }, + { n: 64, spice: 0.20, born: 2, every: 6.0, waves: 5 }, + { n: 76, spice: 0.26, born: 2, every: 5.5, waves: 5 }, + { n: 88, spice: 0.32, born: 3, every: 5.0, waves: 6 }, + { n: 100, spice: 0.38, born: 3, every: 4.5, waves: 6 }, + { n: 116, spice: 0.44, born: 4, every: 4.0, waves: 7, name: 'critical' }, +]; + +// You lose by NEGLECT, not by bad luck. The outbreak has to stay above this +// share of the field for MELTDOWN_HOLD seconds — a spike is survivable, a +// sustained fire is not. Tuned just under the unattended equilibrium (~18%), +// so standing still loses and competent play never dies to variance. +const MELTDOWN_FRAC = 0.16; +const MELTDOWN_HOLD = 6.0; + +export class Arena { + constructor() { + this.index = 0; + this.t = 0; + this.spawnT = 0; + this.cleared = false; + this.best = 0; + } + + spec() { return ARENAS[Math.min(this.index, ARENAS.length - 1)]; } + + // build the room definition for arena i + room(i) { + const s = ARENAS[Math.min(i, ARENAS.length - 1)]; + const W = CFG.W, H = CFG.H; + const pad = 90; + const particles = []; + + // deterministic-ish scatter with a minimum separation so the field + // starts at its natural packing distance instead of exploding on frame 1 + const placed = []; + let guard = 0; + while (particles.length < s.n && guard++ < s.n * 60) { + const x = pad + random() * (W - pad * 2); + const y = pad + random() * (H - pad * 2); + let ok = true; + for (const p of placed) { + if (Math.hypot(p.x - x, p.y - y) < 52) { ok = false; break; } + } + if (!ok) continue; + placed.push({ x, y }); + const el = random() < s.spice + ? SPICE[Math.floor(random() * SPICE.length)] + : FUEL[Math.floor(random() * FUEL.length)]; + particles.push({ x, y, el }); + } + + return { + id: `arena-${i}`, + ambient: 'neutral', + arena: true, + arenaIndex: i, + bounds: { x: 0, y: 0, w: W, h: H }, + molly: { x: W / 2, y: H - 120 }, + particles, + zones: [], + membranes: [], + exit: null, + }; + } + + enter(world, i) { + this.index = i; + this.t = 0; + this.spawnT = 0; + this.wave = 0; + this.cleared = false; + this.meltdown = false; + this.critT = 0; + this._lastWarn = -1; + this.fieldSize = world.particles.length; + world.arena = this; + } + + // ignite: pick an atom far from Molly so the first one is never a cheap shot + ignite(world, count = 1) { + const m = world.molly; + const cands = world.particles + .filter(p => !p.radical && !p.noble) + .map(p => ({ p, d: Math.hypot(p.x - m.x, p.y - m.y) })) + .sort((a, b) => b.d - a.d); + for (let k = 0; k < count && k < cands.length; k++) { + const p = cands[Math.floor(random() * Math.min(8, cands.length))].p; + if (Radicals.make(p)) { + FX.ring(p.x, p.y, 6, 120, '#FFFFFF', 0.5, 3); + Juice.trauma(world.camera, 0.25); + Audio.light(); + } + } + } + + update(world, dt) { + const s = this.spec(); + if (this.cleared || this.meltdown) return; + + this.t += dt; + this.spawnT += dt; + + const live = world.radicals.count; + this.best = Math.max(this.best, world.radicals.chain); + + // ---- the curve ---- + // ignite quickly the first time (nobody wants to stare at a quiet room), + // then on a timer, for a FIXED number of waves + const firstDelay = 2.4; + const due = this.wave === 0 ? this.t >= firstDelay : this.spawnT >= s.every; + if (this.wave < s.waves && due) { + this.spawnT = 0; + this.wave++; + this.ignite(world, s.born); + } + + // ---- MELTDOWN: sustained neglect. ---- + const limit = Math.max(4, Math.ceil(this.fieldSize * MELTDOWN_FRAC)); + if (live >= limit) { + this.critT = (this.critT || 0) + dt; + // the room screams before it kills you — never a surprise + if (this.critT > 1 && Math.floor(this.critT * 3) !== this._lastWarn) { + this._lastWarn = Math.floor(this.critT * 3); + Juice.trauma(world.camera, 0.18); + } + } else { + this.critT = 0; + } + if (this.critT > MELTDOWN_HOLD) { + this.meltdown = true; + Juice.trauma(world.camera, 1); + FX.screenFlash('#FF3355', 0.7); + Audio.thud(); + return; + } + + // ---- CLEARED: every wave spent and the field is quiet again ---- + if (this.wave >= s.waves && live === 0 && this.t > 4) { + this.cleared = true; + Audio.chime(); + FX.screenFlash('#7FE3C4', 0.45); + } + } + + // how far through the wave budget we are, 0..1 + progress() { + const s = this.spec(); + return clamp(this.wave / s.waves, 0, 1); + } + + // 0..1 how hot the room is — drives the colour grade and the music + heat(world) { + return clamp(world.radicals.count / 18, 0, 1); + } +} diff --git a/src/audio.js b/src/audio.js new file mode 100644 index 0000000..99e64b2 --- /dev/null +++ b/src/audio.js @@ -0,0 +1,219 @@ +import { CFG } from './config.js'; +import { clamp } from './util.js'; + +// Everything synthesized at runtime. Zero audio files. +class AudioEngine { + constructor() { this.ctx = null; this.ready = false; } + + init() { + if (this.ready) return; + const AC = window.AudioContext || window.webkitAudioContext; + if (!AC) return; + this.ctx = new AC(); + this.master = this.ctx.createGain(); + this.master.gain.value = CFG.MASTER_GAIN; + this.master.connect(this.ctx.destination); + + // shared reverb-ish tail + this.conv = this.ctx.createConvolver(); + this.conv.buffer = this._impulse(1.1, 2.6); + this.wet = this.ctx.createGain(); + this.wet.gain.value = 0.24; + this.conv.connect(this.wet).connect(this.master); + + // --- ambient drone (per zone) --- + this.amb = this.ctx.createGain(); + this.amb.gain.value = 0; + this.ambFilter = this.ctx.createBiquadFilter(); + this.ambFilter.type = 'lowpass'; + this.ambFilter.frequency.value = 320; + this.ambOsc = this.ctx.createOscillator(); + this.ambOsc.type = 'sawtooth'; + this.ambOsc.frequency.value = 44; + const ambNoise = this._noiseSource(true); + const nGain = this.ctx.createGain(); nGain.gain.value = 0.09; + this.ambOsc.connect(this.ambFilter); + ambNoise.connect(nGain).connect(this.ambFilter); + this.ambFilter.connect(this.amb).connect(this.master); + this.ambOsc.start(); + + // --- hold hum (the wrestle tell) --- + this.humGain = this.ctx.createGain(); + this.humGain.gain.value = 0; + this.humOsc = this.ctx.createOscillator(); + this.humOsc.type = 'sawtooth'; + this.humOsc.frequency.value = 90; + this.humFilter = this.ctx.createBiquadFilter(); + this.humFilter.type = 'bandpass'; + this.humFilter.frequency.value = 400; + this.humFilter.Q.value = 3; + this.humOsc.connect(this.humFilter).connect(this.humGain).connect(this.master); + this.humOsc.start(); + + this.ready = true; + } + + resume() { if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume(); } + + _impulse(dur, decay) { + const rate = this.ctx.sampleRate; + const n = Math.floor(rate * dur); + const buf = this.ctx.createBuffer(2, n, rate); + for (let c = 0; c < 2; c++) { + const d = buf.getChannelData(c); + for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / n, decay); + } + return buf; + } + + _noiseSource(loop = false) { + const rate = this.ctx.sampleRate; + const buf = this.ctx.createBuffer(1, rate * 2, rate); + const d = buf.getChannelData(0); + for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; + const src = this.ctx.createBufferSource(); + src.buffer = buf; src.loop = true; + if (loop) src.start(); + return src; + } + + // ---- one-shots ---- + + // short blip; pitch UP for give, DOWN for take + pop(up = true) { + if (!this.ready) return; + const t = this.ctx.currentTime; + const o = this.ctx.createOscillator(); + const g = this.ctx.createGain(); + o.type = 'sine'; + o.frequency.setValueAtTime(up ? 420 : 700, t); + o.frequency.exponentialRampToValueAtTime(up ? 880 : 300, t + 0.09); + g.gain.setValueAtTime(0.0001, t); + g.gain.exponentialRampToValueAtTime(0.5, t + 0.006); + g.gain.exponentialRampToValueAtTime(0.0001, t + 0.13); + o.connect(g).connect(this.master); + g.connect(this.conv); + o.start(t); o.stop(t + 0.16); + } + + // THE money sound: 80 -> 50 Hz sine sweep + filtered noise burst + snap() { + if (!this.ready) return; + const t = this.ctx.currentTime; + + const o = this.ctx.createOscillator(); + const g = this.ctx.createGain(); + o.type = 'sine'; + o.frequency.setValueAtTime(80, t); + o.frequency.exponentialRampToValueAtTime(50, t + 0.12); + g.gain.setValueAtTime(0.0001, t); + g.gain.exponentialRampToValueAtTime(1.0, t + 0.008); + g.gain.exponentialRampToValueAtTime(0.0001, t + 0.42); + o.connect(g).connect(this.master); + g.connect(this.conv); + o.start(t); o.stop(t + 0.45); + + const n = this._noiseSource(); + const nf = this.ctx.createBiquadFilter(); + nf.type = 'bandpass'; nf.Q.value = 1.1; + nf.frequency.setValueAtTime(2400, t); + nf.frequency.exponentialRampToValueAtTime(320, t + 0.16); + const ng = this.ctx.createGain(); + ng.gain.setValueAtTime(0.55, t); + ng.gain.exponentialRampToValueAtTime(0.0001, t + 0.2); + n.connect(nf).connect(ng).connect(this.master); + ng.connect(this.conv); + n.start(t); n.stop(t + 0.24); + } + + thud() { + if (!this.ready) return; + const t = this.ctx.currentTime; + const n = this._noiseSource(); + const f = this.ctx.createBiquadFilter(); + f.type = 'lowpass'; f.frequency.value = 260; + const g = this.ctx.createGain(); + g.gain.setValueAtTime(0.32, t); + g.gain.exponentialRampToValueAtTime(0.0001, t + 0.13); + n.connect(f).connect(g).connect(this.master); + n.start(t); n.stop(t + 0.15); + } + + heat() { + if (!this.ready) return; + const t = this.ctx.currentTime; + const n = this._noiseSource(); + const f = this.ctx.createBiquadFilter(); + f.type = 'lowpass'; + f.frequency.setValueAtTime(4200, t); + f.frequency.exponentialRampToValueAtTime(200, t + 0.4); + const g = this.ctx.createGain(); + g.gain.setValueAtTime(0.5, t); + g.gain.exponentialRampToValueAtTime(0.0001, t + 0.45); + n.connect(f).connect(g).connect(this.master); + g.connect(this.conv); + n.start(t); n.stop(t + 0.5); + } + + light() { + if (!this.ready) return; + const t = this.ctx.currentTime; + const o = this.ctx.createOscillator(); + const g = this.ctx.createGain(); + o.type = 'triangle'; + o.frequency.setValueAtTime(1800, t); + o.frequency.exponentialRampToValueAtTime(3600, t + 0.07); + g.gain.setValueAtTime(0.0001, t); + g.gain.exponentialRampToValueAtTime(0.22, t + 0.004); + g.gain.exponentialRampToValueAtTime(0.0001, t + 0.1); + o.connect(g).connect(this.master); + g.connect(this.conv); + o.start(t); o.stop(t + 0.12); + } + + chime() { + if (!this.ready) return; + const t = this.ctx.currentTime; + [523.25, 659.25, 783.99].forEach((f, i) => { + const o = this.ctx.createOscillator(); + const g = this.ctx.createGain(); + o.type = 'sine'; o.frequency.value = f; + const s = t + i * 0.07; + g.gain.setValueAtTime(0.0001, s); + g.gain.exponentialRampToValueAtTime(0.24, s + 0.01); + g.gain.exponentialRampToValueAtTime(0.0001, s + 0.9); + o.connect(g).connect(this.master); + g.connect(this.conv); + o.start(s); o.stop(s + 1.0); + }); + } + + // ---- continuous ---- + + // effort 0..1 — climbs as the two fight through the barrier + setHum(effort) { + if (!this.ready) return; + const t = this.ctx.currentTime; + const e = clamp(effort, 0, 1); + this.humGain.gain.setTargetAtTime(e * 0.16, t, 0.03); + this.humOsc.frequency.setTargetAtTime(80 + e * 210, t, 0.04); + this.humFilter.frequency.setTargetAtTime(300 + e * 1500, t, 0.05); + } + + setAmbience(kind) { + if (!this.ready) return; + const t = this.ctx.currentTime; + const map = { + none: { g: 0.05, f: 240, p: 44 }, + neutral: { g: 0.07, f: 300, p: 46 }, + sour: { g: 0.13, f: 620, p: 62 }, + soapy: { g: 0.10, f: 380, p: 38 }, + }; + const m = map[kind] || map.none; + this.amb.gain.setTargetAtTime(m.g, t, 0.5); + this.ambFilter.frequency.setTargetAtTime(m.f, t, 0.5); + this.ambOsc.frequency.setTargetAtTime(m.p, t, 0.5); + } +} + +export const Audio = new AudioEngine(); diff --git a/src/bolts.js b/src/bolts.js new file mode 100644 index 0000000..5621db2 --- /dev/null +++ b/src/bolts.js @@ -0,0 +1,234 @@ +import { CFG } from './config.js'; +import { clamp, segDist, rgba } from './util.js'; +import { FX } from './fx.js'; +import { Audio } from './audio.js'; +import { Juice } from './juice.js'; +import { Overload } from './overload.js'; + +// ============================================================ +// LASERHANDS. +// Tap right = PROTON BOLT (+1). Tap left = VACUUM BOLT (-1). +// The bolt never deals damage. Its payload is CHARGE. +// The moment a bolt does HP, the chemistry becomes set dressing. +// ============================================================ + +export class Bolts { + constructor() { + this.list = []; + this.ammo = CFG.AMMO_MAX; + this.ringSpin = 0; + this.muzzle = 0; + this.handKick = 0; + this.dryClick = 0; + } + + reset() { + this.list.length = 0; + this.ammo = CFG.AMMO_MAX; + this.muzzle = 0; + this.handKick = 0; + } + + fire(world, sign) { + const m = world.molly; + if (m.cool > 0) return false; + + // give SPENDS a proton. take EARNS one back. + if (sign > 0 && this.ammo <= 0) { + this.dryClick = 0.16; + Audio.pop(false); + return false; + } + + let dx = m.aim.x - m.x, dy = m.aim.y - m.y; + const d = Math.hypot(dx, dy) || 1; + dx /= d; dy /= d; + + // spawn just outside her body so it reads as leaving the hand + this.list.push({ + x: m.x + dx * (m.r + 6), + y: m.y + dy * (m.r + 6), + vx: dx * CFG.BOLT_SPEED, + vy: dy * CFG.BOLT_SPEED, + sign, + life: CFG.BOLT_LIFE, + trail: [], + }); + + if (sign > 0) this.ammo--; + m.cool = CFG.BOLT_COOLDOWN; + + // ---- the shot feel ---- + this.muzzle = CFG.MUZZLE_LIFE; + this.handKick = CFG.HAND_KICK; + this.muzzleDir = { x: dx, y: dy }; + + // give shoves her back, take tugs her forward — opposite body language + const rec = sign > 0 ? -CFG.BOLT_RECOIL : CFG.BOLT_PULL; + m.vx += dx * rec; m.vy += dy * rec; + + Juice.trauma(world.camera, 0.06); + Audio.pop(sign > 0); + FX.burst(m.x + dx * (m.r + 6), m.y + dy * (m.r + 6), 4, + sign > 0 ? CFG.COL_POS : CFG.COL_NEG, 220, 0.16); + return true; + } + + // a proton flies home and rejoins the ring + _refund(world, x, y) { + if (this.ammo >= CFG.AMMO_MAX) return; + this.ammo++; + FX.burst(x, y, 3, CFG.COL_POS, 160, 0.3); + } + + update(world, dt) { + const m = world.molly; + this.ringSpin += dt * CFG.AMMO_SPIN * (1 + (1 - this.ammo / CFG.AMMO_MAX) * 2.2); + this.muzzle = Math.max(0, this.muzzle - dt); + this.handKick = Math.max(0, this.handKick - dt * 90); + this.dryClick = Math.max(0, this.dryClick - dt); + + for (let i = this.list.length - 1; i >= 0; i--) { + const b = this.list[i]; + b.life -= dt; + if (b.life <= 0) { this.list.splice(i, 1); continue; } + + // ---- bolts are CHARGED BODIES: they curve through the field ---- + // (reuses the Coulomb solver already running. bank shots are a skill.) + for (const p of world.particles) { + if (!p.charged) continue; + const dx = p.x - b.x, dy = p.y - b.y; + const d2 = dx * dx + dy * dy; + if (d2 > CFG.CHARGE_RANGE * CFG.CHARGE_RANGE || d2 < 1) continue; + const dd = Math.max(Math.sqrt(d2), CFG.D_MIN); + let f = (CFG.K_CHARGE * CFG.BOLT_CURVE * b.sign * p.charge) / (dd * dd); + f = clamp(f, -CFG.MAX_CHARGE_FORCE, CFG.MAX_CHARGE_FORCE); + const n = Math.sqrt(d2); + b.vx -= (dx / n) * f * dt; + b.vy -= (dy / n) * f * dt; + } + + const px = b.x, py = b.y; + b.x += b.vx * dt; + b.y += b.vy * dt; + + b.trail.push({ x: b.x, y: b.y }); + if (b.trail.length > 6) b.trail.shift(); + + // ---- the membrane blocks bolts (they're charged!) ---- + let died = false; + for (const mem of world.membranes) { + const s = segDist(b.x, b.y, mem.a.x, mem.a.y, mem.b.x, mem.b.y); + if (s.d < CFG.MEM_THICK * 0.6) { + FX.burst(s.cx, s.cy, 9, CFG.COL_MEM, 240, 0.35); + FX.ring(s.cx, s.cy, 2, 26, CFG.COL_MEM, 0.26, 2); + Audio.thud(); + this.list.splice(i, 1); + died = true; + break; + } + } + if (died) continue; + + // ---- hit test: SWEPT, not point ---- + // At 940 px/s on a 1/120 step a bolt moves 7.8px/frame, and hydrogen is + // r7. A point test tunnels straight through small atoms — you aim dead-on + // and watch it phase by. Test the whole segment travelled this frame. + for (const p of world.particles) { + const hitR = p.r + CFG.BOLT_R; + const s = segDist(p.x, p.y, px, py, b.x, b.y); + if (s.d > hitR) continue; + // snap the bolt back to the impact point so sparks land on the surface + b.x = s.cx; b.y = s.cy; + const dx = p.x - b.x, dy = p.y - b.y; + + const changed = p.setCharge(p.charge + b.sign); + // charge display clamps at +-1, but the OVERLOAD meter keeps counting. + // keep feeding one atom and you build a bomb. + if (!p.noble) Overload.push(p, b.sign); + const col = b.sign > 0 ? CFG.COL_POS : CFG.COL_NEG; + + if (changed || p.fuse) { + Juice.hitstop(CFG.BOLT_HITSTOP); + Juice.trauma(world.camera, CFG.BOLT_TRAUMA); + Audio.pop(b.sign > 0); + FX.screenFlash(col, 0.16); + // take rips a proton loose — it flies home to the ring + if (b.sign < 0) this._refund(world, p.x, p.y); + } else { + // saturated: it bounces off with a dull tap + Audio.thud(); + } + + // impact sparks along the normal + const n = Math.hypot(dx, dy) || 1; + FX.burst(b.x, b.y, CFG.BOLT_SPARKS, col, 380, 0.4); + FX.ring(p.x, p.y, p.r, p.r + 26, col, 0.24, 2); + // knock it back a little — a shot should MOVE things + p.addImpulse((dx / n) * 130, (dy / n) * 130); + + this.list.splice(i, 1); + died = true; + break; + } + if (died) continue; + + // out of bounds + const B = world.bounds; + if (b.x < B.x - 40 || b.x > B.x + B.w + 40 || b.y < B.y - 40 || b.y > B.y + B.h + 40) { + this.list.splice(i, 1); + } + } + } + + draw(ctx, world) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + + for (const b of this.list) { + const col = b.sign > 0 ? CFG.COL_POS : CFG.COL_NEG; + // tracer + if (b.trail.length > 1) { + ctx.strokeStyle = rgba(col, 0.5); + ctx.lineWidth = 2.4; + ctx.beginPath(); + ctx.moveTo(b.trail[0].x, b.trail[0].y); + for (const t of b.trail) ctx.lineTo(t.x, t.y); + ctx.stroke(); + } + // head + const g = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, CFG.BOLT_R * 4); + g.addColorStop(0, rgba('#ffffff', 0.95)); + g.addColorStop(0.35, rgba(col, 0.8)); + g.addColorStop(1, rgba(col, 0)); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(b.x, b.y, CFG.BOLT_R * 4, 0, Math.PI * 2); ctx.fill(); + } + + // ---- muzzle flash ---- + const m = world.molly; + if (this.muzzle > 0 && this.muzzleDir) { + const a = this.muzzle / CFG.MUZZLE_LIFE; + const mx = m.x + this.muzzleDir.x * (m.r + 4); + const my = m.y + this.muzzleDir.y * (m.r + 4); + const g = ctx.createRadialGradient(mx, my, 0, mx, my, 26 * a); + g.addColorStop(0, rgba('#ffffff', 0.85 * a)); + g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(mx, my, 26 * a, 0, Math.PI * 2); ctx.fill(); + } + + // ---- the ammo ring: 8 pips, spins faster as it empties (nervous) ---- + for (let i = 0; i < CFG.AMMO_MAX; i++) { + const ang = this.ringSpin + (i / CFG.AMMO_MAX) * Math.PI * 2; + const R = CFG.AMMO_RING_R + (this.dryClick > 0 ? Math.sin(this.dryClick * 60) * 3 : 0); + const x = m.x + Math.cos(ang) * R; + const y = m.y + Math.sin(ang) * R; + const has = i < this.ammo; + ctx.fillStyle = has ? rgba(CFG.COL_POS, 0.9) : rgba(CFG.COL_NEU, 0.13); + ctx.beginPath(); ctx.arc(x, y, has ? 2.2 : 1.3, 0, Math.PI * 2); ctx.fill(); + } + + ctx.restore(); + } +} diff --git a/src/bonds.js b/src/bonds.js new file mode 100644 index 0000000..7c24ec0 --- /dev/null +++ b/src/bonds.js @@ -0,0 +1,201 @@ +import { CFG } from './config.js'; +import { clamp } from './util.js'; +import { FX } from './fx.js'; +import { Audio } from './audio.js'; +import { Input, setRumble, pulseRumble } from './input.js'; +import { bond } from './particle.js'; +import { barrierHump, lockDist, bondLen } from './physics.js'; + +// ============================================================ +// THE SIGNATURE MECHANIC +// Hold two atoms. Squeeze them through the activation barrier. +// The challenge is the HOLDING, not the letting go. +// Cross BOND_LOCK_DIST -> SNAP. +// ============================================================ + +export class BondsSystem { + constructor() { + this.sep = 0; // current anchor separation while squeezing + this.effort = 0; // 0..1 — drives rumble + hum + this.axis = { x: 1, y: 0 }; + } + + reset() { this.sep = 0; this.effort = 0; } + + _held(molly) { + return [molly.hands.L, molly.hands.R].filter(Boolean); + } + + _acquire(world) { + const m = world.molly; + for (const key of ['L', 'R']) { + if (m.hands[key]) continue; + let best = null, bestD = CFG.GRAB_RANGE; + for (const p of world.particles) { + if (p.grabbedBy || p.fixed) continue; + let d = Math.hypot(p.x - m.aim.x, p.y - m.aim.y); + // radicals grab preferentially — when one is closing on you, you + // should not have to fight the targeting to get hold of it + if (p.radical) d *= 0.55; + if (d < bestD) { bestD = d; best = p; } + } + if (best) { + best.grabbedBy = key; + m.hands[key] = best; + FX.burst(best.x, best.y, 4, CFG.COL_MOLLY, 90, 0.25); + // reset the squeeze whenever the hand set changes + this.sep = 0; + } else break; + } + } + + _releaseAll(molly) { + for (const key of ['L', 'R']) { + const p = molly.hands[key]; + if (p) { p.grabbedBy = null; molly.hands[key] = null; } + } + this.sep = 0; + this.effort = 0; + } + + update(world, dt) { + const m = world.molly; + + if (Input.grab) this._acquire(world); + else if (m.hands.L || m.hands.R) this._releaseAll(m); + + const held = this._held(m); + if (held.length === 0) { + this.effort = 0; + Audio.setHum(0); + setRumble(0); + return; + } + + // ---- hand anchors ------------------------------------------------- + let anchors; + if (held.length === 1) { + anchors = [{ x: m.aim.x, y: m.aim.y }]; + } else { + const [a, b] = held; + let ax = b.x - a.x, ay = b.y - a.y; + const al = Math.hypot(ax, ay) || 1; + // smooth the squeeze axis so it doesn't jitter + const k = 1 - Math.exp(-14 * dt); + this.axis.x += ((ax / al) - this.axis.x) * k; + this.axis.y += ((ay / al) - this.axis.y) * k; + const nl = Math.hypot(this.axis.x, this.axis.y) || 1; + this.axis.x /= nl; this.axis.y /= nl; + + if (this.sep === 0) this.sep = clamp(al, lockDist(a, b) * 0.8, CFG.BOND_ATTEMPT_RANGE * 1.9); + // HOLDING squeezes them together. This is the verb. + const rate = CFG.SQUEEZE_RATE * (a.radical && b.radical ? CFG.RAD_SQUEEZE_MUL : 1); + this.sep = Math.max(0, this.sep - rate * dt); + + anchors = [ + { x: m.aim.x - this.axis.x * this.sep / 2, y: m.aim.y - this.axis.y * this.sep / 2 }, + { x: m.aim.x + this.axis.x * this.sep / 2, y: m.aim.y + this.axis.y * this.sep / 2 }, + ]; + } + + // ---- spring each held particle to its anchor ---------------------- + held.forEach((p, i) => { + const an = anchors[i]; + const dx = an.x - p.x, dy = an.y - p.y; + const fx = dx * CFG.HAND_SPRING - p.vx * CFG.HAND_DAMP; + const fy = dy * CFG.HAND_SPRING - p.vy * CFG.HAND_DAMP; + p.addForce(fx, fy, dt); + }); + + // ---- THE BARRIER -------------------------------------------------- + this.effort = 0; + if (held.length === 2) { + const [a, b] = held; + const canBond = a.freeSlots > 0 && b.freeSlots > 0 && !a.isBondedTo(b); + const dx = b.x - a.x, dy = b.y - a.y; + const d = Math.hypot(dx, dy) || 1e-4; + const nx = dx / d, ny = dy / d; + + // two radicals ALWAYS pair, slots or not — recombination doesn't + // care about valence. this is the kill move. + const bothRad = a.radical && b.radical; + + if (canBond || bothRad) { + // mutual pull once they're in the neighbourhood + if (d < CFG.BOND_ATTEMPT_RANGE) { + const pull = CFG.BOND_PULL * (1 - d / CFG.BOND_ATTEMPT_RANGE) * (bothRad ? 1.8 : 1); + a.addForce(nx * pull, ny * pull, dt); + b.addForce(-nx * pull, -ny * pull, dt); + } + + // the barrier itself is applied globally in physics.js — we only + // READ it here, to drive the rumble and the hum. + const lock = lockDist(a, b); + this.effort = d <= lock ? 1 : barrierHump(d, a, b); + + if (d <= lock) { + if (bothRad) { + world.radicals.terminate(world, a, b); + this._releaseAll(m); + Audio.setHum(0); + setRumble(0); + this.effort = 0; + } else { + this._snap(world, a, b); + } + return; + } + } + } + + Audio.setHum(this.effort); + setRumble(this.effort * 0.85); + } + + _snap(world, a, b) { + const m = world.molly; + const cx = (a.x + b.x) / 2, cy = (a.y + b.y) / 2; + + bond(a, b); + this._releaseAll(m); + + // settle them at bond length so the spring doesn't explode + const dx = b.x - a.x, dy = b.y - a.y; + const d = Math.hypot(dx, dy) || 1; + const nx = dx / d, ny = dy / d; + const L = bondLen(a, b); + a.x = cx - nx * L / 2; a.y = cy - ny * L / 2; + b.x = cx + nx * L / 2; b.y = cy + ny * L / 2; + + // ---- the payoff ---- + FX.freeze(CFG.HITSTOP); + world.camera.addTrauma(CFG.SNAP_TRAUMA); + Audio.snap(); + Audio.setHum(0); + pulseRumble(1, 220); + + const col = a.charge > 0 || b.charge > 0 ? CFG.COL_POS : CFG.COL_NEG; + FX.burst(cx, cy, CFG.SNAP_SPARKS, col, 520, 0.6); + FX.burst(cx, cy, 10, '#ffffff', 300, 0.35); + FX.ring(cx, cy, 6, CFG.SNAP_SHOCK_RADIUS, '#ffffff', 0.4, 4); + FX.ring(cx, cy, 2, 70, col, 0.3, 3); + FX.screenFlash('#ffffff', 0.4); + + m.knockback(cx, cy, CFG.SNAP_KNOCKBACK); + + // the shockwave is a TOOL, not just juice — it scatters things + for (const p of world.particles) { + if (p === a || p === b || p.fixed) continue; + const ddx = p.x - cx, ddy = p.y - cy; + const dd = Math.hypot(ddx, ddy); + if (dd > CFG.SNAP_SHOCK_RADIUS || dd < 1e-4) continue; + const falloff = 1 - dd / CFG.SNAP_SHOCK_RADIUS; + p.addImpulse((ddx / dd) * CFG.SNAP_SHOCKWAVE * falloff, + (ddy / dd) * CFG.SNAP_SHOCKWAVE * falloff); + } + + world.onSnap && world.onSnap(a, b, cx, cy); + this.effort = 0; + setRumble(0); + } +} diff --git a/src/camera.js b/src/camera.js new file mode 100644 index 0000000..7b316f6 --- /dev/null +++ b/src/camera.js @@ -0,0 +1,50 @@ +import { CFG } from './config.js'; +import { clamp, lerp } from './util.js'; + +// Follow camera + trauma-model screenshake. +// (offset = MAX_SHAKE * trauma^2 * noise — "The Art of Screenshake") +export class Camera { + constructor() { + this.x = CFG.W / 2; this.y = CFG.H / 2; + this.trauma = 0; + this.t = 0; + this.shakeX = 0; this.shakeY = 0; this.rot = 0; + this.scale = 1; + this.targetScale = 1; + this.viewW = CFG.W; this.viewH = CFG.H; + } + + addTrauma(v) { this.trauma = clamp(this.trauma + v, 0, 1); } + + follow(tx, ty, dt) { + const k = 1 - Math.exp(-CFG.CAM_LERP * dt); + this.x = lerp(this.x, tx, k); + this.y = lerp(this.y, ty, k); + } + + update(dt) { + this.t += dt; + this.trauma = Math.max(0, this.trauma - CFG.TRAUMA_DECAY * dt); + const s = this.trauma * this.trauma; + const n = (a, b) => Math.sin(this.t * a + b) * Math.sin(this.t * b * 1.7 + a); + this.shakeX = CFG.MAX_SHAKE * s * n(61, 13); + this.shakeY = CFG.MAX_SHAKE * s * n(47, 29); + this.rot = CFG.MAX_ROT * s * n(37, 7); + this.scale = lerp(this.scale, this.targetScale, 1 - Math.exp(-3 * dt)); + } + + apply(ctx) { + ctx.translate(CFG.W / 2, CFG.H / 2); + ctx.rotate(this.rot); + ctx.scale(this.scale, this.scale); + ctx.translate(-this.x + this.shakeX, -this.y + this.shakeY); + } + + screenToWorld(sx, sy) { + // inverse of apply() ignoring rotation (small angle; fine for aiming) + return { + x: (sx - CFG.W / 2) / this.scale + this.x - this.shakeX, + y: (sy - CFG.H / 2) / this.scale + this.y - this.shakeY, + }; + } +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..ac1535c --- /dev/null +++ b/src/config.js @@ -0,0 +1,181 @@ +// ============================================================ +// ALL FEEL LIVES HERE. Tune the game by editing this file only. +// +// The three knobs that matter most, in order: +// 1. JITTER_STRENGTH — the life of the world +// 2. the bond barrier — BARRIER_*, BOND_LOCK_DIST — the wrestle +// 3. snap juice — SNAP_TRAUMA, HITSTOP, SNAP_SHOCKWAVE +// ============================================================ + +export const CFG = { + // ---- world ---- + W: 1280, + H: 720, + DT: 1 / 120, // fixed sim step + MAX_FRAME: 0.25, // clamp huge tab-switch deltas + + // ---- molly ---- + MOLLY_RADIUS: 14, + MOLLY_ACCEL: 3000, + MOLLY_MAX_SPEED: 260, + MOLLY_DAMP: 0.004, // fraction of velocity left after 1s + MOLLY_JITTER: 26, // she never sits perfectly still + + // ---- particles / the storm ---- + P_RADIUS: 11, + JITTER_STRENGTH: 90, // px/s per sqrt(s) — Brownian, framerate-independent + P_DAMP: 0.02, // fraction of velocity left after 1s + COLLIDE_PUSH: 0.5, // overlap resolution strength + + // ---- charge interaction ---- + CHARGE_RANGE: 190, + K_CHARGE: 1.2e7, // F = K*q1*q2/d^2 + D_MIN: 26, // clamp denominator + MAX_CHARGE_FORCE: 3000, + DISPLAY_LERP: 9, // charge color/shape morph speed (per second) + + // ---- hands / grab ---- + GRAB_RANGE: 130, + AIM_DIST: 92, // reticle offset from molly (gamepad) + HAND_SPRING: 500, + HAND_DAMP: 30, + CHARGE_COOLDOWN: 0.16, + + // ---- THE BOND WRESTLE (the heart of the game) ---- + SQUEEZE_RATE: 42, // px/s the hands close while you HOLD. the verb. + // Radical pairs recombine barrierlessly, so the SQUEEZE has to be fast too — + // dropping the barrier alone doesn't help when the hands are the bottleneck. + // 1.8s is a wonderful puzzle payoff and a fatal combat move. + RAD_SQUEEZE_MUL: 3.4, + BOND_ATTEMPT_RANGE: 62, // where mutual attraction kicks in while held + BOND_PULL: 900, // attraction while wrestling + BARRIER_OUTER: 46, // barrier band starts here... + // ...and peaks halfway to BOND_LOCK_DIST. + // MUST stay > MAX_CHARGE_FORCE, or opposites bond themselves and the + // wrestle — the whole game — gets skipped. The barrier is why Molly matters. + BARRIER_FORCE: 3400, + // Bond distances SCALE WITH ATOM SIZE. A flat lock distance is a trap: + // two carbons (r13) can never get closer than 26px because collision + // stops them, so a flat 22px lock means carbon can never bond at all. + // Big atoms bond further out — which is also just true. + LOCK_MUL: 0.92, // lock distance = (ra + rb) * this + BOND_LEN_MUL: 0.95, // resting bond length = (ra + rb) * this + BARRIER_BAND: 24, // barrier runs from lock -> lock + this + BOND_LOCK_DIST: 22, // fallback only + BOND_LENGTH: 25, // fallback only + BOND_SPRING: 560, + BOND_DAMP: 16, + BOND_BREAK_DIST: 74, + + // ---- snap payoff ---- + SNAP_TRAUMA: 0.62, + HITSTOP: 0.05, + SNAP_KNOCKBACK: 340, // shove on molly + SNAP_SHOCKWAVE: 620, // radial impulse on nearby particles (this is a TOOL) + SNAP_SHOCK_RADIUS: 170, + SNAP_SPARKS: 26, + + // ---- zones ---- + ZONE_FLIP_TIME: 1.2, // seconds of sour air before a neutral thing goes + + ZONE_GRACE: 0.35, // timer reset after player acts on a particle + + // ---- membrane ---- + MEM_THICK: 24, + MEM_FORCE: 6000, + MEM_BOUNCE_TRAUMA: 0.14, + + // ---- LASERHANDS ---- + // Tap = bolt. Travel time, never hitscan — travel is what reads as "pyew". + BOLT_SPEED: 940, + BOLT_R: 4, + BOLT_LIFE: 1.8, + BOLT_COOLDOWN: 0.16, + BOLT_HITSTOP: 45, // ms, requested through the juice budget + BOLT_TRAUMA: 0.15, + BOLT_RECOIL: 40, // molly shoved backward by give + BOLT_PULL: 30, // molly tugged forward by take + BOLT_CURVE: 0.30, // bolts are charged bodies -> they BEND round atoms + BOLT_SPARKS: 8, + HAND_KICK: 6, // px the hand sprite kicks back + MUZZLE_LIFE: 0.09, + + // give SPENDS, take EARNS. you physically cannot spam one hand. + AMMO_MAX: 8, + AMMO_RING_R: 18, + AMMO_SPIN: 1.2, + + // ---- OVERCHARGE / DETONATION ---- + // Charge clamps at +-1 for handling, but you can OVERLOAD past it. + // Cram 3 into one atom and it goes critical: a fuse, then a blast that + // charges everything nearby — which can push THOSE past 3 too. Cascade. + OVERLOAD_MAX: 3, + FUSE_TIME: 0.9, + BLAST_R: 130, + BLAST_IMPULSE: 700, + BLAST_HITSTOP: 160, + BLAST_TRAUMA: 0.62, + CHAIN_FUSE_MUL: 0.5, // a fuse lit by a blast burns faster + + // ---- THE RADICAL ---- + RAD_TICK: 0.32, // deterministic hop interval. NEVER per-frame random. + RAD_REACH: 70, // how far it can grab + // 2nd victim this close -> it takes BOTH, and branch factor crosses 1. + // MUST exceed the field's natural packing distance. The barrier repels every + // bondable pair out to (lock + BARRIER_BAND) ~= 48px, so atoms self-space + // around there; a split radius below that can never fire and the outbreak + // would only ever crawl instead of blooming. + RAD_SPLIT_R: 58, + RAD_TELEGRAPH: 0.2, + RAD_CAP: 120, + RAD_KILL_HITSTOP: 130, + RAD_KILL_TRAUMA: 0.7, + RAD_QUENCH_R: 150, // annihilation puts out everything in this radius + RAD_BARRIER_MUL: 0.15, // radical-radical is barrierless -> fast wrestle + + // ---- molly's life ---- + MOLLY_ELECTRONS: 3, + MOLLY_IFRAME: 1.1, + MOLLY_DEATH: 0.4, + + // ---- juice budget ---- + JUICE_WINDOW: 0.5, // seconds + JUICE_HITSTOP_CAP: 200, // ms of hitstop allowed per window + + // ---- openers ---- + HEAT_RADIUS: 155, + HEAT_IMPULSE: 540, + HEAT_JITTER_SPIKE: 2.4, + HEAT_TRAUMA: 0.34, + HEAT_COOLDOWN: 0.5, + LIGHT_COOLDOWN: 0.35, + + // ---- camera / juice ---- + CAM_LERP: 7, + MAX_SHAKE: 20, + MAX_ROT: 2 * Math.PI / 180, + TRAUMA_DECAY: 1.5, + + // ---- palette ---- + COL_POS: '#FF2D9B', + COL_NEG: '#2DB8FF', + COL_NEU: '#C8D0D8', + // near-black reads as a VOID. ink-indigo reads as a MEDIUM. + COL_BG: '#0B0716', + COL_GRID: '#1A1030', + GRID_STEP: 32, + COL_MOLLY: '#FFE9A3', + COL_MEM: '#7FE3C4', + + // zone washes (subtle — never hurt readability) + TINT_SOUR: 'rgba(190, 210, 40, 0.055)', + TINT_SOAPY: 'rgba(130, 110, 255, 0.055)', + + // ---- audio ---- + MASTER_GAIN: 0.35, +}; + +// charge state -> visual identity. Color AND shape, always redundant. +// +1 positive : hot pink, SPIKY outline +// 0 neutral : dim gray-white, flat & quiet +// -1 negative : cyan, smooth with a soft pulsing halo +export const CHARGE_COL = { '-1': CFG.COL_NEG, '0': CFG.COL_NEU, '1': CFG.COL_POS }; diff --git a/src/elements.js b/src/elements.js new file mode 100644 index 0000000..5427860 --- /dev/null +++ b/src/elements.js @@ -0,0 +1,34 @@ +// ============================================================ +// TWELVE ELEMENTS. Each is a fill colour, a valence, a radius, a verb. +// +// Palette is from REAL EMISSION / FLAME-TEST COLOUR, not CPK. +// CPK's black carbon and white hydrogen both die on a dark ground, +// and #FFFFFF is reserved for radicals and nothing else, ever. +// +// THE LAW: element owns the FILL. charge owns the EDGE and the LIGHT. +// ============================================================ + +export const ELEMENTS = { + H: { col: '#EAF4FF', slots: 1, r: 7, mass: 0.4, name: 'hydrogen' }, + C: { col: '#9B8CFF', slots: 4, r: 13, mass: 1.2, name: 'carbon' }, + N: { col: '#4D7BFF', slots: 3, r: 11, mass: 1.1, name: 'nitrogen' }, + O: { col: '#2BE86B', slots: 2, r: 11, mass: 1.3, name: 'oxygen' }, + Na: { col: '#FF9500', slots: 1, r: 14, mass: 1.8, name: 'sodium' }, + Cl: { col: '#D8FF3B', slots: 1, r: 13, mass: 2.0, name: 'chlorine' }, + S: { col: '#FFE23D', slots: 2, r: 13, mass: 2.0, name: 'sulfur' }, + Mg: { col: '#D9DEE6', slots: 2, r: 13, mass: 1.6, name: 'magnesium' }, + Fe: { col: '#9FB4C4', slots: 3, r: 15, mass: 3.0, name: 'iron' }, + Cu: { col: '#23D9C0', slots: 2, r: 14, mass: 2.8, name: 'copper' }, + K: { col: '#C77DFF', slots: 1, r: 15, mass: 2.2, name: 'potassium' }, + // noble: 0 slots. refuses to bond no matter how hard you wrestle. + // inert stops meaning "dim grey" and starts meaning "Vegas sign". + Ne: { col: '#FF5E3A', slots: 0, r: 9, mass: 1.0, name: 'neon', noble: true }, +}; + +export const DEFAULT_EL = 'C'; + +export function elementOf(sym) { + return ELEMENTS[sym] || ELEMENTS[DEFAULT_EL]; +} + +export const ELEMENT_KEYS = Object.keys(ELEMENTS); diff --git a/src/fx.js b/src/fx.js new file mode 100644 index 0000000..6cc8f34 --- /dev/null +++ b/src/fx.js @@ -0,0 +1,82 @@ +import { rand, randUnit, rgba, damp } from './util.js'; + +export const FX = { + sparks: [], + rings: [], + flash: 0, + flashCol: '#ffffff', + hitstop: 0, + + burst(x, y, n, col, speed = 320, life = 0.5) { + for (let i = 0; i < n; i++) { + const u = randUnit(); + const s = speed * rand(0.25, 1); + this.sparks.push({ + x, y, vx: u.x * s, vy: u.y * s, + life: life * rand(0.5, 1), max: life, col, + w: rand(1.2, 3), + }); + } + }, + + ring(x, y, r0, r1, col, life = 0.4, width = 3) { + this.rings.push({ x, y, r: r0, r1, col, life, max: life, width }); + }, + + screenFlash(col, v = 0.5) { this.flash = v; this.flashCol = col; }, + + freeze(t) { this.hitstop = Math.max(this.hitstop, t); }, + + update(dt) { + for (let i = this.sparks.length - 1; i >= 0; i--) { + const s = this.sparks[i]; + s.life -= dt; + if (s.life <= 0) { this.sparks.splice(i, 1); continue; } + const d = damp(0.02, dt); + s.vx *= d; s.vy *= d; + s.x += s.vx * dt; s.y += s.vy * dt; + } + for (let i = this.rings.length - 1; i >= 0; i--) { + const r = this.rings[i]; + r.life -= dt; + if (r.life <= 0) { this.rings.splice(i, 1); continue; } + } + this.flash = Math.max(0, this.flash - dt * 3.2); + }, + + draw(ctx) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + for (const s of this.sparks) { + const a = Math.max(0, s.life / s.max); + ctx.strokeStyle = rgba(s.col, a); + ctx.lineWidth = s.w * a; + ctx.beginPath(); + ctx.moveTo(s.x, s.y); + ctx.lineTo(s.x - s.vx * 0.02, s.y - s.vy * 0.02); + ctx.stroke(); + } + for (const r of this.rings) { + const t = 1 - r.life / r.max; + const rad = r.r + (r.r1 - r.r) * t; + ctx.strokeStyle = rgba(r.col, (1 - t) * 0.85); + ctx.lineWidth = r.width * (1 - t); + ctx.beginPath(); + ctx.arc(r.x, r.y, rad, 0, Math.PI * 2); + ctx.stroke(); + } + ctx.restore(); + }, + + drawOverlay(ctx, W, H) { + if (this.flash > 0.001) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.fillStyle = rgba(this.flashCol, this.flash * 0.5); + ctx.fillRect(0, 0, W, H); + ctx.restore(); + } + }, + + clear() { this.sparks.length = 0; this.rings.length = 0; this.flash = 0; this.hitstop = 0; }, +}; diff --git a/src/input.js b/src/input.js new file mode 100644 index 0000000..f2c9a0d --- /dev/null +++ b/src/input.js @@ -0,0 +1,128 @@ +import { CFG } from './config.js'; +import { clamp } from './util.js'; + +// Unified input. Game code never checks device type. +export const Input = { + moveX: 0, moveY: 0, + aimX: 0, aimY: 0, // world coords + give: false, take: false, // edge-triggered (consumed) + giveHeld: false, takeHeld: false, + grab: false, // held + heat: false, light: false, // edge-triggered + retry: false, + usingGamepad: false, + _mouse: { x: CFG.W / 2, y: CFG.H / 2 }, + _keys: new Set(), + _pad: null, + _rumble: 0, +}; + +const KEYMAP = { + KeyW: 'up', ArrowUp: 'up', + KeyS: 'down', ArrowDown: 'down', + KeyA: 'left', ArrowLeft: 'left', + KeyD: 'right', ArrowRight: 'right', +}; + +let edge = { give: false, take: false, heat: false, light: false, retry: false }; + +export function initInput(canvas, camera) { + addEventListener('keydown', (e) => { + if (e.repeat) return; + Input._keys.add(e.code); + if (e.code === 'KeyQ') edge.heat = true; + if (e.code === 'KeyE') edge.light = true; + if (e.code === 'KeyR') edge.retry = true; + if (e.code === 'Space') e.preventDefault(); + }); + addEventListener('keyup', (e) => Input._keys.delete(e.code)); + + canvas.addEventListener('mousemove', (e) => { + const r = canvas.getBoundingClientRect(); + Input._mouse.x = ((e.clientX - r.left) / r.width) * CFG.W; + Input._mouse.y = ((e.clientY - r.top) / r.height) * CFG.H; + Input.usingGamepad = false; + }); + canvas.addEventListener('mousedown', (e) => { + e.preventDefault(); + if (e.button === 0) edge.give = true; + if (e.button === 2) edge.take = true; + }); + canvas.addEventListener('contextmenu', (e) => e.preventDefault()); + addEventListener('blur', () => Input._keys.clear()); + + Input._camera = camera; +} + +export function pollInput(camera) { + // ---- gamepad ---- + const pads = navigator.getGamepads ? navigator.getGamepads() : []; + const pad = [...pads].find((p) => p && p.connected); + Input._pad = pad || null; + + let mx = 0, my = 0; + if (Input._keys.has('KeyA') || Input._keys.has('ArrowLeft')) mx -= 1; + if (Input._keys.has('KeyD') || Input._keys.has('ArrowRight')) mx += 1; + if (Input._keys.has('KeyW') || Input._keys.has('ArrowUp')) my -= 1; + if (Input._keys.has('KeyS') || Input._keys.has('ArrowDown')) my += 1; + + let grab = Input._keys.has('Space'); + + if (pad) { + const dz = (v) => (Math.abs(v) < 0.18 ? 0 : v); + const lx = dz(pad.axes[0] || 0), ly = dz(pad.axes[1] || 0); + if (lx || ly) { mx = lx; my = ly; Input.usingGamepad = true; } + + const rx = dz(pad.axes[2] || 0), ry = dz(pad.axes[3] || 0); + if (rx || ry) { + Input.usingGamepad = true; + Input._padAim = { x: rx, y: ry }; + } + const btn = (i) => pad.buttons[i] && pad.buttons[i].pressed; + if (btn(7)) { if (!Input.giveHeld) edge.give = true; Input.giveHeld = true; } else Input.giveHeld = false; + if (btn(6)) { if (!Input.takeHeld) edge.take = true; Input.takeHeld = true; } else Input.takeHeld = false; + if (btn(0)) grab = true; + if (btn(4) && !Input._lb) edge.heat = true; Input._lb = btn(4); + if (btn(5) && !Input._rb) edge.light = true; Input._rb = btn(5); + } + + const m = Math.hypot(mx, my); + if (m > 1) { mx /= m; my /= m; } + Input.moveX = mx; Input.moveY = my; + Input.grab = grab; + + Input.give = edge.give; Input.take = edge.take; + Input.heat = edge.heat; Input.light = edge.light; + Input.retry = edge.retry; + edge = { give: false, take: false, heat: false, light: false, retry: false }; + + // aim resolved in molly.js (needs her position for gamepad-relative aim) + Input.mouseWorld = camera.screenToWorld(Input._mouse.x, Input._mouse.y); +} + +export function setRumble(v) { + Input._rumble = clamp(v, 0, 1); + const pad = Input._pad; + if (!pad) return; + const act = pad.vibrationActuator; + if (!act) return; + if (v < 0.02) return; + try { + act.playEffect('dual-rumble', { + duration: 90, startDelay: 0, + weakMagnitude: clamp(v, 0, 1), + strongMagnitude: clamp(v * 0.7, 0, 1), + }); + } catch (_) { /* not supported */ } +} + +export function pulseRumble(v, ms = 160) { + const pad = Input._pad; + if (!pad || !pad.vibrationActuator) return; + try { + pad.vibrationActuator.playEffect('dual-rumble', { + duration: ms, startDelay: 0, + weakMagnitude: clamp(v, 0, 1), strongMagnitude: clamp(v, 0, 1), + }); + } catch (_) {} +} diff --git a/src/juice.js b/src/juice.js new file mode 100644 index 0000000..541de03 --- /dev/null +++ b/src/juice.js @@ -0,0 +1,42 @@ +import { CFG } from './config.js'; +import { FX } from './fx.js'; + +// ============================================================ +// THE JUICE BUDGET. +// Written FIRST, before bolts/detonations/cascades can stack on it. +// Without a cap, a chain reaction reads as "the game crashed" +// rather than "the game is going off". +// ============================================================ + +const win = []; // [{t, ms}] hitstop spend inside the window +let clock = 0; + +export const Juice = { + tick(dt) { + clock += dt; + while (win.length && clock - win[0].t > CFG.JUICE_WINDOW) win.shift(); + }, + + spent() { + let s = 0; + for (const e of win) s += e.ms; + return s; + }, + + // request hitstop; returns what was actually granted + hitstop(ms) { + const room = Math.max(0, CFG.JUICE_HITSTOP_CAP - this.spent()); + const grant = Math.min(ms, room); + if (grant > 0) { + win.push({ t: clock, ms: grant }); + FX.freeze(grant / 1000); + } + return grant; + }, + + // trauma is self-limiting (clamped 0..1 + decay) but funnel it here + // so every source is auditable in one place + trauma(camera, v) { camera.addTrauma(v); }, + + reset() { win.length = 0; clock = 0; }, +}; diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..ee18c98 --- /dev/null +++ b/src/main.js @@ -0,0 +1,218 @@ +import { CFG } from './config.js'; +import { clamp } from './util.js'; +import { Camera } from './camera.js'; +import { World } from './world.js'; +import { ROOMS } from './rooms.data.js'; +import { Input, initInput, pollInput } from './input.js'; +import { step } from './physics.js'; +import { render } from './render.js'; +import { FX } from './fx.js'; +import { Audio } from './audio.js'; +import { Arena, ARENAS } from './arena.js'; + +const canvas = document.getElementById('stage'); +const ctx = canvas.getContext('2d', { alpha: false }); +const titleEl = document.getElementById('title'); +const startBtn = document.getElementById('start'); + +const camera = new Camera(); +const world = new World(camera); + +let state = 'title'; // title | play | transition | end +let roomIndex = 0; +let transT = 0; +let meltT = 0; +let acc = 0; +let last = performance.now(); + +// ---- canvas sizing: logical 1280x720, letterboxed ---- +function resize() { + const dpr = Math.min(devicePixelRatio || 1, 2); + const s = Math.min(innerWidth / CFG.W, innerHeight / CFG.H); + canvas.width = Math.floor(CFG.W * dpr); + canvas.height = Math.floor(CFG.H * dpr); + canvas.style.width = `${Math.floor(CFG.W * s)}px`; + canvas.style.height = `${Math.floor(CFG.H * s)}px`; + canvas.style.position = 'absolute'; + canvas.style.left = `${Math.floor((innerWidth - CFG.W * s) / 2)}px`; + canvas.style.top = `${Math.floor((innerHeight - CFG.H * s) / 2)}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx._dpr = dpr; +} +addEventListener('resize', resize); +resize(); + +initInput(canvas, camera); + +startBtn.addEventListener('click', () => { + Audio.init(); + Audio.resume(); + titleEl.classList.add('gone'); + loadRoom(0); + state = 'play'; +}); + +const arena = new Arena(); +let arenaIndex = 0; + +function loadRoom(i) { + roomIndex = clamp(i, 0, ROOMS.length - 1); + world.arena = null; + world.load(ROOMS[roomIndex]); +} + +// CASCADE: the tutorial is over, the game starts. +function loadArena(i) { + arenaIndex = clamp(i, 0, ARENAS.length - 1); + const def = arena.room(arenaIndex); + world.load(def); + arena.enter(world, arenaIndex); + Audio.setAmbience('sour'); +} + +function clampCamera() { + const B = world.bounds; + const hw = CFG.W / (2 * camera.scale), hh = CFG.H / (2 * camera.scale); + camera.x = B.w <= hw * 2 ? B.x + B.w / 2 : clamp(camera.x, B.x + hw, B.x + B.w - hw); + camera.y = B.h <= hh * 2 ? B.y + B.h / 2 : clamp(camera.y, B.y + hh, B.y + B.h - hh); +} + +function simulate(dt) { + world.molly.resolveAim(); + world.handleVerbs(); + world.molly.move(dt); + step(world, dt); + world.update(dt); +} + +function frame(now) { + requestAnimationFrame(frame); + + let dt = (now - last) / 1000; + last = now; + dt = Math.min(dt, CFG.MAX_FRAME); + + if (state === 'title') { + ctx.setTransform(ctx._dpr, 0, 0, ctx._dpr, 0, 0); + ctx.fillStyle = CFG.COL_BG; + ctx.fillRect(0, 0, CFG.W, CFG.H); + return; + } + + pollInput(camera); + + if (Input.retry) { + if (state === 'arena' || state === 'arenaClear') loadArena(arenaIndex); + else if (state === 'play') loadRoom(roomIndex); + } + // dev: SHIFT+A jumps straight to CASCADE + if (Input._keys.has('KeyA') && Input._keys.has('ShiftLeft') && state !== 'arena') { + loadArena(0); state = 'arena'; + } + + // hitstop: freeze the sim, keep rendering. sells the snap. + if (FX.hitstop > 0) { + FX.hitstop -= dt; + } else if (state === 'play' || state === 'end' || state === 'arena' || state === 'arenaClear') { + acc += dt; + let guard = 0; + while (acc >= CFG.DT && guard++ < 8) { + simulate(CFG.DT); + acc -= CFG.DT; + } + } + + FX.update(dt); + camera.follow(world.molly.x, world.molly.y, dt); + camera.update(dt); + clampCamera(); + + // ---- death or meltdown: restart the arena. cheap, fast, no menu. ---- + if ((state === 'play' || state === 'arena') && world.molly.dead && world.molly.dying <= 0) { + if (state === 'arena') loadArena(arenaIndex); + else loadRoom(roomIndex); + } + if (state === 'arena' && arena.meltdown) { + meltT += dt; + if (meltT > 1.1) { meltT = 0; loadArena(arenaIndex); } + } else meltT = 0; + + // ---- room flow ---- + if (state === 'play') { + if (world.atExit()) { + state = 'transition'; + transT = 0; + world.fadeTarget = 1; + } + } else if (state === 'transition') { + transT += dt; + if (transT > 0.75) { + if (roomIndex >= ROOMS.length - 1) { + state = 'end'; + } else { + loadRoom(roomIndex + 1); + state = 'play'; + } + } + } else if (state === 'arena') { + if (arena.cleared) { + state = 'arenaClear'; + transT = 0; + world.fadeTarget = 1; + } + } else if (state === 'arenaClear') { + transT += dt; + if (transT > 0.9) { + loadArena(arenaIndex + 1); + state = 'arena'; + } + } else if (state === 'end') { + // the scale reveal: pull back, then out + // the scale reveal, then the tonal cut into CASCADE + world._t = (world._t || 0) + dt; + camera.targetScale = Math.max(0.3, 1 - world._t * 0.14); + if (world._t > 4.2) world.fadeTarget = 1; + if (world._t > 5.4) { + camera.targetScale = 1; + loadArena(0); + state = 'arena'; + } + } + + // hitstop/sim runs for arena states too + if (state === 'arena' || state === 'arenaClear') { + // (already stepped above via the shared sim block) + } + + ctx.setTransform(ctx._dpr, 0, 0, ctx._dpr, 0, 0); + render(ctx, world, camera); + + if (state === 'end' && world._t > 6.4) drawEndCard(); +} + +function drawEndCard() { + ctx.save(); + ctx.setTransform(ctx._dpr, 0, 0, ctx._dpr, 0, 0); + ctx.fillStyle = '#05070a'; + ctx.fillRect(0, 0, CFG.W, CFG.H); + ctx.textAlign = 'center'; + ctx.fillStyle = '#C8D0D8'; + ctx.font = '700 40px -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif'; + ctx.fillText('MOLLY COOL', CFG.W / 2, CFG.H / 2 - 10); + ctx.fillStyle = '#45535f'; + ctx.font = '12px ui-monospace, Menlo, monospace'; + ctx.fillText('vertical slice · press R to replay the last room', CFG.W / 2, CFG.H / 2 + 26); + ctx.restore(); +} + +requestAnimationFrame(frame); + +// dev helpers — stepN drives the sim deterministically without rAF, +// which is how the physics gets tested. +window.MC = { + world, camera, CFG, loadRoom, loadArena, ROOMS, ARENAS, arena, simulate, + stepN(n = 120, dt = CFG.DT) { for (let i = 0; i < n; i++) simulate(dt); }, + goArena(i = 0) { loadArena(i); state = 'arena'; }, + get state() { return state; }, + set state(v) { state = v; }, +}; diff --git a/src/molly.js b/src/molly.js new file mode 100644 index 0000000..1296dfb --- /dev/null +++ b/src/molly.js @@ -0,0 +1,109 @@ +import { CFG } from './config.js'; +import { clamp, damp, randUnit } from './util.js'; +import { Input } from './input.js'; + +export class Molly { + constructor(x, y) { + this.x = x; this.y = y; + this.vx = 0; this.vy = 0; + this.charge = 0; + this.displayCharge = 0; + this.r = CFG.MOLLY_RADIUS; + this.aim = { x: x + 80, y }; + this.hands = { L: null, R: null }; // held particles + this.cool = 0; + this.heatCool = 0; + this.lightCool = 0; + this.flash = 0; + this.spin = 0; + // she's subject to the zones too — that's the whole lesson of the sour room + this.zoneTimer = 0; + this.grace = 0; + + // ---- life ---- + this.electrons = CFG.MOLLY_ELECTRONS; + this.iframe = 0; + this.dying = 0; + this.dead = false; + } + + hurt(world) { + if (this.iframe > 0 || this.dead) return false; + this.electrons--; + this.iframe = CFG.MOLLY_IFRAME; + if (this.electrons <= 0) { this.dying = CFG.MOLLY_DEATH; this.dead = true; } + return true; + } + + revive(n = CFG.MOLLY_ELECTRONS) { + this.electrons = n; + this.iframe = 0; + this.dying = 0; + this.dead = false; + } + + get quiet() { return this.charge === 0; } + + setCharge(c) { + c = clamp(c, -1, 1); + if (c === this.charge) return false; + this.charge = c; + this.flash = 1; + this.grace = CFG.ZONE_GRACE; + this.zoneTimer = 0; + return true; + } + + resolveAim() { + if (Input.usingGamepad && Input._padAim) { + const a = Input._padAim; + const m = Math.hypot(a.x, a.y); + if (m > 0.2) { + this.aim.x = this.x + (a.x / m) * CFG.AIM_DIST; + this.aim.y = this.y + (a.y / m) * CFG.AIM_DIST; + } + } else if (Input.mouseWorld) { + this.aim.x = Input.mouseWorld.x; + this.aim.y = Input.mouseWorld.y; + } + } + + move(dt) { + this.cool = Math.max(0, this.cool - dt); + this.heatCool = Math.max(0, this.heatCool - dt); + this.lightCool = Math.max(0, this.lightCool - dt); + this.flash = Math.max(0, this.flash - dt * 4); + this.spin += dt * 0.7; + this.iframe = Math.max(0, this.iframe - dt); + if (this.dying > 0) this.dying = Math.max(0, this.dying - dt); + + this.vx += Input.moveX * CFG.MOLLY_ACCEL * dt; + this.vy += Input.moveY * CFG.MOLLY_ACCEL * dt; + + // she never sits perfectly still + const j = randUnit(); + this.vx += j.x * CFG.MOLLY_JITTER * Math.sqrt(dt); + this.vy += j.y * CFG.MOLLY_JITTER * Math.sqrt(dt); + + const d = damp(CFG.MOLLY_DAMP, dt); + this.vx *= d; this.vy *= d; + + const sp = Math.hypot(this.vx, this.vy); + if (sp > CFG.MOLLY_MAX_SPEED) { + const k = CFG.MOLLY_MAX_SPEED / sp; + this.vx *= k; this.vy *= k; + } + + this.x += this.vx * dt; + this.y += this.vy * dt; + + this.displayCharge += (this.charge - this.displayCharge) * (1 - Math.exp(-CFG.DISPLAY_LERP * dt)); + } + + knockback(fromX, fromY, power) { + const dx = this.x - fromX, dy = this.y - fromY; + const d = Math.hypot(dx, dy) || 1; + this.vx += (dx / d) * power; + this.vy += (dy / d) * power; + } +} diff --git a/src/overload.js b/src/overload.js new file mode 100644 index 0000000..b6863c8 --- /dev/null +++ b/src/overload.js @@ -0,0 +1,133 @@ +import { CFG } from './config.js'; +import { rgba, clamp } from './util.js'; +import { FX } from './fx.js'; +import { Audio } from './audio.js'; +import { Juice } from './juice.js'; +import { Radicals } from './radicals.js'; + +// ============================================================ +// OVERLOAD. +// Charge handling clamps at +-1, but you can keep CRAMMING protons in. +// Past OVERLOAD_MAX an atom goes critical: a rising fuse, then a blast +// that dumps charge into everything nearby — which can tip THOSE over. +// +// The biggest payoff in the game, expressed entirely in the currency +// the game already had. Also your worst mistake. +// ============================================================ + +export class Overload { + constructor() { this.detonations = 0; } + reset() { this.detonations = 0; } + + // returns true if it consumed the charge input + static push(p, dir) { + p.load = (p.load || 0) + dir; + if (p.load < 0) p.load = 0; + if (p.load >= CFG.OVERLOAD_MAX && !p.fuse) { + p.fuse = CFG.FUSE_TIME; + p.fuseMax = CFG.FUSE_TIME; + } + return true; + } + + update(world, dt) { + const ps = world.particles; + for (let i = ps.length - 1; i >= 0; i--) { + const p = ps[i]; + if (!p.fuse) continue; + p.fuse -= dt; + + // the scream: pitch climbs with the fuse + if (!p._screamT || world.time - p._screamT > 0.11) { + p._screamT = world.time; + const k = 1 - p.fuse / p.fuseMax; + FX.burst(p.x, p.y, 1, '#FFFFFF', 60 + k * 200, 0.2); + } + + if (p.fuse <= 0) this.detonate(world, p); + } + } + + detonate(world, p) { + const cx = p.x, cy = p.y; + p.fuse = 0; p.load = 0; + this.detonations++; + + Juice.hitstop(CFG.BLAST_HITSTOP); + Juice.trauma(world.camera, CFG.BLAST_TRAUMA); + Audio.snap(); + Audio.heat(); + FX.screenFlash('#FFFFFF', 0.6); + FX.ring(cx, cy, 6, CFG.BLAST_R, '#FFFFFF', 0.45, 5); + FX.ring(cx, cy, 2, CFG.BLAST_R * 0.7, CFG.COL_POS, 0.35, 3); + FX.burst(cx, cy, 34, CFG.COL_POS, 620, 0.7); + FX.burst(cx, cy, 14, '#FFFFFF', 400, 0.4); + + // the blast dumps charge AND momentum into everything it catches + for (const q of world.particles) { + if (q === p) continue; + const dx = q.x - cx, dy = q.y - cy; + const d = Math.hypot(dx, dy); + if (d > CFG.BLAST_R || d < 1e-4) continue; + const f = 1 - d / CFG.BLAST_R; + + q.addImpulse((dx / d) * CFG.BLAST_IMPULSE * f, (dy / d) * CFG.BLAST_IMPULSE * f); + + if (q.noble) continue; + q.setCharge(q.charge + 1); + // Two rings, so the cascade has a shape you can learn: + // inner half -> full load, tips instantly (this is the chain) + // outer half -> primed but not lit (this is the setup for the NEXT one) + // A flat +1 never chains; a flat +3 detonates the whole screen. + q.load = (q.load || 0) + (f > 0.5 ? CFG.OVERLOAD_MAX : f > 0.2 ? 2 : 1); + // a fuse lit by a blast burns FASTER — that's what makes it a cascade + if (q.load >= CFG.OVERLOAD_MAX && !q.fuse) { + q.fuse = CFG.FUSE_TIME * CFG.CHAIN_FUSE_MUL; + q.fuseMax = q.fuse; + } + // a blast rips bonds apart homolytically — free radicals, your problem now + if (q.bonds.length && f > 0.55) { + Radicals.make(q); + } + } + + // molly is not exempt. your own bomb will hurt you. + const m = world.molly; + const md = Math.hypot(m.x - cx, m.y - cy); + if (md < CFG.BLAST_R) { + const f = 1 - md / CFG.BLAST_R; + const dx = m.x - cx, dy = m.y - cy, d = Math.hypot(dx, dy) || 1; + m.vx += (dx / d) * CFG.BLAST_IMPULSE * f; + m.vy += (dy / d) * CFG.BLAST_IMPULSE * f; + if (f > 0.45) m.hurt(world); + } + } + + draw(ctx, world) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + for (const p of world.particles) { + if (!p.fuse) continue; + const k = 1 - p.fuse / p.fuseMax; // 0..1 toward detonation + const pulse = 0.5 + Math.sin(world.time * (14 + k * 46)) * 0.5; + + // white-hot core + const R = p.r * (1.5 + k * 1.4 + pulse * 0.35); + const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, R * 2); + g.addColorStop(0, `rgba(255,255,255,${0.55 + k * 0.4})`); + g.addColorStop(0.4, rgba(CFG.COL_POS, 0.4 + k * 0.35)); + g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(p.x, p.y, R * 2, 0, Math.PI * 2); ctx.fill(); + + // the blast radius you're about to be inside of + ctx.strokeStyle = `rgba(255,45,155,${0.1 + k * 0.4})`; + ctx.lineWidth = 1 + k * 2; + ctx.setLineDash([5, 9]); + ctx.beginPath(); ctx.arc(p.x, p.y, CFG.BLAST_R * (0.55 + k * 0.45), 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + } + ctx.restore(); + } +} diff --git a/src/particle.js b/src/particle.js new file mode 100644 index 0000000..312ae93 --- /dev/null +++ b/src/particle.js @@ -0,0 +1,72 @@ +import { CFG } from './config.js'; +import { clamp, random } from './util.js'; +import { elementOf } from './elements.js'; + +let NEXT_ID = 1; + +export class Particle { + constructor(o = {}) { + this.id = NEXT_ID++; + this.x = o.x ?? 0; + this.y = o.y ?? 0; + this.vx = 0; this.vy = 0; + this.charge = o.charge ?? 0; + this.displayCharge = this.charge; + + // element drives colour / valence / radius / mass unless overridden + this.el = o.el || null; + const E = this.el ? elementOf(this.el) : null; + this.col = E ? E.col : CFG.COL_NEU; + this.noble = !!(E && E.noble); + + this.r = o.r ?? (E ? E.r : CFG.P_RADIUS); + this.slots = o.slots ?? (E ? E.slots : 1); + this.bonds = []; + this.zoneTimer = 0; + this.grace = 0; // brief immunity after the player acts + this.grabbedBy = null; // 'L' | 'R' | null + this.fixed = !!o.fixed; + this.tag = o.tag ?? ''; + this.mass = o.mass ?? (E ? E.mass : 1); + this.spin = random() * Math.PI * 2; + this.flash = 0; + } + + get freeSlots() { return Math.max(0, this.slots - this.bonds.length); } + get charged() { return this.charge !== 0; } + + setCharge(c) { + c = clamp(c, -1, 1); + if (c === this.charge) return false; + this.charge = c; + this.grace = CFG.ZONE_GRACE; + this.zoneTimer = 0; + this.flash = 1; + return true; + } + + addForce(fx, fy, dt) { + if (this.fixed) return; + this.vx += (fx / this.mass) * dt; + this.vy += (fy / this.mass) * dt; + } + + addImpulse(ix, iy) { + if (this.fixed) return; + this.vx += ix / this.mass; + this.vy += iy / this.mass; + } + + isBondedTo(p) { return this.bonds.includes(p); } +} + +export function bond(a, b) { + if (a === b || a.isBondedTo(b)) return false; + a.bonds.push(b); b.bonds.push(a); + return true; +} + +export function unbond(a, b) { + const i = a.bonds.indexOf(b); if (i >= 0) a.bonds.splice(i, 1); + const j = b.bonds.indexOf(a); if (j >= 0) b.bonds.splice(j, 1); +} diff --git a/src/physics.js b/src/physics.js new file mode 100644 index 0000000..59ffdfa --- /dev/null +++ b/src/physics.js @@ -0,0 +1,268 @@ +import { CFG } from './config.js'; +import { clamp, damp, randUnit, segDist, pointInRect, random } from './util.js'; +import { FX } from './fx.js'; +import { Audio } from './audio.js'; +import { unbond } from './particle.js'; + +// ---- 1. the storm ---------------------------------------------------- +function jitter(world, dt) { + const s = Math.sqrt(dt); + for (const p of world.particles) { + if (p.fixed || p.grabbedBy) continue; + const u = randUnit(); + const k = CFG.JITTER_STRENGTH * (p.jitterMul || 1); + p.vx += u.x * k * s; + p.vy += u.y * k * s; + } +} + +// ---- 2. charge interaction ------------------------------------------ +function chargeForces(world, dt) { + const ps = world.particles; + for (let i = 0; i < ps.length; i++) { + const a = ps[i]; + if (!a.charged) continue; + for (let j = i + 1; j < ps.length; j++) { + const b = ps[j]; + if (!b.charged) continue; + if (a.isBondedTo(b)) continue; + + const dx = b.x - a.x, dy = b.y - a.y; + const d2 = dx * dx + dy * dy; + if (d2 > CFG.CHARGE_RANGE * CFG.CHARGE_RANGE) continue; + const d = Math.sqrt(d2) || 1e-4; + const dd = Math.max(d, CFG.D_MIN); + + // F = K q1 q2 / d^2 ; positive product => repel, negative => attract + let f = (CFG.K_CHARGE * a.charge * b.charge) / (dd * dd); + f = clamp(f, -CFG.MAX_CHARGE_FORCE, CFG.MAX_CHARGE_FORCE); + + const nx = dx / d, ny = dy / d; + // f>0 (like charges) pushes them apart + a.addForce(-nx * f, -ny * f, dt); + b.addForce(nx * f, ny * f, dt); + } + } +} + +// ---- 2b. the activation barrier --------------------------------------- +// Applies to EVERY bondable pair, not just the ones in Molly's hands. +// This is why the world doesn't spontaneously bond itself: attraction alone +// parks two atoms at arm's length. Getting over the hump needs her squeeze. +// bond geometry, scaled to the atoms involved +export function lockDist(a, b) { + return (a.r + b.r) * CFG.LOCK_MUL; +} +export function bondLen(a, b) { + return (a.r + b.r) * CFG.BOND_LEN_MUL; +} + +export function barrierHump(d, a, b) { + const lock = (a && b && a.r && b.r) ? lockDist(a, b) : CFG.BOND_LOCK_DIST; + const outer = lock + CFG.BARRIER_BAND; + if (d <= lock || d >= outer) return 0; + const t = (d - lock) / (outer - lock); + const hump = Math.sin(Math.PI * t); + // radical + radical recombination is BARRIERLESS. this one line is what + // makes terminating a pair fast enough to be a combat move (0.4s) rather + // than a puzzle payoff (1.8s). ship it with the radical or the whole + // direction reads as failed on a tuning bug. + if (a && b && a.radical && b.radical) return hump * CFG.RAD_BARRIER_MUL; + return hump; +} + +function barrierForces(world, dt) { + const ps = world.particles; + for (let i = 0; i < ps.length; i++) { + const a = ps[i]; + if (a.freeSlots <= 0) continue; + for (let j = i + 1; j < ps.length; j++) { + const b = ps[j]; + if (b.freeSlots <= 0 || a.isBondedTo(b)) continue; + const dx = b.x - a.x, dy = b.y - a.y; + const d = Math.hypot(dx, dy); + const hump = barrierHump(d, a, b); + if (hump <= 0) continue; + const rep = CFG.BARRIER_FORCE * hump; + const nx = dx / (d || 1), ny = dy / (d || 1); + a.addForce(-nx * rep, -ny * rep, dt); + b.addForce(nx * rep, ny * rep, dt); + } + } +} + +// ---- 3. zones re-tune your hands ------------------------------------- +function zones(world, dt) { + // Molly is in here too. The sour air recharges HER — that's the lesson. + for (const p of [...world.particles, world.molly]) { + p.grace = Math.max(0, p.grace - dt); + const z = world.zoneAt(p.x, p.y); + if (!z || z.type === 'neutral') { p.zoneTimer = Math.max(0, p.zoneTimer - dt * 2); continue; } + if (p.grace > 0) { p.zoneTimer = 0; continue; } + + if (z.type === 'sour') { + if (p.charge < 1) { + p.zoneTimer += dt; + if (p.zoneTimer >= CFG.ZONE_FLIP_TIME) { + p.zoneTimer = 0; + p.charge = clamp(p.charge + 1, -1, 1); + p.flash = 1; + FX.burst(p.x, p.y, 6, CFG.COL_POS, 160, 0.3); + Audio.pop(true); + } + } else p.zoneTimer = 0; + } else if (z.type === 'soapy') { + if (p.charge !== 0) { + p.zoneTimer += dt; + if (p.zoneTimer >= CFG.ZONE_FLIP_TIME) { + p.zoneTimer = 0; + p.charge += p.charge > 0 ? -1 : 1; + p.flash = 1; + FX.burst(p.x, p.y, 6, CFG.COL_NEU, 160, 0.3); + Audio.pop(false); + } + } else p.zoneTimer = 0; + } + } +} + +// ---- 4. the membrane: blocks charge, ignores neutral ----------------- +function membranes(world, dt) { + const bodies = [...world.particles, world.molly]; + for (const m of world.membranes) { + for (const p of bodies) { + const charged = p.charge !== 0; + const s = segDist(p.x, p.y, m.a.x, m.a.y, m.b.x, m.b.y); + const reach = CFG.MEM_THICK + (p.r || 10); + + if (!charged) { + // neutral things pass freely — a soft shimmer as they phase through + if (s.d < reach && random() < 0.08) { + FX.burst(s.cx, s.cy, 1, CFG.COL_MEM, 40, 0.35); + } + continue; + } + + if (s.d < reach) { + const push = CFG.MEM_FORCE * (1 - s.d / reach); + if (p.addForce) p.addForce(s.nx * push, s.ny * push, dt); + else { p.vx += s.nx * push * dt; p.vy += s.ny * push * dt; } + + if (!m._cool) m._cool = 0; + const speedInto = -(p.vx * s.nx + p.vy * s.ny); + if (speedInto > 90 && m._cool <= 0) { + m._cool = 0.12; + Audio.thud(); + world.camera.addTrauma(CFG.MEM_BOUNCE_TRAUMA); + FX.burst(s.cx, s.cy, 7, CFG.COL_MEM, 190, 0.35); + FX.ring(s.cx, s.cy, 4, 34, CFG.COL_MEM, 0.3, 2); + } + } + } + if (m._cool > 0) m._cool -= dt; + } +} + +// ---- 5. bond springs ------------------------------------------------- +function bondSprings(world, dt) { + const seen = new Set(); + for (const a of world.particles) { + for (const b of a.bonds) { + const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; + if (seen.has(key)) continue; + seen.add(key); + + const dx = b.x - a.x, dy = b.y - a.y; + const d = Math.hypot(dx, dy) || 1e-4; + + if (d > CFG.BOND_BREAK_DIST) { + unbond(a, b); + FX.burst((a.x + b.x) / 2, (a.y + b.y) / 2, 8, CFG.COL_NEU, 200, 0.35); + Audio.thud(); + continue; + } + + const nx = dx / d, ny = dy / d; + const stretch = d - bondLen(a, b); + const rvx = b.vx - a.vx, rvy = b.vy - a.vy; + const along = rvx * nx + rvy * ny; + const f = stretch * CFG.BOND_SPRING + along * CFG.BOND_DAMP; + + a.addForce(nx * f, ny * f, dt); + b.addForce(-nx * f, -ny * f, dt); + } + } +} + +// ---- 6. collisions --------------------------------------------------- +function collide(world) { + const ps = world.particles; + for (let i = 0; i < ps.length; i++) { + const a = ps[i]; + for (let j = i + 1; j < ps.length; j++) { + const b = ps[j]; + if (a.isBondedTo(b)) continue; + // a pair held in BOTH hands is being wrestled — collision must not + // fight the squeeze, or big atoms stall just outside the lock distance + if (a.grabbedBy && b.grabbedBy) continue; + const dx = b.x - a.x, dy = b.y - a.y; + const min = a.r + b.r; + const d2 = dx * dx + dy * dy; + if (d2 > min * min || d2 < 1e-6) continue; + const d = Math.sqrt(d2); + const overlap = (min - d) * CFG.COLLIDE_PUSH; + const nx = dx / d, ny = dy / d; + if (!a.fixed) { a.x -= nx * overlap; a.y -= ny * overlap; } + if (!b.fixed) { b.x += nx * overlap; b.y += ny * overlap; } + } + } + // molly vs particles + const m = world.molly; + for (const p of ps) { + const dx = p.x - m.x, dy = p.y - m.y; + const min = p.r + m.r; + const d2 = dx * dx + dy * dy; + if (d2 > min * min || d2 < 1e-6) continue; + const d = Math.sqrt(d2); + const overlap = (min - d) * 0.5; + const nx = dx / d, ny = dy / d; + if (!p.fixed && !p.grabbedBy) { p.x += nx * overlap; p.y += ny * overlap; } + m.x -= nx * overlap * 0.5; m.y -= ny * overlap * 0.5; + } +} + +// ---- 7. integrate ---------------------------------------------------- +function integrate(world, dt) { + const d = damp(CFG.P_DAMP, dt); + for (const p of world.particles) { + if (p.fixed) { p.vx = p.vy = 0; continue; } + p.vx *= d; p.vy *= d; + p.x += p.vx * dt; + p.y += p.vy * dt; + p.displayCharge += (p.charge - p.displayCharge) * (1 - Math.exp(-CFG.DISPLAY_LERP * dt)); + p.flash = Math.max(0, p.flash - dt * 4); + p.spin += dt * (0.4 + p.charge * 0.5); + + // soft world bounds + const B = world.bounds; + if (p.x < B.x + p.r) { p.x = B.x + p.r; p.vx = Math.abs(p.vx) * 0.4; } + if (p.x > B.x + B.w - p.r) { p.x = B.x + B.w - p.r; p.vx = -Math.abs(p.vx) * 0.4; } + if (p.y < B.y + p.r) { p.y = B.y + p.r; p.vy = Math.abs(p.vy) * 0.4; } + if (p.y > B.y + B.h - p.r) { p.y = B.y + B.h - p.r; p.vy = -Math.abs(p.vy) * 0.4; } + } + const m = world.molly, B = world.bounds; + m.x = clamp(m.x, B.x + m.r, B.x + B.w - m.r); + m.y = clamp(m.y, B.y + m.r, B.y + B.h - m.r); +} + +export function step(world, dt) { + jitter(world, dt); + chargeForces(world, dt); + barrierForces(world, dt); + zones(world, dt); + world.bondsSystem.update(world, dt); // hands + barrier + snap + membranes(world, dt); + bondSprings(world, dt); + collide(world); + integrate(world, dt); +} diff --git a/src/radicals.js b/src/radicals.js new file mode 100644 index 0000000..8ec1bd9 --- /dev/null +++ b/src/radicals.js @@ -0,0 +1,226 @@ +import { CFG } from './config.js'; +import { FX } from './fx.js'; +import { Audio } from './audio.js'; +import { Juice } from './juice.js'; + +// ============================================================ +// THE RADICAL. +// One enemy. One boolean. No bestiary, no AI, no pathing, no plan. +// +// An unpaired electron. Every RAD_TICK seconds it grabs the nearest +// atom, completes itself, and hands the wound to its victim. +// Population is conserved per hop — but the wound MOVES, and in a +// dense pocket it takes two at once and the branch factor crosses 1. +// +// You cannot shoot it dead. You TERMINATE it: two radicals wrestled +// together annihilate, because radical-radical recombination is +// barrierless. The mechanic you already built is the kill move. +// +// #FFFFFF is reserved for radicals and nothing else, ever. +// ============================================================ + +export class Radicals { + constructor() { + this.tick = 0; + this.count = 0; + this.chain = 0; // how many hops this outbreak has made + this.peak = 0; + } + + reset() { this.tick = 0; this.count = 0; this.chain = 0; this.peak = 0; } + + static make(p) { + if (p.radical || p.noble) return false; + p.radical = true; + p.radTele = 0; + p.radTarget = null; + FX.burst(p.x, p.y, 10, '#FFFFFF', 300, 0.4); + FX.ring(p.x, p.y, 2, 40, '#FFFFFF', 0.3, 2); + return true; + } + + static clear(p) { + p.radical = false; + p.radTele = 0; + p.radTarget = null; + } + + all(world) { return world.particles.filter(p => p.radical); } + + // radical + radical = annihilation. barrierless, so the wrestle is FAST. + terminate(world, a, b) { + const cx = (a.x + b.x) / 2, cy = (a.y + b.y) / 2; + Radicals.clear(a); Radicals.clear(b); + + Juice.hitstop(CFG.RAD_KILL_HITSTOP); + Juice.trauma(world.camera, CFG.RAD_KILL_TRAUMA); + Audio.snap(); + FX.screenFlash('#FFFFFF', 0.55); + FX.burst(cx, cy, 40, '#FFFFFF', 640, 0.7); + FX.ring(cx, cy, 4, CFG.RAD_QUENCH_R, '#FFFFFF', 0.5, 5); + FX.ring(cx, cy, 2, CFG.RAD_QUENCH_R * 0.6, '#7FE3C4', 0.4, 3); + + // the quench ring puts out everything it touches + for (const p of world.particles) { + if (!p.radical) continue; + if (Math.hypot(p.x - cx, p.y - cy) < CFG.RAD_QUENCH_R) { + Radicals.clear(p); + FX.burst(p.x, p.y, 8, '#7FE3C4', 220, 0.35); + } + } + world.kills = (world.kills || 0) + 2; + } + + update(world, dt) { + const ps = world.particles; + this.tick += dt; + + let n = 0; + for (const p of ps) if (p.radical) n++; + this.count = n; + this.peak = Math.max(this.peak, n); + + // ---- telegraph: 0.2s white flicker + a thin red hunt-thread ---- + // (never per-frame random — the Brownian storm already supplies chaos. + // stacking randomness on top reads as UNFAIR rather than DANGEROUS.) + for (const p of ps) { + if (!p.radical) continue; + if (p.radTele > 0) p.radTele -= dt; + } + + // ---- SELF-QUENCH ---- + // Two radicals that drift into contact recombine on their own — real + // chemistry, and the population's only natural sink. It stops outbreaks + // growing without limit, and it means herding them together is a + // legitimate tactic even without grabbing a pair by hand. + for (let i = 0; i < ps.length; i++) { + const a = ps[i]; + if (!a.radical) continue; + for (let j = i + 1; j < ps.length; j++) { + const b = ps[j]; + if (!b.radical) continue; + const d = Math.hypot(b.x - a.x, b.y - a.y); + if (d <= (a.r + b.r) * CFG.LOCK_MUL * 1.15) { + this.terminate(world, a, b); + break; + } + } + } + + if (this.tick < CFG.RAD_TICK) return; + this.tick -= CFG.RAD_TICK; + + let live = 0; + for (const p of ps) if (p.radical) live++; + + const radicals = ps.filter(p => p.radical); + if (!radicals.length) return; + + for (const r of radicals) { + if (!r.radical) continue; // may have been terminated mid-loop + if (live >= CFG.RAD_CAP) break; // live count, not the stale one + + // ---- find prey ---- + const prey = []; + for (const q of ps) { + if (q === r || q.radical || q.noble || q.fixed) continue; + const d = Math.hypot(q.x - r.x, q.y - r.y); + if (d < CFG.RAD_REACH) prey.push({ q, d }); + } + if (!prey.length) continue; + prey.sort((a, b) => a.d - b.d); + + // In a genuinely CROWDED pocket it takes TWO and the branch factor + // crosses 1.0. Requiring three close neighbours (not two) keeps this + // deterministic and readable — "it splits in a crowd" — while stopping + // every hop from doubling, which is a 4-second unsurvivable wipe. + const takes = (prey.length >= 3 && prey[2].d < CFG.RAD_SPLIT_R) ? 2 : 1; + + for (let i = 0; i < takes; i++) { + const victim = prey[i].q; + if (!victim || victim.radical) continue; + + // the wound JUMPS + Radicals.make(victim); + victim.radTele = CFG.RAD_TELEGRAPH; + this.chain++; + live++; + + // a bite: the victim gets shoved, the radical recoils + const dx = victim.x - r.x, dy = victim.y - r.y; + const d = Math.hypot(dx, dy) || 1; + victim.addImpulse((dx / d) * 90, (dy / d) * 90); + r.addImpulse(-(dx / d) * 40, -(dy / d) * 40); + + FX.burst((r.x + victim.x) / 2, (r.y + victim.y) / 2, 5, '#FFFFFF', 200, 0.28); + } + + // it completed itself and goes quiet — UNLESS it split, in which + // case the original stays hot and the population actually grows + if (takes === 1) { Radicals.clear(r); live--; } + Juice.trauma(world.camera, 0.03); + } + + let n2 = 0; + for (const p of ps) if (p.radical) n2++; + this.count = n2; + } + + // ---- draw: pure white, jagged corona, hunt-thread to its next victim ---- + draw(ctx, world) { + const t = world.time; + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + + for (const p of world.particles) { + if (!p.radical) continue; + + // the hunt-thread: you can SEE what it's about to take + let best = null, bestD = CFG.RAD_REACH; + for (const q of world.particles) { + if (q === p || q.radical || q.noble || q.fixed) continue; + const d = Math.hypot(q.x - p.x, q.y - p.y); + if (d < bestD) { bestD = d; best = q; } + } + if (best) { + const urgency = 1 - (this.tick / CFG.RAD_TICK); + ctx.strokeStyle = `rgba(255,60,60,${0.12 + urgency * 0.4})`; + ctx.lineWidth = 1 + urgency * 1.2; + ctx.setLineDash([3, 5]); + ctx.beginPath(); + ctx.moveTo(p.x, p.y); ctx.lineTo(best.x, best.y); + ctx.stroke(); + ctx.setLineDash([]); + } + + // jagged white corona + const flick = p.radTele > 0 ? 1 : 0.72 + Math.sin(t * 26 + p.id) * 0.28; + const R = p.r + 8; + ctx.beginPath(); + const spikes = 11; + for (let i = 0; i <= spikes * 2; i++) { + const a = t * 2.4 + p.id + (i / (spikes * 2)) * Math.PI * 2; + const rr = R + (i % 2 === 0 ? 6 : -3) * flick; + const x = p.x + Math.cos(a) * rr, y = p.y + Math.sin(a) * rr; + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + } + ctx.closePath(); + ctx.strokeStyle = `rgba(255,255,255,${0.85 * flick})`; + ctx.lineWidth = 2; + ctx.stroke(); + + // the only pure-white glow in the game + const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, R * 2.1); + g.addColorStop(0, `rgba(255,255,255,${0.5 * flick})`); + g.addColorStop(0.45, `rgba(255,255,255,${0.16 * flick})`); + g.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(p.x, p.y, R * 2.1, 0, Math.PI * 2); ctx.fill(); + + // solid white core — unmistakable + ctx.fillStyle = `rgba(255,255,255,${0.92 * flick})`; + ctx.beginPath(); ctx.arc(p.x, p.y, p.r * 0.55, 0, Math.PI * 2); ctx.fill(); + } + ctx.restore(); + } +} diff --git a/src/render.js b/src/render.js new file mode 100644 index 0000000..1b13e48 --- /dev/null +++ b/src/render.js @@ -0,0 +1,436 @@ +import { CFG } from './config.js'; +import { clamp, mixHex, rgba, lerp } from './util.js'; +import { FX } from './fx.js'; + +// charge -> color, continuous over displayCharge (-1..1) +function chargeColor(dc) { + if (dc >= 0) return mixHex(CFG.COL_NEU, CFG.COL_POS, clamp(dc, 0, 1)); + return mixHex(CFG.COL_NEU, CFG.COL_NEG, clamp(-dc, 0, 1)); +} + +// POSITIVE = spiky (aggressive). NEGATIVE = smooth + halo. NEUTRAL = flat + quiet. +// Shape carries charge as well as color, so it reads in motion and without color. +// THE LAW: element owns the FILL (flat, matte, never blooms). +// charge owns the EDGE and the LIGHT (the only thing that goes additive). +// charge also keeps both SHAPE channels — spiky (+) / haloed (-) / flat (0) — +// so it survives greyscale and deuteranopia, which colour-only coding does not. +function drawBody(ctx, x, y, r, dc, spin, col, fill) { + const pos = clamp(dc, 0, 1); + const neg = clamp(-dc, 0, 1); + + // negative: soft pulsing halo. Kept tight and dim on purpose — at 116 atoms + // a wide bright halo on every negative body stacks into a milky wash that + // eats the element colours and the radicals' white. + if (neg > 0.03) { + const pulse = 1 + Math.sin(spin * 3) * 0.07; + const R = r * (1 + 0.95 * neg) * pulse; + const g = ctx.createRadialGradient(x, y, r * 0.65, x, y, R); + g.addColorStop(0, rgba(CFG.COL_NEG, 0.30 * neg)); + g.addColorStop(1, rgba(CFG.COL_NEG, 0)); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(x, y, R, 0, Math.PI * 2); ctx.fill(); + } + + // positive: spiky outline + if (pos > 0.03) { + const spikes = 9; + const amp = 4.6 * pos; + ctx.beginPath(); + for (let i = 0; i <= spikes * 2; i++) { + const a = spin + (i / (spikes * 2)) * Math.PI * 2; + const rr = r + (i % 2 === 0 ? amp : -amp * 0.25); + const px = x + Math.cos(a) * rr, py = y + Math.sin(a) * rr; + i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); + } + ctx.closePath(); + // outline only — a filled corona buries the element hue underneath it + ctx.strokeStyle = rgba(CFG.COL_POS, 0.95 * pos); + ctx.lineWidth = 2; + ctx.stroke(); + } + + // core — ELEMENT FILL. Matte and never blooms, but SATURATED: these twelve + // hues are the game's identity and they have to survive on a dark ground. + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + if (fill) { + // radial: brighter at the top-left shoulder so it reads as a solid body + const g = ctx.createRadialGradient(x - r * 0.3, y - r * 0.35, r * 0.15, x, y, r); + g.addColorStop(0, mixHex(fill, '#FFFFFF', 0.28)); + g.addColorStop(0.55, fill); + g.addColorStop(1, mixHex('#0B0716', fill, 0.55)); + ctx.fillStyle = g; + } else ctx.fillStyle = rgba('#0b1118', 0.9); + ctx.fill(); + // CHARGE RIM + ctx.strokeStyle = col; + ctx.lineWidth = neg > 0.3 || pos > 0.3 ? 2.2 : 1.4; + ctx.stroke(); + // a thin element hairline inside the rim keeps identity readable when neutral + if (fill) { + ctx.beginPath(); + ctx.arc(x, y, r - 2.6, 0, Math.PI * 2); + ctx.strokeStyle = rgba(fill, 0.55); + ctx.lineWidth = 1.2; + ctx.stroke(); + } + + // charge glow sits OUTSIDE the body as a rim light, so the element hue + // inside stays legible. charge owns the edge; element owns the fill. + const chg = Math.max(pos, neg); + if (chg > 0.05) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + const g = ctx.createRadialGradient(x, y, r * 0.72, x, y, r * 1.5); + g.addColorStop(0, 'rgba(0,0,0,0)'); + g.addColorStop(0.5, rgba(pos > neg ? CFG.COL_POS : CFG.COL_NEG, 0.42 * chg)); + g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(x, y, r * 1.5, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + } +} + +// OPEN SOCKETS: free valence as rings with dark centres, pulsing at 2Hz and +// leaning toward the nearest compatible partner. Carbon with four open sockets +// reads as visibly HUNGRY; completed methane reads as SATISFIED. +// This is valence made legible in peripheral vision, at speed, with no text. +function drawSlots(ctx, p, world, t) { + const free = p.freeSlots; + if (free <= 0 || p.noble) return; + + // find the nearest thing worth leaning toward + let lean = null, bestD = 120; + if (world) { + for (const q of world.particles) { + if (q === p || q.freeSlots <= 0 || q.noble || p.isBondedTo(q)) continue; + const d = Math.hypot(q.x - p.x, q.y - p.y); + if (d < bestD) { bestD = d; lean = q; } + } + } + const leanA = lean ? Math.atan2(lean.y - p.y, lean.x - p.x) : null; + const pulse = 0.5 + Math.sin(t * 12.6) * 0.5; // 2Hz + const R = p.r + 7; + + for (let i = 0; i < free; i++) { + let a = p.spin * 0.8 + (i / free) * Math.PI * 2; + if (leanA !== null) { + // bias one socket toward the partner + const pull = i === 0 ? 0.55 : 0.14; + let diff = Math.atan2(Math.sin(leanA - a), Math.cos(leanA - a)); + a += diff * pull; + } + const x = p.x + Math.cos(a) * R, y = p.y + Math.sin(a) * R; + ctx.beginPath(); + ctx.arc(x, y, 2.6 + pulse * 0.9, 0, Math.PI * 2); + ctx.strokeStyle = rgba(p.col || CFG.COL_NEU, 0.35 + pulse * 0.45); + ctx.lineWidth = 1.3; + ctx.stroke(); + } +} + +function drawMembrane(ctx, m, t) { + const dx = m.b.x - m.a.x, dy = m.b.y - m.a.y; + const L = Math.hypot(dx, dy) || 1; + const nx = -dy / L, ny = dx / L; + const th = CFG.MEM_THICK; + + ctx.save(); + // oily band + const g = ctx.createLinearGradient( + m.a.x + nx * th, m.a.y + ny * th, + m.a.x - nx * th, m.a.y - ny * th + ); + g.addColorStop(0, rgba(CFG.COL_MEM, 0)); + g.addColorStop(0.5, rgba(CFG.COL_MEM, 0.2)); + g.addColorStop(1, rgba(CFG.COL_MEM, 0)); + ctx.strokeStyle = g; + ctx.lineWidth = th * 2; + ctx.beginPath(); ctx.moveTo(m.a.x, m.a.y); ctx.lineTo(m.b.x, m.b.y); ctx.stroke(); + + // shimmer — a travelling sheen so it reads as fluid, not a wall + ctx.globalCompositeOperation = 'lighter'; + const steps = Math.max(2, Math.floor(L / 9)); + for (let i = 0; i <= steps; i++) { + const u = i / steps; + const x = m.a.x + dx * u, y = m.a.y + dy * u; + const w = Math.sin(u * 22 + t * 2.4) * 0.5 + 0.5; + const off = Math.sin(u * 13 - t * 1.7) * 3; + ctx.fillStyle = rgba(CFG.COL_MEM, 0.05 + w * 0.22); + ctx.beginPath(); + ctx.arc(x + nx * off, y + ny * off, 1.6 + w * 1.6, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); +} + +export function render(ctx, world, camera) { + const t = world.time; + + // NOTE: main.js owns the base (devicePixelRatio) transform. Never reset it here. + ctx.fillStyle = CFG.COL_BG; + ctx.fillRect(0, 0, CFG.W, CFG.H); + + ctx.save(); + camera.apply(ctx); + + // ---- the ground is a MEDIUM, not a void: graph lattice ---- + { + const B = world.bounds, S = CFG.GRID_STEP; + ctx.strokeStyle = CFG.COL_GRID; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let x = B.x; x <= B.x + B.w; x += S) { ctx.moveTo(x, B.y); ctx.lineTo(x, B.y + B.h); } + for (let y = B.y; y <= B.y + B.h; y += S) { ctx.moveTo(B.x, y); ctx.lineTo(B.x + B.w, y); } + ctx.stroke(); + } + + // ---- zone washes ---- + for (const z of world.zones) { + ctx.fillStyle = z.type === 'sour' ? CFG.TINT_SOUR + : z.type === 'soapy' ? CFG.TINT_SOAPY : 'rgba(0,0,0,0)'; + if (z.shape === 'circle') { + ctx.beginPath(); ctx.arc(z.x, z.y, z.r, 0, Math.PI * 2); ctx.fill(); + } else ctx.fillRect(z.x, z.y, z.w, z.h); + } + + // ---- bounds ---- + const B = world.bounds; + ctx.strokeStyle = 'rgba(60,80,100,0.22)'; + ctx.lineWidth = 2; + ctx.strokeRect(B.x, B.y, B.w, B.h); + + // ---- exit ---- + if (world.exit) { + const e = world.exit; + const open = world.exitOpen; + const pulse = 0.5 + Math.sin(t * 3) * 0.5; + ctx.save(); + if (open) { + ctx.globalCompositeOperation = 'lighter'; + const g = ctx.createRadialGradient(e.x + e.w / 2, e.y + e.h / 2, 2, + e.x + e.w / 2, e.y + e.h / 2, Math.max(e.w, e.h)); + g.addColorStop(0, rgba('#7FE3C4', 0.3 + pulse * 0.2)); + g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = g; + ctx.fillRect(e.x - 60, e.y - 60, e.w + 120, e.h + 120); + } + ctx.strokeStyle = open ? rgba('#7FE3C4', 0.7) : 'rgba(70,90,105,0.25)'; + ctx.setLineDash(open ? [] : [5, 7]); + ctx.lineWidth = 2; + ctx.strokeRect(e.x, e.y, e.w, e.h); + ctx.setLineDash([]); + ctx.restore(); + } + + // ---- membranes ---- + for (const m of world.membranes) drawMembrane(ctx, m, t); + + // ---- bonds ---- + const seen = new Set(); + for (const a of world.particles) { + for (const b of a.bonds) { + const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; + if (seen.has(key)) continue; + seen.add(key); + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.strokeStyle = rgba('#ffffff', 0.5); + ctx.lineWidth = 3.5; + ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); + ctx.strokeStyle = rgba(CFG.COL_MOLLY, 0.35); + ctx.lineWidth = 7; + ctx.stroke(); + ctx.restore(); + } + } + + // ---- hand tethers ---- + const m = world.molly; + for (const key of ['L', 'R']) { + const p = m.hands[key]; + if (!p) continue; + ctx.strokeStyle = rgba(CFG.COL_MOLLY, 0.3); + ctx.lineWidth = 1.4; + ctx.setLineDash([3, 5]); + ctx.beginPath(); ctx.moveTo(m.x, m.y); ctx.lineTo(p.x, p.y); ctx.stroke(); + ctx.setLineDash([]); + } + + // ---- light beam ---- + if (world.beam) { + const a = world.beam.life / world.beam.max; + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.strokeStyle = rgba('#FFF3B0', a * 0.8); + ctx.lineWidth = 2 * a; + ctx.beginPath(); + ctx.moveTo(world.beam.x1, world.beam.y1); + ctx.lineTo(world.beam.x2, world.beam.y2); + ctx.stroke(); + ctx.restore(); + } + + // ---- particles ---- + for (const p of world.particles) { + const col = chargeColor(p.displayCharge); + drawBody(ctx, p.x, p.y, p.r, p.displayCharge, p.spin, col, p.col); + drawSlots(ctx, p, world, t); + if (p.flash > 0.01) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.fillStyle = rgba('#ffffff', p.flash * 0.5); + ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (1 + p.flash), 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + } + } + + // ---- molly ---- + // In a 116-atom field you must find yourself INSTANTLY. She gets a wide + // warm pool nothing else has, plus a slow sweep ring, so the eye locks on + // her without her having to be the brightest thing on screen. + { + const col = m.charge === 0 ? CFG.COL_MOLLY : chargeColor(m.displayCharge); + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + + const R = m.r * 5.2; + const g = ctx.createRadialGradient(m.x, m.y, 1, m.x, m.y, R); + g.addColorStop(0, rgba(CFG.COL_MOLLY, 0.30)); + g.addColorStop(0.35, rgba(CFG.COL_MOLLY, 0.10)); + g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(m.x, m.y, R, 0, Math.PI * 2); ctx.fill(); + + // a slow sweeping arc — motion the eye catches even in chaos + const sweep = t * 1.1; + ctx.strokeStyle = rgba(CFG.COL_MOLLY, 0.5); + ctx.lineWidth = 1.6; + ctx.beginPath(); + ctx.arc(m.x, m.y, m.r + 20, sweep, sweep + 1.1); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(m.x, m.y, m.r + 20, sweep + Math.PI, sweep + Math.PI + 1.1); + ctx.stroke(); + ctx.restore(); + + drawBody(ctx, m.x, m.y, m.r, m.displayCharge, m.spin * 1.6, col, CFG.COL_MOLLY); + + // her core mark + ctx.beginPath(); + ctx.arc(m.x, m.y, 4.2, 0, Math.PI * 2); + ctx.fillStyle = m.charge === 0 ? CFG.COL_MOLLY : '#ffffff'; + ctx.fill(); + + // ---- her electrons: the only HUD in the game, and it orbits her ---- + for (let i = 0; i < CFG.MOLLY_ELECTRONS; i++) { + const a = -t * 1.6 + (i / CFG.MOLLY_ELECTRONS) * Math.PI * 2; + const R = m.r + 13; + const x = m.x + Math.cos(a) * R, y = m.y + Math.sin(a) * R; + const alive = i < m.electrons; + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + if (alive) { + const g = ctx.createRadialGradient(x, y, 0, x, y, 7); + g.addColorStop(0, rgba('#7FE3C4', 0.95)); + g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.fill(); + } + ctx.restore(); + ctx.beginPath(); + ctx.arc(x, y, 2.4, 0, Math.PI * 2); + ctx.fillStyle = alive ? '#BFFFE9' : 'rgba(120,140,155,0.22)'; + ctx.fill(); + } + + // i-frames: she strobes after a hit + if (m.iframe > 0 && Math.floor(t * 22) % 2 === 0) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.fillStyle = rgba('#FF3355', 0.3); + ctx.beginPath(); ctx.arc(m.x, m.y, m.r * 1.7, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + } + } + + // ---- reticle ---- + { + const a = m.aim; + const target = world.targetAt(a.x, a.y); + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.strokeStyle = rgba(target ? CFG.COL_MOLLY : '#5d6b7a', target ? 0.9 : 0.4); + ctx.lineWidth = 1.4; + const r = target ? target.r + 9 + Math.sin(t * 6) * 1.5 : 7; + ctx.beginPath(); ctx.arc(target ? target.x : a.x, target ? target.y : a.y, r, 0, Math.PI * 2); ctx.stroke(); + if (!target) { + // aiming at nothing = you'll charge yourself. show the link. + ctx.strokeStyle = rgba(CFG.COL_MOLLY, 0.16); + ctx.setLineDash([2, 6]); + ctx.beginPath(); ctx.moveTo(m.x, m.y); ctx.lineTo(a.x, a.y); ctx.stroke(); + ctx.setLineDash([]); + } + ctx.restore(); + } + + world.overload.draw(ctx, world); + world.radicals.draw(ctx, world); + world.bolts.draw(ctx, world); + + FX.draw(ctx); + ctx.restore(); + + // ---- screen-space overlays ---- + FX.drawOverlay(ctx, CFG.W, CFG.H); + + // ---- CASCADE readout: the chain, and how hot the room is ---- + if (world.arena && world.room && world.room.arena) { + const heat = world.arena.heat(world); + const n = world.radicals.count; + + // the room reddens as it goes critical — readable in peripheral vision + if (heat > 0.01) { + ctx.save(); + const g = ctx.createRadialGradient(CFG.W / 2, CFG.H / 2, CFG.H * 0.32, + CFG.W / 2, CFG.H / 2, CFG.W * 0.72); + g.addColorStop(0, 'rgba(0,0,0,0)'); + g.addColorStop(1, `rgba(255,40,60,${0.05 + heat * 0.3})`); + ctx.fillStyle = g; + ctx.fillRect(0, 0, CFG.W, CFG.H); + ctx.restore(); + } + + // chain length, big and quiet, top centre. the only number in the game. + const chain = world.radicals.chain; + if (chain > 0) { + ctx.save(); + ctx.textAlign = 'center'; + ctx.font = '700 34px ui-monospace, SFMono-Regular, Menlo, monospace'; + ctx.fillStyle = rgba('#FFFFFF', 0.10 + heat * 0.25); + ctx.fillText(String(chain), CFG.W / 2, 56); + ctx.font = '10px ui-monospace, Menlo, monospace'; + ctx.fillStyle = rgba('#FFFFFF', 0.16); + ctx.fillText('CHAIN', CFG.W / 2, 72); + ctx.restore(); + } + + // live radical pips, bottom centre — how much is actually hunting you + if (n > 0) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + const shown = Math.min(n, 24); + for (let i = 0; i < shown; i++) { + const x = CFG.W / 2 - (shown - 1) * 5 + i * 10; + ctx.fillStyle = rgba('#FFFFFF', 0.7); + ctx.beginPath(); ctx.arc(x, CFG.H - 30, 2.4, 0, Math.PI * 2); ctx.fill(); + } + ctx.restore(); + } + } + + if (world.fade > 0.001) { + ctx.fillStyle = rgba('#05070a', clamp(world.fade, 0, 1)); + ctx.fillRect(0, 0, CFG.W, CFG.H); + } +} diff --git a/src/rooms.data.js b/src/rooms.data.js new file mode 100644 index 0000000..249a04c --- /dev/null +++ b/src/rooms.data.js @@ -0,0 +1,175 @@ +import { CFG } from './config.js'; +import { pointInRect } from './util.js'; + +const W = CFG.W, H = CFG.H; + +// scatter helper +const ring = (cx, cy, r, n, spec = {}) => + Array.from({ length: n }, (_, i) => { + const a = (i / n) * Math.PI * 2; + return { x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r, ...spec }; + }); + +// ============================================================ +// Seven beats. No text in any of them. Everything is taught by +// something happening to you. +// ============================================================ +export const ROOMS = [ + + // ---- 0. WAKE ------------------------------------------------------ + // Dark. One mote. You poke it because that's what people do. + // Learns: right hand pushes, left hand pulls. + { + id: 'wake', + ambient: 'none', + bounds: { x: 0, y: 0, w: W, h: H }, + molly: { x: W / 2 - 140, y: H / 2 }, + particles: [{ x: W / 2 + 60, y: H / 2, el: 'O', tag: 'first' }], + exit: { x: W - 120, y: H / 2 - 70, w: 90, h: 140 }, + onEnter(world) { world._pokes = 0; }, + check(world) { + const p = world.find('first'); + if (!p) return false; + if (p.charge !== 0 && !p._wasCharged) { p._wasCharged = true; world._pokes++; } + if (p.charge === 0) p._wasCharged = false; + return world._pokes >= 2; + }, + }, + + // ---- 1. FIRST TOY ------------------------------------------------- + // Two motes. Charge them opposite and they LUNGE together on their own. + // Learns: opposites grab. Taught by two colours finding each other. + { + id: 'toy', + ambient: 'neutral', + bounds: { x: 0, y: 0, w: W, h: H }, + molly: { x: 150, y: H / 2 }, + // an oxygen and two hydrogens. this room is secretly water. + particles: [ + { x: W / 2 - 130, y: H / 2 - 60, el: 'O' }, + { x: W / 2 + 130, y: H / 2 + 60, el: 'H' }, + { x: W / 2, y: H / 2 - 170, el: 'H' }, + ], + exit: { x: W - 120, y: H / 2 - 70, w: 90, h: 140 }, + check(world) { + const ps = world.particles; + for (let i = 0; i < ps.length; i++) + for (let j = i + 1; j < ps.length; j++) { + const a = ps[i], b = ps[j]; + if (a.charge * b.charge >= 0) continue; // must be opposites + if (Math.hypot(a.x - b.x, a.y - b.y) < 40) return true; + } + return false; + }, + }, + + // ---- 2. THE SNAP -------------------------------------------------- + // A clot blocks the way. Wrestle two motes together — the snap's + // shockwave blows the clot apart. The snap is a TOOL, not just juice. + // Learns: bonds = hold-and-release. + { + id: 'snap', + ambient: 'neutral', + bounds: { x: 0, y: 0, w: W, h: H }, + molly: { x: 130, y: H / 2 }, + particles: [ + // NEUTRAL on purpose. Charged opposites would slam together on their own + // and let you pinch-snap with no wrestle — skipping the room's whole point. + // With no attraction to help, your hands are the only way over the barrier. + { x: 380, y: H / 2 - 90, el: 'H' }, + { x: 380, y: H / 2 + 90, el: 'H' }, + // the clot is NEON — noble, 0 slots, refuses to bond, glows like a + // Vegas sign. you can't chemistry your way through it; you shockwave it. + ...ring(W - 250, H / 2, 46, 7, { el: 'Ne', mass: 2, tag: 'clot' }), + ...ring(W - 250, H / 2, 84, 9, { el: 'Ne', mass: 2, tag: 'clot' }), + ], + exit: { x: W - 120, y: H / 2 - 70, w: 90, h: 140 }, + onEnter(world) { world._bonded = false; world.onSnap = () => { world._bonded = true; }; }, + check(world) { return !!world._bonded; }, + }, + + // ---- 3. THE WALL -------------------------------------------------- + // Throw charged debris at the membrane -> it bounces, and the wall + // doesn't even acknowledge it. Drift a neutral thing -> it passes. + // Then: strip YOURSELF neutral and go quiet to cross. + // Learns: the wall reads charge; neutral = sneak. + { + id: 'wall', + ambient: 'neutral', + bounds: { x: 0, y: 0, w: W, h: H }, + molly: { x: 130, y: H / 2, charge: 1 }, + particles: [ + { x: 300, y: H / 2 - 120, el: 'C', tag: 'debris' }, + { x: 340, y: H / 2 + 40, el: 'N', tag: 'debris' }, + { x: 250, y: H / 2 + 150, el: 'S', tag: 'debris' }, + ], + membranes: [{ a: { x: W / 2 + 40, y: -20 }, b: { x: W / 2 + 40, y: H + 20 } }], + exit: { x: W - 130, y: H / 2 - 80, w: 100, h: 160 }, + check() { return true; }, // the wall itself is the lock + }, + + // ---- 4. SOUR ------------------------------------------------------ + // The air keeps shoving protons onto everything — including YOU. + // Neutralize and you flip back positive in about a second. + // So you have to go quiet AND move. Learns: zones re-tune your hands. + { + id: 'sour', + ambient: 'sour', + bounds: { x: 0, y: 0, w: W, h: H }, + molly: { x: 120, y: H / 2, charge: 1 }, + zones: [{ shape: 'rect', x: 0, y: 0, w: W, h: H, type: 'sour' }], + particles: [ + { x: 300, y: H / 2 - 130, el: 'Mg' }, + { x: 330, y: H / 2 + 120, el: 'Cu' }, + ], + membranes: [{ a: { x: W / 2 + 90, y: -20 }, b: { x: W / 2 + 90, y: H + 20 } }], + exit: { x: W - 130, y: H / 2 - 80, w: 100, h: 160 }, + check() { return true; }, + }, + + // ---- 5. THE HEIST ------------------------------------------------- + // The key is charged, so it bounces off the vault. + // Strip it neutral -> it drifts through -> the sour air inside + // charges it back up -> it can't get back out. The lock engages. + // You never opened the door. You made the loot un-leavable. + { + id: 'heist', + ambient: 'sour', + bounds: { x: 0, y: 0, w: W, h: H }, + molly: { x: 130, y: H / 2 }, + zones: [{ shape: 'rect', x: W / 2 + 60, y: 0, w: W / 2 - 60, h: H, type: 'sour' }], + particles: [ + { x: 340, y: H / 2, el: 'Na', charge: 1, tag: 'key' }, + { x: 260, y: H / 2 - 160, el: 'Cl' }, + { x: 300, y: H / 2 + 170, el: 'K' }, + ], + membranes: [{ a: { x: W / 2 + 60, y: -20 }, b: { x: W / 2 + 60, y: H + 20 } }], + exit: { x: W - 120, y: H / 2 - 70, w: 90, h: 140 }, + vault: { x: W / 2 + 60, y: 0, w: W / 2 - 60, h: H }, + check(world) { + const k = world.find('key'); + if (!k) return false; + // inside the vault AND charged = locked in = lock engaged + return pointInRect(k.x, k.y, world.room.vault) && k.charge !== 0; + }, + }, + + // ---- 6. SCALE ----------------------------------------------------- + // This was one droplet. There are thousands. Something huge is pulsing. + { + id: 'scale', + ambient: 'none', + bounds: { x: 0, y: 0, w: W, h: H }, + molly: { x: W / 2, y: H / 2 + 80 }, + scale: 1, + particles: [ + ...ring(W / 2, H / 2, 220, 14, { el: 'C' }), + ...ring(W / 2, H / 2, 330, 20, { el: 'H' }), + ], + onEnter(world) { + world._t = 0; + world.camera.targetScale = 1; + }, + check() { return false; }, // ends on its own via main.js + }, +]; diff --git a/src/util.js b/src/util.js new file mode 100644 index 0000000..7c9470a --- /dev/null +++ b/src/util.js @@ -0,0 +1,80 @@ +export const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); +export const lerp = (a, b, t) => a + (b - a) * t; +export const len = (x, y) => Math.hypot(x, y); + +// --------------------------------------------------------------- +// Seedable RNG. The whole sim draws its randomness from here, so a +// test run can be made bit-for-bit reproducible with seedRandom(n). +// Left unseeded it's just Math.random. +// --------------------------------------------------------------- +let _rng = Math.random; + +export function seedRandom(seed) { + if (seed === null || seed === undefined) { _rng = Math.random; return; } + // mulberry32 + let s = seed >>> 0; + _rng = function () { + s = (s + 0x6D2B79F5) >>> 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export const random = () => _rng(); +export const rand = (a = 1, b) => (b === undefined ? _rng() * a : a + _rng() * (b - a)); +export const randSign = () => (_rng() < 0.5 ? -1 : 1); + +export function randUnit() { + const a = _rng() * Math.PI * 2; + return { x: Math.cos(a), y: Math.sin(a) }; +} + +// exponential damping that is correct at any dt: +// `keep` = fraction of velocity remaining after 1 second +export const damp = (keep, dt) => Math.pow(keep, dt); + +// smooth 0..1 ramp +export const smooth = (t) => { + t = clamp(t, 0, 1); + return t * t * (3 - 2 * t); +}; + +// #rrggbb -> {r,g,b} +export function hex2rgb(h) { + const n = parseInt(h.slice(1), 16); + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; +} + +export function mixHex(a, b, t) { + const A = hex2rgb(a), B = hex2rgb(b); + t = clamp(t, 0, 1); + return `rgb(${Math.round(lerp(A.r, B.r, t))},${Math.round(lerp(A.g, B.g, t))},${Math.round(lerp(A.b, B.b, t))})`; +} + +export function rgba(h, a) { + const c = hex2rgb(h); + return `rgba(${c.r},${c.g},${c.b},${a})`; +} + +// distance from point p to segment ab; returns {d, nx, ny, t} +// n = unit vector from the closest point on the segment toward p +export function segDist(px, py, ax, ay, bx, by) { + const dx = bx - ax, dy = by - ay; + const l2 = dx * dx + dy * dy || 1e-6; + let t = ((px - ax) * dx + (py - ay) * dy) / l2; + t = clamp(t, 0, 1); + const cx = ax + dx * t, cy = ay + dy * t; + let nx = px - cx, ny = py - cy; + const d = Math.hypot(nx, ny) || 1e-6; + return { d, nx: nx / d, ny: ny / d, t, cx, cy }; +} + +export function pointInRect(px, py, r) { + return px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h; +} + +// cheap deterministic-ish noise for shake +export function noise1(t) { + return Math.sin(t * 12.9898) * 43758.5453 % 1; +} diff --git a/src/world.js b/src/world.js new file mode 100644 index 0000000..9b4f36e --- /dev/null +++ b/src/world.js @@ -0,0 +1,226 @@ +import { CFG } from './config.js'; +import { pointInRect } from './util.js'; +import { Molly } from './molly.js'; +import { BondsSystem } from './bonds.js'; +import { Bolts } from './bolts.js'; +import { Juice } from './juice.js'; +import { Radicals } from './radicals.js'; +import { Overload } from './overload.js'; +import { Particle } from './particle.js'; +import { FX } from './fx.js'; +import { Audio } from './audio.js'; +import { Input } from './input.js'; + +export class World { + constructor(camera) { + this.camera = camera; + this.particles = []; + this.membranes = []; + this.zones = []; + this.bounds = { x: 0, y: 0, w: CFG.W, h: CFG.H }; + this.molly = new Molly(CFG.W / 2, CFG.H / 2); + this.bondsSystem = new BondsSystem(); + this.bolts = new Bolts(); + this.radicals = new Radicals(); + this.overload = new Overload(); + this.exit = null; + this.exitOpen = false; + this.onSnap = null; + this.room = null; + this.time = 0; + this.fade = 1; // 1 = black + this.fadeTarget = 0; + } + + load(room) { + this.room = room; + this.particles = []; + this.membranes = (room.membranes || []).map((m) => ({ ...m, _cool: 0 })); + this.zones = room.zones || []; + this.bounds = room.bounds || { x: 0, y: 0, w: CFG.W, h: CFG.H }; + this.exit = room.exit || null; + this.exitOpen = false; + this.onSnap = null; + this.time = 0; + + for (const spec of room.particles || []) this.spawn(spec); + + this.molly.x = room.molly.x; + this.molly.y = room.molly.y; + this.molly.vx = this.molly.vy = 0; + this.molly.charge = room.molly.charge ?? 0; + this.molly.displayCharge = this.molly.charge; + this.molly.hands.L = this.molly.hands.R = null; + this.bondsSystem.reset(); + this.bolts.reset(); + this.radicals.reset(); + this.overload.reset(); + for (const p of this.particles) { p.load = 0; p.fuse = 0; } + this.molly.revive(); + this.kills = 0; + Juice.reset(); + + // rooms can seed an outbreak + for (const spec of room.radicals || []) { + const p = this.particles[spec] ?? this.particles[0]; + if (p) Radicals.make(p); + } + + FX.clear(); + this.camera.x = this.molly.x; + this.camera.y = this.molly.y; + this.camera.trauma = 0; + this.camera.targetScale = room.scale ?? 1; + this.camera.scale = room.scale ?? 1; + + this.fade = 1; this.fadeTarget = 0; + Audio.setAmbience(room.ambient || 'none'); + room.onEnter && room.onEnter(this); + } + + spawn(spec) { + const p = new Particle(spec); + this.particles.push(p); + return p; + } + + find(tag) { return this.particles.find((p) => p.tag === tag); } + findAll(tag) { return this.particles.filter((p) => p.tag === tag); } + + zoneAt(x, y) { + for (const z of this.zones) { + if (z.shape === 'circle') { + if (Math.hypot(x - z.x, y - z.y) < z.r) return z; + } else if (pointInRect(x, y, z)) return z; + } + return null; + } + + // ---- player verbs that aren't grab ---------------------------------- + targetAt(x, y, range = CFG.GRAB_RANGE) { + let best = null, bestD = range; + for (const p of this.particles) { + const d = Math.hypot(p.x - x, p.y - y); + if (d < bestD) { bestD = d; best = p; } + } + return best; + } + + handleVerbs() { + const m = this.molly; + + // ---- LASERHANDS: tap = bolt. ranged, travel time, curves in the field. ---- + if (Input.give || Input.take) { + const dir = Input.give ? 1 : -1; + + // aiming at your own feet still charges YOURSELF — that's how you go + // quiet to cross the membrane, and it must never require a target. + const selfAim = Math.hypot(m.aim.x - m.x, m.aim.y - m.y) < m.r + 18; + if (selfAim) { + if (m.cool <= 0 && m.setCharge(m.charge + dir)) { + m.cool = CFG.CHARGE_COOLDOWN; + Audio.pop(dir > 0); + FX.burst(m.x, m.y, 9, dir > 0 ? CFG.COL_POS : CFG.COL_NEG, 210, 0.36); + FX.ring(m.x, m.y, m.r, m.r + 26, dir > 0 ? CFG.COL_POS : CFG.COL_NEG, 0.3, 2); + this.camera.addTrauma(0.07); + } + } else { + this.bolts.fire(this, dir); + } + } + + // HEAT — the crowbar. area impulse, spikes the storm, can break bonds. + if (Input.heat && m.heatCool <= 0) { + m.heatCool = CFG.HEAT_COOLDOWN; + Audio.heat(); + this.camera.addTrauma(CFG.HEAT_TRAUMA); + FX.ring(m.aim.x, m.aim.y, 8, CFG.HEAT_RADIUS, '#FF9A3C', 0.4, 4); + FX.burst(m.aim.x, m.aim.y, 18, '#FF9A3C', 400, 0.5); + for (const p of this.particles) { + const dx = p.x - m.aim.x, dy = p.y - m.aim.y; + const d = Math.hypot(dx, dy); + if (d > CFG.HEAT_RADIUS || d < 1e-4) continue; + const f = 1 - d / CFG.HEAT_RADIUS; + p.addImpulse((dx / d) * CFG.HEAT_IMPULSE * f, (dy / d) * CFG.HEAT_IMPULSE * f); + p.jitterMul = CFG.HEAT_JITTER_SPIKE; + p._jitterDecay = 0.9; + } + } + + // LIGHT — the scalpel. one target, flips its mood. + if (Input.light && m.lightCool <= 0) { + const t = this.targetAt(m.aim.x, m.aim.y, 150); + if (t) { + m.lightCool = CFG.LIGHT_COOLDOWN; + Audio.light(); + t.setCharge(t.charge === 0 ? 1 : -t.charge); + FX.burst(t.x, t.y, 12, '#FFF3B0', 260, 0.4); + FX.ring(t.x, t.y, 2, 44, '#FFF3B0', 0.3, 2); + // a thin beam, drawn by render.js for a moment + this.beam = { x1: m.x, y1: m.y, x2: t.x, y2: t.y, life: 0.16, max: 0.16 }; + } + } + } + + update(dt) { + this.time += dt; + Juice.tick(dt); + this.bolts.update(this, dt); + this.radicals.update(this, dt); + this.overload.update(this, dt); + + // ---- radicals eat Molly ---- + const m = this.molly; + if (!m.dead) { + for (const p of this.particles) { + if (!p.radical) continue; + if (Math.hypot(p.x - m.x, p.y - m.y) > p.r + m.r + 4) continue; + if (m.hurt(this)) { + Juice.hitstop(90); + Juice.trauma(this.camera, 0.55); + Audio.thud(); + FX.screenFlash('#FF3355', 0.5); + FX.burst(m.x, m.y, 18, '#FF3355', 420, 0.5); + FX.ring(m.x, m.y, 4, 90, '#FF3355', 0.4, 3); + const dx = m.x - p.x, dy = m.y - p.y, d = Math.hypot(dx, dy) || 1; + m.vx += (dx / d) * 300; m.vy += (dy / d) * 300; + if (m.dead) { + Juice.trauma(this.camera, 1); + FX.screenFlash('#FFFFFF', 0.8); + FX.burst(m.x, m.y, 44, '#FF3355', 620, 0.9); + } + } + break; + } + } + for (const p of this.particles) { + if (p._jitterDecay) { + p._jitterDecay -= dt; + if (p._jitterDecay <= 0) { p.jitterMul = 1; p._jitterDecay = 0; } + } + } + if (this.beam) { + this.beam.life -= dt; + if (this.beam.life <= 0) this.beam = null; + } + this.fade += (this.fadeTarget - this.fade) * (1 - Math.exp(-2.2 * dt)); + + if (this.arena && this.room && this.room.arena) this.arena.update(this, dt); + + if (this.room && this.room.check && !this.exitOpen) { + if (this.room.check(this)) { + this.exitOpen = true; + Audio.chime(); + if (this.exit) { + FX.ring(this.exit.x + this.exit.w / 2, this.exit.y + this.exit.h / 2, + 10, 120, '#7FE3C4', 0.7, 3); + } + } + } + } + + atExit() { + if (!this.exit || !this.exitOpen) return false; + return pointInRect(this.molly.x, this.molly.y, this.exit); + } +} diff --git a/test/arena.test.mjs b/test/arena.test.mjs new file mode 100644 index 0000000..fabd3d9 --- /dev/null +++ b/test/arena.test.mjs @@ -0,0 +1,84 @@ +import { makeWorld, tick, sim, check, section, report, CFG, Input } from './harness.mjs'; +const { Arena, ARENAS } = await import('../src/arena.js'); + +section('CASCADE — the arena'); +{ + const w = makeWorld(99); + const arena = new Arena(); + const def = arena.room(0); + check('arena builds a field', def.particles.length > 20, `${def.particles.length} atoms`); + + // minimum separation respected (field must not explode on frame 1) + let tooClose = 0; + for (let i = 0; i < def.particles.length; i++) + for (let j = i + 1; j < def.particles.length; j++) { + const a = def.particles[i], b = def.particles[j]; + if (Math.hypot(a.x - b.x, a.y - b.y) < 50) tooClose++; + } + check('field starts at natural packing', tooClose === 0, `${tooClose} overlaps`); + + w.load(def); + arena.enter(w, 0); + sim(w, 3); + check('arena ignites quickly (no dead air)', w.radicals.count > 0, `n=${w.radicals.count} at 3s`); + + // survivability: does an unattended arena reach a plateau, not a wipe? + const trace = []; + for (let s = 0; s < 8; s++) { sim(w, 5); trace.push(`${(s+1)*5}s:${w.radicals.count}`); } + console.log(' unattended 40s -> ' + trace.join(' ')); + check('unattended arena does not fill the screen', + w.radicals.count < 60, `n=${w.radicals.count}`); + check('waves are finite (a fight you can finish)', arena.wave <= arena.spec().waves, + `${arena.wave}/${arena.spec().waves} waves`); +} + +section('CASCADE — difficulty actually ramps'); +{ + const counts = ARENAS.map((s, i) => { + const w = makeWorld(7); + const a = new Arena(); + const def = a.room(i); + w.load(def); a.enter(w, i); + sim(w, 20); + return { i, atoms: def.particles.length, n: w.radicals.count }; + }); + console.log(' ' + counts.map(c => `a${c.i}:${c.atoms}atoms/${c.n}rad`).join(' ')); + check('later arenas are denser', counts[7].atoms > counts[0].atoms, + `${counts[0].atoms} -> ${counts[7].atoms}`); + check('later arenas are hotter at 20s', counts[7].n >= counts[0].n, + `${counts[0].n} -> ${counts[7].n}`); +} + + +section('CASCADE — win and lose conditions'); +{ + // LOSE: a hyper-dense arena left unattended must melt down + const w = makeWorld(3); + const a = new Arena(); + const def = a.room(7); // the densest + w.load(def); a.enter(w, 7); + let melted = null; + for (let i = 0; i < 120 * 90 && melted === null; i++) { tick(w); if (a.meltdown) melted = i / 120; } + check('unattended late arena MELTS DOWN (a real lose state)', melted !== null, + melted ? `at ${melted.toFixed(1)}s` : 'never melted'); + + // WIN: quench everything by hand and the arena clears + const w2 = makeWorld(11); + const a2 = new Arena(); + const d2 = a2.room(0); + w2.load(d2); a2.enter(w2, 0); + let cleared = null; + for (let i = 0; i < 120 * 120 && cleared === null; i++) { + tick(w2); + // stand-in for a competent player: quench one radical per half second + if (i % 60 === 0) { + const r = w2.particles.find(p => p.radical); + if (r) { r.radical = false; } + } + if (a2.cleared) cleared = i / 120; + } + check('arena CLEARS when the player keeps up', cleared !== null, + cleared ? `at ${cleared.toFixed(1)}s` : 'never cleared'); +} + +process.exit(report() ? 0 : 1); diff --git a/test/harness.mjs b/test/harness.mjs new file mode 100644 index 0000000..e2d6f06 --- /dev/null +++ b/test/harness.mjs @@ -0,0 +1,78 @@ +// Headless sim harness. Runs the real physics in Node — no browser, no +// canvas, no module-cache games. Deterministic and fast. +// +// node test/harness.mjs +// +// Stubs only the things that touch the DOM (audio + gamepad rumble). + +import { CFG } from '../src/config.js'; + +// ---- stub the browser bits before anything imports them ---- +globalThis.window = globalThis; +globalThis.addEventListener = () => {}; +// node 26 has a getter-only navigator — patch the method onto it instead +if (!globalThis.navigator) { + Object.defineProperty(globalThis, 'navigator', { value: {}, configurable: true }); +} +if (!globalThis.navigator.getGamepads) { + try { globalThis.navigator.getGamepads = () => []; } + catch { Object.defineProperty(globalThis, 'navigator', { value: { getGamepads: () => [] }, configurable: true }); } +} + +const { World } = await import('../src/world.js'); +const { Camera } = await import('../src/camera.js'); +const { step } = await import('../src/physics.js'); +const { Radicals } = await import('../src/radicals.js'); +const { Input } = await import('../src/input.js'); +const { ROOMS } = await import('../src/rooms.data.js'); +const { seedRandom } = await import('../src/util.js'); + +export function makeWorld(seed = 1234) { + seedRandom(seed); // reproducible runs — the storm is seeded + const cam = new Camera(); + const w = new World(cam); + Input.usingGamepad = false; + Input.grab = false; + Input.moveX = Input.moveY = 0; + Input.mouseWorld = { x: -9999, y: -9999 }; + return w; +} + +export function sim(w, seconds) { + const n = Math.round(seconds / CFG.DT); + for (let i = 0; i < n; i++) tick(w); +} + +export function tick(w) { + w.molly.resolveAim(); + w.handleVerbs(); + w.molly.move(CFG.DT); + step(w, CFG.DT); + w.update(CFG.DT); +} + +export function aimAt(w, x, y) { + Input.mouseWorld = { x, y }; + w.molly.resolveAim(); +} + +export { CFG, World, Camera, Radicals, Input, ROOMS, step, seedRandom }; + +// --------------------------------------------------------------- +// tiny assert lib +// --------------------------------------------------------------- +let pass = 0, fail = 0; +const fails = []; + +export function check(name, cond, detail = '') { + if (cond) { pass++; console.log(` \x1b[32m✓\x1b[0m ${name}${detail ? ' ' + detail : ''}`); } + else { fail++; fails.push(name); console.log(` \x1b[31m✗\x1b[0m ${name}${detail ? ' ' + detail : ''}`); } +} + +export function section(t) { console.log(`\n\x1b[1m${t}\x1b[0m`); } + +export function report() { + console.log(`\n${fail === 0 ? '\x1b[32m' : '\x1b[31m'}${pass} passed, ${fail} failed\x1b[0m`); + if (fails.length) console.log('failed: ' + fails.join(', ')); + return fail === 0; +} diff --git a/test/overload.test.mjs b/test/overload.test.mjs new file mode 100644 index 0000000..924fe71 --- /dev/null +++ b/test/overload.test.mjs @@ -0,0 +1,70 @@ +import { makeWorld, tick, sim, aimAt, check, section, report, CFG, Input, ROOMS } from './harness.mjs'; +const { Arena } = await import('../src/arena.js'); + +section('OVERLOAD — build a bomb out of charge'); +{ + const w = makeWorld(5); + w.load(ROOMS[1]); + w.particles.length = 0; + const p = w.spawn({ x: 640, y: 360, el: 'C' }); + p.fixed = true; + w.molly.x = 300; w.molly.y = 360; + aimAt(w, 640, 360); + + check('starts unloaded', !p.fuse, `load=${p.load||0}`); + for (let k = 0; k < CFG.OVERLOAD_MAX; k++) { + w.molly.cool = 0; w.bolts.fire(w, 1); + for (let i = 0; i < 200 && w.bolts.list.length; i++) tick(w); + } + check('3 proton bolts light a fuse', p.fuse > 0, `fuse=${(p.fuse||0).toFixed(2)}s`); + + let boomT = null; + for (let i = 0; i < 400 && boomT === null; i++) { tick(w); if (!p.fuse) boomT = i / 120; } + check('fuse detonates', boomT !== null, boomT ? `after ${boomT.toFixed(2)}s` : 'never'); + check('detonation counted', w.overload.detonations === 1, `n=${w.overload.detonations}`); +} + +section('OVERLOAD — the cascade'); +{ + const w = makeWorld(5); + w.load(ROOMS[1]); + w.particles.length = 0; + // a tight cluster: one blast should tip the neighbours over too + for (let i = 0; i < 10; i++) + w.spawn({ x: 560 + (i % 5) * 46, y: 320 + Math.floor(i / 5) * 46, el: 'H' }); + w.molly.x = 200; w.molly.y = 100; + const seed = w.particles[0]; + seed.load = CFG.OVERLOAD_MAX; seed.fuse = 0.1; seed.fuseMax = 0.1; + + for (let i = 0; i < 600; i++) tick(w); + check('one blast triggers others (CASCADE)', w.overload.detonations > 1, + `${w.overload.detonations} detonations from 1 seed`); + check('blast makes radicals (your mess now)', w.radicals.chain >= 0, + `radicals spawned=${w.particles.filter(p=>p.radical).length}`); +} + +section('OVERLOAD — the juice budget holds'); +{ + const { Juice } = await import('../src/juice.js'); + Juice.reset(); + let granted = 0; + for (let i = 0; i < 30; i++) granted += Juice.hitstop(CFG.BLAST_HITSTOP); + check('30 simultaneous blasts cannot freeze the screen', granted <= CFG.JUICE_HITSTOP_CAP, + `${granted}ms granted of ${30*CFG.BLAST_HITSTOP}ms requested`); +} + +section('PERF — the densest arena'); +{ + const w = makeWorld(2); + const a = new Arena(); + w.load(a.room(7)); a.enter(w, 7); + sim(w, 6); + const t0 = process.hrtime.bigint(); + for (let i = 0; i < 600; i++) tick(w); // 5 sim-seconds + const ms = Number(process.hrtime.bigint() - t0) / 1e6; + const perStep = ms / 600; + check('sim step is fast enough for 60fps', perStep < 4, + `${perStep.toFixed(2)}ms/step, ${w.particles.length} atoms, ${w.radicals.count} radicals`); +} + +process.exit(report() ? 0 : 1); diff --git a/test/sim.test.mjs b/test/sim.test.mjs new file mode 100644 index 0000000..1ddf1d9 --- /dev/null +++ b/test/sim.test.mjs @@ -0,0 +1,168 @@ +import { + makeWorld, sim, tick, aimAt, check, section, report, + CFG, Radicals, Input, ROOMS, +} from './harness.mjs'; + +// ============================================================ +// THE GO/NO-GO: does one radical eat a field? +// ============================================================ +section('THE RADICAL — outbreak dynamics'); +{ + const w = makeWorld(); + w.load(ROOMS[1]); + w.particles.length = 0; + // a dense-ish field, deterministic layout (no Math.random in the test) + let i = 0; + for (let gx = 0; gx < 16; gx++) + for (let gy = 0; gy < 9; gy++) + w.spawn({ x: 240 + gx * 42, y: 110 + gy * 46, el: ['H', 'C', 'O', 'N'][i++ % 4] }); + w.molly.x = 80; w.molly.y = 660; + + Radicals.make(w.particles[0]); + tick(w); + const t0 = w.radicals.count; + + const trace = []; + for (let s = 0; s < 6; s++) { + sim(w, 1); + trace.push(`t=${s + 1}s n=${w.radicals.count} hops=${w.radicals.chain}`); + } + console.log(' ' + trace.join(' | ')); + + check('starts with exactly one radical', t0 === 1, `n=${t0}`); + check('the wound MOVES (chain hops)', w.radicals.chain > 5, `hops=${w.radicals.chain}`); + check('branch factor crosses 1 in a dense field', w.radicals.peak > 1, `peak=${w.radicals.peak}`); + check('respects the population cap', w.radicals.peak <= CFG.RAD_CAP, `peak=${w.radicals.peak}`); +} + +// ============================================================ +// TERMINATION — the wrestle is the kill move +// ============================================================ +section('TERMINATION — two radicals annihilate'); +{ + const w = makeWorld(); + w.load(ROOMS[1]); + w.particles.length = 0; + const a = w.spawn({ x: 620, y: 322, el: 'C' }); + const b = w.spawn({ x: 620, y: 398, el: 'C' }); + Radicals.make(a); Radicals.make(b); + w.molly.x = 620; w.molly.y = 520; // stand clear; the squeeze point is lethal + aimAt(w, 620, 360); + Input.grab = true; + + let killT = null; + for (let i = 0; i < 600; i++) { + tick(w); + if (!a.radical && !b.radical) { killT = i / 120; break; } + } + Input.grab = false; + + check('two radicals can be terminated', killT !== null, killT ? `in ${killT.toFixed(2)}s` : 'NEVER'); + check('termination is FAST enough to be a combat move', + killT !== null && killT < 0.9, `${killT ? killT.toFixed(2) : '-'}s (target ~0.4, must be < 0.9)`); + check('they annihilate rather than bond', !a.isBondedTo(b)); +} + +// ============================================================ +// the barrier multiplier must actually be doing the work +// ============================================================ +section('BARRIER — radical pairs are barrierless'); +{ + const { barrierHump } = await import('../src/physics.js'); + const A = { r: 11, radical: false }, B = { r: 11, radical: false }; + const lock = (A.r + B.r) * CFG.LOCK_MUL; + const d = lock + CFG.BARRIER_BAND / 2; // peak of the hump + const normal = barrierHump(d, A, B); + const rad = barrierHump(d, { r: 11, radical: true }, { r: 11, radical: true }); + check('normal pair hits the full barrier', normal > 0.9, `hump=${normal.toFixed(2)}`); + check('radical pair is ~85% easier', rad < normal * 0.2, `hump=${rad.toFixed(2)}`); +} + +// ============================================================ +// MOLLY'S LIFE +// ============================================================ +section('MOLLY — she can die'); +{ + const w = makeWorld(); + w.load(ROOMS[1]); + w.particles.length = 0; + const r = w.spawn({ x: 400, y: 360, el: 'C' }); + r.fixed = true; + Radicals.make(r); + w.molly.x = 400; w.molly.y = 360; + + check('starts with 3 electrons', w.molly.electrons === 3, `e=${w.molly.electrons}`); + tick(w); + check('radical contact costs an electron', w.molly.electrons === 2, `e=${w.molly.electrons}`); + check('i-frames engage', w.molly.iframe > 0, `iframe=${w.molly.iframe.toFixed(2)}`); + + // i-frames should prevent instant death + for (let i = 0; i < 30; i++) tick(w); + check('i-frames prevent melt-through', w.molly.electrons === 2, `e=${w.molly.electrons}`); + + // now run it out — hold her in contact (knockback would otherwise save her) + let deathT = null; + for (let i = 0; i < 1200 && deathT === null; i++) { + w.molly.x = 400; w.molly.y = 360; w.molly.vx = w.molly.vy = 0; + tick(w); + if (w.molly.dead) deathT = i / 120; + } + check('she dies when electrons run out', w.molly.dead, deathT ? `at ${deathT.toFixed(2)}s` : 'survived'); + check('death takes long enough to read', deathT === null || deathT > 1.5, `${deathT ? deathT.toFixed(2) : '-'}s`); +} + +// ============================================================ +// REGRESSION — everything that worked before still works +// ============================================================ +section('REGRESSION'); +{ + let ok = 0; + for (let i = 0; i < ROOMS.length; i++) { + const w = makeWorld(); + try { w.load(ROOMS[i]); sim(w, 2); ok++; } + catch (e) { console.log(` room ${i} threw: ${e.message}`); } + } + check('all rooms load + simulate', ok === ROOMS.length, `${ok}/${ROOMS.length}`); + + // the wrestle + const w = makeWorld(); + w.load(ROOMS[2]); + const a = w.particles[0], b = w.particles[1]; + a.x = 620; a.y = 322; a.vx = a.vy = 0; + b.x = 620; b.y = 398; b.vx = b.vy = 0; + w.molly.x = 620; w.molly.y = 360; + aimAt(w, 620, 360); + Input.grab = true; + let t = null; + for (let i = 0; i < 600; i++) { tick(w); if (a.isBondedTo(b)) { t = i / 120; break; } } + Input.grab = false; + check('bond wrestle still snaps', t !== null, t ? `${t.toFixed(2)}s` : 'FAILED'); + check('wrestle stayed in its feel window', t !== null && t > 1.0 && t < 2.6, `${t ? t.toFixed(2) : '-'}s`); + check('room 2 exit opens', w.exitOpen); + + // the heist + const h = makeWorld(); + h.load(ROOMS[5]); + const k = h.find('key'); + k.setCharge(0); k.grace = 0; k.zoneTimer = 0; + k.x = 660; k.y = 360; k.vx = 260; + let locked = null; + for (let i = 0; i < 900 && locked === null; i++) { tick(h); if (h.exitOpen) locked = i / 120; } + check('heist still solvable', locked !== null, locked ? `${locked.toFixed(2)}s` : 'FAILED'); + + // laserhands + const g = makeWorld(); + g.load(ROOMS[1]); + g.particles.length = 0; + const target = g.spawn({ x: 900, y: 360, el: 'H' }); + target.fixed = true; + g.molly.x = 200; g.molly.y = 360; g.molly.cool = 0; + aimAt(g, 900, 360); + g.bolts.fire(g, 1); + let hit = null; + for (let i = 0; i < 300 && hit === null; i++) { tick(g); if (target.charge === 1) hit = i / 120; } + check('bolt travels and lands on hydrogen', hit !== null, hit ? `${hit.toFixed(2)}s over 700px` : 'MISSED'); + check('bolt is not hitscan', hit !== null && hit > 0.3, `${hit ? hit.toFixed(2) : '-'}s`); +} + +process.exit(report() ? 0 : 1);