package main
import (
"fmt"
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/vector"
)
// Rendering constants. All coordinate math goes through TilePx;
// no code should reason about window pixels directly.
const (
TilePx = 16
VirtualW = 640
VirtualH = 360
)
// roleColor maps a RoomRole to its tint. The mapping lives here so
// that ship data stays free of any rendering concerns.
func roleColor(role RoomRole) color.RGBA {
switch role {
case RolePilot:
return color.RGBA{90, 130, 180, 255} // steel blue
case RoleWeapons:
return color.RGBA{170, 80, 70, 255} // dusty red
case RoleShields:
return color.RGBA{80, 140, 120, 255} // teal green
case RoleMedBay:
return color.RGBA{190, 160, 90, 255} // warm ochre
case RoleEngines:
return color.RGBA{140, 95, 160, 255} // muted violet
default:
return color.RGBA{120, 120, 120, 255}
}
}
// dimRoleColor returns ~60% brightness of the powered tint for a role.
// Used by drawRoom when the system is unpowered.
func dimRoleColor(role RoomRole) color.RGBA {
c := roleColor(role)
return color.RGBA{
R: uint8(uint16(c.R) * 6 / 10),
G: uint8(uint16(c.G) * 6 / 10),
B: uint8(uint16(c.B) * 6 / 10),
A: 255,
}
}
// drawShip draws every room of the ship, dimming rooms whose system has no
// 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),
// a 1-px border, and the role name printed inside.
func drawRoom(screen *ebiten.Image, r Room, powered bool) {
x := float32(r.GridX * TilePx)
y := float32(r.GridY * TilePx)
w := float32(r.GridW * TilePx)
h := float32(r.GridH * TilePx)
var fill color.RGBA
if powered {
fill = roleColor(r.Role)
} else {
fill = dimRoleColor(r.Role)
}
vector.DrawFilledRect(screen, x, y, w, h, fill, false)
border := color.RGBA{230, 230, 230, 255}
// Four 1-px edges.
vector.DrawFilledRect(screen, x, y, w, 1, border, false)
vector.DrawFilledRect(screen, x, y+h-1, w, 1, border, false)
vector.DrawFilledRect(screen, x, y, 1, h, border, false)
vector.DrawFilledRect(screen, x+w-1, y, 1, h, border, false)
// Label: a few pixels inside the top-left corner.
ebitenutil.DebugPrintAt(screen, r.Name, int(x)+3, int(y)+2)
// Cell markers: small dim squares at cell positions.
for _, c := range r.Cells {
cx := float32(c[0]*TilePx + TilePx/2 - 2)
cy := float32(c[1]*TilePx + TilePx/2 - 2)
vector.DrawFilledRect(screen, cx, cy, 4, 4, color.RGBA{255, 255, 255, 60}, false)
}
}
// 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, s Ship, c Combat, sys []System) {
shieldMax := playerShieldMax(sys)
if shieldMax == 0 {
return
}
var shieldsRoom Room
found := false
for _, r := range s.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, s Ship, sys []System) {
pct := sys[int(RoleEngines)].PowerLevel * 5
var enginesRoom Room
found := false
for _, r := range s.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
// full roleColor, empty cells use dimRoleColor, each with a 1-px white outline.
func drawHud(screen *ebiten.Image, sys []System) {
// Reactor readout.
ebitenutil.DebugPrintAt(screen,
fmt.Sprintf("Reactor: %d/%d", reactorUsed(sys), ReactorCap),
8, HudY+8)
const cellW = 12
const cellH = 10
const cellGap = 2
// cellBottom is the y of the bottom-most cell's top edge; loop-invariant.
const cellBottom = VirtualH - cellGap
outline := color.RGBA{255, 255, 255, 255}
// Role labels in display order (index == int(RoomRole)).
roleLabels := [HudColCount]string{"Pilot", "Weap", "Shld", "Med", "Eng"}
for i := 0; i < HudColCount; i++ {
role := RoomRole(i)
s := sys[i]
colLeft := HudX + i*HudColW
// Role label at the top of the column.
ebitenutil.DebugPrintAt(screen, roleLabels[i], colLeft+4, HudY+4)
// Cells stacked bottom-up. Row 0 is at the bottom of the HUD strip.
for j := 0; j < s.MaxLevel; j++ {
cy := cellBottom - (j+1)*(cellH+cellGap)
cx := colLeft + 4
var fill color.RGBA
if j < s.PowerLevel {
fill = roleColor(role)
} else {
fill = dimRoleColor(role)
}
vector.DrawFilledRect(screen, float32(cx), float32(cy), float32(cellW), float32(cellH), fill, false)
// 1-px white outline on every cell.
vector.DrawFilledRect(screen, float32(cx), float32(cy), float32(cellW), 1, outline, false)
vector.DrawFilledRect(screen, float32(cx), float32(cy+cellH-1), float32(cellW), 1, outline, false)
vector.DrawFilledRect(screen, float32(cx), float32(cy), 1, float32(cellH), outline, false)
vector.DrawFilledRect(screen, float32(cx+cellW-1), float32(cy), 1, float32(cellH), outline, false)
}
}
}
const CrewRadius = 6
var white = color.RGBA{255, 255, 255, 255}
func drawCrew(screen *ebiten.Image, c Crew, selected bool) {
var fx, fy float64
if len(c.Path) > 0 {
fx = float64(c.TileX) + (float64(c.Path[0][0])-float64(c.TileX))*c.MoveT
fy = float64(c.TileY) + (float64(c.Path[0][1])-float64(c.TileY))*c.MoveT
} else {
fx = float64(c.TileX)
fy = float64(c.TileY)
}
px := float32(fx*TilePx + TilePx/2)
py := float32(fy*TilePx + TilePx/2)
vector.DrawFilledCircle(screen, px, py, CrewRadius, c.Color, true)
if selected {
vector.StrokeCircle(screen, px, py, CrewRadius+1, 1, white, true)
}
ebitenutil.DebugPrintAt(screen, string(c.Initial), int(px)-2, int(py)-5)
}