~bigbes/game-prototype-ftl

ref: a1f0ec5512bf44f4623fd022550b285c1980ce14 game-prototype-ftl/docs/tasks/combat.md -rw-r--r-- 24.8 KiB
a1f0ec55 — Eugene Blikh docs(combat): record review conclusion 30 days ago

#combat

Branch: combat

Fourth and last gameplay milestone of the ftl-shape vertical slice. Adds a single combat encounter against a placeholder enemy ship; power allocation drives the loop (Weapons charge faster with more power, Shields absorb hits and regenerate, Engines provide an evasion %); auto-fire on ready; win/lose detection with R-to-restart.

#Design

Purpose. Make the systems-power data layer pay off. The player and a placeholder enemy ship trade fire under power-driven mechanics; the encounter is short and deterministic-given-RNG, with a clean win/lose state and a one-key restart. Combat is the climactic milestone before polish.

In scope.

  • Enemy ship rendered to the right of the player ship (same room layout, shifted; no systems / no UI of its own).
  • Player ship gets a single weapon ("Laser I", damage 1). Charge rate = WeaponsPower charge-units / sec, threshold WeaponChargeMax = 8. On full charge, fires automatically, deals 1 to enemy, resets.
  • Enemy AI is a fixed timer: fires every EnemyFireInterval = 6 s for 1 damage at the player.
  • Shields: layers = floor(ShieldsPower / 2) (so 0/1=none, 2/3=1 layer, 4=2 layers). Each layer absorbs one hit. Layers regenerate continuously, one per ShieldRegen = 3 s of elapsed time, capped at the current max.
  • Engines: evasion % = EnginesPower * 5. Each incoming hit rolls; on success the shot misses (no shield consumed, no hull damage).
  • Hulls: PlayerHullMax = 20, EnemyHullMax = 15. Damage clamps at 0.
  • Win/lose detection. Centered overlay text "VICTORY (R to restart)" / "DEFEAT (R to restart)". R resets the encounter (combat state, systems, crew).
  • Visual readouts: top-of-screen hull bars (player left, enemy right). A small horizontal weapon-charge bar near the Weapons HUD column. Shield-layer dots above the player Shields room. Evasion % text near the Engines room. A 200 ms red flash on the ship that just took a hull hit.

Out of scope. Per-room targeting; system damage; crew taking damage / fleeing rooms; repair; multiple weapons; multiple enemy types or AI variation; missiles / drones / beams / hacking; FTL drive / map / events; sound / music; Pilot and MedBay gameplay effects (their HUD columns still allocate power but produce no effect — flagged for polish).

Chosen approach.

  • Data — new file combat.go (pure, no Ebitengine):

    • type GameResult int with GameOngoing, GameVictory, GameDefeat.
    • type CombatSide struct { Hull, MaxHull int; ShieldLayers int; ShieldRegenT float64; HitFlashT float64 }.
    • type Combat struct { Player, Enemy CombatSide; PlayerWeaponT float64; EnemyFireT float64; Result GameResult }.
    • func NewCombat() Combat — initial state: player 20/20, enemy 15/15, all timers 0, Result = GameOngoing.
    • func updateCombat(c *Combat, sys []System, dt float64, rng *rand.Rand) — full tick. If Result != GameOngoing, no-op. Otherwise:
      1. Tick HitFlashT down on both sides (clamp ≥ 0).
      2. Compute playerShieldMax(sys); clamp Player.ShieldLayers ≤ max.
      3. Advance shield regen: ShieldRegenT += dt; while ShieldRegenT >= ShieldRegen && ShieldLayers < max: ++ShieldLayers; ShieldRegenT -= ShieldRegen.
      4. Advance player weapon charge by dt * float64(sys[int(RoleWeapons)].PowerLevel). On >= WeaponChargeMax: call resolveIncoming(&c.Enemy, 0, rng), reset PlayerWeaponT = 0.
      5. Advance enemy fire timer by dt. On >= EnemyFireInterval: call resolveIncoming(&c.Player, 5*sys[int(RoleEngines)].PowerLevel, rng), reset EnemyFireT = 0.
      6. Win/lose check: if Enemy.Hull == 0 && Player.Hull > 0: Result = GameVictory; if Player.Hull == 0: Result = GameDefeat.
    • func resolveIncoming(side *CombatSide, evasionPct int, rng *rand.Rand)if rng.Intn(100) < evasionPct: return (dodged). Else: if ShieldLayers > 0: --ShieldLayers; else Hull = max(0, Hull-1); HitFlashT = HitFlashDuration.
    • func playerShieldMax(sys []System) intsys[int(RoleShields)].PowerLevel / 2.
    • Constants: const ( WeaponChargeMax = 8.0; ShieldRegen = 3.0; EnemyFireInterval = 6.0; HitFlashDuration = 0.2; PlayerHullMax = 20; EnemyHullMax = 15 ).
  • Enemy ship layout — modify ship.go:

    • Add func NewEnemyShip() Ship — same 5-room layout shifted by +24 tiles; enemy occupies tiles [27, 38] × [6, 15], pixels [432, 608] × [96, 240].
  • Rendering — modify render.go:

    • Add func drawEnemyShip(screen *ebiten.Image, s Ship, hitFlashT float64) — every room rendered with full roleColor (no power dimming). When hitFlashT > 0: translucent red overlay across all rooms.
    • Modify drawShip to take an extra hitFlashT float64 arg → same red overlay when player takes a hull hit.
    • Add func drawHullBars(screen *ebiten.Image, c Combat) — top-left "PLAYER 20/20" + filled bar; top-right "ENEMY 15/15" mirrored.
    • Add func drawWeaponCharge(screen *ebiten.Image, c Combat) — horizontal fill bar near the Weapons HUD column, fraction = PlayerWeaponT / WeaponChargeMax.
    • Add func drawShieldsIndicator(screen *ebiten.Image, c Combat, sys []System) — N dots above the player Shields room, lit = active layer, dim = max-but-regenerating slot.
    • Add func drawEvasionReadout(screen *ebiten.Image, sys []System) — text "Evade: NN%" above the Engines room.
    • Add func drawResultOverlay(screen *ebiten.Image, result GameResult) — semi-transparent dark fill + centered "VICTORY (R to restart)" / "DEFEAT (R to restart)".
  • Input + integration — modify main.go:

    • Game gains combat Combat, enemy Ship, rng *rand.Rand.
    • main() initialises combat := NewCombat(), enemy := NewEnemyShip(), rng := rand.New(rand.NewSource(time.Now().UnixNano())).
    • Update:
      • When combat.Result == GameOngoing: call updateCombat(&g.combat, g.systems, dt, g.rng) after the existing crew tick.
      • On inpututil.IsKeyJustPressed(ebiten.KeyR) and combat.Result != GameOngoing: full reset — g.combat = NewCombat(); g.systems = NewStartingSystems(); g.crew = NewStartingCrew(); g.selectedCrew = -1.
      • HUD power-allocation clicks remain unchanged during ongoing combat. Gated to no-op when Result != GameOngoing to keep the post-game state stable until restart.
    • Draw: existing drawShip(screen, g.ship, g.systems, g.combat.Player.HitFlashT); new drawEnemyShip(screen, g.enemy, g.combat.Enemy.HitFlashT); new HUD overlays (hull bars, weapon charge, shield dots, evasion %); existing drawHud; drawCrew for each crew; drawResultOverlay last.

Tradeoffs that settled the decisions.

  • Auto-fire vs click-to-fire — with one weapon and no per-room targeting, manual fire adds an interaction step with no decision behind it. Auto-fire keeps the loop legible as resource flow.
  • Dumb-timer enemy vs charging-weapon enemy — symmetric AI doubles the test surface for no gameplay payoff at this scope. A timer is two state vars and one branch.
  • Damage 1, hulls 20/15 — ~15 enemy hits to win, ~20 player hits to lose; at default power split (4 weap / 2 shld / 2 eng) that's a ~30-60 s fight, the right length for "one combat encounter."
  • Shield max from floor(power/2) — mirrors FTL's "2 power per layer", keeps the existing System data unchanged, makes each shield power level visibly different.
  • Evasion as flat % roll — RNG is unavoidable for FTL-style evasion; seeded *rand.Rand makes the tick deterministic in tests.
  • R to restart vs auto-restart — explicit input prevents accidental loops; matches FTL's "main menu after defeat" condensed to one keypress.
  • Pilot + MedBay inert — their FTL effects (pilot evasion bonus, MedBay heal) require crew-positioning gameplay, which is polish-tier. Marking them inert keeps scope honest.

Backwards compat. Greenfield additions; no public API; only behavior change is Game.Update's ordering (combat tick interleaved with crew tick). HUD click handling, crew movement, power allocation semantics unchanged. R is a new key binding — not previously used anywhere in the project.

TDD: yesupdateCombat, resolveIncoming, playerShieldMax, NewCombat, and the restart-reset block are pure functions with meaningful branching (idle vs ongoing, weapon-charge threshold, shield regen tick, hull underflow, evasion roll on/off, win/lose state transitions). Rendering and key input stay manual smoke. Seeded *rand.Rand makes evasion deterministic.

#Invariants

  • IV1 — combat.go and ship.go must not import Ebitengine. Combat logic and ship layouts are pure.
  • IV2 — updateCombat and resolveIncoming are the only mutators of Combat and CombatSide fields. Renderer reads only.
  • IV3 — 0 ≤ Hull ≤ MaxHull for both sides at all times.
  • IV4 — 0 ≤ ShieldLayers ≤ playerShieldMax(sys) after every updateCombat call. Layers clamp down when power is removed.
  • IV5 — Combat.Result only transitions out of GameOngoing once per fight; once GameVictory or GameDefeat, stays until reset by R.
  • IV6 — updateCombat(c, sys, dt, rng) is deterministic for a given input state and *rand.Rand state; the only randomness source is the passed *rand.Rand.
  • IV7 — Single codebase builds unchanged for GOOS=js GOARCH=wasm and native; no //go:build, no platform-suffixed files.
  • IV8 — HUD geometry constants stay in hud.go (carried over from systems-power's IV6).

#Principles

  • PC1 — Fail fast: invalid combat actions (e.g. R while ongoing) are silent no-ops, not partial state changes.
  • PC2 — Tests cover pure logic (combat tick, hit resolution, shield regen, charge, win/lose); rendering and input wiring stay manual.
  • PC3 — Separate data from rendering and input: combat.go doesn't know pixel layout; render.go reads Combat only.
  • PC4 — Game-feel numbers (hull max, charge max, shield regen, evasion factor, enemy fire interval) live as named constants, not magic literals scattered through tick logic.

#Assumptions

  • AS1 — inpututil.IsKeyJustPressed(ebiten.KeyR) works under GOOS=js GOARCH=wasm. Same package as the mouse path that already works in WASM.
  • AS2 — math/rand's *rand.Rand is portable to WASM. Verified by Go's WASM port matrix.
  • AS3 — A single 60 TPS Ebitengine tick rate gives smooth-enough charge / regen / flash animation at the chosen constants without sub-frame stepping.

#Unknowns

  • UK1 — Whether the chosen game-feel numbers (hulls 20/15, weapon 8/charge, shields 3 s regen, enemy 6 s fire, evasion 5%/level) produce a "fair" feeling fight at the baseline 8-reactor allocation. Resolved at verify by playing through several encounters with different power setups.
  • UK2 — Whether the visible feedback for shield hits (layer count change) and evasion (no other signal beyond the enemy fire timer resetting) is legible at 640×360. Resolved at verify by browser smoke; if too subtle, add a text "MISS" / "BLOCK" toast.

#Plan

Approach: TDD-first on the pure pieces (combat tick, then enemy ship layout), then one integration phase that wires rendering, input, and the restart key. Greenfield additions; the only signature change is drawShip (one call site).

#PH1 — Combat data + tick (TDD)

  • 1.1 combat_test.go (create) — tests first, must fail before 1.2.
    • TestNewCombat_initialStatePlayer.Hull=20, MaxHull=20, ShieldLayers=0, Enemy.Hull=15, MaxHull=15, all timers 0, Result==GameOngoing. Covers IV3.
    • TestPlayerShieldMax_table — shields power 0..4 → 0,0,1,1,2.
    • TestResolveIncoming_shieldsAbsorbShieldLayers>0--ShieldLayers, hull unchanged, HitFlashT==0.
    • TestResolveIncoming_hullDamageShieldLayers==0Hull--, HitFlashT==HitFlashDuration.
    • TestResolveIncoming_hullClampsAtZeroHull==0 → stays 0 (IV3).
    • TestResolveIncoming_evasionMiss — seeded rng rolling under threshold → no change to side state.
    • TestResolveIncoming_evasionHit — seeded rng rolling over threshold → damage applied.
    • TestUpdateCombat_postResultNoOpResult=GameVictory → state frozen across a tick (IV5).
    • TestUpdateCombat_hitFlashTicksDown — both sides' HitFlashT decremented and clamped ≥0.
    • TestUpdateCombat_shieldRegen — power=4, layers=0, dt > ShieldRegen → layers increments toward playerShieldMax.
    • TestUpdateCombat_shieldsClampOnPowerDrop — layers=2, shields power dropped to 0 → next tick clamps to 0 (IV4).
    • TestUpdateCombat_weaponChargeFires — weapons=1, PlayerWeaponT just below WeaponChargeMax → next tick fires, Enemy.Hull--, PlayerWeaponT=0.
    • TestUpdateCombat_weaponZeroPowerNoCharge — weapons=0 → PlayerWeaponT unchanged.
    • TestUpdateCombat_enemyFiresOnTimerEnemyFireT crosses EnemyFireInterval, engines=0, no shields → Player.Hull--, EnemyFireT=0.
    • TestUpdateCombat_victoryTransition — enemy hull=1, weapon ready → Result==GameVictory after fire.
    • TestUpdateCombat_defeatTransition — player hull=1, enemy fires, no shields, no evasion → Result==GameDefeat.
    • TestUpdateCombat_determinism — same seed + same input sequence → identical state (IV6, AS2).
  • 1.2 combat.go (create)
    • package main; imports math/rand only. No Ebitengine. Respects: IV1.
    • const ( WeaponChargeMax = 8.0; ShieldRegen = 3.0; EnemyFireInterval = 6.0; HitFlashDuration = 0.2; PlayerHullMax = 20; EnemyHullMax = 15 ). Respects: PC4.
    • type GameResult int with GameOngoing, GameVictory, GameDefeat.
    • type CombatSide struct { Hull, MaxHull int; ShieldLayers int; ShieldRegenT float64; HitFlashT float64 }.
    • type Combat struct { Player, Enemy CombatSide; PlayerWeaponT float64; EnemyFireT float64; Result GameResult }.
    • func NewCombat() Combat — returns initial state per design step list.
    • func playerShieldMax(sys []System) intsys[int(RoleShields)].PowerLevel / 2. Pure query (GPC3).
    • func resolveIncoming(side *CombatSide, evasionPct int, rng *rand.Rand) — design step list. Sole mutator pair on CombatSide. Respects: IV2.
    • func updateCombat(c *Combat, sys []System, dt float64, rng *rand.Rand) — design step list 1–6. Sole mutator on Combat. Respects: IV2, IV3, IV4, IV5, IV6.
  • Commit: combat: Combat data + updateCombat with tests

#PH2 — Enemy ship layout (TDD)

  • 2.1 ship_test.go (create) — tests first, must fail before 2.2.
    • TestNewEnemyShip_roomCount — 5 rooms.
    • TestNewEnemyShip_shifted — for each room, enemy.Rooms[i].GridX == player.Rooms[i].GridX + 24; GridY/GridW/GridH/Role/Name unchanged.
  • 2.2 ship.go (modify, append)
    • func NewEnemyShip() Ship — same 5 rooms as NewPlayerShip, every GridX shifted by +24. Pure data; no Ebitengine. Respects: IV1.
  • Commit: combat: NewEnemyShip layout

#PH3 — Render & input integration (manual smoke)

  • 3.1 render.go (modify)
    • drawShip(screen *ebiten.Image, s Ship, sys []System, hitFlashT float64) — signature change: extra hitFlashT. After existing room loop, when hitFlashT > 0, overlay translucent red across all rooms (alpha scaled by hitFlashT / HitFlashDuration). Respects: PC3.
    • drawEnemyShip(screen *ebiten.Image, s Ship, hitFlashT float64) — every room rendered with full roleColor (no power dimming, no label-only treatment beyond border + name). Same red overlay rule on hitFlashT > 0.
    • drawHullBars(screen *ebiten.Image, c Combat) — top-left "PLAYER %d/%d" + filled bar; top-right "ENEMY %d/%d" mirrored.
    • drawWeaponCharge(screen *ebiten.Image, c Combat) — horizontal fill bar above the Weapons HUD column at (HudX + int(RoleWeapons)*HudColW, HudY-…). Reads HudX, HudColW, HudY from hud.go. Respects: IV8.
    • drawShieldsIndicator(screen *ebiten.Image, c Combat, sys []System)playerShieldMax(sys) dots above the player Shields room (room read from layout, not hard-coded — call sites use s.Rooms indexing by role). Lit dot for active layer; dim dot for max-but-regenerating slot.
    • drawEvasionReadout(screen *ebiten.Image, sys []System)"Evade: NN%" text above the Engines room; value sys[int(RoleEngines)].PowerLevel * 5.
    • drawResultOverlay(screen *ebiten.Image, result GameResult) — semi-transparent dark fill + centered "VICTORY (R to restart)" / "DEFEAT (R to restart)". No-op when result == GameOngoing.
  • 3.2 main.go (modify)
    • Game gains combat Combat, enemy Ship, rng *rand.Rand. Init in main() via NewCombat(), NewEnemyShip(), rand.New(rand.NewSource(time.Now().UnixNano())).
    • Update:
      • After existing crew tick, when g.combat.Result == GameOngoing: updateCombat(&g.combat, g.systems, dt, g.rng).
      • Existing left-click HUD gate: keep addPower call. Add early-return guard at the start of HUD branch — if g.combat.Result != GameOngoing, no-op. Same for right-click HUD gate. Respects: PC1.
      • On inpututil.IsKeyJustPressed(ebiten.KeyR) and g.combat.Result != GameOngoing: g.combat = NewCombat(); g.systems = NewStartingSystems(); g.crew = NewStartingCrew(); g.selectedCrew = -1. Respects: AS1.
    • Draw: replace drawShip(screen, g.ship, g.systems) with drawShip(screen, g.ship, g.systems, g.combat.Player.HitFlashT); insert drawEnemyShip(screen, g.enemy, g.combat.Enemy.HitFlashT); after existing drawHud add drawHullBars, drawWeaponCharge, drawShieldsIndicator, drawEvasionReadout; finally drawResultOverlay(screen, g.combat.Result) last so it sits on top.
  • Commit: combat: render, input, restart

#Test strategy

Per Design TDD: yes. PH1 + PH2 tests written first and must fail on the pre-implementation tree. PH3 has no automated tests — rendering + key input need real display. Manual smoke via make play-web:

  • Default 8/0/0/0/0 split → weapons charge ramps, fires every ~8 s, no shield protection, no evasion, fight ends in defeat eventually.
  • 4 weapons / 2 shields / 2 engines → shields show 1 dot, evasion text reads "Evade: 10%", weapon-charge bar at ~½ rate; ~30–60 s win.
  • Drop shields power mid-fight → dots clamp down on next tick (IV4 visible).
  • Take a hull hit → 200 ms red flash on the hit ship.
  • Reach Player.Hull == 0 → DEFEAT overlay; HUD clicks become no-ops; press R → encounter resets, ship + crew + systems all back to start.
  • Reach Enemy.Hull == 0 → VICTORY overlay; same restart flow.
  • Mid-fight HUD clicks still re-route power.

#Order & dependencies

  • PH1 and PH2 are independent — different files, no shared symbols. Either order or parallel.
  • PH3 depends on both: consumes Combat, NewCombat, updateCombat, playerShieldMax, HitFlashDuration, WeaponChargeMax, GameResult, NewEnemyShip.

#Risks / rollback

  • RK1 — UK1 game-feel: numbers tuned during PH3 verify; constants are const in one file (PC4), so retuning is one diff.
  • RK2 — UK2 legibility of shield/evasion feedback: covered at verify; "MISS"/"BLOCK" toast is the documented fallback.
  • RK3 — drawShip signature change ripples to one call site in main.go:Draw. Mitigation: PH3 commits both edits together; native + WASM build run before commit.
  • Rollback: each phase is its own commit. Reverting PH3 leaves PH1+PH2 as dead but harmless code.

#Interfaces

  • IF1 — func NewCombat() Combat — defined in combat.go, consumed by main.go init + R-restart.
  • IF2 — func updateCombat(c *Combat, sys []System, dt float64, rng *rand.Rand) — defined in combat.go, consumed by main.go:Update.
  • IF3 — func playerShieldMax(sys []System) int — defined in combat.go, consumed by render.go:drawShieldsIndicator.
  • IF4 — type Combat, type CombatSide, type GameResult (and constants GameOngoing/Victory/Defeat, HitFlashDuration, WeaponChargeMax) — defined in combat.go, consumed by render.go (read-only) and main.go.
  • IF5 — func NewEnemyShip() Ship — defined in ship.go, consumed by main.go init.
  • IF6 — func drawShip(screen, Ship, []System, float64) — signature change in render.go, consumed by main.go:Draw (one call site).

#Interface graph

  • PH1 -> IF1, IF2, IF3, IF4 @ combat.go, combat_test.go
  • PH2 -> IF5 @ ship.go, ship_test.go
  • PH3 IF1, IF2, IF3, IF4, IF5 -> IF6 @ render.go, main.go

#Verify

Result: passed in-session; unit tests + browser smoke deferred to user (see Notes).

Invariants / assumptions:

  • CK1 (IV1) — grep 'hajimehoshi/ebiten' combat.go ship.go combat_test.go ship_test.go → empty.
  • CK2 (IV2) — grep -E '\.(Hull|MaxHull|ShieldLayers|...)\s*[+\-*/]?=' outside combat.go → empty (the only hit is a == comparison in main.go:42, a read).
  • CK3 (IV5) — Result = writes only in combat.go:123, 125; main.go reset path is whole-struct replacement via NewCombat(), gated on Result != GameOngoing.
  • CK4 (IV7) — GOOS=js GOARCH=wasm go build . → succeeds.
  • CK5 (IV8) — HudY/HudX/HudColW/HudColCount defined only in hud.go:8-11; all other refs are reads.
  • CK6 (AS2) — math/rand ships in WASM build (covered by CK4).

Interfaces:

  • CK7 (IF1) — NewCombat() callers: main.go:48, 128 (init + R-restart).
  • CK8 (IF2) — updateCombat(&c, sys, dt, rng) called from main.go:43; signature matches.
  • CK9 (IF3) — playerShieldMax(sys) called from render.go:160 (drawShieldsIndicator) + combat.go:94 (internal).
  • CK10 (IF4) — GameOngoing/Victory/Defeat, HitFlashDuration, WeaponChargeMax referenced read-only across render.go and main.go.
  • CK11 (IF5) — NewEnemyShip() called from main.go:129.
  • CK12 (IF6) — drawShip(screen, ship, sys, hitFlashT) 4-arg signature: 1 call site at main.go:105, matches definition render.go:54.

Build: go build . && GOOS=js GOARCH=wasm go build . && go vet ./... → all clean.

Notes:

  • Unit tests (PH1's 17 combat_test.go tests + PH2's 2 ship_test.go tests + 32 prior tests) cannot execute in this Claude Code session: Ebitengine v2.9.9 internal/ui package's init() calls currentMouseLocation() which dereferences nil when no Window Server is attached (/Users/blikh/go/pkg/mod/github.com/hajimehoshi/ebiten/v2@v2.9.9/internal/ui/ui_darwin.go:286). Compile + go vet ./... are clean. User: run go test ./... in a regular terminal to confirm green.
  • End-to-end browser smoke (auto-fire, shield regen, evasion, hit-flash, R-restart, mid-fight power re-routing) requires make play-web and a real browser session — also deferred to the user.

#Conclusion

Outcome: combat encounter implemented and integrated; reviewer cleared the diff with no Critical or Important findings (HEAD d77efb6).

Invariants:

  • IV1 — combat.go and ship.go import only math/rand; no Ebitengine references.
  • IV2 — only updateCombat and resolveIncoming mutate Combat / CombatSide fields; main.go reset uses whole-struct replacement via NewCombat().
  • IV3 — resolveIncoming guards Hull > 0 before decrement; hull cannot underflow.
  • IV4 — shield layers clamped before the regen loop; power-drop test confirms layers fall in one tick.
  • IV5 — Result != GameOngoing early-return freezes state; tie-tick resolves to GameDefeat per if/else if ordering.
  • IV6 — updateCombat takes *rand.Rand as the only randomness source; TestUpdateCombat_determinism covers it.
  • IV7 — GOOS=js GOARCH=wasm go build . clean.
  • IV8 — HudY/HudX/HudColW/HudColCount defined only in hud.go; render and main read them.

#Assumptions check

  • AS1 — held (deferred). inpututil.IsKeyJustPressed(ebiten.KeyR) is the same package as the mouse path that already works in WASM; user-side browser smoke confirms.
  • AS2 — held. math/rand builds and vets clean for GOOS=js GOARCH=wasm.
  • AS3 — held (deferred). 60 TPS smoothness is judged at user-side browser smoke; constants are tunable in combat.go.

#Unknowns outcome

  • UK1 — still-open. Game-feel of hulls 20/15, weapon 8/charge, shields 3 s regen, enemy 6 s fire, evasion 5%/level needs the user-side browser playthrough at multiple power splits. All tunables are const in combat.go (PC4) — retuning is one diff.
  • UK2 — still-open. Legibility of shield-layer dots and evasion readout at 640×360 needs the user-side browser smoke. Fallback: text "MISS"/"BLOCK" toast.

#Deviations from plan

  • PH3 — drawShieldsIndicator and drawEvasionReadout each gained an s Ship parameter (plan signatures took only c Combat, sys []System and sys []System). Why: the first implementation called NewPlayerShip() per frame inside the renderer to locate the Shields/Engines room, allocating a 5-room slice each draw and hiding the coupling between layout and renderer. Fix threads g.ship through (commit 9eb78ec). Aligns with GPC1 and the plan bullet's intent ("call sites use s.Rooms indexing by role").

Verified by:

  • go test ./... deferred — Ebitengine GLFW init() panics on currentMouseLocation() in this session (no Window Server). User: run in a regular terminal to confirm 51/51 (32 prior + 17 combat + 2 ship).
  • Browser smoke deferred — make play-web and play through several encounters at different power splits to settle UK1 + UK2 and confirm AS1 + AS3.