Compare commits
6 Commits
1d0622a3d5
...
b2cd5a2701
| Author | SHA1 | Date |
|---|---|---|
|
|
b2cd5a2701 | |
|
|
c411a170dd | |
|
|
0d51184daa | |
|
|
ecff203159 | |
|
|
8fd94ce48c | |
|
|
5949bac861 |
|
|
@ -0,0 +1 @@
|
|||
{"version":1,"dirMtimeMs":1789549446576.3806,"goals":[]}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,133 @@
|
|||
{
|
||||
"version": 3,
|
||||
"id": "mu3lln4y-51f5mi",
|
||||
"objective": "=== Goal ===\nObjective: Make Orbit's combat both feel and read — sharper ship handling plus clear hit/kill/death feedback (VFX + SFX) and a readability layer (target indicator, damage numbers, kill feed, bar hit-flash), kept in sync across the training range (/combat) and the main-game combat (index.html?combat).\n\nSuccess criteria (observable):\n- In BOTH contexts, each of: firing, bullet impact, shield hit, hull hit, enemy kill, player-down, and player-respawn produces a distinct VFX and a distinct SFX.\n- Readability layer is live in both contexts: damage numbers on every hit (enemy and player), a target/lock indicator on the current target, threat cues for hostiles, a kill feed, and a visible hit-flash on the combat bar's shields/hull gauges.\n- Ship handling feels noticeably better in the range (WASD) — responsive thrust/rotation/brake, all values living in data/combat.json and shared with the main game.\n- node dev/combat.test.mjs passes with 0 failures; CDP screenshots (dev/cdp-shot.mjs pattern) show the new feedback in both /combat and index.html?combat; the README combat section documents the new feedback set and dev hooks.\n\nBoundaries:\n- IN SCOPE: js/combat/* (Combat.js, CombatSystem.js, CombatScene.js, Fx.js, EnemyShip.js, Bullet.js), js/ui/CombatBar.js, data/combat.json, SFX wiring, both combat contexts.\n- OUT OF SCOPE: real skill implementations (the 1–8 seats stay as stubs on the system's cast() stub), new enemy types/AI/behavior variety, waves/difficulty/escalation, permanent death or economy flows, non-combat game systems (map, jump gates, economy), new sprite art.\n\nConstraints:\n- Phaser 4 UMD + plain ES6 modules, no build step; hand-rolled circle collision checks only (no arcade colliders), per the existing combat architecture.\n- No new binary assets: VFX must be procedural (canvas dot textures / additive glows, Fx.js style); SFX must go through the existing data/sfx.json + playSfx() infrastructure using existing sound files only.\n- Pure rules stay in js/combat/Combat.js (pure, unit-tested); ALL tuning numbers live in data/combat.json; the range and the main game must share the same rules and numbers (no divergent copies).\n- Skill seats 1–8, the energy gauge, and the auto-fire model are left as-is (stub/placeholder behavior unchanged).\n\nVerification contract:\n1) node dev/combat.test.mjs → 0 failures. 2) CDP-driven visual checks of BOTH /combat and index.html?combat (dev/cdp-shot.mjs pattern) capturing: damage numbers, target indicator, kill feed, bar hit-flash, kill burst, player-down/respawn cues. 3) Manual flow checklist passed in both contexts: fire → muzzle flash + shot SFX; enemy hit → impact flash + damage number; shield/hull hit → bar flash + appropriate SFX; enemy kill → burst + kill-feed entry + explosion SFX; player down → down cue + respawn cue. 4) Re-read this goal's success criteria and confirm each is addressed before marking complete.\n\nIf blocked: stop and ask the user (default blocker rule).",
|
||||
"status": "complete",
|
||||
"autoContinue": true,
|
||||
"usage": {
|
||||
"tokensUsed": 26772762,
|
||||
"activeSeconds": 15915
|
||||
},
|
||||
"sisyphus": false,
|
||||
"createdAt": "2026-09-16T04:26:00.418Z",
|
||||
"updatedAt": "2026-09-16T09:04:06.574Z",
|
||||
"revision": 368,
|
||||
"scheduler": {
|
||||
"version": 1,
|
||||
"owner": "01a0a871-e065-702a-82c0-0d2f07bc718c",
|
||||
"generation": "d0ea2130-f3b3-4b18-9766-cbf9099ec156",
|
||||
"used": 1,
|
||||
"phase": "running",
|
||||
"repairUsed": true,
|
||||
"decision": {
|
||||
"kind": "ready",
|
||||
"nextAction": "The prior execution did not declare a disposition. Declare ready with a concrete next action, wait with a deadline, or complete/pause/block. This is the only repair prompt.",
|
||||
"purpose": "repair"
|
||||
},
|
||||
"dispatch": {
|
||||
"id": "3bfbf874-69e5-4dcc-94dd-48b33ee597af",
|
||||
"kind": "repair",
|
||||
"claimedAt": 1789532760444
|
||||
}
|
||||
},
|
||||
"taskList": {
|
||||
"tasks": [
|
||||
{
|
||||
"id": "task-1",
|
||||
"title": "Baseline audit: inventory current feedback + bar state in both contexts",
|
||||
"status": "complete",
|
||||
"verificationContract": "A gap list (in goal notes / a short doc) mapping each success-criterion feedback type to what exists today in range vs main game vs CombatBar; no code changes.",
|
||||
"completedAt": "2026-09-16T04:33:51.611Z",
|
||||
"evidence": "Audit doc at .pi/goals/combat-feedback-audit.md; baseline node dev/combat.test.mjs passes (all checks green)"
|
||||
},
|
||||
{
|
||||
"id": "task-2",
|
||||
"title": "Ship handling feel pass (player.* in data/combat.json, both contexts)",
|
||||
"status": "complete",
|
||||
"verificationContract": "Tuned thrust/rotSpeed/drag/brake (+ micro-feel tweaks in Combat.js rules if needed); range fly-check feels responsive; pure rules still covered by dev/combat.test.mjs (0 failures).",
|
||||
"completedAt": "2026-09-16T06:19:40.252Z",
|
||||
"evidence": "Committed 5949bac: pure handleFeel (exact integration) + combat.player tuning (thrust 2000/drag 1.8/rot 6.4/brake 0.9); WASD fixed (addKeys comma-list bug → event API); dev/combat.test.mjs 0 failures;"
|
||||
},
|
||||
{
|
||||
"id": "task-3",
|
||||
"title": "Combat SFX layer: fire/impact/shield/hull/kill/down/respawn via sfx.json + playSfx",
|
||||
"status": "complete",
|
||||
"verificationContract": "Each listed event fires its SFX in BOTH /combat and index.html?combat; wiring is shared (no per-context copies); no new sound files.",
|
||||
"completedAt": "2026-09-16T06:48:29.382Z",
|
||||
"evidence": "Committed: shared js/combat/CombatSfx.js (7 distinct voices from existing sfx.json: fire=scan, impact=ui_click, shield=solar_buzz, hull=mining_split, kill=thunder×4 random, down=deconstruct, respawn=d"
|
||||
},
|
||||
{
|
||||
"id": "task-4",
|
||||
"title": "Feedback VFX pass: damage numbers, impact flash tuning, kill burst, player down/respawn cues (shared Fx layer)",
|
||||
"status": "complete",
|
||||
"verificationContract": "Every listed cue renders in both contexts from one shared Fx path; CDP screenshots show damage numbers, kill burst, and down/respawn cues.",
|
||||
"completedAt": "2026-09-16T07:07:02.596Z",
|
||||
"evidence": "Committed ecff203: Fx.js gains damage() (cyan=shield/orange=hull numbers), color-coded hit flashes, enhanced kill burst (delayed 2nd shockwave), down() + respawn() cues, shared FX_COLORS. Both context"
|
||||
},
|
||||
{
|
||||
"id": "task-5",
|
||||
"title": "Readability layer: target indicator, threat cues, kill feed, CombatBar hit-flash on shields/hull",
|
||||
"status": "complete",
|
||||
"verificationContract": "Target/lock indicator tracks the current target; off-screen/threat cues appear; kill feed lists kills; bar gauges flash on player hits — verified via CDP screenshots in both contexts.",
|
||||
"completedAt": "2026-09-16T08:03:23.497Z",
|
||||
"evidence": "Commit 0d51184: CombatHud shared (lock reticle, threat cues, off-screen arrows, kill feed, gauge flash); pickTarget/feedPush/offscreenArrow in Combat.js; resyncAroundShip fixes main-game pack placemen"
|
||||
},
|
||||
{
|
||||
"id": "task-6",
|
||||
"title": "Parity + verification: range/main-game sync, tests, README combat section update",
|
||||
"status": "complete",
|
||||
"verificationContract": "node dev/combat.test.mjs 0 failures; CDP screenshot set for both contexts committed as evidence; README combat section documents the new feedback, SFX, and dev hooks; success-criteria checklist re-read and all items confirmed.",
|
||||
"completedAt": "2026-09-16T08:59:41.074Z",
|
||||
"evidence": "combat.test.mjs 96 checks + sfx.test.mjs 21 checks = 0 failures; sfxcheck PASS both contexts (7 events); gauge-flash/lock/threat/feed verified in both; combat-shot.mjs → dev/shots/combat-range.png + c"
|
||||
}
|
||||
],
|
||||
"blockCompletion": true,
|
||||
"proposedAt": "2026-09-16T04:25:18.473Z"
|
||||
},
|
||||
"stopReason": "agent",
|
||||
"archivedPath": ".pi/goals/archived/goal_2026091603040656_mu3lln4y-51f5mi.md"
|
||||
}
|
||||
|
||||
# Goal Prompt
|
||||
|
||||
=== Goal ===
|
||||
Objective: Make Orbit's combat both feel and read — sharper ship handling plus clear hit/kill/death feedback (VFX + SFX) and a readability layer (target indicator, damage numbers, kill feed, bar hit-flash), kept in sync across the training range (/combat) and the main-game combat (index.html?combat).
|
||||
|
||||
Success criteria (observable):
|
||||
- In BOTH contexts, each of: firing, bullet impact, shield hit, hull hit, enemy kill, player-down, and player-respawn produces a distinct VFX and a distinct SFX.
|
||||
- Readability layer is live in both contexts: damage numbers on every hit (enemy and player), a target/lock indicator on the current target, threat cues for hostiles, a kill feed, and a visible hit-flash on the combat bar's shields/hull gauges.
|
||||
- Ship handling feels noticeably better in the range (WASD) — responsive thrust/rotation/brake, all values living in data/combat.json and shared with the main game.
|
||||
- node dev/combat.test.mjs passes with 0 failures; CDP screenshots (dev/cdp-shot.mjs pattern) show the new feedback in both /combat and index.html?combat; the README combat section documents the new feedback set and dev hooks.
|
||||
|
||||
Boundaries:
|
||||
- IN SCOPE: js/combat/* (Combat.js, CombatSystem.js, CombatScene.js, Fx.js, EnemyShip.js, Bullet.js), js/ui/CombatBar.js, data/combat.json, SFX wiring, both combat contexts.
|
||||
- OUT OF SCOPE: real skill implementations (the 1–8 seats stay as stubs on the system's cast() stub), new enemy types/AI/behavior variety, waves/difficulty/escalation, permanent death or economy flows, non-combat game systems (map, jump gates, economy), new sprite art.
|
||||
|
||||
Constraints:
|
||||
- Phaser 4 UMD + plain ES6 modules, no build step; hand-rolled circle collision checks only (no arcade colliders), per the existing combat architecture.
|
||||
- No new binary assets: VFX must be procedural (canvas dot textures / additive glows, Fx.js style); SFX must go through the existing data/sfx.json + playSfx() infrastructure using existing sound files only.
|
||||
- Pure rules stay in js/combat/Combat.js (pure, unit-tested); ALL tuning numbers live in data/combat.json; the range and the main game must share the same rules and numbers (no divergent copies).
|
||||
- Skill seats 1–8, the energy gauge, and the auto-fire model are left as-is (stub/placeholder behavior unchanged).
|
||||
|
||||
Verification contract:
|
||||
1) node dev/combat.test.mjs → 0 failures. 2) CDP-driven visual checks of BOTH /combat and index.html?combat (dev/cdp-shot.mjs pattern) capturing: damage numbers, target indicator, kill feed, bar hit-flash, kill burst, player-down/respawn cues. 3) Manual flow checklist passed in both contexts: fire → muzzle flash + shot SFX; enemy hit → impact flash + damage number; shield/hull hit → bar flash + appropriate SFX; enemy kill → burst + kill-feed entry + explosion SFX; player down → down cue + respawn cue. 4) Re-read this goal's success criteria and confirm each is addressed before marking complete.
|
||||
|
||||
If blocked: stop and ask the user (default blocker rule).
|
||||
|
||||
## Progress
|
||||
|
||||
- Status: complete
|
||||
- Auto-continue: on
|
||||
- Sisyphus mode: no
|
||||
- Time spent: 4h25m15s
|
||||
- Tokens used: 27M (26,772,762) tokens
|
||||
## Tasks
|
||||
|
||||
<!-- blockCompletion: true -->
|
||||
- [x] task-1: Baseline audit: inventory current feedback + bar state in both contexts — evidence: Audit doc at .pi/goals/combat-feedback-audit.md; baseline node dev/combat.test.mjs passes (all checks green)
|
||||
- [x] task-2: Ship handling feel pass (player.* in data/combat.json, both contexts) — evidence: Committed 5949bac: pure handleFeel (exact integration) + combat.player tuning (thrust 2000/drag 1.8/rot 6.4/brake 0.9); WASD fixed (addKeys comma-list bug → event API); dev/combat.test.mjs 0 failures;
|
||||
- [x] task-3: Combat SFX layer: fire/impact/shield/hull/kill/down/respawn via sfx.json + playSfx — evidence: Committed: shared js/combat/CombatSfx.js (7 distinct voices from existing sfx.json: fire=scan, impact=ui_click, shield=solar_buzz, hull=mining_split, kill=thunder×4 random, down=deconstruct, respawn=d
|
||||
- [x] task-4: Feedback VFX pass: damage numbers, impact flash tuning, kill burst, player down/respawn cues (shared Fx layer) — evidence: Committed ecff203: Fx.js gains damage() (cyan=shield/orange=hull numbers), color-coded hit flashes, enhanced kill burst (delayed 2nd shockwave), down() + respawn() cues, shared FX_COLORS. Both context
|
||||
- [x] task-5: Readability layer: target indicator, threat cues, kill feed, CombatBar hit-flash on shields/hull — evidence: Commit 0d51184: CombatHud shared (lock reticle, threat cues, off-screen arrows, kill feed, gauge flash); pickTarget/feedPush/offscreenArrow in Combat.js; resyncAroundShip fixes main-game pack placemen
|
||||
- [x] task-6: Parity + verification: range/main-game sync, tests, README combat section update — evidence: combat.test.mjs 96 checks + sfx.test.mjs 21 checks = 0 failures; sfxcheck PASS both contexts (7 events); gauge-flash/lock/threat/feed verified in both; combat-shot.mjs → dev/shots/combat-range.png + c
|
||||
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
# Combat feedback — baseline audit (goal: make combat feel and read)
|
||||
|
||||
Scope: range = `combat.html` / `/combat` (js/combat/CombatScene.js), main game = `index.html?combat`
|
||||
(js/scenes/GameScene.js + js/combat/CombatSystem.js + js/ui/CombatBar.js). Shared: js/combat/{Combat,
|
||||
EnemyShip,Bullet,Fx}.js, data/combat.json.
|
||||
|
||||
## What EXISTS today
|
||||
|
||||
### VFX (both contexts, shared Fx.js: additive soft-dot flashes/rings/sparks, world-space, stepped in update)
|
||||
- Player shot: muzzle flash (range 30 px / main 26 px, 110 ms)
|
||||
- Enemy shot: muzzle flash (28 px, 100 ms)
|
||||
- Bullet impact: pink flash on enemy hits, blue on player hits (130 ms)
|
||||
- Player hit: generic white flash on the ship (60 px, 140 ms) — NOT distinct shield vs hull
|
||||
- Enemy kill: `Fx.explode` (white core + color bloom + ring + 22 sparks)
|
||||
- Player down: `Fx.explode` + range-only "SIGNAL LOST" center message (main game: no message)
|
||||
- Player respawn: cyan flash
|
||||
- Ram: white flash at contact midpoint
|
||||
- Thrust glow dot (range only)
|
||||
|
||||
### SFX
|
||||
- **Zero combat SFX in both contexts.**
|
||||
- Range loads NO audio at all (CombatScene.preload = textures only) → range SFX needs a load step.
|
||||
- Main game loads all 17 files from data/sfx.json in GameScene.preload.
|
||||
- Shared voice exists: `playSfxOn(scene, name)` / `playSfxOneOfOn` (js/utils/Sfx.js) with guards
|
||||
(sfx.enabled, sound manager, audio-cache check). No combat keys in data/sfx.json yet.
|
||||
- Repurposable existing files (no new binaries): scan-01 (ping), ui_click/ui_hover (ticks),
|
||||
ui_window (whoosh), mining-02 (crack), thunder-01..04 (boom set), type-construct (build-up),
|
||||
type-deconstruct (collapse).
|
||||
|
||||
### Readability / UI
|
||||
- Range HUD: top-left armor panel (HULL/SHD bars + numbers), top-right KILLS counter, center
|
||||
message. **No damage numbers, no kill feed, no target indicator, no bar hit-flash, no threat cues.**
|
||||
- CombatBar (main game): vertical SHD/HUL gauges (fill = level, updated each frame via setStats),
|
||||
8 empty skill seats (cast() stub), ENR gauge (static full). **No hit-flash, no numeric readout,
|
||||
no feed, no target read.**
|
||||
- CombatSystem hooks: onPlayerDamage / onPlayerDown / onPlayerUp / onKill / onActiveChange —
|
||||
GameScene wires ONLY onActiveChange (bar swap). The other four are available seams (currently null).
|
||||
- Range has NO hooks at all — its damage/kill/down paths live inline in CombatScene.
|
||||
|
||||
### Ship handling
|
||||
- Range (WASD): CombatScene.updatePlayer applies combat.player rotSpeed 4.8 rad/s (instant rate cap),
|
||||
thrust 1500 px/s², brake 0.6×thrust (S/Down), maxSpeed 560 cap. **Drag: combat.player.drag 1.6 is
|
||||
DEAD CONFIG — not read anywhere; effective drag is ship.drag 2.4 from data/ship.json via Ship.update.**
|
||||
- Main game: click-to-fly (Ship.js: ship.json thrust 900, maxSpeed 480, drag 2.4, rotSpeed 10) —
|
||||
mouse steers, combat adds auto-fire only. WASD not used there.
|
||||
- No throttle ramp, no rotation smoothing — both feel instant/steppy (candidate micro-feel gaps).
|
||||
|
||||
### Duplication (parity risk)
|
||||
- fire/kill/respawn/damagePlayer logic is COPIED between CombatScene and CombatSystem (each has its
|
||||
own fx.flash calls, its own armor bookkeeping, its own respawn timers). Fx.js itself is shared.
|
||||
- SFX wiring must go into BOTH copy sites (or a shared helper) to stay in sync.
|
||||
|
||||
## Gaps vs success criteria (per feedback type)
|
||||
|
||||
| Event | Range VFX | Main VFX | SFX (both) | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Firing (player) | muzzle flash | muzzle flash | none | distinct sound needed |
|
||||
| Firing (enemy) | muzzle flash | muzzle flash | none | (nice-to-have, distinct + quieter) |
|
||||
| Bullet impact (enemy hit) | flash | flash | none | no damage number |
|
||||
| Shield hit (player) | generic flash | generic flash | none | not distinct from hull hit |
|
||||
| Hull hit (player) | generic flash | generic flash | none | not distinct; bar doesn't flash |
|
||||
| Enemy kill | explode | explode | none | no kill feed |
|
||||
| Player down | explode + msg | explode only | none | main game gives no down cue |
|
||||
| Player respawn | flash | flash | none | none distinct |
|
||||
|
||||
| Readability item | Range | Main |
|
||||
|---|---|---|
|
||||
| Damage numbers (both sides) | missing | missing |
|
||||
| Target/lock indicator | missing | missing (auto-fire target invisible) |
|
||||
| Threat cues (hostile state) | missing | missing |
|
||||
| Kill feed | KILLS counter only | missing |
|
||||
| Bar hit-flash (SHD/HUL) | N/A (own HUD panel) | missing |
|
||||
|
||||
## Ground rules found in code (must keep)
|
||||
- Pure rules in js/combat/Combat.js, tested by dev/combat.test.mjs (344 lines; stubs Phaser;
|
||||
covers applyDamage/headingOf/orbitStep/EnemyShip/Bullet/CombatSystem incl. auto-fire, swept hits,
|
||||
rams, respawn, cast-stub). New pure feel rules belong there + tested.
|
||||
- Fx items are world-space additive dots; combat scene steps fx.update(delta) in its loop.
|
||||
- No arcade colliders; swept circle checks (hitsSwept) in both contexts.
|
||||
- All numbers in data/combat.json; CombatBar built like ActionBar (scene.add.* + container parenting).
|
||||
- data/sfx.json key = play name, asset queued `sfx_<key>`; sfx.enabled master switch.
|
||||
- Theme palette: neon #00e5ff, neon2 #ff2d6f, amber #ffc94d, ink #eaf6ff, dim #7d92c4, faint #3d4c74.
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{"type":"goal_created","goalId":"mu3lln4y-51f5mi","objective":"=== Goal ===\nObjective: Make Orbit's combat both feel and read — sharper ship handling plus clear hit/kill/death feedback (VFX + SFX) and a readability layer (target indicator, damage numbers, kill feed, bar hit-flash), kept in sync across the training range (/combat) and the main-game combat (index.html?combat).\n\nSuccess criteria (observable):\n- In BOTH contexts, each of: firing, bullet impact, shield hit, hull hit, enemy kill, player-down, and player-respawn produces a distinct VFX and a distinct SFX.\n- Readability layer is live in both contexts: damage numbers on every hit (enemy and player), a target/lock indicator on the current target, threat cues for hostiles, a kill feed, and a visible hit-flash on the combat bar's shields/hull gauges.\n- Ship handling feels noticeably better in the range (WASD) — responsive thrust/rotation/brake, all values living in data/combat.json and shared with the main game.\n- node dev/combat.test.mjs passes with 0 failures; CDP screenshots (dev/cdp-shot.mjs pattern) show the new feedback in both /combat and index.html?combat; the README combat section documents the new feedback set and dev hooks.\n\nBoundaries:\n- IN SCOPE: js/combat/* (Combat.js, CombatSystem.js, CombatScene.js, Fx.js, EnemyShip.js, Bullet.js), js/ui/CombatBar.js, data/combat.json, SFX wiring, both combat contexts.\n- OUT OF SCOPE: real skill implementations (the 1–8 seats stay as stubs on the system's cast() stub), new enemy types/AI/behavior variety, waves/difficulty/escalation, permanent death or economy flows, non-combat game systems (map, jump gates, economy), new sprite art.\n\nConstraints:\n- Phaser 4 UMD + plain ES6 modules, no build step; hand-rolled circle collision checks only (no arcade colliders), per the existing combat architecture.\n- No new binary assets: VFX must be procedural (canvas dot textures / additive glows, Fx.js style); SFX must go through the existing data/sfx.json + playSfx() infrastructure using existing sound files only.\n- Pure rules stay in js/combat/Combat.js (pure, unit-tested); ALL tuning numbers live in data/combat.json; the range and the main game must share the same rules and numbers (no divergent copies).\n- Skill seats 1–8, the energy gauge, and the auto-fire model are left as-is (stub/placeholder behavior unchanged).\n\nVerification contract:\n1) node dev/combat.test.mjs → 0 failures. 2) CDP-driven visual checks of BOTH /combat and index.html?combat (dev/cdp-shot.mjs pattern) capturing: damage numbers, target indicator, kill feed, bar hit-flash, kill burst, player-down/respawn cues. 3) Manual flow checklist passed in both contexts: fire → muzzle flash + shot SFX; enemy hit → impact flash + damage number; shield/hull hit → bar flash + appropriate SFX; enemy kill → burst + kill-feed entry + explosion SFX; player down → down cue + respawn cue. 4) Re-read this goal's success criteria and confirm each is addressed before marking complete.\n\nIf blocked: stop and ask the user (default blocker rule).","sisyphus":false,"autoContinue":true,"at":"2026-09-16T04:26:00.418Z"}
|
||||
{"type":"task_list_set","goalId":"mu3lln4y-51f5mi","taskCount":6,"blockCompletion":true,"at":"2026-09-16T04:26:00.418Z"}
|
||||
{"type":"task_started","goalId":"mu3lln4y-51f5mi","taskId":"task-1","at":"2026-09-16T04:26:15.002Z"}
|
||||
{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-1","evidence":"Audit doc at .pi/goals/combat-feedback-audit.md; baseline node dev/combat.test.mjs passes (all checks green)","at":"2026-09-16T04:33:51.611Z"}
|
||||
{"type":"task_started","goalId":"mu3lln4y-51f5mi","taskId":"task-2","at":"2026-09-16T04:35:30.972Z"}
|
||||
{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-2","evidence":"Committed 5949bac: pure handleFeel (exact integration) + combat.player tuning (thrust 2000/drag 1.8/rot 6.4/brake 0.9); WASD fixed (addKeys comma-list bug → event API); dev/combat.test.mjs 0 failures;","at":"2026-09-16T06:19:40.253Z"}
|
||||
{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-3","evidence":"Committed: shared js/combat/CombatSfx.js (7 distinct voices from existing sfx.json: fire=scan, impact=ui_click, shield=solar_buzz, hull=mining_split, kill=thunder×4 random, down=deconstruct, respawn=d","at":"2026-09-16T06:48:29.383Z"}
|
||||
{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-4","evidence":"Committed ecff203: Fx.js gains damage() (cyan=shield/orange=hull numbers), color-coded hit flashes, enhanced kill burst (delayed 2nd shockwave), down() + respawn() cues, shared FX_COLORS. Both context","at":"2026-09-16T07:07:02.597Z"}
|
||||
{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-5","evidence":"Commit 0d51184: CombatHud shared (lock reticle, threat cues, off-screen arrows, kill feed, gauge flash); pickTarget/feedPush/offscreenArrow in Combat.js; resyncAroundShip fixes main-game pack placemen","at":"2026-09-16T08:03:23.498Z"}
|
||||
{"type":"task_started","goalId":"mu3lln4y-51f5mi","taskId":"task-6","at":"2026-09-16T08:03:51.673Z"}
|
||||
{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-6","evidence":"combat.test.mjs 96 checks + sfx.test.mjs 21 checks = 0 failures; sfxcheck PASS both contexts (7 events); gauge-flash/lock/threat/feed verified in both; combat-shot.mjs → dev/shots/combat-range.png + c","at":"2026-09-16T08:59:41.075Z"}
|
||||
{"type":"completion_requested","goalId":"mu3lln4y-51f5mi","at":"2026-09-16T09:00:25.729Z"}
|
||||
{"type":"audit_started","goalId":"mu3lln4y-51f5mi","at":"2026-09-16T09:00:25.730Z"}
|
||||
{"type":"audit_result","goalId":"mu3lln4y-51f5mi","verdict":"approved","report":"The dev server (8080) and a CDP browser (9333) are live, so I can verify directly. First, let me look at the committed screenshot evidence and the README combat section.\n\nI've confirmed the screenshots show the combat HUD, reticles, and kill feed elements. Now I'll run the live CDP checks to verify SFX wiring and flight feel in the running dev environment.\n\nAll verification paths are now confirmed. Let me do a final constraint sweep before deciding.\n\n## Audit summary\n\nI verified the executor's claims against the actual repository and ran the live verification contract myself (dev server + CDP browser were available):\n\n**1. Distinct VFX + SFX per event, both contexts**\n- Code: `CombatScene.js` (range) and `CombatSystem.js` (main) both funnel every event through the shared `combatSfx`/`combatSfxEnemyHit` (7 distinct voices — scan/ui_click/solar_buzz/mining_split/thunder×4/deconstruct/discovery, all existing `data/sfx.json` files, all confirmed on disk) and the shared procedural `Fx` (muzzle flash, color-coded shield-cyan/hull-orange hit flash + damage number, kill burst with double ring, DOWN burst, REFORMED bloom — all canvas dots/additive).\n- Live: `node dev/combat.sfxcheck.mjs` → **PASS** in both `/combat` and `index.html?combat`, all seven events through the shared path (I ran it, exit 0).\n\n**2. Readability layer live in both contexts** (my own CDP probes)\n- Range: `lastFlash={kind:shield}`, kill feed entry landed, lock target acquired; gauge-flash overlay live (peak alpha 0.72, decaying).\n- Main: same, with the flash positioned exactly on the `CombatBar.gaugeRect('shield')` track (peak alpha 1.0), bar visible, feed entry, target true.\n- Committed screenshots `dev/shots/combat-range.png` / `combat-main.png` visually show: lock reticle, threat dots, kill feed \"HOSTILE DESTROYED\", damage numbers (\"10\"), kill burst, tracer, combat bar gauges.\n\n**3. Ship handling (range WASD)**\n- `node dev/combat-flycheck.mjs` → **PASS**: 337 ms to 60% maxSpeed, 133 ms to 30° turn, 7.45 rad/s sustained spin, 308 ms stop from 560 px/s.\n- Real keyboard check: actual 'W' keydown accelerated the ship 0→526 px/s; keyup released it.\n- Values live in `data/combat.json` → `combat.player`; pure `handleFeel` in `js/combat/Combat.js`; both contexts read the same file (weapon, armor, readability, bar).\n\n**4. Tests & docs**\n- `node dev/combat.test.mjs` → 96 checks, **0 failures** (exit 0); `sfx.test.mjs` 21/21.\n- `dev/combat-shot.mjs` follows the `dev/cdp-shot.mjs` CDP pattern; README combat section fully documents feel, the VFX set, the SFX map, the readability layer, and all three dev checkers + hooks.\n\n**Constraints honored**: pure rules in `Combat.js` (unit-tested), single tuning surface `data/combat.json`, shared modules across both contexts (no divergent copies), no new binary game assets (only the two evidence screenshots in the diff), hand-rolled circle collision only, skill stubs (`cast()` no-op), energy gauge (regen 0, full), and auto-fire left as-is, Phaser 4 UMD + plain ES6.\n\nEvery explicit success criterion and the verification contract are genuinely satisfied; the executor's claims match the evidence I independently reproduced.\n\n<approved/>","at":"2026-09-16T09:04:06.559Z"}
|
||||
{"type":"goal_completed","goalId":"mu3lln4y-51f5mi","archivePath":".pi/goals/archived/goal_2026091603040656_mu3lln4y-51f5mi.md","at":"2026-09-16T09:04:06.577Z"}
|
||||
{"type":"goal_archived","goalId":"mu3lln4y-51f5mi","archivePath":".pi/goals/archived/goal_2026091603040656_mu3lln4y-51f5mi.md","at":"2026-09-16T09:04:06.578Z"}
|
||||
44
README.md
44
README.md
|
|
@ -52,6 +52,48 @@ respawns, the bar — lives in **`data/combat.json`**:
|
|||
`window.__ORBIT_COMBAT__ = { system, bar, player, kills, spawn,
|
||||
damagePlayer, cast }`.
|
||||
|
||||
- **Feel** — the handling is what the range tunes. Every feel number
|
||||
(thrust, top speed, drag, turn rate + turn response, throttle ramp/
|
||||
decay, brake) lives in `data/combat.json` under `combat.player`, and
|
||||
both fronts run the same pure `handleFeel` integration
|
||||
(`js/combat/Combat.js`) — so what you feel in the range is what the
|
||||
main game ships. Thrust ramps in and decays out, turns ease on, and
|
||||
the brake kills the velocity instantly.
|
||||
- **Feedback — VFX** — every combat event has a distinct *visual* cue,
|
||||
all procedural (`js/combat/Fx.js` — canvas dots / additive glows, no
|
||||
new art): a tracer + muzzle flash on **firing**, a spark burst +
|
||||
damage number on **impact**, a cyan flash + damage number on a
|
||||
**shield hit**, an orange flash + damage number on a **hull hit**, a
|
||||
big burst + double ring on an **enemy kill**, a DOWN burst when the
|
||||
**player goes down**, and a REFORMED bloom on **respawn**. Shield
|
||||
reads cyan and hull reads orange in both the flash and the number, so
|
||||
a glance tells you which layer took the hit.
|
||||
- **Feedback — SFX** — the same seven events each get a *distinct* voice
|
||||
from the existing `data/sfx.json` set (no new sound files), through
|
||||
one shared event→voice map + playback path (`js/combat/CombatSfx.js`)
|
||||
so the two fronts can't drift: firing = `scan`, impact = `ui_click`,
|
||||
shield = `solar_buzz`, hull = `mining_split`, kill = `thunder_01..04`
|
||||
(one at random), down = `deconstruct`, respawn = `discovery`. Enemy
|
||||
fire reuses the `scan` voice, quieter.
|
||||
- **Readability layer** — a shared HUD (`js/combat/CombatHud.js`, pure
|
||||
rules in `js/combat/Combat.js`) makes the fight *readable* in both
|
||||
fronts: a rotating **lock reticle** on the current target (the same
|
||||
target the cannon auto-fires — one rule, one target), pulsing
|
||||
**threat markers** over every live hostile, **off-screen arrows** at
|
||||
hostiles outside the camera (the main game's camera follows the ship),
|
||||
a **kill feed** (newest-first, top-right), and a **gauge flash** — the
|
||||
combat bar's shields/hull gauge lights the moment it takes the hit.
|
||||
- **Dev hooks + checkers** — besides the two pokes above, every SFX call
|
||||
is logged to `window.__COMBAT_SFX__ = { log, last }` (capped), so the
|
||||
wiring is verifiable headless in both fronts. CDP checkers drive the
|
||||
real game and read state back: `node dev/combat-flycheck.mjs`
|
||||
(flight feel — t60/t30, spin, stop distance, top speed),
|
||||
`node dev/combat.sfxcheck.mjs` (all seven SFX events fire through the
|
||||
shared path, in both the range and the main game), and
|
||||
`node dev/combat-shot.mjs [outDir]` (captures a labeled feedback frame
|
||||
per context through the production paths into `dev/shots/` — the
|
||||
committed `combat-range.png` + `combat-main.png` are that evidence).
|
||||
|
||||
## Current state — v0.2: a seedable galaxy
|
||||
|
||||
- Main menu with **Continue** (resumes the newest save — grayed out while
|
||||
|
|
@ -336,7 +378,7 @@ node dev/perimeter-shimmer.test.mjs # the READY row's dual-shimmer rim: perimete
|
|||
node dev/quests.test.mjs # the quest ledger: grant/claim + the ONE priority (set/clear/sticks/auto-assign) + save round-trip
|
||||
node dev/sfx.test.mjs # the shared SFX voice: guards + sfx.json keys/files
|
||||
node dev/music.test.mjs # the shared music voice: guards + music.json contract
|
||||
node dev/combat.test.mjs # the combat RULES: armor damage, the enemy's ring AI + lance decision, bullet life, CombatSystem (pack, auto-fire, hits, rams, armor, respawn) — real data/combat.json
|
||||
node dev/combat.test.mjs # the combat RULES: armor damage, the enemy's ring AI + lance decision, bullet life, CombatSystem (pack, auto-fire, hits, rams, armor, respawn), ship FEEL (handleFeel), the SFX event→voice map, and the READABILITY rules (pickTarget, feed, off-screen arrows, the shared CombatHud) — real data/combat.json
|
||||
```
|
||||
|
||||
`dev/test-game.html` boots straight into the GameScene (no menu click),
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 61 KiB |
Binary file not shown.
|
|
@ -19,12 +19,15 @@
|
|||
},
|
||||
|
||||
"player": {
|
||||
"_comment": "The combat feel of YOUR ship in the range (the art, hull stats, and ship name still come from data/ship.json). WASD/arrows fly, SPACE/J fires (hold = auto-fire). thrust px/s^2, maxSpeed px/s, drag 1/s (exponential coast decay), rotSpeed rad/s (keyboard turn rate), brake = the S/Down reverse-throttle as a fraction of thrust. weapon.* = the player's cannon: cooldown s between shots, bulletSpeed px/s, damage per hit, spread rad of aim jitter, bulletLife s before a shot fizzles, color/size the tracer's glow.",
|
||||
"thrust": 1500,
|
||||
"_comment": "The combat feel of YOUR ship in the range (the art, hull stats, and ship name still come from data/ship.json). WASD/arrows fly, SPACE/J fires (hold = auto-fire). The flight feel (js/combat/Combat.js → handleFeel, tested): thrust px/s^2 at full throttle, maxSpeed px/s cap, drag 1/s (exponential coast decay — the range OVERRIDES ship.drag with this, so data/combat.json is the one tuning surface), brake = the S/Down reverse-throttle as a fraction of FULL thrust (instant — a responsive stop). rotSpeed rad/s = the SUSTAINED keyboard turn rate; rotApproach 1/s = how fast the turn rate builds (a tap is a nudge, a hold is a full spin); thrustRamp 1/s = how fast the throttle reaches full on W; thrustDecay 1/s = how fast the throttle cuts when W lets go (no lingering kick). weapon.* = the player's cannon: cooldown s between shots, bulletSpeed px/s, damage per hit, spread rad of aim jitter, bulletLife s before a shot fizzles, color/size the tracer's glow.",
|
||||
"thrust": 2000,
|
||||
"maxSpeed": 560,
|
||||
"drag": 1.6,
|
||||
"rotSpeed": 4.8,
|
||||
"brake": 0.6,
|
||||
"drag": 1.8,
|
||||
"rotSpeed": 6.4,
|
||||
"rotApproach": 20,
|
||||
"thrustRamp": 18,
|
||||
"thrustDecay": 30,
|
||||
"brake": 0.9,
|
||||
"weapon": {
|
||||
"key": "space",
|
||||
"cooldown": 0.16,
|
||||
|
|
@ -96,6 +99,15 @@
|
|||
"respawnDelay": 1.6
|
||||
},
|
||||
|
||||
"readability": {
|
||||
"_comment": "The READABILITY LAYER (js/combat/CombatHud.js) — the 'what do I need to see' side of combat, shared by both contexts. reticle = the target/LOCK indicator: rotating corner brackets on the current target (the one your guns are on — the range's nearest hostile, the main game's auto-fire target; the shared rule is Combat.js → pickTarget). margin = px outside the target's hitRadius, spin = rad/s bracket rotation, color = the lock's amber. threat = the hostile cue: a small pulsing marker above EVERY enemy (size px, pulse 1/s, above = px over the ship's center, color = hot red). offscreen = the edge ARROWS for hostiles outside the camera's view (main game — the range's arena is one screen, so they rarely fire there): pad = px inside the screen edge, size = arrow px, max = the most arrows drawn (nearest hostiles win), color. feed = the KILL FEED, top-right (entries = max lines, life ms per entry, gap px between lines, size px font, xInset = px in from the screen's RIGHT edge, y0 = px DOWN from the top (the first line), ink + hit colors). barFlash = the combat bar's GAUGE FLASH when YOU take a hit: life ms, alpha = the white overlay's start (the shield gauge flashes on shield hits, the hull gauge on hull hits — both when the shields pop through).",
|
||||
"reticle": { "enabled": true, "margin": 16, "spin": 1.6, "color": "#ffd84d" },
|
||||
"threat": { "enabled": true, "size": 8, "pulse": 2.2, "above": 36, "color": "#ff2d6f" },
|
||||
"offscreen": { "enabled": true, "pad": 28, "size": 13, "max": 4, "color": "#ff2d6f" },
|
||||
"feed": { "enabled": true, "entries": 5, "life": 3000, "gap": 19, "size": 13, "xInset": 18, "y0": 64, "ink": "#cfe9ff", "kill": "#ffd84d" },
|
||||
"barFlash": { "life": 340, "alpha": 0.85 }
|
||||
},
|
||||
|
||||
"hud": {
|
||||
"_comment": "The range console chrome (colors fall back to data/theme.json). title = the corner label; hints = the control line at the bottom.",
|
||||
"title": "COMBAT // SANDBOX",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
// Dev-only: the RANGE FLIGHT FEEL CHECK (headless, CDP).
|
||||
//
|
||||
// node dev/combat-flycheck.mjs [baseUrl]
|
||||
//
|
||||
// Loads the training range (/combat), then drives the SAME key states a
|
||||
// WASD player would produce (scene.keys.*.isDown — the exact input the
|
||||
// feel step reads) and measures the response:
|
||||
//
|
||||
// thrust — time from rest to 60% of maxSpeed (W) [want < 450 ms]
|
||||
// turn — time from rest to a 30° heading change (D) [want < 250 ms]
|
||||
// spin — sustained turn rate once the turn is up [want ≥ rotSpeed]
|
||||
// brake — time from full speed to < 60 px/s (S) [want < 800 ms]
|
||||
//
|
||||
// (A headless tab ticks ~24 fps, so the budgets carry a frame-quantization
|
||||
// margin — at 60 fps the same run is roughly 100–150 ms faster.)
|
||||
//
|
||||
// Prints a JSON summary + a PASS/FAIL line (exit 0 on PASS). Needs the
|
||||
// dev server (node dev/server.mjs 8080) and a CDP browser on 9333 — the
|
||||
// same setup as dev/cdp-shot.mjs.
|
||||
const [url = 'http://127.0.0.1:8080/combat'] = process.argv.slice(2);
|
||||
const CDP = process.env.CDP_PORT ? `127.0.0.1:${process.env.CDP_PORT}` : '127.0.0.1:9333';
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// The page-side check (runs inside the tab — real scene, real ship, real
|
||||
// feel step; the ship is made unkillable so a stray lance can't abort it).
|
||||
const PAGE_CODE = `
|
||||
(async () => {
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
if (!window.game) throw new Error('game not booted');
|
||||
const scene = window.game.scene.getScene('CombatScene');
|
||||
if (!scene || !scene.ship) throw new Error('CombatScene not ready');
|
||||
const ship = scene.ship;
|
||||
const maxSpeed = (window.__COMBAT_MAXSPEED__ ?? 560);
|
||||
|
||||
// Isolate the FEEL: no pack to ram into, no damage to respawn the ship
|
||||
// (a respawn resets rotation/position and would poison the numbers).
|
||||
scene.maxHull = 1e9; scene.hull = 1e9; scene.maxShields = 1e9; scene.shields = 1e9;
|
||||
scene.enemies.length = 0;
|
||||
scene.bullets.length = 0;
|
||||
|
||||
const press = (name, isDown) => { scene.keys[name].isDown = isDown; };
|
||||
const speed = () => Math.hypot(ship.body.velocity.x, ship.body.velocity.y);
|
||||
const alive = () => {
|
||||
if (scene.playerDead) throw new Error('player died during the check (invincibility failed?)');
|
||||
};
|
||||
|
||||
// --- thrust: rest → 60% of maxSpeed -----------------------------------
|
||||
ship.stop();
|
||||
await sleep(120);
|
||||
press('up', true);
|
||||
const t0 = performance.now();
|
||||
while (performance.now() - t0 < 2000 && speed() < maxSpeed * 0.6) { await sleep(10); alive(); }
|
||||
alive();
|
||||
const t60 = Math.round(performance.now() - t0);
|
||||
press('up', false);
|
||||
await sleep(300);
|
||||
ship.stop();
|
||||
await sleep(150);
|
||||
|
||||
// --- turn: rest → 30°, then the sustained rate -------------------------
|
||||
const r0 = ship.rotation;
|
||||
press('right', true);
|
||||
const t1 = performance.now();
|
||||
while (performance.now() - t1 < 3000 && Math.abs(ship.rotation - r0) < Math.PI / 6) { await sleep(10); alive(); }
|
||||
alive();
|
||||
const t30 = Math.round(performance.now() - t1);
|
||||
const r1 = ship.rotation;
|
||||
await sleep(300);
|
||||
alive();
|
||||
const spinRate = (ship.rotation - r1) / 0.3; // rad/s once the turn is up
|
||||
press('right', false);
|
||||
await sleep(300);
|
||||
ship.stop();
|
||||
|
||||
// --- brake: full speed → < 60 px/s (open space, nose ahead) -----------
|
||||
ship.stop();
|
||||
scene.feeel.rot = Math.PI / 2; // heading = east (+x), open space ahead
|
||||
scene.feeel.turnVel = 0;
|
||||
ship.x = 200; ship.y = 360; ship.rotation = Math.PI / 2;
|
||||
ship.body.velocity.set(0, 0);
|
||||
await sleep(120);
|
||||
press('up', true);
|
||||
await sleep(1000); // build up to full
|
||||
alive();
|
||||
const vFull = Math.round(speed());
|
||||
press('up', false);
|
||||
press('down', true);
|
||||
const t2 = performance.now();
|
||||
while (performance.now() - t2 < 4000 && speed() > 60) { await sleep(10); alive(); }
|
||||
alive();
|
||||
const tStop = Math.round(performance.now() - t2);
|
||||
press('down', false);
|
||||
ship.stop();
|
||||
|
||||
return { t60, t30, spinRate: Number(spinRate.toFixed(2)), vFull, tStop, maxSpeed };
|
||||
})()
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
// Open a fresh tab for the range (don't disturb other CDP users).
|
||||
let target;
|
||||
try {
|
||||
target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json();
|
||||
} catch {
|
||||
target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'POST' })).json();
|
||||
}
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
const send = (method, params = {}) => new Promise((res, rej) => {
|
||||
const mid = ++id;
|
||||
pending.set(mid, { res, rej });
|
||||
ws.send(JSON.stringify({ id: mid, method, params }));
|
||||
});
|
||||
ws.onmessage = (m) => {
|
||||
const msg = JSON.parse(m.data);
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
const { res, rej } = pending.get(msg.id);
|
||||
pending.delete(msg.id);
|
||||
msg.error ? rej(new Error(msg.error.message)) : res(msg.result);
|
||||
}
|
||||
};
|
||||
await new Promise((res, rej) => {
|
||||
ws.onopen = res;
|
||||
ws.onerror = () => rej(new Error('ws error'));
|
||||
});
|
||||
await send('Runtime.enable');
|
||||
await sleep(6000); // boot + config + textures + fonts
|
||||
|
||||
const r = await send('Runtime.evaluate', { expression: PAGE_CODE, returnByValue: true, awaitPromise: true });
|
||||
if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails).slice(0, 500));
|
||||
const s = r.result.value;
|
||||
ws.close();
|
||||
|
||||
const ok = s.t60 < 450 && s.t30 < 250 && s.spinRate >= 5.5 && s.tStop < 800 && s.vFull > 400;
|
||||
console.log(JSON.stringify(s, null, 2));
|
||||
console.log(
|
||||
`flycheck: thrust ${s.t60} ms to 60% · turn ${s.t30} ms to 30° · spin ${s.spinRate} rad/s · ` +
|
||||
`brake ${s.tStop} ms to stop from ${s.vFull} px/s → ${ok ? 'PASS' : 'FAIL'}`,
|
||||
);
|
||||
// Close the tab again (best effort).
|
||||
try { await fetch(`http://${CDP}/json/close/${target.id}`); } catch {}
|
||||
process.exit(ok ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('flycheck error:', e.message);
|
||||
process.exit(2);
|
||||
});
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
/**
|
||||
* Dev-only CDP screenshot harness for the COMBAT FEEDBACK set — drives
|
||||
* the real game in BOTH combat contexts (the training range `/combat` and
|
||||
* the main game `index.html?combat`), fires the combat events through the
|
||||
* PRODUCTION paths (fire / impact / shield+hull / kill / player-hit), and
|
||||
* captures a labeled frame per context so the new feedback (VFX + the
|
||||
* readability layer) is on record.
|
||||
*
|
||||
* node dev/combat-shot.mjs [outDir]
|
||||
*
|
||||
* Requires the dev server (http://127.0.0.1:8080) and a headless
|
||||
* Chromium-CDP browser on 127.0.0.1:9333 (same as dev/cdp-shot.mjs):
|
||||
* /opt/brave.com/brave/brave --headless --disable-gpu \
|
||||
* --remote-debugging-port=9333 about:blank
|
||||
*
|
||||
* Outputs (committed as evidence under the given outDir, default
|
||||
* dev/shots/):
|
||||
* combat-range.png — /combat: lock reticle, threat cue, enemy-hit
|
||||
* flash + damage number, kill burst + feed,
|
||||
* player-hit flash + gauge flash
|
||||
* combat-main.png — index.html?combat: the same set in the
|
||||
* main game, with the real combat bar gauging
|
||||
*
|
||||
* Every event goes through the shared production code (CombatSfx / Fx /
|
||||
* CombatHud), so the frame is a faithful picture of what a player sees.
|
||||
*/
|
||||
const fs = await import('node:fs');
|
||||
const CDP = process.env.CDP_PORT ? `127.0.0.1:${process.env.CDP_PORT}` : '127.0.0.1:9333';
|
||||
const BASE = process.env.BASE ?? 'http://127.0.0.1:8080';
|
||||
const OUT = process.argv[2] ?? 'dev/shots';
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
|
||||
// Open a fresh page target at `url`.
|
||||
async function openPage(url) {
|
||||
let target;
|
||||
try {
|
||||
target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json();
|
||||
} catch {
|
||||
target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'POST' })).json();
|
||||
}
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
const send = (method, params = {}) => new Promise((res, rej) => {
|
||||
const mid = ++id;
|
||||
pending.set(mid, { res, rej });
|
||||
ws.send(JSON.stringify({ id: mid, method, params }));
|
||||
});
|
||||
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = () => rej(new Error('ws error')); });
|
||||
ws.onmessage = (m) => {
|
||||
const x = JSON.parse(m.data);
|
||||
if (x.id && pending.has(x.id)) {
|
||||
const { res, rej } = pending.get(x.id);
|
||||
pending.delete(x.id);
|
||||
x.error ? rej(new Error(x.error.message)) : res(x.result);
|
||||
}
|
||||
};
|
||||
await send('Runtime.enable');
|
||||
await send('Page.enable');
|
||||
await send('Network.enable');
|
||||
await send('Network.setCacheDisabled', { cacheDisabled: true });
|
||||
await send('Page.navigate', { url });
|
||||
const ev = async (expr) => {
|
||||
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
|
||||
if (r.exceptionDetails) throw new Error('page: ' + JSON.stringify(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text));
|
||||
return r.result?.value;
|
||||
};
|
||||
const waitTrue = async (expr, timeoutMs = 45000) => {
|
||||
const end = Date.now() + timeoutMs;
|
||||
while (Date.now() < end) {
|
||||
const v = await ev(expr).catch(() => null);
|
||||
if (v) return v;
|
||||
await sleep(250);
|
||||
}
|
||||
throw new Error('timed out waiting for: ' + expr.slice(0, 60));
|
||||
};
|
||||
const shot = async (path, delayMs = 110) => {
|
||||
await sleep(delayMs); // let the last paint land (flash near peak, numbers mid-rise)
|
||||
const r = await send('Page.captureScreenshot', { format: 'png' });
|
||||
fs.writeFileSync(path, Buffer.from(r.data, 'base64'));
|
||||
console.log(` shot: ${path}`);
|
||||
};
|
||||
const close = async () => {
|
||||
await fetch(`http://${CDP}/json/close/${target.id}`).catch(() => {});
|
||||
ws.close();
|
||||
};
|
||||
return { ev, waitTrue, shot, close, target };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ the range
|
||||
{
|
||||
console.log('range (/combat):');
|
||||
const p = await openPage(`${BASE}/combat`);
|
||||
try {
|
||||
await p.waitTrue('Boolean(window.game && window.game.scene.getScene("CombatScene"))');
|
||||
await sleep(1200); // let the pack settle into its ring
|
||||
await p.ev(`(async () => {
|
||||
const s = window.game.scene.getScene('CombatScene');
|
||||
// A live target on the current-target rule (the lock + threat cue show it).
|
||||
const e = s.enemies.find((x) => !x.dead) || (s.spawnEnemy(s.w / 2, s.h / 2, 99), s.enemies[s.enemies.length - 1]);
|
||||
// Fan the cannon at it through the real fire path (the shared weapon).
|
||||
const cfg = await (await fetch('/data/combat.json', { cache: 'no-store' })).json();
|
||||
const wpn = cfg.player.weapon;
|
||||
const a = Math.atan2(e.y - s.ship.y, e.x - s.ship.x);
|
||||
for (let i = -3; i <= 3; i++) s.firePlayer(a + i * 0.05, wpn);
|
||||
// A guaranteed enemy hit through the real collide path: spawn a
|
||||
// tracer ON the enemy so the first frame's swept segment crosses it.
|
||||
const e2 = s.enemies.find((x) => !x.dead);
|
||||
if (e2) s.spawnBullet(e2.x, e2.y, a, wpn, 'player');
|
||||
return true;
|
||||
})()`);
|
||||
// Wait for the fan to land (the SFX dev log records an
|
||||
// impact/shield/hull voice — it stays true once it has fired) so
|
||||
// the enemy-hit feedback is live, then finish the cue set + capture.
|
||||
await p.waitTrue(`(() => {
|
||||
const log = window.__COMBAT_SFX__?.log ?? [];
|
||||
return log.some((x) => x.event === 'impact' || x.event === 'shield' || x.event === 'hull');
|
||||
})()`, 8000).catch(() => null);
|
||||
await p.ev(`(() => {
|
||||
const s = window.game.scene.getScene('CombatScene');
|
||||
// The kill (burst + kill feed + the thunder voice).
|
||||
const e = s.enemies.find((x) => !x.dead);
|
||||
if (e) s.killEnemy(e);
|
||||
// A player hit (damage number + the bar's gauge flash).
|
||||
s.shields = 40; s.hull = 100;
|
||||
s.damagePlayer(8);
|
||||
return true;
|
||||
})()`);
|
||||
await p.shot(`${OUT}/combat-range.png`);
|
||||
} finally {
|
||||
await p.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- the main
|
||||
{
|
||||
console.log('main (index.html?combat):');
|
||||
const p = await openPage(`${BASE}/index.html?combat`);
|
||||
try {
|
||||
await p.waitTrue('Boolean(window.game && window.game.scene.getScene("MenuScene") && window.game.scene.getScene("MenuScene").seedValue)');
|
||||
await p.ev('window.game.scene.getScene("MenuScene").startNewGame()');
|
||||
await p.waitTrue('Boolean(window.__ORBIT_COMBAT__)');
|
||||
// The new-run tutorial comms are 'ack' comms: each owns the screen
|
||||
// and pauses the sim until the player ACKs. For the combat shot we
|
||||
// clear the chain through the hub's own finish path (end the active
|
||||
// one, drop the pending queue, un-pause) so the fight is LIVE —
|
||||
// the sim ticks, the tracers fly, and the bar's gauge flash is
|
||||
// visible — instead of frozen behind a comm panel.
|
||||
await p.ev(`(() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
const h = g.comms;
|
||||
if (!h) return true;
|
||||
if (h.current) { try { h._finish(); } catch (e) {} }
|
||||
if (Array.isArray(h.queue)) h.queue.length = 0;
|
||||
h._setPaused(false);
|
||||
try { h.box?.close(); } catch (e) {}
|
||||
h.box = null;
|
||||
return true;
|
||||
})()`);
|
||||
// The camera follows the ship smoothly (game.camera.followRate). The
|
||||
// ship settles at its system rim AFTER create (tether), so the camera
|
||||
// must catch up — wait until it is centered on the ship, or the
|
||||
// ship + pack (now re-anchored on it) sit off-screen for the shot.
|
||||
await p.waitTrue(`(() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
const cam = g.cameras.main;
|
||||
const w = g.scale.width, h = g.scale.height;
|
||||
const cx = (cam.worldView?.x ?? cam.scrollX ?? cam.x) + w / 2;
|
||||
const cy = (cam.worldView?.y ?? cam.scrollY ?? cam.y) + h / 2;
|
||||
return Math.abs(cx - g.ship.x) < 24 && Math.abs(cy - g.ship.y) < 24;
|
||||
})()`, 8000).catch(() => null);
|
||||
await sleep(600); // the pack settles + the lock acquires + any drift damps
|
||||
await p.ev(`(async () => {
|
||||
const oc = window.__ORBIT_COMBAT__;
|
||||
const sys = oc.system;
|
||||
const t = sys.currentTarget() || sys.enemies.find((x) => !x.dead);
|
||||
if (!t) return false;
|
||||
const cfg = await (await fetch('/data/combat.json', { cache: 'no-store' })).json();
|
||||
const wpn = cfg.player.weapon;
|
||||
const nose = (sys.ship.radius || 12) + 8;
|
||||
const a = Math.atan2(t.y - sys.ship.y, t.x - sys.ship.x);
|
||||
for (let i = -3; i <= 3; i++) {
|
||||
const aa = a + i * 0.05;
|
||||
sys.spawnBullet(sys.ship.x + Math.cos(aa) * nose, sys.ship.y + Math.sin(aa) * nose, aa, wpn, 'player');
|
||||
}
|
||||
// A guaranteed enemy hit through the real collide path: spawn a
|
||||
// tracer ON the target so the first frame's swept segment crosses it.
|
||||
const t2 = sys.enemies.find((x) => !x.dead);
|
||||
if (t2) sys.spawnBullet(t2.x, t2.y, a, wpn, 'player');
|
||||
return true;
|
||||
})()`);
|
||||
await p.waitTrue(`(() => {
|
||||
const log = window.__COMBAT_SFX__?.log ?? [];
|
||||
return log.some((x) => x.event === 'impact' || x.event === 'shield' || x.event === 'hull');
|
||||
})()`, 8000).catch(() => null);
|
||||
await p.ev(`(() => {
|
||||
const oc = window.__ORBIT_COMBAT__;
|
||||
const sys = oc.system;
|
||||
const e = sys.enemies.find((x) => !x.dead);
|
||||
if (e) { e.shields = 0; e.damage(9999); sys.killEnemy(e, sys._now()); }
|
||||
sys.shields = 40; sys.hull = 100;
|
||||
sys.damagePlayer(8);
|
||||
return true;
|
||||
})()`);
|
||||
await p.shot(`${OUT}/combat-main.png`);
|
||||
} finally {
|
||||
await p.close();
|
||||
}
|
||||
}
|
||||
|
||||
console.log('combat feedback screenshots: done');
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error('combat-shot failed:', err.message); process.exit(1); });
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
/**
|
||||
* Combat SFX wiring check (dev tool, run with Node + a CDP browser):
|
||||
*
|
||||
* node dev/combat.sfxcheck.mjs
|
||||
*
|
||||
* Drives REAL game events in BOTH combat contexts — the range
|
||||
* (http://127.0.0.1:8080/combat) and the main game
|
||||
* (http://127.0.0.1:8080/index.html?combat) — and asserts that each of
|
||||
* the seven combat events (fire / impact / shield / hull / kill /
|
||||
* down / respawn) went through the SHARED SFX path
|
||||
* (js/combat/CombatSfx.js → window.__COMBAT_SFX__ dev log).
|
||||
*
|
||||
* Why a log: a headless browser has no audio manager, so the shared
|
||||
* guards (js/utils/Sfx.js) stay silent — the dev log records the call
|
||||
* itself, which is exactly the wiring under test. In a real browser
|
||||
* the same calls play the data/sfx.json voices.
|
||||
*
|
||||
* Requires: the dev server on :8080 (node dev/server.mjs 8080) and a
|
||||
* headless Chromium/Brave with CDP on 127.0.0.1:9333 (see dev/cdp-shot.mjs).
|
||||
*/
|
||||
const CDP = process.env.CDP_PORT ? `127.0.0.1:${process.env.CDP_PORT}` : '127.0.0.1:9333';
|
||||
const BASE = process.env.BASE_URL ?? 'http://127.0.0.1:8080';
|
||||
const EVENTS = ['fire', 'impact', 'shield', 'hull', 'kill', 'down', 'respawn'];
|
||||
|
||||
function connect(target) {
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
const send = (method, params = {}) => new Promise((res, rej) => {
|
||||
const mid = ++id;
|
||||
pending.set(mid, { res, rej });
|
||||
ws.send(JSON.stringify({ id: mid, method, params }));
|
||||
});
|
||||
ws.onmessage = (m) => {
|
||||
const x = JSON.parse(m.data);
|
||||
if (x.id && pending.has(x.id)) {
|
||||
const { res, rej } = pending.get(x.id);
|
||||
pending.delete(x.id);
|
||||
x.error ? rej(new Error(x.error.message)) : res(x.result);
|
||||
}
|
||||
};
|
||||
const open = new Promise((r) => { ws.onopen = r; ws.onerror = () => r(); });
|
||||
return { ws, send, open };
|
||||
}
|
||||
|
||||
async function evalJs(c, expression, awaitPromise = true) {
|
||||
const r = await c.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise });
|
||||
if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails, null, 2));
|
||||
return r.result?.value;
|
||||
}
|
||||
|
||||
async function openPage(path) {
|
||||
const url = `${BASE}${path}`;
|
||||
let target;
|
||||
try {
|
||||
target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json();
|
||||
} catch {
|
||||
target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'POST' })).json();
|
||||
}
|
||||
const c = connect(target);
|
||||
await c.open;
|
||||
await c.send('Runtime.enable');
|
||||
return { c, target };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the seven events in `scope` (a JS expression resolving to a
|
||||
* scope object) and return the distinct events observed in the dev log.
|
||||
*/
|
||||
function driveScript(scopeExpr) {
|
||||
return `(async () => {
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const scope = ${scopeExpr};
|
||||
if (!scope) return 'no scope';
|
||||
const logBefore = (window.__COMBAT_SFX__ && window.__COMBAT_SFX__.log || []).length;
|
||||
|
||||
// fire — the cannon goes off (player side, full voice).
|
||||
scope.fire();
|
||||
|
||||
// impact + shield — a hit absorbed by the shields.
|
||||
scope.armShielded();
|
||||
scope.hitPlayer(5);
|
||||
|
||||
// impact + hull — the shields are gone, the armor takes it.
|
||||
scope.armBare();
|
||||
scope.hitPlayer(5);
|
||||
|
||||
// kill — a hostile goes down.
|
||||
scope.killOne();
|
||||
|
||||
// down — the player's hull breaks.
|
||||
scope.killPlayer();
|
||||
|
||||
// respawn — the player reforms.
|
||||
scope.respawn();
|
||||
|
||||
await sleep(80);
|
||||
const log = (window.__COMBAT_SFX__ && window.__COMBAT_SFX__.log) || [];
|
||||
const slice = log.slice(logBefore);
|
||||
return [...new Set(slice.map((e) => e.event))];
|
||||
})()`;
|
||||
}
|
||||
|
||||
async function checkContext(name, path, scopeExpr, startExpr) {
|
||||
const { c, target } = await openPage(path);
|
||||
try {
|
||||
// Give the game time to boot (assets, fonts).
|
||||
let ready = false;
|
||||
for (let i = 0; i < 80; i++) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
try {
|
||||
ready = await evalJs(c, 'Boolean(window.game)', false);
|
||||
} catch { ready = false; }
|
||||
if (ready) break;
|
||||
}
|
||||
if (!ready) throw new Error('game never booted');
|
||||
|
||||
// Start the run (the main game boots on the menu — the range doesn't).
|
||||
if (startExpr) await evalJs(c, startExpr, false);
|
||||
|
||||
// Wait for the combat scope to exist (the hook lands in create()).
|
||||
let scoped = false;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
try {
|
||||
scoped = await evalJs(c, `Boolean(${scopeExpr})`, false);
|
||||
} catch { scoped = false; }
|
||||
if (scoped) break;
|
||||
}
|
||||
if (!scoped) throw new Error('combat scope never appeared');
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
|
||||
const events = await evalJs(c, driveScript(scopeExpr));
|
||||
if (!Array.isArray(events)) throw new Error(`drive failed: ${events}`);
|
||||
const missing = EVENTS.filter((e) => !events.includes(e));
|
||||
if (missing.length) throw new Error(`missing events: ${missing.join(', ')}`);
|
||||
console.log(`${name}: all seven events fired through the shared SFX path`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`${name}: FAIL — ${err.message}`);
|
||||
return false;
|
||||
} finally {
|
||||
try {
|
||||
await fetch(`http://${CDP}/json/close/${target.id}`).catch(() => {});
|
||||
} catch { /* ignore */ }
|
||||
c.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
const scopeRange = `(() => {
|
||||
const scene = window.game.scene.getScene('CombatScene');
|
||||
const cfg = window.config;
|
||||
return {
|
||||
fire: () => scene.firePlayer(0, cfg.section('combat.player.weapon', {})),
|
||||
armShielded: () => { scene.shields = scene.maxShields; scene.playerDead = false; scene.ship.setVisible(true); },
|
||||
armBare: () => { scene.shields = 0; scene.hull = 50; },
|
||||
hitPlayer: (n) => scene.damagePlayer(n),
|
||||
killOne: () => {
|
||||
const e = scene.enemies.find((x) => !x.dead);
|
||||
if (e) { e.damage(9999); scene.killEnemy(e); }
|
||||
},
|
||||
killPlayer: () => {
|
||||
if (scene.playerDead) { scene.respawnAt = 0; scene.maybeRespawn(scene.time.now); }
|
||||
scene.shields = 0; scene.hull = 1; scene.damagePlayer(9999);
|
||||
},
|
||||
respawn: () => { scene.respawnAt = 0; scene.maybeRespawn(scene.time.now); },
|
||||
};
|
||||
})()`;
|
||||
|
||||
const scopeMain = `(() => {
|
||||
const oc = window.__ORBIT_COMBAT__;
|
||||
if (!oc) return null;
|
||||
const sys = oc.system;
|
||||
return {
|
||||
fire: () => {
|
||||
const t = sys.nearestEnemy(sys.ship.x, sys.ship.y);
|
||||
if (t) sys.firePlayerAt(sys.ship, t, sys._now());
|
||||
},
|
||||
armShielded: () => { sys.shields = sys.maxShields; sys.playerDown = false; sys.ship.setVisible(true); },
|
||||
armBare: () => { sys.shields = 0; sys.hull = 50; },
|
||||
hitPlayer: (n) => sys.damagePlayer(n),
|
||||
killOne: () => {
|
||||
const e = sys.enemies.find((x) => !x.dead);
|
||||
if (e) { e.damage(9999); sys.killEnemy(e, sys._now()); }
|
||||
},
|
||||
killPlayer: () => {
|
||||
if (sys.playerDown) sys.revivePlayer(sys._now());
|
||||
sys.shields = 0; sys.hull = 1; sys.damagePlayer(9999);
|
||||
},
|
||||
respawn: () => sys.revivePlayer(sys._now()),
|
||||
};
|
||||
})()`;
|
||||
|
||||
const results = [];
|
||||
results.push(await checkContext('range (/combat)', '/combat', scopeRange));
|
||||
results.push(await checkContext(
|
||||
'main (index.html?combat)',
|
||||
'/index.html?combat',
|
||||
scopeMain,
|
||||
`(async () => {
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const m = window.game && window.game.scene && window.game.scene.getScene('MenuScene');
|
||||
if (m && typeof m.seedValue === 'string') { m.startNewGame(); return 'started'; }
|
||||
await sleep(500);
|
||||
}
|
||||
return 'menu never ready';
|
||||
})()`,
|
||||
));
|
||||
|
||||
if (results.every(Boolean)) {
|
||||
console.log('combat SFX wiring: PASS (both contexts, all seven events)');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log('combat SFX wiring: FAIL');
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -94,6 +94,49 @@ function makeImg(x = 0, y = 0, key = '__dot') {
|
|||
return o;
|
||||
}
|
||||
|
||||
// The readability layer's seams (js/combat/CombatHud.js) — just enough
|
||||
// of the display list for it to run headless and be asserted against.
|
||||
function makeGraphics() {
|
||||
return {
|
||||
visible: true,
|
||||
clear() { return this; },
|
||||
lineStyle() { return this; },
|
||||
fillStyle() { return this; },
|
||||
lineBetween() { return this; },
|
||||
fillCircle() { return this; },
|
||||
fillTriangle() { return this; },
|
||||
setScrollFactor() { return this; },
|
||||
setDepth() { return this; },
|
||||
setVisible(v) { this.visible = v; return this; },
|
||||
destroy() { this.destroyed = true; },
|
||||
};
|
||||
}
|
||||
|
||||
function makeTextObj() {
|
||||
return {
|
||||
text: '', alpha: 0, visible: true,
|
||||
setText(t) { this.text = String(t); return this; },
|
||||
setAlpha(a) { this.alpha = a; return this; },
|
||||
setVisible(v) { this.visible = v; return this; },
|
||||
setOrigin() { return this; },
|
||||
setScrollFactor() { return this; },
|
||||
setDepth() { return this; },
|
||||
destroy() { this.destroyed = true; },
|
||||
};
|
||||
}
|
||||
|
||||
function makeRectObj(x = 0, y = 0, w = 0, h = 0, alpha = 0) {
|
||||
return {
|
||||
x, y, width: w, height: h, alpha, visible: true,
|
||||
setAlpha(a) { this.alpha = a; return this; },
|
||||
setVisible(v) { this.visible = v; return this; },
|
||||
setOrigin() { return this; },
|
||||
setScrollFactor() { return this; },
|
||||
setDepth() { return this; },
|
||||
destroy() { this.destroyed = true; },
|
||||
};
|
||||
}
|
||||
|
||||
function makeScene({ withSheet = true } = {}) {
|
||||
return {
|
||||
textures: { exists: () => withSheet },
|
||||
|
|
@ -101,6 +144,9 @@ function makeScene({ withSheet = true } = {}) {
|
|||
add: {
|
||||
existing: (o) => o,
|
||||
image: (x, y, key) => makeImg(x, y, key),
|
||||
graphics: () => makeGraphics(),
|
||||
text: (x, y, s) => makeTextObj(),
|
||||
rectangle: (x, y, w, h, c, a) => makeRectObj(x, y, w, h, a ?? 0),
|
||||
},
|
||||
physics: { add: { existing: (o) => { o.body = makeBody(); return o; } } },
|
||||
scale: { width: 1280, height: 720 },
|
||||
|
|
@ -120,12 +166,16 @@ for (const f of fs.readdirSync(dataDir)) {
|
|||
config.init(configData);
|
||||
console.log('config loaded from data/:', Object.keys(configData).join(', '));
|
||||
|
||||
const { applyDamage, headingOf, orbitStep } = await import(
|
||||
const { applyDamage, headingOf, orbitStep, handleFeel, pickTarget, feedPush, offscreenArrow } = await import(
|
||||
pathToFileURL(join(__dirname, '../js/combat/Combat.js')).href
|
||||
);
|
||||
const { EnemyShip } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyShip.js')).href);
|
||||
const { Bullet } = await import(pathToFileURL(join(__dirname, '../js/combat/Bullet.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 {
|
||||
COMBAT_SFX, combatSfx, combatSfxEnemyHit,
|
||||
} = await import(pathToFileURL(join(__dirname, '../js/combat/CombatSfx.js')).href);
|
||||
|
||||
let failures = 0;
|
||||
const check = (label, cond) => {
|
||||
|
|
@ -169,6 +219,71 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
|
|||
check('orbitStep: counter-direction', approx(t2.angle, -1));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ feel
|
||||
|
||||
{
|
||||
const P = (k, d) => config.get(`combat.player.${k}`, d);
|
||||
const cfg = {
|
||||
rotSpeed: P('rotSpeed', 5.6),
|
||||
rotApproach: P('rotApproach', 16),
|
||||
thrustRamp: P('thrustRamp', 14),
|
||||
thrustDecay: P('thrustDecay', 26),
|
||||
brake: P('brake', 0.8),
|
||||
};
|
||||
check('feel cfg ships: all five knobs present + sane',
|
||||
cfg.rotSpeed > 0 && cfg.rotApproach > 0 && cfg.thrustRamp > 0 &&
|
||||
cfg.thrustDecay > 0 && cfg.brake > 0 && cfg.brake <= 1);
|
||||
|
||||
// Turn: a key BUILDS the turn rate (a tap is a nudge, a hold a spin) —
|
||||
// no instant full rate on the first frame, and it settles at ±rotSpeed.
|
||||
const st = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
|
||||
handleFeel(st, { right: true }, 1 / 60, cfg);
|
||||
check('feel: first frame builds turn (no instant full rate)',
|
||||
st.turnVel > 0 && st.turnVel < cfg.rotSpeed && st.rot > 0);
|
||||
for (let i = 0; i < 60; i++) handleFeel(st, { right: true }, 1 / 60, cfg);
|
||||
check('feel: turn settles at rotSpeed (no overshoot)',
|
||||
st.turnVel <= cfg.rotSpeed && Math.abs(st.turnVel - cfg.rotSpeed) < 0.05);
|
||||
|
||||
const sFlip = { rot: 0, turnVel: cfg.rotSpeed, throttle: 0, brake: 0 };
|
||||
for (let i = 0; i < 3; i++) handleFeel(sFlip, { left: true }, 1 / 60, cfg);
|
||||
check('feel: left flips the turn (eases toward -rotSpeed)', sFlip.turnVel < 0);
|
||||
|
||||
const s0 = { rot: 0, turnVel: cfg.rotSpeed, throttle: 0, brake: 0 };
|
||||
for (let i = 0; i < 120; i++) handleFeel(s0, {}, 1 / 60, cfg);
|
||||
check('feel: released turn decays to 0', s0.turnVel === 0);
|
||||
|
||||
// Throttle: ramps up toward 1 (bounded), cuts to 0 when released.
|
||||
const s1 = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
|
||||
for (let i = 0; i < 30; i++) handleFeel(s1, { up: true }, 1 / 60, cfg);
|
||||
check('feel: throttle ramps up, bounded by 1', s1.throttle > 0.5 && s1.throttle <= 1);
|
||||
for (let i = 0; i < 60; i++) handleFeel(s1, {}, 1 / 60, cfg);
|
||||
check('feel: throttle cuts to 0 on release', s1.throttle === 0);
|
||||
|
||||
// Brake: instant while down (a fraction of full thrust), gone on release.
|
||||
const s2 = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
|
||||
handleFeel(s2, { down: true }, 1 / 60, cfg);
|
||||
check('feel: brake is instant (no ramp)', s2.brake === cfg.brake);
|
||||
handleFeel(s2, {}, 1 / 60, cfg);
|
||||
check('feel: brake drops when released', s2.brake === 0);
|
||||
|
||||
// Frame-rate independence: two half-steps ≈ one full step.
|
||||
const a = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
|
||||
handleFeel(a, { right: true, up: true }, 1 / 60, cfg);
|
||||
const b = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
|
||||
handleFeel(b, { right: true, up: true }, 1 / 120, cfg);
|
||||
handleFeel(b, { right: true, up: true }, 1 / 120, cfg);
|
||||
check('feel: frame-rate independent (≈)',
|
||||
Math.abs(a.rot - b.rot) < 1e-3 && Math.abs(a.throttle - b.throttle) < 1e-3);
|
||||
|
||||
// dt clamp: a backgrounded tab (dt = 5 s) can't fast-forward the feel.
|
||||
const c = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
|
||||
handleFeel(c, { right: true, up: true }, 5, cfg);
|
||||
const d = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
|
||||
handleFeel(d, { right: true, up: true }, 0.1, cfg);
|
||||
check('feel: dt clamped at 0.1 s (no tab-switch jump)',
|
||||
Math.abs(c.rot - d.rot) < 1e-9 && Math.abs(c.throttle - d.throttle) < 1e-9);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- enemy
|
||||
|
||||
{
|
||||
|
|
@ -307,6 +422,28 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
|
|||
combat.update(target.respawnAt + 10, 16);
|
||||
check('system: enemy reforms (armor full, alive)', !target.dead && target.hull === target.maxHull);
|
||||
|
||||
// CURRENT TARGET (the readability layer's lock): the auto-fire target —
|
||||
// the nearest live hostile WITHIN auto-fire range (the shared rule).
|
||||
check('system: currentTarget = the nearest live hostile in range',
|
||||
combat.currentTarget() === combat.nearestEnemy(ship.x, ship.y) && combat.currentTarget() !== null);
|
||||
combat.autoFire.range = 5;
|
||||
check('system: currentTarget honors the auto-fire range (none in 5px)', combat.currentTarget() === null);
|
||||
combat.autoFire.range = 800;
|
||||
|
||||
// RE-ANCHOR (the main game): the ship's start position settles AFTER
|
||||
// create (the tether barrier) — the pack gets pulled around it.
|
||||
ship.x = 1200; ship.y = 300;
|
||||
const moved = combat.resyncAroundShip();
|
||||
let cx = 0, cy = 0, n = 0;
|
||||
for (const e of combat.enemies) { if (!e.dead) { cx += e.x; cy += e.y; n++; } }
|
||||
cx /= n; cy /= n;
|
||||
check('system: resync pulls the pack around the settled ship',
|
||||
moved === true && Math.abs(cx - 1200) < 1e-6 && Math.abs(cy - 300) < 1e-6);
|
||||
check('system: resync is a no-op once anchored', combat.resyncAroundShip() === false);
|
||||
check('system: resync with no ship is a no-op', (combat.ship = null, combat.resyncAroundShip()) === false);
|
||||
combat.ship = ship;
|
||||
ship.x = 0; ship.y = 0;
|
||||
|
||||
// RAM: the ship inside the enemy's combined radius → both take damage.
|
||||
const ram = combat.enemies[0];
|
||||
ship.x = 0; ship.y = 0;
|
||||
|
|
@ -338,6 +475,164 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
|
|||
check('system: destroy clears pack + tracers', combat.enemies.length === 0 && combat.bullets.length === 0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ SFX
|
||||
|
||||
{
|
||||
// A scene with just enough of a sound manager for the shared guards to
|
||||
// pass (js/utils/Sfx.js) — every play lands in `played`.
|
||||
const makeSfxScene = () => {
|
||||
const played = [];
|
||||
return {
|
||||
played,
|
||||
sound: { play: (key, o) => played.push({ key, volume: o.volume }) },
|
||||
cache: { audio: { has: () => true } },
|
||||
};
|
||||
};
|
||||
const sfxNames = config.get('sfx') ?? {};
|
||||
|
||||
check('sfx: the seven combat events, each mapped',
|
||||
Object.keys(COMBAT_SFX).sort().join(',') ===
|
||||
'down,fire,hull,impact,kill,respawn,shield');
|
||||
|
||||
check('sfx: every mapped voice exists in data/sfx.json',
|
||||
Object.values(COMBAT_SFX).flat().every((n) => typeof sfxNames[n] === 'string'));
|
||||
|
||||
const sets = Object.values(COMBAT_SFX).map((ns) => new Set(ns));
|
||||
check('sfx: the events\u2019 voices are pairwise distinct',
|
||||
sets.every((a, i) => sets.every((b, j) => i === j || ![...a].some((n) => b.has(n)))));
|
||||
|
||||
const s1 = makeSfxScene();
|
||||
check('sfx: fire → the scan ping, base volume',
|
||||
combatSfx(s1, 'fire') === 'fire' &&
|
||||
s1.played.length === 1 &&
|
||||
s1.played[0].key === 'sfx_scan' &&
|
||||
s1.played[0].volume === Number(config.get('sfx.volume', 0.55)));
|
||||
|
||||
const s2 = makeSfxScene();
|
||||
combatSfx(s2, 'fire', { vol: 0.45 }); // enemy lances play quieter
|
||||
check('sfx: vol scales the volume',
|
||||
Math.abs(s2.played[0].volume - Number(config.get('sfx.volume', 0.55)) * 0.45) < 1e-9);
|
||||
|
||||
const killKeys = new Set();
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const sc = makeSfxScene();
|
||||
combatSfx(sc, 'kill');
|
||||
killKeys.add(sc.played[0]?.key);
|
||||
}
|
||||
check('sfx: kill picks one of the thunder set',
|
||||
killKeys.size >= 2 && [...killKeys].every((k) => /^sfx_thunder_0[1-4]$/.test(k)));
|
||||
|
||||
const sU = makeSfxScene();
|
||||
check('sfx: unknown event is a no-op', combatSfx(sU, 'nope') === null && sU.played.length === 0);
|
||||
|
||||
// The shield/hull decision (one shared helper, both contexts):
|
||||
// in front of the hit the craft has `preShields` shield points.
|
||||
const hit = (pre, dmg) => {
|
||||
const sc = makeSfxScene();
|
||||
combatSfxEnemyHit(sc, pre, dmg);
|
||||
return sc.played.map((p) => p.key.replace('sfx_', ''));
|
||||
};
|
||||
check('sfx: full shields → the shield voice only',
|
||||
JSON.stringify(hit(40, 10)) === JSON.stringify(['solar_buzz']));
|
||||
check('sfx: no shields → the hull voice only',
|
||||
JSON.stringify(hit(0, 10)) === JSON.stringify(['mining_split']));
|
||||
check('sfx: shield breaks through → BOTH (the pop reads)',
|
||||
JSON.stringify(hit(5, 10)) === JSON.stringify(['solar_buzz', 'mining_split']));
|
||||
|
||||
// The dev log: every call is remembered (capped) + mirrored to window.
|
||||
combatSfx(makeSfxScene(), 'down');
|
||||
const after = window.__COMBAT_SFX__ ?? null;
|
||||
check('sfx: dev log records the call (window.__COMBAT_SFX__)',
|
||||
after && after.last === 'down' &&
|
||||
after.log.length >= 1 && after.log.length <= 64 &&
|
||||
after.log[after.log.length - 1].event === 'down');
|
||||
|
||||
// The master switch: off → nothing plays, but the log still records
|
||||
// the call (headless verification depends on it).
|
||||
const saved = config.data.sfx.enabled;
|
||||
config.data.sfx.enabled = false;
|
||||
const sOff = makeSfxScene();
|
||||
combatSfx(sOff, 'kill');
|
||||
config.data.sfx.enabled = saved;
|
||||
check('sfx: master switch off → silent, still logged',
|
||||
sOff.played.length === 0 && (window.__COMBAT_SFX__?.last === 'kill'));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- readability layer
|
||||
|
||||
{
|
||||
// pickTarget — the shared "current target" rule (the range's lock and
|
||||
// the main game's auto-fire both run it).
|
||||
const A = { x: 10, y: 0, dead: false, hitRadius: 20 };
|
||||
const B = { x: 3, y: 0, dead: true, hitRadius: 20 }; // dead — never qualifies
|
||||
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 skips the dead (even when nearest)', pickTarget([A, B, C], 2, 0) === A);
|
||||
check('read: pickTarget honors the range (and nulls outside it)',
|
||||
pickTarget([A, C], 0, 0, 50) === A && pickTarget([A, C], 0, 0, 5) === null);
|
||||
check('read: pickTarget empty / all-dead → null',
|
||||
pickTarget([], 0, 0) === null && pickTarget([B], 0, 0) === null);
|
||||
check('read: pickTarget tolerates missing entries', pickTarget([null, undefined, A], 0, 0) === A);
|
||||
|
||||
// feedPush — the kill feed's pure state step.
|
||||
const f1 = feedPush([], 'KILL A', 1000, 5, 3000);
|
||||
const f2 = feedPush(f1, 'KILL B', 2000, 5, 3000);
|
||||
check('read: feed lands the newest first (and keeps the rest)',
|
||||
f2.length === 2 && f2[0].label === 'KILL B' && f2[1].label === 'KILL A');
|
||||
check('read: feed caps at the entry count',
|
||||
feedPush(feedPush(feedPush([], 'a', 0, 2, 3000), 'b', 1, 2, 3000), 'c', 2, 2, 3000).length === 2);
|
||||
const f3 = feedPush(feedPush([], 'KILL OLD', 0, 5, 3000), 'KILL NEW', 4000, 5, 3000);
|
||||
check('read: feed drops the expired (life 3000ms)', f3.length === 1 && f3[0].label === 'KILL NEW');
|
||||
|
||||
// offscreenArrow — the edge-arrow geometry (pure).
|
||||
const VW = 1280, VH = 720, PAD = 28;
|
||||
check('read: on-screen target → no arrow', offscreenArrow(VW, VH, 640, 360, PAD) === null);
|
||||
const right = offscreenArrow(VW, VH, 2000, 360, PAD);
|
||||
check('read: off-screen right → arrow on the right edge, aimed +x',
|
||||
right && Math.abs(right.x - (VW - PAD)) < 1e-9 && Math.abs(right.angle) < 1e-9);
|
||||
const up = offscreenArrow(VW, VH, 640, -500, PAD);
|
||||
check('read: off-screen up → arrow on the top edge, aimed -y',
|
||||
up && Math.abs(up.y - PAD) < 1e-9 && approx(up.angle, -Math.PI / 2, 1e-9));
|
||||
const diag = offscreenArrow(VW, VH, 2000, -500, PAD);
|
||||
check('read: diagonal → the point sits on the inset edge, aimed NE',
|
||||
diag &&
|
||||
(Math.abs(diag.x - (VW - PAD)) < 1e-9 || Math.abs(diag.y - PAD) < 1e-9) &&
|
||||
diag.angle < 0 && diag.angle > -Math.PI / 2);
|
||||
|
||||
// CombatHud — the shared layer running headless on the stub scene.
|
||||
const scene = makeScene({ withSheet: true });
|
||||
const enemies = [
|
||||
{ x: 300, y: 300, dead: false, hitRadius: 20 },
|
||||
{ x: 900, y: 400, dead: true, hitRadius: 20 }, // the threat cue skips it
|
||||
];
|
||||
const rects = { shield: { x: 44, y: 75, w: 210, h: 9 }, hull: { x: 44, y: 35, w: 210, h: 9 } };
|
||||
const hud = new CombatHud(scene, {
|
||||
getEnemies: () => enemies,
|
||||
getTarget: () => enemies[0],
|
||||
gaugeRect: (k) => rects[k] ?? null,
|
||||
});
|
||||
check('read: hud.kill lands the feed entry (the pure step)',
|
||||
hud.kill('HOSTILE DESTROYED')[0].label === 'HOSTILE DESTROYED');
|
||||
const overlay = hud.flash('hull');
|
||||
check('read: hud.flash records the layer + the gauge rect',
|
||||
overlay && overlay.width === 210 && overlay.height === 9 && hud.lastFlash.kind === 'hull');
|
||||
hud.update(200); // the draw pass (reticle/threat/arrows/feed) + flash step
|
||||
check('read: the draw pass runs; the flash is fading',
|
||||
overlay.alpha > 0 && overlay.alpha < 0.85);
|
||||
hud.update(400); // past the 340ms life → the overlay retires
|
||||
check('read: the flash retires after its life',
|
||||
hud.flashes.length === 0 && overlay.destroyed === true);
|
||||
hud.destroy();
|
||||
check('read: hud.destroy clears the bookkeeping',
|
||||
hud.feed.length === 0 && hud.feedTexts.length === 0 && hud.flashes.length === 0);
|
||||
|
||||
// No gauge rects (a context without a bar) → the flash is a safe no-op
|
||||
// that still records the layer (the dev-log contract).
|
||||
const hud2 = new CombatHud(makeScene({ withSheet: true }), { getEnemies: () => [] });
|
||||
check('read: flash without a gauge is a recorded no-op',
|
||||
hud2.flash('shield') === null && hud2.lastFlash.kind === 'shield');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ done
|
||||
|
||||
console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`);
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
|
|
@ -34,6 +34,67 @@ export function applyDamage(armor, amount) {
|
|||
return { shieldHit, hullHit, dead: armor.hull <= 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* One step of the combat ship's FLIGHT FEEL (the range's WASD handling —
|
||||
* js/combat/CombatScene.js): a smoothed turn rate, a ramped throttle,
|
||||
* and an instant brake. Pure — no Phaser — so it runs in bare Node
|
||||
* (dev/combat.test.mjs) and the scene just reads the result onto the
|
||||
* arcade body each frame.
|
||||
*
|
||||
* The feel state `st` is: `{ rot, turnVel, throttle, brake }`
|
||||
* rot — the render heading (rad); the scene assigns it to the ship
|
||||
* turnVel — the current turn velocity (rad/s), eased toward ±rotSpeed
|
||||
* throttle — 0..1, eased toward 1 while `up`, toward 0 when released;
|
||||
* the scene scales the weapon-free thrust by it
|
||||
* brake — the instant reverse-throttle fraction while `down` (0..1)
|
||||
*
|
||||
* `input` is the boolean key state `{ left, right, up, down }`.
|
||||
* `cfg` (data/combat.json → player): rotSpeed (rad/s sustained turn),
|
||||
* rotApproach (1/s, how fast turnVel settles — smoothing), thrustRamp
|
||||
* (1/s, throttle-up responsiveness), thrustDecay (1/s, throttle release),
|
||||
* brake (fraction of FULL thrust, instant while down).
|
||||
* Exponential approaches ⇒ frame-rate independent, no overshoot.
|
||||
*
|
||||
* @param {{rot?: number, turnVel?: number, throttle?: number, brake?: number}} st
|
||||
* @param {{left?: boolean, right?: boolean, up?: boolean, down?: boolean}} input
|
||||
* @param {number} dt seconds (clamped to ≤ 0.1 — a backgrounded tab can't fast-forward the feel)
|
||||
* @param {object} cfg
|
||||
* @returns the same `st` object, mutated
|
||||
*/
|
||||
export function handleFeel(st, input, dt, cfg) {
|
||||
const d = Math.min(Math.max(Number(dt) || 0, 0), 0.1);
|
||||
const cfg2 = cfg ?? {};
|
||||
|
||||
// Turn: the turn VELOCITY eases toward ±rotSpeed (a smoothed gyroscope —
|
||||
// the first key-press builds turn, so a tap is a nudge and a hold is
|
||||
// a full-rate spin). v(t) = T − (T − v₀)·e^(−r·t), and rot integrates
|
||||
// that EXACTLY (∫₀ᵈ v dt) — so the feel is frame-rate independent by
|
||||
// construction (two half-steps ≡ one full step, no Euler drift).
|
||||
const dir = (input.right ? 1 : 0) - (input.left ? 1 : 0);
|
||||
const T = dir * Math.max(0, Number(cfg2.rotSpeed) || 0);
|
||||
const r = Math.max(0.1, Number(cfg2.rotApproach) || 12);
|
||||
const v0 = st.turnVel || 0;
|
||||
const q = Math.exp(-r * d);
|
||||
const v1 = T - (T - v0) * q;
|
||||
st.rot = (st.rot || 0) + (T * d - (T - v0) * (1 - q) / r);
|
||||
st.turnVel = v1;
|
||||
if (dir === 0 && Math.abs(st.turnVel) < 1e-4) st.turnVel = 0;
|
||||
|
||||
// Throttle: ramps up toward 1 while `up`; cuts quickly when released
|
||||
// (no lingering kick after the key lets go).
|
||||
const want = input.up ? 1 : 0;
|
||||
const rate = want
|
||||
? Math.max(0.1, Number(cfg2.thrustRamp) || 10)
|
||||
: Math.max(0.1, Number(cfg2.thrustDecay) || 16);
|
||||
st.throttle = want - (want - (st.throttle || 0)) * Math.exp(-rate * d);
|
||||
if (want === 0 && st.throttle < 1e-3) st.throttle = 0;
|
||||
|
||||
// Brake: an INSTANT reverse throttle (S/Down) as a fraction of FULL
|
||||
// thrust — a responsive stop, independent of the ramp.
|
||||
st.brake = input.down ? Math.max(0, Number(cfg2.brake) || 0) : 0;
|
||||
return st;
|
||||
}
|
||||
|
||||
/**
|
||||
* The render heading (rad) of a ship sprite: its rotation minus the
|
||||
* art's facing offset (ship.artOffset — the sheet art may not face east,
|
||||
|
|
@ -58,3 +119,86 @@ export function orbitStep(px, py, orbitAngle, orbitDir, orbitSpeed, dt, orbitRad
|
|||
y: py + Math.sin(angle) * orbitRadius,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- the readability layer
|
||||
//
|
||||
// The READ rules behind the readability layer (js/combat/CombatHud.js) —
|
||||
// pure, so both contexts (the range AND the main game) share the same
|
||||
// "what counts as the current target / where does the off-screen arrow
|
||||
// land / what the feed shows" logic. data/combat.json → readability.*
|
||||
// carries the numbers; CombatHud does the drawing.
|
||||
|
||||
/**
|
||||
* 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
|
||||
* main game's auto-fire both run this rule, so "what's locked" never
|
||||
* drifts between the two. Dead hostiles never qualify.
|
||||
*
|
||||
* @returns {object|null} the target (anything with .x/.y/.dead)
|
||||
*/
|
||||
export function pickTarget(enemies, x, y, range = Infinity) {
|
||||
let best = null;
|
||||
let bestD = Infinity;
|
||||
const r2 = range * range;
|
||||
for (const e of enemies || []) {
|
||||
if (!e || e.dead) continue;
|
||||
const dx = e.x - x;
|
||||
const dy = e.y - y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 <= r2 && d2 < bestD) {
|
||||
bestD = d2;
|
||||
best = e;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* One kill-feed entry lands (pure state step): unshift {label, t: now},
|
||||
* drop anything older than `life` ms, cap at `max` lines. Returns a NEW
|
||||
* array (the caller's stays put — the same rule the range and the main
|
||||
* game's feed both run).
|
||||
*
|
||||
* @returns {Array<{label: string, t: number}>} the next feed state
|
||||
*/
|
||||
export function feedPush(feed, label, now, max = 5, life = 3000) {
|
||||
const kept = (feed || []).filter((e) => e && now - e.t < life);
|
||||
return [{ label, t: now }, ...kept].slice(0, Math.max(1, max));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the OFF-SCREEN arrow for a hostile sits: the point where the ray
|
||||
* from the viewport's center to the target's SCREEN position (sx, sy)
|
||||
* meets the viewport's edge, inset by `pad` px — plus the angle to aim
|
||||
* the arrow at the target. Returns null while the target is on screen
|
||||
* (nothing to point at). Pure geometry — no scene, no Phaser.
|
||||
*
|
||||
* @param {number} vw the viewport's width (px)
|
||||
* @param {number} vh the viewport's height (px)
|
||||
* @param {number} sx the target's x in SCREEN space
|
||||
* @param {number} sy the target's y in SCREEN space
|
||||
* @param {number} [pad] the inset from the screen edge (px)
|
||||
* @returns {{x: number, y: number, angle: number}|null}
|
||||
*/
|
||||
export function offscreenArrow(vw, vh, sx, sy, pad = 28) {
|
||||
const px = Math.max(0, pad);
|
||||
const ix = px;
|
||||
const iy = px;
|
||||
const iw = Math.max(1, vw - 2 * px);
|
||||
const ih = Math.max(1, vh - 2 * px);
|
||||
// On screen (inside the inset rect)? Nothing to point at.
|
||||
if (sx >= ix && sx <= ix + iw && sy >= iy && sy <= iy + ih) return null;
|
||||
const cx = ix + iw / 2;
|
||||
const cy = iy + ih / 2;
|
||||
const dx = sx - cx;
|
||||
const dy = sy - cy;
|
||||
if (dx === 0 && dy === 0) return null; // exactly on center — degenerate
|
||||
// Ray from the center: smallest t that puts us on the inset rect's edge.
|
||||
let t = Infinity;
|
||||
if (dx !== 0) t = Math.min(t, (dx > 0 ? iw / 2 / dx : -iw / 2 / dx));
|
||||
if (dy !== 0) t = Math.min(t, (dy > 0 ? ih / 2 / dy : -ih / 2 / dy));
|
||||
if (!Number.isFinite(t) || t <= 0) return null;
|
||||
const x = cx + t * dx;
|
||||
const y = cy + t * dy;
|
||||
return { x, y, angle: Math.atan2(dy, dx) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,325 @@
|
|||
import Phaser from '../vendor/phaser.js';
|
||||
import { config } from '../config/Config.js';
|
||||
import { toColor, toCss } from '../utils/Color.js';
|
||||
import { fontStack, themeColor } from '../utils/Theme.js';
|
||||
|
||||
// Codebase pattern: a local fallback stack passed into fontStack()
|
||||
// (Theme.js has no FONT_FALLBACK export — see js/scenes/MenuScene.js).
|
||||
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
||||
import { pickTarget, feedPush, offscreenArrow } from './Combat.js';
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
/**
|
||||
* The READABILITY LAYER — the "what do I need to see" side of combat,
|
||||
* SHARED by both contexts (the range, js/combat/CombatScene.js, and the
|
||||
* main game via js/scenes/GameScene.js):
|
||||
*
|
||||
* - the TARGET/LOCK indicator — rotating amber brackets on the current
|
||||
* target (the one your guns are on),
|
||||
* - the THREAT cues — a pulsing hot-red marker above every hostile,
|
||||
* - the OFF-SCREEN arrows — red edge arrows pointing at hostiles
|
||||
* outside the camera's view (the main game's scrolling camera; the
|
||||
* range's arena is one screen, so they rarely fire there),
|
||||
* - the KILL FEED — the recent kills, top-right, fading,
|
||||
* - the GAUGE FLASH — a white pulse over the combat bar's shields/hull
|
||||
* gauge when YOU take that layer's hit (the bar's rect is supplied
|
||||
* by the context — the main game's js/ui/CombatBar.js, the range's
|
||||
* HUD — and the flash itself lives HERE, one shared path).
|
||||
*
|
||||
* The RULES are pure and tested (js/combat/Combat.js → pickTarget,
|
||||
* feedPush, offscreenArrow); the numbers are data/combat.json →
|
||||
* readability.*. This file only draws.
|
||||
*/
|
||||
export class CombatHud {
|
||||
/**
|
||||
* @param {Phaser.Scene} scene the scene to draw in
|
||||
* @param {object} [o]
|
||||
* getEnemies () => Array<object> — the hostiles (anything with
|
||||
* .x/.y/.dead/.hitRadius — the contexts' EnemyShips)
|
||||
* getTarget () => object|null — the CURRENT TARGET (the context's
|
||||
* rule: the range locks the nearest hostile, the main
|
||||
* game the auto-fire's target — both are pickTarget)
|
||||
* getCamera () => camera|null — the scrolling camera (main game);
|
||||
* null in the range (the arena is one fixed screen)
|
||||
* gaugeRect (kind) => {x,y,w,h}|null — the gauge rect to flash for
|
||||
* 'shield'/'hull' (the context's own bar)
|
||||
* worldToScreen (x, y) => {x, y} — world→viewport (zoom-aware). If
|
||||
* omitted, falls back to the camera's scroll (zoom 1).
|
||||
*/
|
||||
constructor(scene, o = {}) {
|
||||
this.scene = scene;
|
||||
this.getEnemies = typeof o.getEnemies === 'function' ? o.getEnemies : () => [];
|
||||
this.getTarget = typeof o.getTarget === 'function' ? o.getTarget : null;
|
||||
this.getCamera = typeof o.getCamera === 'function' ? o.getCamera : null;
|
||||
this.worldToScreen = typeof o.worldToScreen === 'function' ? o.worldToScreen : null;
|
||||
this.gaugeRect = typeof o.gaugeRect === 'function' ? o.gaugeRect : null;
|
||||
|
||||
const r = config.section('combat.readability', {});
|
||||
this.reticle = r.reticle ?? {};
|
||||
this.threat = r.threat ?? {};
|
||||
this.offscreen = r.offscreen ?? {};
|
||||
this.feedCfg = r.feed ?? {};
|
||||
this.barFlash = r.barFlash ?? {};
|
||||
|
||||
this.feed = []; // [{label, t}] — the pure state (feedPush owns it)
|
||||
this.feedTexts = []; // live text objects, parallel to this.feed
|
||||
this.flashes = []; // {rect, t0, life, alpha} — the gauge-flash overlays
|
||||
this.lastFlash = null; // {kind, at} — the test/CDP readout
|
||||
this.lastTarget = null; // the last drawn lock target (the CDP readout)
|
||||
|
||||
this._now = () =>
|
||||
scene.time && Number.isFinite(scene.time.now) ? scene.time.now : Date.now();
|
||||
|
||||
// World space (follows the ships at any camera scroll):
|
||||
this._reticleG = this._worldGraphics(11);
|
||||
this._threatG = this._worldGraphics(11);
|
||||
// Screen space (pinned to the viewport):
|
||||
this._arrowG = this._screenGraphics(120);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ the draw
|
||||
|
||||
/** A world-space graphics object (or null — the stubs without it). */
|
||||
_worldGraphics(depth) {
|
||||
if (typeof this.scene.add?.graphics !== 'function') return null;
|
||||
return this.scene.add.graphics().setDepth(depth);
|
||||
}
|
||||
|
||||
/** A screen-space graphics object (scrollFactor 0). */
|
||||
_screenGraphics(depth) {
|
||||
if (typeof this.scene.add?.graphics !== 'function') return null;
|
||||
return this.scene.add.graphics().setScrollFactor(0).setDepth(depth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step the layer (call once per frame, from the scene's update).
|
||||
* @param {number} [time] the scene clock (ms) — defaults to scene.time.now
|
||||
*/
|
||||
update(time) {
|
||||
const t = Number.isFinite(time) ? time : this._now();
|
||||
this._drawReticle(t);
|
||||
this._drawThreat(t);
|
||||
this._drawArrows(t);
|
||||
this._drawFeed(t);
|
||||
this._stepFlashes(t);
|
||||
}
|
||||
|
||||
/** The lock: rotating amber brackets around the current target. */
|
||||
_drawReticle(t) {
|
||||
const g = this._reticleG;
|
||||
if (!g) return;
|
||||
const target = this.getTarget ? this.getTarget() : null;
|
||||
this.lastTarget = target;
|
||||
if (!target || this.reticle.enabled === false) {
|
||||
g.clear();
|
||||
return;
|
||||
}
|
||||
const x = target.x;
|
||||
const y = target.y;
|
||||
const s = (Number(target.hitRadius) || 20) + (Number(this.reticle.margin) || 16);
|
||||
const a = ((t * 0.001 * (Number(this.reticle.spin) || 1.6)) % TAU + TAU) % TAU;
|
||||
const L = s * 0.5;
|
||||
const color = toColor(this.reticle.color, 0xffd84d);
|
||||
const cos = Math.cos(a);
|
||||
const sin = Math.sin(a);
|
||||
const rot = (px, py) => [x + px * cos - py * sin, y + px * sin + py * cos];
|
||||
g.clear();
|
||||
g.lineStyle(3, color, 0.95);
|
||||
for (const [qx, qy] of [[1, 1], [1, -1], [-1, 1], [-1, -1]]) {
|
||||
const [cx1, cy1] = rot(qx * s, qy * s);
|
||||
const [ax1, ay1] = rot(qx * (s - L), qy * s);
|
||||
const [ax2, ay2] = rot(qx * s, qy * (s - L));
|
||||
g.lineBetween(ax1, ay1, cx1, cy1);
|
||||
g.lineBetween(cx1, cy1, ax2, ay2);
|
||||
}
|
||||
}
|
||||
|
||||
/** The threat cues: a pulsing hot-red marker above every hostile. */
|
||||
_drawThreat(t) {
|
||||
const g = this._threatG;
|
||||
if (!g) return;
|
||||
g.clear();
|
||||
if (this.threat.enabled === false) return;
|
||||
const color = toColor(this.threat.color, 0xff2d6f);
|
||||
const size = Math.max(2, Number(this.threat.size) || 8);
|
||||
const above = Math.max(0, Number(this.threat.above) || 36);
|
||||
const pulseHz = Math.max(0.1, Number(this.threat.pulse) || 2.2);
|
||||
const pulse = 0.55 + 0.45 * Math.sin(t * 0.001 * TAU * pulseHz);
|
||||
for (const e of this.getEnemies()) {
|
||||
if (!e || e.dead) continue;
|
||||
g.fillStyle(color, pulse);
|
||||
g.fillCircle(e.x, e.y - above, size);
|
||||
}
|
||||
}
|
||||
|
||||
/** The off-screen arrows: red edge arrows at hostiles outside the view. */
|
||||
_drawArrows(t) {
|
||||
const g = this._arrowG;
|
||||
if (!g) return;
|
||||
g.clear();
|
||||
if (this.offscreen.enabled === false) return;
|
||||
const scale = this.scene.scale ?? {};
|
||||
const vw = Number(scale.width) || 1280;
|
||||
const vh = Number(scale.height) || 720;
|
||||
const pad = Math.max(0, Number(this.offscreen.pad) || 28);
|
||||
const size = Math.max(4, Number(this.offscreen.size) || 13);
|
||||
const max = Math.max(1, Number(this.offscreen.max) || 4);
|
||||
const color = toColor(this.offscreen.color, 0xff2d6f);
|
||||
const pulse = 0.7 + 0.3 * Math.sin(t * 0.001 * TAU * 2.2);
|
||||
|
||||
// world→screen (zoom-aware if the context supplies it, else scroll-1).
|
||||
const cam = this.getCamera ? this.getCamera() : null;
|
||||
const toScreen = (x, y) => {
|
||||
if (this.worldToScreen) return this.worldToScreen(x, y);
|
||||
const ox = cam ? Number(cam.worldView?.x ?? cam.scrollX ?? 0) : 0;
|
||||
const oy = cam ? Number(cam.worldView?.y ?? cam.scrollY ?? 0) : 0;
|
||||
return { x: x - ox, y: y - oy };
|
||||
};
|
||||
const cx = vw / 2;
|
||||
const cy = vh / 2;
|
||||
|
||||
// Nearest hostiles first (a clutter cap: the `max` closest arrows win).
|
||||
const list = this.getEnemies()
|
||||
.filter((e) => e && !e.dead)
|
||||
.map((e) => {
|
||||
const s = toScreen(e.x, e.y);
|
||||
return { e, s, d: (s.x - cx) ** 2 + (s.y - cy) ** 2 };
|
||||
})
|
||||
.sort((p, q) => p.d - q.d)
|
||||
.slice(0, max);
|
||||
|
||||
for (const { s } of list) {
|
||||
const p = offscreenArrow(vw, vh, s.x, s.y, pad);
|
||||
if (!p) continue; // on screen — the threat marker covers it
|
||||
const c = Math.cos(p.angle);
|
||||
const sn = Math.sin(p.angle);
|
||||
const px = -sn; // perpendicular
|
||||
const py = c;
|
||||
g.fillStyle(color, pulse);
|
||||
g.fillTriangle(
|
||||
p.x + c * size * 1.5,
|
||||
p.y + sn * size * 1.5, // tip (toward the target)
|
||||
p.x - c * size - px * size,
|
||||
p.y - sn * size - py * size,
|
||||
p.x - c * size + px * size,
|
||||
p.y - sn * size + py * size,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The kill feed (top-right, fading; the pure state is Combat.feedPush). */
|
||||
_drawFeed(t) {
|
||||
if (this.feedCfg.enabled === false) return;
|
||||
const max = Math.max(1, Number(this.feedCfg.entries) || 5);
|
||||
const life = Math.max(200, Number(this.feedCfg.life) || 3000);
|
||||
// Prune expired entries (and their text objects).
|
||||
this.feed = this.feed.filter((e) => e && t - e.t < life);
|
||||
while (this.feed.length > max) this.feed.pop();
|
||||
// Retire surplus text (the feed shrank).
|
||||
while (this.feedTexts.length > this.feed.length) {
|
||||
const o = this.feedTexts.pop();
|
||||
if (o && typeof o.destroy === 'function') o.destroy();
|
||||
}
|
||||
const canText = typeof this.scene.add?.text === 'function';
|
||||
if (!canText) return; // state still lands (this.feed) — the stubs just don't draw
|
||||
const scale = this.scene.scale ?? {};
|
||||
const vw = Number(scale.width) || 1280;
|
||||
const x = vw - (Number(this.feedCfg.xInset) || 18);
|
||||
const y0 = Number(this.feedCfg.y0) ?? 64;
|
||||
const gap = Math.max(10, Number(this.feedCfg.gap) || 19);
|
||||
const size = Math.max(8, Number(this.feedCfg.size) || 13);
|
||||
const ink = toCss(this.feedCfg.ink, '#cfe9ff');
|
||||
for (let i = 0; i < this.feed.length; i++) {
|
||||
const entry = this.feed[i];
|
||||
let o = this.feedTexts[i];
|
||||
if (!o) {
|
||||
o = this.scene
|
||||
.add.text(x, y0 + i * gap, '', {
|
||||
fontFamily: fontStack('body', FONT_FALLBACK),
|
||||
fontSize: `${size}px`,
|
||||
color: ink,
|
||||
letterSpacing: 1,
|
||||
})
|
||||
.setOrigin(1, 0)
|
||||
.setScrollFactor(0)
|
||||
.setDepth(120)
|
||||
.setAlpha(0);
|
||||
this.feedTexts[i] = o;
|
||||
}
|
||||
if (o.text !== entry.label) o.setText(entry.label);
|
||||
const age = t - entry.t;
|
||||
// Hold full, then fade out over the last third of the life.
|
||||
const hold = life * 0.66;
|
||||
o.setAlpha(age <= hold ? 0.95 : Math.max(0, 0.95 * (1 - (age - hold) / (life - hold))));
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ the flash
|
||||
|
||||
/**
|
||||
* Flash a gauge (the player's hit read on the bar): a white pulse over
|
||||
* the rect the context reports for `kind` ('shield'/'hull'). The flash
|
||||
* is stepped in update() — one shared path for the main bar and the
|
||||
* range's HUD gauges.
|
||||
*/
|
||||
flash(kind) {
|
||||
const t = this._now();
|
||||
this.lastFlash = { kind, at: t };
|
||||
const rect = this.gaugeRect ? this.gaugeRect(kind) : null;
|
||||
if (!rect || typeof this.scene.add?.rectangle !== 'function') return null;
|
||||
const life = Math.max(60, Number(this.barFlash.life) || 340);
|
||||
const alpha = Math.max(0, Math.min(1, Number(this.barFlash.alpha) ?? 0.85));
|
||||
const o = this.scene.add.rectangle(
|
||||
rect.x + rect.w / 2,
|
||||
rect.y + rect.h / 2,
|
||||
rect.w,
|
||||
rect.h,
|
||||
0xffffff,
|
||||
0,
|
||||
);
|
||||
if (typeof o.setScrollFactor === 'function') o.setScrollFactor(0);
|
||||
if (typeof o.setDepth === 'function') o.setDepth(60);
|
||||
this.flashes.push({ rect: o, t0: t, life, alpha });
|
||||
return o;
|
||||
}
|
||||
|
||||
_stepFlashes(t) {
|
||||
for (let i = this.flashes.length - 1; i >= 0; i--) {
|
||||
const f = this.flashes[i];
|
||||
const age = t - f.t0;
|
||||
if (age >= f.life) {
|
||||
if (f.rect && typeof f.rect.destroy === 'function') f.rect.destroy();
|
||||
this.flashes.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
if (f.rect) f.rect.alpha = f.alpha * (1 - age / f.life);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- misc
|
||||
|
||||
/** A kill lands in the feed (the pure step is Combat.feedPush). */
|
||||
kill(label = 'HOSTILE DESTROYED') {
|
||||
this.feed = feedPush(this.feed, label, this._now(),
|
||||
Math.max(1, Number(this.feedCfg.entries) || 5),
|
||||
Math.max(200, Number(this.feedCfg.life) || 3000));
|
||||
return this.feed;
|
||||
}
|
||||
|
||||
/** Tear down (the scene owns most of it — this is the bookkeeping). */
|
||||
destroy() {
|
||||
for (const o of this.feedTexts) if (o && typeof o.destroy === 'function') o.destroy();
|
||||
for (const f of this.flashes) if (f.rect && typeof f.rect.destroy === 'function') f.rect.destroy();
|
||||
if (this._reticleG && typeof this._reticleG.destroy === 'function') this._reticleG.destroy();
|
||||
if (this._threatG && typeof this._threatG.destroy === 'function') this._threatG.destroy();
|
||||
if (this._arrowG && typeof this._arrowG.destroy === 'function') this._arrowG.destroy();
|
||||
this.feed = [];
|
||||
this.feedTexts = [];
|
||||
this.flashes = [];
|
||||
this.getEnemies = this.getTarget = this.getCamera = this.gaugeRect = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-exported for the contexts — the shared "current target" rule. */
|
||||
export { pickTarget };
|
||||
|
|
@ -9,9 +9,11 @@ const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
|||
import { Ship } from '../entities/Ship.js';
|
||||
import { EnemyShip } from './EnemyShip.js';
|
||||
import { Bullet } from './Bullet.js';
|
||||
import { Fx } from './Fx.js';
|
||||
import { Fx, FX_COLORS } from './Fx.js';
|
||||
import { StarDrift } from './StarDrift.js';
|
||||
import { applyDamage } from './Combat.js';
|
||||
import { applyDamage, handleFeel, pickTarget } from './Combat.js';
|
||||
import { combatSfx, combatSfxEnemyHit, loadCombatSfx } from './CombatSfx.js';
|
||||
import { CombatHud } from './CombatHud.js';
|
||||
|
||||
/**
|
||||
* The COMBAT SANDBOX — Orbit's standalone combat range (combat.html /
|
||||
|
|
@ -72,6 +74,10 @@ export class CombatScene extends Phaser.Scene {
|
|||
frameHeight: cfg.enemy?.frameHeight ?? 128,
|
||||
});
|
||||
}
|
||||
|
||||
// The combat voices (js/combat/CombatSfx.js — the shared event→sound
|
||||
// map; the main game loads these same keys itself in GameScene).
|
||||
loadCombatSfx(this);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- create
|
||||
|
|
@ -104,6 +110,16 @@ export class CombatScene extends Phaser.Scene {
|
|||
this.ship = new Ship(this, w * 0.24, h / 2);
|
||||
this.ship.setDepth(10);
|
||||
this.ship.body.setAllowGravity(false);
|
||||
// The range's drift drag — combat.player.drag OVERRIDES ship.drag
|
||||
// (data/ship.json), so data/combat.json is the one feel surface.
|
||||
// (Ship.update reads this.drag every frame — the assignment sticks.)
|
||||
const cd = Number(config.get('combat.player.drag', 2.4));
|
||||
if (Number.isFinite(cd) && cd > 0) this.ship.drag = cd;
|
||||
// The flight-feel state (js/combat/Combat.js → handleFeel, a PURE
|
||||
// step exercised by dev/combat.test.mjs): smoothed turn rate,
|
||||
// ramped throttle, instant brake. The scene assigns it onto the
|
||||
// arcade body each frame (updatePlayer).
|
||||
this.feeel = { rot: this.ship.rotation, turnVel: 0, throttle: 0, brake: 0 };
|
||||
|
||||
// The pack (spawned on a ring around the screen center).
|
||||
this.enemies = [];
|
||||
|
|
@ -122,16 +138,58 @@ export class CombatScene extends Phaser.Scene {
|
|||
// The range console (HUD).
|
||||
this.createHud();
|
||||
|
||||
// Controls: WASD/arrows + SPACE/J. R = reset, P = pause (keydown
|
||||
// events — the same `keydown-XXX` pattern as the main game).
|
||||
const kb = this.input.keyboard;
|
||||
this.keys = kb.addKeys({
|
||||
up: 'W,UP',
|
||||
down: 'S,DOWN',
|
||||
left: 'A,LEFT',
|
||||
right: 'D,RIGHT',
|
||||
fire: 'SPACE,J',
|
||||
// The READABILITY LAYER (js/combat/CombatHud.js — shared with the
|
||||
// main game): the lock on the current target (the nearest hostile —
|
||||
// the same pickTarget rule the main game's auto-fire runs), the
|
||||
// threat markers, the off-screen arrows (rare — the arena is one
|
||||
// screen), the kill feed, and the HUD gauge flash on player hits.
|
||||
this.hud = new CombatHud(this, {
|
||||
getEnemies: () => this.enemies,
|
||||
getTarget: () =>
|
||||
this.playerDead || !this.ship ? null : pickTarget(this.enemies, this.ship.x, this.ship.y),
|
||||
// No scrolling camera — the arena is a fixed full screen.
|
||||
gaugeRect: (kind) => this.gaugeRects?.[kind] ?? null,
|
||||
});
|
||||
|
||||
// Controls: WASD/arrows + SPACE/J. R = reset, P = pause.
|
||||
//
|
||||
// NOTE — this build (Phaser 4.2.1 Giedi): `addKeys({up:'W,UP',…})`
|
||||
// looks each value up in the KeyCodes map, which has NO 'W,UP' entry —
|
||||
// every name landed on ONE shared Key with keyCode `undefined`, so the
|
||||
// old `keys.up.isDown` polling could never see a real key-press
|
||||
// (WASD was silently dead). The main game's working pattern is the
|
||||
// event API — the manager emits `keydown-<NAME>` for real keycodes —
|
||||
// so drive a plain key-state object from those events. Real Key
|
||||
// objects are still registered (single names — that form works) so
|
||||
// the manager captures them (arrows/space must not scroll the page).
|
||||
const kb = this.input.keyboard;
|
||||
for (const code of ['W', 'A', 'S', 'D', 'UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'J']) {
|
||||
kb.addKey(code);
|
||||
}
|
||||
this.keys = {
|
||||
up: { isDown: false },
|
||||
down: { isDown: false },
|
||||
left: { isDown: false },
|
||||
right: { isDown: false },
|
||||
fire: { isDown: false },
|
||||
};
|
||||
const bind = (name, code) => {
|
||||
kb.on(`keydown-${code}`, () => { this.keys[name].isDown = true; });
|
||||
kb.on(`keyup-${code}`, () => { this.keys[name].isDown = false; });
|
||||
};
|
||||
bind('up', 'W'); bind('up', 'UP');
|
||||
bind('down', 'S'); bind('down', 'DOWN');
|
||||
bind('left', 'A'); bind('left', 'LEFT');
|
||||
bind('right', 'D'); bind('right', 'RIGHT');
|
||||
bind('fire', 'SPACE'); bind('fire', 'J');
|
||||
// Losing focus must not leave a key "held" (wire once — the scene can
|
||||
// restart in place, and the closure re-reads `this.keys` every blur).
|
||||
if (typeof window !== 'undefined' && !this.__blurWired) {
|
||||
this.__blurWired = true;
|
||||
window.addEventListener('blur', () => {
|
||||
for (const k of Object.values(this.keys)) k.isDown = false;
|
||||
});
|
||||
}
|
||||
kb.on('keydown-R', () => this.reset());
|
||||
kb.on('keydown-P', () => this.togglePause());
|
||||
|
||||
|
|
@ -177,6 +235,7 @@ export class CombatScene extends Phaser.Scene {
|
|||
|
||||
this.fx.update(delta);
|
||||
this.starfield.update(time, delta);
|
||||
this.hud.update(time); // the readability layer (lock/threat/feed/flash)
|
||||
this.updateHud(time);
|
||||
}
|
||||
|
||||
|
|
@ -195,27 +254,41 @@ export class CombatScene extends Phaser.Scene {
|
|||
// don't thrust this frame), then apply the keyboard on top.
|
||||
ship.update(time, delta);
|
||||
|
||||
// The flight feel (combat.player → the PURE step in Combat.js —
|
||||
// smoothed turn rate + ramped throttle + instant brake; tested by
|
||||
// dev/combat.test.mjs). The feel state lives on the scene.
|
||||
const p = config.section('combat.player', {});
|
||||
const keys = this.keys;
|
||||
const rot = Math.max(0.2, config.get('combat.player.rotSpeed', 4.8)) * dt;
|
||||
if (keys.left.isDown) ship.rotation -= rot;
|
||||
if (keys.right.isDown) ship.rotation += rot;
|
||||
handleFeel(
|
||||
this.feeel,
|
||||
{
|
||||
left: keys.left.isDown,
|
||||
right: keys.right.isDown,
|
||||
up: keys.up.isDown,
|
||||
down: keys.down.isDown,
|
||||
},
|
||||
dt,
|
||||
p,
|
||||
);
|
||||
ship.rotation = this.feeel.rot;
|
||||
|
||||
const heading = ship.rotation - ship.artOffset; // world radians (Ship contract)
|
||||
const thrust = Math.max(0, config.get('combat.player.thrust', 1500));
|
||||
const full = Math.max(0, config.get('combat.player.thrust', 1500));
|
||||
const thrust = full * this.feeel.throttle;
|
||||
let ax = 0;
|
||||
let ay = 0;
|
||||
let thrusting = false;
|
||||
if (keys.up.isDown) {
|
||||
if (thrust > 0) {
|
||||
ax += Math.cos(heading) * thrust;
|
||||
ay += Math.sin(heading) * thrust;
|
||||
thrusting = true;
|
||||
}
|
||||
if (keys.down.isDown) {
|
||||
const f = thrust * Math.max(0, config.get('combat.player.brake', 0.6));
|
||||
ax -= Math.cos(heading) * f;
|
||||
ay -= Math.sin(heading) * f;
|
||||
if (this.feeel.brake > 0) {
|
||||
// The brake is a fraction of FULL thrust (an instant stop).
|
||||
ax -= Math.cos(heading) * full * this.feeel.brake;
|
||||
ay -= Math.sin(heading) * full * this.feeel.brake;
|
||||
}
|
||||
if (thrusting || keys.down.isDown) ship.body.acceleration.set(ax, ay);
|
||||
if (thrusting || this.feeel.brake > 0) ship.body.acceleration.set(ax, ay);
|
||||
|
||||
// Ship's drift branch has no speed cap — enforce the range's.
|
||||
const maxV = Math.max(1, config.get('combat.player.maxSpeed', 560));
|
||||
|
|
@ -251,6 +324,7 @@ export class CombatScene extends Phaser.Scene {
|
|||
const y = this.ship.y + Math.sin(heading) * nose;
|
||||
this.spawnBullet(x, y, angle, wpn, 'player');
|
||||
this.fx.flash(x, y, wpn.color ?? 0x00e5ff, 30, 110);
|
||||
combatSfx(this, 'fire');
|
||||
}
|
||||
|
||||
updateThrustGlow(ship, heading, thrusting, dt) {
|
||||
|
|
@ -317,6 +391,7 @@ export class CombatScene extends Phaser.Scene {
|
|||
'enemy',
|
||||
);
|
||||
this.fx.flash(e.x + Math.cos(toPlayer) * nose, e.y + Math.sin(toPlayer) * nose, wpn.color ?? 0xff2d6f, 28, 100);
|
||||
combatSfx(this, 'fire', { vol: 0.45 }); // the same blaster voice, quieter
|
||||
}
|
||||
|
||||
killEnemy(e) {
|
||||
|
|
@ -324,6 +399,8 @@ export class CombatScene extends Phaser.Scene {
|
|||
e._killFx = true; // the dead flag can't be the "already counted" guard
|
||||
this.kills += 1;
|
||||
this.fx.explode(e.x, e.y, 0xff5d6f);
|
||||
combatSfx(this, 'kill');
|
||||
if (this.hud) this.hud.kill('HOSTILE DESTROYED'); // the kill feed
|
||||
e.setVisible(false);
|
||||
if (e.body) {
|
||||
e.body.velocity.x = 0;
|
||||
|
|
@ -380,7 +457,10 @@ export class CombatScene extends Phaser.Scene {
|
|||
}
|
||||
// Walls clip tracers (a bullet never outlives the arena).
|
||||
if (b.x < this.inset || b.x > this.w - this.inset || b.y < this.inset || b.y > this.h - this.inset) {
|
||||
if (b.consume()) this.bullets.splice(i, 1);
|
||||
if (b.consume()) {
|
||||
this.bullets.splice(i, 1);
|
||||
combatSfx(this, 'impact', { vol: 0.5 }); // the tracer dies on the wall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -417,8 +497,13 @@ export class CombatScene extends Phaser.Scene {
|
|||
for (const e of this.enemies) {
|
||||
if (e.dead) continue;
|
||||
if (this.hitsSwept(b, e, e.hitRadius, delta)) {
|
||||
const preShields = e.shields;
|
||||
const killed = e.damage(b.damage);
|
||||
this.fx.flash(b.x, b.y, 0xff9db4, 34, 130);
|
||||
const kind = preShields > 0 ? 'shield' : 'hull';
|
||||
this.fx.flash(b.x, b.y, FX_COLORS[kind], 34, 130);
|
||||
this.fx.damage(b.x, b.y, b.damage, kind);
|
||||
combatSfx(this, 'impact');
|
||||
combatSfxEnemyHit(this, preShields, b.damage);
|
||||
if (killed) this.killEnemy(e);
|
||||
if (b.consume()) this.bullets.splice(i, 1);
|
||||
break;
|
||||
|
|
@ -485,8 +570,12 @@ export class CombatScene extends Phaser.Scene {
|
|||
const midX = (a.x + b.x) / 2;
|
||||
const midY = (a.y + b.y) / 2;
|
||||
this.fx.flash(midX, midY, 0xffffff, 46, 160);
|
||||
this.damagePlayer(Number(config.get('combat.enemy.ramDamage', 10)) || 0);
|
||||
const killed = b.damage(Number(config.get('combat.enemy.ramDamageSelf', 22)) || 0);
|
||||
const preShields = b.shields;
|
||||
const ramSelf = Number(config.get('combat.enemy.ramDamageSelf', 22)) || 0;
|
||||
this.damagePlayer(Number(config.get('combat.enemy.ramDamage', 10)) || 0); // impact + shield/hull
|
||||
this.fx.damage(b.x, b.y, ramSelf, preShields > 0 ? 'shield' : 'hull');
|
||||
combatSfxEnemyHit(this, preShields, ramSelf);
|
||||
const killed = b.damage(ramSelf);
|
||||
if (killed) this.killEnemy(b);
|
||||
}
|
||||
}
|
||||
|
|
@ -520,17 +609,30 @@ export class CombatScene extends Phaser.Scene {
|
|||
if (this.playerDead) return;
|
||||
this.lastHitAt = this.time.now;
|
||||
const armor = { hull: this.hull, shields: this.shields };
|
||||
const { dead } = applyDamage(armor, n);
|
||||
const { dead, shieldHit, hullHit } = applyDamage(armor, n);
|
||||
this.hull = armor.hull;
|
||||
this.shields = armor.shields;
|
||||
this.fx.flash(this.ship.x, this.ship.y, 0xffffff, 60, 140);
|
||||
// The hit-flash reads the layer that took it (cyan = shields, orange = hull).
|
||||
const kind = shieldHit > 0 ? 'shield' : 'hull';
|
||||
this.fx.flash(this.ship.x, this.ship.y, FX_COLORS[kind], 60, 140);
|
||||
this.fx.damage(this.ship.x, this.ship.y, n, kind); // the damage number (both contexts)
|
||||
// The player-hit voices (one place: lances AND rams funnel here).
|
||||
combatSfx(this, 'impact');
|
||||
if (shieldHit > 0) combatSfx(this, 'shield');
|
||||
if (hullHit > 0) combatSfx(this, 'hull');
|
||||
// The player-hit read on the gauges (the layer(s) that took it).
|
||||
if (this.hud) {
|
||||
if (shieldHit > 0) this.hud.flash('shield');
|
||||
if (hullHit > 0) this.hud.flash('hull');
|
||||
}
|
||||
if (dead) this.killPlayer();
|
||||
}
|
||||
|
||||
killPlayer() {
|
||||
this.playerDead = true;
|
||||
this.hull = 0;
|
||||
this.fx.explode(this.ship.x, this.ship.y, themeColor('neon', 0x00e5ff));
|
||||
this.fx.down(this.ship.x, this.ship.y); // the DOWN cue (burst + hold)
|
||||
combatSfx(this, 'down');
|
||||
this.ship.stop();
|
||||
this.ship.setVisible(false);
|
||||
if (config.get('combat.sandbox.respawn', true)) {
|
||||
|
|
@ -549,7 +651,8 @@ export class CombatScene extends Phaser.Scene {
|
|||
this.ship.setVisible(true);
|
||||
this.playerDead = false;
|
||||
this.clearBullets();
|
||||
this.fx.flash(this.ship.x, this.ship.y, themeColor('neon', 0x00e5ff), 70, 260);
|
||||
this.fx.respawn(this.ship.x, this.ship.y); // the REFORMED cue
|
||||
combatSfx(this, 'respawn');
|
||||
}
|
||||
|
||||
clearBullets() {
|
||||
|
|
@ -647,6 +750,12 @@ export class CombatScene extends Phaser.Scene {
|
|||
}).setOrigin(0, 0.5).setDepth(D);
|
||||
this.shdBarBg = this.add.rectangle(bx + bw / 2, py + 80, bw, 9, 0x101a2c).setDepth(D);
|
||||
this.shdBar = this.add.rectangle(bx + bw / 2, py + 80, bw, 9, neon).setDepth(D);
|
||||
// The gauge rects (for the readability layer's hit-flash — the same
|
||||
// shared flash the main game's combat bar gets).
|
||||
this.gaugeRects = {
|
||||
hull: { x: bx, y: py + 35.5, w: bw, h: 9 },
|
||||
shield: { x: bx, y: py + 75.5, w: bw, h: 9 },
|
||||
};
|
||||
this.shdText = this.add.text(numX, py + 80, '40', {
|
||||
fontFamily: bodyFont, fontSize: '15px', color: toCss(ink, '#eaf6ff'),
|
||||
}).setOrigin(0, 0.5).setDepth(D);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
import { config } from '../config/Config.js';
|
||||
import { playSfxOn, playSfxOneOfOn } from '../utils/Sfx.js';
|
||||
|
||||
/**
|
||||
* Combat SFX — ONE shared event→voice map for both combat contexts (the
|
||||
* range: js/combat/CombatScene.js, and the main game: js/combat/CombatSystem.js).
|
||||
* The contexts call `combatSfx(scene, event)` at their event sites; the
|
||||
* map + the playback path live HERE, so the two contexts can never drift.
|
||||
*
|
||||
* Every event gets a DISTINCT voice from the EXISTING data/sfx.json set
|
||||
* (no new sound files):
|
||||
*
|
||||
* fire scan the blaster ping (player cannon; enemy lances
|
||||
* play the same voice, quieter via `vol`)
|
||||
* impact ui_click the light tick of a tracer striking
|
||||
* shield solar_buzz the energy shimmer of a field absorbing
|
||||
* hull mining_split the heavy crack of armor taking the hit
|
||||
* kill thunder_01..04 one at random — the boom of a ship dying
|
||||
* down deconstruct the player's death (the collapse voice)
|
||||
* respawn discovery the player reforming (the "new object in
|
||||
* view" voice)
|
||||
*
|
||||
* Playback goes through the shared Sfx.js guards (master switch, audio
|
||||
* manager, cache) — a headless scene with no manager simply doesn't
|
||||
* sound, but the dev log below still records the call.
|
||||
*/
|
||||
export const COMBAT_SFX = {
|
||||
fire: ['scan'],
|
||||
impact: ['ui_click'],
|
||||
shield: ['solar_buzz'],
|
||||
hull: ['mining_split'],
|
||||
kill: ['thunder_01', 'thunder_02', 'thunder_03', 'thunder_04'],
|
||||
down: ['deconstruct'],
|
||||
respawn: ['discovery'],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- dev log
|
||||
//
|
||||
// Dev hook (documented in the README's combat section): every combatSfx
|
||||
// call is recorded here — capped, mirrored to window.__COMBAT_SFX__ —
|
||||
// so the wiring is verifiable in a headless browser (no audio manager)
|
||||
// in BOTH contexts: dev/combat.sfxcheck.mjs drives real game events and
|
||||
// reads the log back. It observes the real call path; it never plays
|
||||
// sound itself.
|
||||
|
||||
const LOG_CAP = 64;
|
||||
const log = [];
|
||||
|
||||
function remember(entry) {
|
||||
log.push(entry);
|
||||
if (log.length > LOG_CAP) log.splice(0, log.length - LOG_CAP);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__COMBAT_SFX__ = { log: log.slice(), last: entry.event };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play the combat voice for `event` on `scene`.
|
||||
*
|
||||
* @param {object} scene a Phaser scene (any of the combat contexts')
|
||||
* @param {string} event a COMBAT_SFX key
|
||||
* @param {object} [o] `vol` = 0..1 multiplier (enemy fire plays quieter)
|
||||
* @returns {string|null} the event name (null for an unknown event)
|
||||
*/
|
||||
export function combatSfx(scene, event, o = {}) {
|
||||
const names = COMBAT_SFX[event];
|
||||
if (!names) return null;
|
||||
const vol = Math.min(1, Math.max(0, Number(o.vol) || 1));
|
||||
const pass = { ...o, volume: Number(config.get('sfx.volume', 0.55)) * vol };
|
||||
delete pass.vol;
|
||||
if (names.length > 1) playSfxOneOfOn(scene, names, pass);
|
||||
else playSfxOn(scene, names[0], pass);
|
||||
remember({ event, names, t: Date.now() });
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* A hostile took a `dmg`-point hit with `preShields` shield points in
|
||||
* front of it (read BEFORE the damage landed): the shield/hull voices —
|
||||
* a hit that BREAKS through to the hull plays both, which reads as the
|
||||
* shield popping. The `impact` tick is the caller's (it also plays on
|
||||
* walls and rams).
|
||||
*/
|
||||
export function combatSfxEnemyHit(scene, preShields, dmg) {
|
||||
if (preShields > 0) combatSfx(scene, 'shield');
|
||||
if (preShields < dmg) combatSfx(scene, 'hull');
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue the combat voices onto a scene's loader (the range's preload —
|
||||
* the main game already loads ALL of data/sfx.json in GameScene).
|
||||
* Skipped when the master switch is off (same rule as GameScene).
|
||||
*/
|
||||
export function loadCombatSfx(scene) {
|
||||
if (!scene || !scene.load || !config.get('sfx.enabled', true)) return;
|
||||
const seen = new Set();
|
||||
for (const names of Object.values(COMBAT_SFX)) {
|
||||
for (const n of names) {
|
||||
if (seen.has(n)) continue;
|
||||
seen.add(n);
|
||||
const path = config.get(`sfx.${n}`, '');
|
||||
if (typeof path === 'string' && path) scene.load.audio(`sfx_${n}`, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { config } from '../config/Config.js';
|
||||
import { EnemyShip } from './EnemyShip.js';
|
||||
import { Bullet } from './Bullet.js';
|
||||
import { Fx } from './Fx.js';
|
||||
import { applyDamage } from './Combat.js';
|
||||
import { Fx, FX_COLORS } from './Fx.js';
|
||||
import { applyDamage, pickTarget } from './Combat.js';
|
||||
import { combatSfx, combatSfxEnemyHit } from './CombatSfx.js';
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
|
|
@ -214,19 +215,51 @@ export class CombatSystem {
|
|||
this.fx.update(delta);
|
||||
}
|
||||
|
||||
/** The nearest live hostile to (x, y), or null. */
|
||||
/** The nearest live hostile to (x, y), or null (the shared pickTarget rule). */
|
||||
nearestEnemy(x, y) {
|
||||
let best = null;
|
||||
let bestD = Infinity;
|
||||
return pickTarget(this.enemies, x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* The CURRENT TARGET (the readability layer's lock, js/combat/CombatHud.js):
|
||||
* the hostile the auto-fire is on — the nearest one WITHIN auto-fire
|
||||
* range (the same pickTarget rule, ranged by game.autoFire.range — so
|
||||
* the brackets sit on exactly the ship the cannon is tracking).
|
||||
*/
|
||||
currentTarget() {
|
||||
if (!this.ship) return null;
|
||||
return pickTarget(this.enemies, this.ship.x, this.ship.y, this.autoFire.range);
|
||||
}
|
||||
|
||||
/**
|
||||
* RE-ANCHOR THE PACK AROUND THE SHIP — in the main game the ship's
|
||||
* start position settles AFTER create() (the tether barrier / a save
|
||||
* restore places it at the starting system's rim; the fallback spawn
|
||||
* is the origin), so a pack spawned around the create-time position
|
||||
* would sit kilometers off. Shifts every live enemy so the pack's
|
||||
* centroid lands on the ship (each enemy's ring angle is preserved).
|
||||
* A no-op once anchored (within 64px), with no ship, or with no live
|
||||
* enemies. The range doesn't need it (its ship never settles away)
|
||||
* — but the shared method is where the rule lives.
|
||||
* @returns {boolean} true if the pack moved
|
||||
*/
|
||||
resyncAroundShip() {
|
||||
if (!this.ship) return false;
|
||||
let cx = 0, cy = 0, n = 0;
|
||||
for (const e of this.enemies) {
|
||||
if (!e.dead) { cx += e.x; cy += e.y; n++; }
|
||||
}
|
||||
if (n === 0) return false;
|
||||
cx /= n; cy /= n;
|
||||
const dx = this.ship.x - cx;
|
||||
const dy = this.ship.y - cy;
|
||||
if (Math.hypot(dx, dy) < 64) return false; // already anchored
|
||||
for (const e of this.enemies) {
|
||||
if (e.dead) continue;
|
||||
const d = Math.hypot(e.x - x, e.y - y);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = e;
|
||||
}
|
||||
e.x += dx;
|
||||
e.y += dy;
|
||||
}
|
||||
return best;
|
||||
return true;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- firing
|
||||
|
|
@ -248,6 +281,7 @@ export class CombatSystem {
|
|||
const y = player.y + Math.sin(a) * nose;
|
||||
this.spawnBullet(x, y, a, wpn, 'player');
|
||||
this.fx.flash(x, y, wpn.color ?? '#00e5ff', 26, 110);
|
||||
combatSfx(this.scene, 'fire');
|
||||
}
|
||||
|
||||
/** A hostile's lance at the player (aimed at the ship, spread-jittered). */
|
||||
|
|
@ -264,6 +298,7 @@ export class CombatSystem {
|
|||
'enemy',
|
||||
);
|
||||
this.fx.flash(e.x + Math.cos(a) * nose, e.y + Math.sin(a) * nose, wpn.color ?? '#ff2d6f', 28, 100);
|
||||
combatSfx(this.scene, 'fire', { vol: 0.45 }); // the same blaster voice, quieter
|
||||
}
|
||||
|
||||
spawnBullet(x, y, angle, wpn, owner) {
|
||||
|
|
@ -315,8 +350,13 @@ export class CombatSystem {
|
|||
for (const e of this.enemies) {
|
||||
if (e.dead) continue;
|
||||
if (this.hitsSwept(b, e, e.hitRadius, delta)) {
|
||||
const preShields = e.shields;
|
||||
const killed = e.damage(b.damage);
|
||||
this.fx.flash(b.x, b.y, '#ff9db4', 34, 130);
|
||||
const kind = preShields > 0 ? 'shield' : 'hull';
|
||||
this.fx.flash(b.x, b.y, FX_COLORS[kind], 34, 130);
|
||||
this.fx.damage(b.x, b.y, b.damage, kind);
|
||||
combatSfx(this.scene, 'impact');
|
||||
combatSfxEnemyHit(this.scene, preShields, b.damage);
|
||||
if (killed) this.killEnemy(e, this._now());
|
||||
if (b.consume()) this.bullets.splice(i, 1);
|
||||
break;
|
||||
|
|
@ -389,8 +429,12 @@ export class CombatSystem {
|
|||
const midX = (a.x + b.x) / 2;
|
||||
const midY = (a.y + b.y) / 2;
|
||||
this.fx.flash(midX, midY, '#ffffff', 46, 160);
|
||||
this.damagePlayer(Number(config.get('combat.enemy.ramDamage', 10)) || 0);
|
||||
const killed = b.damage(Number(config.get('combat.enemy.ramDamageSelf', 22)) || 0);
|
||||
const preShields = b.shields;
|
||||
const ramSelf = Number(config.get('combat.enemy.ramDamageSelf', 22)) || 0;
|
||||
this.damagePlayer(Number(config.get('combat.enemy.ramDamage', 10)) || 0); // impact + shield/hull
|
||||
this.fx.damage(b.x, b.y, ramSelf, preShields > 0 ? 'shield' : 'hull');
|
||||
combatSfxEnemyHit(this.scene, preShields, ramSelf);
|
||||
const killed = b.damage(ramSelf);
|
||||
if (killed) this.killEnemy(b, t);
|
||||
}
|
||||
}
|
||||
|
|
@ -402,11 +446,19 @@ export class CombatSystem {
|
|||
if (this.playerDown) return;
|
||||
this.lastHitAt = this._now();
|
||||
const armor = { hull: this.hull, shields: this.shields };
|
||||
const { dead } = applyDamage(armor, n);
|
||||
const { dead, shieldHit, hullHit } = applyDamage(armor, n);
|
||||
this.hull = armor.hull;
|
||||
this.shields = armor.shields;
|
||||
if (this.ship) this.fx.flash(this.ship.x, this.ship.y, '#ffffff', 60, 140);
|
||||
if (this.onPlayerDamage) this.onPlayerDamage(n, { hull: this.hull, shields: this.shields });
|
||||
// The hit-flash reads the layer that took it (cyan = shields, orange = hull).
|
||||
const kind = shieldHit > 0 ? 'shield' : 'hull';
|
||||
if (this.ship) this.fx.flash(this.ship.x, this.ship.y, FX_COLORS[kind], 60, 140);
|
||||
if (this.ship) this.fx.damage(this.ship.x, this.ship.y, n, kind); // the damage number (both contexts)
|
||||
// The player-hit voices (one place: lances AND rams funnel here).
|
||||
combatSfx(this.scene, 'impact');
|
||||
if (shieldHit > 0) combatSfx(this.scene, 'shield');
|
||||
if (hullHit > 0) combatSfx(this.scene, 'hull');
|
||||
if (this.onPlayerDamage)
|
||||
this.onPlayerDamage(n, { hull: this.hull, shields: this.shields, shieldHit, hullHit });
|
||||
if (dead) this.killPlayer();
|
||||
}
|
||||
|
||||
|
|
@ -415,10 +467,11 @@ export class CombatSystem {
|
|||
this.playerDown = true;
|
||||
this.hull = 0;
|
||||
if (this.ship) {
|
||||
this.fx.explode(this.ship.x, this.ship.y, '#7ce8ff');
|
||||
this.fx.down(this.ship.x, this.ship.y); // the DOWN cue (burst + hold)
|
||||
this.ship.stop();
|
||||
this.ship.setVisible(false);
|
||||
}
|
||||
combatSfx(this.scene, 'down');
|
||||
if (this.onPlayerDown) this.onPlayerDown();
|
||||
if (this.playerRespawn) {
|
||||
this.respawnAt = this._now() + this.playerRespawnDelay;
|
||||
|
|
@ -433,7 +486,8 @@ export class CombatSystem {
|
|||
this.lastHitAt = 0;
|
||||
this.respawnAt = 0;
|
||||
if (this.ship) this.ship.setVisible(true);
|
||||
if (this.ship) this.fx.flash(this.ship.x, this.ship.y, '#7ce8ff', 80, 300);
|
||||
if (this.ship) this.fx.respawn(this.ship.x, this.ship.y); // the REFORMED cue
|
||||
combatSfx(this.scene, 'respawn');
|
||||
if (this.onPlayerUp) this.onPlayerUp();
|
||||
}
|
||||
|
||||
|
|
@ -447,6 +501,7 @@ export class CombatSystem {
|
|||
e._killFx = true;
|
||||
this.kills += 1;
|
||||
this.fx.explode(e.x, e.y, '#ff5d6f');
|
||||
combatSfx(this.scene, 'kill');
|
||||
e.setVisible(false);
|
||||
if (e.body) {
|
||||
e.body.velocity.x = 0;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,26 @@
|
|||
import Phaser from '../vendor/phaser.js';
|
||||
import { toColor } from '../utils/Color.js';
|
||||
import { toColor, toCss } from '../utils/Color.js';
|
||||
import { canvasTexture } from '../utils/Textures.js';
|
||||
import { fontStack } from '../utils/Theme.js';
|
||||
|
||||
const DOT_KEY = '__combat_dot';
|
||||
const DOT_SIZE = 64;
|
||||
|
||||
/**
|
||||
* The combat feedback palette (both contexts, one place):
|
||||
* shield — a field absorbing (cyan)
|
||||
* hull — armor taking the hit (orange)
|
||||
* down — the player's death (hot red)
|
||||
* respawn — the player reforming (cool cyan)
|
||||
*/
|
||||
export const FX_COLORS = {
|
||||
shield: 0x7ce8ff,
|
||||
hull: 0xffa04d,
|
||||
kill: 0xff5d6f,
|
||||
down: 0xff2d6f,
|
||||
respawn: 0x7ce8ff,
|
||||
};
|
||||
|
||||
function drawSoftDot(ctx, size) {
|
||||
const c = size / 2;
|
||||
const g = ctx.createRadialGradient(c, c, 0, c, c, c);
|
||||
|
|
@ -89,12 +105,79 @@ export class Fx {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A floating text cue (damage numbers, DOWN / REFORMED). Rises and
|
||||
* fades over `life` ms. Needs scene.add.text (real scenes have it;
|
||||
* the test stubs don't — a missing text seam is a graceful no-op).
|
||||
*/
|
||||
text(x, y, str, color, size = 15, life = 500, rise = 26) {
|
||||
if (typeof this.scene.add?.text !== 'function') return null;
|
||||
const t = this.scene.add.text(x, y, str, {
|
||||
fontFamily: fontStack('body'),
|
||||
fontSize: `${size}px`,
|
||||
fontStyle: 'bold',
|
||||
color: toCss(color, '#ffffff'), // canvas text needs a CSS string
|
||||
stroke: '#02131c',
|
||||
strokeThickness: 4,
|
||||
}).setOrigin(0.5).setDepth(22).setAlpha(0.95);
|
||||
this.items.push({ img: t, t0: 0, life, kind: 'text', rise });
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* A damage number (both contexts): the points just dealt, floating up
|
||||
* off the point of impact — cyan when the shields took it, orange
|
||||
* when the armor took it.
|
||||
*/
|
||||
damage(x, y, amount, kind = 'hull') {
|
||||
const n = Math.max(0, Math.round(Number(amount) || 0));
|
||||
const color = kind === 'shield' ? FX_COLORS.shield : FX_COLORS.hull;
|
||||
this.text(x, y - 10, String(n), color, 15, 520, 26);
|
||||
}
|
||||
|
||||
/** The big one — a ship's death: core flash + colored bloom + wave + sparks. */
|
||||
explode(x, y, color) {
|
||||
this.flash(x, y, 0xffffff, 90, 160);
|
||||
this.flash(x, y, color, 150, 420);
|
||||
this.ring(x, y, color, 200, 480);
|
||||
this.sparks(x, y, color, 22, 300, 700);
|
||||
this.delayedRing(x, y, color, 300, 620, 140);
|
||||
this.sparks(x, y, color, 26, 320, 750);
|
||||
}
|
||||
|
||||
/**
|
||||
* A ring that waits `delay` ms before it starts (the kill burst's
|
||||
* second shockwave). Spawned invisible until its delay runs out.
|
||||
*/
|
||||
delayedRing(x, y, color, size, life, delay) {
|
||||
const img = this.scene
|
||||
.add.image(x, y, DOT_KEY)
|
||||
.setTint(toColor(color, 0xffffff))
|
||||
.setBlendMode(Phaser.BlendModes.ADD)
|
||||
.setDepth(19)
|
||||
.setScale(6 / DOT_SIZE)
|
||||
.setAlpha(0);
|
||||
this.items.push({ img, t0: 0, life, kind: 'ring', from: 6 / DOT_SIZE, to: size / DOT_SIZE, delay });
|
||||
return img;
|
||||
}
|
||||
|
||||
/**
|
||||
* The player-DOWN cue: the death burst in hot red + a big DOWN hold
|
||||
* (the "the hull broke" beat before the reform clock).
|
||||
*/
|
||||
down(x, y) {
|
||||
this.flash(x, y, 0xffffff, 120, 180);
|
||||
this.flash(x, y, FX_COLORS.down, 200, 520);
|
||||
this.ring(x, y, FX_COLORS.down, 260, 600);
|
||||
this.sparks(x, y, FX_COLORS.down, 24, 300, 800);
|
||||
this.text(x, y - 34, 'DOWN', FX_COLORS.down, 26, 950, 10);
|
||||
}
|
||||
|
||||
/** The player-RESPAWN cue: a clean cyan bloom + the REFORMED pop. */
|
||||
respawn(x, y) {
|
||||
this.flash(x, y, 0xffffff, 90, 140);
|
||||
this.flash(x, y, FX_COLORS.respawn, 160, 420);
|
||||
this.ring(x, y, FX_COLORS.respawn, 220, 500);
|
||||
this.text(x, y - 30, 'REFORMED', FX_COLORS.respawn, 16, 850, 12);
|
||||
}
|
||||
|
||||
/** Step every live item (ms). Dead ones are destroyed. */
|
||||
|
|
@ -103,7 +186,9 @@ export class Fx {
|
|||
for (let i = this.items.length - 1; i >= 0; i--) {
|
||||
const it = this.items[i];
|
||||
it.t0 += deltaMs;
|
||||
const t = Math.min(1, it.t0 / it.life);
|
||||
const eff = it.t0 - (it.delay || 0);
|
||||
if (eff <= 0) continue; // still in its delay — stays hidden
|
||||
const t = Math.min(1, eff / it.life);
|
||||
if (t >= 1) {
|
||||
it.img.destroy();
|
||||
this.items.splice(i, 1);
|
||||
|
|
@ -115,6 +200,9 @@ export class Fx {
|
|||
} else if (it.kind === 'ring') {
|
||||
it.img.setScale(it.from + (it.to - it.from) * t);
|
||||
it.img.setAlpha(0.8 * (1 - t));
|
||||
} else if (it.kind === 'text') {
|
||||
it.img.y -= (it.rise / it.life) * deltaMs;
|
||||
it.img.setAlpha(Math.min(0.95, 1.6 * (1 - t)));
|
||||
} else if (it.kind === 'spark') {
|
||||
it.img.x += it.vx * dt;
|
||||
it.img.y += it.vy * dt;
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
|
|||
import { ActionBar } from '../ui/ActionBar.js';
|
||||
import { CombatBar } from '../ui/CombatBar.js';
|
||||
import { CombatSystem } from '../combat/CombatSystem.js';
|
||||
import { CombatHud } from '../combat/CombatHud.js';
|
||||
import { MineralHud } from '../ui/MineralHud.js';
|
||||
import { CreditsHud } from '../ui/CreditsHud.js';
|
||||
import { GOODS_TEXTURE_KEY } from '../economy/Economy.js';
|
||||
|
|
@ -930,8 +931,40 @@ export class GameScene extends Phaser.Scene {
|
|||
this.combat = new CombatSystem(this, {
|
||||
ship: this.ship,
|
||||
onActiveChange: (active) => this._setCombatBar(active),
|
||||
// The readability layer's bookkeeping (js/combat/CombatHud.js —
|
||||
// the same shared layer the range runs): a kill lands in the
|
||||
// feed, and a hit flashes the bar's gauge that took it.
|
||||
onKill: () => this.combatHud?.kill('HOSTILE DESTROYED'),
|
||||
onPlayerDamage: (n, a) => {
|
||||
if (!this.combatHud || !a) return;
|
||||
if (a.shieldHit > 0) this.combatHud.flash('shield');
|
||||
if (a.hullHit > 0) this.combatHud.flash('hull');
|
||||
},
|
||||
});
|
||||
this.combat.spawnPack(this.ship.x, this.ship.y);
|
||||
// SETTLE WINDOW — the ship's start position is finalized AFTER
|
||||
// create() (a fresh run's home rim comes from the tether barrier;
|
||||
// a load's from the save), so the pack — spawned around the
|
||||
// create-time position — is re-anchored around the ship for the
|
||||
// first few frames (update() → CombatSystem.resyncAroundShip).
|
||||
this._combatSettleFrames = 30;
|
||||
// The READABILITY LAYER (shared with the range): the lock on the
|
||||
// auto-fire target, the threat markers, the off-screen arrows
|
||||
// (the camera follows the ship — hostiles can sit outside it),
|
||||
// the kill feed, and the combat bar's gauge flash on player hits.
|
||||
this.combatHud = new CombatHud(this, {
|
||||
getEnemies: () => (this.combat ? this.combat.enemies : []),
|
||||
getTarget: () => this.combat?.currentTarget() ?? null,
|
||||
getCamera: () => this.cameras?.main ?? null,
|
||||
worldToScreen: (x, y) => {
|
||||
const c = this.cameras?.main;
|
||||
const ox = c?.worldView?.x ?? c?.x ?? 0;
|
||||
const oy = c?.worldView?.y ?? c?.y ?? 0;
|
||||
const z = c?.zoom ?? 1;
|
||||
return { x: (x - ox) * z, y: (y - oy) * z };
|
||||
},
|
||||
gaugeRect: (kind) => this.combatBar?.gaugeRect(kind) ?? null,
|
||||
});
|
||||
if (typeof window !== 'undefined') {
|
||||
// Tuning handle: poke the pack / the armor / the bar from the console.
|
||||
window.__ORBIT_COMBAT__ = {
|
||||
|
|
@ -2185,9 +2218,18 @@ export class GameScene extends Phaser.Scene {
|
|||
// COMBAT (the ?combat demo): the pack's AI + lances, the ship's
|
||||
// auto-fire, the tracers, the rams, the player's armor — frozen by an
|
||||
// ACK comm's pause, like the ship.
|
||||
if (this.combat && !_commsPaused) {
|
||||
this.combat.update(_time, delta);
|
||||
this.combatBar?.setStats(this.combat);
|
||||
if (this.combat) {
|
||||
// The settle window (above): one-shot re-anchor of the pack while
|
||||
// the ship's start position settles; a no-op once anchored.
|
||||
if (this._combatSettleFrames > 0) {
|
||||
this._combatSettleFrames--;
|
||||
if (this.combat.resyncAroundShip()) this._combatSettleFrames = 0;
|
||||
}
|
||||
if (!_commsPaused) {
|
||||
this.combat.update(_time, delta);
|
||||
this.combatBar?.setStats(this.combat);
|
||||
this.combatHud?.update(_time); // the readability layer (lock/threat/feed/flash)
|
||||
}
|
||||
}
|
||||
this.engineFx?.update(_time, delta); // burn fades even through a pause
|
||||
// Session time (saved with the game) — capped so a backgrounded tab
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ export class CombatBar extends Phaser.GameObjects.Container {
|
|||
.setOrigin(0.5, 0.5)
|
||||
.setScrollFactor(0);
|
||||
this.add(labelT);
|
||||
return { fill, frac: 1 };
|
||||
return { fill, frac: 1, track: { x: cx - gaugeW / 2, y: gaugeTop, w: gaugeW, h: gaugeH } };
|
||||
};
|
||||
|
||||
const leftX = x0 + pad + gaugeW / 2;
|
||||
|
|
@ -173,6 +173,16 @@ export class CombatBar extends Phaser.GameObjects.Container {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The gauge's TRACK rect (for the readability layer's hit-flash —
|
||||
* js/combat/CombatHud.js flashes this rect when the player takes that
|
||||
* layer's hit): 'shield' or 'hull'.
|
||||
*/
|
||||
gaugeRect(kind) {
|
||||
const g = kind === 'hull' ? this.gHull : this.gShield;
|
||||
return g && g.track ? g.track : null;
|
||||
}
|
||||
|
||||
/** Tear down (scene destroy) — the children go with the scene anyway. */
|
||||
destroy() {
|
||||
this.onCast = null;
|
||||
|
|
|
|||
Loading…
Reference in New Issue