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