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:
parent
0a86995738
commit
8cce093a79
|
|
@ -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
|
|
@ -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.
|
||||||
|
|
@ -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.
481
data/combat.json
481
data/combat.json
|
|
@ -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" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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`);
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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]).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue