~bigbes/game-prototype-ftl

ref: cbe6e62362e646c22aa14ba9e17901858dc88813 game-prototype-ftl/docs/tasks/combat.md -rw-r--r-- 10.7 KiB
cbe6e623 — Eugene Blikh combat: Combat data + updateCombat with tests 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.