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.
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.
WeaponsPower charge-units / sec, threshold WeaponChargeMax = 8. On full charge, fires automatically, deals 1 to enemy, resets.EnemyFireInterval = 6 s for 1 damage at the player.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.EnginesPower * 5. Each incoming hit rolls; on success the shot misses (no shield consumed, no hull damage).PlayerHullMax = 20, EnemyHullMax = 15. Damage clamps at 0.R resets the encounter (combat state, systems, crew).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:
HitFlashT down on both sides (clamp ≥ 0).playerShieldMax(sys); clamp Player.ShieldLayers ≤ max.ShieldRegenT += dt; while ShieldRegenT >= ShieldRegen && ShieldLayers < max: ++ShieldLayers; ShieldRegenT -= ShieldRegen.dt * float64(sys[int(RoleWeapons)].PowerLevel). On >= WeaponChargeMax: call resolveIncoming(&c.Enemy, 0, rng), reset PlayerWeaponT = 0.dt. On >= EnemyFireInterval: call resolveIncoming(&c.Player, 5*sys[int(RoleEngines)].PowerLevel, rng), reset EnemyFireT = 0.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) int — sys[int(RoleShields)].PowerLevel / 2.const ( WeaponChargeMax = 8.0; ShieldRegen = 3.0; EnemyFireInterval = 6.0; HitFlashDuration = 0.2; PlayerHullMax = 20; EnemyHullMax = 15 ).Enemy ship layout — modify ship.go:
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:
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.drawShip to take an extra hitFlashT float64 arg → same red overlay when player takes a hull hit.func drawHullBars(screen *ebiten.Image, c Combat) — top-left "PLAYER 20/20" + filled bar; top-right "ENEMY 15/15" mirrored.func drawWeaponCharge(screen *ebiten.Image, c Combat) — horizontal fill bar near the Weapons HUD column, fraction = PlayerWeaponT / WeaponChargeMax.func drawShieldsIndicator(screen *ebiten.Image, c Combat, sys []System) — N dots above the player Shields room, lit = active layer, dim = max-but-regenerating slot.func drawEvasionReadout(screen *ebiten.Image, sys []System) — text "Evade: NN%" above the Engines room.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:
combat.Result == GameOngoing: call updateCombat(&g.combat, g.systems, dt, g.rng) after the existing crew tick.inpututil.IsKeyJustPressed(ebiten.KeyR) and combat.Result != GameOngoing: full reset — g.combat = NewCombat(); g.systems = NewStartingSystems(); g.crew = NewStartingCrew(); g.selectedCrew = -1.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.
floor(power/2) — mirrors FTL's "2 power per layer", keeps the existing System data unchanged, makes each shield power level visibly different.*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.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: yes — updateCombat, 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.
combat.go and ship.go must not import Ebitengine. Combat logic and ship layouts are pure.updateCombat and resolveIncoming are the only mutators of Combat and CombatSide fields. Renderer reads only.0 ≤ Hull ≤ MaxHull for both sides at all times.0 ≤ ShieldLayers ≤ playerShieldMax(sys) after every updateCombat call. Layers clamp down when power is removed.Combat.Result only transitions out of GameOngoing once per fight; once GameVictory or GameDefeat, stays until reset by R.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.GOOS=js GOARCH=wasm and native; no //go:build, no platform-suffixed files.hud.go (carried over from systems-power's IV6).combat.go doesn't know pixel layout; render.go reads Combat only.inpututil.IsKeyJustPressed(ebiten.KeyR) works under GOOS=js GOARCH=wasm. Same package as the mouse path that already works in WASM.math/rand's *rand.Rand is portable to WASM. Verified by Go's WASM port matrix.