~bigbes/game-prototype-ftl

5eed43f007995d8ab8f8329fd2638a3bd904ca3a — Eugene Blikh 30 days ago 7b6452c
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
2 files changed, 200 insertions(+), 3 deletions(-)

M main.go
M render.go
M main.go => main.go +38 -1
@@ 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)
	}

M render.go => render.go +162 -2
@@ 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