@@ 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)
}
@@ 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