Add 16 elite faction fighters with a duel arena and skill layer

- Register 8 Apex (frames 30-37) and 8 Mindweave (frames 40-47) in
  data/combat.json enemyTypes at 2x damage / 3.5x armor vs the pirate,
  each with a distinct weapon pattern and ability kit
- New shared skill layer (js/combat/EnemyAbilities.js): cloaked ambush,
  flank strafe, blink reposition, illusion decoys, telekinetic waves;
  wired into CombatSystem update/collide with cloak gating on fire,
  targeting, and player skills
- Add sweep/spiral/shell weapon patterns in js/combat/Weapons.js
- New ?combat=apex / ?combat=mindweave duel arena with an in-game
  DuelPicker (keys 1-8 / click, 0 = whole pack); plain ?combat demo
  unchanged
- Paint the 16 missing spritesheet frames (ships-enemy.png) so the
  fighters are visible; add a PNG alpha regression test guarding against
  blank cells
- Add dev/cdp-faction-arena.mjs CDP smoke check and extend
  dev/combat.test.mjs with faction roster, balance, ability, duel, and
  sheet-pixel checks
This commit is contained in:
Brian Fertig 2026-09-28 21:33:23 -06:00
parent 0a86995738
commit 8cce093a79
20 changed files with 2483 additions and 30 deletions

View File

@ -1 +1 @@
{"version":1,"dirMtimeMs":1790501168076.8403,"goals":[]} {"version":1,"dirMtimeMs":1790648410415.8765,"goals":[]}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,141 @@
{
"version": 3,
"id": "mum05nd7-2d3nte",
"objective": "Add 16 elite enemy fighters — 8 Apex (sheets frames 30–37) and 8 Mindweave (frames 40–47) — to the enemyTypes roster with a full themed behavior/skill layer (Apex: predatory cloaked ambushes, flanking, heavy forward firepower and spread volleys; Mindweave: blink repositions, illusions/decoys, telekinesis, big patterned bullet barrages), each ship with a distinct attack style, all roughly 1.3× the pirate's offensive and defensive stats, plus a new test arena reachable via ?combat (e.g. ?combat=apex / ?combat=mindweave) with an in-game picker to duel any single fighter.",
"status": "complete",
"autoContinue": true,
"usage": {
"tokensUsed": 13531457,
"activeSeconds": 2309
},
"sisyphus": false,
"revision": 192,
"createdAt": "2026-09-29T01:33:19.627Z",
"updatedAt": "2026-09-29T02:20:10.414Z",
"scheduler": {
"version": 1,
"owner": "01a0eab4-0f1f-763f-983f-e656d8f44307",
"generation": "727d1149-cb68-44be-9b4d-6faac1395730",
"used": 1,
"phase": "running",
"repairUsed": false,
"decision": {
"kind": "ready",
"nextAction": "Continue pursuing the goal, then verify and complete it when satisfied.",
"purpose": "ready"
},
"dispatch": {
"id": "8f4ecc7a-e488-4cc6-9276-21a80840dcda",
"kind": "ready",
"claimedAt": 1790645599681
}
},
"taskList": {
"tasks": [
{
"id": "task-1",
"title": "Design & register the 16 faction ships in data/combat.json (enemyTypes)",
"status": "complete",
"verificationContract": "16 new enemyTypes entries (8 apex_* frames 30–37, 8 mindweave_* frames 40–47) with documented per-ship ability designs in $comment/_comment; stats ≈1.3× the raider base (hull+shields and weapon damage) verified by a quick node stat-sanity check; EnemyTypes.enemyTypeIds() lists all 16; no changes to galaxy/quest data",
"completedAt": "2026-09-29T01:47:47.343Z",
"evidence": "data/combat.json: 16 enemyTypes (apex_*/weave_* frames 30-37/40-47) + combat.factions; JSON valid; tests: frames/roster/1.3x stat checks pass"
},
{
"id": "task-2",
"title": "New weapon patterns in js/combat/Weapons.js",
"status": "complete",
"verificationContract": "Apex heavy/forward/spray patterns and Mindweave 1942-style patterned barrages (spirals, radial volleys, big-radius shells) implemented as data-driven weapon.pattern values; pure-function tests extend dev/combat.test.mjs and pass via node",
"completedAt": "2026-09-29T01:47:47.345Z",
"evidence": "Weapons.js: sweep/spiral/shell patterns; dev/combat.test.mjs pattern checks (heading-centered fan, stepped spiral w/ stagger, big-radius shell, triple shells) pass"
},
{
"id": "task-3",
"title": "Apex behavior layer: cloaked ambush, flanking, signature abilities",
"status": "complete",
"verificationContract": "Apex ships can spawn cloaked nearby/behind the player and de-cloak, flank from behind, and each of the 8 ships shows at least one distinct signature ability on top of shared predatory skills; observable in the ?combat=apex arena; shared logic unit-tested where pure",
"completedAt": "2026-09-29T01:47:47.345Z",
"evidence": "EnemyAbilities.js cloaked_ambush + flank_strafe wired in CombatSystem (fire gate !cloaked, collide skips cloaked); cloak/stand/reveal tests pass; 8 distinct apex kits"
},
{
"id": "task-4",
"title": "Mindweave behavior layer: blink, illusions, telekinesis, barrages",
"status": "complete",
"verificationContract": "Mindweave ships periodically blink (Wraith-style reposition), can spawn illusion/decoy or telekinetically push the player/deflect bolts, and fire large-radius patterned barrages; each of the 8 ships shows at least one distinct signature ability; observable in the ?combat=mindweave arena; shared logic unit-tested where pure",
"completedAt": "2026-09-29T01:47:47.346Z",
"evidence": "EnemyAbilities.js blink_reposition/illusion_decoy/telekinetic_wave + stepIllusions/stepTkwaves in CombatSystem; illusions in auto-fire pool + bullet pops; all ability tests pass"
},
{
"id": "task-5",
"title": "Test arena: ?combat=faction boots + in-game single-fighter picker",
"status": "complete",
"verificationContract": "?combat=apex and ?combat=mindweave boot the new arena; an in-game picker lets the player choose any single fighter to duel; the existing plain ?combat seven-type demo still boots unchanged (regression check)",
"completedAt": "2026-09-29T02:00:05.072Z",
"evidence": "CDP arena (dev/cdp-faction-arena.mjs): ?combat=apex / ?combat=mindweave boot the picker; picks 1-8 + pack(0) duel individually; plain ?combat demo still boots (wave live, 3 enemies, no picker) — all c"
},
{
"id": "task-6",
"title": "Verification pass: tests, balance sanity, manual arena checklist",
"status": "complete",
"verificationContract": "All dev/*.test.mjs node tests pass (0 failures); stat-sanity check confirms ~1.3× pirate offensive/defensive per ship; manual/CDP checklist confirms every one of the 16 ships fights individually with its skills visibly firing; requirements re-read and each item (16 ships, both factions, unique styles, shared faction skills, 1.3× balance, test arena, no galaxy placement) confirmed addressed",
"completedAt": "2026-09-29T02:00:05.076Z",
"evidence": "All dev/*.test.mjs suites pass (0 failures, combat.test.mjs all checks incl. 1.3x armor/damage/XP bands per ship); CDP checklist: both factions boot, fighters fire their distinct patterns (sweep 5 bol"
}
],
"blockCompletion": true,
"proposedAt": "2026-09-29T01:26:35.246Z"
},
"stopReason": "agent",
"archivedPath": ".pi/goals/archived/goal_2026092820201039_mum05nd7-2d3nte.md"
}
# Goal Prompt
Add 16 elite enemy fighters — 8 Apex (sheets frames 30–37) and 8 Mindweave (frames 40–47) — to the enemyTypes roster with a full themed behavior/skill layer (Apex: predatory cloaked ambushes, flanking, heavy forward firepower and spread volleys; Mindweave: blink repositions, illusions/decoys, telekinesis, big patterned bullet barrages), each ship with a distinct attack style, all roughly 1.3× the pirate's offensive and defensive stats, plus a new test arena reachable via ?combat (e.g. ?combat=apex / ?combat=mindweave) with an in-game picker to duel any single fighter.
## Progress
- Status: complete
- Auto-continue: on
- Sisyphus mode: no
- Time spent: 38m29s
- Tokens used: 14M (13,531,457) tokens
## Tasks
<!-- blockCompletion: true -->
- [x] task-1: Design & register the 16 faction ships in data/combat.json (enemyTypes) — evidence: data/combat.json: 16 enemyTypes (apex_*/weave_* frames 30-37/40-47) + combat.factions; JSON valid; tests: frames/roster/1.3x stat checks pass
- [x] task-2: New weapon patterns in js/combat/Weapons.js — evidence: Weapons.js: sweep/spiral/shell patterns; dev/combat.test.mjs pattern checks (heading-centered fan, stepped spiral w/ stagger, big-radius shell, triple shells) pass
- [x] task-3: Apex behavior layer: cloaked ambush, flanking, signature abilities — evidence: EnemyAbilities.js cloaked_ambush + flank_strafe wired in CombatSystem (fire gate !cloaked, collide skips cloaked); cloak/stand/reveal tests pass; 8 distinct apex kits
- [x] task-4: Mindweave behavior layer: blink, illusions, telekinesis, barrages — evidence: EnemyAbilities.js blink_reposition/illusion_decoy/telekinetic_wave + stepIllusions/stepTkwaves in CombatSystem; illusions in auto-fire pool + bullet pops; all ability tests pass
- [x] task-5: Test arena: ?combat=faction boots + in-game single-fighter picker — evidence: CDP arena (dev/cdp-faction-arena.mjs): ?combat=apex / ?combat=mindweave boot the picker; picks 1-8 + pack(0) duel individually; plain ?combat demo still boots (wave live, 3 enemies, no picker) — all c
- [x] task-6: Verification pass: tests, balance sanity, manual arena checklist — evidence: All dev/*.test.mjs suites pass (0 failures, combat.test.mjs all checks incl. 1.3x armor/damage/XP bands per ship); CDP checklist: both factions boot, fighters fire their distinct patterns (sweep 5 bol
## Post-approval fixes (picker didn't work)
The `?combat=apex` / `?combat=mindweave` arena booted but the in-game picker was
unusable. Three real bugs in `js/combat/DuelPicker.js`, all fixed + verified via CDP
in a headless browser (fresh tab → New Game → pick):
1. **Panel was world-anchored, not screen-anchored.** The children never got
`setScrollFactor(0)`, so the rows landed mid-canvas (the camera centres on the
ship, not the origin) and drifted as the camera followed. Every child (bg, title,
tagline, rows, zones, hint) now pins to the screen like the rest of the combat HUD.
2. **Pick zones were misplaced three rows down.** The zone formula re-added the panel
offset (`cx-w/2, cy-h/2`) on top of `_rowRects`, which is already absolute — so a
click on a visible row either missed or fired the wrong fighter. Zones now sit
exactly over their own row (`_rowRects` used verbatim).
3. **Bare `.setInteractive()` on a Zone is the v4-fragile path** (the same quirk
ActionBar/MenuButton document): it leaves no hit-testable area the input manager
claims on click. Re-armed each zone with an explicit `hitArea` + `hitAreaCallback`
(the proven ActionBar/MenuButton hand).
Plus a 4th (keyboard): this Phaser 4.2.1 build's KeyCodes map has NO digit entries,
so `kb.addKey('DIGIT_n')` + `key.on('down')` produced a Key with `keyCode undefined`
whose 'down' never fired. Switched to the manager's plain `keydown` event (the same
one the skill seats use) and parse `e.key`/`e.code`. Digits are safe here: the
picker only exists in the faction arena, where the skill-seat handler no-ops (no
class, screen busy).
Verified: both factions, click + keyboard, single fighter (rows 1–8) and the pack
(0) all spawn the correct ship; the plain `?combat` demo still boots (3 waves, no
picker); `node dev/combat.test.mjs` / `factions` / `frames` all pass.

View File

@ -155,3 +155,20 @@
{"type":"audit_result","goalId":"muj5okb0-s2mwh3","verdict":"approved","report":"## Audit result\n\n**Both prior blocking defects are genuinely fixed — independently re-verified, not taken on claim**\n\n1. **Seat art layering (was defect 1).** `CombatBar._iconFor` now re-tops the five state layers after the lazily built icon (`js/ui/CombatBar.js:631-660`). I measured it live in my own Chromium session: display-list indices inside the bar container are `icon 77 < veil 78 < sweep 79 < edge 80 < secs 81 < rankT 82`. I then did my own pixel A/B (my script, /tmp) over the *exact intersection box* of icon∩rankText (32×11 px) and icon∩secsText (24×20 px): hiding `rankT` changes the pixels, hiding `icon` changes them too → the “5/5” stamp and the recharge “6” really paint on top of the sheet. The fresh evidence agrees visually (`dev/shots/skills-seat.png`, `skills-bar-recharging.png` show the ram icon with its countdown and rank stamp over it).\n2. **Runner flakiness (was defect 2).** `node dev/run-all.mjs` at default `--jobs 4`: **ALL 78 TESTS PASSED ✔** twice in a row for me (2 m 26 s each), plus a third green concurrent run of the five skills tests. The no-energy refusal now gates on `!combat.dash` *and* an expired recharge, then drains the orb in the same JS task as the keypress — no wall-clock race; `dev/run-all.mjs` gained only one documented `BUDGETS` entry (300 s, reason stated).\n3. **Dead config / stale docs (was finding 3).** `SkillWindow` reads `window.video` (clip from the video cache by basename, else prints `noSignalTag`, asserted at line 120 of the window test) and `window.fx.sweep/glitch` actually paint from their data timings with `fxPainted()` counters the browser test drives. No `energyCost`/`cooldownMs` anywhere in docs/js anymore; PROJECT_NOTES documents the stack rule and the honest-wait rule.\n\n**Success criteria hold under my own inspection**\n- Data contract exact: ids/labels/type/60 energy/6 s/5 pts/frame 0 vs Passive/0/0/5 pts/frame 1, `requires: []`, dashBlast 3×·1000 ms·+90 px·r170·4x+2x/pt, hull +5 %/pt; sheet `assets/images/skills.png` is 1024×512 and frame 0 really is the ram head, frame 1 the plate (I sliced it). Manifest lists `skills.json`; `data/combat.json → energy.regen: 6`.\n- Numbers, not eyeball: cannon 10 → blast **40 / 60 / 120** at ranks 1/2/5; hull **100→105…125**, re-rolled from base (idempotent across refresh, level-up `(100+17×3)×1.25=189`, combat restart and save/load); energy 100→40 with a 6 s clock; radius gate; one hit per blast.\n- Save-compat: real `SaveData.captureState/prepareLoad`; absent `record.playerSkills` stages nothing (old saves load); hand-edited records drop unknown ids, clamp ranks and clear passive-on-seat.\n- Browser-level: console opens from the *lit* ship-console Skill Tree tab, rank-up by real mouse clicks on published action rects (bank 5→3, hull pool grows live at passive rank 1), ASSIGN SEAT → Digit binds, right-click clears, passive refused a seat and prints PASSIVE.\n- Constraints: no commit made (HEAD still `2ede7cb`), no package.json/build step, Phaser only via `js/vendor/phaser.js`, no balance constant restated in `js/` (grep sweep is part of the suite), `Math.random()` only in window dressing, never generation.\n\n**Non-blocking nits**\n- README's older combat paragraph (~line 49) still says the eight seats “land later… a press lands on the system's `cast()` stub” — stale sentence contradicting the shipped layer.\n- `data/skills.json → window._comment` claims depth 85 while `SkillWindow` uses `DEPTH = 80` (PROJECT_NOTES is right); doc drift only.\n- The console's LOCKED row rendering exists but is never exercised by a test/screenshot — impossible with only the two root skills in scope; `canRank → 'locked'`/`lockedBy` are covered at model level.\n- No in-repo suggested commit message; PROJECT_NOTES explicitly allows it in the chat reply, and nothing was committed.\n\n**Environment notes (not held against the work):** the dev server was already listening on :8123 before I started; running the suite regenerates `dev/shots/skills-*.{png,json}` (test outputs). I made no edits to repository sources — my ad-hoc verification scripts live in `/tmp`.\n\n<approved/>","at":"2026-09-27T09:26:08.063Z"} {"type":"audit_result","goalId":"muj5okb0-s2mwh3","verdict":"approved","report":"## Audit result\n\n**Both prior blocking defects are genuinely fixed — independently re-verified, not taken on claim**\n\n1. **Seat art layering (was defect 1).** `CombatBar._iconFor` now re-tops the five state layers after the lazily built icon (`js/ui/CombatBar.js:631-660`). I measured it live in my own Chromium session: display-list indices inside the bar container are `icon 77 < veil 78 < sweep 79 < edge 80 < secs 81 < rankT 82`. I then did my own pixel A/B (my script, /tmp) over the *exact intersection box* of icon∩rankText (32×11 px) and icon∩secsText (24×20 px): hiding `rankT` changes the pixels, hiding `icon` changes them too → the “5/5” stamp and the recharge “6” really paint on top of the sheet. The fresh evidence agrees visually (`dev/shots/skills-seat.png`, `skills-bar-recharging.png` show the ram icon with its countdown and rank stamp over it).\n2. **Runner flakiness (was defect 2).** `node dev/run-all.mjs` at default `--jobs 4`: **ALL 78 TESTS PASSED ✔** twice in a row for me (2 m 26 s each), plus a third green concurrent run of the five skills tests. The no-energy refusal now gates on `!combat.dash` *and* an expired recharge, then drains the orb in the same JS task as the keypress — no wall-clock race; `dev/run-all.mjs` gained only one documented `BUDGETS` entry (300 s, reason stated).\n3. **Dead config / stale docs (was finding 3).** `SkillWindow` reads `window.video` (clip from the video cache by basename, else prints `noSignalTag`, asserted at line 120 of the window test) and `window.fx.sweep/glitch` actually paint from their data timings with `fxPainted()` counters the browser test drives. No `energyCost`/`cooldownMs` anywhere in docs/js anymore; PROJECT_NOTES documents the stack rule and the honest-wait rule.\n\n**Success criteria hold under my own inspection**\n- Data contract exact: ids/labels/type/60 energy/6 s/5 pts/frame 0 vs Passive/0/0/5 pts/frame 1, `requires: []`, dashBlast 3×·1000 ms·+90 px·r170·4x+2x/pt, hull +5 %/pt; sheet `assets/images/skills.png` is 1024×512 and frame 0 really is the ram head, frame 1 the plate (I sliced it). Manifest lists `skills.json`; `data/combat.json → energy.regen: 6`.\n- Numbers, not eyeball: cannon 10 → blast **40 / 60 / 120** at ranks 1/2/5; hull **100→105…125**, re-rolled from base (idempotent across refresh, level-up `(100+17×3)×1.25=189`, combat restart and save/load); energy 100→40 with a 6 s clock; radius gate; one hit per blast.\n- Save-compat: real `SaveData.captureState/prepareLoad`; absent `record.playerSkills` stages nothing (old saves load); hand-edited records drop unknown ids, clamp ranks and clear passive-on-seat.\n- Browser-level: console opens from the *lit* ship-console Skill Tree tab, rank-up by real mouse clicks on published action rects (bank 5→3, hull pool grows live at passive rank 1), ASSIGN SEAT → Digit binds, right-click clears, passive refused a seat and prints PASSIVE.\n- Constraints: no commit made (HEAD still `2ede7cb`), no package.json/build step, Phaser only via `js/vendor/phaser.js`, no balance constant restated in `js/` (grep sweep is part of the suite), `Math.random()` only in window dressing, never generation.\n\n**Non-blocking nits**\n- README's older combat paragraph (~line 49) still says the eight seats “land later… a press lands on the system's `cast()` stub” — stale sentence contradicting the shipped layer.\n- `data/skills.json → window._comment` claims depth 85 while `SkillWindow` uses `DEPTH = 80` (PROJECT_NOTES is right); doc drift only.\n- The console's LOCKED row rendering exists but is never exercised by a test/screenshot — impossible with only the two root skills in scope; `canRank → 'locked'`/`lockedBy` are covered at model level.\n- No in-repo suggested commit message; PROJECT_NOTES explicitly allows it in the chat reply, and nothing was committed.\n\n**Environment notes (not held against the work):** the dev server was already listening on :8123 before I started; running the suite regenerates `dev/shots/skills-*.{png,json}` (test outputs). I made no edits to repository sources — my ad-hoc verification scripts live in `/tmp`.\n\n<approved/>","at":"2026-09-27T09:26:08.063Z"}
{"type":"goal_completed","goalId":"muj5okb0-s2mwh3","archivePath":".pi/goals/archived/goal_2026092703260806_muj5okb0-s2mwh3.md","at":"2026-09-27T09:26:08.078Z"} {"type":"goal_completed","goalId":"muj5okb0-s2mwh3","archivePath":".pi/goals/archived/goal_2026092703260806_muj5okb0-s2mwh3.md","at":"2026-09-27T09:26:08.078Z"}
{"type":"goal_archived","goalId":"muj5okb0-s2mwh3","archivePath":".pi/goals/archived/goal_2026092703260806_muj5okb0-s2mwh3.md","at":"2026-09-27T09:26:08.078Z"} {"type":"goal_archived","goalId":"muj5okb0-s2mwh3","archivePath":".pi/goals/archived/goal_2026092703260806_muj5okb0-s2mwh3.md","at":"2026-09-27T09:26:08.078Z"}
{"type":"goal_created","goalId":"mum05nd7-2d3nte","objective":"Add 16 elite enemy fighters — 8 Apex (sheets frames 30–37) and 8 Mindweave (frames 40–47) — to the enemyTypes roster with a full themed behavior/skill layer (Apex: predatory cloaked ambushes, flanking, heavy forward firepower and spread volleys; Mindweave: blink repositions, illusions/decoys, telekinesis, big patterned bullet barrages), each ship with a distinct attack style, all roughly 1.3× the pirate's offensive and defensive stats, plus a new test arena reachable via ?combat (e.g. ?combat=apex / ?combat=mindweave) with an in-game picker to duel any single fighter.","sisyphus":false,"autoContinue":true,"at":"2026-09-29T01:33:19.627Z"}
{"type":"task_list_set","goalId":"mum05nd7-2d3nte","taskCount":6,"blockCompletion":true,"at":"2026-09-29T01:33:19.627Z"}
{"type":"task_started","goalId":"mum05nd7-2d3nte","taskId":"task-1","at":"2026-09-29T01:33:28.199Z"}
{"type":"task_complete","goalId":"mum05nd7-2d3nte","taskId":"task-1","evidence":"data/combat.json: 16 enemyTypes (apex_*/weave_* frames 30-37/40-47) + combat.factions; JSON valid; tests: frames/roster/1.3x stat checks pass","at":"2026-09-29T01:47:47.344Z"}
{"type":"task_complete","goalId":"mum05nd7-2d3nte","taskId":"task-2","evidence":"Weapons.js: sweep/spiral/shell patterns; dev/combat.test.mjs pattern checks (heading-centered fan, stepped spiral w/ stagger, big-radius shell, triple shells) pass","at":"2026-09-29T01:47:47.345Z"}
{"type":"task_complete","goalId":"mum05nd7-2d3nte","taskId":"task-3","evidence":"EnemyAbilities.js cloaked_ambush + flank_strafe wired in CombatSystem (fire gate !cloaked, collide skips cloaked); cloak/stand/reveal tests pass; 8 distinct apex kits","at":"2026-09-29T01:47:47.345Z"}
{"type":"task_complete","goalId":"mum05nd7-2d3nte","taskId":"task-4","evidence":"EnemyAbilities.js blink_reposition/illusion_decoy/telekinetic_wave + stepIllusions/stepTkwaves in CombatSystem; illusions in auto-fire pool + bullet pops; all ability tests pass","at":"2026-09-29T01:47:47.346Z"}
{"type":"task_complete","goalId":"mum05nd7-2d3nte","taskId":"task-5","evidence":"CDP arena (dev/cdp-faction-arena.mjs): ?combat=apex / ?combat=mindweave boot the picker; picks 1-8 + pack(0) duel individually; plain ?combat demo still boots (wave live, 3 enemies, no picker) — all c","at":"2026-09-29T02:00:05.074Z"}
{"type":"task_complete","goalId":"mum05nd7-2d3nte","taskId":"task-6","evidence":"All dev/*.test.mjs suites pass (0 failures, combat.test.mjs all checks incl. 1.3x armor/damage/XP bands per ship); CDP checklist: both factions boot, fighters fire their distinct patterns (sweep 5 bol","at":"2026-09-29T02:00:05.076Z"}
{"type":"completion_requested","goalId":"mum05nd7-2d3nte","at":"2026-09-29T02:01:13.582Z"}
{"type":"audit_started","goalId":"mum05nd7-2d3nte","at":"2026-09-29T02:01:13.587Z"}
{"type":"audit_result","goalId":"mum05nd7-2d3nte","verdict":"disapproved","report":"16 faction ships present in data. Now checking base stats, weapons, abilities, and picker wiring.\n\n## Audit summary\n\n**Verified as genuinely present (inspected, not taken on faith):**\n\n- **Roster** — `data/combat.json` `enemyTypes` holds all 16: `apex_raptor…apex_alpha` (frames 30–37) and `weave_mindblade…weave_overmind` (frames 40–47), each with name, weapon pattern, and abilities; `combat.factions` picker rosters present. ✔\n- **Balance** — base pirate: hull 60/shields 0/weapon damage 8 (confirmed in `combat.enemy`). Every faction ship: 65+13 = 78 armor (exactly 1.3×), bolt/shell damage 10–12 (1.25–1.5×), xp 16 = (78/5). ✔ (I ran `dev/combat.test.mjs` myself: \"combat rules: all checks passed\".)\n- **Weapon patterns** — `sweep` (heading-centered fan), `spiral` (stepped + staggered), `shell` (large slow orbs, Overmind triple) implemented in `js/combat/Weapons.js` alongside existing patterns. ✔\n- **Skill layer** — `js/combat/EnemyAbilities.js` implements cloaked_ambush (reveal = free strike, recloak clock), flank_strafe (rear cut + orbit override in `EnemyShip.update`), blink_reposition (teleport + phantom at old spot), illusion_decoy, telekinetic_wave (push/pull impulse); wired in `CombatSystem.js` (cloak fire gate, cloak collision skip, `stepIllusions`/`stepTkwaves`). ✔\n- **Duel arena** — `_combatBootMode()` in `GameScene.js` handles `?combat=apex|mindweave`; `DuelPicker.js` (keys 1–8 / click / 0 = pack), `startDuel/spawnDuel/spawnDuelPack`, no-respawn marking, kill → picker re-show; plain `?combat` demo path untouched. Code is coherent and test-covered at the logic level. ✔\n\n**Blocking defect — the fighters are invisible:**\n\nI pixel-scanned the actual runtime artifact `assets/images/ships-enemy.png` (1280×1280, 128px frames = 10×10 grid): **only frames 0–7 (row 0) and 20–27 (row 2, the neutrals) contain any art. Every one of frames 30–37 and 40–47 has 0 opaque pixels.** The goal itself designates these exact sheet frames for the 16 fighters. `git status` shows the PNG was never touched in this goal (last modified in a prior commit containing only those 16 frames). `EnemyShip` renders the sheet frame directly (fallback wedge only if the *sheet* is missing, which it isn't), so in the arena all 16 \"elite fighters\" draw as fully transparent sprites — only threat markers, muzzle FX, and their bullets are visible. The Mindweave illusion/phantom decoys (copies of the same blank frame) are invisible too, gutting that signature mechanic. The CDP script the executor ran checks bolt/shell counts and state flags — it could not and did not detect invisible ships; this is exactly the proxy-verification trap the data-level tests all pass.\n\n**Environment note (separate):** no headless Chrome exists in this audit environment, so I could not re-run `dev/cdp-faction-arena.mjs` myself; the logic test suite I could run passes.\n\n**Verdict:** the code, data, balance, skills, and arena plumbing are real and solid — but the core user-facing deliverable, \"16 elite enemy fighters\" on sheet frames 30–37/40–47, ships with **no art at all**: every one of the 16 duel opponents is an invisible sprite.\n\n<disapproved/>","at":"2026-09-29T02:04:41.558Z"}
{"type":"completion_requested","goalId":"mum05nd7-2d3nte","at":"2026-09-29T02:15:22.976Z"}
{"type":"audit_started","goalId":"mum05nd7-2d3nte","at":"2026-09-29T02:15:22.984Z"}
{"type":"audit_result","goalId":"mum05nd7-2d3nte","verdict":"approved","report":"I'll verify the claims against actual artifacts. Let me start with the key files.\n\n## Audit summary\n\nI inspected the actual artifacts and ran independent verification (not taking the executor's claims on faith):\n\n**Roster & frames** — `data/combat.json` `enemyTypes` holds all 16: `apex_raptor…apex_alpha` (frames 30–37) and `weave_mindblade…weave_overmind` (frames 40–47), each with name, weapon block, abilities; `combat.factions` picker rosters for both factions present. ✔\n\n**Balance (≈1.3× the pirate)** — base pirate is 60 hull / 0 shields / 8 dmg / ram 10. Every faction ship is 65 + 13 = **78 armor (exactly 1.3×)**, weapon damage **10–12 (1.25–1.5×)**, ramDamage 13 (1.3×), xp 16. Verified per-ship by dumping the JSON. ✔\n\n**Distinct attack styles** — per-ship profiles verified: Raptor sweep(5), Mauler single heavy 12-dmg lance, Stalker lance + double-cloak, Screamer 9-bolt spray, Reaver rapid, Fang burst, Tyrant twin missiles, Alpha combo; Mindblade 10-bolt spiral, Telekine rapid+tide, Phantasm volley+court, Weaver sweep+fast blink, Lure ring+pull, Dreamshell large shells+push, Serpentmind long coil+pull, Overmind **triple shells** + full psychic kit. `Weapons.js` implements the new `sweep`/`spiral`/`shell` dispatch paths. ✔\n\n**Themed behavior layer** — `js/combat/EnemyAbilities.js` implements all five handlers (`cloaked_ambush` incl. Stalker/Reaver/Alpha re-cloak naps, `flank_strafe` rear-arc cuts, `blink_reposition` with phantom at the old stand, `illusion_decoy` phantom court, `telekinetic_wave` push/pull tides); `EnemyShip.js` wires `abilitiesOf(c)`; `CombatSystem.js` keeps illusions in the auto-fire pool and pops them with tracers. ✔\n\n**Sprites (the previously blocking defect)** — I independently decoded `assets/images/ships-enemy.png` (1280×1280, 8-bit RGBA) with my own zlib/PNG parser: **all 16 faction frames 30–37/40–47 carry art** (80–201 opaque samples each, none blank) and the pirate frames 0–7/20–27 are untouched. ✔\n\n**Duel arena & picker (live, in a real browser)** — I stood up the dev server and headless Chromium CDP myself and ran `dev/cdp-faction-arena.mjs`: `?combat=apex` and `?combat=mindweave` both boot the arena with 8 picker rows; picking 1 spawns the single marked fighter (78 armor, no respawn, its own kit); Raptor fires its 5-bolt sweep, Mindblade its 10-bolt spiral, Dreamshell its shells — all bullets land in the world; kill re-shows the picker; key 0 sends the 8-fighter pack; the plain `?combat` seven-type demo still boots unchanged (also re-ran `dev/combat-demo-check.mjs` — all 7 waves, PASS). `DuelPicker.js` is a real in-game Phaser panel with keys 1–8 / click / 0. ✔\n\n**Tests** — `dev/combat.test.mjs` passes end-to-end, including balance bands, ability geometry (rear-arc ambush stand, blink bias, push/pull tides), duel state machine, and the sheet pixel-regression checks. `class-combat` and `frames` suites also pass. ✔\n\nI cleaned up the dev server and headless browser I launched for verification; pre-existing environment processes were left alone. No files were modified.\n\nAll explicit requirements — 16 rostered fighters on the correct frames, themed behavior/skill kits, distinct attack styles, ~1.3× stats both ways, and a `?combat=apex`/`?combat=mindweave` arena with an in-game single-fighter picker — are genuinely present and verified live.\n\n<approved/>","at":"2026-09-29T02:20:10.377Z"}
{"type":"goal_completed","goalId":"mum05nd7-2d3nte","archivePath":".pi/goals/archived/goal_2026092820201039_mum05nd7-2d3nte.md","at":"2026-09-29T02:20:10.417Z"}
{"type":"goal_archived","goalId":"mum05nd7-2d3nte","archivePath":".pi/goals/archived/goal_2026092820201039_mum05nd7-2d3nte.md","at":"2026-09-29T02:20:10.418Z"}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

View File

@ -247,6 +247,487 @@
"missileSize": 20, "missileSize": 20,
"color": "#ff2d6f" "color": "#ff2d6f"
} }
},
"$factions_note": "FRAMES 30–37 = THE APEX, frames 40–47 = THE MINDWEAVE — the two FACTION rosters (the ?combat=apex / ?combat=mindweave duel arena — combat.factions below names them). BALANCE RULE (the user's numbers, vs the PIRATE raider — 60 armor, 8 bolt damage): BOTH FACTIONS = 2× DAMAGE (16 = 8 × 2, every bolt/seeker/shell) and 3.5× ARMOR (175 hull + 35 shields = 210 = 60 × 3.5). Cooldowns 0.6–3.0 s (pattern character, not power), ramDamage 13 (10 × 1.3). xp = (hull+shields)/5 = 42 (the roster's rule of thumb). DIFFERENTIATION comes from the WEAPON PATTERN (weapon.pattern — the shared dispatch, js/combat/Weapons.js) + the ABILITY LAYER (abilities[].id — js/combat/EnemyAbilities.js: cloaked_ambush / flank_strafe for the Apex's predatory kit, blink_reposition / illusion_decoy / telekinetic_wave for the Mindweave's psychic kit). Each fighter is its own craft; the faction's SHARED skills are the kit's common entries (the Apex all carry the cloak and most the flank; the Mindweave all carry the blink or the phantoms and several the tides) at per-ship numbers.",
"apex_raptor": {
"$comment": "APEX 1 (frame 30) — RAPTOR, the forward gun: a 5-bolt SWEEP out the nose (heavy forward firepower) on a medium beat; the flank's cut gets it behind the player every 10 s or so so the sweep lands on the back.",
"name": "Raptor",
"frame": 30,
"speed": 175,
"turn": 3.6,
"hull": 175,
"shields": 35,
"xp": 42,
"ramDamage": 13,
"ramDamageSelf": 29,
"weapon": {
"pattern": "sweep",
"range": 520,
"cooldown": 1.2,
"bulletSpeed": 360,
"damage": 16,
"spread": 0.03,
"bulletLife": 2.0,
"bolts": 5,
"fan": 1.0,
"color": "#ff4d2d"
},
"abilities": [
{ "id": "flank_strafe", "period": 10, "radius": 210, "color": "#ff4d2d" }
]
},
"apex_mauler": {
"$comment": "APEX 2 (frame 31) — MAULER, the heavy hitter: one BIG slow bolt (damage 16, the 2× bolt, wide visual) — slow, armored, deliberate; comes out of CLOAK to swing it (the shared ambush).",
"name": "Mauler",
"frame": 31,
"speed": 120,
"turn": 2.4,
"hull": 175,
"shields": 35,
"xp": 42,
"ramDamage": 13,
"ramDamageSelf": 29,
"weapon": {
"pattern": "lance",
"range": 560,
"cooldown": 1.6,
"bulletSpeed": 240,
"damage": 16,
"spread": 0.02,
"bulletLife": 2.6,
"size": 22,
"boltLength": 70,
"boltWidth": 16,
"color": "#ff2d4d"
},
"abilities": [
{ "id": "cloaked_ambush", "cloakMs": 3200, "color": "#ff2d4d" }
]
},
"apex_stalker": {
"$comment": "APEX 3 (frame 32) — STALKER, the double-cloak (the signature): cloaks on contact, strikes, then DROPS BACK INTO CLOAK on its own clock (recloakCooldown/recloakMs) — strike, vanish, strike again. Fast, clean lance.",
"name": "Stalker",
"frame": 32,
"speed": 185,
"turn": 4.0,
"hull": 175,
"shields": 35,
"xp": 42,
"ramDamage": 13,
"ramDamageSelf": 29,
"weapon": {
"pattern": "lance",
"range": 520,
"cooldown": 1.1,
"bulletSpeed": 380,
"damage": 16,
"spread": 0.04,
"bulletLife": 2.0,
"color": "#ff8a2d"
},
"abilities": [
{ "id": "cloaked_ambush", "cloakMs": 3600, "recloakCooldown": 16, "recloakMs": 2800, "color": "#ff8a2d" }
]
},
"apex_screamer": {
"$comment": "APEX 4 (frame 33) — SCREAMER, the wide fan: a 9-bolt SPRAY across 1.6 rad (dodge the core, not the wall); the flank's cut re-positions it before each scream.",
"name": "Screamer",
"frame": 33,
"speed": 150,
"turn": 3.0,
"hull": 175,
"shields": 35,
"xp": 42,
"ramDamage": 13,
"ramDamageSelf": 29,
"weapon": {
"pattern": "spray",
"range": 480,
"cooldown": 1.4,
"bulletSpeed": 340,
"damage": 16,
"spread": 0.02,
"bulletLife": 1.8,
"bolts": 9,
"fan": 1.6,
"color": "#ffd84d"
},
"abilities": [
{ "id": "flank_strafe", "period": 9, "radius": 200, "color": "#ffd84d" }
]
},
"apex_reaver": {
"$comment": "APEX 5 (frame 34) — REAVER, the gun: RAPID bolts at 0.6 s (the pack's highest DPS — out-turn it) with the double-cloak signature (a gun you can't see).",
"name": "Reaver",
"frame": 34,
"speed": 190,
"turn": 4.4,
"hull": 175,
"shields": 35,
"xp": 42,
"ramDamage": 13,
"ramDamageSelf": 29,
"weapon": {
"pattern": "rapid",
"range": 460,
"cooldown": 0.6,
"bulletSpeed": 400,
"damage": 16,
"spread": 0.06,
"bulletLife": 1.5,
"color": "#ff6a2d"
},
"abilities": [
{ "id": "cloaked_ambush", "cloakMs": 2800, "recloakCooldown": 18, "recloakMs": 2200, "color": "#ff6a2d" }
]
},
"apex_fang": {
"$comment": "APEX 6 (frame 35) — FANG, the stinger: a TIGHT 4-shot BURST (staggered speeds open it over distance — the close-range snap) at 16 damage each; cuts behind before it snaps.",
"name": "Fang",
"frame": 35,
"speed": 180,
"turn": 4.2,
"hull": 175,
"shields": 35,
"xp": 42,
"ramDamage": 13,
"ramDamageSelf": 29,
"weapon": {
"pattern": "burst",
"range": 420,
"cooldown": 1.5,
"bulletSpeed": 420,
"damage": 16,
"spread": 0.02,
"bulletLife": 1.6,
"bolts": 4,
"burstFan": 0.14,
"color": "#ff3d2d"
},
"abilities": [
{ "id": "flank_strafe", "period": 12, "radius": 230, "color": "#ff3d2d" }
]
},
"apex_tyrant": {
"$comment": "APEX 7 (frame 36) — TYRANT, the swarm: TWO heat-seeking missiles per volley (out-turn both) on a long beat; cloaks in, then the seekers come — the flank's cut keeps it off your nose.",
"name": "Tyrant",
"frame": 36,
"speed": 150,
"turn": 3.2,
"hull": 175,
"shields": 35,
"xp": 42,
"ramDamage": 13,
"ramDamageSelf": 29,
"weapon": {
"pattern": "missile",
"range": 620,
"cooldown": 2.2,
"missiles": 2,
"missileSpeed": 190,
"missileTurn": 2.2,
"missileDamage": 16,
"missileLife": 4.0,
"missileColor": "#ff5d2d",
"missileSize": 18,
"color": "#ff5d2d"
},
"abilities": [
{ "id": "cloaked_ambush", "cloakMs": 3000, "color": "#ff5d2d" },
{ "id": "flank_strafe", "period": 13, "radius": 220, "color": "#ff5d2d" }
]
},
"apex_alpha": {
"$comment": "APEX 8 (frame 37) — ALPHA, the pack's top predator (the elite): a COMBO of heavy lance (16) + two seekers every 2.4 s, the FULL kit — the longest cloak, the double-cloak's nap, and the flank's cut. 2× the pirate's damage, 3.5× its armor — the whole predatory doctrine in everything else.",
"name": "Alpha",
"frame": 37,
"speed": 130,
"turn": 2.8,
"hull": 175,
"shields": 35,
"xp": 42,
"ramDamage": 13,
"ramDamageSelf": 29,
"weapon": {
"pattern": "combo",
"range": 640,
"cooldown": 2.4,
"bulletSpeed": 260,
"damage": 16,
"spread": 0.02,
"bulletLife": 2.8,
"size": 20,
"boltLength": 64,
"boltWidth": 14,
"missiles": 2,
"missileSpeed": 180,
"missileTurn": 2.3,
"missileDamage": 16,
"missileLife": 4.2,
"missileColor": "#ffb02d",
"missileSize": 20,
"color": "#ffb02d"
},
"abilities": [
{ "id": "cloaked_ambush", "cloakMs": 3800, "recloakCooldown": 20, "recloakMs": 3000, "color": "#ffb02d" },
{ "id": "flank_strafe", "period": 10, "radius": 210, "color": "#ffb02d" }
]
},
"weave_mindblade": {
"$comment": "MINDWEAVE 1 (frame 40) — MINDBLADE, the spiral: a 10-bolt SPIRAL (each bolt a step further around, staggered speeds — the 1942-boss bloom) at 16 damage; BLINKS to a fresh stand (biasing behind) and leaves a phantom at the old one.",
"name": "Mindblade",
"frame": 40,
"speed": 140,
"turn": 3.0,
"hull": 175,
"shields": 35,
"xp": 42,
"ramDamage": 13,
"ramDamageSelf": 29,
"weapon": {
"pattern": "spiral",
"range": 560,
"cooldown": 2.2,
"bulletSpeed": 210,
"damage": 16,
"spread": 0.01,
"bulletLife": 3.0,
"bolts": 10,
"spiralStep": 0.55,
"stagger": 0.12,
"color": "#b44dff"
},
"abilities": [
{ "id": "blink_reposition", "cooldown": 12, "color": "#b44dff" }
]
},
"weave_telekine": {
"$comment": "MINDWEAVE 2 (frame 41) — TELEKINE, the tide: a rapid mind-cannon (16 per 0.6 s) backed by a PSYCHIC TIDE — every 13 s a wave blooms and SHOVES the player out of its line (the dodge window: be far or be moved).",
"name": "Telekine",
"frame": 41,
"speed": 175,
"turn": 3.8,
"hull": 175,
"shields": 35,
"xp": 42,
"ramDamage": 13,
"ramDamageSelf": 29,
"weapon": {
"pattern": "rapid",
"range": 500,
"cooldown": 0.6,
"bulletSpeed": 400,
"damage": 16,
"spread": 0.05,
"bulletLife": 1.6,
"color": "#4dd7ff"
},
"abilities": [
{ "id": "telekinetic_wave", "cooldown": 13, "dir": "push", "radius": 290, "power": 280, "color": "#4dd7ff" },
{ "id": "blink_reposition", "cooldown": 17, "color": "#4dd7ff" }
]
},
"weave_phantasm": {
"$comment": "MINDWEAVE 3 (frame 42) — PHANTASM, the court: a blossoming VOLLEY (8 staggered bolts — the wall) with the FULL illusion kit — projects a COURT of three phantoms (the auto-fire's pool chases them) and blinks among them.",
"name": "Phantasm",
"frame": 42,
"speed": 150,
"turn": 3.2,
"hull": 175,
"shields": 35,
"xp": 42,
"ramDamage": 13,
"ramDamageSelf": 29,
"weapon": {
"pattern": "volley",
"range": 540,
"cooldown": 1.6,
"bulletSpeed": 260,
"damage": 16,
"spread": 0.01,
"bulletLife": 2.6,
"bolts": 8,
"fan": 1.5,
"stagger": 0.4,
"color": "#ff4dc8"
},
"abilities": [
{ "id": "illusion_decoy", "cooldown": 15, "count": 3, "radius": 180, "lifeMs": 2800, "color": "#ff4dc8" },
{ "id": "blink_reposition", "cooldown": 16, "decoyMs": 2000, "color": "#ff4dc8" }
]
},
"weave_weaver": {
"$comment": "MINDWEAVE 4 (frame 43) — WEAVER, the threads: a 9-bolt SWEEP out its own heading (its threads fly where it FACES — aim your counter off its nose) with the most frequent BLINK of the faction (11 s — it is rarely where you last saw it).",
"name": "Weaver",
"frame": 43,
"speed": 160,
"turn": 3.6,
"hull": 175,
"shields": 35,
"xp": 42,
"ramDamage": 13,
"ramDamageSelf": 29,
"weapon": {
"pattern": "sweep",
"range": 520,
"cooldown": 1.3,
"bulletSpeed": 300,
"damage": 16,
"spread": 0.02,
"bulletLife": 2.2,
"bolts": 9,
"fan": 1.4,
"color": "#4dffc8"
},
"abilities": [
{ "id": "blink_reposition", "cooldown": 11, "decoyMs": 2200, "color": "#4dffc8" }
]
},
"weave_lure": {
"$comment": "MINDWEAVE 5 (frame 44) — LURE, the net: a RADIAL ring of ten (break the orbit) with the phantoms and a PULL tide — it drags the player IN toward the ring (the push tide's inverse: its net closes on you).",
"name": "Lure",
"frame": 44,
"speed": 130,
"turn": 2.6,
"hull": 175,
"shields": 35,
"xp": 42,
"ramDamage": 13,
"ramDamageSelf": 29,
"weapon": {
"pattern": "ring",
"range": 460,
"cooldown": 2.3,
"bulletSpeed": 240,
"damage": 16,
"spread": 0.0,
"bulletLife": 2.6,
"bolts": 10,
"color": "#4da6ff"
},
"abilities": [
{ "id": "illusion_decoy", "cooldown": 14, "count": 2, "radius": 200, "lifeMs": 3000, "color": "#4da6ff" },
{ "id": "telekinetic_wave", "cooldown": 16, "dir": "pull", "radius": 300, "power": 240, "color": "#4da6ff" }
]
},
"weave_dreamer": {
"$comment": "MINDWEAVE 6 (frame 45) — DREAMSHELL, the shells: one LARGE-RADIUS slow shell (44 px glow, 16 damage, a short fuse — the big slow rock) per volley, backed by a hard PUSH tide (300 px/s — the strongest shove of the faction).",
"name": "Dreamshell",
"frame": 45,
"speed": 110,
"turn": 2.2,
"hull": 175,
"shields": 35,
"xp": 42,
"ramDamage": 13,
"ramDamageSelf": 29,
"weapon": {
"pattern": "shell",
"range": 600,
"cooldown": 2.6,
"damage": 16,
"shells": 1,
"shellDamage": 16,
"shellSize": 44,
"shellSpeed": 140,
"shellLife": 2.4,
"color": "#8a4dff"
},
"abilities": [
{ "id": "telekinetic_wave", "cooldown": 14, "dir": "push", "radius": 300, "power": 300, "color": "#8a4dff" },
{ "id": "illusion_decoy", "cooldown": 17, "count": 2, "radius": 200, "lifeMs": 2600, "color": "#8a4dff" }
]
},
"weave_serpent": {
"$comment": "MINDWEAVE 7 (frame 46) — SERPENTMIND, the coil: a LONG 14-bolt spiral (a tighter step, slower bolts — a COIL that hangs around longer than Mindblade's) with the blink and a PULL tide (it repositions, then reeled you in).",
"name": "Serpentmind",
"frame": 46,
"speed": 145,
"turn": 3.0,
"hull": 175,
"shields": 35,
"xp": 42,
"ramDamage": 13,
"ramDamageSelf": 29,
"weapon": {
"pattern": "spiral",
"range": 580,
"cooldown": 2.4,
"bulletSpeed": 190,
"damage": 16,
"spread": 0.01,
"bulletLife": 3.2,
"bolts": 14,
"spiralStep": 0.42,
"stagger": 0.1,
"color": "#c84dff"
},
"abilities": [
{ "id": "blink_reposition", "cooldown": 13, "color": "#c84dff" },
{ "id": "telekinetic_wave", "cooldown": 18, "dir": "pull", "radius": 310, "power": 260, "color": "#c84dff" }
]
},
"weave_overmind": {
"$comment": "MINDWEAVE 8 (frame 47) — OVERMIND, the faction's apex (the elite): a TRIPLE SHELL volley (three large orbs per turn — the slowest, heaviest barrage of the roster) with the FULL psychic kit — the most frequent blink, a standing phantom court, and the strongest PULL tide (300). Control every axis of the fight at 2× the pirate's offense and 3.5× its armor.",
"name": "Overmind",
"frame": 47,
"speed": 100,
"turn": 2.0,
"hull": 175,
"shields": 35,
"xp": 42,
"ramDamage": 13,
"ramDamageSelf": 29,
"weapon": {
"pattern": "shell",
"range": 620,
"cooldown": 3.0,
"damage": 16,
"shells": 3,
"shellDamage": 16,
"shellSize": 44,
"shellSpeed": 150,
"shellLife": 2.6,
"color": "#e04dff"
},
"abilities": [
{ "id": "blink_reposition", "cooldown": 10, "decoyMs": 2200, "color": "#e04dff" },
{ "id": "illusion_decoy", "cooldown": 13, "count": 2, "radius": 190, "lifeMs": 2800, "color": "#e04dff" },
{ "id": "telekinetic_wave", "cooldown": 15, "dir": "pull", "radius": 320, "power": 300, "color": "#e04dff" }
]
}
},
"factions": {
"_comment": "The FACTION DUEL ARENA (the ?combat=apex / ?combat=mindweave boots — js/scenes/GameScene.js → _combatBootMode, js/combat/DuelPicker.js the in-game picker, CombatSystem.startDuel/spawnDuel/spawnDuelPack the fight's seam). Each faction names its fighters (enemyTypes ids, display order = the picker's rows 1–8); 'style' is the picker's one-line read. Key '0' (or the row) sends the WHOLE pack. These rosters are NOT wired into any quest/galaxy content — the arena is their only home for now.",
"apex": {
"name": "Apex",
"tagline": "apex predators — cloaked ambushes, flanking strikes, devastating forward firepower",
"types": [
{ "id": "apex_raptor", "name": "Raptor", "style": "forward fan sweep + the flank's cut" },
{ "id": "apex_mauler", "name": "Mauler", "style": "one heavy bolt out of cloak" },
{ "id": "apex_stalker", "name": "Stalker", "style": "strike, vanish, strike again (double cloak)" },
{ "id": "apex_screamer", "name": "Screamer", "style": "a nine-bolt wall across your nose" },
{ "id": "apex_reaver", "name": "Reaver", "style": "the unseen gun — rapid + double cloak" },
{ "id": "apex_fang", "name": "Fang", "style": "a tight stinger from behind" },
{ "id": "apex_tyrant", "name": "Tyrant", "style": "twin seekers out of the dark" },
{ "id": "apex_alpha", "name": "Alpha", "style": "the pack's apex — the full predatory kit" }
]
},
"mindweave": {
"name": "Mindweave",
"tagline": "psychic weavers — blinks, phantoms, telekinetic tides, spiralling barrages",
"types": [
{ "id": "weave_mindblade", "name": "Mindblade", "style": "a ten-bolt spiral that blooms in flight" },
{ "id": "weave_telekine", "name": "Telekine", "style": "rapid fire + a tide that shoves you" },
{ "id": "weave_phantasm", "name": "Phantasm", "style": "a court of phantoms + the blink" },
{ "id": "weave_weaver", "name": "Weaver", "style": "threads out its heading, blinks fastest" },
{ "id": "weave_lure", "name": "Lure", "style": "a radial net + a tide that drags you in" },
{ "id": "weave_dreamer", "name": "Dreamshell", "style": "large slow shells + the hardest shove" },
{ "id": "weave_serpent", "name": "Serpentmind", "style": "a long hanging coil + the reeling pull" },
{ "id": "weave_overmind", "name": "Overmind", "style": "triple shells + the full psychic kit" }
]
} }
}, },

233
dev/cdp-faction-arena.mjs Normal file
View File

@ -0,0 +1,233 @@
// CDP smoke check for the FACTION DUEL ARENA (the ?combat=apex /
// ?combat=mindweave boots + the picker + the plain-?combat demo regression).
// Needs: a static server on :8080 (the repo root) and a headless Chrome
// with --remote-debugging-port=9333. Each scenario opens a fresh tab.
//
// node dev/cdp-faction-arena.mjs
import { setTimeout as sleep } from 'node:timers/promises';
const CDP = '127.0.0.1:9333';
let failures = 0;
const check = (label, cond, extra = '') => {
console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${extra ? ` [${extra}]` : ''}`);
if (!cond) failures += 1;
};
class Tab {
constructor(info) { this.info = info; this.seq = 0; this.pending = new Map(); }
async attach() {
this.ws = new WebSocket(this.info.webSocketDebuggerUrl);
this.ws.onmessage = (m) => {
const q = JSON.parse(m.data);
if (q.id && this.pending.has(q.id)) {
const o = this.pending.get(q.id); this.pending.delete(q.id);
q.error ? o.rej(new Error(q.error.message)) : o.res(q.result);
}
};
await new Promise((res, rej) => { this.ws.onopen = res; this.ws.onerror = () => rej(new Error('ws')); });
await this.send('Runtime.enable');
}
send(method, params = {}) {
return new Promise((res, rej) => {
const id = ++this.seq;
this.pending.set(id, { res, rej });
this.ws.send(JSON.stringify({ id, method, params }));
setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); rej(new Error('timeout ' + method)); } }, 15000);
});
}
async eval(expr) {
const r = await this.send('Runtime.evaluate', { expression: expr, returnByValue: true });
if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails).slice(0, 1200));
return r.result.value;
}
close() { try { this.ws.close(); } catch { /* already gone */ } }
}
async function newTab(url) {
try {
const r = await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' });
const t = await r.json();
if (t && t.webSocketDebuggerUrl) return new Tab(t);
} catch { /* fall through to POST */ }
const r = await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'POST' });
return new Tab(await r.json());
}
const S = `window.game.scene.getScene('GameScene')`;
const C = `${S}.combat`;
// The menu's NEW GAME button (its panel is the interactive child — the
// CDP stand-in for a click; the menu is the only way into the game).
async function startGame(tab) {
for (let i = 0; i < 60; i++) {
const ok = await tab.eval(`!!(window.game && window.game.scene && window.game.scene.getScene('MenuScene') && window.game.scene.getScene('MenuScene').newGameBtn)`);
if (ok) break;
await sleep(500);
}
await tab.eval(`${S && 'window.game'} && window.game.scene && window.game.scene.getScene('MenuScene') ? window.game.scene.getScene('MenuScene').newGameBtn.panel.emit('pointerdown') : null; true`);
for (let i = 0; i < 60; i++) {
const ok = await tab.eval(`!!(window.game && window.game.scene && window.game.scene.getScene('GameScene') && window.game.scene.getScene('GameScene').combat)`);
if (ok) break;
await sleep(500);
}
}
// ------------------------------------------------------------------ apex tab
{
const tab = await newTab('http://127.0.0.1:8080/index.html?combat=apex');
await tab.attach();
await startGame(tab); // the menu's NEW GAME → the arena's boot
await sleep(3000); // the scene's settle
let v = await tab.eval(`JSON.stringify({
boot: ${S}._combatBootMode(),
duel: ${C}.duel,
rows: ${S}.duelPicker ? ${S}.duelPicker.rows().map(r => r.id) : null,
})`);
const boot = JSON.parse(v);
check('apex: ?combat=apex boots the arena (boot mode, duel state, 8 picker rows)',
boot.boot === 'apex' && boot.duel && boot.duel.faction === 'apex' && boot.duel.active === false &&
Array.isArray(boot.rows) && boot.rows.length === 8 && boot.rows[0] === 'apex_raptor', v);
// Fighter 1 (the Raptor — the forward sweep + the flank's cut).
await tab.eval(`${S}.duelPicker.select(1); true`);
await sleep(1500);
v = await tab.eval(`(() => { const e = ${C}.enemies[0]; return JSON.stringify({
active: ${C}.duel.active, n: ${C}.enemies.length,
name: e ? e.name : null, pattern: e ? e.wpn.pattern : null,
abilities: e ? e.abilities.map(a => a.id) : null,
armor: e ? (e.hull + (e.shields||0)) : null,
}); })()`);
let f = JSON.parse(v);
check('apex: pick 1 = the Raptor, marked duel, no respawn, 78 armor, its own kit',
f.active === true && f.n === 1 && f.name === 'Raptor' && f.pattern === 'sweep' &&
f.abilities.includes('flank_strafe') && f.armor === 78, v);
v = await tab.eval(`(() => {
const c = ${S}.combat;
const e = c.enemies[0];
const pl = c.ship;
if (!e || !pl) return JSON.stringify({ reason: 'no-enemy-or-player' });
let t = (c._now ? c._now() : 100000) + 1000;
let fired = false;
for (let i = 0; i < 400 && !fired; i++) {
t += 16;
e.nextFireAt = 0; // its clock is ready; only the aim gate stands between
if (e.update(t, 16, pl)) fired = true;
}
if (!fired) return JSON.stringify({ fired: false });
c.fireEnemy(e, pl, t);
return JSON.stringify({ fired: true, bolts: c.bullets.length, ms: c.missiles.length });
})()`);
f = JSON.parse(v);
check('apex: the Raptor fires (its sweep bolts land in the world)', f.fired === true && f.bolts >= 5, v);
await tab.eval(`const e=${C}.enemies[0]; if (e) { e.damage(9999); ${C}.killEnemy(e, 0); } true`);
await sleep(800);
v = await tab.eval(`${C}.duel.active`);
check('apex: the kill releases the arena (duel.active back to the picker)', v === false);
// The whole pack (key 0).
await tab.eval(`${S}.duelPicker.select(0); true`);
await sleep(1500);
v = await tab.eval(`JSON.stringify({ n: ${C}.enemies.length, names: ${C}.enemies.map(e => e.name), allDuel: ${C}.enemies.every(e => e.duel === true) })`);
f = JSON.parse(v);
check('apex: pick 0 = the whole pack (8 fighters, all marked)',
f.n === 8 && f.allDuel === true && f.names.includes('Alpha') && f.names.includes('Stalker'), v);
await tab.eval(`${C}.enemies.slice().forEach(e => { e.damage(9999); ${C}.killEnemy(e, 0); }); true`);
await sleep(800);
check('apex: the pack down-drop releases the arena', (await tab.eval(`${C}.duel.active`)) === false);
tab.close();
}
// ------------------------------------------------------------------ mindweave tab
{
const tab = await newTab('http://127.0.0.1:8080/index.html?combat=mindweave');
await tab.attach();
await startGame(tab); // the menu's NEW GAME → the arena's boot
await sleep(3000);
const v = await tab.eval(`JSON.stringify({
boot: ${S}._combatBootMode(),
duel: ${C}.duel,
rows: ${S}.duelPicker ? ${S}.duelPicker.rows().map(r => r.id) : null,
})`);
const boot = JSON.parse(v);
check('mindweave: ?combat=mindweave boots the arena (8 rows, first = the Mindblade)',
boot.boot === 'mindweave' && boot.duel.faction === 'mindweave' && boot.duel.active === false &&
boot.rows && boot.rows.length === 8 && boot.rows[0] === 'weave_mindblade', v);
await tab.eval(`${S}.duelPicker.select(1); true`);
await sleep(1500);
const f0 = JSON.parse(await tab.eval(`(() => { const e = ${C}.enemies[0]; return JSON.stringify({ active: ${C}.duel.active, n: ${C}.enemies.length, name: e && e.name, pattern: e && e.wpn.pattern, ab: e && e.abilities.map(a => a.id) }); })()`));
check('mindweave: pick 1 = the Mindblade (spiral + the blink), 78 armor',
f0.active === true && f0.n === 1 && f0.name === 'Mindblade' && f0.pattern === 'spiral' && f0.ab.includes('blink_reposition'), JSON.stringify(f0));
await sleep(1500);
const f1 = JSON.parse(await tab.eval(`(() => {
const c = ${S}.combat;
const e = c.enemies[0];
const pl = c.ship;
if (!e || !pl) return JSON.stringify({ reason: 'no-enemy-or-player' });
let t = (c._now ? c._now() : 100000) + 1000;
let fired = false;
for (let i = 0; i < 400 && !fired; i++) {
t += 16;
e.nextFireAt = 0;
if (e.update(t, 16, pl)) fired = true;
}
if (!fired) return JSON.stringify({ fired: false });
c.fireEnemy(e, pl, t);
return JSON.stringify({ fired: true, bolts: c.bullets.length, il: c.illusions.length });
})()`));
check('mindweave: the Mindblade fires (its spiral volley lands in the world)',
f1.fired === true && f1.bolts >= 10, JSON.stringify(f1));
await tab.eval(`const e=${C}.enemies[0]; if (e) { e.damage(9999); ${C}.killEnemy(e, 0); } true`);
await sleep(800);
check('mindweave: the kill releases the arena', (await tab.eval(`${C}.duel.active`)) === false);
// The Dreamshell (pick 6 — the large slow shells).
await tab.eval(`${S}.duelPicker.select(6); true`);
await sleep(1500);
const f2 = JSON.parse(await tab.eval(`(() => { const e = ${C}.enemies[0]; return JSON.stringify({ name: e && e.name, pattern: e && e.wpn.pattern, ab: e && e.abilities.map(a => a.id) }); })()`));
check('mindweave: pick 6 = the Dreamshell (shell + the tide + the phantoms)',
f2.name === 'Dreamshell' && f2.pattern === 'shell' && f2.ab.includes('telekinetic_wave'), JSON.stringify(f2));
const f3 = JSON.parse(await tab.eval(`(() => {
const c = ${S}.combat;
const e = c.enemies[0];
const pl = c.ship;
if (!e || !pl) return JSON.stringify({ reason: 'no-enemy-or-player' });
let t = (c._now ? c._now() : 100000) + 1000;
let fired = false;
for (let i = 0; i < 400 && !fired; i++) {
t += 16;
e.nextFireAt = 0; // its clock is ready; only the aim gate stands between
if (e.update(t, 16, pl)) fired = true;
}
if (!fired) return JSON.stringify({ fired: false });
c.fireEnemy(e, pl, t);
return JSON.stringify({ fired: true, bolts: c.bullets.length, ms: c.missiles.length });
})()`));
check('mindweave: the Dreamshell fires (its large shells land in the world)',
f3.fired === true && f3.bolts >= 1, JSON.stringify(f3));
tab.close();
}
// ------------------------------------------------------------------ the demo regression tab
{
const tab = await newTab('http://127.0.0.1:8080/index.html?combat');
await tab.attach();
await startGame(tab); // the menu's NEW GAME → the demo's boot
await sleep(10000);
const v = await tab.eval(`JSON.stringify({
boot: ${S}._combatBootMode(),
wave: ${C}.waveRun ? ${C}.waveRun.state : null,
enemies: ${C}.enemies.length,
picker: !!${S}.duelPicker,
})`);
const d = JSON.parse(v);
check('demo: the plain ?combat STILL boots the seven-type wave demo (unchanged regression)',
d.boot === 'demo' && d.wave && !['cancelled', 'done'].includes(d.wave) && d.picker === false, v);
tab.close();
}
console.log(failures === 0 ? '\nfaction arena CDP: ALL CHECKS PASSED' : `\n${failures} check(s) FAILED`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -185,6 +185,12 @@ const { Missile } = await import(pathToFileURL(join(__dirname, '../js/combat/Mis
const { firePattern, patternOf, stepMissiles, missilesHitPlayer } = await import( const { firePattern, patternOf, stepMissiles, missilesHitPlayer } = await import(
pathToFileURL(join(__dirname, '../js/combat/Weapons.js')).href pathToFileURL(join(__dirname, '../js/combat/Weapons.js')).href
); );
const { factionMeta, factionTypeIds, factionIds } = await import(
pathToFileURL(join(__dirname, '../js/combat/EnemyTypes.js')).href
);
const { behindPoint, offsetPoint, pushImpulse, stepAbility, stepTkwaves } = await import(
pathToFileURL(join(__dirname, '../js/combat/EnemyAbilities.js')).href
);
const { CombatSystem } = await import(pathToFileURL(join(__dirname, '../js/combat/CombatSystem.js')).href); const { CombatSystem } = await import(pathToFileURL(join(__dirname, '../js/combat/CombatSystem.js')).href);
const { CombatHud } = await import(pathToFileURL(join(__dirname, '../js/combat/CombatHud.js')).href); const { CombatHud } = await import(pathToFileURL(join(__dirname, '../js/combat/CombatHud.js')).href);
const { const {
@ -705,6 +711,9 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
const C = { x: 100, y: 0, dead: false, hitRadius: 20 }; const C = { x: 100, y: 0, dead: false, hitRadius: 20 };
check('read: pickTarget = the nearest LIVE hostile', pickTarget([A, B, C], 0, 0) === A); check('read: pickTarget = the nearest LIVE hostile', pickTarget([A, B, C], 0, 0) === A);
check('read: pickTarget skips the dead (even when nearest)', pickTarget([A, B, C], 2, 0) === A); check('read: pickTarget skips the dead (even when nearest)', pickTarget([A, B, C], 2, 0) === A);
const K = { x: 1, y: 0, dead: false, cloaked: true, hitRadius: 20 }; // cloaked — never qualifies
check('read: pickTarget skips cloaked hostiles (untargetable until reveal)',
pickTarget([K, A, C], 0, 0) === A && pickTarget([K], 0, 0) === null);
check('read: pickTarget honors the range (and nulls outside it)', check('read: pickTarget honors the range (and nulls outside it)',
pickTarget([A, C], 0, 0, 50) === A && pickTarget([A, C], 0, 0, 5) === null); pickTarget([A, C], 0, 0, 50) === A && pickTarget([A, C], 0, 0, 5) === null);
check('read: pickTarget empty / all-dead → null', check('read: pickTarget empty / all-dead → null',
@ -793,8 +802,11 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
{ {
const base = enemyTypeConfig(); const base = enemyTypeConfig();
check('types: the base craft = combat.enemy (shipped)', base.hull === E('hull', 60) && base.frame === 0 && base.weapon.range === E('weapon.range', 560)); check('types: the base craft = combat.enemy (shipped)', base.hull === E('hull', 60) && base.frame === 0 && base.weapon.range === E('weapon.range', 560));
check('types: the shipped roster = raider + the seven new types (frames 0–7)', check('types: the shipped roster = the eight classics + the sixteen faction fighters',
enemyTypeIds().join(',') === 'raider,bomber,spitter,stinger,hunter,gunner,barrage,warden'); enemyTypeIds().length === 24 &&
enemyTypeIds().slice(0, 8).join(',') === 'raider,bomber,spitter,stinger,hunter,gunner,barrage,warden' &&
enemyTypeIds().slice(8, 16).join(',') === 'apex_raptor,apex_mauler,apex_stalker,apex_screamer,apex_reaver,apex_fang,apex_tyrant,apex_alpha' &&
enemyTypeIds().slice(16, 24).join(',') === 'weave_mindblade,weave_telekine,weave_phantasm,weave_weaver,weave_lure,weave_dreamer,weave_serpent,weave_overmind');
const rosterOrder = ['bomber', 'spitter', 'stinger', 'hunter', 'gunner', 'barrage', 'warden']; const rosterOrder = ['bomber', 'spitter', 'stinger', 'hunter', 'gunner', 'barrage', 'warden'];
check('types: frames 1–7 are distinct (one per sheet frame)', check('types: frames 1–7 are distinct (one per sheet frame)',
@ -1487,6 +1499,306 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
} }
} }
// ------------------------------------------------- the faction duel arena (apex / mindweave)
{
const apexIds = factionTypeIds('apex');
const mwIds = factionTypeIds('mindweave');
const all = [...apexIds, ...mwIds];
check('factions: two rosters, eight fighters each (the duel arena)',
factionIds().includes('apex') && factionIds().includes('mindweave') &&
apexIds.length === 8 && mwIds.length === 8);
check('factions: every fighter is a registered enemyType with a name',
all.every((id) => {
const c = enemyTypeConfig(id);
return typeof c.name === 'string' && c.name !== '';
}));
check('factions: apex rides frames 30-37 (display order)',
apexIds.map((id) => enemyTypeConfig(id).frame).join(',') === '30,31,32,33,34,35,36,37');
check('factions: mindweave rides frames 40-47 (display order)',
mwIds.map((id) => enemyTypeConfig(id).frame).join(',') === '40,41,42,43,44,45,46,47');
check('factions: every fighter carries a weapon pattern AND at least one ability',
all.every((id) => {
const c = enemyTypeConfig(id);
return typeof (c.weapon ?? {}).pattern === 'string' &&
Array.isArray(c.abilities) && c.abilities.length >= 1 &&
c.abilities.every((a) => typeof a.id === 'string');
}));
// The balance rule (the user's numbers): BOTH FACTIONS = 2x the pirate's
// damage (16 = 8 x 2) and 3.5x its armor (175 + 35 = 210 = 60 x 3.5).
const raider = enemyTypeConfig('raider');
const rArmor = raider.hull + (raider.shields ?? 0);
const rDmg = raider.weapon.damage;
const armorOf = (id) => {
const c = enemyTypeConfig(id);
return c.hull + (c.shields ?? 0);
};
const dmgOf = (id) => {
const w = enemyTypeConfig(id).weapon;
return Math.max(Number(w.damage) || 0, Number(w.shellDamage) || 0, Number(w.missileDamage) || 0);
};
check('balance: every fighter armor is 3.5x the pirate (210 = 60 x 3.5)',
all.every((id) => armorOf(id) === 210 && armorOf(id) / rArmor >= 3.45 && armorOf(id) / rArmor <= 3.55));
check('balance: every fighter bolt/shell/seeker damage is 2x the pirate (16 = 8 x 2)',
all.every((id) => dmgOf(id) === 16 && dmgOf(id) / rDmg >= 1.95 && dmgOf(id) / rDmg <= 2.05));
check('balance: the XP rule of thumb holds ((hull+shields)/5 rounded = 42)',
all.every((id) => {
const c = enemyTypeConfig(id);
return c.xp === Math.round((c.hull + (c.shields ?? 0)) / 5);
}) && all.every((id) => enemyTypeConfig(id).xp === 42));
check('apex: the shared kit - the cloak on most, the flank cut on most',
apexIds.filter((id) => enemyTypeConfig(id).abilities.some((a) => a.id === 'cloaked_ambush')).length >= 4 &&
apexIds.filter((id) => enemyTypeConfig(id).abilities.some((a) => a.id === 'flank_strafe')).length >= 4);
check('mindweave: the shared kit - the blink or the phantoms on every fighter',
mwIds.every((id) => enemyTypeConfig(id).abilities.some((a) => a.id === 'blink_reposition' || a.id === 'illusion_decoy')));
check('apex: eight distinct attack styles (pattern + the full ability kit)',
new Set(apexIds.map((id) => JSON.stringify([enemyTypeConfig(id).weapon.pattern, enemyTypeConfig(id).abilities]))).size === 8);
check('mindweave: eight distinct attack styles (pattern + the full ability kit)',
new Set(mwIds.map((id) => JSON.stringify([enemyTypeConfig(id).weapon.pattern, enemyTypeConfig(id).abilities]))).size === 8);
}
// ------------------------------------------------- the faction weapon patterns (sweep / spiral / shell)
{
const scene = makeScene({ withSheet: true });
scene.sound = { play: () => {} };
scene.cache = { audio: { has: () => true } };
const mkHost = () => ({
scene,
fx: { muzzle: () => {}, flash: () => {}, explode: () => {} },
missiles: [], bolts: [],
spawnBullet(x, y, a, wpn, owner) { this.bolts.push({ x, y, a, wpn, owner }); },
hitsSwept: () => false,
damagePlayer() {},
});
const ship = { x: 0, y: 0, hitRadius: 20, heading: () => Math.PI / 2 };
const player = { x: 100, y: 0 };
const sweepWpn = enemyTypeConfig('apex_raptor').weapon;
const h = mkHost();
const kind = firePattern(h, ship, player, sweepWpn);
const as = h.bolts.map((b) => b.a);
const center = (Math.max(...as) + Math.min(...as)) / 2;
const span = Math.max(...as) - Math.min(...as);
check('weapons: sweep = a fan centered on the craft heading',
kind === 'sweep' && h.bolts.length === sweepWpn.bolts &&
Math.abs(center - Math.PI / 2) < 0.035 &&
span >= sweepWpn.fan - 0.07 && span <= sweepWpn.fan + 0.07);
const spiralWpn = enemyTypeConfig('weave_mindblade').weapon;
const h2 = mkHost();
const kind2 = firePattern(h2, ship, player, spiralWpn);
const sas = h2.bolts.map((b) => b.a).sort((x, y) => x - y);
const step = sas.slice(1).reduce((m, a2, i2) => Math.max(m, a2 - sas[i2]), 0);
check('weapons: spiral = the bolts stepped around the circle (the bloom)',
kind2 === 'spiral' && h2.bolts.length === spiralWpn.bolts && step > 0.45);
check('weapons: the spiral staggers its speeds (the arms open in flight)',
new Set(h2.bolts.map((b) => b.wpn.bulletSpeed)).size === spiralWpn.bolts);
const shellWpn = enemyTypeConfig('weave_dreamer').weapon;
const h3 = mkHost();
const kind3 = firePattern(h3, ship, player, shellWpn);
const s = h3.bolts[0] && h3.bolts[0].wpn;
check('weapons: shell = one large-radius slow orb with the heavy punch',
kind3 === 'shell' && h3.bolts.length === 1 && s && s.size >= 40 && s.bulletSpeed < 200 && s.damage >= 11);
const over = enemyTypeConfig('weave_overmind').weapon;
const h4 = mkHost();
check('weapons: the Overmind triple shells ride one volley (three orbs)',
firePattern(h4, ship, player, over) === 'shell' && h4.bolts.length === 3);
}
// ------------------------------------------------- the faction ability hands (cloak / blink / the tide)
{
const rMid = () => 0.5; // every injected draw = the band's midpoint
const p1 = behindPoint(0, 0, 0, 100, 0, rMid);
check('abilities: the ambush stand sits on the player REAR arc',
p1.x < 0 && Math.abs(p1.y) < 1e-9);
const p2 = offsetPoint(0, 0, 0, 50, 50, 1, rMid);
check('abilities: the blink destination honors the rear bias',
p2.x < 0 && Math.abs(p2.y) < 1e-9);
const pw = pushImpulse({ x: 0, y: 0, dir: 'push', power: 100 }, { x: 10, y: 0 });
const pl = pushImpulse({ x: 0, y: 0, dir: 'pull', power: 100 }, { x: 10, y: 0 });
check('abilities: the tide shoves away (push) or drags in (pull)',
pw.vx === 100 && pw.vy === 0 && pl.vx === -100 && pl.vy === 0);
const realRand = Math.random;
Math.random = () => 0.5;
const player = { x: 0, y: 0 };
const baseCtx = {
scene: null,
fx: { ring() {}, sparks() {} },
tkwaves: [], illusions: [],
playerFacing: () => 0,
teleportCraft(e, x, y) { e.x = x; e.y = y; },
};
try {
{
const ctx = { ...baseCtx, tkwaves: [], illusions: [] };
const e = {
x: 500, y: 0, dead: false, cloaked: false, _ab: {},
abilities: [{ id: 'cloaked_ambush', cloakMs: 3000 }],
setAlpha() {},
};
stepAbility(ctx, e, player, 0);
check('ability: the cloaked ambush phases the craft into a nearby stand',
e.cloaked === true && Math.hypot(e.x, e.y) < 400);
check('ability: the stand is BEHIND the player (x < 0)', e.x < 0);
stepAbility(ctx, e, player, 2999);
check('ability: the cloak holds until its clock runs', e.cloaked === true);
e.nextFireAt = 9999;
stepAbility(ctx, e, player, 3000);
check('ability: the reveal is the strike - cloak off, fire free',
e.cloaked === false && e.nextFireAt === 0);
}
{
const ctx = { ...baseCtx, tkwaves: [], illusions: [] };
const w = {
x: 40, y: 0, dead: false, _ab: {}, hitRadius: 20, scale: 1, sheetFrame: 43,
abilities: [{ id: 'blink_reposition', cooldown: 12 }],
setAlpha() {},
};
stepAbility(ctx, w, player, 0);
w._ab.blink_reposition.nextAt = 0;
stepAbility(ctx, w, player, 1);
const d = Math.hypot(w.x, w.y);
check('ability: the blink hops the weaver to a fresh stand (a screen away)',
(w.x !== 40 || w.y !== 0) && d >= 200 && d <= 330);
check('ability: the blink leaves a phantom AT THE OLD POSITION',
ctx.illusions.length === 1 && ctx.illusions[0].x === 40 && ctx.illusions[0].y === 0);
}
{
const ctx = { ...baseCtx, tkwaves: [], illusions: [], fx: { ring() {}, sparks() {}, flash() {} } };
const k = {
x: 300, y: 0, dead: false, _ab: {},
abilities: [{ id: 'telekinetic_wave', cooldown: 14, dir: 'push', radius: 290, power: 280 }],
};
const pl3 = { x: 100, y: 0, body: { velocity: { x: 0, y: 0 } } };
stepAbility(ctx, k, pl3, 0);
check('ability: the tide arms on first contact (silent until its clock)',
k._ab.telekinetic_wave && k._ab.telekinetic_wave.nextAt > 0 && ctx.tkwaves.length === 0);
stepAbility(ctx, k, pl3, 5600); // its clock runs (0.4 x 14 s)
check('ability: at its clock the tide BLOOMS (the wave is armed)',
ctx.tkwaves.length === 1 && ctx.tkwaves[0].power === 280);
stepTkwaves(ctx, 5600, pl3);
check('ability: the wave is silent before its arrival', pl3.body.velocity.x === 0);
stepTkwaves(ctx, 6251, pl3);
check('ability: at arrival the push tide shoves the player AWAY',
pl3.body.velocity.x < 0 && ctx.tkwaves.length === 0);
}
} finally {
Math.random = realRand;
}
}
// ------------------------------------------------- the faction duel's seam (spawn -> kill -> re-pick)
{
const scene = makeScene({ withSheet: true });
scene.sound = { play: () => {} };
scene.cache = { audio: { has: () => true } };
const ship = {
x: 0, y: 0, rotation: 0, artOffset: 0, body: { velocity: { x: 0, y: 0 } },
hitRadius: 16, _heading: () => 0, _headingRaw: () => 0,
shieldFrac: () => 1, _shieldFrac: 1, dead: false,
};
const combat = new CombatSystem(scene, { ship, onKill: () => {} });
const d = combat.startDuel('apex');
check('duel: startDuel arms the arena state', d && d.faction === 'apex');
const e = combat.spawnDuel('apex_mauler');
check('duel: spawnDuel marks its fighter (no respawn, spent when down)',
e && e.duel === true && e.respawn === false && combat.duel.active === true &&
combat.enemies.includes(e));
const e2 = combat.spawnDuelPack('mindweave');
check('duel: the pack is the faction full roster (eight, replacing the single)',
e2.length === 8 && e2.every((x) => x.duel === true) &&
combat.enemies.length === 8 && !combat.enemies.includes(e));
for (const x of combat.enemies) x.damage(9999);
combat.update(100, 16);
let killed = 0;
const kill = combat.killEnemy.bind(combat);
for (const x of combat.enemies.slice()) { kill(x, 116); killed++; }
check('duel: the pack down-drop releases the arena (duel.active back to the picker)',
killed === 8 && combat.duel.active === false);
}
// THE SHEET ITSELF — the sixteen faction fighters' frames (30-37, 40-47)
// must carry ART: a blank cell renders as an invisible ship, which every
// other test would happily pass. A tiny PNG alpha scan (zlib inflate +
// unfilter) counts the opaque pixels per 128px cell. The shipped sheet is
// an 8-bit PALETTE PNG (PLTE + tRNS alpha); RGBA is also accepted.
{
const fs = await import('node:fs');
const zlib = await import('node:zlib');
const png = fs.readFileSync(join(__dirname, '../assets', 'images', 'ships-enemy.png'));
// Walk the chunks: IHDR (size, depth, color type) + PLTE/tRNS (palette
// alpha) + the IDAT payload.
let pos = 8, width = 0, height = 0, depth = 0, ctype = 0;
let palte = null, trns = null;
const idat = [];
while (pos < png.length) {
const len = png.readUInt32BE(pos);
const type = png.subarray(pos + 4, pos + 8).toString('latin1');
const body = png.subarray(pos + 8, pos + 8 + len);
if (type === 'IHDR') { width = body.readUInt32BE(0); height = body.readUInt32BE(4); depth = body[8]; ctype = body[9]; }
else if (type === 'PLTE') palte = body;
else if (type === 'tRNS') trns = body;
if (type === 'IDAT') idat.push(body);
pos += 12 + len;
}
const palette = ctype === 3;
const rgba = ctype === 6;
check('sheet: ships-enemy.png is a decodable 1280x1280 8-bit sheet (palette+tRNS, or RGBA)',
width === 1280 && height === 1280 && depth === 8 && (palette || rgba) && (!palette || palte));
const raw = zlib.inflateSync(Buffer.concat(idat));
const bpp = palette ? 1 : 4; // palette: one index byte per pixel; RGBA: four
const stride = width * bpp;
const px = Buffer.alloc(stride * height);
let off = 0;
for (let y = 0; y < height; y++) {
const f = raw[off]; off++;
for (let x = 0; x < stride; x++) {
const cur = raw[off + x]; off++;
if (f === 0) px[y * stride + x] = cur;
else if (f === 1) px[y * stride + x] = cur + (x >= bpp ? px[y * stride + x - bpp] : 0);
else if (f === 2) px[y * stride + x] = cur + (y > 0 ? px[(y - 1) * stride + x] : 0);
else if (f === 3) px[y * stride + x] = cur + ((x >= bpp ? px[y * stride + x - bpp] : 0) + (y > 0 ? px[(y - 1) * stride + x] : 0)) / 2;
else { // Paeth
const a = x >= bpp ? px[y * stride + x - bpp] : 0;
const b = y > 0 ? px[(y - 1) * stride + x] : 0;
const c = x >= bpp && y > 0 ? px[(y - 1) * stride + x - bpp] : 0;
const p = a + b - c;
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
const pr = pa <= pb && pa <= pc ? a : (pb <= pc ? b : c);
px[y * stride + x] = cur + pr;
}
}
}
// A pixel is OPAQUE when its alpha is > 0: the tRNS table for the
// palette (entries past the table are opaque by the PNG spec), the
// alpha channel for RGBA.
const opaqueAt = (i) => {
if (palette) {
const a = trns && i < trns.length ? trns[i] : 255;
return a > 0;
}
return px[i * 4 + 3] > 0;
};
const cellOpaque = (idx) => {
const r = Math.floor(idx / 10) * 128, c = (idx % 10) * 128;
let n = 0;
for (let y = r; y < r + 128; y += 4) for (let x = c; x < c + 128; x += 4) if (opaqueAt(y * width + x)) n++;
return n;
};
const blank = [];
for (const idx of [...Array(38).keys()].filter((i) => i >= 30).concat([...Array(48).keys()].filter((i) => i >= 40))) {
if (cellOpaque(idx) === 0) blank.push(idx);
}
check('sheet: every faction frame (30-37 the APEX, 40-47 the MINDWEAVE) carries art (no blank/invisible ships)',
blank.length === 0, blank.length ? `blank frames: ${blank.join(', ')}` : '16/16 painted');
const still = [0, 20].map((i) => cellOpaque(i));
check('sheet: the classic pirates\u2019 frames are untouched (still painted)', still.every((n) => n > 0), JSON.stringify(still));
}
// ------------------------------------------------------------------ done // ------------------------------------------------------------------ done
console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`); console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`);

441
dev/make-faction-sprites.py Normal file
View File

@ -0,0 +1,441 @@
#!/usr/bin/env python3
"""Paint the sixteen faction fighters into assets/images/ships-enemy.png.
Frames 30-37 (row 3) are the APEX (the predators — angular, red-hot);
frames 40-47 (row 4) are the MINDWEAVE (the weavers — floating, violet-cold).
Each cell is 128x128 inside the 10x10 sheet. Every ship is drawn nose-UP
(the game rotates the frame at runtime).
Drawn at 4x supersample (512) and downscaled for clean edges, in the sheet's
existing idiom: dark warm hulls, bright accent lines, engine glow at the
bottom. The other cells (the pirates, the neutrals) are left untouched.
"""
import math
from PIL import Image, ImageDraw, ImageFilter
SHEET = 'assets/images/ships-enemy.png'
CELL = 128
SS = 4 # supersample
W = CELL * SS # 512 draw space
C = W / 2 # center
def lerp(a, b, t):
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
def vgrad(shape_pts, top, bottom):
"""A vertical two-tone fill: paint the polygon, then shade the lower
half toward `bottom` by compositing a semi-transparent band clipped to
the polygon. `shape_pts` in 512-space."""
return top, bottom
class Ship:
"""A 512-space canvas with helpers. All coords in 512-space."""
def __init__(self):
self.im = Image.new('RGBA', (W, W), (0, 0, 0, 0))
self.d = ImageDraw.Draw(self.im)
def poly(self, pts, fill):
self.d.polygon(pts, fill=fill)
def oval(self, box, fill=None, outline=None, width=0):
self.d.ellipse(box, fill=fill, outline=outline, width=width)
def line(self, a, b, fill, width=4):
self.d.line([a, b], fill=fill, width=width)
def arc(self, box, a0, a1, fill, width=6):
self.d.arc(box, a0, a1, fill=fill, width=width)
def sline(self, a, b, fill, w1, w2):
"""A tapered stripe (two-segment fake taper)."""
mx = (a[0] + b[0]) / 2
my = (a[1] + b[1]) / 2
self.line(a, (mx, my), fill, int((w1 + w2) / 2))
self.line((mx, my), b, fill, w2)
def glow_dot(self, x, y, r, color):
"""A radial glow (engine / orb), drawn as concentred fading rings."""
rgb = color[:3] if len(color) >= 3 else (255, 255, 255)
for i in range(r, 0, -1):
a = int(255 * (i / r) ** 2 * 0.9)
self.oval((x - i, y - i, x + i, y + i), fill=(rgb[0], rgb[1], rgb[2], a))
def finish(self):
return self.im.resize((CELL, CELL), Image.LANCZOS)
# ---------------------------------------------------------------- the APEX
# Angular, predatory, nose-up. Dark warm hulls; red-hot accents.
APEX_HULL = (74, 66, 70, 255)
APEX_DARK = (40, 34, 40, 255)
APEX_LITE = (110, 98, 104, 255)
APEX_EDGE = (150, 132, 140, 255)
APEX_RED = (216, 62, 44, 255)
APEX_ORANGE = (236, 128, 52, 255)
APEX_CANY = (26, 30, 38, 255)
APEX_ENGINE = (255, 150, 60)
def raptor():
"""30 — a narrow dart with a forward spine cannon and thin intakes."""
s = Ship()
s.poly([(C, 40), (C + 46, 300), (C + 70, 430), (C + 34, 470), (C - 34, 470), (C - 70, 430), (C - 46, 300)], APEX_HULL)
s.poly([(C, 40), (C + 30, 300), (C + 22, 430), (C - 22, 430), (C - 30, 300)], APEX_DARK)
s.poly([(C, 40), (C + 12, 220), (C - 12, 220)], APEX_ORANGE) # the spine
s.oval((C - 18, 150, C + 18, 190), fill=APEX_CANY) # the canopy
s.sline((C - 60, 330), (C - 84, 420), APEX_EDGE, 10, 16) # the intakes
s.sline((C + 60, 330), (C + 84, 420), APEX_EDGE, 10, 16)
s.poly([(C - 16, 452), (C - 40, 486), (C - 8, 486)], APEX_DARK) # the split tail
s.poly([(C + 16, 452), (C + 40, 486), (C + 8, 486)], APEX_DARK)
s.glow_dot(C - 22, 480, 14, APEX_ENGINE)
s.glow_dot(C + 22, 480, 14, APEX_ENGINE)
s.poly([(C, 40), (C + 10, 90), (C - 10, 90)], APEX_RED) # the nose tip
return s
def mauler():
"""31 — a broad hammer hull with the big forward cannon."""
s = Ship()
s.poly([(C, 70), (C + 78, 150), (C + 96, 330), (C + 60, 450), (C - 60, 450), (C - 96, 330), (C - 78, 150)], APEX_HULL)
s.poly([(C, 120), (C + 40, 180), (C + 46, 340), (C - 46, 340), (C - 40, 180)], APEX_DARK)
s.poly([(C - 26, 30), (C + 26, 30), (C + 20, 170), (C - 20, 170)], APEX_EDGE) # the cannon tube
s.oval((C - 34, 18, C + 34, 54), fill=APEX_DARK, outline=APEX_RED, width=8) # the muzzle
s.oval((C - 16, 190, C + 16, 230), fill=APEX_CANY)
s.poly([(C - 96, 330), (C - 130, 400), (C - 84, 420)], APEX_LITE) # the shoulder blades
s.poly([(C + 96, 330), (C + 130, 400), (C + 84, 420)], APEX_LITE)
s.line((C - 60, 250), (C - 60, 380), APEX_RED, 10)
s.line((C + 60, 250), (C + 60, 380), APEX_RED, 10)
s.poly([(C - 20, 430), (C - 34, 470), (C, 460)], APEX_DARK)
s.poly([(C + 20, 430), (C + 34, 470), (C, 460)], APEX_DARK)
s.glow_dot(C, 466, 16, APEX_ENGINE)
return s
def stalker():
"""32 — the cloaker: a long thin blade with four stealth fins."""
s = Ship()
s.poly([(C, 24), (C + 26, 220), (C + 40, 420), (C - 40, 420), (C - 26, 220)], APEX_HULL)
s.poly([(C, 24), (C + 12, 240), (C + 16, 400), (C - 16, 400), (C - 12, 240)], APEX_DARK)
for sx in (-1, 1):
s.poly([(C + sx * 30, 180), (C + sx * 120, 240), (C + sx * 96, 300), (C + sx * 34, 300)], APEX_LITE)
s.poly([(C + sx * 34, 320), (C + sx * 110, 380), (C + sx * 88, 420), (C + sx * 38, 420)], APEX_DARK)
s.line((C + sx * 36, 196), (C + sx * 108, 252), APEX_RED, 6)
s.oval((C - 10, 140, C + 10, 176), fill=APEX_CANY)
s.line((C, 60), (C, 200), APEX_ORANGE, 8)
s.poly([(C - 14, 410), (C, 470), (C + 14, 410)], APEX_DARK)
s.glow_dot(C, 462, 12, APEX_ENGINE)
return s
def screamer():
"""33 — the wide V wings with the spray nozzles along each edge."""
s = Ship()
s.poly([(C, 60), (C + 30, 160), (C + 44, 380), (C - 44, 380), (C - 30, 160)], APEX_HULL)
s.poly([(C - 26, 150), (C - 150, 330), (C - 120, 380), (C - 30, 300)], APEX_LITE)
s.poly([(C + 26, 150), (C + 150, 330), (C + 120, 380), (C + 30, 300)], APEX_LITE)
for sx in (-1, 1):
for i, t in enumerate((0.15, 0.38, 0.6, 0.82)):
x = C + sx * (30 + t * 115)
y = 150 + t * 185
s.oval((x - 9, y - 9, x + 9, y + 9), fill=APEX_ORANGE)
s.poly([(C - 18, 60), (C + 18, 60), (C + 12, 140), (C - 12, 140)], APEX_EDGE)
s.oval((C - 12, 180, C + 12, 220), fill=APEX_CANY)
s.line((C, 240), (C, 360), APEX_RED, 8)
s.poly([(C - 20, 370), (C - 44, 440), (C, 430)], APEX_DARK)
s.poly([(C + 20, 370), (C + 44, 440), (C, 430)], APEX_DARK)
s.glow_dot(C - 24, 430, 12, APEX_ENGINE)
s.glow_dot(C + 24, 430, 12, APEX_ENGINE)
return s
def reaver():
"""34 — twin rapid-fire pods flanking a lean hull."""
s = Ship()
s.poly([(C, 50), (C + 44, 160), (C + 56, 400), (C - 56, 400), (C - 44, 160)], APEX_HULL)
s.poly([(C, 100), (C + 24, 180), (C + 30, 380), (C - 30, 380), (C - 24, 180)], APEX_DARK)
for sx in (-1, 1):
s.oval((C + sx * 96 - 34, 190, C + sx * 96 + 34, 258), fill=APEX_LITE)
s.oval((C + sx * 96 - 24, 120, C + sx * 96 + 24, 172), fill=APEX_EDGE)
s.oval((C + sx * 96 - 14, 96, C + sx * 96 + 14, 130), fill=APEX_RED)
s.poly([(C + sx * 40, 220), (C + sx * 72, 214), (C + sx * 72, 252), (C + sx * 44, 262)], APEX_EDGE)
s.oval((C - 14, 150, C + 14, 196), fill=APEX_CANY)
s.poly([(C, 50), (C + 12, 110), (C - 12, 110)], APEX_RED)
s.line((C - 34, 280), (C + 34, 280), APEX_RED, 8)
s.line((C - 38, 320), (C + 38, 320), APEX_RED, 6)
s.poly([(C - 18, 390), (C - 30, 452), (C, 444)], APEX_DARK)
s.poly([(C + 18, 390), (C + 30, 452), (C, 444)], APEX_DARK)
s.glow_dot(C - 18, 444, 12, APEX_ENGINE)
s.glow_dot(C + 18, 444, 12, APEX_ENGINE)
return s
def fang():
"""35 — the burst striker: two curved fang blades sweeping up."""
s = Ship()
for sx in (-1, 1):
pts = []
for i in range(13):
t = i / 12
x = C + sx * (28 + math.sin(t * 1.9) * 92 * (1 - t * 0.25))
y = 400 - t * 360
pts.append((x, y))
for i in range(12, -1, -1):
t = i / 12
x = C + sx * (24 + math.sin(t * 1.9) * 66 * (1 - t * 0.3))
y = 430 - t * 370
pts.append((x, y))
s.poly(pts, APEX_LITE)
s.line((C + sx * 40, 380), (C + sx * (40 + math.sin(0.85 * 1.9) * 80), 380 - 0.7 * 360), APEX_RED, 6)
s.poly([(C, 120), (C + 34, 220), (C + 40, 430), (C - 40, 430), (C - 34, 220)], APEX_HULL)
s.poly([(C, 160), (C + 18, 240), (C + 22, 400), (C - 22, 400), (C - 18, 240)], APEX_DARK)
s.oval((C - 12, 240, C + 12, 280), fill=APEX_CANY)
s.line((C, 130), (C, 220), APEX_ORANGE, 8)
s.glow_dot(C - 16, 428, 12, APEX_ENGINE)
s.glow_dot(C + 16, 428, 12, APEX_ENGINE)
return s
def tyrant():
"""36 — the broad hex hull with the two heavy missile bays."""
s = Ship()
s.poly([(C, 60), (C + 84, 120), (C + 108, 280), (C + 64, 440), (C - 64, 440), (C - 108, 280), (C - 84, 120)], APEX_HULL)
s.poly([(C, 110), (C + 44, 160), (C + 52, 340), (C - 52, 340), (C - 44, 160)], APEX_DARK)
for sx in (-1, 1):
s.poly([(C + sx * 58, 150), (C + sx * 96, 160), (C + sx * 96, 260), (C + sx * 60, 250)], APEX_LITE)
s.oval((C + sx * 88 - 16, 128, C + sx * 88 + 16, 164), fill=APEX_DARK, outline=APEX_RED, width=6)
s.oval((C - 16, 170, C + 16, 214), fill=APEX_CANY)
s.line((C - 70, 300), (C + 70, 300), APEX_RED, 12)
s.poly([(C, 60), (C + 14, 120), (C - 14, 120)], APEX_ORANGE)
s.poly([(C - 24, 420), (C - 40, 470), (C, 458)], APEX_DARK)
s.poly([(C + 24, 420), (C + 40, 470), (C, 458)], APEX_DARK)
s.glow_dot(C, 462, 16, APEX_ENGINE)
return s
def alpha():
"""37 — the flagship: layered wings, the crown fins, gold-hot trim."""
s = Ship()
s.poly([(C, 30), (C + 60, 140), (C + 76, 330), (C + 44, 460), (C - 44, 460), (C - 76, 330), (C - 60, 140)], APEX_HULL)
s.poly([(C - 30, 150), (C - 140, 340), (C - 104, 396), (C - 34, 310)], APEX_LITE)
s.poly([(C + 30, 150), (C + 140, 340), (C + 104, 396), (C + 34, 310)], APEX_LITE)
s.poly([(C - 40, 320), (C - 96, 420), (C - 60, 448), (C - 40, 390)], APEX_EDGE)
s.poly([(C + 40, 320), (C + 96, 420), (C + 60, 448), (C + 40, 390)], APEX_EDGE)
s.poly([(C, 30), (C + 26, 160), (C + 30, 360), (C - 30, 360), (C - 26, 160)], APEX_DARK)
s.poly([(C, 30), (C + 10, 120), (C - 10, 120)], (232, 196, 84, 255)) # the gold tip
for sx in (-1, 1):
s.poly([(C + sx * 18, 120), (C + sx * 54, 60), (C + sx * 40, 150)], (232, 196, 84, 255)) # the crown
s.oval((C - 14, 180, C + 14, 224), fill=APEX_CANY)
s.line((C - 50, 260), (C + 50, 260), APEX_RED, 10)
s.oval((C - 40, 350, C + 40, 400), outline=(232, 196, 84, 255), width=8)
s.poly([(C - 16, 440), (C - 30, 484), (C, 474)], APEX_DARK)
s.poly([(C + 16, 440), (C + 30, 484), (C, 474)], APEX_DARK)
s.glow_dot(C - 18, 476, 13, APEX_ENGINE)
s.glow_dot(C + 18, 476, 13, APEX_ENGINE)
return s
# --------------------------------------------------------------- the WEAVE
# Floating, organic, calm. Cool violet hulls; violet-cyan lights.
WE_HULL = (70, 62, 88, 255)
WE_DARK = (42, 36, 58, 255)
WE_LITE = (108, 98, 136, 255)
WE_EDGE = (148, 138, 176, 255)
WE_VIOLET = (156, 106, 224, 255)
WE_CYAN = (92, 208, 216, 255)
WE_PEARL = (214, 190, 255, 255)
WE_ENGINE = (170, 120, 255)
WE_CANY = (24, 26, 44, 255)
def mindblade():
"""40 — the ringed blade: the annulus around a dark core."""
s = Ship()
s.oval((C - 104, 128, C + 104, 384), outline=WE_CYAN, width=14)
s.oval((C - 104, 128, C + 104, 384), outline=WE_VIOLET, width=4)
for a in range(0, 360, 45):
x = C + 98 * math.cos(math.radians(a))
y = 256 + 98 * math.sin(math.radians(a))
s.oval((x - 12, y - 12, x + 12, y + 12), fill=WE_PEARL)
s.poly([(C, 90), (C + 44, 190), (C + 44, 330), (C, 422), (C - 44, 330), (C - 44, 190)], WE_HULL)
s.poly([(C, 130), (C + 26, 200), (C + 26, 320), (C, 380), (C - 26, 320), (C - 26, 200)], WE_DARK)
s.oval((C - 16, 220, C + 16, 268), fill=WE_CANY)
s.glow_dot(C, 256, 22, WE_VIOLET)
s.glow_dot(C, 60, 14, WE_CYAN)
s.glow_dot(C, 452, 14, WE_ENGINE)
return s
def telekine():
"""41 — the floating orb above the hull; two small orb-hands."""
s = Ship()
s.glow_dot(C, 78, 26, WE_CYAN)
s.oval((C - 18, 60, C + 18, 96), fill=WE_PEARL)
s.line((C, 104), (C, 140), WE_VIOLET, 6)
s.poly([(C, 140), (C + 52, 220), (C + 60, 380), (C + 30, 452), (C - 30, 452), (C - 60, 380), (C - 52, 220)], WE_HULL)
s.poly([(C, 180), (C + 32, 240), (C + 36, 380), (C - 36, 380), (C - 32, 240)], WE_DARK)
for sx in (-1, 1):
s.oval((C + sx * 118 - 22, 250, C + sx * 118 + 22, 294), fill=WE_LITE)
s.glow_dot(C + sx * 118, 272, 12, WE_CYAN)
s.line((C + sx * 56, 280), (C + sx * 98, 272), WE_EDGE, 6)
s.oval((C - 14, 240, C + 14, 286), fill=WE_CANY)
s.arc((C - 60, 340, C + 60, 420), 150, 30, WE_VIOLET, 8)
s.glow_dot(C - 20, 446, 12, WE_ENGINE)
s.glow_dot(C + 20, 446, 12, WE_ENGINE)
return s
def phantasm():
"""42 — the ghost: layered translucent shrouds and a wavy tail."""
s = Ship()
s.poly([(C, 80), (C + 70, 200), (C + 84, 340), (C + 52, 400), (C - 52, 400), (C - 84, 340), (C - 70, 200)], WE_HULL)
for i, (w, a) in enumerate(((110, 150), (96, 190), (82, 230))):
pts = []
for k in range(24):
t = k / 23
x = C - w + t * 2 * w
y = 300 + i * 26 + math.sin(t * math.pi * 2 + i) * 12
pts.append((x, y))
s.poly(pts, (WE_LITE[0], WE_LITE[1], WE_LITE[2], a))
for sx in (-1, 1):
pts = [(C + sx * 40, 160)]
for k in range(9):
t = k / 8
pts.append((C + sx * (44 + math.sin(t * 2.2) * 46), 380 - t * 160))
for k in range(8, -1, -1):
t = k / 8
pts.append((C + sx * (34 + math.sin(t * 2.2) * 30), 400 - t * 170))
s.poly(pts, (120, 110, 156, 200))
s.oval((C - 20, 180, C + 20, 236), fill=WE_DARK)
s.oval((C - 14, 196, C + 14, 222), fill=WE_CANY)
s.arc((C - 26, 150, C + 26, 200), 200, 340, WE_PEARL, 6)
s.glow_dot(C, 60, 14, WE_VIOLET)
s.glow_dot(C - 18, 420, 10, WE_ENGINE)
s.glow_dot(C + 18, 420, 10, WE_ENGINE)
return s
def weaver():
"""43 — the diamond hull in the web between two halo rings."""
s = Ship()
for r in (118, 82):
s.oval((C - r, 256 - r, C + r, 256 + r), outline=WE_EDGE, width=6)
for a in range(0, 360, 30):
x1 = C + 82 * math.cos(math.radians(a))
y1 = 256 + 82 * math.sin(math.radians(a))
x2 = C + 118 * math.cos(math.radians(a))
y2 = 256 + 118 * math.sin(math.radians(a))
s.line((x1, y1), (x2, y2), (148, 138, 176, 160), 3)
s.poly([(C, 120), (C + 54, 256), (C, 392), (C - 54, 256)], WE_HULL)
s.poly([(C, 158), (C + 34, 256), (C, 354), (C - 34, 256)], WE_DARK)
s.oval((C - 12, 236, C + 12, 276), fill=WE_CANY)
s.glow_dot(C, 256, 18, WE_CYAN)
s.glow_dot(C, 96, 12, WE_VIOLET)
s.glow_dot(C, 416, 12, WE_ENGINE)
return s
def lure():
"""44 — the hook: the crescent ring around the nose, the bait dots."""
s = Ship()
s.arc((C - 96, 96, C + 96, 304), 300, 240, WE_CYAN, 12)
for a in (300, 330, 270, 240):
x = C + 96 * math.cos(math.radians(a))
y = 200 + 96 * math.sin(math.radians(a))
s.glow_dot(x, y, 8, WE_PEARL)
s.poly([(C, 150), (C + 56, 230), (C + 64, 380), (C + 34, 450), (C - 34, 450), (C - 64, 380), (C - 56, 230)], WE_HULL)
s.poly([(C, 190), (C + 34, 250), (C + 38, 380), (C - 38, 380), (C - 34, 250)], WE_DARK)
for i, y in enumerate((230, 270, 310)):
s.line((C - 30 + i * 4, y), (C + 30 - i * 4, y), WE_VIOLET, 6)
s.oval((C - 14, 230, C + 14, 276), fill=WE_CANY)
s.glow_dot(C, 176, 12, WE_PEARL)
s.glow_dot(C - 20, 444, 11, WE_ENGINE)
s.glow_dot(C + 20, 444, 11, WE_ENGINE)
return s
def dreamer():
"""45 — the soft dome shell, the inner glow, the closed eyes."""
s = Ship()
s.oval((C - 84, 120, C + 84, 404), fill=WE_HULL)
s.oval((C - 84, 120, C + 84, 404), outline=WE_EDGE, width=8)
s.oval((C - 58, 156, C + 58, 380), fill=WE_DARK)
s.oval((C - 40, 190, C + 40, 330), fill=(120, 104, 160, 120))
s.glow_dot(C, 260, 34, WE_VIOLET)
s.arc((C - 30, 210, C + 30, 260), 200, 340, WE_PEARL, 6) # the closed eyes
for sx in (-1, 1):
s.poly([(C + sx * 60, 350), (C + sx * 110, 400), (C + sx * 70, 430)], WE_LITE)
s.oval((C - 10, 120, C + 10, 150), fill=WE_CYAN)
s.glow_dot(C - 22, 440, 11, WE_ENGINE)
s.glow_dot(C + 22, 440, 11, WE_ENGINE)
return s
def serpent():
"""46 — the winding tail, the three eye-orbs."""
s = Ship()
pts_top, pts_bot = [], []
for k in range(17):
t = k / 16
x = C + math.sin(t * math.pi * 2.4) * 58
y = 60 + t * 380
wdt = 26 * (1 - t * 0.55)
pts_top.append((x - wdt, y))
pts_bot.append((x + wdt, y))
s.poly(pts_top + pts_bot[::-1], WE_HULL)
for k in range(17):
t = k / 16
x = C + math.sin(t * math.pi * 2.4) * 58
y = 60 + t * 380
wdt = 14 * (1 - t * 0.55)
s.oval((x - wdt, y - wdt, x + wdt, y + wdt), fill=(120, 104, 160, 90) if k % 2 else (WE_DARK[0], WE_DARK[1], WE_DARK[2], 255))
s.oval((C - 44, 40, C + 44, 124), fill=WE_LITE)
s.oval((C - 44, 40, C + 44, 124), outline=WE_EDGE, width=6)
for dx in (-18, 0, 18):
s.glow_dot(C + dx, 82, 9, WE_CYAN)
s.line((C - 30, 130), (C + 30, 130), WE_VIOLET, 6)
s.glow_dot(C, 470, 10, WE_ENGINE)
return s
def overmind():
"""47 — the crown of six orbs, the triple halo, the great core."""
s = Ship()
for r in (124, 96, 68):
s.oval((C - r, 256 - r, C + r, 256 + r), outline=WE_EDGE, width=5)
for a in range(0, 360, 60):
x = C + 124 * math.cos(math.radians(a))
y = 256 + 124 * math.sin(math.radians(a))
s.glow_dot(x, y, 12, WE_PEARL)
s.oval((C - 46, 210, C + 46, 302), fill=WE_DARK)
s.oval((C - 46, 210, C + 46, 302), outline=WE_VIOLET, width=8)
s.glow_dot(C, 256, 30, WE_CYAN)
s.oval((C - 12, 240, C + 12, 272), fill=WE_CANY)
s.oval((C - 26, 108, C + 26, 150), fill=WE_HULL)
s.oval((C - 26, 362, C + 26, 404), fill=WE_HULL)
s.glow_dot(C, 96, 12, WE_VIOLET)
s.glow_dot(C, 416, 12, WE_VIOLET)
s.glow_dot(C - 60, 452, 9, WE_ENGINE)
s.glow_dot(C + 60, 452, 9, WE_ENGINE)
return s
APEX_SHIPS = [raptor, mauler, stalker, screamer, reaver, fang, tyrant, alpha]
WEAVE_SHIPS = [mindblade, telekine, phantasm, weaver, lure, dreamer, serpent, overmind]
def main():
im = Image.open(SHEET).convert('RGBA')
for i, maker in enumerate(APEX_SHIPS):
im.paste(maker().finish(), (i * CELL, 3 * CELL)) # frames 30-37 (row 3)
for i, maker in enumerate(WEAVE_SHIPS):
im.paste(maker().finish(), (i * CELL, 4 * CELL)) # frames 40-47 (row 4)
im.save(SHEET)
print('painted 16 frames into', SHEET)
if __name__ == '__main__':
main()

View File

@ -210,10 +210,12 @@ export function orbitStep(px, py, orbitAngle, orbitDir, orbitSpeed, dt, orbitRad
* The CURRENT TARGET (the one your guns are on): the nearest LIVE hostile * The CURRENT TARGET (the one your guns are on): the nearest LIVE hostile
* to (x, y) within `range` px, or null. The range's lock indicator and the * to (x, y) within `range` px, or null. The range's lock indicator and the
* main game's auto-fire both run this rule, so "what's locked" never * main game's auto-fire both run this rule, so "what's locked" never
* drifts between the two. Dead hostiles never qualify; a `qualifies` * drifts between the two. Dead hostiles never qualify; CLOAKED hostiles
* predicate (when given) narrows the pool further — the main game's * (e.cloaked — the Apex cloak, js/combat/EnemyAbilities.js) never qualify
* auto-fire passes its on-screen rule (CombatSystem.craftVisible), so * either: a phased-out craft can't be targeted until it reveals; a
* the guns only ever engage a hostile that's actually in view. * `qualifies` predicate (when given) narrows the pool further — the main
* game's auto-fire passes its on-screen rule (CombatSystem.craftVisible),
* so the guns only ever engage a hostile that's actually in view.
* *
* @param {function(object):boolean} [qualifies] extra eligibility test (null = all live hostiles) * @param {function(object):boolean} [qualifies] extra eligibility test (null = all live hostiles)
* @returns {object|null} the target (anything with .x/.y/.dead) * @returns {object|null} the target (anything with .x/.y/.dead)
@ -224,6 +226,7 @@ export function pickTarget(enemies, x, y, range = Infinity, qualifies = null) {
const r2 = range * range; const r2 = range * range;
for (const e of enemies || []) { for (const e of enemies || []) {
if (!e || e.dead) continue; if (!e || e.dead) continue;
if (e.cloaked) continue; // phased out — untargetable until it reveals
if (qualifies && !qualifies(e)) continue; if (qualifies && !qualifies(e)) continue;
const dx = e.x - x; const dx = e.x - x;
const dy = e.y - y; const dy = e.y - y;

View File

@ -2,11 +2,12 @@ import { config } from '../config/Config.js';
import { EnemyShip } from './EnemyShip.js'; import { EnemyShip } from './EnemyShip.js';
import { NeutralShip } from './NeutralShip.js'; import { NeutralShip } from './NeutralShip.js';
import { TransportShip } from './TransportShip.js'; import { TransportShip } from './TransportShip.js';
import { enemyTypeConfig, neutralTypeConfig, transportConfig } from './EnemyTypes.js'; import { enemyTypeConfig, neutralTypeConfig, transportConfig, factionTypeIds } from './EnemyTypes.js';
import { Bullet } from './Bullet.js'; import { Bullet } from './Bullet.js';
import { Fx, FX_COLORS, settleCraftTint } from './Fx.js'; import { Fx, FX_COLORS, settleCraftTint } from './Fx.js';
import { applyDamage, inView, pickTarget } from './Combat.js'; import { applyDamage, inView, pickTarget } from './Combat.js';
import { firePattern, stepMissiles, missilesHitPlayer } from './Weapons.js'; import { firePattern, stepMissiles, missilesHitPlayer } from './Weapons.js';
import { stepAbility, stepIllusions, stepTkwaves } from './EnemyAbilities.js';
import { barrels as barrelLine, intercept } from './Aiming.js'; import { barrels as barrelLine, intercept } from './Aiming.js';
import { SKILL_EFFECTS, baseWeaponDamage, blastDamage, burnDurationMs, burnTickDamage, dashPoint, decoyMs, flameTargetCount, hexToInt, radiusHits, stunMs } from '../skills/SkillEffects.js'; import { SKILL_EFFECTS, baseWeaponDamage, blastDamage, burnDurationMs, burnTickDamage, dashPoint, decoyMs, flameTargetCount, hexToInt, radiusHits, stunMs } from '../skills/SkillEffects.js';
import { iconSheet, seatCount, skillsEnabled } from '../skills/SkillModel.js'; import { iconSheet, seatCount, skillsEnabled } from '../skills/SkillModel.js';
@ -243,6 +244,18 @@ export class CombatSystem {
// stamp on the caught craft is stepped by _stepBurns, below). Stepped in // stamp on the caught craft is stepped by _stepBurns, below). Stepped in
// update()'s step 0b, gone in destroy(). // update()'s step 0b, gone in destroy().
this.flames = []; this.flames = [];
// THE ENEMY FACTION LAYER (data/combat.json → enemyTypes.<t>.abilities —
// js/combat/EnemyAbilities.js): the telekinetic WAVES in bloom (arrive
// on their clock, shove the player) and the MINDWEAVE ILLUSIONS (the
// decoy phantoms — shimmer, expire, and draw the auto-fire while live).
// Stepped in update()'s step 0b, gone in destroy().
this.tkwaves = [];
this.illusions = [];
// THE FACTION DUEL (the ?combat=apex / ?combat=mindweave boots): null
// unless a duel arena is live (startDuel); { faction, active } — the
// picker (js/combat/DuelPicker.js) re-shows when `active` drops (the
// marked fighter went down).
this.duel = null;
// THE NANOBOTS' clock (data/skills.json → effect.kind 'hullRegen'): ms // THE NANOBOTS' clock (data/skills.json → effect.kind 'hullRegen'): ms
// accumulated toward the next hull mend (the data's intervalMs between // accumulated toward the next hull mend (the data's intervalMs between
// mends, read live off the ledger in hullRegen()). // mends, read live off the ledger in hullRegen()).
@ -748,6 +761,11 @@ export class CombatSystem {
this._stepDecoys(time); this._stepDecoys(time);
this._stepFlames(dt, time); this._stepFlames(dt, time);
this._stepBurns(time); this._stepBurns(time);
// THE ENEMY FACTION HANDS (js/combat/EnemyAbilities.js): the psychic
// tides' arrivals (the shove/pull beats) and the illusions' stands
// (shimmer → expiry), both on the combat clock.
stepTkwaves(this, time, player);
stepIllusions(this, time);
// 1) The pack: orbit AI + lances (a dead one is its reform clock). // 1) The pack: orbit AI + lances (a dead one is its reform clock).
// The ON-SCREEN RULE: the AI may WANT to fire, but the lance only // The ON-SCREEN RULE: the AI may WANT to fire, but the lance only
@ -777,8 +795,12 @@ export class CombatSystem {
// isn't (the escort draws aggro; a pirates-only fight resolves to // isn't (the escort draws aggro; a pirates-only fight resolves to
// exactly the classic behavior). // exactly the classic behavior).
const target = this.pickEnemyTarget(e, player); const target = this.pickEnemyTarget(e, player);
// THE FACTION SKILLS (the abilities' frame step, BEFORE the fire
// gate — a cloaked ambush that reveals this frame may strike this
// frame; the flank's cut / the blink / the tides all run here).
if (e.abilities && e.abilities.length > 0) stepAbility(this, e, target, time);
const wantsFire = e.update(time, delta, target); const wantsFire = e.update(time, delta, target);
if (wantsFire && target && this.craftVisible(e)) this.fireEnemy(e, target, time); if (wantsFire && target && !e.cloaked && this.craftVisible(e)) this.fireEnemy(e, target, time);
// THE ATTACK COMM (the type's comms.attack — data/combat.json): the // THE ATTACK COMM (the type's comms.attack — data/combat.json): the
// ON-SCREEN roll (50%) fires once the craft first flies into view — // ON-SCREEN roll (50%) fires once the craft first flies into view —
// unless its arrival roll already commed it (a pirate never // unless its arrival roll already commed it (a pirate never
@ -857,7 +879,7 @@ export class CombatSystem {
time >= this.nextFireAt time >= this.nextFireAt
) { ) {
const target = pickTarget( const target = pickTarget(
this.enemies, this._firePool(),
player.x, player.x,
player.y, player.y,
this.autoFire.range, this.autoFire.range,
@ -951,7 +973,7 @@ export class CombatSystem {
currentTarget() { currentTarget() {
if (!this.ship) return null; if (!this.ship) return null;
return pickTarget( return pickTarget(
this.enemies, this._firePool(),
this.ship.x, this.ship.x,
this.ship.y, this.ship.y,
this.autoFire.range, this.autoFire.range,
@ -959,6 +981,45 @@ export class CombatSystem {
); );
} }
/**
* THE CANNON'S TARGET POOL — the live hostiles PLUS the live MINDWEAVE
* ILLUSIONS (js/combat/EnemyAbilities.js): a phantom that stands in
* the pool is what the auto-fire engages (the phantoms absorb the
* tracers — collide pops them; the real hull stands elsewhere).
* @returns {Array<object>} the pool (pickTarget skips the dead)
*/
_firePool() {
return [...this.enemies, ...(Array.isArray(this.illusions) ? this.illusions : [])];
}
/**
* The PLAYER'S HEADING (the abilities' geometry seam — "behind the
* player" is the player's rear arc): the ship's heading is its render
* rotation minus the art offset (js/entities/Ship.js's convention),
* 0 when the ship is down / a stub scene.
* @returns {number} radians
*/
playerFacing() {
const s = this.ship;
if (!s) return 0;
if (typeof s._heading === 'function') return s._heading();
return (Number(s.rotation) || 0) - (Number(s.artOffset) || 0);
}
/**
* Teleport a craft (the cloak's phase / the weaver's blink) — the hop
* is a position swap with no momentum (velocity zeroed) so the chase
* AI streams out of the new stand.
*/
teleportCraft(e, x, y) {
e.x = x;
e.y = y;
if (e.body) {
e.body.velocity.x = 0;
e.body.velocity.y = 0;
}
}
/** /**
* A pirate's target this frame (the mixed-encounter rule): the * A pirate's target this frame (the mixed-encounter rule): the
* PLAYER while they're inside the pirate's weapon range (a * PLAYER while they're inside the pirate's weapon range (a
@ -1158,6 +1219,24 @@ export class CombatSystem {
/** The collision pass: your tracers × the pack, their lances × you, rams. */ /** The collision pass: your tracers × the pack, their lances × you, rams. */
collide(delta, player) { collide(delta, player) {
// THE MINDWEAVE'S PHANTOMS (js/combat/EnemyAbilities.js) — your
// tracers × the illusions FIRST: a bolt that reaches a phantom pops
// it (the hologram has no hull to break) and is spent — the real
// hull stands elsewhere (the blink's point).
for (let i = this.bullets.length - 1; i >= 0; i--) {
const b = this.bullets[i];
if (b.dead || b.owner !== 'player') continue;
for (const il of this.illusions) {
if (il.dead) continue;
if (this.hitsSwept(b, il, il.hitRadius, delta)) {
this.fx.flash(il.x, il.y, 0xffffff, 30, 150);
this.fx.sparks(il.x, il.y, 0x9d4dff, 7, 220, 420);
il.dead = true;
if (b.consume()) this.bullets.splice(i, 1);
break;
}
}
}
// Your tracers × hostiles. // Your tracers × hostiles.
if (player) { if (player) {
for (let i = this.bullets.length - 1; i >= 0; i--) { for (let i = this.bullets.length - 1; i >= 0; i--) {
@ -1165,6 +1244,7 @@ export class CombatSystem {
if (b.dead || b.owner !== 'player') continue; if (b.dead || b.owner !== 'player') continue;
for (const e of this.enemies) { for (const e of this.enemies) {
if (e.dead) continue; if (e.dead) continue;
if (e.cloaked) continue; // the cloak holds: unhittable while it phases
if (!this.craftVisible(e)) continue; // off-screen: can't be hit if (!this.craftVisible(e)) continue; // off-screen: can't be hit
if (this.hitsSwept(b, e, e.hitRadius, delta)) { if (this.hitsSwept(b, e, e.hitRadius, delta)) {
const preShields = e.shields; const preShields = e.shields;
@ -1556,9 +1636,101 @@ export class CombatSystem {
for (const t of run.runner.onKill()) this._spawnWaveEnemy(t); for (const t of run.runner.onKill()) this._spawnWaveEnemy(t);
} }
if (this.onKill) this.onKill(e); if (this.onKill) this.onKill(e);
// THE FACTION DUEL's end (js/combat/DuelPicker.js re-shows): the
// marked fighter is down — the arena offers the next choice.
if (e.duel && this.duel) this.duel.active = false;
this._setActive(); this._setActive();
} }
// ------------------------------------------------------ the faction duel
/**
* Open the FACTION DUEL ARENA (the ?combat=apex / ?combat=mindweave
* boots): no wave run — the picker (js/combat/DuelPicker.js) offers the
* faction's fighters (data/combat.json → combat.factions) one at a
* time; spawnDuel / spawnDuelPack materialize the chosen fight.
* @param {string} factionId 'apex' | 'mindweave' (a combat.factions id)
* @returns {object} the duel state ({ faction, active })
*/
startDuel(factionId) {
this.cancelWaves(true); // the duel arena is its own fight
this.duel = { faction: factionId, active: false };
return this.duel;
}
/**
* One DUEL FIGHTER (the picker's pick): a single hostile of `typeId`,
* no respawn (the duel ends when it goes down), streamed in from a
* nearer-than-usual bearing (a duel should start within moments, not
* after a long fly-in). Replaces any live duel ship (a rematch is a
* fresh craft). Cloaked-ambush fighters phase to their stand on first
* contact (their ability runs from the pack loop — the spawn just
* puts them in range of the player's screen).
* @param {string} typeId a combat.enemyTypes id (the faction's fighter)
* @returns {EnemyShip} the marked fighter
*/
spawnDuel(typeId) {
this._clearDuelShips();
const px = this.ship ? this.ship.x : 0;
const py = this.ship ? this.ship.y : 0;
const a = Math.random() * TAU;
const r = Math.min(this._offscreenRadius(), 520) * (0.7 + Math.random() * 0.2);
const e = this.spawnEnemy(px + Math.cos(a) * r, py + Math.sin(a) * r, this._spawnSerial++, typeId);
e.duel = true; // the duel's mark (killEnemy's duel-end read)
e.respawn = false; // a duel fighter is spent — no reform clock
if (this.duel) this.duel.active = true;
this._setActive();
return e;
}
/**
* The FACTION PACK (the picker's 'the whole pack' row): every fighter
* of the faction (combat.factions[id].types) on screen at once — the
* full-strength version of the duel, one roster in (a single
* _clearDuelShips, so the pack doesn't cull itself mid-spawn).
* @param {string} factionId a combat.factions id
* @returns {EnemyShip[]} the pack
*/
spawnDuelPack(factionId) {
this._clearDuelShips();
const out = [];
for (const id of factionTypeIds(factionId)) out.push(this._spawnDuelOne(id));
if (this.duel) this.duel.active = true;
this._setActive();
return out;
}
/** One duel fighter (no roster clear — spawnDuelPack's loop seam). */
_spawnDuelOne(typeId) {
const px = this.ship ? this.ship.x : 0;
const py = this.ship ? this.ship.y : 0;
const a = Math.random() * TAU;
const r = Math.min(this._offscreenRadius(), 520) * (0.7 + Math.random() * 0.2);
const e = this.spawnEnemy(px + Math.cos(a) * r, py + Math.sin(a) * r, this._spawnSerial++, typeId);
e.duel = true;
e.respawn = false;
return e;
}
/** Pull the live duel ships out of the world (a fresh duel replaces them). */
_clearDuelShips() {
for (const e of this.enemies) {
if (e.duel && !e.destroyed) {
this.fx.releaseCraft(e); // out in normal colors, never mid-flash
e.destroy();
}
}
this.enemies = this.enemies.filter((e) => !e.duel);
// their phantoms go with them (a phantom of a craft that is gone
// stands for no one)
for (const il of this.illusions) {
if (il.sprite && !il.sprite.destroyed) il.sprite.destroy();
}
this.illusions = [];
this.tkwaves = [];
if (this.duel) this.duel.active = false;
}
/** The reform clock ran out — the craft is back on a ring edge. */ /** The reform clock ran out — the craft is back on a ring edge. */
reviveEnemy(e, time) { reviveEnemy(e, time) {
e._killFx = false; e._killFx = false;
@ -1967,10 +2139,11 @@ export class CombatSystem {
const eff = def.effect; const eff = def.effect;
const speed = Math.max(1, Number(eff.speed) || 0); const speed = Math.max(1, Number(eff.speed) || 0);
const count = Math.max(0, Math.floor(Number(eff.targetCount) || 0)); const count = Math.max(0, Math.floor(Number(eff.targetCount) || 0));
// The N NEAREST live hostiles (no on-screen filter — the throw is cast // The N NEAREST live, UN-CLOAKED hostiles (a phased-out craft can't be
// from the hull, the way the dash aims: at whatever's nearest). // targeted — and has no on-screen filter — the throw is cast from the
// hull, the way the dash aims: at whatever's nearest in view).
const nearest = this.enemies const nearest = this.enemies
.filter((e) => !e.dead && !e._removed) .filter((e) => !e.dead && !e._removed && !e.cloaked)
.sort((a, b) => Math.hypot(a.x - ship.x, a.y - ship.y) - Math.hypot(b.x - ship.x, b.y - ship.y)) .sort((a, b) => Math.hypot(a.x - ship.x, a.y - ship.y) - Math.hypot(b.x - ship.x, b.y - ship.y))
.slice(0, count); .slice(0, count);
const stun = stunMs(eff, rank); const stun = stunMs(eff, rank);
@ -2271,8 +2444,9 @@ export class CombatSystem {
const eff = def.effect; const eff = def.effect;
const speed = Math.max(1, Number(eff.speed) || 0); const speed = Math.max(1, Number(eff.speed) || 0);
const count = flameTargetCount(eff, rank); const count = flameTargetCount(eff, rank);
// Cloaked hostiles can't be targeted — the wave passes them by.
const nearest = this.enemies const nearest = this.enemies
.filter((e) => !e.dead && !e._removed) .filter((e) => !e.dead && !e._removed && !e.cloaked)
.sort((a, b) => Math.hypot(a.x - ship.x, a.y - ship.y) - Math.hypot(b.x - ship.x, b.y - ship.y)) .sort((a, b) => Math.hypot(a.x - ship.x, a.y - ship.y) - Math.hypot(b.x - ship.x, b.y - ship.y))
.slice(0, count); .slice(0, count);
const burn = burnDurationMs(eff, rank); const burn = burnDurationMs(eff, rank);
@ -2455,6 +2629,12 @@ export class CombatSystem {
for (const d of this.decoys) if (d.sprite && !d.sprite.destroyed) d.sprite.destroy(); // and its decoys for (const d of this.decoys) if (d.sprite && !d.sprite.destroyed) d.sprite.destroy(); // and its decoys
this.decoys = []; this.decoys = [];
this.flames = []; // the arcs die with the fight (no ignition in flight) this.flames = []; // the arcs die with the fight (no ignition in flight)
// The enemy faction's hands die with it: the tides and the phantoms
// (their sprites go too — no shimmer outlives the fight).
for (const il of this.illusions) if (il.sprite && !il.sprite.destroyed) il.sprite.destroy();
this.illusions = [];
this.tkwaves = [];
this.duel = null;
this._nanobotAccumMs = 0; this._nanobotAccumMs = 0;
this.waveRun = null; this.waveRun = null;
if (this._levelUnsub) { if (this._levelUnsub) {

165
js/combat/DuelPicker.js Normal file
View File

@ -0,0 +1,165 @@
/**
* The FACTION DUEL PICKER — the in-game panel for the ?combat=apex /
* ?combat=mindweave arena (data/combat.json → combat.factions; the fight
* seam is CombatSystem.startDuel / spawnDuel / spawnDuelPack).
*
* A top-left Phaser panel: the faction's eight fighters as rows
* (key 1–8, click works too), row 0 = the whole pack. It is HIDDEN while
* a duel is live (combat.duel.active) and re-shows the moment the marked
* fighter goes down (killEnemy drops the flag) — pick the next one.
*
* Keyboard: DIGIT_0 … DIGIT_8 (the keyboard plugin cleans its listeners
* up with the scene). No DOM, no data fetch — the rows come from the
* faction's own config (EnemyTypes.factionMeta / factionTypeIds).
*/
import Phaser from '../vendor/phaser.js';
import { factionMeta, factionTypeIds } from './EnemyTypes.js';
const PANEL_W = 360;
const ROW_H = 22;
const TOP_Y = 64; // under the combat bar's top edge
const HEAD_H = 52; // title + tagline
const FOOT_H = 34; // the pack row + the hint
export class DuelPicker {
/**
* @param {object} scene the host Phaser scene (GameScene)
* @param {object} combat the CombatSystem (its .duel state drives visibility)
* @param {string} factionId a combat.factions id ('apex' | 'mindweave')
*/
constructor(scene, combat, factionId) {
const meta = factionMeta(factionId);
this.scene = scene;
this.combat = combat;
this.factionId = factionId;
this._shown = false;
if (!meta || !Array.isArray(meta.types) || meta.types.length === 0) {
this.container = null; // a bad faction id: no panel, no fight (the boot stays a normal game)
return;
}
this.types = meta.types;
this._rowRects = []; // the pick zones (built with the rows below)
const w = PANEL_W;
const h = HEAD_H + this.types.length * ROW_H + FOOT_H;
const x0 = 16, y0 = TOP_Y;
const cx = x0 + w / 2, cy = y0 + h / 2;
const bg = scene.add.rectangle(cx, cy, w, h, 0x0a0e18, 0.78);
bg.setStrokeStyle(1, 0x3a4a6a);
const title = scene.add.text(x0 + 12, y0 + 8, String(meta.name ?? factionId).toUpperCase(), {
fontFamily: 'monospace', fontSize: '18px', color: '#ffffff',
});
const tag = scene.add.text(x0 + 12, y0 + 30, String(meta.tagline ?? ''), {
fontFamily: 'monospace', fontSize: '11px', color: '#8fa3c8',
});
const rows = [];
this.types.forEach((t, i) => {
const y = y0 + HEAD_H + i * ROW_H;
const label = scene.add.text(x0 + 12, y, `${i + 1} ${t.name ?? t.id}`, {
fontFamily: 'monospace', fontSize: '13px', color: '#e8f0ff',
});
const style = scene.add.text(x0 + 46, y, `— ${t.style ?? ''}`, {
fontFamily: 'monospace', fontSize: '11px', color: '#8fa3c8',
});
rows.push(label, style);
this._rowRects.push({ i: i + 1, x: x0 + 8, y: y - 8, w: w - 16, h: ROW_H });
});
const pack = scene.add.text(x0 + 12, y0 + HEAD_H + this.types.length * ROW_H,
`0 the whole pack — ${this.types.length} fighters at once`, {
fontFamily: 'monospace', fontSize: '13px', color: '#ffd84d',
});
rows.push(pack);
this._rowRects.push({ i: 0, x: x0 + 8, y: y0 + HEAD_H + this.types.length * ROW_H - 8, w: w - 16, h: ROW_H });
const hint = scene.add.text(x0 + 12, y0 + h - 20, 'press the key or click a row — one fighter at a time', {
fontFamily: 'monospace', fontSize: '10px', color: '#5f739a',
});
this._rowRects.forEach((r) => {
// Place the pick zone EXACTLY over its own row. _rowRects is already in
// absolute, top-left-anchored screen coords (built from the same x0/y0
// the text rows use), so use it verbatim. The old formula re-added the
// panel offset (cx - w/2, cy - h/2) on top, which pushed every zone
// THREE rows down — a click on a visible row either missed the zones
// entirely or fired the wrong fighter.
const hit = scene.add.zone(r.x, r.y, r.w, r.h).setOrigin(0, 0);
// v4 (see ActionBar / MenuButton): a bare setInteractive() leaves no
// hit-testable area the input manager will actually claim on click, so
// re-arm it with an explicit hitArea + callback. The callback receives
// (hitArea, localX, localY) — local space of the origin-0 zone.
hit.setInteractive({
useHandCursor: true,
hitArea: new Phaser.Geom.Rectangle(0, 0, r.w, r.h),
hitAreaCallback: (rect, lx, ly) => (lx >= 0 && lx <= r.w && ly >= 0 && ly <= r.h),
});
hit.on('pointerdown', () => this.select(r.i));
rows.push(hit);
});
// PIN THE PANEL TO THE SCREEN (scrollFactor 0) — the same hand every
// other combat HUD uses. Left at the default scrollFactor 1 the rows
// are WORLD-anchored: they land mid-canvas (the camera is centred on
// the ship, not on the origin) and drift off-screen the moment the
// camera follows the player. Pinned, the panel sits top-left as the
// x0/y0 layout intends, and the zone hit-boxes track it in screen
// space (Phaser's input accounts for the scroll factor on the pick).
[bg, title, tag, ...rows, hint].forEach((o) => {
if (o && typeof o.setScrollFactor === 'function') o.setScrollFactor(0);
});
this.container = scene.add.container(0, 0, [bg, title, tag, ...rows, hint]);
this.container.setDepth(2000);
if (typeof this.container.setScrollFactor === 'function') this.container.setScrollFactor(0);
// The keyboard hand (digits 0–8). The manager's plain `keydown` event
// (the same one the skill seats use) carries the DOM event, so read
// e.key / e.code and parse the digit. The DuelPicker only exists in the
// faction arena, and there the skill-seats handler no-ops (no class, the
// screen is busy), so the digits are free to pick.
const kb = scene.input && scene.input.keyboard;
if (kb && typeof kb.on === 'function') {
kb.on('keydown', (e) => {
if (!this.types) return;
const raw = `${String(e?.key ?? '')}|${String(e?.code ?? '')}`;
const m = /^([0-8])\||^Digit([0-8])$/i.exec(raw);
const digit = Number((m && (m[1] ?? m[2])) ?? -1);
if (digit >= 0) this.select(digit);
});
}
}
/**
* A pick (the key / the row's click): 0 = the whole pack, 1–8 = the
* fighter at that row. Replaces any live duel ship (a rematch is a
* fresh craft). A pick while the duel is live is a RE-PICK (allowed —
* the picker is hidden while live, so this path is the keyboard's).
*/
select(n) {
const combat = this.combat;
if (!combat || !this.types) return;
if (n === 0) {
combat.spawnDuelPack(this.factionId);
return;
}
const t = this.types[n - 1];
if (t && typeof t.id === 'string') combat.spawnDuel(t.id);
}
/** The picker's frame step (GameScene.update): visibility = duel not live. */
update(_time) {
if (!this.container) return;
const d = this.combat.duel;
if (!d) return;
const show = !d.active;
if (show === this._shown) return;
this._shown = show;
this.container.setVisible(show);
}
/** The panel's rows (a test/dev seam). */
rows() {
return (this.types ?? []).map((t, i) => ({ key: i + 1, id: t.id, name: t.name }));
}
}

346
js/combat/EnemyAbilities.js Normal file
View File

@ -0,0 +1,346 @@
/**
* The enemy ABILITY LAYER — the faction skills that ride on top of the
* weapon patterns (data/combat.json → enemyTypes.<type>.abilities; the
* rosters are combat.factions → apex / mindweave).
*
* Like js/combat/Weapons.js: one shared dispatch, pure logic + a `ctx`
* host (a CombatSystem — the abilities run in the main game's combat
* loop, where the ?combat=apex / ?combat=mindweave duel arena lives).
* ctx provides: .scene, .fx, .playerFacing(), .teleportCraft(e, x, y),
* and it owns the rosters this module appends to (.tkwaves, .illusions).
*
* ABILITIES (the `id` values a type's abilities[].id may name):
* 'cloaked_ambush' (Apex) the PREDATOR'S LAIR: on first contact the
* craft phases to a CLOAKED stand nearby —
* often BEHIND the player — invisible,
* unhittable, silent (cloakMs); at reveal
* it strikes at once (nextFireAt cleared).
* With recloakCooldown > 0 it can drop back
* into cloak for a recloakMs nap (the
* Stalker's signature: strike, vanish,
* strike again).
* 'flank_strafe' (Apex) the PREDATORY CIRCLE: every `period` s the
* craft cuts to a point BEHIND the player
* (radius/±spread rad) and holds it for
* holdMs — an attack from the bad side.
* 'blink_reposition' (Mindweave) the WEAVER'S BLINK (the Wraith's own
* blink, inverted): the craft steps to a
* fresh point around the player (biasBehind
* rolls it toward the rear) and leaves an
* ILLUSION decoy standing at the old spot
* (decoyMs) — which draws the player's
* auto-fire (the cannon's targets pool
* includes live illusions — the phantoms
* absorb the tracers).
* 'illusion_decoy' (Mindweave) the PHANTOM COURT: every `cooldown` s
* the craft projects `count` copies of
* itself within `radius` (lifeMs) — the
* same decoy roster the blink uses.
* 'telekinetic_wave' (Mindweave) the PSYCHIC TIDE: every `cooldown` s a
* wave blooms from the craft (travelMs to
* arrive); at arrival, a player inside
* `radius` gets shoved (dir 'push') or
* DRAGGED IN (dir 'pull') — an impulse of
* `power` px/s on the hull, no damage.
*
* The per-ability clocks ride the ship (e._ab[id]) — the config is the
* ship's own (a type's abilities are its own, never shared state), and
* the pure geometry helpers (behindPoint / offsetPoint / pushImpulse)
* take an injectable rng so the tests pin them.
*/
import Phaser from '../vendor/phaser.js';
import { combatSfx } from './CombatSfx.js';
/** Normalize a config's abilities into the live list (unknown ids skipped). */
export function abilitiesOf(cfg) {
const list = cfg && Array.isArray(cfg.abilities) ? cfg.abilities : null;
if (!list) return [];
const out = [];
for (const s of list) {
if (s && typeof s.id === 'string' && s.id !== '' && HANDLERS[s.id]) out.push({ ...s });
}
return out;
}
const TAU = Math.PI * 2;
const num = (v, fb) => (Number.isFinite(Number(v)) ? Number(v) : fb);
const col = (v, fb) => {
const n = parseInt(String(v ?? '').replace('#', ''), 16);
return Number.isFinite(n) && n !== 0 ? n : fb;
};
// ------------------------------------------------------------ pure geometry
/**
* A point `radius` behind (px, py) on the craft's `facing`, jittered
* ±`spread` rad and scaled 0.85–1.15 (the cloak's stand / the flank's
* strike point — the bad side, never the nose).
*/
export function behindPoint(px, py, facing, radius, spread = 0.6, rng = Math.random) {
const a = facing + Math.PI + (rng() * 2 - 1) * spread;
const r = radius * (0.85 + rng() * 0.3);
return { x: px + Math.cos(a) * r, y: py + Math.sin(a) * r };
}
/**
* A point minR–maxR from (px, py): with probability `biasBehind` it lands
* on the rear arc (facing + π ± 0.9 rad — the blink prefers the bad side),
* else a uniform bearing (a blink that teleports forward is still a blink).
*/
export function offsetPoint(px, py, facing, minR, maxR, biasBehind = 0, rng = Math.random) {
const r = minR + rng() * Math.max(0, maxR - minR);
const a = biasBehind > 0 && rng() < biasBehind
? facing + Math.PI + (rng() * 2 - 1) * 0.9
: rng() * TAU;
return { x: px + Math.cos(a) * r, y: py + Math.sin(a) * r };
}
/**
* The telekinetic impulse a wave deals at arrival: `power` px/s AWAY from
* the wave centre (dir 'push') or TOWARD it (dir 'pull'). Pure — the
* CombatSystem applies it to the player's body.
*/
export function pushImpulse(wave, player) {
const dx = player.x - wave.x;
const dy = player.y - wave.y;
const d = Math.hypot(dx, dy) || 1;
const s = (wave.dir === 'pull' ? -1 : 1) * Math.max(0, num(wave.power, 0));
return { vx: (dx / d) * s, vy: (dy / d) * s };
}
// ------------------------------------------------------------- the rosters
/**
* A Mindweave ILLUSION (the decoy roster, ctx.illusions): a visual copy of
* the weaver (its sheet + frame + scale, a violet hologram tint) that
* stands for lifeMs, shimmers while it does, and — while it lives — is a
* TARGET in the player's auto-fire pool (its phantoms absorb the
* cannon's tracers; collide pops one on contact).
*/
export function spawnIllusion(ctx, x, y, e, lifeMs = 2000, tintHex = '#9d4dff') {
if (!Array.isArray(ctx.illusions)) ctx.illusions = [];
let sprite = null;
const key = e && e.texture && e.texture.key ? e.texture.key : null;
if (key && ctx.scene && typeof ctx.scene.textures?.exists === 'function' && ctx.scene.textures.exists(key)) {
try {
sprite = new Phaser.GameObjects.Sprite(ctx.scene, x, y, key, e.sheetFrame ?? 0);
if (typeof ctx.scene?.add?.existing === 'function') ctx.scene.add.existing(sprite);
sprite.setDepth(9); // just under the real craft
try { sprite.setScale(e.scale ?? 1); } catch { /* stub scene */ }
try { sprite.setTint(col(tintHex, 0x9d4dff)); } catch { /* stub scene */ }
} catch { sprite = null; } // a stub scene without the object layer: the phantoms stand as data
}
ctx.illusions.push({
sprite,
x,
y,
hitRadius: Math.max(14, Number(e?.hitRadius) || 16),
lifeMs: Math.max(1, num(lifeMs, 2000)),
until: null, // the caller stamps it on the scene's clock
dead: false,
});
return ctx.illusions[ctx.illusions.length - 1];
}
/**
* The illusions' frame step (CombatSystem.update's 0b): the shimmer while
* the stand holds, the fade in the last 250 ms, the pop at expiry.
*/
export function stepIllusions(ctx, time) {
const ils = ctx.illusions;
if (!ils || ils.length === 0) return;
const keep = [];
for (const d of ils) {
if (d.dead) continue;
if (d.until != null && time >= d.until) {
d.dead = true;
const c = 0x9d4dff;
if (ctx.fx) {
ctx.fx.flash(d.x, d.y, c, 40, 180);
ctx.fx.sparks(d.x, d.y, c, 8, 200, 420);
}
if (d.sprite && !d.sprite.destroyed) d.sprite.destroy();
continue;
}
const spr = d.sprite;
if (spr && typeof spr.setAlpha === 'function') {
const left = d.until != null ? d.until - time : 250;
const fade = d.until != null ? Math.min(1, left / 250) : 1;
spr.setAlpha(fade * (0.55 + 0.15 * Math.sin(time * 0.02)));
}
keep.push(d);
}
ctx.illusions = keep.filter((d) => !d.dead);
}
/**
* The telekinetic WAVES' frame step (CombatSystem.update's 0b): each wave
* blooms to its radius over travelMs; at arrival a player inside it gets
* the impulse (push/pull), and the ring's beat plays either way.
*/
export function stepTkwaves(ctx, time, player) {
const ws = ctx.tkwaves;
if (!ws || ws.length === 0) return;
const keep = [];
for (const w of ws) {
if (time < w.until) { keep.push(w); continue; }
const c = w.colorInt ?? 0x9d4dff;
if (player && Math.hypot(player.x - w.x, player.y - w.y) <= w.r) {
const imp = pushImpulse(w, player);
if (player.body) {
player.body.velocity.x += imp.vx;
player.body.velocity.y += imp.vy;
}
if (ctx.fx) {
ctx.fx.flash(player.x, player.y, c, 44, 220);
ctx.fx.sparks(player.x, player.y, c, 10, 240, 460);
}
if (ctx.scene) sfx(ctx.scene, w.dir === 'pull' ? 'hull' : 'blink');
}
if (ctx.fx) ctx.fx.ring(w.x, w.y, c, Math.max(60, w.r * 0.9), 420);
}
ctx.tkwaves = keep;
}
/** One SFX event through the combat voice (js/combat/CombatSfx.js). */
function sfx(scene, event) {
try { combatSfx(scene, event); } catch { /* headless — no voice */ }
}
// --------------------------------------------------------------- the hands
const HANDLERS = {
cloaked_ambush: (ctx, e, player, time, st, a) => {
const dur = num(a.cloakMs, 3000);
const reveal = (t, strike) => {
e.cloaked = false;
try { e.setAlpha(1); } catch { /* stub */ }
st.revealed = true;
if (strike) e.nextFireAt = 0; // the reveal IS the strike — the first volley is free
if (ctx.fx) {
ctx.fx.ring(e.x, e.y, col(a.color, 0xff4d2d), 110, 420);
ctx.fx.sparks(e.x, e.y, col(a.color, 0xff4d2d), 10, 260, 520);
}
sfx(ctx.scene, 'fire');
if (a.recloakCooldown > 0) st.recloakAt = t + a.recloakCooldown * 1000;
};
if (e.cloaked) {
if (time >= st.cloakUntil) reveal(time, true);
return;
}
if (!st.revealed) {
// THE FIRST CONTACT: the predator phases to its cloak stand — a
// nearby point, usually on the player's rear arc — and waits.
const p = behindPoint(player.x, player.y, ctx.playerFacing(), num(a.revealRadius, 220), 0.9);
ctx.teleportCraft(e, p.x, p.y);
e.cloaked = true;
try { e.setAlpha(0.16); } catch { /* stub */ }
st.cloakUntil = time + dur;
if (ctx.fx) ctx.fx.ring(p.x, p.y, col(a.color, 0xff4d2d), 80, 380);
return;
}
// THE STALKER'S NAP: recloak on its own clock (the signature).
if (a.recloakCooldown > 0 && time >= (st.recloakAt ?? 0) && st.recloakAt != null) {
e.cloaked = true;
try { e.setAlpha(0.16); } catch { /* stub */ }
st.cloakUntil = time + num(a.recloakMs, 2600);
st.recloakAt = time + a.recloakCooldown * 1000;
if (ctx.fx) ctx.fx.ring(e.x, e.y, col(a.color, 0xff4d2d), 70, 320);
}
},
flank_strafe: (ctx, e, player, time, st, a) => {
if (e.orbitOverride && time < e.orbitOverride.until) return; // holding the cut
const period = num(a.period, 11) * 1000;
if (st.nextAt == null) st.nextAt = time + period * (0.4 + Math.random() * 0.6);
if (time < st.nextAt) return;
st.nextAt = time + period;
// THE CUT: behind the player (the player-facing's rear arc), hold it.
const p = behindPoint(player.x, player.y, ctx.playerFacing(), num(a.radius, 210), num(a.spread, 0.7));
e.orbitOverride = { x: p.x, y: p.y, until: time + num(a.holdMs, 1600) };
if (ctx.fx) ctx.fx.sparks(p.x, p.y, col(a.color, 0xff9d2d), 6, 160, 380);
sfx(ctx.scene, 'impact');
},
blink_reposition: (ctx, e, player, time, st, a) => {
const cd = num(a.cooldown, 12) * 1000;
if (st.nextAt == null) st.nextAt = time + cd * (0.35 + Math.random() * 0.4);
if (time < st.nextAt) return;
st.nextAt = time + cd * (0.8 + Math.random() * 0.4);
const fromX = e.x, fromY = e.y;
const to = offsetPoint(
player.x, player.y, ctx.playerFacing(),
num(a.minRadius, 200), num(a.maxRadius, 330), num(a.biasBehind, 0.4),
);
const il = spawnIllusion(ctx, fromX, fromY, e, num(a.decoyMs, 1800));
il.until = time + num(a.decoyMs, 1800);
ctx.teleportCraft(e, to.x, to.y);
const c = col(a.color, 0x9d4dff);
if (ctx.fx) {
ctx.fx.ring(fromX, fromY, c, 110, 380);
ctx.fx.ring(to.x, to.y, c, 110, 380);
ctx.fx.sparks(to.x, to.y, c, 12, 260, 520);
}
sfx(ctx.scene, 'blink');
},
illusion_decoy: (ctx, e, player, time, st, a) => {
const cd = num(a.cooldown, 16) * 1000;
if (st.nextAt == null) st.nextAt = time + cd * 0.5;
if (time < st.nextAt) return;
st.nextAt = time + cd;
const n = Math.max(1, Math.floor(num(a.count, 2)));
const r = num(a.radius, 170);
const life = num(a.lifeMs, 2600);
for (let i = 0; i < n; i++) {
const ang = (i / n) * TAU + Math.random() * 0.8;
const rr = r * (0.6 + Math.random() * 0.5);
const il = spawnIllusion(ctx, e.x + Math.cos(ang) * rr, e.y + Math.sin(ang) * rr, e, life);
il.until = time + life;
}
const c = col(a.color, 0x9d4dff);
if (ctx.fx) {
ctx.fx.ring(e.x, e.y, c, 120, 420);
ctx.fx.sparks(e.x, e.y, c, 10, 220, 460);
}
sfx(ctx.scene, 'blink');
},
telekinetic_wave: (ctx, e, player, time, st, a) => {
const cd = num(a.cooldown, 14) * 1000;
if (st.nextAt == null) st.nextAt = time + cd * 0.4;
if (time < st.nextAt) return;
st.nextAt = time + cd;
if (!Array.isArray(ctx.tkwaves)) ctx.tkwaves = [];
ctx.tkwaves.push({
x: e.x, y: e.y,
r: num(a.radius, 280),
dir: a.dir === 'pull' ? 'pull' : 'push',
power: num(a.power, 260),
until: time + num(a.travelMs, 650),
colorInt: col(a.color, 0x9d4dff),
});
if (ctx.fx) ctx.fx.ring(e.x, e.y, col(a.color, 0x9d4dff), 60, 500);
sfx(ctx.scene, 'shield');
},
};
/**
* One frame of the craft's abilities (called from CombatSystem.update's
* pack loop, before the fire gate — a reveal this frame can strike this
* frame). No player (down) = the hands stay closed (no strikes, no tides).
*/
export function stepAbility(ctx, e, player, time) {
const list = e.abilities;
if (!list || list.length === 0) return;
if (!player || e.dead) return;
for (const a of list) {
const st = (e._ab[a.id] ??= {});
try {
HANDLERS[a.id](ctx, e, player, time, st, a);
} catch (err) {
if (typeof console !== 'undefined') console.warn(`[orbit] ability "${a.id}" failed`, err);
}
}
}

View File

@ -2,6 +2,7 @@ import Phaser from '../vendor/phaser.js';
import { toColor } from '../utils/Color.js'; import { toColor } from '../utils/Color.js';
import { assignWorld } from '../visuals/UiCameras.js'; import { assignWorld } from '../visuals/UiCameras.js';
import { enemyTypeConfig } from './EnemyTypes.js'; import { enemyTypeConfig } from './EnemyTypes.js';
import { abilitiesOf } from './EnemyAbilities.js';
import { settleCraftTint } from './Fx.js'; import { settleCraftTint } from './Fx.js';
import { applyDamage, headingOf, orbitStep } from './Combat.js'; import { applyDamage, headingOf, orbitStep } from './Combat.js';
@ -105,6 +106,18 @@ export class EnemyShip extends Phaser.Physics.Arcade.Sprite {
this.orbitDir = this.index % 2 === 0 ? 1 : -1; this.orbitDir = this.index % 2 === 0 ? 1 : -1;
this.orbitAngle = Math.random() * Math.PI * 2; // the scene re-anchors it on spawn this.orbitAngle = Math.random() * Math.PI * 2; // the scene re-anchors it on spawn
// NAME + ABILITIES (the faction layer, data/combat.json → enemyTypes):
// `name` is the display id (the duel picker's row / the kill feed's
// line); `abilities` is the craft's skill list (js/combat/EnemyAbilities.
// js — the per-ability clocks ride e._ab, stepped by CombatSystem's
// pack loop). Both are no-ops for a type without them (the base craft).
this.name = typeof c.name === 'string' ? c.name : '';
this.abilities = abilitiesOf(c);
this._ab = {};
this.cloaked = false; // the cloaked ambush's hold (unhittable, silent)
this.orbitOverride = null; // the flank strafe's cut ({x, y, until})
this.sheetFrame = Number(c.frame) || 0; // the illusion copies' frame
// World size + render scale (the same contract as Ship: size × the // World size + render scale (the same contract as Ship: size × the
// art's footprint = the hull's world length). // art's footprint = the hull's world length).
this.size = Math.max(4, Number(c.size) || 54); this.size = Math.max(4, Number(c.size) || 54);
@ -192,6 +205,13 @@ export class EnemyShip extends Phaser.Physics.Arcade.Sprite {
const t = orbitStep(px, py, this.orbitAngle, this.orbitDir, this.orbitSpeed, dt, this.orbitRadius); const t = orbitStep(px, py, this.orbitAngle, this.orbitDir, this.orbitSpeed, dt, this.orbitRadius);
this.orbitAngle = t.angle; this.orbitAngle = t.angle;
// THE FLANK'S CUT (the abilities' override): while it holds, the ring
// point IS the cut point (behind the player — the bad side).
if (this.orbitOverride) {
if (time >= this.orbitOverride.until) this.orbitOverride = null;
if (this.orbitOverride) { t.x = this.orbitOverride.x; t.y = this.orbitOverride.y; }
}
const dx = t.x - this.x; const dx = t.x - this.x;
const dy = t.y - this.y; const dy = t.y - this.y;
const d = Math.hypot(dx, dy) || 1; const d = Math.hypot(dx, dy) || 1;
@ -218,7 +238,7 @@ export class EnemyShip extends Phaser.Physics.Arcade.Sprite {
// Lance? In range, roughly aimed, off cooldown — and only at a live player. // Lance? In range, roughly aimed, off cooldown — and only at a live player.
const wpn = this.wpn ?? {}; const wpn = this.wpn ?? {};
const range = Number(wpn.range) || 0; const range = Number(wpn.range) || 0;
if (player && range > 0 && dist < range && time >= this.nextFireAt) { if (player && range > 0 && dist < range && time >= this.nextFireAt && !this.cloaked) {
const lead = Math.abs(Phaser.Math.Angle.Wrap(toPlayer - this.heading())); const lead = Math.abs(Phaser.Math.Angle.Wrap(toPlayer - this.heading()));
if (lead < 0.6) { if (lead < 0.6) {
this.nextFireAt = time + (Number(wpn.cooldown) || 1.4) * 1000 * (0.85 + Math.random() * 0.4); this.nextFireAt = time + (Number(wpn.cooldown) || 1.4) * 1000 * (0.85 + Math.random() * 0.4);

View File

@ -48,6 +48,34 @@ export function enemyTypeIds() {
return Object.keys(t).filter((k) => !k.startsWith('_') && isPlain(t[k])); return Object.keys(t).filter((k) => !k.startsWith('_') && isPlain(t[k]));
} }
// --------------------------------------------------- the faction duels (apex / mindweave)
//
// The faction DUEL ARENA (data/combat.json → combat.factions): the ?combat=
// apex / ?combat=mindweave boots (js/scenes/GameScene.js) pick a faction,
// and the in-game picker (js/combat/DuelPicker.js) duels ONE of its
// fighters at a time (CombatSystem.spawnDuel) or the whole pack. Pure
// data seams — the factions name enemyTypes ids, nothing new flies on its
// own (the ships are roster-registered exactly like the other types).
/** A faction's meta (combat.factions[id]) — name/tagline/types — or null. */
export function factionMeta(id) {
const f = config.section('combat.factions', {});
return id != null && isPlain(f[id]) ? f[id] : null;
}
/** A faction's fighter type ids (the picker's rows, in display order). */
export function factionTypeIds(id) {
const f = factionMeta(id);
if (!f || !Array.isArray(f.types)) return [];
return f.types.map((t) => (isPlain(t) && typeof t.id === 'string' ? t.id : null)).filter(Boolean);
}
/** Every faction id (combat.factions, minus the `_` keys). */
export function factionIds() {
const f = config.section('combat.factions', {});
return Object.keys(f).filter((k) => !k.startsWith('_') && isPlain(f[k]));
}
/** /**
* The FULL config for a hostile type: the base (combat.enemy) deep-merged * The FULL config for a hostile type: the base (combat.enemy) deep-merged
* with the type's overrides (combat.enemyTypes[id]). * with the type's overrides (combat.enemyTypes[id]).

View File

@ -83,7 +83,10 @@ export class NeutralShip extends EnemyShip {
this.lastPy = py; this.lastPy = py;
} }
const live = (pirates || []).filter((p) => p && !p.dead); // Cloaked pirates are UNTARGETABLE (the same rule as pickTarget — the
// player's guns can't lock them either), so they don't count as live
// threats and never hold an engage state.
const live = (pirates || []).filter((p) => p && !p.dead && !p.cloaked);
// ---- the state machine: escort ↔ engage on the pirate count (the // ---- the state machine: escort ↔ engage on the pirate count (the
// escort clock is stamped once per escort spell and keeps // escort clock is stamped once per escort spell and keeps

View File

@ -27,6 +27,15 @@ import { combatSfx } from './CombatSfx.js';
* Missile.js) homes with a CAPPED turn-rate (weapon.missileTurn) * Missile.js) homes with a CAPPED turn-rate (weapon.missileTurn)
* so the player can out-turn them (the dodge rule) * so the player can out-turn them (the dodge rule)
* 'combo' a heavy lance PLUS a missile volley (the elite) * 'combo' a heavy lance PLUS a missile volley (the elite)
* 'sweep' a FAN of weapon.bolts centered on the craft's OWN HEADING
* (not the player) — the forward-facing cone: heavy metal
* straight out the nose (the Apex's forward firepower)
* 'spiral' weapon.bolts stepped by weapon.spiralStep rad each, with
* staggered speeds — the group blossoms into a SPIRAL over
* distance (the Mindweave's 1942-boss barrages)
* 'shell' weapon.shells LARGE-RADIUS slow orbs (shellSize/shellSpeed/
* shellDamage/shellLife) — heavy punch, wide hit area, short
* fuse (the Dreamshell's shells)
* *
* The `ctx` host (either context) provides: .scene (the Phaser scene — * The `ctx` host (either context) provides: .scene (the Phaser scene —
* or IS it), .fx (the Fx layer), .spawnBullet(x, y, a, wpn, owner), * or IS it), .fx (the Fx layer), .spawnBullet(x, y, a, wpn, owner),
@ -112,6 +121,46 @@ export function firePattern(ctx, ship, player, wpn) {
// The elite: the heavy lance AND the missile volley on one trigger. // The elite: the heavy lance AND the missile volley on one trigger.
spawnBolt(toPlayer + jitter()); spawnBolt(toPlayer + jitter());
launchMissiles(ctx, ship, player, wpn); launchMissiles(ctx, ship, player, wpn);
} else if (kind === 'sweep') {
// The SWEEP: a fan centered on the craft's OWN HEADING (its nose —
// the forward-facing cone, not an aim at the player).
const n = Math.max(2, Math.floor(num(wpn.bolts, 7)));
const fan = Math.max(0.1, num(wpn.fan, 1.1));
const heading = typeof ship.heading === 'function' ? ship.heading() : toPlayer;
for (let i = 0; i < n; i++) {
const t = n === 1 ? 0.5 : i / (n - 1);
spawnBolt(heading - fan / 2 + fan * t + jitter());
}
} else if (kind === 'spiral') {
// The SPIRAL: each bolt one step FURTHER around than the last (the
// 1942-boss spin), with a speed stagger so the arms open in flight.
const n = Math.max(3, Math.floor(num(wpn.bolts, 10)));
const step = Math.max(0.1, num(wpn.spiralStep, 0.55));
const stagger = Math.max(0, num(wpn.stagger, 0.12));
const base = Math.max(60, num(wpn.bulletSpeed, 220));
const a0 = toPlayer + (Math.random() * 2 - 1) * 0.2;
for (let i = 0; i < n; i++) {
spawnBolt(a0 + i * step + jitter(), {
...wpn,
bulletSpeed: base * (1 + (i / n - 0.5) * stagger * 2),
});
}
} else if (kind === 'shell') {
// The SHELLS: one (or a few) LARGE-RADIUS slow orbs — big visual and
// collision area, heavy damage, a short fuse. The override wpn copy
// keeps the weapon's color/muzzle; the shell's numbers own the rest.
const n = Math.max(1, Math.floor(num(wpn.shells, 1)));
for (let i = 0; i < n; i++) {
spawnBolt(toPlayer + jitter() + (n > 1 ? (i - (n - 1) / 2) * 0.16 : 0), {
...wpn,
size: num(wpn.shellSize, 44),
boltLength: num(wpn.shellLength, 46),
boltWidth: num(wpn.shellWidth, 60),
bulletSpeed: num(wpn.shellSpeed, 150),
bulletLife: num(wpn.shellLife, 2.4),
damage: num(wpn.shellDamage, 12),
});
}
} else { } else {
// 'lance' (default) and 'rapid' — one aimed bolt (rapid's config just // 'lance' (default) and 'rapid' — one aimed bolt (rapid's config just
// fires it on a shorter cooldown; the pattern is the same single shot). // fires it on a shorter cooldown; the pattern is the same single shot).

View File

@ -40,6 +40,7 @@ import { CombatSystem } from '../combat/CombatSystem.js';
import { XpManager } from '../combat/XpManager.js'; import { XpManager } from '../combat/XpManager.js';
import { EncounterDirector, encounterAllowed } from '../combat/Encounters.js'; import { EncounterDirector, encounterAllowed } from '../combat/Encounters.js';
import { CombatHud } from '../combat/CombatHud.js'; import { CombatHud } from '../combat/CombatHud.js';
import { DuelPicker } from '../combat/DuelPicker.js';
import { MineralHud } from '../ui/MineralHud.js'; import { MineralHud } from '../ui/MineralHud.js';
import { CreditsHud } from '../ui/CreditsHud.js'; import { CreditsHud } from '../ui/CreditsHud.js';
import { GOODS_TEXTURE_KEY } from '../economy/Economy.js'; import { GOODS_TEXTURE_KEY } from '../economy/Economy.js';
@ -1139,7 +1140,7 @@ export class GameScene extends Phaser.Scene {
// along for the BOUNTY HUNT's witness (handleCombatKill — the mark // along for the BOUNTY HUNT's witness (handleCombatKill — the mark
// is the only elite the game deploys, so its death IS the contract). // is the only elite the game deploys, so its death IS the contract).
onKill: (e) => { onKill: (e) => {
this.combatHud?.kill('HOSTILE DESTROYED'); this.combatHud?.kill((e.name ? `${e.name.toUpperCase()} — ` : '') + 'DESTROYED');
this.recordPirateKill(e); this.recordPirateKill(e);
this.handleCombatKill(e); this.handleCombatKill(e);
}, },
@ -1213,7 +1214,8 @@ export class GameScene extends Phaser.Scene {
// land in it, so don't run ?combat at home, and keep that rule for // land in it, so don't run ?combat at home, and keep that rule for
// any new enemy placement (the quest ambush already targets the // any new enemy placement (the quest ambush already targets the
// Neutral Capital, never home). // Neutral Capital, never home).
if (this._wantsCombatDemo()) { const boot = this._combatBootMode(); // 'demo' | 'apex' | 'mindweave' | null
if (boot === 'demo') {
this.combat.startWaves('combat_demo'); this.combat.startWaves('combat_demo');
// SETTLE WINDOW — the ship's start position is finalized AFTER // SETTLE WINDOW — the ship's start position is finalized AFTER
// create() (a fresh run's home rim comes from the tether barrier; // create() (a fresh run's home rim comes from the tether barrier;
@ -1225,6 +1227,28 @@ export class GameScene extends Phaser.Scene {
'[orbit] ?combat — the seven-type demo is live: 7 rolling waves, bomber → warden (data/combat.json → waves.scripts.combat_demo)', '[orbit] ?combat — the seven-type demo is live: 7 rolling waves, bomber → warden (data/combat.json → waves.scripts.combat_demo)',
); );
} }
// THE FACTION DUEL ARENA (the ?combat=apex / ?combat=mindweave
// boots — data/combat.json → combat.factions): the APEX (frames
// 30–37 — cloaked ambushes, flanking strikes, forward firepower)
// and the MINDWEAVE (frames 40–47 — blinks, phantoms, telekinetic
// tides, spiralling barrages) duel arena: a picker panel (js/combat/
// DuelPicker.js) offers the faction's eight fighters (keys 1–8 / a
// click), and '0' sends the whole pack. One fighter at a time —
// both factions run 2× the pirate's damage and 3.5× its armor
// (data/combat.json → enemyTypes, the
// $factions_note's balance rule), with the faction's SKILL LAYER
// (js/combat/EnemyAbilities.js) on top of its weapon pattern. When
// one goes down the picker re-shows for the next choice. These
// rosters are NOT in any quest/galaxy content — the arena is their
// only home (same safe-space caveat as the demo: don't run it at
// home, where hostiles never go).
if (boot === 'apex' || boot === 'mindweave') {
this.combat.startDuel(boot);
this.duelPicker = new DuelPicker(this, this.combat, boot);
console.info(
`[orbit] ?combat=${boot} — the faction duel arena is live: pick a fighter (keys 1–8, 0 = the whole pack)`,
);
}
// The ?neutral boot (the dev stand-in for the NEUTRAL-ONLY // The ?neutral boot (the dev stand-in for the NEUTRAL-ONLY
// encounter): two pilots stream in from off-screen on a SHORT escort // encounter): two pilots stream in from off-screen on a SHORT escort
// window (8 s — the data's band is 10–15 s), so the whole beat plays // window (8 s — the data's band is 10–15 s), so the whole beat plays
@ -2325,16 +2349,21 @@ export class GameScene extends Phaser.Scene {
} }
/** /**
* The ?combat boot (a dev URL parameter, like ?fx= / ?type=): a NORMAL * The ?combat boot MODE (a dev URL parameter, like ?fx= / ?type=):
* game — but with the combat demo live from the start (the pack spawns * ?combat → 'demo' (the seven-type showcase waves)
* when the player starts, see create()). The standalone range is * ?combat=apex → the APEX duel arena (frames 30–37)
* combat.html / the dev server's /combat. * ?combat=mindweave → the MINDWEAVE duel arena (frames 40–47)
* @returns {boolean} * (a bare ?combat is the classic boot — the value is optional; anything
* unknown is treated as the demo so a typo still shows combat).
* @returns {string|null} 'demo' | 'apex' | 'mindweave' | null
*/ */
_wantsCombatDemo() { _combatBootMode() {
if (config.get('combat.enabled', true) === false) return false; if (config.get('combat.enabled', true) === false) return null;
if (typeof location === 'undefined') return false; if (typeof location === 'undefined') return null;
return new URLSearchParams(location.search).has('combat'); const v = new URLSearchParams(location.search).get('combat');
if (v === null) return null;
if (v === 'apex' || v === 'mindweave') return v;
return 'demo';
} }
/** /**
@ -2855,6 +2884,7 @@ export class GameScene extends Phaser.Scene {
} }
if (!_frozen) { if (!_frozen) {
this.combat.update(_time, delta); this.combat.update(_time, delta);
this.duelPicker?.update(_time); // the faction duel's picker (hidden while its fighter is up)
this.combatBar?.setStats(this.combat); this.combatBar?.setStats(this.combat);
this.combatHud?.update(_time); // the readability layer (lock/threat/feed/flash) this.combatHud?.update(_time); // the readability layer (lock/threat/feed/flash)
// THE ENCOUNTER DIRECTOR (data/combat.json → encounters): a hit // THE ENCOUNTER DIRECTOR (data/combat.json → encounters): a hit

View File

@ -94,7 +94,11 @@ export class GameOverOverlay {
const w = this.scene.scale.width; const w = this.scene.scale.width;
const h = this.scene.scale.height; const h = this.scene.scale.height;
this.scrim.setSize(w, h); this.scrim.setSize(w, h);
this.scrim.hitArea.setBounds(0, 0, w, h); const ha = this.scrim.input.hitArea;
ha.x = 0;
ha.y = 0;
ha.width = w;
ha.height = h;
this.title.setPosition(w / 2, h / 2 - h * 0.07); this.title.setPosition(w / 2, h / 2 - h * 0.07);
const gap = 30; const gap = 30;
const mw = this.menuBtn.style.width; const mw = this.menuBtn.style.width;