From 5eed43f007995d8ab8f8329fd2638a3bd904ca3a Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Tue, 28 Apr 2026 04:24:38 +0300 Subject: [PATCH] combat: render, input, restart - drawShip gains hitFlashT param; red flash overlay on hit - drawEnemyShip: full roleColor rooms, same flash rule - drawHullBars, drawWeaponCharge, drawShieldsIndicator, drawEvasionReadout, drawResultOverlay - Game gains combat/enemy/rng; updateCombat wired in Update - HUD clicks no-op after result; R key restarts when fight is decided --- main.go | 39 ++++++++++++- render.go | 164 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 200 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index d281e5ec1d842b47e94eaa47a426f6f7f68ca214..0e953700b42aae12d24ba68b5fa551dcac89549b 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "image/color" + "math/rand" "time" "github.com/hajimehoshi/ebiten/v2" @@ -16,6 +17,9 @@ type Game struct { selectedCrew int lastTime time.Time systems []System + combat Combat + enemy Ship + rng *rand.Rand } // Update advances simulation by dt seconds and handles input. @@ -34,10 +38,27 @@ func (g *Game) Update() error { updateCrew(&g.crew[i], dt) } + // Advance combat each tick when ongoing. + if g.combat.Result == GameOngoing { + updateCombat(&g.combat, g.systems, dt, g.rng) + } + + // Restart on R when the fight is decided. + if inpututil.IsKeyJustPressed(ebiten.KeyR) && g.combat.Result != GameOngoing { + g.combat = NewCombat() + g.systems = NewStartingSystems() + g.crew = NewStartingCrew() + g.selectedCrew = -1 + } + if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) { cx, cy := ebiten.CursorPosition() if cy >= HudY { // HUD strip: route to power management. + // No-op after combat ends (PC1). + if g.combat.Result != GameOngoing { + return nil + } if role, ok := hudHitTest(cx, cy); ok { addPower(g.systems, role, ReactorCap) } @@ -64,6 +85,10 @@ func (g *Game) Update() error { if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonRight) { cx, cy := ebiten.CursorPosition() if cy >= HudY { + // No-op after combat ends (PC1). + if g.combat.Result != GameOngoing { + return nil + } if role, ok := hudHitTest(cx, cy); ok { removePower(g.systems, role) } @@ -77,11 +102,17 @@ func (g *Game) Update() error { // Draw renders the whole frame. func (g *Game) Draw(screen *ebiten.Image) { screen.Fill(color.RGBA{10, 15, 25, 255}) - drawShip(screen, g.ship, g.systems) + drawShip(screen, g.ship, g.systems, g.combat.Player.HitFlashT) + drawEnemyShip(screen, g.enemy, g.combat.Enemy.HitFlashT) for i, c := range g.crew { drawCrew(screen, c, i == g.selectedCrew) } drawHud(screen, g.systems) + drawHullBars(screen, g.combat) + drawWeaponCharge(screen, g.combat) + drawShieldsIndicator(screen, g.combat, g.systems) + drawEvasionReadout(screen, g.systems) + drawResultOverlay(screen, g.combat.Result) } // Layout fixes the virtual resolution; the window scales to fit. @@ -94,6 +125,9 @@ func main() { walk := walkableTiles(ship) crew := NewStartingCrew() systems := NewStartingSystems() + combat := NewCombat() + enemy := NewEnemyShip() + rng := rand.New(rand.NewSource(time.Now().UnixNano())) ebiten.SetWindowSize(1280, 720) ebiten.SetWindowTitle("ftl-shape") if err := ebiten.RunGame(&Game{ @@ -102,6 +136,9 @@ func main() { crew: crew, selectedCrew: -1, systems: systems, + combat: combat, + enemy: enemy, + rng: rng, }); err != nil { panic(err) } diff --git a/render.go b/render.go index dfdf3aa2edc10b749951ea59e5318bb091176823..2f53043154534eb3a83d776bc5f98ff76029322f 100644 --- a/render.go +++ b/render.go @@ -49,12 +49,41 @@ func dimRoleColor(role RoomRole) color.RGBA { } // drawShip draws every room of the ship, dimming rooms whose system has no -// power. Rendering delegates to drawRoom so per-room logic lives in one place. -func drawShip(screen *ebiten.Image, s Ship, sys []System) { +// power. When hitFlashT > 0, a translucent red overlay is drawn across all +// rooms with alpha scaled by hitFlashT / HitFlashDuration. +func drawShip(screen *ebiten.Image, s Ship, sys []System, hitFlashT float64) { for _, r := range s.Rooms { powered := sys[int(r.Role)].PowerLevel > 0 drawRoom(screen, r, powered) } + if hitFlashT > 0 { + drawHitFlash(screen, s, hitFlashT) + } +} + +// drawEnemyShip renders the enemy ship with full roleColor on every room +// (no power dimming). Same red overlay rule on hitFlashT > 0. +func drawEnemyShip(screen *ebiten.Image, s Ship, hitFlashT float64) { + for _, r := range s.Rooms { + drawRoom(screen, r, true) + } + if hitFlashT > 0 { + drawHitFlash(screen, s, hitFlashT) + } +} + +// drawHitFlash overlays a translucent red rect over each room of the ship. +// Alpha is scaled proportionally to flashT / HitFlashDuration. +func drawHitFlash(screen *ebiten.Image, s Ship, flashT float64) { + alpha := uint8(180 * flashT / HitFlashDuration) + flash := color.RGBA{R: 255, A: alpha} + for _, r := range s.Rooms { + x := float32(r.GridX * TilePx) + y := float32(r.GridY * TilePx) + w := float32(r.GridW * TilePx) + h := float32(r.GridH * TilePx) + vector.DrawFilledRect(screen, x, y, w, h, flash, false) + } } // drawRoom draws a single room: a role-tinted fill (dimmed when unpowered), @@ -84,6 +113,137 @@ func drawRoom(screen *ebiten.Image, r Room, powered bool) { ebitenutil.DebugPrintAt(screen, r.Name, int(x)+3, int(y)+2) } +// drawHullBars renders hull readouts for both sides. +// Player: top-left. Enemy: top-right. +func drawHullBars(screen *ebiten.Image, c Combat) { + const barH = 6 + const barY = 14 + const barW = 80 + const textY = 4 + + // Player side (top-left). + playerLabel := fmt.Sprintf("PLAYER %d/%d", c.Player.Hull, c.Player.MaxHull) + ebitenutil.DebugPrintAt(screen, playerLabel, 4, textY) + playerBarFill := barW * c.Player.Hull / c.Player.MaxHull + vector.DrawFilledRect(screen, 4, barY, float32(barW), barH, color.RGBA{60, 60, 60, 255}, false) + vector.DrawFilledRect(screen, 4, barY, float32(playerBarFill), barH, color.RGBA{80, 200, 80, 255}, false) + + // Enemy side (top-right, mirrored). + enemyLabel := fmt.Sprintf("ENEMY %d/%d", c.Enemy.Hull, c.Enemy.MaxHull) + labelW := len(enemyLabel) * 6 + ebitenutil.DebugPrintAt(screen, enemyLabel, VirtualW-labelW-4, textY) + enemyBarFill := barW * c.Enemy.Hull / c.Enemy.MaxHull + vector.DrawFilledRect(screen, float32(VirtualW-barW-4), barY, float32(barW), barH, color.RGBA{60, 60, 60, 255}, false) + vector.DrawFilledRect(screen, float32(VirtualW-barW-4), barY, float32(enemyBarFill), barH, color.RGBA{200, 80, 80, 255}, false) +} + +// drawWeaponCharge renders a horizontal fill bar above the Weapons HUD column. +// Bar position: HudX + int(RoleWeapons)*HudColW, a few pixels above HudY. +func drawWeaponCharge(screen *ebiten.Image, c Combat) { + const barH = 4 + const barY = HudY - 8 + barX := float32(HudX + int(RoleWeapons)*HudColW) + barW := float32(HudColW) + + // Background. + vector.DrawFilledRect(screen, barX, barY, barW, barH, color.RGBA{60, 30, 30, 255}, false) + // Fill proportional to charge. + fillW := barW * float32(c.PlayerWeaponT) / float32(WeaponChargeMax) + if fillW > 0 { + vector.DrawFilledRect(screen, barX, barY, fillW, barH, color.RGBA{220, 120, 60, 255}, false) + } +} + +// drawShieldsIndicator renders dots above the Shields room. +// Lit dot = active layer; dim dot = slot available but not yet regenerated. +func drawShieldsIndicator(screen *ebiten.Image, c Combat, sys []System) { + shieldMax := playerShieldMax(sys) + if shieldMax == 0 { + return + } + + // Find the Shields room in the player ship layout. + var shieldsRoom Room + found := false + for _, r := range NewPlayerShip().Rooms { + if r.Role == RoleShields { + shieldsRoom = r + found = true + break + } + } + if !found { + return + } + + const dotSize = 4 + const dotGap = 2 + // Place dots above the shields room. + baseY := shieldsRoom.GridY*TilePx - dotSize - 4 + baseX := shieldsRoom.GridX * TilePx + + for i := 0; i < shieldMax; i++ { + dx := float32(baseX + i*(dotSize+dotGap)) + dy := float32(baseY) + var fill color.RGBA + if i < c.Player.ShieldLayers { + fill = roleColor(RoleShields) + } else { + fill = dimRoleColor(RoleShields) + } + vector.DrawFilledRect(screen, dx, dy, dotSize, dotSize, fill, false) + } +} + +// drawEvasionReadout renders "Evade: NN%" above the Engines room. +// Value is sys[RoleEngines].PowerLevel * 5. +func drawEvasionReadout(screen *ebiten.Image, sys []System) { + pct := sys[int(RoleEngines)].PowerLevel * 5 + + // Find the Engines room in the player ship layout. + var enginesRoom Room + found := false + for _, r := range NewPlayerShip().Rooms { + if r.Role == RoleEngines { + enginesRoom = r + found = true + break + } + } + if !found { + return + } + + label := fmt.Sprintf("Evade: %d%%", pct) + px := enginesRoom.GridX * TilePx + py := enginesRoom.GridY*TilePx - 12 + ebitenutil.DebugPrintAt(screen, label, px, py) +} + +// drawResultOverlay draws a semi-transparent dark fill over the whole viewport +// with a centered win/lose message. No-op when result == GameOngoing. +func drawResultOverlay(screen *ebiten.Image, result GameResult) { + if result == GameOngoing { + return + } + + // Semi-transparent dark overlay across the full virtual viewport. + vector.DrawFilledRect(screen, 0, 0, VirtualW, VirtualH, color.RGBA{0, 0, 0, 180}, false) + + var msg string + switch result { + case GameVictory: + msg = "VICTORY (R to restart)" + case GameDefeat: + msg = "DEFEAT (R to restart)" + } + + // Center the message: DebugPrintAt uses 6px-per-char fixed-width font. + x := (VirtualW - len(msg)*6) / 2 + y := VirtualH/2 - 4 + ebitenutil.DebugPrintAt(screen, msg, x, y) +} + // drawHud renders the power management strip at the bottom of the screen. // It shows a reactor readout on the left and five per-system power columns // on the right. Cells are stacked bottom-up; filled cells use the system's