[lane B] Round 1: flight controller, cannon, enemy framework on the stub
Flight (js/flight/): tube-mode controller in spline space (s,x,y) — flow-locked forward motion, throttle as a spring-back lean, boost + i-frames, banking chase cam on the parallel-transport frame, arcade wall response (shove + graze damage, never a hard stop). Twin-stick input: WASD/left-stick moves the ship in the disc, mouse/right-stick aims, Shift/Ctrl is throttle. Gamepad supported. Combat (js/combat/): lysozyme cannon (pooled, instanced, heat-limited with hysteresis lockout) + antacid torpedo emitting level:neutralize. Enemy framework with floater/seeker/turret and turn-rate-limited homing darts, spawned from C's level:event. Coat/hull model and the full bus surface E builds against. Measured (deterministic stepper): flow-lock exact (3s at flow 14 -> s=44.0); boost peak 30.8 u/s; heat 4s fire -> 2s lockout; wall slam 13.3 coat at 11.3 u/s with forward speed untouched; 10 combat draws at 55 live enemies (budget <=80); 0.10 ms/frame CPU; 0 leaks over 20 create/dispose cycles. fps NOT claimed — rAF does not run in the automated pane. Evidence: docs/shots/laneB/. Three findings escalated in LANE_B_NOTES: - qa.sh gate #1 is a no-op: node --check silently exits 0 on ESM, so the syntax gate has never checked anything. I shipped a real SyntaxError past a GREEN qa. Fix + verification handed to F. - Surf is structurally broken: the peristalsis wave (13.64 u/s) is slower than the player (19.6 u/s), so level 2's signature mechanic loses to mashing throttle. Not tunable from this lane; escalated to A/F with options. - Custom ShaderMaterials need #include <colorspace_fragment>, else ART_BIBLE's hostile amber reaches the display as rgb(255,26,6). Fixed here; stub/walls inherit it. js/flight/dev.html is a dev harness (boot.js has no player wiring yet); it retires once F pastes the snippet in LANE_B_NOTES. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
1b5ec767cd
commit
0a038582a7
236
docs/LANES/LANE_B_NOTES.md
Normal file
236
docs/LANES/LANE_B_NOTES.md
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
# LANE B — Flight & Combat · NOTES
|
||||||
|
|
||||||
|
Round 1. Owner: Lane B. Territory: `web/js/flight/**`, `web/js/combat/**`.
|
||||||
|
Everything below was measured on the **stub** (`js/stub/world_stub.js`), per ROUND1 §Lane B.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What landed
|
||||||
|
|
||||||
|
| file | what |
|
||||||
|
|---|---|
|
||||||
|
| `flight/tuning.js` | **every** flight feel number, with rationale. The deliverable. |
|
||||||
|
| `flight/input.js` | devices → one `intent`. Twin-stick: WASD/stick = disc, mouse/stick = aim. |
|
||||||
|
| `flight/player.js` | tube controller (s,x,y), chase cam, coat/hull, wall response, bus emissions |
|
||||||
|
| `flight/ship.js` | ENDO-1 procedural hull + reticle (`assets.get('models','endo1')` upgrade path) |
|
||||||
|
| `flight/emissive.js` | the one material behind every glowing thing B draws |
|
||||||
|
| `combat/balance.js` | every combat number |
|
||||||
|
| `combat/weapons.js` | lysozyme cannon + antacid torpedo, pooled/instanced, heat |
|
||||||
|
| `combat/enemies.js` | registry + floater/seeker/turret + homing darts |
|
||||||
|
| `combat/index.js` | `createCombat` — composes the above, consumes `level:event` |
|
||||||
|
| `flight/dev.html/.js` | **dev harness** (see → Lane F #1; retire once boot wires the player) |
|
||||||
|
|
||||||
|
Round-1 asks 1–4 are done. Arena/6DOF is round 2 as instructed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feel numbers (the deliverable)
|
||||||
|
|
||||||
|
The full set with rationale lives in `flight/tuning.js` + `combat/balance.js`. The load-bearing ones:
|
||||||
|
|
||||||
|
- **Forward is never yours.** `ds/dt = flow × (throttle + surf + boost)`. Throttle ∈ [0.6, 1.4] and
|
||||||
|
springs back to 1.0 — it's a lean, not a button you hold. No stop, no reverse, ever.
|
||||||
|
- **Throttle is NOT WASD.** WASD/left-stick moves the ship in the cross-section disc; mouse/right-stick
|
||||||
|
aims; throttle is Shift/Ctrl (triggers). This is what makes "mouse-aim + WASD" and "gamepad
|
||||||
|
twin-stick" the same game instead of two.
|
||||||
|
- **Disc:** accel 78 u/s², max 13 u/s, damping 6.5/s → crosses the ~16 u tube in ~1.2 s. Dodgeable, not twitchy.
|
||||||
|
- **Boost:** +120% flow for 0.55 s, 2.4 s cooldown, **i-frames 0.35 s** — deliberately shorter than the
|
||||||
|
burst, so boosting *through* something is a read, not a get-out-of-jail.
|
||||||
|
- **Wall = shove + graze, never a hard stop** (arcade law). Damage `(impact − 3.0) × 1.6`, capped 28,
|
||||||
|
0.25 s cooldown. Below 3.0 u/s is free, so you can lean on a fold. Forward speed is untouched.
|
||||||
|
- **Cannon heat is the gun's only cost.** 11 shots/s × 5.5 heat − 30/s cooling = **+25 heat/s net** ⇒
|
||||||
|
~4 s of held fire, then a 2.0 s lockout (100 → resume at 40). Burst and you never see the meter.
|
||||||
|
|
||||||
|
### Measured (DBG.step, deterministic fixed timestep — see → Lane F #2)
|
||||||
|
|
||||||
|
| claim | measured |
|
||||||
|
|---|---|
|
||||||
|
| flow-lock exact | 3.0 s at flow 14 from s=2 → **s=44.0** (predicted 44.0) |
|
||||||
|
| boost peak | **30.8 u/s** = 14 × (1.0 + 1.2) ✓ · i-frames end at 0.35 s ✓ · cd 2.4 s ✓ |
|
||||||
|
| heat cycle | overheat at 4.0 s held fire → **2.0 s lockout** (60 heat ÷ 30/s) → resume ✓ |
|
||||||
|
| wall slam | impact 11.3 u/s → **13.3 coat** = (11.3−3.0)×1.6 ✓ · forward speed **14.0 → 14.0** ✓ · no tunnelling (rho 7.58 < wallRho 8.49) |
|
||||||
|
| scraping | one damage tick only; subsequent per-frame bounces fall under grazeFloor ✓ |
|
||||||
|
| turret mount | rho **6.13** = wallRho(7.73) − radius(1.6) ✓ · darts 14 dmg each ✓ |
|
||||||
|
| cannon | 2 hits kill a 24 hp floater ✓ · `enemy:die {type:'floater', kind:'cannon', score:100}` |
|
||||||
|
| level pump | stub events fired: 4 floaters @ s=60, 3 seekers @ s=140 ✓ |
|
||||||
|
| **draws** | **10 combat draws** with **55 live enemies** + full cannon stream (budget ≤80) |
|
||||||
|
| tris | 107.8k worst case — **102k of that is the stub tube**; all of combat ≈ 5.7k |
|
||||||
|
| cpu | **0.10 ms/frame** (update+render, 55 enemies, firing) |
|
||||||
|
| **leak test** | **0** geometries / textures / scene-children leaked over 6 and 20 create→dispose cycles |
|
||||||
|
|
||||||
|
**fps is NOT claimed.** `requestAnimationFrame` does not run in the automated browser pane
|
||||||
|
(`document.visibilityState === 'hidden'`), so wall-clock fps could not be honestly measured this
|
||||||
|
round. The 0.10 ms CPU figure is real; the GPU side needs a human session once boot wires the player.
|
||||||
|
|
||||||
|
Evidence: `docs/shots/laneB/` — `round1_combat` (roster + pellet, banked), `round1_wallgraze`
|
||||||
|
(pinned to the fold, the 13.3 hit above), `round1_boost` (FOV kick at 30.8 u/s), `round1_enemies`
|
||||||
|
(wall-mounted turrets + floater). WebP not PNG: a 1280×720 PNG was 1.9 MB of base64 vs 25 KB WebP,
|
||||||
|
and the repo already standardises on WebP (ART_BIBLE). Say the word and I'll switch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠ surf: the esophagus mechanic does not work, and I can't fix it in my lane
|
||||||
|
|
||||||
|
This is the round's most important finding. **`world.flowPulse` surf is implemented and fires
|
||||||
|
correctly — but riding a crest is strictly slower than ignoring the wave.**
|
||||||
|
|
||||||
|
```
|
||||||
|
crest phase speed = WAVE_W / WAVE_K = 3.0 / 0.22 = 13.64 u/s (stub, and A's shader will mirror it)
|
||||||
|
player top speed = flow × throttleMax = 14 × 1.4 = 19.6 u/s
|
||||||
|
```
|
||||||
|
|
||||||
|
A wave travelling 13.64 u/s **cannot carry anything faster than 13.64 u/s**. Measured: riding a crest
|
||||||
|
at phase-lock = 19.2 u/s vs just mashing throttle = 19.6 u/s. Surfing **loses**. The correct play is
|
||||||
|
to ignore the signature mechanic of level 2 and hold the throttle key.
|
||||||
|
|
||||||
|
It is not a tuning problem. The crest window is fixed in **space**, so a bigger `surf.gain` just
|
||||||
|
ejects you out the front sooner — measured distance gained per crest is **~3.0 units at gain 0.4,
|
||||||
|
0.75 AND 1.2** (2.96 / 3.04 / 3.08). The bonus cancels itself. I verified the ride directly: starting
|
||||||
|
*on* a crest at neutral throttle, the ship falls off after **0.6 s** because the surf bonus (24.5 u/s)
|
||||||
|
outruns the very wave granting it.
|
||||||
|
|
||||||
|
Shipped as an honest **peristaltic shove** (~0.3–0.6 s at 24.5 u/s as each crest sweeps over you).
|
||||||
|
It feels good and is truthful to what the wave physically is. It is not the GDD's "ride peristalsis
|
||||||
|
rings for boost" speed line. `WAVE_W`/`WAVE_K` live in F's stub and will live in A's wall shader
|
||||||
|
(gameplay wave must equal visual wave), so **B cannot fix this alone** → see → Lane A/F #1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ### → Lane F
|
||||||
|
|
||||||
|
**1. Wire the player into `boot.js`** (I own no shell). Paste-ready; `?fly=1` keeps A's noclip:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// imports, top of boot.js
|
||||||
|
import { createPlayer } from './flight/player.js';
|
||||||
|
import { createCombat } from './combat/index.js';
|
||||||
|
|
||||||
|
// after: scene.add(world.group) / scene.background = ...
|
||||||
|
const rng = createRng(flags.seed ?? world.level?.seed ?? 0);
|
||||||
|
const player = flags.fly ? null
|
||||||
|
: createPlayer({ scene, world, bus, rng, flags, camera, dom: renderer.domElement });
|
||||||
|
const combat = player ? createCombat({ scene, world, bus, rng, flags, player }) : null;
|
||||||
|
|
||||||
|
// level-event pump — nobody owns this yet (see #3). Emits C's events as the player crosses them.
|
||||||
|
let _evts = [...(world.level?.events ?? [])].sort((a, b) => a.s - b.s), _ei = 0;
|
||||||
|
const pumpTo = (s) => { while (_ei < _evts.length && s >= _evts[_ei].s) bus.emit('level:event', _evts[_ei++]); };
|
||||||
|
|
||||||
|
// in frame(), REPLACING `world.update(dt, sCam); updateCamera(dt);`
|
||||||
|
if (player) {
|
||||||
|
player.update(dt); // must run first: combat hit-tests this frame's ship position
|
||||||
|
pumpTo(player.state.s);
|
||||||
|
combat.update(dt);
|
||||||
|
world.update(dt, player.state.s);
|
||||||
|
} else {
|
||||||
|
updateCamera(dt); // ?fly=1 noclip / title drift
|
||||||
|
world.update(dt, sCam);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The player drives `camera` (position, `up`, `lookAt`, FOV) — `updateCamera()` must not also run.
|
||||||
|
`DBG` line's `s=${sCam}` should read `player?.state.s ?? sCam`.
|
||||||
|
|
||||||
|
**2. `tools/qa.sh` gate #1 is a no-op — it passes every broken file in the repo.**
|
||||||
|
`node --check` **silently exits 0 on any file containing `import`/`export`** (ESM detection). Every
|
||||||
|
module in `web/js` is ESM, so the syntax gate has never checked anything. I found this the hard way:
|
||||||
|
I shipped a genuine `SyntaxError` (backticks inside a GLSL comment inside a template literal, closing
|
||||||
|
the literal early), qa.sh went GREEN, and the browser refused the module. Verified on node v26.5.0:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --check broken_esm.js ; echo $? # -> 0 (!!)
|
||||||
|
node --check --input-type=module < broken.js ; echo $? # -> 1 (correct)
|
||||||
|
```
|
||||||
|
Fix — swap the loop body in gate #1:
|
||||||
|
```bash
|
||||||
|
node --check --input-type=module < "$f" 2>/dev/null || { node --check --input-type=module < "$f"; JSBAD=1; }
|
||||||
|
```
|
||||||
|
Confirmed it still passes all good modules (`rng.js`, `world_stub.js`) and fails real breakage. Worth
|
||||||
|
landing before round 2 — A/C/D/E are all writing ESM against a gate that cannot fail.
|
||||||
|
|
||||||
|
**3. Nobody owns the `level:event` emitter.** TECH specs the event and says "raw C events as player
|
||||||
|
crosses their `s`", but no lane owns the pump. B consumes it. I put a dev-only copy in my harness and
|
||||||
|
the snippet in #1 — please assign it (boot or C's registry) and delete whichever copy loses.
|
||||||
|
|
||||||
|
**4. Bus additions** (TECH §Bus says F referees names). All emitted faithfully already:
|
||||||
|
- `player:state {coat,coatMax,hull,hullMax,speed,flow,throttle,boostReady,boostCd,boostCdMax,surfing,iframes,s,length,progress,biome,alive}` — **E cannot build a HUD without this.** Damage events
|
||||||
|
alone can't express regen, ambient drain, throttle or progress. Emitted every frame; treat as
|
||||||
|
read-only, don't retain.
|
||||||
|
- `combat:state {heat,heatMax,overheated,ammo,ammoMax,score,kills,enemies}` — same reason.
|
||||||
|
- `player:surf {active, s}` — edge-triggered.
|
||||||
|
- `enemy:die` carries an extra `score` and `kind` alongside the specced `{id,type,s}` (additive).
|
||||||
|
- **Damage rule I adopted:** `player:damage` = *discrete* hits only (wall/dart/contact/aura-tick).
|
||||||
|
Continuous drain (biome `coatDrain`) never emits — it would fire 60×/s and pin E's damage flash on.
|
||||||
|
Continuous state rides `player:state`. Floater auras tick at 0.2 s so they read as pulses.
|
||||||
|
|
||||||
|
**5. `?shots=1` respected** in my harness. My `DBG.step/leakTest/spawn/tp` helpers are harness-only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ### → Lane A (and F, who owns the stub today)
|
||||||
|
|
||||||
|
**1. The wave must outrun the player, or level 2's mechanic is dead.** See §surf above. For "ride the
|
||||||
|
crest" to be the fast line, **crest phase speed must exceed `flow × throttleMax` (19.6 u/s)**; today
|
||||||
|
it's 13.64. Options, your call (F settles design):
|
||||||
|
- **(a) Raise the wave** — `WAVE_W/WAVE_K > 19.6`, e.g. `WAVE_W 4.8 @ K 0.22` → 21.8 u/s. Then
|
||||||
|
riding pays ~+11% over mashing and the crest genuinely carries you. Cheapest fix; the visual
|
||||||
|
wave just travels faster (arguably better for a speed-run level).
|
||||||
|
- **(b) Expose the phase speed** so B can *speed-lock* the player to the crest while riding — the
|
||||||
|
truest "flow-locked" reading, and then the wave carries you at exactly its own speed.
|
||||||
|
Either way **B needs (b)'s data**: please add `biome.waveSpeed` (or `world.crestSpeed(s)`) to the
|
||||||
|
contract. Right now `flowPulse` gives amplitude only, so B can see *that* it's on a crest but not
|
||||||
|
*how fast the crest is going* — I had to hardcode 3.0/0.22 from the stub to analyse this.
|
||||||
|
|
||||||
|
**2. `world.flowPulse(s, t)` is missing from the TECH world contract** but is specced in both LANE_A
|
||||||
|
and LANE_B charters and in ROUND1 §A.4. I call it guarded (`world.flowPulse?.(s) ?? 0`). Please add it
|
||||||
|
to TECH §THE WORLD CONTRACT before F freezes it.
|
||||||
|
|
||||||
|
**3. `world.sample(s)` allocates** ~6 `Vector3`s per call. It's the hot path: I call it for the ship,
|
||||||
|
the aim frame, the camera (×2) and once per live enemy per frame — ~60 calls/frame at 55 enemies,
|
||||||
|
i.e. ~350 allocations/frame of pure GC churn. Not a problem yet (0.10 ms/frame), but it will be with
|
||||||
|
villi + a boss. Request: an out-param variant, `world.sample(s, out)` reusing a caller-owned frame
|
||||||
|
object, keeping the allocating form as sugar. I'll adopt it the round it exists.
|
||||||
|
|
||||||
|
**4. Frame convention I built against** (please keep): `worldPos = frame.pos + frame.nor·y + frame.bin·x`,
|
||||||
|
`theta = atan2(x, y)` — matches the stub's `project()` (`atan2(rel·bin, rel·nor)`). Camera `up` is
|
||||||
|
`frame.nor`, never world-up. Parallel transport must stay roll-stable or the chase cam rolls with it.
|
||||||
|
|
||||||
|
**5. Custom `ShaderMaterial` needs `#include <colorspace_fragment>` or ART_BIBLE colours are wrong.**
|
||||||
|
`ColorManagement` is on and `outputColorSpace` is `srgb`, so `THREE.Color(0xff5a2a)` is converted to
|
||||||
|
**linear** on the way in — but a hand-written fragment shader gets no conversion on the way out and
|
||||||
|
writes linear straight into the sRGB target. Measured: hostile amber `#ff5a2a` reached the display as
|
||||||
|
**rgb(255, 26, 6)** — blood red, not amber — silently breaking the emissive readability law. One line
|
||||||
|
at the end of `main()` fixes it. **The stub's wall shader has the same issue** (F: its teal is being
|
||||||
|
written linear too), and A's wall/villi shaders will inherit it. Worth an ART_BIBLE or TECH note so
|
||||||
|
every lane writing GLSL gets it right once.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ### → Lane C
|
||||||
|
|
||||||
|
- **Ambient `coatDrain` does nothing in the esophagus as balanced.** Coat regen is 8/s (after a 2.5 s
|
||||||
|
no-damage delay) and esophagus `coatDrain` is 0.5/s, so out of combat you are always topped up —
|
||||||
|
ambient drain only bites while regen is suppressed, i.e. for 2.5 s after each hit. That's a
|
||||||
|
defensible design (it makes *taking* a hit hurt twice), but if you want standing ambient pressure in
|
||||||
|
a biome, `coatDrain` must exceed **8/s** — which is presumably exactly what the stomach should do.
|
||||||
|
Flagging so your biome numbers are chosen knowingly, not by accident.
|
||||||
|
- Spawn events: I fan `count` out over `s + i×3.5` so a count of 6 is an encounter, not a wall of
|
||||||
|
enemies at one arclength. Add `theta`/`rho` to a spawn event and I'll honour them exactly.
|
||||||
|
- Live archetype ids for your L2 encounter curve: `floater`, `seeker`, `turret`. Unknown `enemy`
|
||||||
|
values warn and no-op, so you can author ahead of me safely.
|
||||||
|
- Pool caps are the population caps: floater 24, seeker 32, turret 12 concurrent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions / next round
|
||||||
|
|
||||||
|
- **Arena/6DOF controller** (round 2, as instructed). `modeAt()`/`arenaAt()` are guarded but unused.
|
||||||
|
- **Per-instance hit flash.** Today `uFlash` is one uniform per *type*, so hitting one floater blinks
|
||||||
|
the whole cohort. Needs an instanced colour attribute — round 2.
|
||||||
|
- **Enemies behind the player are never reaped.** They idle at their spawn `s` forever (pool-capped, so
|
||||||
|
bounded, but wasteful). Wants a despawn rule once C's checkpoints exist.
|
||||||
|
- **Dart readability:** a dart passing the camera fills a chunk of the screen with white-hot additive.
|
||||||
|
Correct per ART_BIBLE, possibly too dominant in motion. Wants a human eye once boot wires the player.
|
||||||
|
- **Torpedo** is built and emits `level:neutralize {s,radius,duration}` faithfully — **nothing consumes
|
||||||
|
it yet** (A/C). Ammo is fixed at 6 until pickups exist (round 2).
|
||||||
|
- **Real fps + real feel.** Everything above is deterministic-stepper truth. Numbers can't tell you
|
||||||
|
whether the bank rate feels good. First human session should re-tune `bank.gain`, `cam.discFollow`
|
||||||
|
and `aim.lag` by hand — those three are pure taste and I set them by reasoning, not by playing.
|
||||||
39
docs/progress/b-progress.md
Normal file
39
docs/progress/b-progress.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# Lane B — progress log
|
||||||
|
|
||||||
|
## 2026-07-16 · Round 1 — flight + first blood, on the stub
|
||||||
|
|
||||||
|
Built the whole Lane B round-1 slice against `?stub=1`: `flight/{tuning,input,player,ship,emissive}.js`
|
||||||
|
and `combat/{balance,weapons,enemies,index}.js`. Round-1 asks 1–4 all landed; arena/6DOF deferred to
|
||||||
|
round 2 as instructed.
|
||||||
|
|
||||||
|
**Shipped**
|
||||||
|
- Tube controller in spline space `(s, x, y)`: flow-locked forward motion, throttle as a spring-back
|
||||||
|
lean, boost + i-frames, banking chase cam on the parallel-transport frame, arcade wall response
|
||||||
|
(shove + graze damage, never a hard stop).
|
||||||
|
- Twin-stick input (WASD/left-stick = disc, mouse/right-stick = aim, Shift/Ctrl = throttle), gamepad
|
||||||
|
supported. Throttle deliberately not on WASD.
|
||||||
|
- Lysozyme cannon (pooled, instanced, heat-limited with hysteresis lockout) + antacid torpedo emitting
|
||||||
|
`level:neutralize`. Enemy framework + floater/seeker/turret + turn-rate-limited homing darts.
|
||||||
|
- Coat/hull model and the full bus surface E builds against.
|
||||||
|
|
||||||
|
**Measured** (deterministic fixed-timestep stepper): flow-lock exact (3 s at flow 14 → s=44.0);
|
||||||
|
boost peak 30.8 u/s; heat cycle 4 s fire → 2 s lockout; wall slam 13.3 coat at 11.3 u/s impact with
|
||||||
|
forward speed untouched; **10 combat draws at 55 live enemies** (budget ≤80); ~5.7k combat tris;
|
||||||
|
0.10 ms/frame CPU; **0 leaks** across 20 create→dispose cycles. fps deliberately not claimed — rAF
|
||||||
|
doesn't run in the automated pane. Evidence in `docs/shots/laneB/`.
|
||||||
|
|
||||||
|
**Findings escalated** (detail in LANE_B_NOTES):
|
||||||
|
1. **`tools/qa.sh` gate #1 never checks anything** — `node --check` silently exits 0 on ESM. I shipped
|
||||||
|
a real SyntaxError past a GREEN qa. Fix + verification handed to F.
|
||||||
|
2. **Surf is structurally broken** — the peristalsis wave (13.64 u/s) is slower than the player
|
||||||
|
(19.6 u/s), so level 2's signature mechanic is a trap: riding a crest loses to mashing throttle.
|
||||||
|
Not tunable from my lane (measured: identical ~3.0 u gained at gain 0.4/0.75/1.2). Escalated to
|
||||||
|
A/F with two options and a request for `biome.waveSpeed`.
|
||||||
|
3. **Custom ShaderMaterials need `#include <colorspace_fragment>`** — without it ART_BIBLE's hostile
|
||||||
|
amber `#ff5a2a` reaches the display as rgb(255,26,6). Fixed in mine; F's stub and A's walls inherit it.
|
||||||
|
4. `flowPulse` missing from the TECH contract; nobody owns the `level:event` emitter; `world.sample()`
|
||||||
|
allocates on the hot path; `player:state`/`combat:state` bus additions needed for E's HUD.
|
||||||
|
|
||||||
|
**Blocked on nothing.** Built entirely against the stub per instructions; boot.js still has no player
|
||||||
|
wiring (F's file), so `web/js/flight/dev.html` is a dev harness that boots stub+player+combat. It
|
||||||
|
retires the moment F pastes the snippet in NOTES §→ Lane F #1.
|
||||||
BIN
docs/shots/laneB/round1_boost.webp
Normal file
BIN
docs/shots/laneB/round1_boost.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
docs/shots/laneB/round1_combat.webp
Normal file
BIN
docs/shots/laneB/round1_combat.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
docs/shots/laneB/round1_enemies.webp
Normal file
BIN
docs/shots/laneB/round1_enemies.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
docs/shots/laneB/round1_wallgraze.webp
Normal file
BIN
docs/shots/laneB/round1_wallgraze.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
59
web/js/combat/balance.js
Normal file
59
web/js/combat/balance.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
// combat/balance.js (Lane B) — every combat number. Sibling of flight/tuning.js: that file
|
||||||
|
// decides how ENDO-1 handles, this one decides what the fight is worth.
|
||||||
|
//
|
||||||
|
// Sized against the stub esophagus (flow 14 u/s, playable disc radius ~7..10 u) and against
|
||||||
|
// the player's own numbers: disc top speed 13 u/s, coat 100 with 8/s regen after 2.5 s.
|
||||||
|
|
||||||
|
export const BALANCE = {
|
||||||
|
// Lysozyme cannon: infinite ammo, heat-limited (GDD). Heat is the whole design — the gun
|
||||||
|
// is free, so the cost has to be time. Sustained fire nets +25 heat/s (11 shots/s × 5.5,
|
||||||
|
// less 30/s cooling) => ~4 s of held fire before lockout, then ~2 s to recover to resumeAt.
|
||||||
|
// Burst and you never see the meter; hold and you're disarmed exactly when it's busiest.
|
||||||
|
cannon: {
|
||||||
|
rof: 11, // shots/s
|
||||||
|
speed: 95, // u/s in world space (closing speed ~75 with player flow)
|
||||||
|
life: 1.15, // s => ~109 u range, comfortably past the fog
|
||||||
|
damage: 12,
|
||||||
|
radius: 0.35,
|
||||||
|
heatPerShot: 5.5,
|
||||||
|
coolRate: 30, // /s
|
||||||
|
overheatAt: 100,
|
||||||
|
resumeAt: 40, // hysteresis — no stutter-firing on the edge of lockout
|
||||||
|
spread: 0.004, // rad, seeded — a hair of life, not a handicap
|
||||||
|
pool: 96, // rof × life = ~13 in flight; 96 covers spikes with room
|
||||||
|
},
|
||||||
|
|
||||||
|
// Antacid torpedo: the dual-use secondary (GDD). Also neutralizes an acid zone for ~10 s
|
||||||
|
// via the level:neutralize bus event — A/C consume it. Ammo-limited, pickup-fed (round 2).
|
||||||
|
torpedo: {
|
||||||
|
rof: 0.8, speed: 42, life: 3.2, damage: 45, splash: 9, radius: 0.7,
|
||||||
|
ammo: 6, ammoMax: 6,
|
||||||
|
neutralizeRadius: 14, neutralizeDuration: 10,
|
||||||
|
pool: 8,
|
||||||
|
},
|
||||||
|
|
||||||
|
enemies: {
|
||||||
|
// amylase droplet — slow floater with a corrosive contact aura. A soft obstacle: it
|
||||||
|
// doesn't chase, it taxes the lane it sits in. Aura ticks discretely (see enemies.js).
|
||||||
|
floater: { hp: 24, radius: 2.2, score: 100, drift: 1.6, bob: 0.5,
|
||||||
|
auraRadius: 4.2, auraDps: 18, auraTick: 0.2, pool: 24 },
|
||||||
|
|
||||||
|
// pepsin — small fast seeker, dissolves on contact. Chases in spline space (s,x,y), so
|
||||||
|
// a pursuit costs three floats. speed 19 > player flow 14: it WILL catch you from
|
||||||
|
// behind, and the answer is to shoot it or boost, not to outrun it at throttle 1.4.
|
||||||
|
seeker: { hp: 14, radius: 1.0, score: 150, speed: 19, turn: 2.4, contact: 16,
|
||||||
|
aggroS: 60, pool: 32 },
|
||||||
|
|
||||||
|
// antibody turret (Peyer's patch) — wall-mounted, fires homing darts. Dart turn radius
|
||||||
|
// is speed/turn = 24/1.8 = 13.3 u, wider than the tube itself: it can never corner you,
|
||||||
|
// only punish a straight line. Dodgeable by design (charter).
|
||||||
|
turret: { hp: 40, radius: 1.6, score: 250, fireEvery: 1.9, rangeS: 55,
|
||||||
|
dartSpeed: 24, dartTurn: 1.8, dartLife: 4.5, dartDamage: 14, pool: 12 },
|
||||||
|
},
|
||||||
|
|
||||||
|
darts: { pool: 48, radius: 0.45 },
|
||||||
|
|
||||||
|
// Chained kills feed the combo meter (GDD: score × style multiplier; E's music layer
|
||||||
|
// listens). Window is generous enough to cross a fold, tight enough to mean something.
|
||||||
|
combo: { window: 2.5 },
|
||||||
|
};
|
||||||
334
web/js/combat/enemies.js
Normal file
334
web/js/combat/enemies.js
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
// combat/enemies.js (Lane B) — the enemy framework and its round-1 archetypes.
|
||||||
|
//
|
||||||
|
// Enemies live in the SAME spline space as the player: (s, x, y). That's the whole trick —
|
||||||
|
// a seeker's pursuit is three float subtractions, not a pathfind, and "is it in front of
|
||||||
|
// me" is `e.s > player.s`. World position is derived once per frame for rendering and hit
|
||||||
|
// tests, never stored as truth.
|
||||||
|
//
|
||||||
|
// Behaviours are one flat update pass over a live array. Visuals are one InstancedMesh per
|
||||||
|
// type (ART_BIBLE emissive colours), so the whole roster is 3 draws + 1 for darts. When
|
||||||
|
// Lane D's GLBs land, swap the pool's geometry and the logic above never notices.
|
||||||
|
//
|
||||||
|
// Determinism: every random here comes from the seeded rng('enemies') stream.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { BALANCE as B } from './balance.js';
|
||||||
|
import { emissiveMat, EMISSIVE } from '../flight/emissive.js';
|
||||||
|
|
||||||
|
const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _scale = new THREE.Vector3();
|
||||||
|
const _dir = new THREE.Vector3(), _v = new THREE.Vector3();
|
||||||
|
const FORWARD = new THREE.Vector3(0, 0, 1);
|
||||||
|
const BUCKET = 20; // u of s per broad-phase bucket
|
||||||
|
|
||||||
|
// Round-1 roster. `geo` is the procedural fallback; D's model swaps in via assets.get.
|
||||||
|
const ARCHETYPES = {
|
||||||
|
floater: {
|
||||||
|
cfg: B.enemies.floater,
|
||||||
|
geo: () => new THREE.IcosahedronGeometry(B.enemies.floater.radius, 1),
|
||||||
|
color: EMISSIVE.hostile,
|
||||||
|
},
|
||||||
|
seeker: {
|
||||||
|
cfg: B.enemies.seeker,
|
||||||
|
geo: () => new THREE.ConeGeometry(B.enemies.seeker.radius, B.enemies.seeker.radius * 2.6, 6),
|
||||||
|
color: EMISSIVE.hostile,
|
||||||
|
},
|
||||||
|
turret: {
|
||||||
|
cfg: B.enemies.turret,
|
||||||
|
geo: () => new THREE.CylinderGeometry(B.enemies.turret.radius * 0.5, B.enemies.turret.radius, B.enemies.turret.radius * 1.6, 7),
|
||||||
|
color: EMISSIVE.hostile,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createEnemies({ scene, world, bus, rng, assets = null }) {
|
||||||
|
const rand = rng ? rng('enemies') : () => 0.5;
|
||||||
|
const pools = {};
|
||||||
|
let nextId = 1;
|
||||||
|
const live = [];
|
||||||
|
const buckets = new Map();
|
||||||
|
let onDeath = null, onBlast = null; // set by combat/index.js
|
||||||
|
const _playerPos = new THREE.Vector3(); // player world pos, resolved once per frame
|
||||||
|
|
||||||
|
for (const [type, def] of Object.entries(ARCHETYPES)) {
|
||||||
|
const glb = assets?.get?.('models', type) ?? null;
|
||||||
|
const geo = glb?.geometry ?? def.geo();
|
||||||
|
const mat = emissiveMat(def.color);
|
||||||
|
const mesh = new THREE.InstancedMesh(geo, mat, def.cfg.pool);
|
||||||
|
mesh.frustumCulled = false;
|
||||||
|
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||||
|
mesh.count = 0;
|
||||||
|
scene.add(mesh);
|
||||||
|
pools[type] = { mesh, geo, mat, cfg: def.cfg, slots: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- darts: the turret's homing projectile (hostile => white-hot) ------------------
|
||||||
|
const dartGeo = new THREE.ConeGeometry(B.darts.radius, B.darts.radius * 3, 5);
|
||||||
|
dartGeo.rotateX(Math.PI / 2); // author along +Z like every projectile
|
||||||
|
const dartMat = emissiveMat(EMISSIVE.hostileShot, { additive: true });
|
||||||
|
const dartMesh = new THREE.InstancedMesh(dartGeo, dartMat, B.darts.pool);
|
||||||
|
dartMesh.frustumCulled = false;
|
||||||
|
dartMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||||
|
dartMesh.count = 0;
|
||||||
|
scene.add(dartMesh);
|
||||||
|
const darts = Array.from({ length: B.darts.pool }, () => ({
|
||||||
|
alive: false, pos: new THREE.Vector3(), vel: new THREE.Vector3(),
|
||||||
|
s: 0, sSpeed: 0, life: 0, turn: 0, damage: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const discToWorld = (out, frame, x, y) =>
|
||||||
|
out.copy(frame.pos).addScaledVector(frame.nor, y).addScaledVector(frame.bin, x);
|
||||||
|
|
||||||
|
// --- spawn ------------------------------------------------------------------------
|
||||||
|
// Charter API: spawn(type, {s, theta, rho}). Level events give us only an `s` and a
|
||||||
|
// count, so theta/rho are filled from the seeded stream — same seed, same encounter.
|
||||||
|
function spawn(type, { s = 0, theta = null, rho = null } = {}) {
|
||||||
|
const pool = pools[type];
|
||||||
|
if (!pool) { console.warn(`[combat] unknown enemy type "${type}"`); return null; }
|
||||||
|
if (pool.slots.length >= pool.cfg.pool) return null; // pool is the population cap
|
||||||
|
|
||||||
|
const cfg = pool.cfg;
|
||||||
|
s = THREE.MathUtils.clamp(s, 0, world.length);
|
||||||
|
const th = theta ?? rand() * Math.PI * 2;
|
||||||
|
const wall = world.wallRho(s, th) - cfg.radius;
|
||||||
|
// turrets bolt to the wall; everything else floats somewhere in the lumen
|
||||||
|
const r = rho ?? (type === 'turret' ? wall : wall * (0.25 + rand() * 0.6));
|
||||||
|
|
||||||
|
const e = {
|
||||||
|
id: nextId++, type, cfg,
|
||||||
|
s, x: Math.sin(th) * r, y: Math.cos(th) * r, // theta = atan2(x, y), per the frame convention
|
||||||
|
vs: 0, vx: 0, vy: 0,
|
||||||
|
hp: cfg.hp, radius: cfg.radius, alive: true,
|
||||||
|
pos: new THREE.Vector3(), flash: 0, phase: rand() * Math.PI * 2,
|
||||||
|
cooldown: rand() * (cfg.fireEvery ?? 1), // desync turret volleys
|
||||||
|
auraT: 0,
|
||||||
|
};
|
||||||
|
live.push(e);
|
||||||
|
pool.slots.push(e);
|
||||||
|
bus.emit('enemy:spawn', { id: e.id, type });
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- damage / death ---------------------------------------------------------------
|
||||||
|
let comboN = 0, comboT = 0;
|
||||||
|
function hurt(e, dmg, kind = 'hit') {
|
||||||
|
if (!e.alive) return;
|
||||||
|
e.hp -= dmg;
|
||||||
|
e.flash = 1;
|
||||||
|
if (e.hp > 0) { bus.emit('audio:cue', { name: 'enemy_hit' }); return; }
|
||||||
|
kill(e, kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function kill(e, kind) {
|
||||||
|
e.alive = false;
|
||||||
|
comboN++; comboT = B.combo.window;
|
||||||
|
bus.emit('enemy:die', { id: e.id, type: e.type, s: e.s, score: e.cfg.score, kind });
|
||||||
|
bus.emit('combo', { n: comboN });
|
||||||
|
bus.emit('audio:cue', { name: 'enemy_die' });
|
||||||
|
onDeath?.(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- broad phase ------------------------------------------------------------------
|
||||||
|
function rebuildBuckets() {
|
||||||
|
buckets.clear();
|
||||||
|
for (const e of live) {
|
||||||
|
const b = Math.floor(e.s / BUCKET);
|
||||||
|
let arr = buckets.get(b);
|
||||||
|
if (!arr) buckets.set(b, (arr = []));
|
||||||
|
arr.push(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visits every live enemy whose s-bucket could contain something within `radius` of `s`.
|
||||||
|
// Callers still do the exact world-space distance test — this only narrows the field.
|
||||||
|
function forEachNear(s, radius, fn) {
|
||||||
|
const lo = Math.floor((s - radius - BUCKET) / BUCKET);
|
||||||
|
const hi = Math.floor((s + radius + BUCKET) / BUCKET);
|
||||||
|
for (let b = lo; b <= hi; b++) {
|
||||||
|
const arr = buckets.get(b);
|
||||||
|
if (!arr) continue;
|
||||||
|
for (const e of arr) if (e.alive) fn(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- behaviours -------------------------------------------------------------------
|
||||||
|
function update(dt, player) {
|
||||||
|
const ps = player.state;
|
||||||
|
// resolve the player's world position ONCE — darts and turrets all want it, and
|
||||||
|
// world.sample() allocates (see NOTES → Lane A: an out-param sample would kill the
|
||||||
|
// last of our per-frame garbage).
|
||||||
|
discToWorld(_playerPos, world.sample(ps.s), ps.x, ps.y);
|
||||||
|
|
||||||
|
for (const e of live) {
|
||||||
|
if (!e.alive) continue;
|
||||||
|
e.flash = Math.max(0, e.flash - dt * 6);
|
||||||
|
const cfg = e.cfg;
|
||||||
|
|
||||||
|
if (e.type === 'floater') {
|
||||||
|
// drifts and bobs; taxes the lane it occupies rather than chasing
|
||||||
|
e.phase += dt * cfg.bob;
|
||||||
|
e.s += Math.sin(e.phase) * cfg.drift * dt;
|
||||||
|
e.x += Math.cos(e.phase * 0.7) * cfg.drift * dt;
|
||||||
|
e.y += Math.sin(e.phase * 0.9) * cfg.drift * dt;
|
||||||
|
|
||||||
|
if (ps.alive) {
|
||||||
|
const d = Math.hypot(e.s - ps.s, e.x - ps.x, e.y - ps.y);
|
||||||
|
e.auraT -= dt;
|
||||||
|
if (d < cfg.auraRadius + player.radius && e.auraT <= 0) {
|
||||||
|
// discrete ticks, not per-frame: continuous player:damage would spam the bus
|
||||||
|
// and pin E's damage flash on (see flight/player.js damage/drain split)
|
||||||
|
player.damage(cfg.auraDps * cfg.auraTick, 'aura');
|
||||||
|
e.auraT = cfg.auraTick;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (e.type === 'seeker') {
|
||||||
|
// pursue in spline space — a chase for three float subtractions
|
||||||
|
const ds = ps.s - e.s, dx = ps.x - e.x, dy = ps.y - e.y;
|
||||||
|
const d = Math.hypot(ds, dx, dy) || 1e-5;
|
||||||
|
if (ps.alive && Math.abs(ds) < cfg.aggroS) {
|
||||||
|
const k = Math.min(1, cfg.turn * dt);
|
||||||
|
e.vs += ((ds / d) * cfg.speed - e.vs) * k;
|
||||||
|
e.vx += ((dx / d) * cfg.speed - e.vx) * k;
|
||||||
|
e.vy += ((dy / d) * cfg.speed - e.vy) * k;
|
||||||
|
}
|
||||||
|
e.s += e.vs * dt; e.x += e.vx * dt; e.y += e.vy * dt;
|
||||||
|
|
||||||
|
if (ps.alive && d < cfg.radius + player.radius) {
|
||||||
|
player.damage(cfg.contact, 'contact');
|
||||||
|
onBlast?.(e.pos, 1.6, 0.25);
|
||||||
|
kill(e, 'contact'); // pepsin dissolves on the hit (GDD)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if (e.type === 'turret') {
|
||||||
|
// wall-mounted; fires only at what's in range and still ahead of it
|
||||||
|
e.cooldown -= dt;
|
||||||
|
if (ps.alive && e.cooldown <= 0 && Math.abs(e.s - ps.s) < cfg.rangeS) {
|
||||||
|
fireDart(e);
|
||||||
|
e.cooldown = cfg.fireEvery;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// everything stays inside the tube
|
||||||
|
const rho = Math.hypot(e.x, e.y);
|
||||||
|
const maxR = world.wallRho(e.s, Math.atan2(e.x, e.y)) - e.radius * 0.5;
|
||||||
|
if (rho > maxR && rho > 1e-5) {
|
||||||
|
const k = maxR / rho;
|
||||||
|
e.x *= k; e.y *= k;
|
||||||
|
if (e.type === 'seeker') { e.vx *= 0.4; e.vy *= 0.4; }
|
||||||
|
}
|
||||||
|
e.s = THREE.MathUtils.clamp(e.s, 0, world.length);
|
||||||
|
discToWorld(e.pos, world.sample(e.s), e.x, e.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDarts(dt, player);
|
||||||
|
reap();
|
||||||
|
rebuildBuckets();
|
||||||
|
render();
|
||||||
|
|
||||||
|
if (comboT > 0 && (comboT -= dt) <= 0 && comboN) { comboN = 0; bus.emit('combo', { n: 0 }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fireDart(e) {
|
||||||
|
const d = darts.find((x) => !x.alive);
|
||||||
|
if (!d) return;
|
||||||
|
d.alive = true;
|
||||||
|
d.pos.copy(e.pos);
|
||||||
|
_dir.copy(_playerPos).sub(e.pos).normalize(); // aim at where the player is now
|
||||||
|
d.vel.copy(_dir).multiplyScalar(e.cfg.dartSpeed);
|
||||||
|
d.life = e.cfg.dartLife;
|
||||||
|
d.s = e.s;
|
||||||
|
d.sSpeed = d.vel.dot(world.sample(e.s).tan);
|
||||||
|
d.turn = e.cfg.dartTurn;
|
||||||
|
d.damage = e.cfg.dartDamage;
|
||||||
|
bus.emit('audio:cue', { name: 'dart' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDarts(dt, player) {
|
||||||
|
const ps = player.state;
|
||||||
|
let n = 0;
|
||||||
|
for (const d of darts) {
|
||||||
|
if (!d.alive) continue;
|
||||||
|
// Turn-rate-limited homing: swing the velocity toward the player by at most turn·dt
|
||||||
|
// radians. Turn radius (dartSpeed/dartTurn = 13.3 u) is wider than the tube itself,
|
||||||
|
// so a dart can never corner you — it only punishes flying straight. That's the design.
|
||||||
|
if (ps.alive) {
|
||||||
|
const speed = d.vel.length();
|
||||||
|
_dir.copy(_playerPos).sub(d.pos);
|
||||||
|
if (_dir.lengthSq() > 1e-8 && speed > 1e-5) {
|
||||||
|
_dir.normalize();
|
||||||
|
_v.copy(d.vel).divideScalar(speed); // current heading, unit
|
||||||
|
const angle = Math.acos(THREE.MathUtils.clamp(_v.dot(_dir), -1, 1));
|
||||||
|
if (angle > 1e-4) {
|
||||||
|
// nlerp toward the target heading, capped at the turn budget for this frame.
|
||||||
|
// At these step sizes it tracks slerp closely and costs no allocation.
|
||||||
|
const t = Math.min(1, (d.turn * dt) / angle);
|
||||||
|
_v.lerp(_dir, t).normalize();
|
||||||
|
d.vel.copy(_v).multiplyScalar(speed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.pos.addScaledVector(d.vel, dt);
|
||||||
|
d.s += d.sSpeed * dt;
|
||||||
|
d.life -= dt;
|
||||||
|
|
||||||
|
let done = d.life <= 0;
|
||||||
|
if (!done && ps.alive) {
|
||||||
|
const r = player.radius + B.darts.radius;
|
||||||
|
if (d.pos.distanceToSquared(_playerPos) <= r * r) {
|
||||||
|
if (player.damage(d.damage, 'dart')) onBlast?.(d.pos, 1.2, 0.2);
|
||||||
|
done = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!done && world.collide(d.pos, B.darts.radius)) done = true;
|
||||||
|
if (done) { d.alive = false; continue; }
|
||||||
|
|
||||||
|
_dir.copy(d.vel).normalize();
|
||||||
|
_q.setFromUnitVectors(FORWARD, _dir);
|
||||||
|
dartMesh.setMatrixAt(n++, _m.compose(d.pos, _q, _scale.set(1, 1, 1)));
|
||||||
|
}
|
||||||
|
dartMesh.count = n;
|
||||||
|
dartMesh.instanceMatrix.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reap() {
|
||||||
|
for (let i = live.length - 1; i >= 0; i--) if (!live[i].alive) live.splice(i, 1);
|
||||||
|
for (const pool of Object.values(pools))
|
||||||
|
for (let i = pool.slots.length - 1; i >= 0; i--) if (!pool.slots[i].alive) pool.slots.splice(i, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
for (const [type, pool] of Object.entries(pools)) {
|
||||||
|
let n = 0;
|
||||||
|
for (const e of pool.slots) {
|
||||||
|
if (!e.alive) continue;
|
||||||
|
if (type === 'seeker') {
|
||||||
|
_dir.set(e.vs, e.vx, e.vy);
|
||||||
|
_q.setFromUnitVectors(FORWARD, _dir.lengthSq() > 1e-6 ? _dir.normalize() : FORWARD);
|
||||||
|
} else {
|
||||||
|
_q.setFromAxisAngle(FORWARD, e.phase);
|
||||||
|
}
|
||||||
|
pool.mesh.setMatrixAt(n++, _m.compose(e.pos, _q, _scale.setScalar(1)));
|
||||||
|
}
|
||||||
|
pool.mesh.count = n;
|
||||||
|
pool.mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
// one flash uniform per type: a hit reads as the whole cohort blinking, which is
|
||||||
|
// wrong. Round 2: per-instance colour attribute. Noted in NOTES.
|
||||||
|
const hot = pool.slots.some((e) => e.alive && e.flash > 0.5);
|
||||||
|
pool.mat.uniforms.uFlash.value = hot ? 0.6 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
spawn, hurt, forEachNear, update,
|
||||||
|
get count() { return live.length; },
|
||||||
|
get live() { return live; },
|
||||||
|
setBlastFx(fn) { onBlast = fn; },
|
||||||
|
setOnDeath(fn) { onDeath = fn; },
|
||||||
|
clear() { for (const e of live) e.alive = false; for (const d of darts) d.alive = false; reap(); },
|
||||||
|
dispose() {
|
||||||
|
for (const pool of Object.values(pools)) {
|
||||||
|
scene.remove(pool.mesh); pool.mesh.dispose(); pool.geo.dispose(); pool.mat.dispose();
|
||||||
|
}
|
||||||
|
scene.remove(dartMesh); dartMesh.dispose(); dartGeo.dispose(); dartMat.dispose();
|
||||||
|
live.length = 0; buckets.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
58
web/js/combat/index.js
Normal file
58
web/js/combat/index.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
// combat/index.js (Lane B) — the createCombat factory. Composes enemies + weapons and
|
||||||
|
// feeds them Lane C's level events. All the wiring, none of the behaviour.
|
||||||
|
//
|
||||||
|
// Update order inside a frame is load-bearing:
|
||||||
|
// boot: player.update(dt) -> combat.update(dt)
|
||||||
|
// here: enemies.update(dt) -> weapons.update(dt)
|
||||||
|
// Enemies move first so weapons hit-test against this frame's positions, not last frame's.
|
||||||
|
|
||||||
|
import { createEnemies } from './enemies.js';
|
||||||
|
import { createWeapons } from './weapons.js';
|
||||||
|
|
||||||
|
export function createCombat({ scene, world, bus, rng, flags = {}, assets = null, player }) {
|
||||||
|
const enemies = createEnemies({ scene, world, bus, rng, assets });
|
||||||
|
const weapons = createWeapons({ scene, world, bus, rng, player, targets: enemies });
|
||||||
|
|
||||||
|
enemies.setBlastFx((pos, size, dur) => weapons.spawnBlast(pos, size, dur));
|
||||||
|
enemies.setOnDeath((e) => weapons.spawnBlast(e.pos, e.radius * 1.6, 0.3));
|
||||||
|
|
||||||
|
const score = { value: 0, kills: 0 };
|
||||||
|
|
||||||
|
// Lane C's events arrive on the bus as the player crosses their `s`. We only care about
|
||||||
|
// `spawn`; checkpoints/hazards/bosses belong to C and E this round. Unknown types are
|
||||||
|
// ignored on purpose — C may add event kinds without breaking combat.
|
||||||
|
const offEvent = bus.on('level:event', (ev) => {
|
||||||
|
if (!ev || ev.type !== 'spawn') return;
|
||||||
|
const n = ev.count ?? 1;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
// fan the group out over a short stretch of s so a count of 6 is an encounter,
|
||||||
|
// not a wall of enemies at one arclength
|
||||||
|
enemies.spawn(ev.enemy, { s: (ev.s ?? 0) + i * 3.5, theta: ev.theta ?? null, rho: ev.rho ?? null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const offDie = bus.on('enemy:die', (e) => {
|
||||||
|
score.value += e.score ?? 0;
|
||||||
|
score.kills++;
|
||||||
|
});
|
||||||
|
|
||||||
|
function update(dt) {
|
||||||
|
enemies.update(dt, player);
|
||||||
|
weapons.update(dt);
|
||||||
|
// combat's slice of the HUD state (E reads player:state for the flight half)
|
||||||
|
bus.emit('combat:state', {
|
||||||
|
heat: weapons.heat, heatMax: weapons.heatMax, overheated: weapons.overheated,
|
||||||
|
ammo: weapons.ammo, ammoMax: weapons.ammoMax,
|
||||||
|
score: score.value, kills: score.kills, enemies: enemies.count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
update,
|
||||||
|
enemies, weapons, score,
|
||||||
|
dispose() {
|
||||||
|
offEvent(); offDie();
|
||||||
|
enemies.dispose(); weapons.dispose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
216
web/js/combat/weapons.js
Normal file
216
web/js/combat/weapons.js
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
// combat/weapons.js (Lane B) — the lysozyme cannon and the antacid torpedo.
|
||||||
|
//
|
||||||
|
// Pooling law: nothing is allocated after construction. Each weapon owns a fixed-capacity
|
||||||
|
// InstancedMesh; firing wakes a slot, expiry sleeps it, and `count` is set to the live
|
||||||
|
// prefix each frame. One draw call per projectile type, forever, no matter the fight.
|
||||||
|
//
|
||||||
|
// Projectiles carry an `s` that is advanced LINEARLY (never re-projected). It is only a
|
||||||
|
// broad-phase hint for the s-bucket lookup — the actual hit test is a world-space distance
|
||||||
|
// compare, so a few units of drift over a pellet's 1.15 s life costs nothing and saves us a
|
||||||
|
// world.project() per pellet per frame.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { BALANCE as B } from './balance.js';
|
||||||
|
import { emissiveMat, EMISSIVE } from '../flight/emissive.js';
|
||||||
|
|
||||||
|
const _v = new THREE.Vector3(), _q = new THREE.Quaternion(), _m = new THREE.Matrix4();
|
||||||
|
const _scale = new THREE.Vector3(), _dir = new THREE.Vector3();
|
||||||
|
const FORWARD = new THREE.Vector3(0, 0, 1);
|
||||||
|
|
||||||
|
// A fixed-capacity instanced pool. Every visual in combat/ is one of these.
|
||||||
|
function makePool(scene, geo, mat, cap, makeSlot) {
|
||||||
|
const mesh = new THREE.InstancedMesh(geo, mat, cap);
|
||||||
|
mesh.frustumCulled = false; // projectiles live around the camera anyway
|
||||||
|
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||||
|
mesh.count = 0;
|
||||||
|
scene.add(mesh);
|
||||||
|
const slots = Array.from({ length: cap }, makeSlot);
|
||||||
|
return {
|
||||||
|
mesh, slots,
|
||||||
|
take() { for (const s of slots) if (!s.alive) { s.alive = true; return s; } return null; },
|
||||||
|
// Pack live slots into the front of the instance buffer; count is the live prefix.
|
||||||
|
flush(write) {
|
||||||
|
let n = 0;
|
||||||
|
for (const s of slots) if (s.alive) write(s, n++);
|
||||||
|
mesh.count = n;
|
||||||
|
mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
return n;
|
||||||
|
},
|
||||||
|
dispose() { scene.remove(mesh); mesh.dispose(); geo.dispose(); mat.dispose(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWeapons({ scene, world, bus, rng, player, targets }) {
|
||||||
|
const rand = rng ? rng('weapons') : () => 0.5;
|
||||||
|
|
||||||
|
// --- lysozyme cannon --------------------------------------------------------------
|
||||||
|
const pelletGeo = new THREE.SphereGeometry(B.cannon.radius, 6, 4);
|
||||||
|
const cannon = makePool(scene, pelletGeo, emissiveMat(EMISSIVE.neutral, { additive: true }),
|
||||||
|
B.cannon.pool, () => ({ alive: false, pos: new THREE.Vector3(), vel: new THREE.Vector3(), s: 0, sSpeed: 0, life: 0 }));
|
||||||
|
|
||||||
|
// --- antacid torpedo --------------------------------------------------------------
|
||||||
|
const torpGeo = new THREE.SphereGeometry(B.torpedo.radius, 8, 6);
|
||||||
|
const torps = makePool(scene, torpGeo, emissiveMat(EMISSIVE.acid, { additive: true }),
|
||||||
|
B.torpedo.pool, () => ({ alive: false, pos: new THREE.Vector3(), vel: new THREE.Vector3(), s: 0, sSpeed: 0, life: 0 }));
|
||||||
|
|
||||||
|
// --- blasts (kills + torpedo detonations) -----------------------------------------
|
||||||
|
const blastGeo = new THREE.SphereGeometry(1, 10, 8);
|
||||||
|
const blasts = makePool(scene, blastGeo, emissiveMat(EMISSIVE.hostileShot, { additive: true, opacity: 0.7 }),
|
||||||
|
24, () => ({ alive: false, pos: new THREE.Vector3(), t: 0, dur: 0.35, size: 1 }));
|
||||||
|
|
||||||
|
const heat = { value: 0, locked: false };
|
||||||
|
let fireTimer = 0, torpTimer = 0;
|
||||||
|
let ammo = B.torpedo.ammo;
|
||||||
|
|
||||||
|
function spawnBlast(pos, size, dur = 0.35) {
|
||||||
|
const b = blasts.take();
|
||||||
|
if (!b) return;
|
||||||
|
b.pos.copy(pos); b.t = 0; b.size = size; b.dur = dur;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fireCannon() {
|
||||||
|
const p = cannon.take();
|
||||||
|
if (!p) return;
|
||||||
|
_dir.copy(player.aimPoint).sub(player.position).normalize();
|
||||||
|
_dir.x += (rand() - 0.5) * B.cannon.spread;
|
||||||
|
_dir.y += (rand() - 0.5) * B.cannon.spread;
|
||||||
|
_dir.normalize();
|
||||||
|
p.pos.copy(player.position);
|
||||||
|
p.vel.copy(_dir).multiplyScalar(B.cannon.speed);
|
||||||
|
p.life = B.cannon.life;
|
||||||
|
p.s = player.state.s;
|
||||||
|
p.sSpeed = p.vel.dot(world.sample(p.s).tan);
|
||||||
|
heat.value += B.cannon.heatPerShot;
|
||||||
|
bus.emit('audio:cue', { name: 'cannon' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fireTorpedo() {
|
||||||
|
const t = torps.take();
|
||||||
|
if (!t) return;
|
||||||
|
_dir.copy(player.aimPoint).sub(player.position).normalize();
|
||||||
|
t.pos.copy(player.position);
|
||||||
|
t.vel.copy(_dir).multiplyScalar(B.torpedo.speed);
|
||||||
|
t.life = B.torpedo.life;
|
||||||
|
t.s = player.state.s;
|
||||||
|
t.sSpeed = t.vel.dot(world.sample(t.s).tan);
|
||||||
|
ammo--;
|
||||||
|
bus.emit('audio:cue', { name: 'torpedo' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dual-use bit (GDD): a torpedo both damages and neutralizes acid for ~10 s.
|
||||||
|
// Lanes A and C consume this; we emit it faithfully whether or not anyone listens yet.
|
||||||
|
function detonate(t) {
|
||||||
|
spawnBlast(t.pos, B.torpedo.splash, 0.5);
|
||||||
|
const s = THREE.MathUtils.clamp(t.s, 0, world.length);
|
||||||
|
bus.emit('level:neutralize', { s, radius: B.torpedo.neutralizeRadius, duration: B.torpedo.neutralizeDuration });
|
||||||
|
bus.emit('audio:cue', { name: 'torpedo_blast' });
|
||||||
|
targets.forEachNear(s, B.torpedo.splash, (e) => {
|
||||||
|
const d = e.pos.distanceTo(t.pos);
|
||||||
|
if (d > B.torpedo.splash + e.radius) return;
|
||||||
|
const falloff = 1 - d / (B.torpedo.splash + e.radius);
|
||||||
|
targets.hurt(e, B.torpedo.damage * falloff, 'torpedo');
|
||||||
|
});
|
||||||
|
t.alive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepProjectile(p, dt) {
|
||||||
|
p.pos.addScaledVector(p.vel, dt);
|
||||||
|
p.s += p.sSpeed * dt; // broad-phase hint only — see header
|
||||||
|
p.life -= dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeProjectile(p, i, mesh, stretch) {
|
||||||
|
_dir.copy(p.vel).normalize();
|
||||||
|
_q.setFromUnitVectors(FORWARD, _dir);
|
||||||
|
_scale.set(1, 1, stretch);
|
||||||
|
mesh.setMatrixAt(i, _m.compose(p.pos, _q, _scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(dt) {
|
||||||
|
const st = player.state;
|
||||||
|
|
||||||
|
// heat: cool always, lock at overheatAt, unlock at resumeAt (hysteresis)
|
||||||
|
heat.value = Math.max(0, heat.value - B.cannon.coolRate * dt);
|
||||||
|
if (heat.value >= B.cannon.overheatAt && !heat.locked) {
|
||||||
|
heat.locked = true;
|
||||||
|
bus.emit('audio:cue', { name: 'overheat' });
|
||||||
|
}
|
||||||
|
if (heat.locked && heat.value <= B.cannon.resumeAt) heat.locked = false;
|
||||||
|
|
||||||
|
const intent = player.intent;
|
||||||
|
fireTimer -= dt;
|
||||||
|
torpTimer -= dt;
|
||||||
|
if (st.alive && intent.fire && !heat.locked) {
|
||||||
|
// catch up in fixed steps so rate of fire is frame-rate independent
|
||||||
|
let guard = 8;
|
||||||
|
while (fireTimer <= 0 && !heat.locked && guard--) {
|
||||||
|
fireCannon();
|
||||||
|
fireTimer += 1 / B.cannon.rof;
|
||||||
|
if (heat.value >= B.cannon.overheatAt) heat.locked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fireTimer < 0) fireTimer = 0;
|
||||||
|
if (st.alive && intent.fire2 && torpTimer <= 0 && ammo > 0) {
|
||||||
|
fireTorpedo();
|
||||||
|
torpTimer = 1 / B.torpedo.rof;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- cannon pellets: step, hit-test, expire ---
|
||||||
|
for (const p of cannon.slots) {
|
||||||
|
if (!p.alive) continue;
|
||||||
|
stepProjectile(p, dt);
|
||||||
|
let hit = false;
|
||||||
|
targets.forEachNear(p.s, B.cannon.radius, (e) => {
|
||||||
|
if (hit) return;
|
||||||
|
const r = e.radius + B.cannon.radius;
|
||||||
|
if (p.pos.distanceToSquared(e.pos) > r * r) return;
|
||||||
|
hit = true;
|
||||||
|
targets.hurt(e, B.cannon.damage, 'cannon');
|
||||||
|
spawnBlast(p.pos, 1.1, 0.16);
|
||||||
|
});
|
||||||
|
if (hit || p.life <= 0) p.alive = false;
|
||||||
|
}
|
||||||
|
cannon.flush((p, i) => writeProjectile(p, i, cannon.mesh, 3.2));
|
||||||
|
|
||||||
|
// --- torpedoes: step, proximity-detonate, expire ---
|
||||||
|
for (const t of torps.slots) {
|
||||||
|
if (!t.alive) continue;
|
||||||
|
stepProjectile(t, dt);
|
||||||
|
let boom = t.life <= 0;
|
||||||
|
if (!boom) {
|
||||||
|
targets.forEachNear(t.s, B.torpedo.radius, (e) => {
|
||||||
|
const r = e.radius + B.torpedo.radius;
|
||||||
|
if (t.pos.distanceToSquared(e.pos) <= r * r) boom = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!boom && world.collide(t.pos, B.torpedo.radius)) boom = true;
|
||||||
|
if (boom) detonate(t);
|
||||||
|
}
|
||||||
|
torps.flush((t, i) => writeProjectile(t, i, torps.mesh, 2.0));
|
||||||
|
|
||||||
|
// --- blasts: expand and fade ---
|
||||||
|
for (const b of blasts.slots) {
|
||||||
|
if (!b.alive) continue;
|
||||||
|
b.t += dt;
|
||||||
|
if (b.t >= b.dur) b.alive = false;
|
||||||
|
}
|
||||||
|
blasts.flush((b, i) => {
|
||||||
|
const k = b.t / b.dur;
|
||||||
|
const r = b.size * (0.35 + k * 0.9);
|
||||||
|
blasts.mesh.setMatrixAt(i, _m.compose(b.pos, _q.identity(), _scale.setScalar(r)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
update,
|
||||||
|
spawnBlast,
|
||||||
|
get heat() { return heat.value; },
|
||||||
|
get heatMax() { return B.cannon.overheatAt; },
|
||||||
|
get overheated() { return heat.locked; },
|
||||||
|
get ammo() { return ammo; },
|
||||||
|
get ammoMax() { return B.torpedo.ammoMax; },
|
||||||
|
addAmmo(n) { ammo = Math.min(B.torpedo.ammoMax, ammo + n); },
|
||||||
|
get liveCount() { return cannon.mesh.count + torps.mesh.count; },
|
||||||
|
dispose() { cannon.dispose(); torps.dispose(); blasts.dispose(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
44
web/js/flight/dev.html
Normal file
44
web/js/flight/dev.html
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>GUTS — Lane B flight/combat harness</title>
|
||||||
|
<!--
|
||||||
|
Lane B's dev harness. NOT the game — web/index.html is, and it's Lane F's.
|
||||||
|
This exists because boot.js has no player wiring yet (round 0 shipped a drift camera) and
|
||||||
|
Lane B may not edit boot.js. It boots the stub world + createPlayer + createCombat so the
|
||||||
|
controller can be flown and screenshotted this round.
|
||||||
|
Retire this once F pastes the snippet in LANE_B_NOTES.md §"-> Lane F".
|
||||||
|
http://localhost:8140/js/flight/dev.html
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; overflow: hidden; background: #02100d; }
|
||||||
|
canvas { display: block; }
|
||||||
|
#dbg { position: fixed; left: 10px; bottom: 10px; color: #39e6ff;
|
||||||
|
font: 12px/1.5 ui-monospace, monospace; text-shadow: 0 0 4px #000;
|
||||||
|
pointer-events: none; z-index: 10; white-space: pre; }
|
||||||
|
#hint { position: fixed; inset: 0; display: grid; place-content: center; text-align: center;
|
||||||
|
color: #dff2ff; font: 14px/1.8 ui-monospace, monospace; background: #02100dcc;
|
||||||
|
z-index: 20; cursor: pointer; }
|
||||||
|
#hint b { color: #5affd2; font-size: 20px; letter-spacing: .2em; }
|
||||||
|
.gone { display: none !important; }
|
||||||
|
</style>
|
||||||
|
<script type="importmap">
|
||||||
|
{ "imports": { "three": "../../vendor/three.module.js",
|
||||||
|
"three/addons/": "../../vendor/addons/" } }
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="dbg"></div>
|
||||||
|
<div id="hint">
|
||||||
|
<b>ENDO-1</b>
|
||||||
|
<div>click to fly · lane B harness</div>
|
||||||
|
<div style="opacity:.6">
|
||||||
|
WASD move in the tube · mouse aim · LMB cannon · RMB/F torpedo<br>
|
||||||
|
Space boost · Shift/Ctrl throttle · ease off to surf the crest · Esc release
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="./dev.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
162
web/js/flight/dev.js
Normal file
162
web/js/flight/dev.js
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
// flight/dev.js (Lane B) — harness boot for dev.html. Mirrors the parts of Lane F's boot.js
|
||||||
|
// that Lane B needs, and NOTHING else. Not shipped; not the game's entry point.
|
||||||
|
//
|
||||||
|
// It exists so flight/combat can be flown, tuned and screenshotted while boot.js still has
|
||||||
|
// no player wiring. The exact snippet F needs to paste into boot.js is in LANE_B_NOTES.md;
|
||||||
|
// when that lands, this file's only remaining job is isolated feel-tuning.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { parseFlags } from '../core/flags.js';
|
||||||
|
import { createRng } from '../core/rng.js';
|
||||||
|
import { createBus } from '../core/bus.js';
|
||||||
|
import { createWorld as createStubWorld, STUB_LEVEL } from '../stub/world_stub.js';
|
||||||
|
import { createPlayer } from './player.js';
|
||||||
|
import { createCombat } from '../combat/index.js';
|
||||||
|
|
||||||
|
const flags = parseFlags();
|
||||||
|
const bus = createBus();
|
||||||
|
const rng = createRng(flags.seed ?? STUB_LEVEL.seed);
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
|
||||||
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||||
|
renderer.setSize(innerWidth, innerHeight);
|
||||||
|
document.body.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 600);
|
||||||
|
|
||||||
|
const world = await createStubWorld(STUB_LEVEL, { rng });
|
||||||
|
scene.add(world.group);
|
||||||
|
scene.background = new THREE.Color(world.biomeAt(0).palette.void);
|
||||||
|
|
||||||
|
const player = createPlayer({ scene, world, bus, rng, flags, camera, dom: renderer.domElement });
|
||||||
|
const combat = createCombat({ scene, world, bus, rng, flags, player });
|
||||||
|
|
||||||
|
// --- dev-only level pump ----------------------------------------------------------------
|
||||||
|
// Emits `level:event` as the player crosses each event's s. NOBODY owns this in the shipped
|
||||||
|
// build yet — TECH.md specifies the event but not its emitter. Flagged to F in NOTES; this
|
||||||
|
// local copy keeps Lane B unblocked without implementing another lane's side.
|
||||||
|
function createLevelPump(level, b) {
|
||||||
|
const evts = [...(level.events ?? [])].sort((a, x) => a.s - x.s);
|
||||||
|
let i = 0;
|
||||||
|
return {
|
||||||
|
update(s) { while (i < evts.length && s >= evts[i].s) b.emit('level:event', evts[i++]); },
|
||||||
|
reset() { i = 0; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const pump = createLevelPump(world.level, bus);
|
||||||
|
|
||||||
|
// --- DBG (mirrors boot.js so NOTES numbers are measured the same way) --------------------
|
||||||
|
const dbgEl = document.getElementById('dbg');
|
||||||
|
const hintEl = document.getElementById('hint');
|
||||||
|
let frames = 0, fpsT = 0, fps = 0;
|
||||||
|
renderer.info.autoReset = false;
|
||||||
|
const stats = { draws: 0, tris: 0 };
|
||||||
|
let hud = null;
|
||||||
|
bus.on('player:state', (s) => (hud = s));
|
||||||
|
let cstate = null;
|
||||||
|
bus.on('combat:state', (s) => (cstate = s));
|
||||||
|
|
||||||
|
window.DBG = {
|
||||||
|
get draws() { return stats.draws; },
|
||||||
|
get tris() { return stats.tris; },
|
||||||
|
get fps() { return fps; },
|
||||||
|
world, player, combat, bus, flags, renderer, scene, camera,
|
||||||
|
// Leak test (charter: "everything pooled + disposed, leak-test enter/exit"). Builds and
|
||||||
|
// tears down N player+combat pairs and reports GPU resource deltas via renderer.info.
|
||||||
|
leakTest(cycles = 5) {
|
||||||
|
// Render FIRST: renderer.info.memory only counts geometries actually uploaded to the
|
||||||
|
// GPU, so a baseline taken before any render reads 0 and the harness's own persistent
|
||||||
|
// objects then look like a leak on the next sample. Ask me how I know.
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
const before = { ...renderer.info.memory, children: scene.children.length };
|
||||||
|
for (let i = 0; i < cycles; i++) {
|
||||||
|
const p = createPlayer({ scene, world, bus, rng, flags, camera, dom: renderer.domElement });
|
||||||
|
const c = createCombat({ scene, world, bus, rng, flags, player: p });
|
||||||
|
c.enemies.spawn('floater', { s: 10 }); c.enemies.spawn('turret', { s: 20 });
|
||||||
|
p.update(1 / 60); c.update(1 / 60);
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
c.dispose(); p.dispose();
|
||||||
|
}
|
||||||
|
const after = { ...renderer.info.memory, children: scene.children.length };
|
||||||
|
return { before, after,
|
||||||
|
leaked: { geometries: after.geometries - before.geometries,
|
||||||
|
textures: after.textures - before.textures,
|
||||||
|
children: after.children - before.children } };
|
||||||
|
},
|
||||||
|
shot(name = 'shot') {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.download = `${name}.png`;
|
||||||
|
a.href = renderer.domElement.toDataURL('image/png');
|
||||||
|
a.click();
|
||||||
|
},
|
||||||
|
// helpers for eyeballing behaviours without flying to them
|
||||||
|
spawn: (type, at = 12) => combat.enemies.spawn(type, { s: player.state.s + at }),
|
||||||
|
tp: (s) => { player.state.s = s; pump.reset(); },
|
||||||
|
|
||||||
|
// Synchronous fixed-timestep advance. Two reasons this exists:
|
||||||
|
// 1. requestAnimationFrame does not run in a hidden/on-demand-rendered tab, so the loop
|
||||||
|
// can't be exercised from an automated browser session without it.
|
||||||
|
// 2. It's deterministic — DBG.step(120, 1/60) is exactly 2 s of game time every time,
|
||||||
|
// independent of frame pacing, so measurements quoted in NOTES are reproducible.
|
||||||
|
step(n = 1, dt = 1 / 60) {
|
||||||
|
for (let i = 0; i < n; i++) tick(dt);
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
stats.draws = renderer.info.render.calls;
|
||||||
|
stats.tris = renderer.info.render.triangles;
|
||||||
|
renderer.info.reset();
|
||||||
|
return { s: player.state.s, draws: stats.draws, tris: stats.tris };
|
||||||
|
},
|
||||||
|
get state() { return hud; },
|
||||||
|
get combatState() { return cstate; },
|
||||||
|
};
|
||||||
|
|
||||||
|
addEventListener('resize', () => {
|
||||||
|
camera.aspect = innerWidth / innerHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(innerWidth, innerHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The one true update order (mirror it in boot.js — see LANE_B_NOTES §-> Lane F):
|
||||||
|
// player -> level pump -> combat -> world
|
||||||
|
// player first so combat hit-tests against this frame's ship position; world last so its
|
||||||
|
// shader time matches the s we just moved to.
|
||||||
|
function tick(dt) {
|
||||||
|
player.update(dt);
|
||||||
|
pump.update(player.state.s);
|
||||||
|
combat.update(dt);
|
||||||
|
world.update(dt, player.state.s);
|
||||||
|
}
|
||||||
|
|
||||||
|
let last = performance.now();
|
||||||
|
function frame(now) {
|
||||||
|
const dt = Math.min((now - last) / 1000, 0.05);
|
||||||
|
last = now;
|
||||||
|
|
||||||
|
tick(dt);
|
||||||
|
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
stats.draws = renderer.info.render.calls;
|
||||||
|
stats.tris = renderer.info.render.triangles;
|
||||||
|
renderer.info.reset();
|
||||||
|
|
||||||
|
hintEl.classList.toggle('gone', !!document.pointerLockElement || flags.shots);
|
||||||
|
|
||||||
|
frames++; fpsT += dt;
|
||||||
|
if (fpsT >= 0.5) {
|
||||||
|
fps = Math.round(frames / fpsT); frames = 0; fpsT = 0;
|
||||||
|
if (!flags.shots && hud) {
|
||||||
|
dbgEl.textContent =
|
||||||
|
`${fps} fps · ${stats.draws} draws · ${(stats.tris / 1000) | 0}k tris\n` +
|
||||||
|
`s ${hud.s.toFixed(0)}/${hud.length | 0} spd ${hud.speed.toFixed(1)} (flow ${hud.flow}) thr ${hud.throttle.toFixed(2)}` +
|
||||||
|
`${hud.surfing ? ' ◄SURF►' : ''}${hud.iframes ? ' [i]' : ''}\n` +
|
||||||
|
`coat ${hud.coat.toFixed(0)}/${hud.coatMax} hull ${hud.hull.toFixed(0)}/${hud.hullMax} ` +
|
||||||
|
`boost ${hud.boostReady ? 'READY' : hud.boostCd.toFixed(1)}\n` +
|
||||||
|
(cstate ? `heat ${cstate.heat.toFixed(0)}/${cstate.heatMax}${cstate.overheated ? ' OVERHEAT' : ''} ` +
|
||||||
|
`torp ${cstate.ammo}/${cstate.ammoMax} score ${cstate.score} kills ${cstate.kills} live ${cstate.enemies}` : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
if (flags.shots) dbgEl.style.display = 'none';
|
||||||
|
requestAnimationFrame(frame);
|
||||||
71
web/js/flight/emissive.js
Normal file
71
web/js/flight/emissive.js
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
// flight/emissive.js (Lane B) — the one material behind every glowing thing B draws.
|
||||||
|
//
|
||||||
|
// ART_BIBLE: gameplay-relevant things glow, and the scene has NO lights (walls fake theirs
|
||||||
|
// with fresnel). So our primitives are unlit: a core colour with a rim that hots up at
|
||||||
|
// grazing angles, which reads as a volume against the dark tint instead of a flat decal.
|
||||||
|
// One material per (colour, rim) pair + InstancedMesh = 1 draw call per enemy/projectile
|
||||||
|
// type, which is how the whole combat layer fits in its 80-draw budget.
|
||||||
|
//
|
||||||
|
// Lives in flight/ rather than combat/ to keep the dependency arrow one-way: combat imports
|
||||||
|
// flight (it needs the player), flight never imports combat.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
// ART_BIBLE §Emissive code — the readability law. Never hand-pick a colour outside this.
|
||||||
|
export const EMISSIVE = {
|
||||||
|
hostile: { core: 0xff5a2a, rim: 0xffb488 }, // hot amber -> red core
|
||||||
|
hostileShot: { core: 0xffe9d0, rim: 0xffffff }, // white-hot
|
||||||
|
neutral: { core: 0x39e6ff, rim: 0xdff2ff }, // cyan (player + flora + interactive)
|
||||||
|
pickup: { core: 0x7dffb0, rim: 0xdaffe9 },
|
||||||
|
gate: { core: 0xb06aff, rim: 0xe6d0ff },
|
||||||
|
acid: { core: 0xc8ff3a, rim: 0xeaffb0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function emissiveMat({ core, rim }, { additive = false, opacity = 1 } = {}) {
|
||||||
|
return new THREE.ShaderMaterial({
|
||||||
|
transparent: opacity < 1 || additive,
|
||||||
|
depthWrite: !additive,
|
||||||
|
blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending,
|
||||||
|
uniforms: {
|
||||||
|
uCore: { value: new THREE.Color(core) },
|
||||||
|
uRim: { value: new THREE.Color(rim) },
|
||||||
|
uOpacity: { value: opacity },
|
||||||
|
uFlash: { value: 0 }, // driven on hit: 0..1 blows the whole body to rim colour
|
||||||
|
},
|
||||||
|
vertexShader: /* glsl */`
|
||||||
|
varying vec3 vN; varying vec3 vV;
|
||||||
|
void main() {
|
||||||
|
// three declares the instanceMatrix attribute for us whenever the drawn object is
|
||||||
|
// an InstancedMesh (USE_INSTANCING); this same material still works on a plain Mesh.
|
||||||
|
// (Careful: no backticks in here — this is a JS template literal.)
|
||||||
|
#ifdef USE_INSTANCING
|
||||||
|
mat4 m = modelViewMatrix * instanceMatrix;
|
||||||
|
#else
|
||||||
|
mat4 m = modelViewMatrix;
|
||||||
|
#endif
|
||||||
|
vec4 mv = m * vec4(position, 1.0);
|
||||||
|
// mat3(m) is only a true normal matrix under uniform scale — every instance we
|
||||||
|
// emit is uniformly scaled, and normalize() eats the leftover constant.
|
||||||
|
vN = normalize(mat3(m) * normal);
|
||||||
|
vV = -mv.xyz;
|
||||||
|
gl_Position = projectionMatrix * mv;
|
||||||
|
}`,
|
||||||
|
fragmentShader: /* glsl */`
|
||||||
|
varying vec3 vN; varying vec3 vV;
|
||||||
|
uniform vec3 uCore; uniform vec3 uRim; uniform float uOpacity; uniform float uFlash;
|
||||||
|
void main() {
|
||||||
|
float f = pow(1.0 - abs(dot(normalize(vN), normalize(vV))), 2.0);
|
||||||
|
vec3 col = mix(uCore, uRim, f) + uRim * f * 0.6;
|
||||||
|
col = mix(col, uRim * 1.6, uFlash);
|
||||||
|
gl_FragColor = vec4(col, uOpacity);
|
||||||
|
|
||||||
|
// MANDATORY for any custom ShaderMaterial in this project. ColorManagement is on,
|
||||||
|
// so THREE.Color(0xff5a2a) is converted to LINEAR working space on the way in, but
|
||||||
|
// a hand-written fragment shader gets no automatic conversion on the way out — it
|
||||||
|
// would write linear straight into the sRGB target. Measured: hostile amber landed
|
||||||
|
// as rgb(255,26,6) blood-red instead of rgb(255,90,42), quietly breaking the
|
||||||
|
// ART_BIBLE emissive readability law. This chunk applies linear -> sRGB.
|
||||||
|
#include <colorspace_fragment>
|
||||||
|
}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
148
web/js/flight/input.js
Normal file
148
web/js/flight/input.js
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
// flight/input.js (Lane B) — raw devices -> one normalized intent object. No game logic
|
||||||
|
// here; player.js reads `intent` and never touches an event listener.
|
||||||
|
//
|
||||||
|
// The scheme is twin-stick, which is what makes the charter's "mouse-aim + WASD" and
|
||||||
|
// "gamepad twin-stick" the same game rather than two:
|
||||||
|
// left hand (WASD / left stick) -> move the ship inside the cross-section disc
|
||||||
|
// right hand (mouse / right stick) -> aim the reticle (the ship's nose follows it, lagged)
|
||||||
|
// Shift/Ctrl (or LT/RT) -> throttle, springs back to neutral when released
|
||||||
|
// Space / A / RB -> boost
|
||||||
|
// LMB / RT / X -> fire
|
||||||
|
// WASD is therefore NOT throttle. Throttle is a modifier because it lives in [0.6,1.4] and
|
||||||
|
// spends most of its life at 1.0 — it's a lean, not a stick.
|
||||||
|
|
||||||
|
const DEADZONE = 0.18;
|
||||||
|
const dz = (v) => (Math.abs(v) < DEADZONE ? 0 : (v - Math.sign(v) * DEADZONE) / (1 - DEADZONE));
|
||||||
|
|
||||||
|
export function createInput(dom = document.body) {
|
||||||
|
const keys = new Set();
|
||||||
|
|
||||||
|
const intent = {
|
||||||
|
disc: { x: 0, y: 0 }, // −1..1 desired disc movement (x = lateral/bin, y = up/nor)
|
||||||
|
aimDelta: { x: 0, y: 0 }, // mouse pixels accumulated since the last read (consumed)
|
||||||
|
aimStick: { x: 0, y: 0 }, // −1..1 right stick
|
||||||
|
throttle: 0, // −1..1 (Ctrl .. Shift)
|
||||||
|
boost: false, // edge-triggered: true for exactly one read
|
||||||
|
fire: false, // held
|
||||||
|
fire2: false, // edge-triggered
|
||||||
|
locked: false, // pointer lock state (the "am I flying" signal)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Held inputs are tracked per-device and OR'd each sample. Sharing one flag would latch:
|
||||||
|
// the trigger sets it true, and only mouse-up ever sets it false again.
|
||||||
|
let mouseFire = false, padFire = false;
|
||||||
|
let boostEdge = false, fire2Edge = false, padBoostWas = false, padFire2Was = false;
|
||||||
|
|
||||||
|
const onKeyDown = (e) => {
|
||||||
|
keys.add(e.code);
|
||||||
|
if (e.code === 'Space') { boostEdge = true; e.preventDefault(); }
|
||||||
|
if (e.code === 'KeyF') fire2Edge = true;
|
||||||
|
};
|
||||||
|
const onKeyUp = (e) => keys.delete(e.code);
|
||||||
|
const onBlur = () => keys.clear(); // alt-tab must not leave a key stuck down
|
||||||
|
|
||||||
|
const onMouseMove = (e) => {
|
||||||
|
if (!intent.locked) return;
|
||||||
|
intent.aimDelta.x += e.movementX;
|
||||||
|
intent.aimDelta.y += e.movementY;
|
||||||
|
};
|
||||||
|
const onMouseDown = (e) => {
|
||||||
|
if (!intent.locked) { dom.requestPointerLock?.(); return; }
|
||||||
|
if (e.button === 0) mouseFire = true;
|
||||||
|
if (e.button === 2) fire2Edge = true;
|
||||||
|
};
|
||||||
|
const onMouseUp = (e) => { if (e.button === 0) mouseFire = false; };
|
||||||
|
const onLockChange = () => {
|
||||||
|
intent.locked = document.pointerLockElement === dom;
|
||||||
|
if (!intent.locked) { mouseFire = false; keys.clear(); }
|
||||||
|
};
|
||||||
|
const onContext = (e) => e.preventDefault(); // RMB is the torpedo, not a menu
|
||||||
|
|
||||||
|
addEventListener('keydown', onKeyDown);
|
||||||
|
addEventListener('keyup', onKeyUp);
|
||||||
|
addEventListener('blur', onBlur);
|
||||||
|
addEventListener('mousemove', onMouseMove);
|
||||||
|
dom.addEventListener('mousedown', onMouseDown);
|
||||||
|
addEventListener('mouseup', onMouseUp);
|
||||||
|
document.addEventListener('pointerlockchange', onLockChange);
|
||||||
|
dom.addEventListener('contextmenu', onContext);
|
||||||
|
|
||||||
|
function readPad() {
|
||||||
|
padFire = false;
|
||||||
|
const pads = navigator.getGamepads?.() || [];
|
||||||
|
for (const p of pads) {
|
||||||
|
if (!p?.connected) continue;
|
||||||
|
const [lx, ly, rx, ry] = p.axes;
|
||||||
|
// left stick wins over WASD only when actually deflected, so both can coexist
|
||||||
|
const dx = dz(lx ?? 0), dy = dz(ly ?? 0);
|
||||||
|
if (dx || dy) { intent.disc.x = dx; intent.disc.y = -dy; }
|
||||||
|
intent.aimStick.x = dz(rx ?? 0);
|
||||||
|
intent.aimStick.y = -dz(ry ?? 0);
|
||||||
|
|
||||||
|
const lt = p.buttons[6]?.value ?? 0, rt = p.buttons[7]?.value ?? 0;
|
||||||
|
if (lt > 0.05 || rt > 0.05) intent.throttle = rt - lt;
|
||||||
|
padFire = rt > 0.5;
|
||||||
|
|
||||||
|
const padBoost = !!(p.buttons[0]?.pressed || p.buttons[5]?.pressed);
|
||||||
|
if (padBoost && !padBoostWas) boostEdge = true;
|
||||||
|
padBoostWas = padBoost;
|
||||||
|
|
||||||
|
const padFire2 = !!p.buttons[2]?.pressed;
|
||||||
|
if (padFire2 && !padFire2Was) fire2Edge = true;
|
||||||
|
padFire2Was = padFire2;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
intent,
|
||||||
|
|
||||||
|
// Called once per frame by player.js, before it reads `intent`.
|
||||||
|
sample() {
|
||||||
|
intent.disc.x = (keys.has('KeyD') ? 1 : 0) - (keys.has('KeyA') ? 1 : 0);
|
||||||
|
intent.disc.y = (keys.has('KeyW') ? 1 : 0) - (keys.has('KeyS') ? 1 : 0);
|
||||||
|
intent.aimStick.x = 0; intent.aimStick.y = 0;
|
||||||
|
intent.throttle = (keys.has('ShiftLeft') || keys.has('ShiftRight') ? 1 : 0) -
|
||||||
|
(keys.has('ControlLeft') || keys.has('ControlRight') ? 1 : 0);
|
||||||
|
readPad();
|
||||||
|
intent.fire = mouseFire || padFire;
|
||||||
|
|
||||||
|
// normalize diagonal so corner-dodging isn't 41% faster than a cardinal one
|
||||||
|
const m = Math.hypot(intent.disc.x, intent.disc.y);
|
||||||
|
if (m > 1) { intent.disc.x /= m; intent.disc.y /= m; }
|
||||||
|
|
||||||
|
intent.boost = boostEdge; boostEdge = false;
|
||||||
|
intent.fire2 = fire2Edge; fire2Edge = false;
|
||||||
|
return intent;
|
||||||
|
},
|
||||||
|
|
||||||
|
// player.js consumes the accumulated mouse delta exactly once per frame
|
||||||
|
takeAim() {
|
||||||
|
const d = { x: intent.aimDelta.x, y: intent.aimDelta.y };
|
||||||
|
intent.aimDelta.x = 0; intent.aimDelta.y = 0;
|
||||||
|
return d;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Harness/test hook (flight/dev.html). Keyboard intents can be driven with real
|
||||||
|
// KeyboardEvents, but firing is gated behind pointer lock, which an automated session
|
||||||
|
// can't be granted — so the trigger needs a door. Dev-only; nothing ships against it.
|
||||||
|
_debug: {
|
||||||
|
setFire(v) { mouseFire = !!v; },
|
||||||
|
pulseFire2() { fire2Edge = true; },
|
||||||
|
pulseBoost() { boostEdge = true; },
|
||||||
|
key(code, down = true) { down ? keys.add(code) : keys.delete(code); },
|
||||||
|
},
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
removeEventListener('keydown', onKeyDown);
|
||||||
|
removeEventListener('keyup', onKeyUp);
|
||||||
|
removeEventListener('blur', onBlur);
|
||||||
|
removeEventListener('mousemove', onMouseMove);
|
||||||
|
dom.removeEventListener('mousedown', onMouseDown);
|
||||||
|
removeEventListener('mouseup', onMouseUp);
|
||||||
|
document.removeEventListener('pointerlockchange', onLockChange);
|
||||||
|
dom.removeEventListener('contextmenu', onContext);
|
||||||
|
if (document.pointerLockElement === dom) document.exitPointerLock?.();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
277
web/js/flight/player.js
Normal file
277
web/js/flight/player.js
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
// flight/player.js (Lane B) — ENDO-1's controller, camera rig and durability.
|
||||||
|
//
|
||||||
|
// THE MODEL (GDD "flow-locked hybrid", tube mode). The player does not live in world space;
|
||||||
|
// it lives in the world's spline space and is rendered into world space each frame:
|
||||||
|
//
|
||||||
|
// s arclength down the canal. Peristalsis owns it: ds/dt = flow·(throttle+surf+boost).
|
||||||
|
// You can lean on it ±40%, burst past it, or surf it. You cannot stop or reverse.
|
||||||
|
// x, y the cross-section disc offset — x along frame.bin (lateral), y along frame.nor
|
||||||
|
// (vertical). This is where ALL your agency is, and it is fully analog.
|
||||||
|
//
|
||||||
|
// worldPos = frame.pos + frame.nor·y + frame.bin·x (frame = world.sample(s))
|
||||||
|
// theta = atan2(x, y) — matches world.project()'s atan2(rel·bin, rel·nor)
|
||||||
|
//
|
||||||
|
// Everything downstream falls out of this: collision is one radius compare against
|
||||||
|
// world.wallRho, the camera is the same three numbers lagged, and enemies chase in the same
|
||||||
|
// space for pennies. Arena/6DOF mode is round 2 (see NOTES); modeAt() is guarded below.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { TUNING as T } from './tuning.js';
|
||||||
|
import { createInput } from './input.js';
|
||||||
|
import { buildShip, buildReticle } from './ship.js';
|
||||||
|
|
||||||
|
export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null, camera, dom }) {
|
||||||
|
const input = createInput(dom);
|
||||||
|
const ship = buildShip(assets);
|
||||||
|
const reticle = buildReticle();
|
||||||
|
scene.add(ship.group, reticle.mesh);
|
||||||
|
|
||||||
|
const st = {
|
||||||
|
s: 2, x: 0, y: 0, // spline-space position
|
||||||
|
vx: 0, vy: 0, // disc velocity (u/s)
|
||||||
|
ax: 0, ay: 0, // aim offset in the disc plane at s + aim.distance
|
||||||
|
throttle: 1,
|
||||||
|
boostT: 0, boostCd: 0, boostBlend: 0, iframeT: 0,
|
||||||
|
surfBlend: 0, onCrest: false,
|
||||||
|
coat: T.coat.max, hull: T.hull.max, sinceDamage: 99, wallCd: 0,
|
||||||
|
roll: 0, pitch: 0,
|
||||||
|
speed: 0, alive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// scratch — this runs 60×/s; nothing in the loop allocates
|
||||||
|
const _pos = new THREE.Vector3(), _aim = new THREE.Vector3(), _look = new THREE.Vector3();
|
||||||
|
const _aimLag = new THREE.Vector3(), _camPos = new THREE.Vector3(), _tmp = new THREE.Vector3();
|
||||||
|
let aimLagInit = false;
|
||||||
|
|
||||||
|
const discToWorld = (out, frame, x, y) =>
|
||||||
|
out.copy(frame.pos).addScaledVector(frame.nor, y).addScaledVector(frame.bin, x);
|
||||||
|
|
||||||
|
const clampS = (s) => THREE.MathUtils.clamp(s, 0, world.length);
|
||||||
|
|
||||||
|
// --- durability -------------------------------------------------------------------
|
||||||
|
// Discrete hits emit player:damage. Continuous drain (biome coatDrain, enemy auras) does
|
||||||
|
// NOT — it would spam the bus 60×/s and E would render a permanent damage flash. Lane E
|
||||||
|
// reads continuous state off player:state instead. Auras tick discretely (combat/enemies).
|
||||||
|
function damage(amount, kind = 'hit') {
|
||||||
|
if (!st.alive || st.iframeT > 0 || amount <= 0) return false;
|
||||||
|
st.sinceDamage = 0;
|
||||||
|
const toCoat = Math.min(st.coat, amount);
|
||||||
|
st.coat -= toCoat;
|
||||||
|
const leak = amount - toCoat;
|
||||||
|
if (leak > 0) st.hull = Math.max(0, st.hull - leak);
|
||||||
|
bus.emit('player:damage', { amount, kind });
|
||||||
|
bus.emit('audio:cue', { name: leak > 0 ? 'hull_hit' : 'coat_hit' });
|
||||||
|
if (st.hull <= 0 && st.alive) { st.alive = false; bus.emit('player:death', { s: st.s }); }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drain(amount) { // continuous, silent — coat only, never kills
|
||||||
|
if (!st.alive || amount <= 0) return;
|
||||||
|
st.coat = Math.max(0, st.coat - amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the loop ---------------------------------------------------------------------
|
||||||
|
function update(dt) {
|
||||||
|
const intent = input.sample();
|
||||||
|
const mouse = input.takeAim();
|
||||||
|
const biome = world.biomeAt(st.s);
|
||||||
|
|
||||||
|
if (!st.alive) { updateCamera(dt, world.sample(st.s)); return; }
|
||||||
|
|
||||||
|
// throttle: a lean, springing back to neutral
|
||||||
|
if (intent.throttle) st.throttle += intent.throttle * T.flow.throttleRate * dt;
|
||||||
|
else st.throttle += (1 - st.throttle) * Math.min(1, T.flow.throttleReturn * dt * 4);
|
||||||
|
st.throttle = THREE.MathUtils.clamp(st.throttle, T.flow.throttleMin, T.flow.throttleMax);
|
||||||
|
|
||||||
|
// boost
|
||||||
|
st.wallCd = Math.max(0, st.wallCd - dt);
|
||||||
|
st.boostCd = Math.max(0, st.boostCd - dt);
|
||||||
|
st.boostT = Math.max(0, st.boostT - dt);
|
||||||
|
st.iframeT = Math.max(0, st.iframeT - dt);
|
||||||
|
if (intent.boost && st.boostCd <= 0) {
|
||||||
|
st.boostT = T.boost.duration;
|
||||||
|
st.boostCd = T.boost.cooldown;
|
||||||
|
st.iframeT = T.boost.iframes;
|
||||||
|
bus.emit('player:boost', { s: st.s });
|
||||||
|
bus.emit('audio:cue', { name: 'boost' });
|
||||||
|
}
|
||||||
|
const boostTarget = st.boostT > 0 ? 1 : 0;
|
||||||
|
st.boostBlend += (boostTarget - st.boostBlend) * Math.min(1, (boostTarget ? 14 : 3.5) * dt);
|
||||||
|
|
||||||
|
// surf: ride the peristalsis crest. flowPulse is Lane A's JS mirror of the wall's vertex
|
||||||
|
// displacement — see NOTES, it's charter-specified but missing from the TECH contract.
|
||||||
|
const pulse = world.flowPulse ? world.flowPulse(st.s) : 0;
|
||||||
|
st.onCrest = pulse > T.surf.threshold;
|
||||||
|
const surfTarget = st.onCrest ? 1 : 0;
|
||||||
|
if (Math.abs(surfTarget - st.surfBlend) > 0.001) {
|
||||||
|
const was = st.surfBlend > 0.5;
|
||||||
|
st.surfBlend += (surfTarget - st.surfBlend) * Math.min(1, T.surf.ramp * dt);
|
||||||
|
if (was !== st.surfBlend > 0.5) bus.emit('player:surf', { active: st.surfBlend > 0.5, s: st.s });
|
||||||
|
}
|
||||||
|
|
||||||
|
// forward motion — the flow carries you; you only ever scale it
|
||||||
|
st.speed = biome.flow * (st.throttle + st.surfBlend * T.surf.gain + st.boostBlend * T.boost.gain);
|
||||||
|
st.s = clampS(st.s + st.speed * dt);
|
||||||
|
|
||||||
|
// disc movement
|
||||||
|
st.vx += intent.disc.x * T.disc.accel * dt;
|
||||||
|
st.vy += intent.disc.y * T.disc.accel * dt;
|
||||||
|
const damp = Math.exp(-T.disc.damping * dt);
|
||||||
|
st.vx *= damp; st.vy *= damp;
|
||||||
|
const sp = Math.hypot(st.vx, st.vy);
|
||||||
|
if (sp > T.disc.maxSpeed) { st.vx *= T.disc.maxSpeed / sp; st.vy *= T.disc.maxSpeed / sp; }
|
||||||
|
st.x += st.vx * dt;
|
||||||
|
st.y += st.vy * dt;
|
||||||
|
|
||||||
|
const frame = world.sample(st.s);
|
||||||
|
discToWorld(_pos, frame, st.x, st.y);
|
||||||
|
collideWall(frame);
|
||||||
|
|
||||||
|
// aim
|
||||||
|
const aimIn = {
|
||||||
|
x: mouse.x * T.aim.sensitivity + intent.aimStick.x * T.aim.padSpeed * dt,
|
||||||
|
y: -mouse.y * T.aim.sensitivity + intent.aimStick.y * T.aim.padSpeed * dt,
|
||||||
|
};
|
||||||
|
st.ax += aimIn.x; st.ay += aimIn.y;
|
||||||
|
if (!aimIn.x && !aimIn.y) { // hands off: drift back to centre
|
||||||
|
const k = Math.min(1, T.aim.recenter * dt);
|
||||||
|
st.ax -= st.ax * k; st.ay -= st.ay * k;
|
||||||
|
}
|
||||||
|
const ar = Math.hypot(st.ax, st.ay);
|
||||||
|
if (ar > T.aim.range) { st.ax *= T.aim.range / ar; st.ay *= T.aim.range / ar; }
|
||||||
|
|
||||||
|
const aimFrame = world.sample(clampS(st.s + T.aim.distance));
|
||||||
|
discToWorld(_aim, aimFrame, st.ax, st.ay);
|
||||||
|
reticle.mesh.position.copy(_aim);
|
||||||
|
if (camera) reticle.mesh.lookAt(camera.position);
|
||||||
|
reticle.setHot(intent.fire ? 1 : 0);
|
||||||
|
|
||||||
|
// the ship's nose chases the reticle rather than snapping to it (Star Fox lag)
|
||||||
|
if (!aimLagInit) { _aimLag.copy(_aim); aimLagInit = true; }
|
||||||
|
_aimLag.lerp(_aim, Math.min(1, T.aim.lag * dt));
|
||||||
|
|
||||||
|
// coat: biome ambient drain, then delayed regen
|
||||||
|
st.sinceDamage += dt;
|
||||||
|
drain((biome.coatDrain ?? 0) * dt);
|
||||||
|
if (st.sinceDamage > T.coat.regenDelay)
|
||||||
|
st.coat = Math.min(T.coat.max, st.coat + T.coat.regen * dt);
|
||||||
|
|
||||||
|
poseShip(dt, frame);
|
||||||
|
updateCamera(dt, frame);
|
||||||
|
emitState(biome);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arcade wall response: shove + graze damage, never a hard stop. Forward motion is the
|
||||||
|
// flow's and is untouched — scraping a fold costs coat and control, not your run.
|
||||||
|
function collideWall(frame) {
|
||||||
|
const hit = world.collide(_pos, T.ship.radius);
|
||||||
|
if (!hit) return;
|
||||||
|
|
||||||
|
const rho = Math.hypot(st.x, st.y);
|
||||||
|
if (rho < 1e-5) return;
|
||||||
|
const ux = st.x / rho, uy = st.y / rho; // outward radial, in disc coords
|
||||||
|
|
||||||
|
st.x -= ux * hit.push.length(); // back inside the wall
|
||||||
|
st.y -= uy * hit.push.length();
|
||||||
|
|
||||||
|
const vr = st.vx * ux + st.vy * uy; // >0 = travelling into the wall
|
||||||
|
if (vr > 0) {
|
||||||
|
st.vx -= (1 + T.wall.restitution) * vr * ux;
|
||||||
|
st.vy -= (1 + T.wall.restitution) * vr * uy;
|
||||||
|
}
|
||||||
|
st.vx -= ux * T.wall.shoveOut;
|
||||||
|
st.vy -= uy * T.wall.shoveOut;
|
||||||
|
|
||||||
|
if (vr > T.wall.grazeFloor && st.wallCd <= 0) {
|
||||||
|
damage(Math.min(T.wall.maxHit, (vr - T.wall.grazeFloor) * T.wall.damagePerSpeed), hit.kind);
|
||||||
|
st.wallCd = T.wall.cooldown;
|
||||||
|
bus.emit('audio:cue', { name: 'wall_scrape' });
|
||||||
|
}
|
||||||
|
discToWorld(_pos, frame, st.x, st.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function poseShip(dt, frame) {
|
||||||
|
const rollTarget = THREE.MathUtils.clamp(-st.vx * T.bank.gain, -T.bank.max, T.bank.max);
|
||||||
|
st.roll += (rollTarget - st.roll) * Math.min(1, T.bank.rate * dt);
|
||||||
|
st.pitch += (st.vy * T.bank.pitchGain - st.pitch) * Math.min(1, T.bank.rate * dt);
|
||||||
|
|
||||||
|
ship.group.position.copy(_pos);
|
||||||
|
ship.group.up.copy(frame.nor);
|
||||||
|
ship.group.lookAt(_aimLag); // hull is authored nose-toward −Z, so this aims it
|
||||||
|
ship.group.rotateZ(st.roll); // roll about the view axis = bank
|
||||||
|
ship.group.rotateX(st.pitch);
|
||||||
|
ship.setThrust(THREE.MathUtils.clamp((st.throttle - 0.6) / 0.8, 0, 1) * 0.6 + st.boostBlend * 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chase cam on the parallel-transport frame. up = frame.nor, ALWAYS — never world-up.
|
||||||
|
// If this ever rolls or snaps, the frame is the suspect, not this function (charter: file
|
||||||
|
// it to Lane A rather than patching here).
|
||||||
|
const cam = { s: 0, x: 0, y: 0, fov: T.cam.fov, init: false };
|
||||||
|
function updateCamera(dt, frame) {
|
||||||
|
if (!camera) return;
|
||||||
|
if (!cam.init) { cam.s = st.s - T.cam.back; cam.x = st.x; cam.y = st.y; cam.init = true; }
|
||||||
|
|
||||||
|
cam.s = clampS(st.s - T.cam.back);
|
||||||
|
const k = Math.min(1, T.cam.discLag * dt);
|
||||||
|
cam.x += (st.x * T.cam.discFollow - cam.x) * k;
|
||||||
|
cam.y += (st.y * T.cam.discFollow - cam.y) * k;
|
||||||
|
|
||||||
|
const cf = world.sample(cam.s);
|
||||||
|
discToWorld(_camPos, cf, cam.x, cam.y + T.cam.up);
|
||||||
|
camera.position.copy(_camPos);
|
||||||
|
|
||||||
|
const lf = world.sample(clampS(st.s + T.cam.lookAhead));
|
||||||
|
discToWorld(_look, lf, st.x * 0.5, st.y * 0.5);
|
||||||
|
_look.lerp(_tmp.copy(_aimLag), T.cam.aimInfluence);
|
||||||
|
camera.up.copy(frame.nor);
|
||||||
|
camera.lookAt(_look);
|
||||||
|
|
||||||
|
const fovTarget = T.cam.fov + st.boostBlend * T.boost.fovKick;
|
||||||
|
if (Math.abs(fovTarget - cam.fov) > 0.01) {
|
||||||
|
cam.fov += (fovTarget - cam.fov) * Math.min(1, T.cam.fovRate * dt);
|
||||||
|
camera.fov = cam.fov;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lane E's HUD is bus-driven only, so continuous state has to arrive as an event. One
|
||||||
|
// small object per frame; treat it as read-only, don't retain it.
|
||||||
|
function emitState(biome) {
|
||||||
|
bus.emit('player:state', {
|
||||||
|
coat: st.coat, coatMax: T.coat.max,
|
||||||
|
hull: st.hull, hullMax: T.hull.max,
|
||||||
|
speed: st.speed, flow: biome.flow, throttle: st.throttle,
|
||||||
|
boostReady: st.boostCd <= 0, boostCd: st.boostCd, boostCdMax: T.boost.cooldown,
|
||||||
|
surfing: st.surfBlend > 0.5, iframes: st.iframeT > 0,
|
||||||
|
s: st.s, length: world.length, progress: world.length ? st.s / world.length : 0,
|
||||||
|
biome: biome.id, alive: st.alive,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bus.emit('player:spawn', { s: st.s });
|
||||||
|
|
||||||
|
return {
|
||||||
|
update,
|
||||||
|
state: st,
|
||||||
|
// combat/ reads this for fire/fire2. Safe because input.sample() is called exactly once
|
||||||
|
// per frame, here, and boot runs player.update() before combat.update() — so the
|
||||||
|
// edge-triggered flags (fire2) are still live when the weapons read them this frame.
|
||||||
|
get intent() { return input.intent; },
|
||||||
|
get _input() { return input; }, // harness only (flight/dev.js) — see input.js._debug
|
||||||
|
get position() { return _pos; },
|
||||||
|
get aimPoint() { return _aim; },
|
||||||
|
get radius() { return T.ship.radius; },
|
||||||
|
get forward() { return world.sample(st.s).tan; },
|
||||||
|
damage,
|
||||||
|
drain,
|
||||||
|
respawn(s = 0) {
|
||||||
|
Object.assign(st, { s, x: 0, y: 0, vx: 0, vy: 0, coat: T.coat.max, hull: T.hull.max, alive: true });
|
||||||
|
cam.init = false; aimLagInit = false;
|
||||||
|
bus.emit('player:spawn', { s });
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
scene.remove(ship.group, reticle.mesh);
|
||||||
|
ship.dispose(); reticle.dispose(); input.dispose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
86
web/js/flight/ship.js
Normal file
86
web/js/flight/ship.js
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
// flight/ship.js (Lane B) — ENDO-1, the probe you fly, and the reticle you aim with.
|
||||||
|
//
|
||||||
|
// Procedural primitives merged into ONE geometry: assets are optional by law (TECH §manifest),
|
||||||
|
// so this is the shipping look until Lane D's GLB lands, and the permanent fallback after.
|
||||||
|
// Convention: the hull is built nose-toward −Z so Object3D.lookAt() aims it with no fudge.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
|
||||||
|
import { emissiveMat, EMISSIVE } from './emissive.js';
|
||||||
|
|
||||||
|
// D's GLB swaps in here when it exists; logic above never learns which one it got.
|
||||||
|
export function buildShip(assets = null) {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = 'endo1';
|
||||||
|
|
||||||
|
const glb = assets?.get?.('models', 'endo1') ?? null;
|
||||||
|
const hull = glb ? glb.scene ?? glb : proceduralHull();
|
||||||
|
group.add(hull);
|
||||||
|
|
||||||
|
// engine bloom at the tail: additive, unlit, reads as thrust without a light
|
||||||
|
const glowGeo = new THREE.SphereGeometry(0.34, 12, 8);
|
||||||
|
const glowMat = emissiveMat(EMISSIVE.neutral, { additive: true, opacity: 0.9 });
|
||||||
|
const glow = new THREE.Mesh(glowGeo, glowMat);
|
||||||
|
glow.position.z = 0.95;
|
||||||
|
group.add(glow);
|
||||||
|
|
||||||
|
const disposables = [glowGeo, glowMat];
|
||||||
|
if (!glb) { disposables.push(hull.geometry, hull.material); }
|
||||||
|
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
glow,
|
||||||
|
setThrust(t) { glow.scale.setScalar(0.7 + t * 0.9); glowMat.uniforms.uOpacity.value = 0.5 + t * 0.5; },
|
||||||
|
setFlash(v) { if (hull.material?.uniforms?.uFlash) hull.material.uniforms.uFlash.value = v; },
|
||||||
|
dispose() { for (const d of disposables) d?.dispose?.(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function proceduralHull() {
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
const body = new THREE.CapsuleGeometry(0.4, 0.9, 4, 12);
|
||||||
|
body.rotateX(Math.PI / 2); // capsule is +Y by default; we fly down −Z
|
||||||
|
parts.push(body);
|
||||||
|
|
||||||
|
const nose = new THREE.ConeGeometry(0.4, 0.85, 12);
|
||||||
|
nose.rotateX(-Math.PI / 2);
|
||||||
|
nose.translate(0, 0, -1.08);
|
||||||
|
parts.push(nose);
|
||||||
|
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const fin = new THREE.BoxGeometry(0.62, 0.07, 0.44);
|
||||||
|
fin.translate(side * 0.52, -0.04, 0.36);
|
||||||
|
fin.rotateZ(side * -0.22);
|
||||||
|
parts.push(fin);
|
||||||
|
}
|
||||||
|
|
||||||
|
const collar = new THREE.TorusGeometry(0.42, 0.06, 6, 14);
|
||||||
|
collar.translate(0, 0, -0.28);
|
||||||
|
parts.push(collar);
|
||||||
|
|
||||||
|
const geo = mergeGeometries(parts, false);
|
||||||
|
for (const p of parts) p.dispose();
|
||||||
|
return new THREE.Mesh(geo, emissiveMat(EMISSIVE.neutral));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reticle floats TUNING.aim.distance ahead in the disc plane. Depth-test off: it is an
|
||||||
|
// instrument reading, not an object in the gut — it must never be swallowed by a fold.
|
||||||
|
export function buildReticle() {
|
||||||
|
const geo = new THREE.RingGeometry(0.55, 0.72, 20);
|
||||||
|
const mat = emissiveMat(EMISSIVE.neutral, { additive: true, opacity: 0.85 });
|
||||||
|
mat.depthTest = false;
|
||||||
|
const mesh = new THREE.Mesh(geo, mat);
|
||||||
|
mesh.renderOrder = 10;
|
||||||
|
|
||||||
|
const dotGeo = new THREE.CircleGeometry(0.12, 10);
|
||||||
|
const dot = new THREE.Mesh(dotGeo, mat);
|
||||||
|
dot.renderOrder = 10;
|
||||||
|
mesh.add(dot);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mesh,
|
||||||
|
setHot(v) { mat.uniforms.uFlash.value = v; }, // hot while the cannon is firing
|
||||||
|
dispose() { geo.dispose(); dotGeo.dispose(); mat.dispose(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
113
web/js/flight/tuning.js
Normal file
113
web/js/flight/tuning.js
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
// flight/tuning.js (Lane B) — THE FEEL. Every number that decides how GUTS handles lives
|
||||||
|
// here and nowhere else. Tuned by hand on the stub esophagus; re-tune per biome later.
|
||||||
|
//
|
||||||
|
// Stub reference frame (js/stub/world_stub.js), which all of these are sized against:
|
||||||
|
// biome.flow = 14 u/s · radius 10 ±15% wobble => 8.5..11.5
|
||||||
|
// wallRho = radius − waveAmp(0.9) − skin(0.6) => a ~7.0..10.0 playable disc radius
|
||||||
|
// peristalsis crest travels at WAVE_W/WAVE_K = 3.0/0.22 = 13.64 u/s
|
||||||
|
//
|
||||||
|
// SURF IS A SHOVE, NOT YET A RIDE — and that is a world-side problem, not a tuning one.
|
||||||
|
// Measured in-engine this round (numbers in LANE_B_NOTES §surf):
|
||||||
|
// crest phase speed = WAVE_W/WAVE_K = 3.0/0.22 = 13.64 u/s
|
||||||
|
// player top speed = flow × throttleMax = 14 × 1.4 = 19.6 u/s
|
||||||
|
// A wave travelling 13.64 u/s cannot carry anything faster than 13.64 u/s, so "ride the
|
||||||
|
// crest" can never be the fast line while the wave is slower than the player. Worse, the
|
||||||
|
// bonus is self-cancelling: the crest window is fixed in SPACE, so a bigger surf.gain just
|
||||||
|
// ejects you out the front sooner. Measured distance gained per crest is ~3.0 units at
|
||||||
|
// gain 0.4, 0.75 AND 1.2 — identical. Tuning this number cannot fix it.
|
||||||
|
//
|
||||||
|
// So surf currently reads as what the wave physically is: a rhythmic peristaltic SHOVE as
|
||||||
|
// each crest sweeps over you (~0.3-0.6 s at 24.5 u/s), which is honest and feels good, but
|
||||||
|
// it is not the level-2 speed line the GDD asks for. The fix belongs to whoever owns the
|
||||||
|
// wave constants (stub = F, world = A): crest speed must exceed 19.6 u/s. Escalated in
|
||||||
|
// LANE_B_NOTES §"-> Lane A / F" with options. Do not paper over it here.
|
||||||
|
//
|
||||||
|
// gain below is therefore chosen for FEEL (punch of the shove), not for speed economy.
|
||||||
|
|
||||||
|
export const TUNING = {
|
||||||
|
ship: {
|
||||||
|
radius: 0.9, // collision sphere (hull reads ~1.9 long, 1.2 wide)
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- forward motion along s -------------------------------------------------------
|
||||||
|
flow: {
|
||||||
|
throttleMin: 0.6, // GDD: throttle is ±40% of biome flow, never a full stop
|
||||||
|
throttleMax: 1.4,
|
||||||
|
throttleRate: 1.8, // throttle units/s while held (0.6 -> 1.4 in ~0.45 s)
|
||||||
|
throttleReturn: 0.9, // /s spring back to neutral 1.0 when hands off
|
||||||
|
},
|
||||||
|
boost: {
|
||||||
|
gain: 1.2, // +120% of biome flow (GDD)
|
||||||
|
duration: 0.55, // s of burst
|
||||||
|
cooldown: 2.4, // s from spend until available again
|
||||||
|
iframes: 0.35, // s invulnerable "at spend" (GDD) — shorter than the burst,
|
||||||
|
// so boosting *through* something is a read, not a get-out
|
||||||
|
fovKick: 13, // degrees of extra FOV at full burst (speed you feel)
|
||||||
|
},
|
||||||
|
surf: {
|
||||||
|
threshold: 0.45, // world.flowPulse(s,t) above this = on the crest.
|
||||||
|
// pulse=pow(sin,3) so 0.45 => sin>0.766 => the peak 22% of
|
||||||
|
// each wave: a ~6.3 u window in a 28.6 u wavelength.
|
||||||
|
gain: 0.75, // +75% flow while on a crest => a 24.5 u/s shove for ~0.3-0.6 s.
|
||||||
|
// Feel-tuned only; see the surf note in the header before
|
||||||
|
// touching it expecting a speed change.
|
||||||
|
ramp: 3.0, // /s blend in/out so cresting doesn't pop
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- movement inside the cross-section disc ---------------------------------------
|
||||||
|
disc: {
|
||||||
|
accel: 78, // u/s²
|
||||||
|
maxSpeed: 13, // u/s — crosses the ~16 u tube in ~1.2 s: dodgeable, not twitchy
|
||||||
|
damping: 6.5, // /s exponential drag => coasts to rest in ~0.3 s
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- aim: the reticle leads, the ship follows (Star Fox lag) -----------------------
|
||||||
|
aim: {
|
||||||
|
distance: 26, // u ahead (in s) that the reticle plane floats
|
||||||
|
sensitivity: 0.022, // disc units per pixel of mouse movement
|
||||||
|
padSpeed: 20, // u/s at full right-stick deflection
|
||||||
|
range: 9, // max |aim| from the centreline (≈ the widest wallRho)
|
||||||
|
recenter: 0.8, // /s drift back to centre when hands off
|
||||||
|
lag: 9, // /s spring — the ship's nose chases the reticle, never snaps
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- attitude: bank into lateral motion -------------------------------------------
|
||||||
|
bank: {
|
||||||
|
gain: 0.055, // rad of roll per u/s of lateral disc velocity
|
||||||
|
max: 0.75, // rad (~43°)
|
||||||
|
rate: 7, // /s spring toward target roll
|
||||||
|
pitchGain: 0.03, // rad of pitch per u/s of vertical disc velocity
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- chase camera ------------------------------------------------------------------
|
||||||
|
cam: {
|
||||||
|
back: 7.2, // u behind the ship (along −tan, measured in s)
|
||||||
|
up: 1.4, // u along the frame normal — ship sits low-centre
|
||||||
|
lookAhead: 16, // u ahead of the ship for the look target
|
||||||
|
discLag: 8.0, // /s spring on the camera's own disc offset
|
||||||
|
discFollow: 0.82, // camera tracks only 82% of the ship's offset, so hard dodges
|
||||||
|
// visibly move the ship within the frame instead of gluing it
|
||||||
|
aimInfluence: 0.35, // how much the look target drifts toward the reticle
|
||||||
|
fov: 75, // matches boot.js's camera so wiring in changes nothing
|
||||||
|
fovRate: 4.5, // /s toward target fov
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- durability (GDD: regenerating mucus coat over a pickup-only hull) -------------
|
||||||
|
coat: {
|
||||||
|
max: 100,
|
||||||
|
regen: 8, // /s
|
||||||
|
regenDelay: 2.5, // s after the last damage before regen resumes
|
||||||
|
// ambient drain is the biome's: world.biomeAt(s).coatDrain (esophagus = 0.5/s)
|
||||||
|
},
|
||||||
|
hull: { max: 100 },
|
||||||
|
|
||||||
|
// --- wall contact: arcade. Shove + graze damage, NEVER a hard stop -----------------
|
||||||
|
wall: {
|
||||||
|
restitution: 0.35, // fraction of inward velocity returned on contact
|
||||||
|
grazeFloor: 3.0, // impact u/s below this is free — you can lean on the wall
|
||||||
|
damagePerSpeed: 1.6, // coat damage per u/s of impact above grazeFloor
|
||||||
|
maxHit: 28, // cap: a full-speed slam is a mistake, not a death
|
||||||
|
shoveOut: 1.0, // extra u/s pushed off the wall
|
||||||
|
cooldown: 0.25, // s between wall damage ticks (scraping ≠ machine-gun damage)
|
||||||
|
},
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user