Merge pull request 'Physics upgrade + repo modularization (RC-375D84)' (#1) from physics-modularization-rc375d84 into main
This commit is contained in:
commit
0fc46c5fd9
52 changed files with 2413 additions and 838 deletions
|
|
@ -377,10 +377,10 @@ python3 -m http.server 8000 # then open http://localhost:8000/
|
||||||
# or open index.html directly in a browser
|
# or open index.html directly in a browser
|
||||||
|
|
||||||
# verify (both arms):
|
# verify (both arms):
|
||||||
node --check js/core.js js/renderer.js js/main.js js/levels.js
|
|
||||||
node test/sim.test.mjs # 15/15 — physics, rules, DOM wiring, rest, framing, clearability
|
node test/sim.test.mjs # 15/15 — physics, rules, DOM wiring, rest, framing, clearability
|
||||||
node test/page.test.mjs # 7/7 — page boot, click session, direction, release
|
node test/page.test.mjs # 7/7 — page boot, click session, direction, release
|
||||||
# or: npm test (runs both, in order)
|
node test/balance.test.mjs# 5/5 — balance spec (material break + enemy kill budgets)
|
||||||
|
# or: npm test (runs all, in order)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 8. Known limitations (honest list)
|
## 8. Known limitations (honest list)
|
||||||
|
|
@ -406,3 +406,27 @@ designs are original. The game is named **Isle Slingers**.
|
||||||
|
|
||||||
See the repository commit SHA recorded in the RC completion payload.
|
See the repository commit SHA recorded in the RC completion payload.
|
||||||
Working tree at commit time contains exactly the files listed in §3.
|
Working tree at commit time contains exactly the files listed in §3.
|
||||||
|
|
||||||
|
## 11. Addendum — RC-375D84 S-2 modularization (2026-09-28)
|
||||||
|
|
||||||
|
The flat `js/{core,levels,audio,renderer,main}.js` files described in §3 no
|
||||||
|
longer exist. The tree was restructured (behavior-neutral, proven by a
|
||||||
|
golden-step hash — identical scripted playthroughs through the original
|
||||||
|
monolith and the split build produce byte-identical state hashes — and by
|
||||||
|
27/27 green with the old files DELETED, so the new paths are the only load
|
||||||
|
path):
|
||||||
|
|
||||||
|
- `js/physics/` — the engine, split into 9 submodules (constants, state,
|
||||||
|
aim, abilities, damage, step, collision, rules, camera) that install into
|
||||||
|
ONE shared scope `P`; `js/physics/index.js` aggregates and exports the
|
||||||
|
unchanged 20-key `ISLE_CORE` API.
|
||||||
|
- `js/levels/` — `helpers/` (constructors + SLING) + `data/level-1..5.js`
|
||||||
|
into shared scope `L`; `index.js` exports the unchanged `{LEVELS, SLING}`.
|
||||||
|
- `js/audio/`, `js/render/`, `js/app/` — the former audio.js / renderer.js /
|
||||||
|
main.js, moved into module folders (content unchanged at S-2 time).
|
||||||
|
- Every module and submodule carries an `agents.md`; the master list is
|
||||||
|
`MODULES.md`; the runtime model and load-order contract is `architecture.md`.
|
||||||
|
- The test suites were re-pointed to the new paths (`sim.test.mjs` now walks
|
||||||
|
the whole `js/` tree; `page.test.mjs` loads the 19-script browser order).
|
||||||
|
|
||||||
|
See `architecture.md` before touching any of these.
|
||||||
|
|
|
||||||
58
MODULES.md
Normal file
58
MODULES.md
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# Isle Slingers — MODULES.md (master module list)
|
||||||
|
|
||||||
|
RC-375D84 S-2. Folders = modules, subfolders = submodules. Every module and
|
||||||
|
submodule carries an `agents.md` describing its surface and invariants.
|
||||||
|
Load contract (order is load-bearing) is in `architecture.md` and encoded in
|
||||||
|
`index.html` + `test/page.test.mjs`.
|
||||||
|
|
||||||
|
| # | Module | Submodules | Public global | Role |
|
||||||
|
|---|--------|-----------|---------------|------|
|
||||||
|
| 1 | `js/physics/` | constants, state, aim, abilities, damage, step, collision, topple, rules, camera | `ISLE_CORE` (via `js/physics/index.js`) | Deterministic DOM-free physics engine (shared scope `P`) |
|
||||||
|
| 2 | `js/levels/` | helpers, data (level-1..5) | `ISLE_LEVELS` `{LEVELS, SLING}` (via `js/levels/index.js`) | Level geometry + enemy/bird layouts (shared scope `L`) |
|
||||||
|
| 3 | `js/audio/` | — (single file) | `ISLE_AUDIO` `{AudioEngine}` | Synthesized WebAudio SFX (no assets) |
|
||||||
|
| 4 | `js/render/` | — (single file) | `ISLE_RENDERER` `{Renderer, VIEW_W, VIEW_H}` | Canvas drawing of the state object |
|
||||||
|
| 5 | `js/app/` | — (single file) | page state on `window` | Input, screens/HUD, fixed-timestep main loop |
|
||||||
|
|
||||||
|
## physics submodules (shared scope `P`)
|
||||||
|
|
||||||
|
| Submodule | Installs (into `P`) | Consumes from `P` |
|
||||||
|
|-----------|--------------------|--------------------|
|
||||||
|
| constants | WORLD, VIEW, MATERIALS, BIRD_TYPES, ENEMY_TYPES, GRAVITY, LAUNCH_POWER, MAX_PULL, MIN_PULL, AIR_DRAG, BIRD_BOUNCE, BIRD_GROUND_FRICTION, REST_SPEED, SETTLE_TIME, BIRD_MAX_AGE, ABILITY_CD, BIRD_BONUS | — |
|
||||||
|
| state | createGame, makeBird, activeBird | WORLD, MATERIALS, BIRD_TYPES, ENEMY_TYPES, makeBird |
|
||||||
|
| aim | aimVector, trajectory | LAUNCH_POWER, GRAVITY, AIR_DRAG, BIRD_BOUNCE, WORLD, BIRD_GROUND_FRICTION |
|
||||||
|
| abilities | launch, triggerAbility | activeBird, aimVector, MATERIALS, BIRD_TYPES |
|
||||||
|
| damage | damageBlock, damageEnemy | MATERIALS, ENEMY_TYPES |
|
||||||
|
| step | step, stepPhysics, spendBird | (nearly all — the physics hub) |
|
||||||
|
| collision | crushCircleVsBox, collideBirdWorld, collideBirdBlocks, collideBirdEnemies, REST_VEL_CLAMP, resolveBoxPair | MATERIALS, WORLD, damageBlock, damageEnemy |
|
||||||
|
| topple | toppleBlocks, blockSupportSpan, beginTopple, integrateTopple, landTopple, topplePathDamage, settleBack, supportStillAlive | WORLD, GRAVITY, MATERIALS, REST_SPEED, damageBlock, damageEnemy, ENEMY_TYPES |
|
||||||
|
| rules | checkEndStates, birdsRemaining, starsFor | — |
|
||||||
|
| camera | updatePopups, updateCameraTarget, decayCamera | WORLD, VIEW |
|
||||||
|
|
||||||
|
## levels submodules (shared scope `L`)
|
||||||
|
|
||||||
|
| Submodule | Installs (into `L`) | Consumes from `L` |
|
||||||
|
|-----------|--------------------|--------------------|
|
||||||
|
| helpers | wood, ice, stone, mogg, grum, king, SLING | — |
|
||||||
|
| data/level-N | one level object each (returns it) | wood/ice/stone/mogg/grum/king, SLING |
|
||||||
|
|
||||||
|
## Physics notes (S-3/S-4/S-5, RC-375D84 — landed 2026-09-29)
|
||||||
|
- **topple** (S-3): blocks carry a rotational DOF (`ang`, `angVel`, `pivoting`).
|
||||||
|
A resting block whose COM is past its support span pivots about the
|
||||||
|
support-edge corner (full continuous rotation, Q1) and either lands on the
|
||||||
|
ground (impact damage via `topplePathDamage`) or, if the pivot is too high to
|
||||||
|
reach the ground, swings back and settles flat (`settleBack`, anti-livelock).
|
||||||
|
- **collision** (S-4): crush is velocity-direction-aware (`boxAlong > 30` —
|
||||||
|
falling boxes crush enemies from above or the side), per-enemy crush cooldown
|
||||||
|
(0.12 s, one landing = one crush), and stacked block contact is mass-weighted
|
||||||
|
so heavy blocks drive light planks (chain-crush).
|
||||||
|
- **step** (S-5): a settled bird with another bird in play becomes `landed`
|
||||||
|
(a live body under real gravity, no lifetime timer) and is integrated with
|
||||||
|
the blocks; the last bird still spends (win/lose unchanged). `rules` must not
|
||||||
|
declare `lost` while a landed bird is in the world.
|
||||||
|
- **state** (S-3/S-5): block records carry `ang`/`angVel`/`pivoting`; bird
|
||||||
|
records gain the `landed` state (queued | aiming | flying | landed | spent).
|
||||||
|
|
||||||
|
## Invariants (see architecture.md)
|
||||||
|
- `ISLE_CORE` and `ISLE_LEVELS` public API surfaces are key-for-key stable.
|
||||||
|
- Deterministic core: `createGame` + `step` are DOM-free and headless-safe.
|
||||||
|
- Fixed timestep 1/120 s. Game is fully offline; no new runtime deps.
|
||||||
154
architecture.md
Normal file
154
architecture.md
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
# Isle Slingers — Architecture
|
||||||
|
|
||||||
|
Original architecture for a fully offline, canvas-rendered slingshot game.
|
||||||
|
No frameworks, no build step, no external assets. As of RC-375D84 (S-2) the
|
||||||
|
JavaScript is a **modularized tree**: folders are modules, subfolders are
|
||||||
|
submodules, and every module/submodule carries its own `agents.md`.
|
||||||
|
|
||||||
|
## Runtime model (plain `<script>` load, single page)
|
||||||
|
|
||||||
|
There is no bundler and no import/export in the browser. Modules are ordinary
|
||||||
|
UMD-style files loaded in a strict order by `index.html`; the test harness
|
||||||
|
(`test/page.test.mjs`) replays that exact order in a DOM stub, and the
|
||||||
|
headless suites (`test/sim.test.mjs`, `test/balance.test.mjs`) load the same
|
||||||
|
modules through Node's CommonJS `require` branch of each UMD wrapper.
|
||||||
|
|
||||||
|
Two kinds of module:
|
||||||
|
|
||||||
|
1. **Shared-scope modules** — a group of submodules that must share one
|
||||||
|
lexical scope (the way a single file used to). Each submodule exports an
|
||||||
|
*installer function* `P -> void`; the aggregator builds one shared object
|
||||||
|
(`P` for physics, `L` for levels) and calls every installer against it.
|
||||||
|
Submodules therefore reference each other's units as `P.<unit>` /
|
||||||
|
`L.<unit>` — never as free names. This preserves the monolith's
|
||||||
|
single-closure semantics exactly (proven by the golden-step hash: identical
|
||||||
|
scripted playthroughs produce byte-identical state hashes).
|
||||||
|
In the browser, submodules push their installer onto a global part list
|
||||||
|
(`ISLE_PHYSICS_PARTS` / `ISLE_LEVEL_PARTS`); the aggregator (the last
|
||||||
|
`<script>` of the group) consumes the list in order.
|
||||||
|
2. **Self-contained modules** — `audio`, `render`, `app`. One file, one
|
||||||
|
global (`ISLE_AUDIO`, `ISLE_RENDERER`, plus the page state object on
|
||||||
|
`window`), no shared scope.
|
||||||
|
|
||||||
|
## Load order (contract — do not reorder)
|
||||||
|
|
||||||
|
```
|
||||||
|
physics submodules : constants, state, aim, abilities, damage, step,
|
||||||
|
collision, topple, rules, camera (shared scope P)
|
||||||
|
physics/index.js : aggregator -> window.ISLE_CORE (the physics API)
|
||||||
|
levels/helpers : constructors + SLING anchor (shared scope L)
|
||||||
|
levels/data/* : one level definition each (shared scope L)
|
||||||
|
levels/index.js : aggregator -> window.ISLE_LEVELS {LEVELS, SLING}
|
||||||
|
audio/index.js : window.ISLE_AUDIO {AudioEngine}
|
||||||
|
render/index.js : window.ISLE_RENDERER {Renderer, VIEW_W, VIEW_H}
|
||||||
|
app/index.js : the page — input, screens, main loop
|
||||||
|
```
|
||||||
|
|
||||||
|
`app` (the former `main.js`) is the only module that touches DOM wiring;
|
||||||
|
`render` draws from the state object returned by `ISLE_CORE.createGame`;
|
||||||
|
`audio` plays the events the core appends to `state.events`.
|
||||||
|
|
||||||
|
## Physics model (RC-375D84, landed 2026-09-29)
|
||||||
|
|
||||||
|
The simulation is a deterministic impulse-ish engine: bodies integrate under
|
||||||
|
gravity at fixed 1/120 s, contacts resolve positionally, and damage routes
|
||||||
|
through two funnels (`damageBlock`, `damageEnemy`). What each RC-375D84 step
|
||||||
|
added, and where it lives:
|
||||||
|
|
||||||
|
- **Boards tip and topple (S-3, Q1 = full continuous rotation).** Blocks carry
|
||||||
|
a rotational DOF: `ang`, `angVel`, `pivoting`. A resting block whose centre
|
||||||
|
of mass lies outside the x-span of its support (ground, or the top face of a
|
||||||
|
block below — `topple.blockSupportSpan`) pivots about the support-edge
|
||||||
|
corner under gravity (point-mass COM, `alpha = G·(comX−pivotX)/r²`), a
|
||||||
|
smooth continuous swing — no discrete snap. Two endings: a far corner
|
||||||
|
reaches the ground line → it lands, impact speed `|angVel|·r` deals
|
||||||
|
self-damage and footprint damage (`topplePathDamage`: blocks take impact,
|
||||||
|
enemies in the footprint can be killed); or the pivot is too high for any
|
||||||
|
corner to reach the ground → the board is a pendulum and swings back,
|
||||||
|
settling flat on its support (`settleBack`, with anti-livelock: it
|
||||||
|
re-tips only on a real knock ≥ 6 px or a damaging hit).
|
||||||
|
- **Falling objects kill (S-4).** Crush is velocity-direction-aware: the
|
||||||
|
moving box must be the body plowing into the enemy (`boxAlong > 30`), so
|
||||||
|
vertical drops and side plows both register and a merely resting box never
|
||||||
|
does. One landing = one crush instance (per-enemy 0.12 s cooldown). Stacked
|
||||||
|
block contact is mass-weighted inelastic, so a heavy block landing on a
|
||||||
|
light plank transfers real momentum through the stack (chain-crush).
|
||||||
|
- **Landed birds stay physical (S-5, Q2 = real continuous gravity, no
|
||||||
|
lifetime timer).** A bird that settles while ANOTHER bird is in play
|
||||||
|
(queued, or flying/aiming/landed) becomes `landed`: it keeps integrating
|
||||||
|
under gravity, falls when its support breaks, chain-damages the boards it
|
||||||
|
lands on, and can kill enemies — for as long as the level runs. The LAST
|
||||||
|
bird still spends exactly as before, so win/lose resolution is unchanged;
|
||||||
|
`rules` must not declare `lost` while a landed bird is in the world, and
|
||||||
|
level end clears landed bodies.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
input (drag/keys) -> app -> ISLE_CORE.launch / ISLE_CORE.triggerAbility
|
||||||
|
|
|
||||||
|
main loop (rAF, fixed 1/120s accumulator) -> ISLE_CORE.step(state, dt)
|
||||||
|
|
|
||||||
|
state = { birds, blocks, enemies, events, score, camera, status, ... }
|
||||||
|
|
|
||||||
|
Renderer.draw(ctx, state) + AudioEngine.play(event)
|
||||||
|
```
|
||||||
|
|
||||||
|
The core is **deterministic and DOM-free**: `createGame(levelDef)` returns a
|
||||||
|
plain state object; `step(state, dt)` mutates it and is safe to call from a
|
||||||
|
headless loop (that is how all physics tests run).
|
||||||
|
|
||||||
|
## Invariants (the load-bearing contracts)
|
||||||
|
|
||||||
|
- `ISLE_CORE` public API is key-for-key stable (see `js/physics/index.js`):
|
||||||
|
consumers use `ISLE_CORE.WORLD`, `MATERIALS`, `BIRD_TYPES`, `ENEMY_TYPES`,
|
||||||
|
`createGame`, `activeBird`, `aimVector`, `trajectory`, `launch`,
|
||||||
|
`triggerAbility`, `step`, `starsFor`, `birdsRemaining`, `LAUNCH_POWER`,
|
||||||
|
`MAX_PULL`, `MIN_PULL`, `BIRD_BONUS`, `AIR_DRAG`, `VIEW`.
|
||||||
|
- `ISLE_LEVELS` public API: `{ LEVELS, SLING }`. Level geometry uses block
|
||||||
|
**centers**; enemies sit with their bottom at `y + r` resting on ground
|
||||||
|
(y=900) or a flat block top — the level-integrity test enforces this.
|
||||||
|
- Physics timestep: 1/120 s in tests and the page accumulator; collision
|
||||||
|
response, crush damage and settle logic all assume `dt` at that scale.
|
||||||
|
- One global per module, attached to `window` in the browser and exported
|
||||||
|
via `module.exports` in Node. Shared-scope part lists
|
||||||
|
(`ISLE_PHYSICS_PARTS`, `ISLE_LEVEL_PARTS`) are **browser-only** transport;
|
||||||
|
Node resolves parts via `require`.
|
||||||
|
|
||||||
|
## Test surface
|
||||||
|
|
||||||
|
- `test/sim.test.mjs` (15) — headless: parses every file under `js/`, DOM-id
|
||||||
|
wiring, level integrity, determinism, stability, trajectory, abilities,
|
||||||
|
win/lose, all 5 levels clearable, fort rest, aim framing.
|
||||||
|
- `test/page.test.mjs` (7) — DOM stub: boots the real page in browser order,
|
||||||
|
click-through the UI, a full 8 s launch + sim, pause/mute/keys, launch
|
||||||
|
direction, sling-band release.
|
||||||
|
- `test/balance.test.mjs` (5) — the balance spec: per-material break budgets
|
||||||
|
and enemy kill budgets under full-power direct hits.
|
||||||
|
- `test/topple.test.mjs` (5) — S-3: overhanging board tips (COM past support
|
||||||
|
edge), rotation is continuous, a fully-supported board never tips (negative
|
||||||
|
control), tip impact damages a block in the fall path, falling board kills
|
||||||
|
an enemy in the fall path.
|
||||||
|
- `test/knifeedge.test.mjs` (6) — S-3 regression: a knife-edge overhang (COM a
|
||||||
|
hair past the edge, pivot too high to reach the ground) tips once and
|
||||||
|
settles back to rest — no livelock; a real knock re-arms it.
|
||||||
|
- `test/fallingkill.test.mjs` (5) — S-4: chain-kill through a knocked plank,
|
||||||
|
direct one-hit kill, repeated-impact kill (grum), one landing = one crush
|
||||||
|
instance, and a resting block never crushes.
|
||||||
|
- `test/landedbird.test.mjs` (4) — S-5: perched bird falls when its platform
|
||||||
|
breaks (Q2 real gravity), no drift on an intact board, chain-damages the
|
||||||
|
board it lands on, kills an enemy in its fall path.
|
||||||
|
|
||||||
|
Total: 47/47. New physics behavior must ship with RED-watched tests in this
|
||||||
|
same surface (every new spec was watched RED on the pre-change tree first).
|
||||||
|
|
||||||
|
## Conventions for agents working in this tree
|
||||||
|
|
||||||
|
1. Change a physics rule -> edit the one submodule that owns it (see each
|
||||||
|
`agents.md`); re-run `npm test` (27/27) and the golden-step hash
|
||||||
|
(`/opt/data/s2_golden.mjs` against `/opt/data/core.orig.js`) when touching
|
||||||
|
anything that predates RC-375D84 behavior.
|
||||||
|
2. Never re-introduce a flat file for a unit that has a submodule folder.
|
||||||
|
3. Keep `index.html` script order in lockstep with `test/page.test.mjs`'s
|
||||||
|
`FILES` list — both encode the load contract.
|
||||||
|
4. No new runtime dependencies; the game must stay fully offline.
|
||||||
30
index.html
30
index.html
|
|
@ -77,10 +77,30 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="js/core.js"></script>
|
<!-- RC-375D84 S-2: modularized tree. Order matters:
|
||||||
<script src="js/levels.js"></script>
|
physics submodules (shared scope) -> physics aggregator (ISLE_CORE)
|
||||||
<script src="js/audio.js"></script>
|
-> levels helpers + data (shared scope) -> levels aggregator (ISLE_LEVELS)
|
||||||
<script src="js/renderer.js"></script>
|
-> audio -> render -> app -->
|
||||||
<script src="js/main.js"></script>
|
<script src="js/physics/constants/index.js"></script>
|
||||||
|
<script src="js/physics/state/index.js"></script>
|
||||||
|
<script src="js/physics/aim/index.js"></script>
|
||||||
|
<script src="js/physics/abilities/index.js"></script>
|
||||||
|
<script src="js/physics/damage/index.js"></script>
|
||||||
|
<script src="js/physics/step/index.js"></script>
|
||||||
|
<script src="js/physics/collision/index.js"></script>
|
||||||
|
<script src="js/physics/topple/index.js"></script>
|
||||||
|
<script src="js/physics/rules/index.js"></script>
|
||||||
|
<script src="js/physics/camera/index.js"></script>
|
||||||
|
<script src="js/physics/index.js"></script>
|
||||||
|
<script src="js/levels/helpers/index.js"></script>
|
||||||
|
<script src="js/levels/data/level-1.js"></script>
|
||||||
|
<script src="js/levels/data/level-2.js"></script>
|
||||||
|
<script src="js/levels/data/level-3.js"></script>
|
||||||
|
<script src="js/levels/data/level-4.js"></script>
|
||||||
|
<script src="js/levels/data/level-5.js"></script>
|
||||||
|
<script src="js/levels/index.js"></script>
|
||||||
|
<script src="js/audio/index.js"></script>
|
||||||
|
<script src="js/render/index.js"></script>
|
||||||
|
<script src="js/app/index.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
17
js/app/agents.md
Normal file
17
js/app/agents.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# js/app/ — agents.md
|
||||||
|
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
|
||||||
|
|
||||||
|
Self-contained module — the page. Owns DOM, input, screens and the main loop.
|
||||||
|
|
||||||
|
- Reads `window.ISLE_CORE`, `ISLE_LEVELS`, `ISLE_RENDERER`, `ISLE_AUDIO`; writes
|
||||||
|
`window.ISLE_PAGE = { getState }` for tests.
|
||||||
|
- Fixed-timestep loop: rAF accumulator at 1/120 s feeding `ISLE_CORE.step`;
|
||||||
|
`pumpFrames` in the page test drives it.
|
||||||
|
- Pointer model: mousedown near the sling arms aim, mousemove drags, mouseup
|
||||||
|
calls `ISLE_CORE.launch` with `aimVector(...)`; tap mid-flight =
|
||||||
|
`triggerAbility`. The direction test (bug #15) asserts pulling back launches
|
||||||
|
TOWARD the fort.
|
||||||
|
- Screens: start / level-select / pause / win / lose; HUD (level, birds, score,
|
||||||
|
pause, mute). Level select unlocks by localStorage progress.
|
||||||
|
- Keep DOM touches in this file only — physics/levels/audio/render must stay
|
||||||
|
DOM-free so the headless suites keep working.
|
||||||
14
js/audio/agents.md
Normal file
14
js/audio/agents.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# js/audio/ — agents.md
|
||||||
|
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
|
||||||
|
|
||||||
|
Self-contained module. Exports `ISLE_AUDIO = { AudioEngine }`.
|
||||||
|
|
||||||
|
- `new AudioEngine()` + `.init()` + `.resume()` + `.setMuted(m)` + `.play(ev)`
|
||||||
|
where `ev` is an item from `state.events` ({type, strength, mat, ...}).
|
||||||
|
- All sound is synthesized (oscillators + noise buffers); no assets, offline.
|
||||||
|
Must degrade silently when `AudioContext` is undefined (the DOM-stub test
|
||||||
|
asserts boot with `AudioContext: undefined`).
|
||||||
|
- Event names are a contract with physics/damage + step ('launch','hit',
|
||||||
|
'break','crack','pop','crush','bird_ground','ability_*','bird_gone',
|
||||||
|
'level_won','level_lost','star','ui'). Adding an event type means adding its
|
||||||
|
synth branch here (unknown types no-op — that is intentional).
|
||||||
658
js/core.js
658
js/core.js
|
|
@ -1,658 +0,0 @@
|
||||||
/* ISLE SLINGERS — core.js
|
|
||||||
* Pure, deterministic game core: physics, rules, scoring, events.
|
|
||||||
* No DOM, no audio, no Math.random — the renderer consumes state.events
|
|
||||||
* for visual-only effects. Loadable in browser (window.ISLE_CORE) or node.
|
|
||||||
*/
|
|
||||||
(function (root, factory) {
|
|
||||||
var api = factory();
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
||||||
else root.ISLE_CORE = api;
|
|
||||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
var WORLD = { w: 2400, ground: 900 };
|
|
||||||
var VIEW = { w: 1280, h: 720 }; // base viewport (renderer renders at this size,
|
|
||||||
// then CSS-scales; camera math lives in the core)
|
|
||||||
|
|
||||||
var MATERIALS = {
|
|
||||||
wood: { hp: 1300, density: 1.0, restitution: 0.18, dmgFactor: 0.9, score: 500, maxVel: 520 },
|
|
||||||
ice: { hp: 480, density: 0.7, restitution: 0.05, dmgFactor: 1.6, score: 800, maxVel: 560 },
|
|
||||||
// stone: retuned 2026-09-28 (RC-0D193E, Commander bug #3 "blocks and pig
|
|
||||||
// bad guys too hard to kill"): at hp 3400 with dmgFactor 0.35 a 70x70
|
|
||||||
// cube took ~11 full-power direct hits — stone walls were functionally
|
|
||||||
// unbreakable, so every enemy behind one was unkillable. Now ~2-3 hits.
|
|
||||||
stone: { hp: 1400, density: 2.2, restitution: 0.04, dmgFactor: 0.8, score: 300, maxVel: 480 }
|
|
||||||
};
|
|
||||||
|
|
||||||
var BIRD_TYPES = {
|
|
||||||
wren: { r: 20, mass: 3.0, label: 'Wren', ability: 'fury' }, // tap: horizontal burst
|
|
||||||
zippy: { r: 17, mass: 2.2, label: 'Zippy', ability: 'dive' }, // tap: steep dive
|
|
||||||
boulder: { r: 26, mass: 6.5, label: 'Boulder', ability: 'quake' } // tap: radial shock
|
|
||||||
};
|
|
||||||
|
|
||||||
var ENEMY_TYPES = {
|
|
||||||
mogg: { hp: 1, r: 24, score: 5000, crushVel: 330, crushDmg: 1.2 },
|
|
||||||
grum: { hp: 2, r: 32, score: 8000, crushVel: 380, crushDmg: 1.0 },
|
|
||||||
king: { hp: 3, r: 38, score: 15000, crushVel: 430, crushDmg: 0.85 }
|
|
||||||
};
|
|
||||||
|
|
||||||
var GRAVITY = 1500;
|
|
||||||
var LAUNCH_POWER = 11.5; // velocity per px of pull
|
|
||||||
var MAX_PULL = 150;
|
|
||||||
var MIN_PULL = 14;
|
|
||||||
var AIR_DRAG = 0.999;
|
|
||||||
var BIRD_BOUNCE = 0.34;
|
|
||||||
var BIRD_GROUND_FRICTION = 0.86;
|
|
||||||
var REST_SPEED = 14; // below this a bird counts as settling
|
|
||||||
var SETTLE_TIME = 0.9; // seconds under REST_SPEED before bird is spent
|
|
||||||
var BIRD_MAX_AGE = 12;
|
|
||||||
var ABILITY_CD = 0.35; // min seconds after launch before ability usable
|
|
||||||
var BIRD_BONUS = 10000;
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ state
|
|
||||||
|
|
||||||
function createGame(levelDef) {
|
|
||||||
var g = {
|
|
||||||
level: levelDef,
|
|
||||||
world: { w: WORLD.w, ground: WORLD.ground },
|
|
||||||
time: 0,
|
|
||||||
score: 0,
|
|
||||||
birds: [],
|
|
||||||
blocks: [],
|
|
||||||
enemies: [],
|
|
||||||
events: [], // visual/audio events for the renderer
|
|
||||||
scorePopups: [], // {x,y,text,age}
|
|
||||||
hadEnemies: levelDef.enemies.length > 0,
|
|
||||||
camera: { x: levelDef.sling.x + 260, y: 560, zoom: 1, shake: 0, snap: true },
|
|
||||||
status: 'ready', // ready | flying | won | lost
|
|
||||||
activeBird: -1,
|
|
||||||
launched: 0,
|
|
||||||
wonAt: null,
|
|
||||||
lostAt: null,
|
|
||||||
stats: { impacts: 0, destroyedBlocks: 0, destroyedEnemies: 0, abilityUses: 0, distance: 0 }
|
|
||||||
};
|
|
||||||
g.blocks = levelDef.blocks.map(function (b, i) {
|
|
||||||
var m = MATERIALS[b.mat];
|
|
||||||
return {
|
|
||||||
id: i, x: b.x, y: b.y, w: b.w, h: b.h, mat: b.mat,
|
|
||||||
hp: m.hp, maxHp: m.hp,
|
|
||||||
vx: 0, vy: 0, rest: 1,
|
|
||||||
mass: m.density * b.w * b.h / 1400
|
|
||||||
};
|
|
||||||
});
|
|
||||||
g.enemies = levelDef.enemies.map(function (e, i) {
|
|
||||||
var t = ENEMY_TYPES[e.kind];
|
|
||||||
// level data y is the CENTER (the level-integrity test treats it the
|
|
||||||
// same way); spawning at e.y - r made every enemy drop 24-38px on
|
|
||||||
// load — a visible "objects moving on their own" wobble at start
|
|
||||||
return {
|
|
||||||
id: i, kind: e.kind, x: e.x, y: e.y, r: t.r,
|
|
||||||
hp: t.hp, maxHp: t.hp, vx: 0, vy: 0, rest: 1, dead: false, deathAge: 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
var i;
|
|
||||||
for (i = 0; i < levelDef.birds.length; i++) {
|
|
||||||
g.birds.push(makeBird(levelDef.birds[i], i));
|
|
||||||
}
|
|
||||||
g.activeBird = g.birds.length ? 0 : -1;
|
|
||||||
if (g.activeBird >= 0) {
|
|
||||||
var fb = g.birds[0];
|
|
||||||
fb.state = 'aiming';
|
|
||||||
fb.x = levelDef.sling.x;
|
|
||||||
fb.y = levelDef.sling.y;
|
|
||||||
}
|
|
||||||
return g;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeBird(kind, idx) {
|
|
||||||
var t = BIRD_TYPES[kind];
|
|
||||||
return {
|
|
||||||
id: idx, kind: kind, r: t.r, mass: t.mass,
|
|
||||||
x: 0, y: 0, vx: 0, vy: 0,
|
|
||||||
state: 'queued', // queued | aiming | flying | spent
|
|
||||||
abilityUsed: false,
|
|
||||||
settleTimer: 0, age: 0,
|
|
||||||
launchT: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function activeBird(g) { return g.activeBird >= 0 ? g.birds[g.activeBird] : null; }
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- aiming
|
|
||||||
|
|
||||||
// Returns {vx,vy,pull} if a valid launch for the given drag, else null.
|
|
||||||
function aimVector(g, sling, drag) {
|
|
||||||
var dx = sling.x - drag.x, dy = sling.y - drag.y;
|
|
||||||
var len = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
if (len < MIN_PULL) return null;
|
|
||||||
var cl = Math.min(len, MAX_PULL);
|
|
||||||
var v = cl * LAUNCH_POWER;
|
|
||||||
return { vx: (dx / len) * v, vy: (dy / len) * v, pull: cl };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pure trajectory preview: identical integrator + substepping as the live
|
|
||||||
// sim (2x 1/120 per output point), so the dotted preview matches the real
|
|
||||||
// flight exactly. Gravity + drag only; stops at the ground.
|
|
||||||
function trajectory(g, sx, sy, vx, vy, steps) {
|
|
||||||
var pts = [];
|
|
||||||
var x = sx, y = sy, i, sub;
|
|
||||||
for (i = 0; i < steps; i++) {
|
|
||||||
for (sub = 0; sub < 2; sub++) {
|
|
||||||
var dt = 1 / 120;
|
|
||||||
var nvy = vy + GRAVITY * dt;
|
|
||||||
x += vx * dt;
|
|
||||||
y += (vy + nvy) * 0.5 * dt;
|
|
||||||
vx *= AIR_DRAG;
|
|
||||||
vy = nvy;
|
|
||||||
}
|
|
||||||
if (y > WORLD.ground - 18) { pts.push({ x: x, y: WORLD.ground - 18, last: true }); break; }
|
|
||||||
pts.push({ x: x, y: y, last: false });
|
|
||||||
}
|
|
||||||
return pts;
|
|
||||||
}
|
|
||||||
|
|
||||||
function launch(g, dragPos) {
|
|
||||||
var b = activeBird(g);
|
|
||||||
if (!b || b.state !== 'aiming' || g.status !== 'ready') return false;
|
|
||||||
var v = aimVector(g, { x: g.level.sling.x, y: g.level.sling.y }, dragPos);
|
|
||||||
if (!v) return false;
|
|
||||||
b.x = g.level.sling.x; b.y = g.level.sling.y;
|
|
||||||
b.vx = v.vx; b.vy = v.vy;
|
|
||||||
b.state = 'flying';
|
|
||||||
b.launchT = g.time;
|
|
||||||
g.status = 'flying';
|
|
||||||
g.launched++;
|
|
||||||
g.events.push({ type: 'launch', x: b.x, y: b.y });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerAbility(g) {
|
|
||||||
var b = activeBird(g);
|
|
||||||
if (!b || b.state !== 'flying' || b.abilityUsed) return false;
|
|
||||||
if (g.time - b.launchT < ABILITY_CD) return false;
|
|
||||||
var kind = b.kind;
|
|
||||||
if (kind === 'wren') {
|
|
||||||
b.vx = b.vx * 0.4 + 980;
|
|
||||||
g.events.push({ type: 'ability_fury', x: b.x, y: b.y });
|
|
||||||
} else if (kind === 'zippy') {
|
|
||||||
b.vy = Math.max(b.vy, 0) + 1050;
|
|
||||||
b.vx *= 0.55;
|
|
||||||
g.events.push({ type: 'ability_dive', x: b.x, y: b.y });
|
|
||||||
} else if (kind === 'boulder') {
|
|
||||||
var R = 150;
|
|
||||||
var i, d, f, ent;
|
|
||||||
for (i = 0; i < g.blocks.length; i++) {
|
|
||||||
var bl = g.blocks[i];
|
|
||||||
if (bl.hp <= 0) continue;
|
|
||||||
d = Math.hypot(bl.x - b.x, bl.y - b.y) - (bl.w + bl.h) / 4;
|
|
||||||
if (d < R) { f = 1 - Math.max(0, d) / R; damageBlock(g, bl, 120 + 300 * f, 'quake'); }
|
|
||||||
}
|
|
||||||
for (i = 0; i < g.enemies.length; i++) {
|
|
||||||
ent = g.enemies[i];
|
|
||||||
if (ent.dead) continue;
|
|
||||||
d = Math.hypot(ent.x - b.x, ent.y - b.y) - ent.r;
|
|
||||||
if (d < R) { f = 1 - Math.max(0, d) / R; damageEnemy(g, ent, 0.7 + 1.6 * f); }
|
|
||||||
}
|
|
||||||
g.camera.shake = Math.max(g.camera.shake, 14);
|
|
||||||
g.events.push({ type: 'ability_quake', x: b.x, y: b.y, r: R });
|
|
||||||
}
|
|
||||||
b.abilityUsed = true;
|
|
||||||
g.stats.abilityUses++;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- damage
|
|
||||||
|
|
||||||
function damageBlock(g, bl, dmg, src) {
|
|
||||||
if (bl.hp <= 0) return;
|
|
||||||
bl.hp -= dmg;
|
|
||||||
if (bl.hp <= 0) {
|
|
||||||
bl.hp = 0;
|
|
||||||
g.score += MATERIALS[bl.mat].score;
|
|
||||||
g.stats.destroyedBlocks++;
|
|
||||||
g.scorePopups.push({ x: bl.x, y: bl.y, text: '+' + MATERIALS[bl.mat].score, age: 0 });
|
|
||||||
g.events.push({ type: 'break', x: bl.x, y: bl.y, mat: bl.mat, big: bl.w * bl.h > 12000 });
|
|
||||||
g.camera.shake = Math.max(g.camera.shake, 4 + bl.w * bl.h / 4000);
|
|
||||||
} else if (dmg > 40) {
|
|
||||||
g.events.push({ type: 'crack', x: bl.x, y: bl.y, mat: bl.mat });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function damageEnemy(g, e, dmg) {
|
|
||||||
if (e.dead) return;
|
|
||||||
e.hp -= dmg;
|
|
||||||
if (e.hp <= 0) {
|
|
||||||
e.dead = true;
|
|
||||||
e.deathAge = 0;
|
|
||||||
g.score += ENEMY_TYPES[e.kind].score;
|
|
||||||
g.stats.destroyedEnemies++;
|
|
||||||
g.scorePopups.push({ x: e.x, y: e.y - 30, text: '+' + ENEMY_TYPES[e.kind].score, age: 0 });
|
|
||||||
g.events.push({ type: 'pop', x: e.x, y: e.y, kind: e.kind });
|
|
||||||
g.camera.shake = Math.max(g.camera.shake, 7);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------- step
|
|
||||||
|
|
||||||
function step(g, dt) {
|
|
||||||
g.time += dt;
|
|
||||||
if (g.status === 'won' || g.status === 'lost') {
|
|
||||||
// let physics settle for a beat so the win/lose screen lands naturally
|
|
||||||
stepPhysics(g, dt);
|
|
||||||
updatePopups(g, dt);
|
|
||||||
decayCamera(g, dt);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
stepPhysics(g, dt);
|
|
||||||
updatePopups(g, dt);
|
|
||||||
decayCamera(g, dt);
|
|
||||||
updateCameraTarget(g);
|
|
||||||
checkEndStates(g);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stepPhysics(g, dt) {
|
|
||||||
var i, b;
|
|
||||||
|
|
||||||
// active bird flight
|
|
||||||
b = activeBird(g);
|
|
||||||
if (b && b.state === 'flying') {
|
|
||||||
b.age += dt;
|
|
||||||
var nvy = b.vy + GRAVITY * dt;
|
|
||||||
b.x += b.vx * dt;
|
|
||||||
b.y += (b.vy + nvy) * 0.5 * dt;
|
|
||||||
b.vx *= AIR_DRAG;
|
|
||||||
b.vy = nvy;
|
|
||||||
g.stats.distance = Math.max(g.stats.distance, b.x);
|
|
||||||
collideBirdWorld(g, b);
|
|
||||||
collideBirdBlocks(g, b);
|
|
||||||
collideBirdEnemies(g, b);
|
|
||||||
// settle / spend detection (low speed anywhere = spent, whether on
|
|
||||||
// the ground or perched on a beam; jitter-tolerant accumulation)
|
|
||||||
var sp = Math.hypot(b.vx, b.vy);
|
|
||||||
if (sp < REST_SPEED) b.settleTimer += dt;
|
|
||||||
else b.settleTimer = Math.max(0, b.settleTimer - dt * 4);
|
|
||||||
if (b.settleTimer > SETTLE_TIME || b.age > BIRD_MAX_AGE || b.x > WORLD.w + 200 || b.x < -200) {
|
|
||||||
spendBird(g, b);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// blocks: gravity + integration + collisions
|
|
||||||
for (i = 0; i < g.blocks.length; i++) {
|
|
||||||
var bl = g.blocks[i];
|
|
||||||
if (bl.hp <= 0) continue;
|
|
||||||
bl.vy = Math.min(bl.vy + GRAVITY * dt, MATERIALS[bl.mat].maxVel);
|
|
||||||
bl.vx *= 0.995;
|
|
||||||
bl.x += bl.vx * dt;
|
|
||||||
bl.y += bl.vy * dt;
|
|
||||||
// ground
|
|
||||||
var halfH = bl.h / 2;
|
|
||||||
if (bl.y + halfH > WORLD.ground) {
|
|
||||||
var impact = bl.vy;
|
|
||||||
bl.y = WORLD.ground - halfH;
|
|
||||||
if (impact > 90) {
|
|
||||||
bl.vy = -impact * MATERIALS[bl.mat].restitution;
|
|
||||||
bl.vx *= 0.8;
|
|
||||||
if (impact > 240) {
|
|
||||||
damageBlock(g, bl, (impact - 240) * 0.45, 'ground');
|
|
||||||
g.events.push({ type: 'block_impact', x: bl.x, y: WORLD.ground, mat: bl.mat, strength: impact });
|
|
||||||
}
|
|
||||||
bl.rest = 0;
|
|
||||||
} else {
|
|
||||||
bl.vy = 0;
|
|
||||||
bl.vx *= 0.82; // rolling friction
|
|
||||||
if (Math.abs(bl.vx) < 4) { bl.vx = 0; bl.rest = 1; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// walls (keep the mess on the island)
|
|
||||||
if (bl.x - bl.w / 2 < 0) { bl.x = bl.w / 2; bl.vx = Math.abs(bl.vx) * 0.2; }
|
|
||||||
if (bl.x + bl.w / 2 > WORLD.w) { bl.x = WORLD.w - bl.w / 2; bl.vx = -Math.abs(bl.vx) * 0.2; }
|
|
||||||
}
|
|
||||||
// block vs block (positional + 1D impulse, 2 relaxation passes)
|
|
||||||
var pass, j, a, c;
|
|
||||||
for (pass = 0; pass < 2; pass++) {
|
|
||||||
for (i = 0; i < g.blocks.length; i++) {
|
|
||||||
a = g.blocks[i];
|
|
||||||
if (a.hp <= 0) continue;
|
|
||||||
for (j = i + 1; j < g.blocks.length; j++) {
|
|
||||||
c = g.blocks[j];
|
|
||||||
if (c.hp <= 0) continue;
|
|
||||||
resolveBoxPair(g, a, c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// block vs bird (crush)
|
|
||||||
if (b && b.state === 'flying') {
|
|
||||||
for (i = 0; i < g.blocks.length; i++) {
|
|
||||||
var blk = g.blocks[i];
|
|
||||||
if (blk.hp <= 0) continue;
|
|
||||||
crushCircleVsBox(g, blk, b, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// enemies: settle on ground/blocks, take crush damage
|
|
||||||
for (i = 0; i < g.enemies.length; i++) {
|
|
||||||
var e = g.enemies[i];
|
|
||||||
if (e.dead) { e.deathAge += dt; continue; }
|
|
||||||
e.vy = Math.min(e.vy + GRAVITY * dt, 700);
|
|
||||||
e.x += e.vx * dt;
|
|
||||||
e.y += e.vy * dt;
|
|
||||||
e.vx *= 0.9;
|
|
||||||
if (e.y + e.r > WORLD.ground) {
|
|
||||||
if (e.vy > 420) { e.vy = -e.vy * 0.25; e.vx *= 0.6; }
|
|
||||||
else { e.vy = 0; e.y = WORLD.ground - e.r; e.vx *= 0.8; }
|
|
||||||
}
|
|
||||||
for (j = 0; j < g.blocks.length; j++) {
|
|
||||||
var bb = g.blocks[j];
|
|
||||||
if (bb.hp <= 0) continue;
|
|
||||||
crushCircleVsBox(g, bb, e, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function crushCircleVsBox(g, box, ent, isEnemy) {
|
|
||||||
var cx = Math.max(box.x - box.w / 2, Math.min(ent.x, box.x + box.w / 2));
|
|
||||||
var cy = Math.max(box.y - box.h / 2, Math.min(ent.y, box.y + box.h / 2));
|
|
||||||
var dx = ent.x - cx, dy = ent.y - cy;
|
|
||||||
var d2 = dx * dx + dy * dy;
|
|
||||||
if (d2 >= ent.r * ent.r) return;
|
|
||||||
var d = Math.sqrt(d2) || 0.001;
|
|
||||||
var nx = dx / d, ny = dy / d;
|
|
||||||
var overlap = ent.r - d;
|
|
||||||
ent.x += nx * overlap * 0.9;
|
|
||||||
ent.y += ny * overlap * 0.9;
|
|
||||||
// impulse: relative velocity along normal
|
|
||||||
var rvx = (ent.vx || 0) - (box.vx || 0), rvy = (ent.vy || 0) - (box.vy || 0);
|
|
||||||
var rel = rvx * nx + rvy * ny;
|
|
||||||
if (rel < -30) {
|
|
||||||
var relSpeed = -rel;
|
|
||||||
ent.vx -= nx * rel * 1.4;
|
|
||||||
ent.vy -= ny * rel * 1.4;
|
|
||||||
if (isEnemy) {
|
|
||||||
// crush damage: fast falling boxes hurt what they land on
|
|
||||||
var spec = ENEMY_TYPES[ent.kind];
|
|
||||||
var boxSpeed = Math.hypot(box.vx || 0, box.vy || 0);
|
|
||||||
if (boxSpeed > spec.crushVel && ny < -0.3) {
|
|
||||||
damageEnemy(g, ent, (boxSpeed - spec.crushVel) * 0.004 * spec.crushDmg + 0.35);
|
|
||||||
g.events.push({ type: 'crush', x: ent.x, y: ent.y });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// bird: transfer momentum into the box
|
|
||||||
var dmg = relSpeed * 0.32 * (ent.mass || 3) * 0.25;
|
|
||||||
if (dmg > 25) damageBlock(g, box, dmg, 'bird');
|
|
||||||
box.vx += nx * rel * 0.05 * (ent.mass || 3);
|
|
||||||
box.vy += ny * rel * 0.05 * (ent.mass || 3);
|
|
||||||
}
|
|
||||||
} else if (rel < 0) {
|
|
||||||
// slow resting contact: remove the normal approach velocity so a
|
|
||||||
// perched enemy/box pair stops oscillating under gravity
|
|
||||||
ent.vx -= nx * rel;
|
|
||||||
ent.vy -= ny * rel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function collideBirdWorld(g, b) {
|
|
||||||
if (b.y + b.r > WORLD.ground) {
|
|
||||||
var impact = -b.vy;
|
|
||||||
b.y = WORLD.ground - b.r;
|
|
||||||
if (b.vy > 0) b.vy = -b.vy * BIRD_BOUNCE;
|
|
||||||
b.vx *= BIRD_GROUND_FRICTION;
|
|
||||||
if (impact > 250) {
|
|
||||||
g.events.push({ type: 'bird_ground', x: b.x, y: WORLD.ground, strength: impact });
|
|
||||||
g.camera.shake = Math.max(g.camera.shake, 5);
|
|
||||||
}
|
|
||||||
if (Math.abs(b.vy) < 30) b.vy = 0;
|
|
||||||
}
|
|
||||||
if (b.x - b.r < 0) { b.x = b.r; b.vx = Math.abs(b.vx) * 0.3; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function collideBirdBlocks(g, b) {
|
|
||||||
var i;
|
|
||||||
for (i = 0; i < g.blocks.length; i++) {
|
|
||||||
var bl = g.blocks[i];
|
|
||||||
if (bl.hp <= 0) continue;
|
|
||||||
var cx = Math.max(bl.x - bl.w / 2, Math.min(b.x, bl.x + bl.w / 2));
|
|
||||||
var cy = Math.max(bl.y - bl.h / 2, Math.min(b.y, bl.y + bl.h / 2));
|
|
||||||
var dx = b.x - cx, dy = b.y - cy;
|
|
||||||
var d2 = dx * dx + dy * dy;
|
|
||||||
if (d2 >= b.r * b.r) continue;
|
|
||||||
var d = Math.sqrt(d2) || 0.001;
|
|
||||||
var nx = dx / d, ny = dy / d;
|
|
||||||
var overlap = b.r - d;
|
|
||||||
b.x += nx * overlap;
|
|
||||||
b.y += ny * overlap;
|
|
||||||
var rvx = b.vx - bl.vx, rvy = b.vy - bl.vy;
|
|
||||||
var rel = rvx * nx + rvy * ny;
|
|
||||||
if (rel < 0) {
|
|
||||||
var relSpeed = -rel;
|
|
||||||
// impulse: j = -(1+e)*rel / (1/m1 + 1/m2)
|
|
||||||
var invM = 1 / b.mass + 1 / bl.mass;
|
|
||||||
var j = -(1 + BIRD_BOUNCE) * rel / invM;
|
|
||||||
b.vx += (j / b.mass) * nx;
|
|
||||||
b.vy += (j / b.mass) * ny;
|
|
||||||
bl.vx -= (j / bl.mass) * nx;
|
|
||||||
bl.vy -= (j / bl.mass) * ny;
|
|
||||||
g.stats.impacts++;
|
|
||||||
var dmg = relSpeed * 0.55 * (b.mass / 3) * MATERIALS[bl.mat].dmgFactor;
|
|
||||||
if (dmg > 30) {
|
|
||||||
damageBlock(g, bl, dmg, 'bird');
|
|
||||||
g.events.push({ type: 'hit', x: cx, y: cy, mat: bl.mat, strength: relSpeed });
|
|
||||||
g.camera.shake = Math.max(g.camera.shake, Math.min(12, relSpeed / 90));
|
|
||||||
}
|
|
||||||
b.vx *= 0.985; b.vy *= 0.985;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function collideBirdEnemies(g, b) {
|
|
||||||
var i;
|
|
||||||
for (i = 0; i < g.enemies.length; i++) {
|
|
||||||
var e = g.enemies[i];
|
|
||||||
if (e.dead) continue;
|
|
||||||
var dx = e.x - b.x, dy = e.y - b.y;
|
|
||||||
var rr = e.r + b.r;
|
|
||||||
var d2 = dx * dx + dy * dy;
|
|
||||||
if (d2 >= rr * rr) continue;
|
|
||||||
var d = Math.sqrt(d2) || 0.001;
|
|
||||||
var nx = dx / d, ny = dy / d;
|
|
||||||
var rel = (b.vx - e.vx) * nx + (b.vy - e.vy) * ny;
|
|
||||||
if (rel > 0) {
|
|
||||||
e.vx += nx * rel * 0.8;
|
|
||||||
e.vy += ny * rel * 0.8 - 120;
|
|
||||||
// per-target hit cooldown so one pass-through = one damage instance
|
|
||||||
if (g.time - (e.lastHit || -9) > 0.12) {
|
|
||||||
var speed = Math.hypot(b.vx, b.vy);
|
|
||||||
// retuned 2026-09-28 (RC-0D193E, Commander bug #3): the old curve
|
|
||||||
// gave ~0.9 dmg to a soft wren lob — a mogg (hp1) could not be
|
|
||||||
// killed without a perfect full-power hit. Bumped so a decent
|
|
||||||
// medium shot connects; the king (hp3) still survives one
|
|
||||||
// full-power wren hit (pinned in test/sim.test.mjs).
|
|
||||||
var dmg = 0.35 + speed / 800 * (b.mass / 3);
|
|
||||||
damageEnemy(g, e, dmg);
|
|
||||||
e.lastHit = g.time;
|
|
||||||
g.events.push({ type: 'bird_hit_enemy', x: e.x, y: e.y, strength: speed });
|
|
||||||
}
|
|
||||||
b.vx *= 0.92; b.vy *= 0.92;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resting-contact clamp: inelastic contacts + zeroing below this speed,
|
|
||||||
// so stacked blocks settle to exactly still instead of oscillating under
|
|
||||||
// gravity re-injection.
|
|
||||||
var REST_VEL_CLAMP = 30; // |v| below this on a resting contact is zeroed
|
|
||||||
|
|
||||||
function resolveBoxPair(g, a, c) {
|
|
||||||
var ox = (a.w / 2 + c.w / 2) - Math.abs(a.x - c.x);
|
|
||||||
var oy = (a.h / 2 + c.h / 2) - Math.abs(a.y - c.y);
|
|
||||||
if (ox <= 0 || oy <= 0) return;
|
|
||||||
if (ox < oy) {
|
|
||||||
// side contact: push apart along x
|
|
||||||
var n = c.x >= a.x ? 1 : -1; // direction from a toward c
|
|
||||||
a.x -= n * ox * 0.5; c.x += n * ox * 0.5;
|
|
||||||
var relv = (c.vx - a.vx) * n;
|
|
||||||
if (relv < 0) { // approaching
|
|
||||||
var avg = (a.vx + c.vx) / 2;
|
|
||||||
a.vx = avg; c.vx = avg;
|
|
||||||
}
|
|
||||||
a.vx *= 0.9; c.vx *= 0.9;
|
|
||||||
} else {
|
|
||||||
// stacked contact: push apart along y, then kill resting velocity
|
|
||||||
var n2 = c.y >= a.y ? 1 : -1;
|
|
||||||
a.y -= n2 * oy * 0.5; c.y += n2 * oy * 0.5;
|
|
||||||
var relv2 = (c.vy - a.vy) * n2;
|
|
||||||
if (relv2 < -REST_VEL_CLAMP) {
|
|
||||||
// real impact: inelastic average (blocks do not bounce off each other)
|
|
||||||
var avgY = (a.vy + c.vy) / 2;
|
|
||||||
a.vy = avgY; c.vy = avgY;
|
|
||||||
} else if (relv2 < 0) {
|
|
||||||
// resting: top takes the bottom's velocity (kills the relative
|
|
||||||
// velocity), then zero both below the clamp so stacks stop
|
|
||||||
// oscillating under gravity re-injection
|
|
||||||
var top = n2 === 1 ? a : c;
|
|
||||||
var bot = n2 === 1 ? c : a;
|
|
||||||
top.vy = bot.vy;
|
|
||||||
if (Math.abs(top.vy) < REST_VEL_CLAMP) { top.vy = 0; bot.vy = 0; }
|
|
||||||
} else {
|
|
||||||
// separating / at rest: clamp near-zero velocities
|
|
||||||
if (Math.abs(a.vy) < 2) a.vy = 0;
|
|
||||||
if (Math.abs(c.vy) < 2) c.vy = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function spendBird(g, b) {
|
|
||||||
b.state = 'spent';
|
|
||||||
b.vx = 0; b.vy = 0;
|
|
||||||
g.events.push({ type: 'bird_gone', x: b.x, y: b.y });
|
|
||||||
// next bird onto the sling
|
|
||||||
var next = -1;
|
|
||||||
for (var i = g.activeBird + 1; i < g.birds.length; i++) {
|
|
||||||
if (g.birds[i].state === 'queued') { next = i; break; }
|
|
||||||
}
|
|
||||||
g.activeBird = next;
|
|
||||||
if (next >= 0) {
|
|
||||||
var nb = g.birds[next];
|
|
||||||
nb.state = 'aiming';
|
|
||||||
nb.x = g.level.sling.x; nb.y = g.level.sling.y;
|
|
||||||
g.status = 'ready';
|
|
||||||
g.events.push({ type: 'bird_load', x: nb.x, y: nb.y, kind: nb.kind });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkEndStates(g) {
|
|
||||||
if (g.status === 'won' || g.status === 'lost') return;
|
|
||||||
var alive = 0;
|
|
||||||
for (var i = 0; i < g.enemies.length; i++) if (!g.enemies[i].dead) alive++;
|
|
||||||
if (g.hadEnemies && alive === 0 && g.launched > 0) {
|
|
||||||
g.status = 'won';
|
|
||||||
g.wonAt = g.time;
|
|
||||||
g.score += BIRD_BONUS * birdsRemaining(g);
|
|
||||||
g.events.push({ type: 'level_won', x: g.camera.x, y: 400 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var b = activeBird(g);
|
|
||||||
var inFlight = false;
|
|
||||||
for (var k = 0; k < g.birds.length; k++) if (g.birds[k].state === 'flying') inFlight = true;
|
|
||||||
if (!inFlight && !b && g.status === 'flying') {
|
|
||||||
// last bird spent, enemies remain
|
|
||||||
g.status = 'lost';
|
|
||||||
g.lostAt = g.time;
|
|
||||||
g.events.push({ type: 'level_lost', x: g.camera.x, y: 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function birdsRemaining(g) {
|
|
||||||
var n = 0;
|
|
||||||
for (var i = 0; i < g.birds.length; i++) if (g.birds[i].state === 'queued') n++;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
function starsFor(g) {
|
|
||||||
var th = g.level.stars;
|
|
||||||
if (!th) return 0;
|
|
||||||
if (g.score >= th[2]) return 3;
|
|
||||||
if (g.score >= th[1]) return 2;
|
|
||||||
if (g.score >= th[0] || g.status === 'won') return 1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePopups(g, dt) {
|
|
||||||
for (var i = g.scorePopups.length - 1; i >= 0; i--) {
|
|
||||||
g.scorePopups[i].age += dt;
|
|
||||||
g.scorePopups[i].y -= 40 * dt;
|
|
||||||
if (g.scorePopups[i].age > 1.4) g.scorePopups.splice(i, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCameraTarget(g) {
|
|
||||||
var b = activeBird(g);
|
|
||||||
var cam = g.camera;
|
|
||||||
var tx, ty, tz;
|
|
||||||
if (b && b.state === 'flying') {
|
|
||||||
// in flight: follow the slinger 1:1 (the bird IS the point of interest)
|
|
||||||
tx = Math.max(420, Math.min(WORLD.w - 420, b.x + 220));
|
|
||||||
ty = Math.max(380, Math.min(WORLD.ground - 160, b.y + 60));
|
|
||||||
tz = 1;
|
|
||||||
} else {
|
|
||||||
// aiming / waiting: frame the slingshot AND the whole action zone
|
|
||||||
// (live blocks + enemies) so launch point and target are always
|
|
||||||
// visible at once — zooming out when the level needs it. Without
|
|
||||||
// this the sling was visible but the fort was off-screen (or
|
|
||||||
// vice-versa mid-flight), making aiming pure guesswork.
|
|
||||||
var left = g.level.sling.x - 190;
|
|
||||||
var right = left + 300;
|
|
||||||
for (var i = 0; i < g.blocks.length; i++) {
|
|
||||||
var bl = g.blocks[i];
|
|
||||||
if (bl.hp > 0) right = Math.max(right, bl.x + bl.w / 2 + 90);
|
|
||||||
}
|
|
||||||
for (var k = 0; k < g.enemies.length; k++) {
|
|
||||||
var en = g.enemies[k];
|
|
||||||
if (!en.dead) right = Math.max(right, en.x + en.r + 90);
|
|
||||||
}
|
|
||||||
var span = right - left;
|
|
||||||
tz = Math.min(1, VIEW.w / span);
|
|
||||||
tx = left + span / 2;
|
|
||||||
// ground at ~78% down the view, whatever the zoom
|
|
||||||
ty = Math.max(300, Math.min(WORLD.ground, WORLD.ground - (0.28 * VIEW.h) / tz));
|
|
||||||
}
|
|
||||||
if (cam.snap) { // first frame of a level: land on target, no pan
|
|
||||||
cam.x = tx; cam.y = ty; cam.zoom = tz; cam.snap = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cam.x += (tx - cam.x) * 0.06;
|
|
||||||
cam.y += (ty - cam.y) * 0.06;
|
|
||||||
cam.zoom = (cam.zoom || 1) + (tz - (cam.zoom || 1)) * 0.08;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decayCamera(g, dt) {
|
|
||||||
if (g.camera.shake > 0) {
|
|
||||||
g.camera.shake *= Math.pow(0.03, dt); // fast decay
|
|
||||||
if (g.camera.shake < 0.3) g.camera.shake = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ API
|
|
||||||
|
|
||||||
return {
|
|
||||||
WORLD: WORLD,
|
|
||||||
VIEW: VIEW,
|
|
||||||
MATERIALS: MATERIALS,
|
|
||||||
BIRD_TYPES: BIRD_TYPES,
|
|
||||||
ENEMY_TYPES: ENEMY_TYPES,
|
|
||||||
GRAVITY: GRAVITY,
|
|
||||||
MAX_PULL: MAX_PULL,
|
|
||||||
MIN_PULL: MIN_PULL,
|
|
||||||
LAUNCH_POWER: LAUNCH_POWER,
|
|
||||||
BIRD_BONUS: BIRD_BONUS,
|
|
||||||
createGame: createGame,
|
|
||||||
activeBird: activeBird,
|
|
||||||
aimVector: aimVector,
|
|
||||||
trajectory: trajectory,
|
|
||||||
launch: launch,
|
|
||||||
triggerAbility: triggerAbility,
|
|
||||||
step: step,
|
|
||||||
starsFor: starsFor,
|
|
||||||
birdsRemaining: birdsRemaining,
|
|
||||||
AIR_DRAG: AIR_DRAG
|
|
||||||
};
|
|
||||||
});
|
|
||||||
158
js/levels.js
158
js/levels.js
|
|
@ -1,158 +0,0 @@
|
||||||
/* ISLE SLINGERS — levels.js
|
|
||||||
* Original level layouts. Coordinates are world pixels; (x,y) = block CENTER.
|
|
||||||
* World: width 2400, ground surface at y=900.
|
|
||||||
*/
|
|
||||||
(function (root, factory) {
|
|
||||||
var api = factory();
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
||||||
else root.ISLE_LEVELS = api;
|
|
||||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// helpers to keep the data below readable
|
|
||||||
function wood(x, y, w, h) { return { x: x, y: y, w: w, h: h, mat: 'wood' }; }
|
|
||||||
function ice(x, y, w, h) { return { x: x, y: y, w: w, h: h, mat: 'ice' }; }
|
|
||||||
function stone(x, y, w, h) { return { x: x, y: y, w: w, h: h, mat: 'stone' }; }
|
|
||||||
function mogg(x, y) { return { x: x, y: y, kind: 'mogg' }; }
|
|
||||||
function grum(x, y) { return { x: x, y: y, kind: 'grum' }; }
|
|
||||||
function king(x, y) { return { x: x, y: y, kind: 'king' }; }
|
|
||||||
|
|
||||||
var SLING = { x: 260, y: 745 };
|
|
||||||
|
|
||||||
var LEVELS = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'First Perch',
|
|
||||||
birds: ['wren', 'wren', 'zippy'],
|
|
||||||
sling: SLING,
|
|
||||||
stars: [12000, 24000, 36000],
|
|
||||||
decor: { palms: [950, 2150, 2350], flowers: [700, 1200, 2050] },
|
|
||||||
blocks: [
|
|
||||||
// simple hut
|
|
||||||
wood(1500, 830, 30, 140),
|
|
||||||
wood(1640, 830, 30, 140),
|
|
||||||
wood(1570, 745, 200, 30)
|
|
||||||
],
|
|
||||||
enemies: [
|
|
||||||
mogg(1570, 876),
|
|
||||||
mogg(1770, 876)
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'Crate Caves',
|
|
||||||
birds: ['wren', 'zippy', 'wren'],
|
|
||||||
sling: SLING,
|
|
||||||
stars: [15000, 28000, 42000],
|
|
||||||
decor: { palms: [1000, 2200], flowers: [800, 1350, 2000] },
|
|
||||||
blocks: [
|
|
||||||
// left tower: stone base + wood post
|
|
||||||
stone(1420, 865, 70, 70),
|
|
||||||
wood(1420, 760, 30, 140),
|
|
||||||
// right tower: ice base + ice post
|
|
||||||
ice(1660, 860, 80, 80),
|
|
||||||
ice(1660, 765, 30, 110),
|
|
||||||
// side crates
|
|
||||||
wood(1300, 860, 80, 80),
|
|
||||||
wood(1790, 860, 80, 80)
|
|
||||||
],
|
|
||||||
enemies: [
|
|
||||||
mogg(1420, 666),
|
|
||||||
mogg(1660, 686),
|
|
||||||
mogg(1540, 876)
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'Glass Works',
|
|
||||||
birds: ['wren', 'zippy', 'boulder'],
|
|
||||||
sling: SLING,
|
|
||||||
stars: [18000, 34000, 50000],
|
|
||||||
decor: { palms: [900, 2250], flowers: [750, 1300, 2100] },
|
|
||||||
blocks: [
|
|
||||||
// ice workshop: two-storey
|
|
||||||
ice(1500, 840, 30, 120),
|
|
||||||
ice(1640, 840, 30, 120),
|
|
||||||
wood(1570, 765, 200, 30),
|
|
||||||
ice(1520, 700, 30, 100),
|
|
||||||
ice(1620, 700, 30, 100),
|
|
||||||
ice(1570, 635, 170, 30),
|
|
||||||
// wood hut
|
|
||||||
wood(1850, 830, 30, 140),
|
|
||||||
wood(1950, 830, 30, 140),
|
|
||||||
wood(1900, 745, 160, 30)
|
|
||||||
],
|
|
||||||
enemies: [
|
|
||||||
mogg(1570, 876),
|
|
||||||
mogg(1570, 596),
|
|
||||||
grum(1900, 868),
|
|
||||||
mogg(1900, 706)
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: 'Stone Keep',
|
|
||||||
birds: ['wren', 'zippy', 'boulder', 'wren'],
|
|
||||||
sling: SLING,
|
|
||||||
stars: [22000, 40000, 58000],
|
|
||||||
decor: { palms: [1050, 2300], flowers: [850, 1400, 2150] },
|
|
||||||
blocks: [
|
|
||||||
// stone wall (three cubes)
|
|
||||||
stone(1600, 865, 70, 70),
|
|
||||||
stone(1750, 865, 70, 70),
|
|
||||||
stone(1900, 865, 70, 70),
|
|
||||||
wood(1750, 815, 400, 30),
|
|
||||||
// second floor
|
|
||||||
wood(1650, 740, 30, 120),
|
|
||||||
wood(1850, 740, 30, 120),
|
|
||||||
wood(1750, 665, 260, 30),
|
|
||||||
// left ice watchtower
|
|
||||||
ice(1450, 840, 30, 120),
|
|
||||||
// right crate
|
|
||||||
stone(2050, 865, 70, 70)
|
|
||||||
],
|
|
||||||
enemies: [
|
|
||||||
mogg(1675, 876),
|
|
||||||
mogg(1825, 876),
|
|
||||||
grum(1750, 618),
|
|
||||||
mogg(2050, 806),
|
|
||||||
mogg(1500, 876)
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
name: 'Mog Fortress',
|
|
||||||
birds: ['wren', 'zippy', 'boulder', 'boulder'],
|
|
||||||
sling: SLING,
|
|
||||||
stars: [28000, 48000, 70000],
|
|
||||||
decor: { palms: [980, 2320], flowers: [780, 1300, 2180] },
|
|
||||||
blocks: [
|
|
||||||
// left stone tower (2 stories + ice cap)
|
|
||||||
stone(1450, 865, 70, 70),
|
|
||||||
stone(1450, 760, 70, 70),
|
|
||||||
ice(1450, 710, 30, 30),
|
|
||||||
// center keep
|
|
||||||
stone(1700, 865, 70, 70),
|
|
||||||
stone(1850, 865, 70, 70),
|
|
||||||
wood(1775, 815, 260, 30),
|
|
||||||
wood(1700, 740, 30, 120),
|
|
||||||
wood(1850, 740, 30, 120),
|
|
||||||
wood(1775, 665, 220, 30),
|
|
||||||
ice(1775, 635, 260, 30),
|
|
||||||
// right post
|
|
||||||
stone(2050, 865, 70, 70),
|
|
||||||
wood(2050, 760, 30, 120)
|
|
||||||
],
|
|
||||||
enemies: [
|
|
||||||
king(1775, 862),
|
|
||||||
mogg(1450, 671),
|
|
||||||
mogg(1775, 626),
|
|
||||||
mogg(2050, 676),
|
|
||||||
mogg(1575, 876),
|
|
||||||
mogg(1975, 876)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return { LEVELS: LEVELS, SLING: SLING };
|
|
||||||
});
|
|
||||||
14
js/levels/agents.md
Normal file
14
js/levels/agents.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# js/levels/ — agents.md
|
||||||
|
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
|
||||||
|
|
||||||
|
The levels **module** (aggregator). Shared scope `L`.
|
||||||
|
|
||||||
|
- `index.js` runs helpers first (constructors + SLING anchor), then one installer
|
||||||
|
per level, and exports `ISLE_LEVELS = { LEVELS, SLING }` — key-for-key stable.
|
||||||
|
- Level objects: `id, name, birds[], sling, stars[3] (ascending), decor, blocks[]
|
||||||
|
(centers! x,y = block CENTER), enemies[] (x,y = enemy center)`. The
|
||||||
|
level-integrity test enforces world bounds, support (ground or flat block top)
|
||||||
|
and non-decreasing enemy-HP difficulty — keep new levels honest.
|
||||||
|
- Adding level 6+ = add `data/level-6.js` + register it in index.html AND in the
|
||||||
|
aggregator's browser part-list expectation (order = difficulty order).
|
||||||
|
- Do not import from physics; levels are pure data plus the constructor helpers.
|
||||||
10
js/levels/data/agents.md
Normal file
10
js/levels/data/agents.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# js/levels/data/ — agents.md
|
||||||
|
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
|
||||||
|
|
||||||
|
Level definitions, one file per level (`level-1.js`..`level-5.js`).
|
||||||
|
|
||||||
|
- Each file installs `L -> levelObject` using the shared helpers (L.wood, L.ice,
|
||||||
|
L.stone, L.mogg, L.grum, L.king, L.SLING). Coordinates are block CENTERS.
|
||||||
|
- The aggregator collects the returned objects in load order = difficulty order.
|
||||||
|
- Balance rule for new levels: star thresholds ascend, enemy HP budget does not
|
||||||
|
decrease across the list (level-integrity test).
|
||||||
31
js/levels/data/level-1.js
Normal file
31
js/levels/data/level-1.js
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
/* ISLE SLINGERS -- levels/data/level-1.js (submodule: level-1)
|
||||||
|
* RC-375D84 S-2: level 1 definition, split from the original js/levels.js.
|
||||||
|
* Installer: returns the level object built from the shared helpers scope L.
|
||||||
|
*/
|
||||||
|
(function (root, factory) {
|
||||||
|
var part = factory();
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = part;
|
||||||
|
else (root.ISLE_LEVEL_PARTS = root.ISLE_LEVEL_PARTS || []).push(part);
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
'use strict';
|
||||||
|
return function (L) {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
name: 'First Perch',
|
||||||
|
birds: ['wren', 'wren', 'zippy'],
|
||||||
|
sling: L.SLING,
|
||||||
|
stars: [12000, 24000, 36000],
|
||||||
|
decor: { palms: [950, 2150, 2350], flowers: [700, 1200, 2050] },
|
||||||
|
blocks: [
|
||||||
|
// simple hut
|
||||||
|
L.wood(1500, 830, 30, 140),
|
||||||
|
L.wood(1640, 830, 30, 140),
|
||||||
|
L.wood(1570, 745, 200, 30)
|
||||||
|
],
|
||||||
|
enemies: [
|
||||||
|
L.mogg(1570, 876),
|
||||||
|
L.mogg(1770, 876)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
37
js/levels/data/level-2.js
Normal file
37
js/levels/data/level-2.js
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
/* ISLE SLINGERS -- levels/data/level-2.js (submodule: level-2)
|
||||||
|
* RC-375D84 S-2: level 2 definition, split from the original js/levels.js.
|
||||||
|
* Installer: returns the level object built from the shared helpers scope L.
|
||||||
|
*/
|
||||||
|
(function (root, factory) {
|
||||||
|
var part = factory();
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = part;
|
||||||
|
else (root.ISLE_LEVEL_PARTS = root.ISLE_LEVEL_PARTS || []).push(part);
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
'use strict';
|
||||||
|
return function (L) {
|
||||||
|
return {
|
||||||
|
id: 2,
|
||||||
|
name: 'Crate Caves',
|
||||||
|
birds: ['wren', 'zippy', 'wren'],
|
||||||
|
sling: L.SLING,
|
||||||
|
stars: [15000, 28000, 42000],
|
||||||
|
decor: { palms: [1000, 2200], flowers: [800, 1350, 2000] },
|
||||||
|
blocks: [
|
||||||
|
// left tower: stone base + wood post
|
||||||
|
L.stone(1420, 865, 70, 70),
|
||||||
|
L.wood(1420, 760, 30, 140),
|
||||||
|
// right tower: ice base + ice post
|
||||||
|
L.ice(1660, 860, 80, 80),
|
||||||
|
L.ice(1660, 765, 30, 110),
|
||||||
|
// side crates
|
||||||
|
L.wood(1300, 860, 80, 80),
|
||||||
|
L.wood(1790, 860, 80, 80)
|
||||||
|
],
|
||||||
|
enemies: [
|
||||||
|
L.mogg(1420, 666),
|
||||||
|
L.mogg(1660, 686),
|
||||||
|
L.mogg(1540, 876)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
40
js/levels/data/level-3.js
Normal file
40
js/levels/data/level-3.js
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
/* ISLE SLINGERS -- levels/data/level-3.js (submodule: level-3)
|
||||||
|
* RC-375D84 S-2: level 3 definition, split from the original js/levels.js.
|
||||||
|
* Installer: returns the level object built from the shared helpers scope L.
|
||||||
|
*/
|
||||||
|
(function (root, factory) {
|
||||||
|
var part = factory();
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = part;
|
||||||
|
else (root.ISLE_LEVEL_PARTS = root.ISLE_LEVEL_PARTS || []).push(part);
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
'use strict';
|
||||||
|
return function (L) {
|
||||||
|
return {
|
||||||
|
id: 3,
|
||||||
|
name: 'Glass Works',
|
||||||
|
birds: ['wren', 'zippy', 'boulder'],
|
||||||
|
sling: L.SLING,
|
||||||
|
stars: [18000, 34000, 50000],
|
||||||
|
decor: { palms: [900, 2250], flowers: [750, 1300, 2100] },
|
||||||
|
blocks: [
|
||||||
|
// ice workshop: two-storey
|
||||||
|
L.ice(1500, 840, 30, 120),
|
||||||
|
L.ice(1640, 840, 30, 120),
|
||||||
|
L.wood(1570, 765, 200, 30),
|
||||||
|
L.ice(1520, 700, 30, 100),
|
||||||
|
L.ice(1620, 700, 30, 100),
|
||||||
|
L.ice(1570, 635, 170, 30),
|
||||||
|
// wood hut
|
||||||
|
L.wood(1850, 830, 30, 140),
|
||||||
|
L.wood(1950, 830, 30, 140),
|
||||||
|
L.wood(1900, 745, 160, 30)
|
||||||
|
],
|
||||||
|
enemies: [
|
||||||
|
L.mogg(1570, 876),
|
||||||
|
L.mogg(1570, 596),
|
||||||
|
L.grum(1900, 868),
|
||||||
|
L.mogg(1900, 706)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
43
js/levels/data/level-4.js
Normal file
43
js/levels/data/level-4.js
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
/* ISLE SLINGERS -- levels/data/level-4.js (submodule: level-4)
|
||||||
|
* RC-375D84 S-2: level 4 definition, split from the original js/levels.js.
|
||||||
|
* Installer: returns the level object built from the shared helpers scope L.
|
||||||
|
*/
|
||||||
|
(function (root, factory) {
|
||||||
|
var part = factory();
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = part;
|
||||||
|
else (root.ISLE_LEVEL_PARTS = root.ISLE_LEVEL_PARTS || []).push(part);
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
'use strict';
|
||||||
|
return function (L) {
|
||||||
|
return {
|
||||||
|
id: 4,
|
||||||
|
name: 'Stone Keep',
|
||||||
|
birds: ['wren', 'zippy', 'boulder', 'wren'],
|
||||||
|
sling: L.SLING,
|
||||||
|
stars: [22000, 40000, 58000],
|
||||||
|
decor: { palms: [1050, 2300], flowers: [850, 1400, 2150] },
|
||||||
|
blocks: [
|
||||||
|
// stone wall (three cubes)
|
||||||
|
L.stone(1600, 865, 70, 70),
|
||||||
|
L.stone(1750, 865, 70, 70),
|
||||||
|
L.stone(1900, 865, 70, 70),
|
||||||
|
L.wood(1750, 815, 400, 30),
|
||||||
|
// second floor
|
||||||
|
L.wood(1650, 740, 30, 120),
|
||||||
|
L.wood(1850, 740, 30, 120),
|
||||||
|
L.wood(1750, 665, 260, 30),
|
||||||
|
// left ice watchtower
|
||||||
|
L.ice(1450, 840, 30, 120),
|
||||||
|
// right crate
|
||||||
|
L.stone(2050, 865, 70, 70)
|
||||||
|
],
|
||||||
|
enemies: [
|
||||||
|
L.mogg(1675, 876),
|
||||||
|
L.mogg(1825, 876),
|
||||||
|
L.grum(1750, 618),
|
||||||
|
L.mogg(2050, 806),
|
||||||
|
L.mogg(1500, 876)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
46
js/levels/data/level-5.js
Normal file
46
js/levels/data/level-5.js
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
/* ISLE SLINGERS -- levels/data/level-5.js (submodule: level-5)
|
||||||
|
* RC-375D84 S-2: level 5 definition, split from the original js/levels.js.
|
||||||
|
* Installer: returns the level object built from the shared helpers scope L.
|
||||||
|
*/
|
||||||
|
(function (root, factory) {
|
||||||
|
var part = factory();
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = part;
|
||||||
|
else (root.ISLE_LEVEL_PARTS = root.ISLE_LEVEL_PARTS || []).push(part);
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
'use strict';
|
||||||
|
return function (L) {
|
||||||
|
return {
|
||||||
|
id: 5,
|
||||||
|
name: 'Mog Fortress',
|
||||||
|
birds: ['wren', 'zippy', 'boulder', 'boulder'],
|
||||||
|
sling: L.SLING,
|
||||||
|
stars: [28000, 48000, 70000],
|
||||||
|
decor: { palms: [980, 2320], flowers: [780, 1300, 2180] },
|
||||||
|
blocks: [
|
||||||
|
// left stone tower (2 stories + ice cap)
|
||||||
|
L.stone(1450, 865, 70, 70),
|
||||||
|
L.stone(1450, 760, 70, 70),
|
||||||
|
L.ice(1450, 710, 30, 30),
|
||||||
|
// center keep
|
||||||
|
L.stone(1700, 865, 70, 70),
|
||||||
|
L.stone(1850, 865, 70, 70),
|
||||||
|
L.wood(1775, 815, 260, 30),
|
||||||
|
L.wood(1700, 740, 30, 120),
|
||||||
|
L.wood(1850, 740, 30, 120),
|
||||||
|
L.wood(1775, 665, 220, 30),
|
||||||
|
L.ice(1775, 635, 260, 30),
|
||||||
|
// right post
|
||||||
|
L.stone(2050, 865, 70, 70),
|
||||||
|
L.wood(2050, 760, 30, 120)
|
||||||
|
],
|
||||||
|
enemies: [
|
||||||
|
L.king(1775, 862),
|
||||||
|
L.mogg(1450, 671),
|
||||||
|
L.mogg(1775, 626),
|
||||||
|
L.mogg(2050, 676),
|
||||||
|
L.mogg(1575, 876),
|
||||||
|
L.mogg(1975, 876)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
10
js/levels/helpers/agents.md
Normal file
10
js/levels/helpers/agents.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# js/levels/helpers/ — agents.md
|
||||||
|
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
|
||||||
|
|
||||||
|
Installs into `L`: wood, ice, stone (block factories, return center-based
|
||||||
|
records), mogg, grum, king (enemy factories) and the SLING anchor {x:260,y:745}.
|
||||||
|
|
||||||
|
- Must load before every `data/level-N.js`. Leaf submodule — consumes nothing.
|
||||||
|
- Material names here ('wood','ice','stone') and enemy kinds ('mogg','grum',
|
||||||
|
'king') are the string contract against constants.MATERIALS / ENEMY_TYPES.
|
||||||
|
Renaming a kind is a cross-module change (levels + constants + render).
|
||||||
21
js/levels/helpers/index.js
Normal file
21
js/levels/helpers/index.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
/* ISLE SLINGERS -- levels/helpers/index.js (submodule: helpers)
|
||||||
|
* RC-375D84 S-2: block/enemy constructor helpers + SLING anchor, split from
|
||||||
|
* the original js/levels.js. Installs into the shared levels scope `L`;
|
||||||
|
* js/levels/index.js loads it first, then the level-N data submodules.
|
||||||
|
*/
|
||||||
|
(function (root, factory) {
|
||||||
|
var part = factory();
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = part;
|
||||||
|
else (root.ISLE_LEVEL_PARTS = root.ISLE_LEVEL_PARTS || []).push(part);
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
'use strict';
|
||||||
|
return function (L) {
|
||||||
|
L.wood = function (x, y, w, h) { return { x: x, y: y, w: w, h: h, mat: 'wood' }; };
|
||||||
|
L.ice = function (x, y, w, h) { return { x: x, y: y, w: w, h: h, mat: 'ice' }; };
|
||||||
|
L.stone = function (x, y, w, h) { return { x: x, y: y, w: w, h: h, mat: 'stone' }; };
|
||||||
|
L.mogg = function (x, y) { return { x: x, y: y, kind: 'mogg' }; };
|
||||||
|
L.grum = function (x, y) { return { x: x, y: y, kind: 'grum' }; };
|
||||||
|
L.king = function (x, y) { return { x: x, y: y, kind: 'king' }; };
|
||||||
|
L.SLING = { x: 260, y: 745 };
|
||||||
|
};
|
||||||
|
});
|
||||||
44
js/levels/index.js
Normal file
44
js/levels/index.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
/* ISLE SLINGERS -- levels/index.js (module: levels -- aggregator / public API)
|
||||||
|
* RC-375D84 S-2 modularization. The original js/levels.js monolith is now:
|
||||||
|
* levels/helpers/ - block/enemy constructors + SLING anchor (shared scope L)
|
||||||
|
* levels/data/level-N.js - one level definition each (uses the shared scope)
|
||||||
|
* This aggregator loads the parts in order, builds the LEVELS array, and
|
||||||
|
* exposes the byte-identical public API: { LEVELS, SLING }.
|
||||||
|
*
|
||||||
|
* Load contract:
|
||||||
|
* browser - levels/helpers/index.js first, then data/level-1..5.js, then this file
|
||||||
|
* (parts arrive on globalThis.ISLE_LEVEL_PARTS in <script> order);
|
||||||
|
* node - this file requires the parts itself, in the same order.
|
||||||
|
*/
|
||||||
|
(function (root, factory) {
|
||||||
|
var api = factory();
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||||
|
else root.ISLE_LEVELS = api;
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var L = {}; // the single shared levels scope
|
||||||
|
|
||||||
|
var parts;
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
parts = [
|
||||||
|
require('./helpers/index.js'),
|
||||||
|
require('./data/level-1.js'),
|
||||||
|
require('./data/level-2.js'),
|
||||||
|
require('./data/level-3.js'),
|
||||||
|
require('./data/level-4.js'),
|
||||||
|
require('./data/level-5.js')
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
parts = (typeof globalThis !== 'undefined' && globalThis.ISLE_LEVEL_PARTS) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var LEVELS = [];
|
||||||
|
for (var i = 0; i < parts.length; i++) {
|
||||||
|
var part = parts[i](L);
|
||||||
|
if (part && part.id) LEVELS.push(part); // level data parts carry an `id`
|
||||||
|
}
|
||||||
|
if (!LEVELS.length) throw new Error('ISLE_LEVELS: no level parts loaded before the aggregator');
|
||||||
|
|
||||||
|
return { LEVELS: LEVELS, SLING: L.SLING };
|
||||||
|
});
|
||||||
12
js/physics/abilities/agents.md
Normal file
12
js/physics/abilities/agents.md
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# js/physics/abilities/ — agents.md
|
||||||
|
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
|
||||||
|
|
||||||
|
Installs into `P`: launch, triggerAbility.
|
||||||
|
|
||||||
|
- `launch(g, vx, vy)` performs the turn transition (aiming -> flying, launched
|
||||||
|
counter, active-bird advance on settle).
|
||||||
|
- `triggerAbility(g)` implements the three mid-flight moves (fury / dive /
|
||||||
|
quake) keyed off the active bird's type and `ABILITY_CD`. Each slinger's move
|
||||||
|
is pinned by the "abilities" test arm (distinct, usable, no-op when
|
||||||
|
unavailable).
|
||||||
|
- Consumes: activeBird, aimVector, MATERIALS, BIRD_TYPES.
|
||||||
65
js/physics/abilities/index.js
Normal file
65
js/physics/abilities/index.js
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
/* ISLE SLINGERS -- physics/abilities/index.js (submodule: abilities)
|
||||||
|
* RC-375D84 S-2 modularization (behavior-neutral split of the original
|
||||||
|
* js/core.js monolith). Installs its units into ONE shared physics scope `P`
|
||||||
|
* that every physics submodule sees -- the same single-scope semantics the
|
||||||
|
* monolith had. Must load before js/physics/index.js (the aggregator that
|
||||||
|
* attaches the public ISLE_CORE API). Surface installed: launch, triggerAbility.
|
||||||
|
*/
|
||||||
|
(function (root, factory) {
|
||||||
|
var part = factory();
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = part;
|
||||||
|
else (root.ISLE_PHYSICS_PARTS = root.ISLE_PHYSICS_PARTS || []).push(part);
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
'use strict';
|
||||||
|
return function (P) {
|
||||||
|
P.launch = function(g, dragPos) {
|
||||||
|
var b = P.activeBird(g);
|
||||||
|
if (!b || b.state !== 'aiming' || g.status !== 'ready') return false;
|
||||||
|
var v = P.aimVector(g, { x: g.level.sling.x, y: g.level.sling.y }, dragPos);
|
||||||
|
if (!v) return false;
|
||||||
|
b.x = g.level.sling.x; b.y = g.level.sling.y;
|
||||||
|
b.vx = v.vx; b.vy = v.vy;
|
||||||
|
b.state = 'flying';
|
||||||
|
b.launchT = g.time;
|
||||||
|
g.status = 'flying';
|
||||||
|
g.launched++;
|
||||||
|
g.events.push({ type: 'P.launch', x: b.x, y: b.y });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
P.triggerAbility = function(g) {
|
||||||
|
var b = P.activeBird(g);
|
||||||
|
if (!b || b.state !== 'flying' || b.abilityUsed) return false;
|
||||||
|
if (g.time - b.launchT < P.ABILITY_CD) return false;
|
||||||
|
var kind = b.kind;
|
||||||
|
if (kind === 'wren') {
|
||||||
|
b.vx = b.vx * 0.4 + 980;
|
||||||
|
g.events.push({ type: 'ability_fury', x: b.x, y: b.y });
|
||||||
|
} else if (kind === 'zippy') {
|
||||||
|
b.vy = Math.max(b.vy, 0) + 1050;
|
||||||
|
b.vx *= 0.55;
|
||||||
|
g.events.push({ type: 'ability_dive', x: b.x, y: b.y });
|
||||||
|
} else if (kind === 'boulder') {
|
||||||
|
var R = 150;
|
||||||
|
var i, d, f, ent;
|
||||||
|
for (i = 0; i < g.blocks.length; i++) {
|
||||||
|
var bl = g.blocks[i];
|
||||||
|
if (bl.hp <= 0) continue;
|
||||||
|
d = Math.hypot(bl.x - b.x, bl.y - b.y) - (bl.w + bl.h) / 4;
|
||||||
|
if (d < R) { f = 1 - Math.max(0, d) / R; P.damageBlock(g, bl, 120 + 300 * f, 'quake'); }
|
||||||
|
}
|
||||||
|
for (i = 0; i < g.enemies.length; i++) {
|
||||||
|
ent = g.enemies[i];
|
||||||
|
if (ent.dead) continue;
|
||||||
|
d = Math.hypot(ent.x - b.x, ent.y - b.y) - ent.r;
|
||||||
|
if (d < R) { f = 1 - Math.max(0, d) / R; P.damageEnemy(g, ent, 0.7 + 1.6 * f); }
|
||||||
|
}
|
||||||
|
g.camera.shake = Math.max(g.camera.shake, 14);
|
||||||
|
g.events.push({ type: 'ability_quake', x: b.x, y: b.y, r: R });
|
||||||
|
}
|
||||||
|
b.abilityUsed = true;
|
||||||
|
g.stats.abilityUses++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
23
js/physics/agents.md
Normal file
23
js/physics/agents.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# js/physics/ — agents.md
|
||||||
|
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
|
||||||
|
|
||||||
|
The physics **module** (aggregator). Deterministic, DOM-free.
|
||||||
|
|
||||||
|
- `index.js` is the ONLY public face: it builds the shared scope `P`, runs every
|
||||||
|
submodule installer against it, and exports `ISLE_CORE` (20-key API, see the
|
||||||
|
return literal — keep it key-for-key stable; adding a key is fine, renaming or
|
||||||
|
dropping one is not).
|
||||||
|
- Submodules must load BEFORE this file in the browser (index.html order); under
|
||||||
|
Node this file requires them in dependency order. Do not add a submodule
|
||||||
|
without registering it in BOTH the index.html script list and this file's
|
||||||
|
`require` list.
|
||||||
|
- The core is the simulation's single source of truth: `step(state, dt)` is the
|
||||||
|
only thing that mutates gameplay state. Everything else is either setup
|
||||||
|
(`createGame`), input translation (`launch`), or read-only queries
|
||||||
|
(`activeBird`, `trajectory`, `aimVector`, `starsFor`, `birdsRemaining`).
|
||||||
|
- Invariants: fixed-timestep consumer (tests run 1/120 s); no `Math.random()`
|
||||||
|
in gameplay paths (determinism is a test arm); no DOM access.
|
||||||
|
- New physics behavior lands in the owning submodule, NOT here. RC-375D84
|
||||||
|
S-3..S-5 (landed 2026-09-29): topple/ owns board tip-over (Q1 full continuous
|
||||||
|
rotation), collision/ owns velocity-aware crush + mass-weighted stacks (S-4),
|
||||||
|
step/ owns the landed-bird state and its integration (S-5).
|
||||||
14
js/physics/aim/agents.md
Normal file
14
js/physics/aim/agents.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# js/physics/aim/ — agents.md
|
||||||
|
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
|
||||||
|
|
||||||
|
Installs into `P`: aimVector, trajectory.
|
||||||
|
|
||||||
|
- `aimVector(g, sling, drag)` clamps drag to [MIN_PULL, MAX_PULL] and maps it to
|
||||||
|
a launch velocity. The pull clamp is what makes full-power shots deterministic
|
||||||
|
(the balance spec relies on it).
|
||||||
|
- `trajectory(g, x, y, vx, vy, steps)` is the preview integrator; it must track
|
||||||
|
the real flight — there is a dedicated test arm ("trajectory preview tracks
|
||||||
|
the real flight"). If you change integration in stepPhysics, change it here in
|
||||||
|
lockstep.
|
||||||
|
- Consumes: LAUNCH_POWER, GRAVITY, AIR_DRAG, BIRD_BOUNCE, WORLD,
|
||||||
|
BIRD_GROUND_FRICTION.
|
||||||
45
js/physics/aim/index.js
Normal file
45
js/physics/aim/index.js
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
/* ISLE SLINGERS -- physics/aim/index.js (submodule: aim)
|
||||||
|
* RC-375D84 S-2 modularization (behavior-neutral split of the original
|
||||||
|
* js/core.js monolith). Installs its units into ONE shared physics scope `P`
|
||||||
|
* that every physics submodule sees -- the same single-scope semantics the
|
||||||
|
* monolith had. Must load before js/physics/index.js (the aggregator that
|
||||||
|
* attaches the public ISLE_CORE API). Surface installed: aimVector, trajectory.
|
||||||
|
*/
|
||||||
|
(function (root, factory) {
|
||||||
|
var part = factory();
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = part;
|
||||||
|
else (root.ISLE_PHYSICS_PARTS = root.ISLE_PHYSICS_PARTS || []).push(part);
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
'use strict';
|
||||||
|
return function (P) {
|
||||||
|
P.aimVector = function(g, sling, drag) {
|
||||||
|
var dx = sling.x - drag.x, dy = sling.y - drag.y;
|
||||||
|
var len = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (len < P.MIN_PULL) return null;
|
||||||
|
var cl = Math.min(len, P.MAX_PULL);
|
||||||
|
var v = cl * P.LAUNCH_POWER;
|
||||||
|
return { vx: (dx / len) * v, vy: (dy / len) * v, pull: cl };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure P.trajectory preview: identical integrator + substepping as the live
|
||||||
|
// sim (2x 1/120 per output point), so the dotted preview matches the real
|
||||||
|
// flight exactly. Gravity + drag only; stops at the ground.
|
||||||
|
P.trajectory = function(g, sx, sy, vx, vy, steps) {
|
||||||
|
var pts = [];
|
||||||
|
var x = sx, y = sy, i, sub;
|
||||||
|
for (i = 0; i < steps; i++) {
|
||||||
|
for (sub = 0; sub < 2; sub++) {
|
||||||
|
var dt = 1 / 120;
|
||||||
|
var nvy = vy + P.GRAVITY * dt;
|
||||||
|
x += vx * dt;
|
||||||
|
y += (vy + nvy) * 0.5 * dt;
|
||||||
|
vx *= P.AIR_DRAG;
|
||||||
|
vy = nvy;
|
||||||
|
}
|
||||||
|
if (y > P.WORLD.ground - 18) { pts.push({ x: x, y: P.WORLD.ground - 18, last: true }); break; }
|
||||||
|
pts.push({ x: x, y: y, last: false });
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
9
js/physics/camera/agents.md
Normal file
9
js/physics/camera/agents.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# js/physics/camera/ — agents.md
|
||||||
|
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
|
||||||
|
|
||||||
|
Installs into `P`: updatePopups, updateCameraTarget, decayCamera.
|
||||||
|
|
||||||
|
- The follow-cam: tracks the active bird / action zone inside the level bounds,
|
||||||
|
eases and decays, and ages score popups. Consumes WORLD + VIEW for clamping.
|
||||||
|
- The "aim framing" test arm asserts the sling + action zone stay in view while
|
||||||
|
aiming — keep that guarantee when changing targets or damping.
|
||||||
70
js/physics/camera/index.js
Normal file
70
js/physics/camera/index.js
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
/* ISLE SLINGERS -- physics/camera/index.js (submodule: camera)
|
||||||
|
* RC-375D84 S-2 modularization (behavior-neutral split of the original
|
||||||
|
* js/core.js monolith). Installs its units into ONE shared physics scope `P`
|
||||||
|
* that every physics submodule sees -- the same single-scope semantics the
|
||||||
|
* monolith had. Must load before js/physics/index.js (the aggregator that
|
||||||
|
* attaches the public ISLE_CORE API). Surface installed: updatePopups, updateCameraTarget, decayCamera.
|
||||||
|
*/
|
||||||
|
(function (root, factory) {
|
||||||
|
var part = factory();
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = part;
|
||||||
|
else (root.ISLE_PHYSICS_PARTS = root.ISLE_PHYSICS_PARTS || []).push(part);
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
'use strict';
|
||||||
|
return function (P) {
|
||||||
|
P.updatePopups = function(g, dt) {
|
||||||
|
for (var i = g.scorePopups.length - 1; i >= 0; i--) {
|
||||||
|
g.scorePopups[i].age += dt;
|
||||||
|
g.scorePopups[i].y -= 40 * dt;
|
||||||
|
if (g.scorePopups[i].age > 1.4) g.scorePopups.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
P.updateCameraTarget = function(g) {
|
||||||
|
var b = P.activeBird(g);
|
||||||
|
var cam = g.camera;
|
||||||
|
var tx, ty, tz;
|
||||||
|
if (b && b.state === 'flying') {
|
||||||
|
// in flight: follow the slinger 1:1 (the bird IS the point of interest)
|
||||||
|
tx = Math.max(420, Math.min(P.WORLD.w - 420, b.x + 220));
|
||||||
|
ty = Math.max(380, Math.min(P.WORLD.ground - 160, b.y + 60));
|
||||||
|
tz = 1;
|
||||||
|
} else {
|
||||||
|
// aiming / waiting: frame the slingshot AND the whole action zone
|
||||||
|
// (live blocks + enemies) so P.launch point and target are always
|
||||||
|
// visible at once — zooming out when the level needs it. Without
|
||||||
|
// this the sling was visible but the fort was off-screen (or
|
||||||
|
// vice-versa mid-flight), making aiming pure guesswork.
|
||||||
|
var left = g.level.sling.x - 190;
|
||||||
|
var right = left + 300;
|
||||||
|
for (var i = 0; i < g.blocks.length; i++) {
|
||||||
|
var bl = g.blocks[i];
|
||||||
|
if (bl.hp > 0) right = Math.max(right, bl.x + bl.w / 2 + 90);
|
||||||
|
}
|
||||||
|
for (var k = 0; k < g.enemies.length; k++) {
|
||||||
|
var en = g.enemies[k];
|
||||||
|
if (!en.dead) right = Math.max(right, en.x + en.r + 90);
|
||||||
|
}
|
||||||
|
var span = right - left;
|
||||||
|
tz = Math.min(1, P.VIEW.w / span);
|
||||||
|
tx = left + span / 2;
|
||||||
|
// ground at ~78% down the view, whatever the zoom
|
||||||
|
ty = Math.max(300, Math.min(P.WORLD.ground, P.WORLD.ground - (0.28 * P.VIEW.h) / tz));
|
||||||
|
}
|
||||||
|
if (cam.snap) { // first frame of a level: land on target, no pan
|
||||||
|
cam.x = tx; cam.y = ty; cam.zoom = tz; cam.snap = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cam.x += (tx - cam.x) * 0.06;
|
||||||
|
cam.y += (ty - cam.y) * 0.06;
|
||||||
|
cam.zoom = (cam.zoom || 1) + (tz - (cam.zoom || 1)) * 0.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
P.decayCamera = function(g, dt) {
|
||||||
|
if (g.camera.shake > 0) {
|
||||||
|
g.camera.shake *= Math.pow(0.03, dt); // fast decay
|
||||||
|
if (g.camera.shake < 0.3) g.camera.shake = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
18
js/physics/collision/agents.md
Normal file
18
js/physics/collision/agents.md
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# js/physics/collision/ — agents.md
|
||||||
|
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
|
||||||
|
|
||||||
|
Installs into `P`: crushCircleVsBox, collideBirdWorld, collideBirdBlocks,
|
||||||
|
collideBirdEnemies, REST_VEL_CLAMP, resolveBoxPair.
|
||||||
|
|
||||||
|
- Contact resolution for circles (birds, enemies) vs axis-aligned blocks and
|
||||||
|
block-block pairs. `resolveBoxPair` is the box-box rest/impulse resolver;
|
||||||
|
`crushCircleVsBox` is the fast-falling-onto-structure damage path.
|
||||||
|
- RC-375D84 S-4 (landed 2026-09-29): `crushCircleVsBox` is velocity-direction-
|
||||||
|
aware (the box must be the body plowing into the enemy, `boxAlong > 30`; a
|
||||||
|
resting box never crushes), carries a per-enemy crush cooldown (0.12 s — one
|
||||||
|
landing = one crush instance) and an inelastic velocity transfer so boxes do
|
||||||
|
not phase through enemies; `resolveBoxPair` stacked contact is mass-weighted
|
||||||
|
inelastic (heavy blocks drive light planks — chain-crush). S-3 (topple) does
|
||||||
|
NOT live here: pivoting blocks are excluded from box-pair resolution by the
|
||||||
|
step loop and integrate in `topple/` — keep it that way.
|
||||||
|
- Consumes: MATERIALS, WORLD, damageBlock, damageEnemy.
|
||||||
212
js/physics/collision/index.js
Normal file
212
js/physics/collision/index.js
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
/* ISLE SLINGERS -- physics/collision/index.js (submodule: collision)
|
||||||
|
* RC-375D84 S-2 modularization (behavior-neutral split of the original
|
||||||
|
* js/core.js monolith). Installs its units into ONE shared physics scope `P`
|
||||||
|
* that every physics submodule sees -- the same single-scope semantics the
|
||||||
|
* monolith had. Must load before js/physics/index.js (the aggregator that
|
||||||
|
* attaches the public ISLE_CORE API). Surface installed: crushCircleVsBox, collideBirdWorld, collideBirdBlocks, collideBirdEnemies, REST_VEL_CLAMP, resolveBoxPair.
|
||||||
|
*/
|
||||||
|
(function (root, factory) {
|
||||||
|
var part = factory();
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = part;
|
||||||
|
else (root.ISLE_PHYSICS_PARTS = root.ISLE_PHYSICS_PARTS || []).push(part);
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
'use strict';
|
||||||
|
return function (P) {
|
||||||
|
P.crushCircleVsBox = function(g, box, ent, isEnemy) {
|
||||||
|
var cx = Math.max(box.x - box.w / 2, Math.min(ent.x, box.x + box.w / 2));
|
||||||
|
var cy = Math.max(box.y - box.h / 2, Math.min(ent.y, box.y + box.h / 2));
|
||||||
|
var dx = ent.x - cx, dy = ent.y - cy;
|
||||||
|
var d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 >= ent.r * ent.r) return;
|
||||||
|
var d = Math.sqrt(d2) || 0.001;
|
||||||
|
var nx = dx / d, ny = dy / d;
|
||||||
|
var overlap = ent.r - d;
|
||||||
|
ent.x += nx * overlap * 0.9;
|
||||||
|
ent.y += ny * overlap * 0.9;
|
||||||
|
// impulse: relative velocity along normal
|
||||||
|
var rvx = (ent.vx || 0) - (box.vx || 0), rvy = (ent.vy || 0) - (box.vy || 0);
|
||||||
|
var rel = rvx * nx + rvy * ny;
|
||||||
|
if (rel < -30) {
|
||||||
|
var relSpeed = -rel;
|
||||||
|
ent.vx -= nx * rel * 1.4;
|
||||||
|
ent.vy -= ny * rel * 1.4;
|
||||||
|
if (isEnemy) {
|
||||||
|
// crush damage: a fast box hurts what it lands on.
|
||||||
|
// S-4 (RC-375D84): the direction gate is velocity-aware -- the BOX
|
||||||
|
// must be the body moving into the enemy (boxAlong > 30). The old gate
|
||||||
|
// (ny < -0.3, enemy above the box) only let SIDE plows crush: a block
|
||||||
|
// dropped STRAIGHT DOWN onto an enemy never registered ("falling
|
||||||
|
// objects can't kill"). Vertical drops and side plows share this path.
|
||||||
|
// Two sub-fixes, both watched in test/fallingkill.test.mjs:
|
||||||
|
// (a) per-enemy crush cooldown (0.12s, same idea as the bird-hit
|
||||||
|
// gate): a box resting on an enemy must not machine-gun damage
|
||||||
|
// every frame; one landing = one crush instance.
|
||||||
|
// (b) inelastic transfer: the box sheds its approach velocity into
|
||||||
|
// the enemy (mass-scaled), so a block does not phase through
|
||||||
|
// the enemy at terminal velocity after the hit.
|
||||||
|
var spec = P.ENEMY_TYPES[ent.kind];
|
||||||
|
var boxSpeed = Math.hypot(box.vx || 0, box.vy || 0);
|
||||||
|
var boxAlong = (box.vx || 0) * nx + (box.vy || 0) * ny; // box velocity toward the enemy
|
||||||
|
if (boxSpeed > spec.crushVel && boxAlong > 30) {
|
||||||
|
if (g.time - (ent.lastCrushT || -9) > 0.12) {
|
||||||
|
P.damageEnemy(g, ent, (boxSpeed - spec.crushVel) * 0.004 * spec.crushDmg + 0.35);
|
||||||
|
g.events.push({ type: 'crush', x: ent.x, y: ent.y });
|
||||||
|
ent.lastCrushT = g.time;
|
||||||
|
// inelastic: box loses a mass-scaled share of its normal velocity
|
||||||
|
var entM = ent.r / 24; // mogg~1, grum~1.3, king~1.6
|
||||||
|
var share = entM / ((box.mass || 1) + entM);
|
||||||
|
box.vx -= nx * boxAlong * share;
|
||||||
|
box.vy -= ny * boxAlong * share;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// bird: transfer momentum into the box
|
||||||
|
var dmg = relSpeed * 0.32 * (ent.mass || 3) * 0.25;
|
||||||
|
if (dmg > 25) P.damageBlock(g, box, dmg, 'bird');
|
||||||
|
box.vx += nx * rel * 0.05 * (ent.mass || 3);
|
||||||
|
box.vy += ny * rel * 0.05 * (ent.mass || 3);
|
||||||
|
}
|
||||||
|
} else if (rel < 0) {
|
||||||
|
// slow resting contact: remove the normal approach velocity so a
|
||||||
|
// perched enemy/box pair stops oscillating under gravity
|
||||||
|
ent.vx -= nx * rel;
|
||||||
|
ent.vy -= ny * rel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
P.collideBirdWorld = function(g, b) {
|
||||||
|
if (b.y + b.r > P.WORLD.ground) {
|
||||||
|
var impact = -b.vy;
|
||||||
|
b.y = P.WORLD.ground - b.r;
|
||||||
|
if (b.vy > 0) b.vy = -b.vy * P.BIRD_BOUNCE;
|
||||||
|
b.vx *= P.BIRD_GROUND_FRICTION;
|
||||||
|
if (impact > 250) {
|
||||||
|
g.events.push({ type: 'bird_ground', x: b.x, y: P.WORLD.ground, strength: impact });
|
||||||
|
g.camera.shake = Math.max(g.camera.shake, 5);
|
||||||
|
}
|
||||||
|
if (Math.abs(b.vy) < 30) b.vy = 0;
|
||||||
|
}
|
||||||
|
if (b.x - b.r < 0) { b.x = b.r; b.vx = Math.abs(b.vx) * 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
P.collideBirdBlocks = function(g, b) {
|
||||||
|
var i;
|
||||||
|
for (i = 0; i < g.blocks.length; i++) {
|
||||||
|
var bl = g.blocks[i];
|
||||||
|
if (bl.hp <= 0) continue;
|
||||||
|
var cx = Math.max(bl.x - bl.w / 2, Math.min(b.x, bl.x + bl.w / 2));
|
||||||
|
var cy = Math.max(bl.y - bl.h / 2, Math.min(b.y, bl.y + bl.h / 2));
|
||||||
|
var dx = b.x - cx, dy = b.y - cy;
|
||||||
|
var d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 >= b.r * b.r) continue;
|
||||||
|
var d = Math.sqrt(d2) || 0.001;
|
||||||
|
var nx = dx / d, ny = dy / d;
|
||||||
|
var overlap = b.r - d;
|
||||||
|
b.x += nx * overlap;
|
||||||
|
b.y += ny * overlap;
|
||||||
|
var rvx = b.vx - bl.vx, rvy = b.vy - bl.vy;
|
||||||
|
var rel = rvx * nx + rvy * ny;
|
||||||
|
if (rel < 0) {
|
||||||
|
var relSpeed = -rel;
|
||||||
|
// impulse: j = -(1+e)*rel / (1/m1 + 1/m2)
|
||||||
|
var invM = 1 / b.mass + 1 / bl.mass;
|
||||||
|
var j = -(1 + P.BIRD_BOUNCE) * rel / invM;
|
||||||
|
b.vx += (j / b.mass) * nx;
|
||||||
|
b.vy += (j / b.mass) * ny;
|
||||||
|
bl.vx -= (j / bl.mass) * nx;
|
||||||
|
bl.vy -= (j / bl.mass) * ny;
|
||||||
|
g.stats.impacts++;
|
||||||
|
var dmg = relSpeed * 0.55 * (b.mass / 3) * P.MATERIALS[bl.mat].dmgFactor;
|
||||||
|
if (dmg > 30) {
|
||||||
|
P.damageBlock(g, bl, dmg, 'bird');
|
||||||
|
g.events.push({ type: 'hit', x: cx, y: cy, mat: bl.mat, strength: relSpeed });
|
||||||
|
g.camera.shake = Math.max(g.camera.shake, Math.min(12, relSpeed / 90));
|
||||||
|
}
|
||||||
|
b.vx *= 0.985; b.vy *= 0.985;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
P.collideBirdEnemies = function(g, b) {
|
||||||
|
var i;
|
||||||
|
for (i = 0; i < g.enemies.length; i++) {
|
||||||
|
var e = g.enemies[i];
|
||||||
|
if (e.dead) continue;
|
||||||
|
var dx = e.x - b.x, dy = e.y - b.y;
|
||||||
|
var rr = e.r + b.r;
|
||||||
|
var d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 >= rr * rr) continue;
|
||||||
|
var d = Math.sqrt(d2) || 0.001;
|
||||||
|
var nx = dx / d, ny = dy / d;
|
||||||
|
var rel = (b.vx - e.vx) * nx + (b.vy - e.vy) * ny;
|
||||||
|
if (rel > 0) {
|
||||||
|
e.vx += nx * rel * 0.8;
|
||||||
|
e.vy += ny * rel * 0.8 - 120;
|
||||||
|
// per-target hit cooldown so one pass-through = one damage instance
|
||||||
|
if (g.time - (e.lastHit || -9) > 0.12) {
|
||||||
|
var speed = Math.hypot(b.vx, b.vy);
|
||||||
|
// retuned 2026-09-28 (RC-0D193E, Commander bug #3): the old curve
|
||||||
|
// gave ~0.9 dmg to a soft wren lob — a mogg (hp1) could not be
|
||||||
|
// killed without a perfect full-power hit. Bumped so a decent
|
||||||
|
// medium shot connects; the king (hp3) still survives one
|
||||||
|
// full-power wren hit (pinned in test/sim.test.mjs).
|
||||||
|
var dmg = 0.35 + speed / 800 * (b.mass / 3);
|
||||||
|
P.damageEnemy(g, e, dmg);
|
||||||
|
e.lastHit = g.time;
|
||||||
|
g.events.push({ type: 'bird_hit_enemy', x: e.x, y: e.y, strength: speed });
|
||||||
|
}
|
||||||
|
b.vx *= 0.92; b.vy *= 0.92;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resting-contact clamp: inelastic contacts + zeroing below this speed,
|
||||||
|
// so stacked blocks settle to exactly still instead of oscillating under
|
||||||
|
// gravity re-injection.
|
||||||
|
P.REST_VEL_CLAMP = 30; // |v| below this on a resting contact is zeroed
|
||||||
|
|
||||||
|
P.resolveBoxPair = function(g, a, c) {
|
||||||
|
var ox = (a.w / 2 + c.w / 2) - Math.abs(a.x - c.x);
|
||||||
|
var oy = (a.h / 2 + c.h / 2) - Math.abs(a.y - c.y);
|
||||||
|
if (ox <= 0 || oy <= 0) return;
|
||||||
|
if (ox < oy) {
|
||||||
|
// side contact: push apart along x
|
||||||
|
var n = c.x >= a.x ? 1 : -1; // direction from a toward c
|
||||||
|
a.x -= n * ox * 0.5; c.x += n * ox * 0.5;
|
||||||
|
var relv = (c.vx - a.vx) * n;
|
||||||
|
if (relv < 0) { // approaching
|
||||||
|
var avg = (a.vx + c.vx) / 2;
|
||||||
|
a.vx = avg; c.vx = avg;
|
||||||
|
}
|
||||||
|
a.vx *= 0.9; c.vx *= 0.9;
|
||||||
|
} else {
|
||||||
|
// stacked contact: push apart along y, then kill resting velocity
|
||||||
|
var n2 = c.y >= a.y ? 1 : -1;
|
||||||
|
a.y -= n2 * oy * 0.5; c.y += n2 * oy * 0.5;
|
||||||
|
var relv2 = (c.vy - a.vy) * n2;
|
||||||
|
if (relv2 < -P.REST_VEL_CLAMP) {
|
||||||
|
// real impact: inelastic common velocity.
|
||||||
|
// S-4 (RC-375D84): mass-WEIGHTED (was a plain (a.vy+c.vy)/2), so a
|
||||||
|
// heavy block landing on a light plank transfers its real momentum
|
||||||
|
// through the stack (chain-crush). A 22.6-mass stone at v over a
|
||||||
|
// 7.2-mass plank now drives the plank to ~0.76v instead of 0.5v.
|
||||||
|
var mA2 = a.mass || 1, mC2 = c.mass || 1;
|
||||||
|
var avgY = (a.vy * mA2 + c.vy * mC2) / (mA2 + mC2);
|
||||||
|
a.vy = avgY; c.vy = avgY;
|
||||||
|
} else if (relv2 < 0) {
|
||||||
|
// resting: top takes the bottom's velocity (kills the relative
|
||||||
|
// velocity), then zero both below the clamp so stacks stop
|
||||||
|
// oscillating under gravity re-injection
|
||||||
|
var top = n2 === 1 ? a : c;
|
||||||
|
var bot = n2 === 1 ? c : a;
|
||||||
|
top.vy = bot.vy;
|
||||||
|
if (Math.abs(top.vy) < P.REST_VEL_CLAMP) { top.vy = 0; bot.vy = 0; }
|
||||||
|
} else {
|
||||||
|
// separating / at rest: clamp near-zero velocities
|
||||||
|
if (Math.abs(a.vy) < 2) a.vy = 0;
|
||||||
|
if (Math.abs(c.vy) < 2) c.vy = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
15
js/physics/constants/agents.md
Normal file
15
js/physics/constants/agents.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# js/physics/constants/ — agents.md
|
||||||
|
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
|
||||||
|
|
||||||
|
Installs into `P`: WORLD, VIEW, MATERIALS, BIRD_TYPES, ENEMY_TYPES, GRAVITY,
|
||||||
|
LAUNCH_POWER, MAX_PULL, MIN_PULL, AIR_DRAG, BIRD_BOUNCE, BIRD_GROUND_FRICTION,
|
||||||
|
REST_SPEED, SETTLE_TIME, BIRD_MAX_AGE, ABILITY_CD, BIRD_BONUS.
|
||||||
|
|
||||||
|
- Leaf submodule: consumes nothing from `P` — it may be loaded first (and in the
|
||||||
|
browser it is).
|
||||||
|
- MATERIALS / BIRD_TYPES / ENEMY_TYPES are the **balance tables**: hp, dmgFactor,
|
||||||
|
density, speed, bonus. The balance spec (`test/balance.test.mjs`) pins the
|
||||||
|
per-material break budget and enemy kill budget — change a table value only
|
||||||
|
with a matching spec update and a watched RED->GREEN.
|
||||||
|
- WORLD.w / WORLD.ground (2400 x 900 surface) and VIEW (1280x720 design
|
||||||
|
resolution) are the spatial contract; render and camera assume them.
|
||||||
54
js/physics/constants/index.js
Normal file
54
js/physics/constants/index.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
/* ISLE SLINGERS -- physics/constants/index.js (submodule: constants)
|
||||||
|
* RC-375D84 S-2 modularization (behavior-neutral split of the original
|
||||||
|
* js/core.js monolith). Installs its units into ONE shared physics scope `P`
|
||||||
|
* that every physics submodule sees -- the same single-scope semantics the
|
||||||
|
* monolith had. Must load before js/physics/index.js (the aggregator that
|
||||||
|
* attaches the public ISLE_CORE API). Surface installed: WORLD, VIEW, MATERIALS, BIRD_TYPES, ENEMY_TYPES, GRAVITY, LAUNCH_POWER, MAX_PULL, MIN_PULL, AIR_DRAG, BIRD_BOUNCE, BIRD_GROUND_FRICTION, REST_SPEED, SETTLE_TIME, BIRD_MAX_AGE, ABILITY_CD, BIRD_BONUS.
|
||||||
|
*/
|
||||||
|
(function (root, factory) {
|
||||||
|
var part = factory();
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = part;
|
||||||
|
else (root.ISLE_PHYSICS_PARTS = root.ISLE_PHYSICS_PARTS || []).push(part);
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
'use strict';
|
||||||
|
return function (P) {
|
||||||
|
P.WORLD = { w: 2400, ground: 900 };
|
||||||
|
P.VIEW = { w: 1280, h: 720 }; // base viewport (renderer renders at this size,
|
||||||
|
// then CSS-scales; camera math lives in the core)
|
||||||
|
|
||||||
|
P.MATERIALS = {
|
||||||
|
wood: { hp: 1300, density: 1.0, restitution: 0.18, dmgFactor: 0.9, score: 500, maxVel: 520 },
|
||||||
|
ice: { hp: 480, density: 0.7, restitution: 0.05, dmgFactor: 1.6, score: 800, maxVel: 560 },
|
||||||
|
// stone: retuned 2026-09-28 (RC-0D193E, Commander bug #3 "blocks and pig
|
||||||
|
// bad guys too hard to kill"): at hp 3400 with dmgFactor 0.35 a 70x70
|
||||||
|
// cube took ~11 full-power direct hits — stone walls were functionally
|
||||||
|
// unbreakable, so every enemy behind one was unkillable. Now ~2-3 hits.
|
||||||
|
stone: { hp: 1400, density: 2.2, restitution: 0.04, dmgFactor: 0.8, score: 300, maxVel: 480 }
|
||||||
|
};
|
||||||
|
|
||||||
|
P.BIRD_TYPES = {
|
||||||
|
wren: { r: 20, mass: 3.0, label: 'Wren', ability: 'fury' }, // tap: horizontal burst
|
||||||
|
zippy: { r: 17, mass: 2.2, label: 'Zippy', ability: 'dive' }, // tap: steep dive
|
||||||
|
boulder: { r: 26, mass: 6.5, label: 'Boulder', ability: 'quake' } // tap: radial shock
|
||||||
|
};
|
||||||
|
|
||||||
|
P.ENEMY_TYPES = {
|
||||||
|
mogg: { hp: 1, r: 24, score: 5000, crushVel: 330, crushDmg: 1.2 },
|
||||||
|
grum: { hp: 2, r: 32, score: 8000, crushVel: 380, crushDmg: 1.0 },
|
||||||
|
king: { hp: 3, r: 38, score: 15000, crushVel: 430, crushDmg: 0.85 }
|
||||||
|
};
|
||||||
|
|
||||||
|
P.GRAVITY = 1500;
|
||||||
|
P.LAUNCH_POWER = 11.5; // velocity per px of pull
|
||||||
|
P.MAX_PULL = 150;
|
||||||
|
P.MIN_PULL = 14;
|
||||||
|
P.AIR_DRAG = 0.999;
|
||||||
|
P.BIRD_BOUNCE = 0.34;
|
||||||
|
P.BIRD_GROUND_FRICTION = 0.86;
|
||||||
|
P.REST_SPEED = 14; // below this a bird counts as settling
|
||||||
|
P.SETTLE_TIME = 0.9; // seconds under P.REST_SPEED before bird is spent
|
||||||
|
P.BIRD_MAX_AGE = 12;
|
||||||
|
P.ABILITY_CD = 0.35; // min seconds after P.launch before ability usable
|
||||||
|
P.BIRD_BONUS = 10000;
|
||||||
|
};
|
||||||
|
});
|
||||||
14
js/physics/damage/agents.md
Normal file
14
js/physics/damage/agents.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# js/physics/damage/ — agents.md
|
||||||
|
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
|
||||||
|
|
||||||
|
Installs into `P`: damageBlock, damageEnemy.
|
||||||
|
|
||||||
|
- Central damage accounting: material break points (with the wood/ice/stone hp
|
||||||
|
ladder from constants.MATERIALS) and enemy hp floors. Score and stats counters
|
||||||
|
(`stats.destroyedBlocks`, `stats.destroyedEnemies`, scorePopups) are minted
|
||||||
|
here.
|
||||||
|
- The balance spec (`test/balance.test.mjs`) asserts per-material break budgets
|
||||||
|
and enemy kill budgets under full-power direct hits — that spec is the
|
||||||
|
contract for everything that deals damage. S-4 (RC-375D84, falling-object
|
||||||
|
impact damage) routes through these two functions so it inherits the spec.
|
||||||
|
- Consumes: MATERIALS, ENEMY_TYPES.
|
||||||
45
js/physics/damage/index.js
Normal file
45
js/physics/damage/index.js
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
/* ISLE SLINGERS -- physics/damage/index.js (submodule: damage)
|
||||||
|
* RC-375D84 S-2 modularization (behavior-neutral split of the original
|
||||||
|
* js/core.js monolith). Installs its units into ONE shared physics scope `P`
|
||||||
|
* that every physics submodule sees -- the same single-scope semantics the
|
||||||
|
* monolith had. Must load before js/physics/index.js (the aggregator that
|
||||||
|
* attaches the public ISLE_CORE API). Surface installed: damageBlock, damageEnemy.
|
||||||
|
*/
|
||||||
|
(function (root, factory) {
|
||||||
|
var part = factory();
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = part;
|
||||||
|
else (root.ISLE_PHYSICS_PARTS = root.ISLE_PHYSICS_PARTS || []).push(part);
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
'use strict';
|
||||||
|
return function (P) {
|
||||||
|
P.damageBlock = function(g, bl, dmg, src) {
|
||||||
|
if (bl.hp <= 0) return;
|
||||||
|
bl.toppled = false; // a real hit can dislodge a settled board to tip again
|
||||||
|
bl.hp -= dmg;
|
||||||
|
if (bl.hp <= 0) {
|
||||||
|
bl.hp = 0;
|
||||||
|
g.score += P.MATERIALS[bl.mat].score;
|
||||||
|
g.stats.destroyedBlocks++;
|
||||||
|
g.scorePopups.push({ x: bl.x, y: bl.y, text: '+' + P.MATERIALS[bl.mat].score, age: 0 });
|
||||||
|
g.events.push({ type: 'break', x: bl.x, y: bl.y, mat: bl.mat, big: bl.w * bl.h > 12000 });
|
||||||
|
g.camera.shake = Math.max(g.camera.shake, 4 + bl.w * bl.h / 4000);
|
||||||
|
} else if (dmg > 40) {
|
||||||
|
g.events.push({ type: 'crack', x: bl.x, y: bl.y, mat: bl.mat });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
P.damageEnemy = function(g, e, dmg) {
|
||||||
|
if (e.dead) return;
|
||||||
|
e.hp -= dmg;
|
||||||
|
if (e.hp <= 0) {
|
||||||
|
e.dead = true;
|
||||||
|
e.deathAge = 0;
|
||||||
|
g.score += P.ENEMY_TYPES[e.kind].score;
|
||||||
|
g.stats.destroyedEnemies++;
|
||||||
|
g.scorePopups.push({ x: e.x, y: e.y - 30, text: '+' + P.ENEMY_TYPES[e.kind].score, age: 0 });
|
||||||
|
g.events.push({ type: 'pop', x: e.x, y: e.y, kind: e.kind });
|
||||||
|
g.camera.shake = Math.max(g.camera.shake, 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
72
js/physics/index.js
Normal file
72
js/physics/index.js
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
/* ISLE SLINGERS -- physics/index.js (module: physics -- aggregator / public API)
|
||||||
|
* RC-375D84 S-2 modularization. This is the THIN AGGREGATOR for the physics
|
||||||
|
* module. The original js/core.js monolith is now split into submodules
|
||||||
|
* (physics/constants, -/state, -/aim, -/abilities, -/damage, -/step,
|
||||||
|
* -/collision, -/rules, -/camera); each installs its units into ONE shared
|
||||||
|
* scope `P`, preserving the monolith's single-closure semantics exactly.
|
||||||
|
*
|
||||||
|
* Load contract:
|
||||||
|
* browser -- every physics/<unit>/index.js <script> must load BEFORE this
|
||||||
|
* file (it reads the ordered parts off globalThis.ISLE_PHYSICS_PARTS);
|
||||||
|
* node -- this file requires the submodules in dependency order itself.
|
||||||
|
*
|
||||||
|
* Public API (ISLE_CORE) is key-for-key identical to the pre-modularization
|
||||||
|
* core.js return object -- same 20 keys, same values. No DOM, deterministic.
|
||||||
|
*/
|
||||||
|
(function (root, factory) {
|
||||||
|
var api = factory();
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||||
|
else root.ISLE_CORE = api;
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var P = {}; // the single shared physics scope (== the old monolith closure)
|
||||||
|
|
||||||
|
var parts;
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
// Node: load submodules in dependency order (constants first, camera last)
|
||||||
|
parts = [
|
||||||
|
require('./constants/index.js'),
|
||||||
|
require('./state/index.js'),
|
||||||
|
require('./aim/index.js'),
|
||||||
|
require('./abilities/index.js'),
|
||||||
|
require('./damage/index.js'),
|
||||||
|
require('./step/index.js'),
|
||||||
|
require('./collision/index.js'),
|
||||||
|
require('./topple/index.js'),
|
||||||
|
require('./rules/index.js'),
|
||||||
|
require('./camera/index.js')
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// browser: submodules already pushed their installers, in <script> order
|
||||||
|
parts = (typeof globalThis !== 'undefined' && globalThis.ISLE_PHYSICS_PARTS) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < parts.length; i++) parts[i](P);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ API
|
||||||
|
// (kept key-for-key identical to the pre-S-2 core.js public surface)
|
||||||
|
|
||||||
|
return {
|
||||||
|
WORLD: P.WORLD,
|
||||||
|
VIEW: P.VIEW,
|
||||||
|
MATERIALS: P.MATERIALS,
|
||||||
|
BIRD_TYPES: P.BIRD_TYPES,
|
||||||
|
ENEMY_TYPES: P.ENEMY_TYPES,
|
||||||
|
GRAVITY: P.GRAVITY,
|
||||||
|
MAX_PULL: P.MAX_PULL,
|
||||||
|
MIN_PULL: P.MIN_PULL,
|
||||||
|
LAUNCH_POWER: P.LAUNCH_POWER,
|
||||||
|
BIRD_BONUS: P.BIRD_BONUS,
|
||||||
|
createGame: P.createGame,
|
||||||
|
activeBird: P.activeBird,
|
||||||
|
aimVector: P.aimVector,
|
||||||
|
trajectory: P.trajectory,
|
||||||
|
launch: P.launch,
|
||||||
|
triggerAbility: P.triggerAbility,
|
||||||
|
step: P.step,
|
||||||
|
starsFor: P.starsFor,
|
||||||
|
birdsRemaining: P.birdsRemaining,
|
||||||
|
AIR_DRAG: P.AIR_DRAG
|
||||||
|
};
|
||||||
|
});
|
||||||
11
js/physics/rules/agents.md
Normal file
11
js/physics/rules/agents.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# js/physics/rules/ — agents.md
|
||||||
|
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
|
||||||
|
|
||||||
|
Installs into `P`: checkEndStates, birdsRemaining, starsFor.
|
||||||
|
|
||||||
|
- Win/lose arbitration (called from step): all enemies dead -> won, no bird
|
||||||
|
left in play with enemies alive -> lost. S-5 (RC-375D84, landed 2026-09-29):
|
||||||
|
a LANDED bird counts as a bird still in play — do not declare `lost` while
|
||||||
|
one is out in the world (it can still fall, smash and kill). `starsFor(g)`
|
||||||
|
maps score to the level's three star thresholds.
|
||||||
|
- Keep this submodule free of physics: it decides, it does not move anything.
|
||||||
59
js/physics/rules/index.js
Normal file
59
js/physics/rules/index.js
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
/* ISLE SLINGERS -- physics/rules/index.js (submodule: rules)
|
||||||
|
* RC-375D84 S-2 modularization (behavior-neutral split of the original
|
||||||
|
* js/core.js monolith). Installs its units into ONE shared physics scope `P`
|
||||||
|
* that every physics submodule sees -- the same single-scope semantics the
|
||||||
|
* monolith had. Must load before js/physics/index.js (the aggregator that
|
||||||
|
* attaches the public ISLE_CORE API). Surface installed: checkEndStates, birdsRemaining, starsFor.
|
||||||
|
*/
|
||||||
|
(function (root, factory) {
|
||||||
|
var part = factory();
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = part;
|
||||||
|
else (root.ISLE_PHYSICS_PARTS = root.ISLE_PHYSICS_PARTS || []).push(part);
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
'use strict';
|
||||||
|
return function (P) {
|
||||||
|
P.checkEndStates = function(g) {
|
||||||
|
if (g.status === 'won' || g.status === 'lost') return;
|
||||||
|
var alive = 0;
|
||||||
|
for (var i = 0; i < g.enemies.length; i++) if (!g.enemies[i].dead) alive++;
|
||||||
|
if (g.hadEnemies && alive === 0 && g.launched > 0) {
|
||||||
|
g.status = 'won';
|
||||||
|
g.wonAt = g.time;
|
||||||
|
g.score += P.BIRD_BONUS * P.birdsRemaining(g);
|
||||||
|
g.events.push({ type: 'level_won', x: g.camera.x, y: 400 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var b = P.activeBird(g);
|
||||||
|
var inFlight = false;
|
||||||
|
// S-5: a LANDED bird is still a live body (it can fall, smash, and kill
|
||||||
|
// while it exists) -- the level must not be declared lost while one is out
|
||||||
|
// in the world.
|
||||||
|
var landedOut = false;
|
||||||
|
for (var k = 0; k < g.birds.length; k++) {
|
||||||
|
if (g.birds[k].state === 'flying') inFlight = true;
|
||||||
|
if (g.birds[k].state === 'landed') landedOut = true;
|
||||||
|
}
|
||||||
|
if (!inFlight && !landedOut && !b && g.status === 'flying') {
|
||||||
|
// last bird spent, enemies remain
|
||||||
|
g.status = 'lost';
|
||||||
|
g.lostAt = g.time;
|
||||||
|
g.events.push({ type: 'level_lost', x: g.camera.x, y: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
P.birdsRemaining = function(g) {
|
||||||
|
var n = 0;
|
||||||
|
for (var i = 0; i < g.birds.length; i++) if (g.birds[i].state === 'queued') n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
P.starsFor = function(g) {
|
||||||
|
var th = g.level.stars;
|
||||||
|
if (!th) return 0;
|
||||||
|
if (g.score >= th[2]) return 3;
|
||||||
|
if (g.score >= th[1]) return 2;
|
||||||
|
if (g.score >= th[0] || g.status === 'won') return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
18
js/physics/state/agents.md
Normal file
18
js/physics/state/agents.md
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# js/physics/state/ — agents.md
|
||||||
|
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
|
||||||
|
|
||||||
|
Installs into `P`: createGame, makeBird, activeBird.
|
||||||
|
|
||||||
|
- `createGame(levelDef)` is the ONLY constructor of a gameplay state object.
|
||||||
|
Its shape (keys `level, world, time, score, birds, blocks, enemies, events,
|
||||||
|
scorePopups, hadEnemies, camera, status, activeBird, launched, wonAt, lostAt,
|
||||||
|
stats`) is consumed by render, app and every test — extending is OK, silently
|
||||||
|
dropping a key is not.
|
||||||
|
- Block/enemy/bird records are plain objects. S-3/S-5 (RC-375D84, landed)
|
||||||
|
extended them additively: blocks carry `ang`, `angVel`, `pivoting`
|
||||||
|
(topple DOF) and birds gain the `landed` state (a live body under gravity
|
||||||
|
while another bird is in play; see step/topple). Keep further extensions
|
||||||
|
additive and keep `createGame` the single place where initial state is minted.
|
||||||
|
- `activeBird(g)` is the canonical "whose turn is it" query — input, camera and
|
||||||
|
rules all go through it; do not re-derive it elsewhere.
|
||||||
|
- Consumes: WORLD, MATERIALS, BIRD_TYPES, ENEMY_TYPES (constants) + makeBird.
|
||||||
85
js/physics/state/index.js
Normal file
85
js/physics/state/index.js
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
/* ISLE SLINGERS -- physics/state/index.js (submodule: state)
|
||||||
|
* RC-375D84 S-2 modularization (behavior-neutral split of the original
|
||||||
|
* js/core.js monolith). Installs its units into ONE shared physics scope `P`
|
||||||
|
* that every physics submodule sees -- the same single-scope semantics the
|
||||||
|
* monolith had. Must load before js/physics/index.js (the aggregator that
|
||||||
|
* attaches the public ISLE_CORE API). Surface installed: createGame, makeBird, activeBird.
|
||||||
|
*/
|
||||||
|
(function (root, factory) {
|
||||||
|
var part = factory();
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = part;
|
||||||
|
else (root.ISLE_PHYSICS_PARTS = root.ISLE_PHYSICS_PARTS || []).push(part);
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
'use strict';
|
||||||
|
return function (P) {
|
||||||
|
P.createGame = function(levelDef) {
|
||||||
|
var g = {
|
||||||
|
level: levelDef,
|
||||||
|
world: { w: P.WORLD.w, ground: P.WORLD.ground },
|
||||||
|
time: 0,
|
||||||
|
score: 0,
|
||||||
|
birds: [],
|
||||||
|
blocks: [],
|
||||||
|
enemies: [],
|
||||||
|
events: [], // visual/audio events for the renderer
|
||||||
|
scorePopups: [], // {x,y,text,age}
|
||||||
|
hadEnemies: levelDef.enemies.length > 0,
|
||||||
|
camera: { x: levelDef.sling.x + 260, y: 560, zoom: 1, shake: 0, snap: true },
|
||||||
|
status: 'ready', // ready | flying | won | lost
|
||||||
|
activeBird: -1,
|
||||||
|
launched: 0,
|
||||||
|
wonAt: null,
|
||||||
|
lostAt: null,
|
||||||
|
stats: { impacts: 0, destroyedBlocks: 0, destroyedEnemies: 0, abilityUses: 0, distance: 0 }
|
||||||
|
};
|
||||||
|
g.blocks = levelDef.blocks.map(function (b, i) {
|
||||||
|
var m = P.MATERIALS[b.mat];
|
||||||
|
return {
|
||||||
|
id: i, x: b.x, y: b.y, w: b.w, h: b.h, mat: b.mat,
|
||||||
|
hp: m.hp, maxHp: m.hp,
|
||||||
|
vx: 0, vy: 0, rest: 1,
|
||||||
|
// S-3 rotational degree of freedom (topple). ang/angVel stay 0 for
|
||||||
|
// every supported stack, so pre-S-3 motion is preserved exactly.
|
||||||
|
ang: 0, angVel: 0, pivoting: false,
|
||||||
|
mass: m.density * b.w * b.h / 1400
|
||||||
|
};
|
||||||
|
});
|
||||||
|
g.enemies = levelDef.enemies.map(function (e, i) {
|
||||||
|
var t = P.ENEMY_TYPES[e.kind];
|
||||||
|
// level data y is the CENTER (the level-integrity test treats it the
|
||||||
|
// same way); spawning at e.y - r made every enemy drop 24-38px on
|
||||||
|
// load — a visible "objects moving on their own" wobble at start
|
||||||
|
return {
|
||||||
|
id: i, kind: e.kind, x: e.x, y: e.y, r: t.r,
|
||||||
|
hp: t.hp, maxHp: t.hp, vx: 0, vy: 0, rest: 1, dead: false, deathAge: 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
var i;
|
||||||
|
for (i = 0; i < levelDef.birds.length; i++) {
|
||||||
|
g.birds.push(P.makeBird(levelDef.birds[i], i));
|
||||||
|
}
|
||||||
|
g.activeBird = g.birds.length ? 0 : -1;
|
||||||
|
if (g.activeBird >= 0) {
|
||||||
|
var fb = g.birds[0];
|
||||||
|
fb.state = 'aiming';
|
||||||
|
fb.x = levelDef.sling.x;
|
||||||
|
fb.y = levelDef.sling.y;
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
P.makeBird = function(kind, idx) {
|
||||||
|
var t = P.BIRD_TYPES[kind];
|
||||||
|
return {
|
||||||
|
id: idx, kind: kind, r: t.r, mass: t.mass,
|
||||||
|
x: 0, y: 0, vx: 0, vy: 0,
|
||||||
|
state: 'queued', // queued | aiming | flying | spent
|
||||||
|
abilityUsed: false,
|
||||||
|
settleTimer: 0, age: 0,
|
||||||
|
launchT: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
P.activeBird = function(g) { return g.activeBird >= 0 ? g.birds[g.activeBird] : null; }
|
||||||
|
};
|
||||||
|
});
|
||||||
22
js/physics/step/agents.md
Normal file
22
js/physics/step/agents.md
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# js/physics/step/ — agents.md
|
||||||
|
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
|
||||||
|
|
||||||
|
Installs into `P`: step, stepPhysics, spendBird. **The physics hub.**
|
||||||
|
|
||||||
|
- `step(g, dt)` = time advance + stepPhysics + end-state check + camera. `stepPhysics`
|
||||||
|
is the integration core: gravity, air drag, bird/bird-block/bird-enemy/
|
||||||
|
block-block contacts, crush, settle bookkeeping.
|
||||||
|
- Because it reaches into nearly every unit, it is installed AFTER aim/damage and
|
||||||
|
BEFORE nothing — but all of its callees must exist in `P` by the time `step`
|
||||||
|
runs (they do, because install order is load order). Keep it that way.
|
||||||
|
- `spendBird` retires a bird that is GONE (aged out / off the island). S-5
|
||||||
|
(RC-375D84, landed 2026-09-29) split the settle branch: a bird that settles
|
||||||
|
while ANOTHER bird is in play becomes `landed` — a live body integrated
|
||||||
|
every frame in `stepPhysics` (real gravity, no lifetime timer) that falls
|
||||||
|
when its support breaks, chain-damages boards, and can kill enemies. The LAST
|
||||||
|
bird still spends (win/lose unchanged — keep it that way; the sim win/lose
|
||||||
|
test is the gate). Landed birds are cleared when the level ends (inside
|
||||||
|
`step`'s won/lost branch). `advanceToNextBird` is the shared "next bird onto
|
||||||
|
the sling" path used by both the spent and landed transitions.
|
||||||
|
- Determinism invariant: no Math.random, no Date, no DOM. Tests call this
|
||||||
|
directly at dt=1/120.
|
||||||
212
js/physics/step/index.js
Normal file
212
js/physics/step/index.js
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
/* ISLE SLINGERS -- physics/step/index.js (submodule: step)
|
||||||
|
* RC-375D84 S-2 modularization (behavior-neutral split of the original
|
||||||
|
* js/core.js monolith). Installs its units into ONE shared physics scope `P`
|
||||||
|
* that every physics submodule sees -- the same single-scope semantics the
|
||||||
|
* monolith had. Must load before js/physics/index.js (the aggregator that
|
||||||
|
* attaches the public ISLE_CORE API). Surface installed: step, stepPhysics, spendBird.
|
||||||
|
*/
|
||||||
|
(function (root, factory) {
|
||||||
|
var part = factory();
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = part;
|
||||||
|
else (root.ISLE_PHYSICS_PARTS = root.ISLE_PHYSICS_PARTS || []).push(part);
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
'use strict';
|
||||||
|
return function (P) {
|
||||||
|
P.step = function(g, dt) {
|
||||||
|
g.time += dt;
|
||||||
|
if (g.status === 'won' || g.status === 'lost') {
|
||||||
|
// S-5: the level is over -- landed bodies no longer exist (their window
|
||||||
|
// was "while another bird was active"), so remove them; the rest of the
|
||||||
|
// world still settles a beat for the win/lose screen.
|
||||||
|
for (var lb = 0; lb < g.birds.length; lb++) {
|
||||||
|
if (g.birds[lb].state === 'landed') {
|
||||||
|
g.birds[lb].state = 'spent';
|
||||||
|
g.events.push({ type: 'bird_gone', x: g.birds[lb].x, y: g.birds[lb].y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
P.stepPhysics(g, dt);
|
||||||
|
P.updatePopups(g, dt);
|
||||||
|
P.decayCamera(g, dt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
P.stepPhysics(g, dt);
|
||||||
|
P.updatePopups(g, dt);
|
||||||
|
P.decayCamera(g, dt);
|
||||||
|
P.updateCameraTarget(g);
|
||||||
|
P.checkEndStates(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
P.stepPhysics = function(g, dt) {
|
||||||
|
var i, b;
|
||||||
|
|
||||||
|
// active bird flight
|
||||||
|
b = P.activeBird(g);
|
||||||
|
if (b && b.state === 'flying') {
|
||||||
|
b.age += dt;
|
||||||
|
var nvy = b.vy + P.GRAVITY * dt;
|
||||||
|
b.x += b.vx * dt;
|
||||||
|
b.y += (b.vy + nvy) * 0.5 * dt;
|
||||||
|
b.vx *= P.AIR_DRAG;
|
||||||
|
b.vy = nvy;
|
||||||
|
g.stats.distance = Math.max(g.stats.distance, b.x);
|
||||||
|
P.collideBirdWorld(g, b);
|
||||||
|
P.collideBirdBlocks(g, b);
|
||||||
|
P.collideBirdEnemies(g, b);
|
||||||
|
// settle / spend detection (low speed anywhere = spent, whether on
|
||||||
|
// the ground or perched on a beam; jitter-tolerant accumulation)
|
||||||
|
var sp = Math.hypot(b.vx, b.vy);
|
||||||
|
if (sp < P.REST_SPEED) b.settleTimer += dt;
|
||||||
|
else b.settleTimer = Math.max(0, b.settleTimer - dt * 4);
|
||||||
|
if (b.age > P.BIRD_MAX_AGE || b.x > P.WORLD.w + 200 || b.x < -200) {
|
||||||
|
// gone for good (off the island or aged out) -> spent, next bird loads
|
||||||
|
P.spendBird(g, b);
|
||||||
|
} else if (b.settleTimer > P.SETTLE_TIME) {
|
||||||
|
// S-5 (RC-375D84, Q2): the "another bird is already active" window.
|
||||||
|
// A bird that settles while MORE birds are in play (queued or active)
|
||||||
|
// is NOT a corpse: it becomes a LANDED body -- real continuous
|
||||||
|
// gravity, no lifetime timer. It falls if its support breaks,
|
||||||
|
// chain-damages blocks it lands on, and can kill enemies, for as long
|
||||||
|
// as the level runs. The level-end rule (last bird, no window left)
|
||||||
|
// keeps the pre-S-5 behaviour exactly: the bird is spent there, so
|
||||||
|
// win/lose resolve unchanged.
|
||||||
|
// ANOTHER bird must be in play (queued, or flying/aiming): the settled
|
||||||
|
// bird lingers only during the "another bird is already active" window.
|
||||||
|
// It never counts itself -- for the LAST bird this stays false and the
|
||||||
|
// pre-S-5 spend path runs exactly as before (win/lose unchanged).
|
||||||
|
var moreBirds = P.birdsRemaining(g) > 0;
|
||||||
|
if (!moreBirds) {
|
||||||
|
for (var ob = 0; ob < g.birds.length; ob++) {
|
||||||
|
if (ob === g.birds.indexOf(b)) continue;
|
||||||
|
if (g.birds[ob].state === 'flying' || g.birds[ob].state === 'landed' || g.birds[ob].state === 'aiming') { moreBirds = true; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (moreBirds) {
|
||||||
|
b.state = 'landed';
|
||||||
|
b.vx = 0; b.vy = 0;
|
||||||
|
g.events.push({ type: 'bird_landed', x: b.x, y: b.y, kind: b.kind });
|
||||||
|
P.advanceToNextBird(g);
|
||||||
|
} else {
|
||||||
|
P.spendBird(g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// S-5: landed birds stay physical bodies -- real gravity, fall when their
|
||||||
|
// support breaks, hit blocks (chain damage) and enemies (kills). No
|
||||||
|
// lifetime timer: they live until the level resolves.
|
||||||
|
for (i = 0; i < g.birds.length; i++) {
|
||||||
|
var lb = g.birds[i];
|
||||||
|
if (lb.state !== 'landed') continue;
|
||||||
|
var lvy = lb.vy + P.GRAVITY * dt;
|
||||||
|
lb.x += lb.vx * dt;
|
||||||
|
lb.y += (lb.vy + lvy) * 0.5 * dt;
|
||||||
|
lb.vy = lvy;
|
||||||
|
lb.vx *= 0.999; // a landed body keeps (almost) all of its momentum
|
||||||
|
P.collideBirdWorld(g, lb);
|
||||||
|
P.collideBirdBlocks(g, lb);
|
||||||
|
P.collideBirdEnemies(g, lb);
|
||||||
|
}
|
||||||
|
|
||||||
|
// blocks: gravity + integration + collisions
|
||||||
|
for (i = 0; i < g.blocks.length; i++) {
|
||||||
|
var bl = g.blocks[i];
|
||||||
|
if (bl.hp <= 0) continue;
|
||||||
|
if (bl.pivoting) continue; // rotate about its pivot; integrated by toppleBlocks
|
||||||
|
bl.vy = Math.min(bl.vy + P.GRAVITY * dt, P.MATERIALS[bl.mat].maxVel);
|
||||||
|
bl.vx *= 0.995;
|
||||||
|
bl.x += bl.vx * dt;
|
||||||
|
bl.y += bl.vy * dt;
|
||||||
|
// ground
|
||||||
|
var halfH = bl.h / 2;
|
||||||
|
if (bl.y + halfH > P.WORLD.ground) {
|
||||||
|
var impact = bl.vy;
|
||||||
|
bl.y = P.WORLD.ground - halfH;
|
||||||
|
if (impact > 90) {
|
||||||
|
bl.vy = -impact * P.MATERIALS[bl.mat].restitution;
|
||||||
|
bl.vx *= 0.8;
|
||||||
|
if (impact > 240) {
|
||||||
|
P.damageBlock(g, bl, (impact - 240) * 0.45, 'ground');
|
||||||
|
g.events.push({ type: 'block_impact', x: bl.x, y: P.WORLD.ground, mat: bl.mat, strength: impact });
|
||||||
|
}
|
||||||
|
bl.rest = 0;
|
||||||
|
} else {
|
||||||
|
bl.vy = 0;
|
||||||
|
bl.vx *= 0.82; // rolling friction
|
||||||
|
if (Math.abs(bl.vx) < 4) { bl.vx = 0; bl.rest = 1; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// walls (keep the mess on the island)
|
||||||
|
if (bl.x - bl.w / 2 < 0) { bl.x = bl.w / 2; bl.vx = Math.abs(bl.vx) * 0.2; }
|
||||||
|
if (bl.x + bl.w / 2 > P.WORLD.w) { bl.x = P.WORLD.w - bl.w / 2; bl.vx = -Math.abs(bl.vx) * 0.2; }
|
||||||
|
}
|
||||||
|
// block vs block (positional + 1D impulse, 2 relaxation passes)
|
||||||
|
var pass, j, a, c;
|
||||||
|
for (pass = 0; pass < 2; pass++) {
|
||||||
|
for (i = 0; i < g.blocks.length; i++) {
|
||||||
|
a = g.blocks[i];
|
||||||
|
if (a.hp <= 0 || a.pivoting) continue;
|
||||||
|
for (j = i + 1; j < g.blocks.length; j++) {
|
||||||
|
c = g.blocks[j];
|
||||||
|
if (c.hp <= 0 || c.pivoting) continue;
|
||||||
|
P.resolveBoxPair(g, a, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// block tip/topple (S-3): rotate about a support-edge pivot, land, impact
|
||||||
|
P.toppleBlocks(g, dt);
|
||||||
|
// block vs bird (crush) -- S-5: the active bird AND any landed bodies
|
||||||
|
for (i = 0; i < g.birds.length; i++) {
|
||||||
|
var cb = g.birds[i];
|
||||||
|
if (cb.state !== 'flying' && cb.state !== 'landed') continue;
|
||||||
|
for (var ci = 0; ci < g.blocks.length; ci++) {
|
||||||
|
var blk = g.blocks[ci];
|
||||||
|
if (blk.hp <= 0 || blk.pivoting) continue;
|
||||||
|
P.crushCircleVsBox(g, blk, cb, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// enemies: settle on ground/blocks, take crush damage
|
||||||
|
for (i = 0; i < g.enemies.length; i++) {
|
||||||
|
var e = g.enemies[i];
|
||||||
|
if (e.dead) { e.deathAge += dt; continue; }
|
||||||
|
e.vy = Math.min(e.vy + P.GRAVITY * dt, 700);
|
||||||
|
e.x += e.vx * dt;
|
||||||
|
e.y += e.vy * dt;
|
||||||
|
e.vx *= 0.9;
|
||||||
|
if (e.y + e.r > P.WORLD.ground) {
|
||||||
|
if (e.vy > 420) { e.vy = -e.vy * 0.25; e.vx *= 0.6; }
|
||||||
|
else { e.vy = 0; e.y = P.WORLD.ground - e.r; e.vx *= 0.8; }
|
||||||
|
}
|
||||||
|
for (j = 0; j < g.blocks.length; j++) {
|
||||||
|
var bb = g.blocks[j];
|
||||||
|
if (bb.hp <= 0 || bb.pivoting) continue;
|
||||||
|
P.crushCircleVsBox(g, bb, e, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// next bird onto the sling (shared by spendBird and the S-5 landed
|
||||||
|
// transition -- the same "another bird is already active" moment)
|
||||||
|
P.advanceToNextBird = function(g) {
|
||||||
|
var next = -1;
|
||||||
|
for (var i = g.activeBird + 1; i < g.birds.length; i++) {
|
||||||
|
if (g.birds[i].state === 'queued') { next = i; break; }
|
||||||
|
}
|
||||||
|
g.activeBird = next;
|
||||||
|
if (next >= 0) {
|
||||||
|
var nb = g.birds[next];
|
||||||
|
nb.state = 'aiming';
|
||||||
|
nb.x = g.level.sling.x; nb.y = g.level.sling.y;
|
||||||
|
g.status = 'ready';
|
||||||
|
g.events.push({ type: 'bird_load', x: nb.x, y: nb.y, kind: nb.kind });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
P.spendBird = function(g, b) {
|
||||||
|
b.state = 'spent';
|
||||||
|
b.vx = 0; b.vy = 0;
|
||||||
|
g.events.push({ type: 'bird_gone', x: b.x, y: b.y });
|
||||||
|
// next bird onto the sling
|
||||||
|
P.advanceToNextBird(g);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
23
js/physics/topple/agents.md
Normal file
23
js/physics/topple/agents.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# js/physics/topple/ — agents.md
|
||||||
|
RC-375D84 S-3 (2026-09-29). Module map: `MODULES.md`; runtime model: `architecture.md`.
|
||||||
|
|
||||||
|
Installs into `P`: toppleBlocks, blockSupportSpan, beginTopple, integrateTopple,
|
||||||
|
landTopple, topplePathDamage, settleBack, supportStillAlive.
|
||||||
|
|
||||||
|
- Owns the board tip/topple behavior (Q1: FULL CONTINUOUS ROTATION). A resting
|
||||||
|
block whose COM lies past the x-span of its support (ground or the top face of
|
||||||
|
a block below, via `blockSupportSpan`) pivots about the support-edge corner
|
||||||
|
under gravity (point-mass COM, `alpha = G*(comX-pivotX)/r^2`).
|
||||||
|
- Two endings: (a) a far corner reaches the ground line -> `landTopple`
|
||||||
|
(impact speed = |angVel|*r, self-damage + `topplePathDamage` on the footprint:
|
||||||
|
blocks take impact, enemies in the footprint can be killed through the normal
|
||||||
|
crush curve); (b) the pivot is too high for any end to reach the ground ->
|
||||||
|
the board is a pendulum and `settleBack` returns it flat to its rest position.
|
||||||
|
- Anti-livelock: a swung-and-settled board is marked `toppled` and only re-tips
|
||||||
|
on a real knock (displacement >= 6px) or a damaging hit (`damageBlock` clears
|
||||||
|
the mark). Do NOT re-arm on velocity alone -- the pendulum's own swing-back
|
||||||
|
speed defeats that (regression: test/knifeedge.test.mjs).
|
||||||
|
- A pivoting block is EXCLUDED from linear integration, box-pair resolution and
|
||||||
|
crush in `step` -- those loops must keep skipping `bl.pivoting`.
|
||||||
|
- Consumes: WORLD, GRAVITY, REST_SPEED, MATERIALS, ENEMY_TYPES, damageBlock,
|
||||||
|
damageEnemy. Tests: test/topple.test.mjs (5 arms), test/knifeedge.test.mjs (6 arms).
|
||||||
223
js/physics/topple/index.js
Normal file
223
js/physics/topple/index.js
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
/* ISLE SLINGERS -- physics/topple/index.js (submodule: topple)
|
||||||
|
* RC-375D84 S-3. Boards tip and topple end-over-end (Commander Q1: FULL
|
||||||
|
* CONTINUOUS ROTATION). A resting block whose centre of mass lies OUTSIDE the
|
||||||
|
* x-span of whatever supports it (ground, or the top face of a block below)
|
||||||
|
* begins pivoting about the support-edge corner on the side the COM leans to.
|
||||||
|
* The pivot is a fixed point; the board rotates about it under gravity
|
||||||
|
* (rigid body, point-mass COM) -- a smooth continuous swing, no snap. When the
|
||||||
|
* far end strikes the ground it lands: impact speed = angVel * r drives damage
|
||||||
|
* to the board itself and to anything in the swept path (blocks take impact,
|
||||||
|
* enemies can be killed). The board then rests at its landed angle.
|
||||||
|
*
|
||||||
|
* Adds a single rotational degree of freedom (ang, angVel, pivot) to blocks.
|
||||||
|
* Blocks that never overhang keep ang=0 forever, so pre-S-3 behaviour is
|
||||||
|
* exactly preserved for supported stacks (proven by topple.test arm 3).
|
||||||
|
*
|
||||||
|
* Installs into the shared physics scope P (same single-scope semantics as the
|
||||||
|
* monolith). Must load before js/physics/index.js. Surface installed:
|
||||||
|
* P.toppleBlocks, P.blockSupportSpan
|
||||||
|
*/
|
||||||
|
(function (root, factory) {
|
||||||
|
var part = factory();
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = part;
|
||||||
|
else (root.ISLE_PHYSICS_PARTS = root.ISLE_PHYSICS_PARTS || []).push(part);
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
'use strict';
|
||||||
|
return function (P) {
|
||||||
|
|
||||||
|
// The x-span [lo, hi] that supports the block, built from the ground and the
|
||||||
|
// top faces of live blocks whose top is at (or just below) this block's
|
||||||
|
// bottom and which overlap it in x. Returns null if the block is free-
|
||||||
|
// falling (nothing directly under it).
|
||||||
|
P.blockSupportSpan = function(g, bl) {
|
||||||
|
var bottom = bl.y + bl.h / 2;
|
||||||
|
var lo = null, hi = null;
|
||||||
|
var i, o, oTop, oBot, oLo, oHi;
|
||||||
|
// ground support: if the block's bottom sits on the ground line
|
||||||
|
if (Math.abs(bottom - P.WORLD.ground) < 2 && Math.abs(bl.vy) < P.REST_SPEED) {
|
||||||
|
lo = bl.x - bl.w / 2; hi = bl.x + bl.w / 2;
|
||||||
|
}
|
||||||
|
for (i = 0; i < g.blocks.length; i++) {
|
||||||
|
o = g.blocks[i];
|
||||||
|
if (o === bl || o.hp <= 0) continue;
|
||||||
|
oTop = o.y - o.h / 2; // top face of the support block (unrotated support)
|
||||||
|
oBot = o.y + o.h / 2;
|
||||||
|
oLo = o.x - o.w / 2; oHi = o.x + o.w / 2;
|
||||||
|
// o supports bl when o's top face is within a hair below bl's bottom
|
||||||
|
if (bottom - oTop > 2 || oTop - bottom > 4) continue; // not touching
|
||||||
|
if (oBot < bottom - 4) continue;
|
||||||
|
// x-overlap between bl and o
|
||||||
|
var ovlLo = Math.max(bl.x - bl.w / 2, oLo);
|
||||||
|
var ovlHi = Math.min(bl.x + bl.w / 2, oHi);
|
||||||
|
if (ovlLo >= ovlHi) continue;
|
||||||
|
lo = (lo === null) ? ovlLo : Math.min(lo, ovlLo);
|
||||||
|
hi = (hi === null) ? ovlHi : Math.max(hi, ovlHi);
|
||||||
|
}
|
||||||
|
if (lo === null || hi === null) return null;
|
||||||
|
return { lo: lo, hi: hi };
|
||||||
|
}
|
||||||
|
|
||||||
|
P.toppleBlocks = function(g, dt) {
|
||||||
|
var i, bl, sp, margin = 2;
|
||||||
|
for (i = 0; i < g.blocks.length; i++) {
|
||||||
|
bl = g.blocks[i];
|
||||||
|
if (bl.hp <= 0) continue;
|
||||||
|
if (bl.pivoting) {
|
||||||
|
// support destroyed or block removed mid-swing -> free fall, keep angle
|
||||||
|
if (!P.supportStillAlive(g, bl)) { bl.pivoting = false; bl.angVel = 0; continue; }
|
||||||
|
P.integrateTopple(g, bl, dt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// candidate for tipping: must be (nearly) at rest to evaluate support
|
||||||
|
if (Math.abs(bl.vy) > P.REST_SPEED * 2 || Math.abs(bl.vx) > P.REST_SPEED * 2) continue;
|
||||||
|
if (bl.toppled) {
|
||||||
|
// anti-livelock: a swung-and-settled board rests until a REAL knock
|
||||||
|
// (displacement > 6px from its settled spot, or a damaging hit, which
|
||||||
|
// clears the flag in damageBlock). Gravity settling never moves it
|
||||||
|
// that far, so the knife-edge re-tip loop is closed.
|
||||||
|
if (Math.abs(bl.x - (bl.restX || bl.x)) + Math.abs(bl.y - (bl.restY || bl.y)) >= 6) {
|
||||||
|
bl.toppled = false;
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sp = P.blockSupportSpan(g, bl);
|
||||||
|
if (!sp) continue; // free-falling or in the air
|
||||||
|
var comX = bl.x;
|
||||||
|
if (comX > sp.hi + margin) {
|
||||||
|
// COM leans right of the support span -> pivot about the support's
|
||||||
|
// top-right corner (the edge of what's underneath, not the board edge)
|
||||||
|
P.beginTopple(g, bl, sp.hi, bl.y + bl.h / 2);
|
||||||
|
} else if (comX < sp.lo - margin) {
|
||||||
|
P.beginTopple(g, bl, sp.lo, bl.y + bl.h / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
P.beginTopple = function(g, bl, px, py) {
|
||||||
|
bl.pivoting = true;
|
||||||
|
bl.restX = bl.x; bl.restY = bl.y; // where it sits if the swing settles back
|
||||||
|
bl.pivotX = px; bl.pivotY = py;
|
||||||
|
// vector from pivot to COM at the start of the swing
|
||||||
|
bl.pvx = bl.x - px; bl.pvy = bl.y - py;
|
||||||
|
bl.ang = 0; bl.angVel = 0;
|
||||||
|
bl.rest = 0;
|
||||||
|
g.events.push({ type: 'topple_start', x: bl.x, y: bl.y, mat: bl.mat });
|
||||||
|
}
|
||||||
|
|
||||||
|
P.integrateTopple = function(g, bl, dt) {
|
||||||
|
var r2 = bl.pvx * bl.pvx + bl.pvy * bl.pvy;
|
||||||
|
if (r2 < 1e-6) { bl.pivoting = false; return; }
|
||||||
|
// rotate the pivot->COM vector by the current angle to get the live COM
|
||||||
|
var c = Math.cos(bl.ang), s = Math.sin(bl.ang);
|
||||||
|
var comX = bl.pivotX + bl.pvx * c - bl.pvy * s;
|
||||||
|
var comY = bl.pivotY + bl.pvx * s + bl.pvy * c;
|
||||||
|
// torque per mass about the pivot: g * (COMx - pivotX); I/m = r2
|
||||||
|
var alpha = P.GRAVITY * (comX - bl.pivotX) / r2;
|
||||||
|
bl.angVel += alpha * dt;
|
||||||
|
bl.ang += bl.angVel * dt;
|
||||||
|
bl.x = comX; bl.y = comY;
|
||||||
|
// landing: the far (non-pivot) corners hit the ground line
|
||||||
|
var hw = bl.w / 2, hh = bl.h / 2;
|
||||||
|
var cx = [ -hw, hw, hw, -hw ], cy = [ -hh, -hh, hh, hh ];
|
||||||
|
var landed = false, k, rx0, ry0, rx, ry;
|
||||||
|
for (k = 0; k < 4; k++) {
|
||||||
|
// corner vector = (pivot->COM) + (COM->corner), rotated by ang
|
||||||
|
rx0 = bl.pvx + cx[k]; ry0 = bl.pvy + cy[k];
|
||||||
|
rx = rx0 * c - ry0 * s;
|
||||||
|
ry = rx0 * s + ry0 * c;
|
||||||
|
if (bl.pivotY + ry >= P.WORLD.ground - 0.5) { landed = true; break; }
|
||||||
|
}
|
||||||
|
if (landed) { P.landTopple(g, bl); return; }
|
||||||
|
// settle-back: the pivot is too high for either end to reach the ground,
|
||||||
|
// so the board is a damped-free pendulum -- when it swings back through
|
||||||
|
// the flat angle, it lands flat on its support (the physical resolution)
|
||||||
|
if (bl.ang < 0 && bl.angVel > 0 && bl.ang > -0.06) P.settleBack(g, bl);
|
||||||
|
else if (bl.ang > 0 && bl.angVel < 0 && bl.ang < 0.06) P.settleBack(g, bl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// true while the block the pivot sits on is still alive (or the pivot is on
|
||||||
|
// the ground line). Used to detect a support destroyed mid-swing.
|
||||||
|
P.supportStillAlive = function(g, bl) {
|
||||||
|
if (Math.abs(bl.pivotY - P.WORLD.ground) < 2) return true;
|
||||||
|
var i, o, oTop;
|
||||||
|
for (i = 0; i < g.blocks.length; i++) {
|
||||||
|
o = g.blocks[i];
|
||||||
|
if (o === bl || o.hp <= 0) continue;
|
||||||
|
oTop = o.y - o.h / 2;
|
||||||
|
if (Math.abs(oTop - bl.pivotY) < 3) {
|
||||||
|
if (bl.pivotX >= o.x - o.w / 2 - 1 && bl.pivotX <= o.x + o.w / 2 + 1) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the board swung back to flat on its support: stop the rotation, rest it
|
||||||
|
// flat (COM returns to its exact start point, so no position drift)
|
||||||
|
P.settleBack = function(g, bl) {
|
||||||
|
bl.angVel = 0;
|
||||||
|
bl.ang = 0;
|
||||||
|
bl.pivoting = false;
|
||||||
|
bl.x = bl.restX; bl.y = bl.restY; // damped pendulum: it sits back where it was
|
||||||
|
bl.toppled = true; // anti-livelock: don't re-tip until knocked or damaged
|
||||||
|
bl.vx = 0; bl.vy = 0; bl.rest = 1;
|
||||||
|
g.events.push({ type: 'topple_settle', x: bl.x, y: bl.y, mat: bl.mat });
|
||||||
|
}
|
||||||
|
|
||||||
|
P.landTopple = function(g, bl) {
|
||||||
|
// impact speed of the COM = tangential speed about the pivot
|
||||||
|
var r = Math.hypot(bl.pvx, bl.pvy);
|
||||||
|
var impact = Math.abs(bl.angVel) * r;
|
||||||
|
// settle: stop the swing, keep the landed angle (board rests propped/flat)
|
||||||
|
bl.angVel = 0;
|
||||||
|
bl.pivoting = false;
|
||||||
|
bl.vx = 0; bl.vy = 0; bl.rest = 1;
|
||||||
|
// clamp COM onto the ground line if it dipped below
|
||||||
|
if (bl.y + bl.h / 2 > P.WORLD.ground) bl.y = P.WORLD.ground - bl.h / 2;
|
||||||
|
if (impact > 60) {
|
||||||
|
// the board itself takes impact damage from the slam
|
||||||
|
P.damageBlock(g, bl, (impact - 60) * 0.35, 'topple');
|
||||||
|
g.events.push({ type: 'topple_land', x: bl.x, y: bl.y, mat: bl.mat, strength: impact });
|
||||||
|
g.camera.shake = Math.max(g.camera.shake, Math.min(14, impact / 60));
|
||||||
|
// anything in the swept path takes it too: check the board's current
|
||||||
|
// rotated extent against live blocks and enemies
|
||||||
|
P.topplePathDamage(g, bl, impact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
P.topplePathDamage = function(g, bl, impact) {
|
||||||
|
// rotated corners of the landed board
|
||||||
|
var c = Math.cos(bl.ang), s = Math.sin(bl.ang);
|
||||||
|
var hw = bl.w / 2, hh = bl.h / 2;
|
||||||
|
var cx = [ -hw, hw, hw, -hw ], cy = [ -hh, -hh, hh, hh ];
|
||||||
|
var xs = [], ys = [];
|
||||||
|
for (var k = 0; k < 4; k++) {
|
||||||
|
xs.push(bl.x + cx[k] * c - cy[k] * s);
|
||||||
|
ys.push(bl.y + cx[k] * s + cy[k] * c);
|
||||||
|
}
|
||||||
|
var minX = Math.min.apply(null, xs), maxX = Math.max.apply(null, xs);
|
||||||
|
var minY = Math.min.apply(null, ys), maxY = Math.max.apply(null, ys);
|
||||||
|
var i, o, spec;
|
||||||
|
// blocks in the landed footprint
|
||||||
|
for (i = 0; i < g.blocks.length; i++) {
|
||||||
|
o = g.blocks[i];
|
||||||
|
if (o === bl || o.hp <= 0) continue;
|
||||||
|
if (o.x + o.w / 2 < minX || o.x - o.w / 2 > maxX) continue;
|
||||||
|
if (o.y + o.h / 2 < minY || o.y - o.h / 2 > maxY) continue;
|
||||||
|
P.damageBlock(g, o, impact * 0.4 * P.MATERIALS[o.mat].dmgFactor, 'topple');
|
||||||
|
}
|
||||||
|
// enemies in the swept footprint can be killed (objective b)
|
||||||
|
for (i = 0; i < g.enemies.length; i++) {
|
||||||
|
o = g.enemies[i];
|
||||||
|
if (o.dead) continue;
|
||||||
|
if (o.x + o.r < minX || o.x - o.r > maxX) continue;
|
||||||
|
if (o.y + o.r < minY || o.y - o.r > maxY) continue;
|
||||||
|
spec = P.ENEMY_TYPES[o.kind];
|
||||||
|
if (impact > spec.crushVel) {
|
||||||
|
P.damageEnemy(g, o, (impact - spec.crushVel) * 0.004 * spec.crushDmg + 0.35);
|
||||||
|
g.events.push({ type: 'crush', x: o.x, y: o.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
16
js/render/agents.md
Normal file
16
js/render/agents.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# js/render/ — agents.md
|
||||||
|
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
|
||||||
|
|
||||||
|
Self-contained module. Exports `ISLE_RENDERER = { Renderer, VIEW_W, VIEW_H }`.
|
||||||
|
|
||||||
|
- `new Renderer(canvas, { onEvent })` — pure draw: `draw(ctx, state)` renders
|
||||||
|
whatever the physics state contains (blocks, enemies, birds, sling, HUD-ish
|
||||||
|
scenery, popups). It never mutates `state`.
|
||||||
|
- VIEW_W/VIEW_H (1280x720) mirror physics constants VIEW — keep them in sync;
|
||||||
|
the camera lives in state (set by physics/camera), render only applies it.
|
||||||
|
- S-3/S-5 (RC-375D84, landed): `drawBlock` rotates about the block COM when
|
||||||
|
`bl.ang` is set (no-op at `ang=0`, so un-tipped blocks draw identically to
|
||||||
|
the pre-S-3 output), and `drawBird` renders `landed` birds as live bodies
|
||||||
|
alongside the `spent` corpses. The DOM-stub test only checks that drawing
|
||||||
|
does not throw and that the sling band stops tracking a launched bird
|
||||||
|
(bug #14 arm).
|
||||||
|
|
@ -280,6 +280,10 @@
|
||||||
Renderer.prototype.drawBlock = function (ctx, bl) {
|
Renderer.prototype.drawBlock = function (ctx, bl) {
|
||||||
if (bl.hp <= 0) return;
|
if (bl.hp <= 0) return;
|
||||||
var x = bl.x - bl.w / 2, y = bl.y - bl.h / 2;
|
var x = bl.x - bl.w / 2, y = bl.y - bl.h / 2;
|
||||||
|
// S-3: a tipped board draws rotated about its centre (COM). Supported
|
||||||
|
// stacks keep ang=0, so the transform is a no-op and output is identical.
|
||||||
|
var rotated = !!bl.ang;
|
||||||
|
if (rotated) { ctx.save(); ctx.translate(bl.x, bl.y); ctx.rotate(bl.ang); ctx.translate(-bl.x, -bl.y); }
|
||||||
var styles = {
|
var styles = {
|
||||||
wood: { fill: '#b98a4e', edge: '#8a5f2e', line: 'rgba(90,60,25,0.5)' },
|
wood: { fill: '#b98a4e', edge: '#8a5f2e', line: 'rgba(90,60,25,0.5)' },
|
||||||
ice: { fill: 'rgba(168,220,250,0.82)', edge: 'rgba(120,180,220,0.9)', line: 'rgba(255,255,255,0.55)' },
|
ice: { fill: 'rgba(168,220,250,0.82)', edge: 'rgba(120,180,220,0.9)', line: 'rgba(255,255,255,0.55)' },
|
||||||
|
|
@ -336,6 +340,7 @@
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (rotated) ctx.restore();
|
||||||
};
|
};
|
||||||
|
|
||||||
Renderer.prototype.drawSling = function (ctx, sling, bird, drag) {
|
Renderer.prototype.drawSling = function (ctx, sling, bird, drag) {
|
||||||
|
|
@ -506,10 +511,12 @@
|
||||||
// enemies
|
// enemies
|
||||||
for (var en = 0; en < state.enemies.length; en++) this.drawMog(ctx, state.enemies[en], t);
|
for (var en = 0; en < state.enemies.length; en++) this.drawMog(ctx, state.enemies[en], t);
|
||||||
|
|
||||||
// spent birds lie on the ground
|
// spent birds lie on the ground; S-5: LANDED birds are live bodies too --
|
||||||
|
// they stay in the world (perched, falling, rolling) until level end.
|
||||||
for (var sp = 0; sp < state.birds.length; sp++) {
|
for (var sp = 0; sp < state.birds.length; sp++) {
|
||||||
var sb = state.birds[sp];
|
var sb = state.birds[sp];
|
||||||
if (sb.state === 'spent' && !sb.gone) this.drawBird(ctx, sb, t);
|
if (sb.state === 'spent' && !sb.gone) this.drawBird(ctx, sb, t);
|
||||||
|
else if (sb.state === 'landed') this.drawBird(ctx, sb, t);
|
||||||
}
|
}
|
||||||
|
|
||||||
// score popups
|
// score popups
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Isle Slingers — an original browser slingshot physics game (RC-0D193E). No dependencies; opens directly in a browser.",
|
"description": "Isle Slingers — an original browser slingshot physics game (RC-0D193E). No dependencies; opens directly in a browser.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node test/sim.test.mjs && node test/page.test.mjs && node test/balance.test.mjs",
|
"test": "node test/sim.test.mjs && node test/page.test.mjs && node test/balance.test.mjs && node test/topple.test.mjs && node test/knifeedge.test.mjs && node test/fallingkill.test.mjs && node test/landedbird.test.mjs",
|
||||||
"start": "python3 -m http.server 8000"
|
"start": "python3 -m http.server 8000"
|
||||||
},
|
},
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@
|
||||||
* grum dies in <= 2 medium-speed direct hits (soft lobs were ~0.9 dmg)
|
* grum dies in <= 2 medium-speed direct hits (soft lobs were ~0.9 dmg)
|
||||||
* king must still SURVIVE one full-power wren hit (sim.test.mjs pins hp3)
|
* king must still SURVIVE one full-power wren hit (sim.test.mjs pins hp3)
|
||||||
*/
|
*/
|
||||||
import ISLE from '../js/core.js';
|
import ISLE from '../js/physics/index.js'; // RC-375D84 S-2: physics module aggregator (was js/core.js)
|
||||||
import levelsModule from '../js/levels.js';
|
import levelsModule from '../js/levels/index.js'; // RC-375D84 S-2: levels module aggregator (was js/levels.js)
|
||||||
const LEVELS = levelsModule.LEVELS;
|
const LEVELS = levelsModule.LEVELS;
|
||||||
let passed = 0, failed = 0;
|
let passed = 0, failed = 0;
|
||||||
const failures = [];
|
const failures = [];
|
||||||
|
|
|
||||||
121
test/fallingkill.test.mjs
Normal file
121
test/fallingkill.test.mjs
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
// test/fallingkill.test.mjs -- RC-375D84 S-4: falling-object impact damage.
|
||||||
|
// Commander (verbatim in the RC): "falling objects need to be able to kill the
|
||||||
|
// enemy's as well not just the birds". Under the RC-0D193E-tuned balance
|
||||||
|
// (block maxVel clamps: stone 480, ice 560, wood 520) blocks kill a mogg in
|
||||||
|
// one impact (1.07-1.45) and a grum (hp 2) in two impacts (1.07 x 2 = 2.14).
|
||||||
|
// S-4 fixes the two gaps that made falling blocks unable to kill, plus a
|
||||||
|
// double-fire defect the fix exposed:
|
||||||
|
// (a) crush direction gate -- the old "ny < -0.3" (enemy ABOVE the box) meant
|
||||||
|
// a block dropped STRAIGHT DOWN onto an enemy never registered. Now the
|
||||||
|
// box must simply be the body moving into the enemy (boxAlong > 30).
|
||||||
|
// (b) chain momentum -- resolveBoxPair used a mass-less (a.vy+c.vy)/2, so a
|
||||||
|
// heavy stone landing on a light plank handed the plank only half the
|
||||||
|
// speed (240 px/s, UNDER the 330 mogg threshold). Mass-weighted inelastic
|
||||||
|
// contact transfers the real momentum (plank -> 466 px/s, OVER), so a
|
||||||
|
// knocked plank is itself a live projectile that can kill.
|
||||||
|
// (c) crush cooldown + inelastic transfer -- with (a) in place a box resting
|
||||||
|
// on an enemy at terminal velocity would otherwise re-fire damage every
|
||||||
|
// frame (4 frames = a king gone). One landing = one crush instance
|
||||||
|
// (0.12s per-enemy cooldown, same idea as the bird-hit gate); the box
|
||||||
|
// also sheds a mass-scaled share of its normal velocity so it does not
|
||||||
|
// phase through the enemy.
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const ISLE = require('../js/physics/index.js');
|
||||||
|
|
||||||
|
const DT = 1 / 120;
|
||||||
|
let passed = 0, failed = 0;
|
||||||
|
function check(name, cond, detail) {
|
||||||
|
if (cond) { passed++; console.log(' PASS ' + name); }
|
||||||
|
else { failed++; console.log(' FAIL ' + name + (detail ? ' -- ' + detail : '')); }
|
||||||
|
}
|
||||||
|
function game(def) {
|
||||||
|
const lvl = Object.assign({ name: 'fk-test', sling: { x: 260, y: 560 }, birds: [], blocks: [], enemies: [] }, def);
|
||||||
|
return ISLE.createGame(lvl);
|
||||||
|
}
|
||||||
|
function step(g, secs) {
|
||||||
|
const n = Math.round(secs / DT);
|
||||||
|
for (let i = 0; i < n; i++) ISLE.step(g, DT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// arm 1 (chain kill): heavy stone 200x200 (mass 62.9) dropped onto an ice
|
||||||
|
// plank 120x30 (mass 1.8) that overhangs a mogg. Mass-weighted contact drives
|
||||||
|
// the plank to ~466 px/s (the old mass-less average would give ~240, UNDER the
|
||||||
|
// 330 threshold) -> the plank crushes the mogg for 1.01 >= hp 1. KILL through
|
||||||
|
// the stack: the falling stone kills the enemy via the plank it knocks.
|
||||||
|
{
|
||||||
|
const g = game({
|
||||||
|
blocks: [
|
||||||
|
{ x: 1300, y: 300, w: 200, h: 200, mat: 'stone' }, // bottom 400
|
||||||
|
{ x: 1300, y: 767, w: 120, h: 30, mat: 'ice' } // 752..782, overhangs mogg at 1340
|
||||||
|
],
|
||||||
|
enemies: [{ x: 1340, y: 876, kind: 'mogg' }]
|
||||||
|
});
|
||||||
|
step(g, 5.0);
|
||||||
|
const e = g.enemies[0];
|
||||||
|
check('chain: heavy stone drives a plank fast enough to KILL the mogg it sweeps', e.dead,
|
||||||
|
'mogg hp=' + e.hp + ' dead=' + e.dead);
|
||||||
|
}
|
||||||
|
|
||||||
|
// arm 2 (direct one-hit kill): stone dropped straight onto a mogg.
|
||||||
|
{
|
||||||
|
const g = game({ blocks: [{ x: 1200, y: 280, w: 120, h: 120, mat: 'stone' }],
|
||||||
|
enemies: [{ x: 1200, y: 876, kind: 'mogg' }] });
|
||||||
|
step(g, 4.0);
|
||||||
|
const e = g.enemies[0];
|
||||||
|
check('direct: a falling stone kills the mogg it lands on (one hit)', e.dead,
|
||||||
|
'mogg hp=' + e.hp + ' dead=' + e.dead);
|
||||||
|
}
|
||||||
|
|
||||||
|
// arm 3 (repeated-impact kill): a grum (hp 2) dies to two ice drops landing at
|
||||||
|
// DISJOINT spots (offset 90px, blocks are 120 wide so they never touch each
|
||||||
|
// other). 1.07 x 2 = 2.14 >= 2.
|
||||||
|
{
|
||||||
|
const g = game({ enemies: [{ x: 1200, y: 868, kind: 'grum' }] });
|
||||||
|
g.blocks.push({ id: 90, x: 1160, y: 280, w: 120, h: 120, mat: 'ice', hp: 480, maxHp: 480, vx: 0, vy: 0, rest: 1, ang: 0, angVel: 0, pivoting: false, mass: 0.7 * 120 * 120 / 1400 });
|
||||||
|
step(g, 4.0); // first impact at x=1160: 1.07
|
||||||
|
const afterFirst = g.enemies[0].hp;
|
||||||
|
g.blocks.push({ id: 91, x: 1250, y: 280, w: 120, h: 120, mat: 'ice', hp: 480, maxHp: 480, vx: 0, vy: 0, rest: 1, ang: 0, angVel: 0, pivoting: false, mass: 0.7 * 120 * 120 / 1400 });
|
||||||
|
step(g, 4.0); // second impact at x=1250: 1.07
|
||||||
|
const e = g.enemies[0];
|
||||||
|
check('repeated: a grum dies to two disjoint ice drops (1.07 x 2 >= 2)', e.dead,
|
||||||
|
'after 1st=' + afterFirst.toFixed(2) + ' final hp=' + e.hp + ' dead=' + e.dead);
|
||||||
|
}
|
||||||
|
|
||||||
|
// arm 4 (one impact, no machine-gun): a LOW stone drop (70px fall -> v=458,
|
||||||
|
// below stone's 480 clamp) deals 0.96 < 1: the mogg survives with EXACTLY one
|
||||||
|
// damage instance even though the stone rests on it for the whole window.
|
||||||
|
// Without the cooldown, the resting box (still registered at terminal speed)
|
||||||
|
// would re-fire every frame and finish it.
|
||||||
|
{
|
||||||
|
const g = game({ blocks: [{ x: 1200, y: 722, w: 120, h: 120, mat: 'stone' }],
|
||||||
|
enemies: [{ x: 1200, y: 876, kind: 'mogg' }] });
|
||||||
|
let crushEvents = 0;
|
||||||
|
const origPush = g.events.push.bind(g.events);
|
||||||
|
g.events.push = function (ev) { if (ev.type === 'crush') crushEvents++; return origPush(ev); };
|
||||||
|
step(g, 4.0);
|
||||||
|
const e = g.enemies[0];
|
||||||
|
// impact ~462 px/s (70px fall + ~5px detection overlap): dmg ~0.99, survives
|
||||||
|
// with a hair of hp -- and the resting box must not re-fire (exactly 1 event).
|
||||||
|
check('one landing = one crush instance (no per-frame re-fire)',
|
||||||
|
crushEvents === 1 && !e.dead && e.hp > 0 && e.hp < 0.05,
|
||||||
|
'crushEvents=' + crushEvents + ' mogg hp=' + e.hp.toFixed(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// arm 5 (negative: under threshold): a wood block dropped only 36px
|
||||||
|
// (v=328 < the 330 mogg crushVel) must deal zero crush damage.
|
||||||
|
{
|
||||||
|
// A block resting ON the mogg with no impact velocity must never crush it:
|
||||||
|
// the crush gate is velocity-based (boxAlong > 30), so a box that is merely
|
||||||
|
// sitting there (boxAlong ~ 0) deals nothing. Guards the per-frame
|
||||||
|
// machine-gun failure mode from the other direction.
|
||||||
|
const g = game({ blocks: [{ x: 1200, y: 832, w: 60, h: 40, mat: 'wood', hp: 1300, maxHp: 1300, vx: 0, vy: 0, rest: 1, ang: 0, angVel: 0, pivoting: false, mass: 1.0 * 60 * 40 / 1400 }],
|
||||||
|
enemies: [{ x: 1200, y: 876, kind: 'mogg' }] });
|
||||||
|
step(g, 4.0);
|
||||||
|
const e = g.enemies[0];
|
||||||
|
check('negative: a block RESTING on the mogg (no impact velocity) never crushes it', e.hp === 1 && !e.dead,
|
||||||
|
'mogg hp=' + e.hp);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('== ' + passed + ' passed, ' + failed + ' failed ==');
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
62
test/knifeedge.test.mjs
Normal file
62
test/knifeedge.test.mjs
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
// test/knifeedge.test.mjs -- RC-375D84 S-3 regression: knife-edge overhang.
|
||||||
|
// A board whose COM is a hair (5px) past the support edge, with the pivot too
|
||||||
|
// high for either end to reach the ground, is a pendulum. It must tip ONCE,
|
||||||
|
// swing back, and REST (damped resolution) -- it must not re-tip in a
|
||||||
|
// livelock loop. This regression was found on real level L3 during the golden
|
||||||
|
// audit: 14 re-tips in 0.5s (gaps 0.01-0.04s) at one position; the v1
|
||||||
|
// anti-livelock flag was defeated by a velocity re-arm (the pendulum's own
|
||||||
|
// swing-back speed cleared the flag), so the board re-tipped forever.
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const ISLE = require('../js/physics/index.js');
|
||||||
|
|
||||||
|
const DT = 1 / 120;
|
||||||
|
let passed = 0, failed = 0;
|
||||||
|
function check(name, cond, detail) {
|
||||||
|
if (cond) { passed++; console.log(' PASS ' + name); }
|
||||||
|
else { failed++; console.log(' FAIL ' + name + (detail ? ' -- ' + detail : '')); }
|
||||||
|
}
|
||||||
|
function game(def) {
|
||||||
|
const lvl = Object.assign({ name: 'knife-test', sling: { x: 260, y: 560 }, birds: [], blocks: [], enemies: [] }, def);
|
||||||
|
return ISLE.createGame(lvl);
|
||||||
|
}
|
||||||
|
// pillar top y=700, right edge x=1100 (pillar 200x200 @1000,800).
|
||||||
|
// Board 300x30 center (1105,685): COM at x=1105, only 5px past the pivot edge
|
||||||
|
// x=1100. Far corner radius ~158 < 200 (pivot-to-ground) -> the ends can never
|
||||||
|
// reach the ground -> pendulum case (settle-back, not a ground landing).
|
||||||
|
const PILLAR = { x: 1000, y: 800, w: 200, h: 200, mat: 'stone' };
|
||||||
|
const KNIFE = { x: 1105, y: 685, w: 300, h: 30, mat: 'wood' };
|
||||||
|
|
||||||
|
const g = game({ blocks: [PILLAR, KNIFE] });
|
||||||
|
const board = g.blocks[1];
|
||||||
|
let tips = 0;
|
||||||
|
const origPush = g.events.push.bind(g.events);
|
||||||
|
g.events.push = function (ev) { if (ev.type === 'topple_start') tips++; return origPush(ev); };
|
||||||
|
|
||||||
|
// run 10s (covers ~9 pendulum periods; a livelock would re-tip every period)
|
||||||
|
const N = 10 / DT;
|
||||||
|
for (let i = 0; i < N; i++) ISLE.step(g, DT);
|
||||||
|
|
||||||
|
check('knife-edge board tips at all (COM 5px past edge)', tips >= 1, 'tips=' + tips);
|
||||||
|
check('NO livelock: a pendulum overhang tips at most ONCE in 10s', tips <= 2, 'tips=' + tips);
|
||||||
|
check('board ends at rest (not pivoting)', board.pivoting === false, 'pivoting=' + board.pivoting);
|
||||||
|
check('board rests flat after settle-back', Math.abs(board.ang || 0) < 0.02, 'ang=' + (board.ang * 180 / Math.PI).toFixed(1) + 'deg');
|
||||||
|
check('board snapped back to its rest position (no drift)', Math.abs(board.x - 1105) < 0.5 && Math.abs(board.y - 685) < 0.5, 'x=' + board.x.toFixed(2) + ' y=' + board.y.toFixed(2));
|
||||||
|
|
||||||
|
// a REAL knock (displacement >= 6px, COM pushed further past the edge) must
|
||||||
|
// re-arm the board to tip again
|
||||||
|
{
|
||||||
|
let tips2 = 0;
|
||||||
|
const g2 = game({ blocks: [PILLAR, KNIFE] });
|
||||||
|
const b2 = g2.blocks[1];
|
||||||
|
const push2 = g2.events.push.bind(g2.events);
|
||||||
|
g2.events.push = function (ev) { if (ev.type === 'topple_start') tips2++; return push2(ev); };
|
||||||
|
for (let i = 0; i < 2 / DT; i++) ISLE.step(g2, DT); // settle: tipped once, swung back, resting
|
||||||
|
// knock: shove it 20px PAST the edge (COM 1105 -> 1125, well outside span [1000,1100])
|
||||||
|
b2.x += 20; b2.vx = 60;
|
||||||
|
for (let i = 0; i < 3 / DT; i++) ISLE.step(g2, DT);
|
||||||
|
check('a real knock (past the edge) re-arms a settled board to tip again', tips2 >= 2, 'tips=' + tips2);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('== ' + passed + ' passed, ' + failed + ' failed ==');
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
151
test/landedbird.test.mjs
Normal file
151
test/landedbird.test.mjs
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
// test/landedbird.test.mjs -- RC-375D84 S-5: landed bird remains a physical body.
|
||||||
|
// Commander (verbatim): "When a board is broken from underneath a bird it just
|
||||||
|
// floats. Gravity needs to work for the birds as well so if the platform falls
|
||||||
|
// from underneath them they can fall causing more damage to any boards that they
|
||||||
|
// fall on like a chain reaction and the birds can also kill more enemy's that way
|
||||||
|
// as well. That is AFTER a bird has been shot from a slingshot and another bird is
|
||||||
|
// already active."
|
||||||
|
// Q2 (Commander ruling): REAL CONTINUOUS GRAVITY, no fixed lifetime timer -- the
|
||||||
|
// bird is a body as long as it exists.
|
||||||
|
//
|
||||||
|
// The pre-S-5 defect: spendBird() marked a settled bird 'spent' and zeroed its
|
||||||
|
// velocity; stepPhysics only integrated the ACTIVE bird, so a settled bird was a
|
||||||
|
// visual corpse -- when its platform broke it FLOATED in place (never fell).
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const ISLE = require('../js/physics/index.js');
|
||||||
|
|
||||||
|
const DT = 1 / 120;
|
||||||
|
let passed = 0, failed = 0;
|
||||||
|
function check(name, cond, detail) {
|
||||||
|
if (cond) { passed++; console.log(' PASS ' + name); }
|
||||||
|
else { failed++; console.log(' FAIL ' + name + (detail ? ' -- ' + detail : '')); }
|
||||||
|
}
|
||||||
|
function game(def) {
|
||||||
|
const lvl = Object.assign({ name: 'lb-test', sling: { x: 260, y: 560 }, birds: [], blocks: [], enemies: [] }, def);
|
||||||
|
return ISLE.createGame(lvl);
|
||||||
|
}
|
||||||
|
function step(g, secs) {
|
||||||
|
const n = Math.round(secs / DT);
|
||||||
|
for (let i = 0; i < n; i++) ISLE.step(g, DT);
|
||||||
|
}
|
||||||
|
// helper: put a bird in a settled/landed state on a board, with a queued second
|
||||||
|
// bird active (the "another bird is already active" window).
|
||||||
|
function setupPerched() {
|
||||||
|
// board 200x30 at (1200,765): x 1100..1300, top 750.
|
||||||
|
const g = game({
|
||||||
|
blocks: [{ x: 1200, y: 765, w: 200, h: 30, mat: 'wood' }],
|
||||||
|
birds: ['wren', 'wren'], // [0] will be perched, [1] the 'already active' next bird
|
||||||
|
});
|
||||||
|
// bird 0: perched on the board, settled (no velocity).
|
||||||
|
const b0 = g.birds[0];
|
||||||
|
b0.state = 'landed';
|
||||||
|
b0.x = 1200; b0.y = 750 - b0.r; // resting on the board top
|
||||||
|
b0.vx = 0; b0.vy = 0;
|
||||||
|
// bird 1: active, aiming on the sling.
|
||||||
|
g.activeBird = 1;
|
||||||
|
g.birds[1].state = 'aiming';
|
||||||
|
g.birds[1].x = g.level.sling.x; g.birds[1].y = g.level.sling.y;
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
// arm 1 (THE S-5 behaviour): the perched bird FALLS when its board breaks.
|
||||||
|
// The board rests on two stone pillars (so the platform is stable); the bird
|
||||||
|
// is perched on the board. A bird that is a corpse (pre-S-5) would stay at its
|
||||||
|
// perch forever while the board falls away. The landed bird must drop toward
|
||||||
|
// the ground (y rises well past the perch).
|
||||||
|
{
|
||||||
|
const g = game({
|
||||||
|
blocks: [
|
||||||
|
{ x: 1120, y: 840, w: 40, h: 120, mat: 'stone' }, // pillar L (top 780)
|
||||||
|
{ x: 1280, y: 840, w: 40, h: 120, mat: 'stone' }, // pillar R (top 780)
|
||||||
|
{ x: 1200, y: 765, w: 200, h: 30, mat: 'wood' } // board 750..780 on the pillars
|
||||||
|
],
|
||||||
|
birds: ['wren', 'wren']
|
||||||
|
});
|
||||||
|
const b0 = g.birds[0];
|
||||||
|
b0.state = 'landed';
|
||||||
|
b0.x = 1200; b0.y = 750 - b0.r;
|
||||||
|
b0.vx = 0; b0.vy = 0;
|
||||||
|
g.activeBird = 1; g.birds[1].state = 'aiming';
|
||||||
|
g.birds[1].x = g.level.sling.x; g.birds[1].y = g.level.sling.y;
|
||||||
|
const board = g.blocks[2];
|
||||||
|
step(g, 0.5);
|
||||||
|
const yIntact = b0.y;
|
||||||
|
board.hp = 0; // break the board out from under the bird
|
||||||
|
step(g, 2.0);
|
||||||
|
check('perched bird FALLS when its platform breaks (Q2 real gravity)', b0.y > yIntact + 80,
|
||||||
|
'y intact=' + yIntact.toFixed(0) + ' -> after break=' + b0.y.toFixed(0) + ' (ground=' + ISLE.WORLD.ground + ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
// arm 2: a landed bird on a stable board (pillars + board) does NOT drift.
|
||||||
|
{
|
||||||
|
const g = game({
|
||||||
|
blocks: [
|
||||||
|
{ x: 1120, y: 840, w: 40, h: 120, mat: 'stone' },
|
||||||
|
{ x: 1280, y: 840, w: 40, h: 120, mat: 'stone' },
|
||||||
|
{ x: 1200, y: 765, w: 200, h: 30, mat: 'wood' }
|
||||||
|
],
|
||||||
|
birds: ['wren', 'wren']
|
||||||
|
});
|
||||||
|
const b0 = g.birds[0];
|
||||||
|
b0.state = 'landed';
|
||||||
|
b0.x = 1200; b0.y = 750 - b0.r;
|
||||||
|
b0.vx = 0; b0.vy = 0;
|
||||||
|
g.activeBird = 1; g.birds[1].state = 'aiming';
|
||||||
|
g.birds[1].x = g.level.sling.x; g.birds[1].y = g.level.sling.y;
|
||||||
|
const x0 = b0.x, y0 = b0.y;
|
||||||
|
step(g, 2.0);
|
||||||
|
check('landed bird on an intact board stays put (no drift, no float-away)', Math.abs(b0.x - x0) < 2 && Math.abs(b0.y - y0) < 3,
|
||||||
|
'x ' + x0.toFixed(1) + '->' + b0.x.toFixed(1) + ' y ' + y0.toFixed(1) + '->' + b0.y.toFixed(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// arm 3 (chain reaction): the falling bird damages the board it lands on.
|
||||||
|
// Bird perched high, board beneath it breaks, a lower board catches it -- the
|
||||||
|
// lower board must take impact damage.
|
||||||
|
{
|
||||||
|
const g = game({
|
||||||
|
blocks: [
|
||||||
|
{ x: 1200, y: 600, w: 120, h: 30, mat: 'ice' }, // upper perch (breaks)
|
||||||
|
{ x: 1200, y: 880, w: 200, h: 40, mat: 'ice' } // lower catcher
|
||||||
|
],
|
||||||
|
birds: ['wren', 'wren']
|
||||||
|
});
|
||||||
|
const b0 = g.birds[0];
|
||||||
|
b0.state = 'landed';
|
||||||
|
b0.x = 1200; b0.y = 585 - b0.r; // on the upper perch top (585)
|
||||||
|
b0.vx = 0; b0.vy = 0;
|
||||||
|
g.activeBird = 1;
|
||||||
|
g.birds[1].state = 'aiming';
|
||||||
|
g.birds[1].x = g.level.sling.x; g.birds[1].y = g.level.sling.y;
|
||||||
|
const catcher = g.blocks[1];
|
||||||
|
const catcherHp0 = catcher.hp;
|
||||||
|
g.blocks[0].hp = 0; // break the perch
|
||||||
|
step(g, 2.0);
|
||||||
|
check('falling bird damages the board it lands on (chain reaction)', catcher.hp < catcherHp0,
|
||||||
|
'catcher hp ' + catcherHp0 + ' -> ' + catcher.hp.toFixed(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// arm 4 (kill): the falling bird can kill an enemy in its fall path.
|
||||||
|
{
|
||||||
|
const g = game({
|
||||||
|
blocks: [{ x: 1200, y: 600, w: 120, h: 30, mat: 'ice' }],
|
||||||
|
enemies: [{ x: 1200, y: 876, kind: 'mogg' }],
|
||||||
|
birds: ['wren', 'wren']
|
||||||
|
});
|
||||||
|
const b0 = g.birds[0];
|
||||||
|
b0.state = 'landed';
|
||||||
|
b0.x = 1200; b0.y = 585 - b0.r;
|
||||||
|
b0.vx = 0; b0.vy = 0;
|
||||||
|
g.activeBird = 1;
|
||||||
|
g.birds[1].state = 'aiming';
|
||||||
|
g.birds[1].x = g.level.sling.x; g.birds[1].y = g.level.sling.y;
|
||||||
|
const e = g.enemies[0];
|
||||||
|
g.blocks[0].hp = 0; // break the perch -> bird drops onto the mogg
|
||||||
|
step(g, 2.5);
|
||||||
|
check('falling bird KILLS an enemy in its fall path', e.dead,
|
||||||
|
'mogg hp=' + e.hp + ' dead=' + e.dead + ' birdY=' + b0.y.toFixed(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('== ' + passed + ' passed, ' + failed + ' failed ==');
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
|
|
@ -145,7 +145,18 @@ console.log('\n== ISLE SLINGERS page boot (DOM stub) ==');
|
||||||
|
|
||||||
// ------------------------------------------------------------------- tests
|
// ------------------------------------------------------------------- tests
|
||||||
test('page loads: all five modules execute without throwing (browser order)', () => {
|
test('page loads: all five modules execute without throwing (browser order)', () => {
|
||||||
for (const f of ['js/core.js', 'js/levels.js', 'js/audio.js', 'js/renderer.js', 'js/main.js']) {
|
// RC-375D84 S-2: modularized tree -- same browser order as index.html
|
||||||
|
const FILES = [
|
||||||
|
'js/physics/constants/index.js', 'js/physics/state/index.js', 'js/physics/aim/index.js',
|
||||||
|
'js/physics/abilities/index.js', 'js/physics/damage/index.js', 'js/physics/step/index.js',
|
||||||
|
'js/physics/collision/index.js', 'js/physics/topple/index.js', 'js/physics/rules/index.js', 'js/physics/camera/index.js',
|
||||||
|
'js/physics/index.js',
|
||||||
|
'js/levels/helpers/index.js',
|
||||||
|
'js/levels/data/level-1.js', 'js/levels/data/level-2.js', 'js/levels/data/level-3.js',
|
||||||
|
'js/levels/data/level-4.js', 'js/levels/data/level-5.js', 'js/levels/index.js',
|
||||||
|
'js/audio/index.js', 'js/render/index.js', 'js/app/index.js'
|
||||||
|
];
|
||||||
|
for (const f of FILES) {
|
||||||
try {
|
try {
|
||||||
load(f);
|
load(f);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,31 @@
|
||||||
* numeric stability, trajectory-preview fidelity, ability behaviour,
|
* numeric stability, trajectory-preview fidelity, ability behaviour,
|
||||||
* win/lose rules, and that every level is clearable by a perfect archer.
|
* win/lose rules, and that every level is clearable by a perfect archer.
|
||||||
*/
|
*/
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import ISLE from '../js/core.js';
|
import ISLE from '../js/physics/index.js'; // RC-375D84 S-2: physics module aggregator (was js/core.js)
|
||||||
import levelsModule from '../js/levels.js';
|
import levelsModule from '../js/levels/index.js'; // RC-375D84 S-2: levels module aggregator (was js/levels.js)
|
||||||
const { LEVELS } = levelsModule;
|
const { LEVELS } = levelsModule;
|
||||||
|
|
||||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// RC-375D84 S-2: recursive list of every .js under js/ (the modularized tree).
|
||||||
|
// Replaces the old hardcoded [core,levels,audio,renderer,main] file list so
|
||||||
|
// the syntax + DOM-wiring arms scan the real submodules, not orphaned monoliths.
|
||||||
|
function listJs(rootRel) {
|
||||||
|
const base = join(HERE, '..', rootRel);
|
||||||
|
const out = [];
|
||||||
|
(function walk(d) {
|
||||||
|
for (const name of readdirSync(d)) {
|
||||||
|
const fp = join(d, name);
|
||||||
|
if (statSync(fp).isDirectory()) walk(fp);
|
||||||
|
else if (name.endsWith('.js')) out.push(fp);
|
||||||
|
}
|
||||||
|
})(base);
|
||||||
|
return out.sort();
|
||||||
|
}
|
||||||
|
const JS_FILES = listJs('js');
|
||||||
let passed = 0, failed = 0;
|
let passed = 0, failed = 0;
|
||||||
const failures = [];
|
const failures = [];
|
||||||
|
|
||||||
|
|
@ -176,10 +193,16 @@ console.log('\n== ISLE SLINGERS headless sim ==');
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 1. syntax
|
// ------------------------------------------------------------------ 1. syntax
|
||||||
test('all JS files parse', () => {
|
test('all JS files parse', () => {
|
||||||
for (const f of ['core.js', 'levels.js', 'audio.js', 'renderer.js', 'main.js']) {
|
const files = JS_FILES.map((fp) => fp.slice(fp.indexOf('js/')));
|
||||||
const src = readFileSync(join(HERE, '..', 'js', f), 'utf8');
|
ok(files.length >= 15, 'S-2: expected >=15 js files in the modularized tree, got ' + files.length);
|
||||||
|
for (const fp of files) {
|
||||||
|
const src = readFileSync(join(HERE, '..', fp), 'utf8');
|
||||||
new Function(src); // throws on syntax error
|
new Function(src); // throws on syntax error
|
||||||
ok(src.length > 500, f + ' suspiciously small');
|
}
|
||||||
|
// every submodule that carries real logic must be more than a stub
|
||||||
|
for (const fp of files.filter((f) => /physics\/(state|step|collision)\//.test(f) || /physics\/index/.test(f))) {
|
||||||
|
const src = readFileSync(join(HERE, '..', fp), 'utf8');
|
||||||
|
ok(src.length > 800, fp + ' suspiciously small');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -194,13 +217,13 @@ test('DOM wiring: every id referenced by js/ exists in index.html', () => {
|
||||||
let m;
|
let m;
|
||||||
const idRe = /id="([^"]+)"/g;
|
const idRe = /id="([^"]+)"/g;
|
||||||
while ((m = idRe.exec(html))) ids.add(m[1]);
|
while ((m = idRe.exec(html))) ids.add(m[1]);
|
||||||
const files = ['core.js', 'levels.js', 'audio.js', 'renderer.js', 'main.js'];
|
|
||||||
const refs = [];
|
const refs = [];
|
||||||
const refRe = /getElementById\(\s*['"]([^'"]+)['"]\s*\)|querySelector(?:All)?\(\s*['"]#([A-Za-z0-9_-]+)['"]\s*\)/g;
|
const refRe = /getElementById\(\s*['"]([^'"]+)['"]\s*\)|querySelector(?:All)?\(\s*['"]#([A-Za-z0-9_-]+)['"]\s*\)/g;
|
||||||
for (const f of files) {
|
for (const fp of JS_FILES) {
|
||||||
const src = readFileSync(join(HERE, '..', 'js', f), 'utf8');
|
const rel = fp.slice(fp.indexOf('js/'));
|
||||||
|
const src = readFileSync(fp, 'utf8');
|
||||||
let r;
|
let r;
|
||||||
while ((r = refRe.exec(src))) refs.push(f + '->' + (r[1] || r[2]));
|
while ((r = refRe.exec(src))) refs.push(rel + '->' + (r[1] || r[2]));
|
||||||
}
|
}
|
||||||
ok(refs.length > 10, 'no DOM references found — scan pattern is broken');
|
ok(refs.length > 10, 'no DOM references found — scan pattern is broken');
|
||||||
const missing = [...new Set(refs.filter((rf) => !ids.has(rf.split('->')[1])))].sort();
|
const missing = [...new Set(refs.filter((rf) => !ids.has(rf.split('->')[1])))].sort();
|
||||||
|
|
|
||||||
96
test/topple.test.mjs
Normal file
96
test/topple.test.mjs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
// test/topple.test.mjs -- RC-375D84 S-3: board tip/topple physics
|
||||||
|
// Q1 (Commander ruling, RC-375D84 turn 2): FULL CONTINUOUS ROTATION -- boards
|
||||||
|
// rotate smoothly through the fall (no discrete settle-then-snap); COM past
|
||||||
|
// the support edge triggers the tip; the tip lands with impact damage and
|
||||||
|
// falling objects can kill enemies.
|
||||||
|
//
|
||||||
|
// Every arm was proven RED on the pre-S-3 tree: blocks carried no rotation
|
||||||
|
// degree of freedom, so nothing could ever tip.
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const ISLE = require('../js/physics/index.js');
|
||||||
|
|
||||||
|
const DT = 1 / 120;
|
||||||
|
let passed = 0, failed = 0;
|
||||||
|
function check(name, cond, detail) {
|
||||||
|
if (cond) { passed++; console.log(' PASS ' + name); }
|
||||||
|
else { failed++; console.log(' FAIL ' + name + (detail ? ' -- ' + detail : '')); }
|
||||||
|
}
|
||||||
|
function step(g, secs) {
|
||||||
|
const n = Math.round(secs / DT);
|
||||||
|
for (let i = 0; i < n; i++) ISLE.step(g, DT);
|
||||||
|
}
|
||||||
|
// Real createGame, custom level definition (no birds needed for these arms).
|
||||||
|
function game(def) {
|
||||||
|
const lvl = Object.assign({ name: 'topple-test', sling: { x: 260, y: 560 }, birds: [], blocks: [], enemies: [] }, def);
|
||||||
|
return ISLE.createGame(lvl);
|
||||||
|
}
|
||||||
|
// Geometry (verified against the pendulum prototype):
|
||||||
|
// ground y=900. Stone pillar 200x200 at (1000,800): x 900..1100, top y=700.
|
||||||
|
// Wood board 300x30 at (1200,685): x 1050..1350, COM at x=1200 -- 100px
|
||||||
|
// PAST the pivot edge (pillar top-right corner x=1100, y=700).
|
||||||
|
const PILLAR = { x: 1000, y: 800, w: 200, h: 200, mat: 'stone' };
|
||||||
|
const BOARD_OVER = { x: 1200, y: 685, w: 300, h: 30, mat: 'wood' };
|
||||||
|
const BOARD_CENTERED = { x: 1000, y: 685, w: 300, h: 30, mat: 'wood' }; // COM at x=1000, inside span 900..1100
|
||||||
|
// impact victim: ice 200x30 at (1250,885) -- under the board's fall path
|
||||||
|
const VICTIM = { x: 1250, y: 885, w: 200, h: 30, mat: 'ice' };
|
||||||
|
|
||||||
|
console.log('== ISLE SLINGERS topple (S-3) ==');
|
||||||
|
|
||||||
|
// ---- arm 1: COM past the support edge -> the board tips end-over-end ----
|
||||||
|
{
|
||||||
|
const g = game({ blocks: [PILLAR, BOARD_OVER] });
|
||||||
|
const board = g.blocks[1];
|
||||||
|
step(g, 2.0);
|
||||||
|
const ang = board.ang || 0;
|
||||||
|
check('overhanging board tips (COM past support edge)', ang > 0.5,
|
||||||
|
'ang=' + ang + 'rad (' + (ang * 180 / Math.PI).toFixed(1) + 'deg)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- arm 2: rotation is CONTINUOUS (Q1) -- smooth swing, intermediate
|
||||||
|
// angles, no discrete settle-then-snap ----
|
||||||
|
{
|
||||||
|
const g = game({ blocks: [PILLAR, BOARD_OVER] });
|
||||||
|
const board = g.blocks[1];
|
||||||
|
const samples = [];
|
||||||
|
for (let i = 0; i < 96; i++) { ISLE.step(g, DT); samples.push(board.ang || 0); }
|
||||||
|
let maxDelta = 0;
|
||||||
|
for (let i = 1; i < samples.length; i++) maxDelta = Math.max(maxDelta, Math.abs(samples[i] - samples[i - 1]));
|
||||||
|
const finalAng = samples[samples.length - 1];
|
||||||
|
const sawIntermediate = samples.some(a => a > 0.1 && a < finalAng - 0.1);
|
||||||
|
check('rotation is continuous (smooth swing, intermediate angles)', sawIntermediate && maxDelta < 0.05,
|
||||||
|
'samples(0.8s, every 4th)=' + samples.filter((_, i) => i % 4 === 0).map(a => a.toFixed(3)).join(',') + ' maxStep=' + maxDelta.toFixed(4) + ' final=' + finalAng.toFixed(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- arm 3: a fully-supported board does NOT tip (negative control) ----
|
||||||
|
{
|
||||||
|
const g = game({ blocks: [PILLAR, BOARD_CENTERED] });
|
||||||
|
const board = g.blocks[1];
|
||||||
|
step(g, 2.0);
|
||||||
|
check('fully-supported board stays put (no tip)', (board.ang || 0) === 0 && (board.vy || 0) === 0,
|
||||||
|
'ang=' + board.ang + ' vy=' + board.vy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- arm 4: the tip lands with IMPACT that damages a block below ----
|
||||||
|
{
|
||||||
|
const g = game({ blocks: [PILLAR, BOARD_OVER, VICTIM] });
|
||||||
|
const victim = g.blocks[2];
|
||||||
|
step(g, 3.0);
|
||||||
|
check('tipping board impact damages a block in its fall path', victim.hp < ISLE.MATERIALS.ice.hp,
|
||||||
|
'victim hp=' + victim.hp + ' (full=' + ISLE.MATERIALS.ice.hp + ') boardAng=' + ((g.blocks[1].ang || 0) * 180 / Math.PI).toFixed(1) + 'deg');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- arm 5: a tipping board KILLS an enemy in its fall path (objective (b)) ----
|
||||||
|
{
|
||||||
|
const g = game({
|
||||||
|
blocks: [PILLAR, BOARD_OVER],
|
||||||
|
enemies: [{ x: 1250, y: 876, kind: 'mogg' }], // r=24, stands under the fall path
|
||||||
|
});
|
||||||
|
const e = g.enemies[0];
|
||||||
|
step(g, 3.0);
|
||||||
|
check('falling board kills an enemy in its fall path', e.dead,
|
||||||
|
'enemy hp=' + e.hp + ' dead=' + e.dead + ' boardAng=' + ((g.blocks[1].ang || 0) * 180 / Math.PI).toFixed(1) + 'deg');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('== ' + passed + ' passed, ' + failed + ' failed ==');
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
Loading…
Reference in a new issue