Replace list-based room display with proper 2D tile map using Binary Space Partitioning. Rooms are carved into a 60x20 grid, connected by L-shaped corridors, and rendered with ANSI-colored ASCII art including fog of war visibility. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
172 lines
4.4 KiB
Go
172 lines
4.4 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/tolelom/catacombs/dungeon"
|
|
"github.com/tolelom/catacombs/entity"
|
|
"github.com/tolelom/catacombs/game"
|
|
)
|
|
|
|
func renderGame(state game.GameState, width, height int, targetCursor int) string {
|
|
mapView := renderMap(state.Floor)
|
|
hudView := renderHUD(state, targetCursor)
|
|
logView := renderCombatLog(state.CombatLog)
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Left,
|
|
mapView,
|
|
hudView,
|
|
logView,
|
|
)
|
|
}
|
|
|
|
func renderMap(floor *dungeon.Floor) string {
|
|
if floor == nil {
|
|
return ""
|
|
}
|
|
headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
|
|
header := headerStyle.Render(fmt.Sprintf("── Catacombs B%d ──", floor.Number))
|
|
return header + "\n" + dungeon.RenderFloor(floor, floor.CurrentRoom, true)
|
|
}
|
|
|
|
func renderHUD(state game.GameState, targetCursor int) string {
|
|
var sb strings.Builder
|
|
border := lipgloss.NewStyle().
|
|
Border(lipgloss.NormalBorder()).
|
|
Padding(0, 1)
|
|
|
|
// Player info
|
|
for _, p := range state.Players {
|
|
hpBar := renderHPBar(p.HP, p.MaxHP, 20)
|
|
status := ""
|
|
if p.IsDead() {
|
|
status = " [DEAD]"
|
|
}
|
|
sb.WriteString(fmt.Sprintf("%s (%s) %s %d/%d%s Gold: %d",
|
|
p.Name, p.Class, hpBar, p.HP, p.MaxHP, status, p.Gold))
|
|
|
|
// Show inventory count
|
|
itemCount := len(p.Inventory)
|
|
relicCount := len(p.Relics)
|
|
if itemCount > 0 || relicCount > 0 {
|
|
sb.WriteString(fmt.Sprintf(" Items:%d Relics:%d", itemCount, relicCount))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
if state.Phase == game.PhaseCombat {
|
|
sb.WriteString("\n")
|
|
// Enemies
|
|
enemyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
|
|
for i, m := range state.Monsters {
|
|
if !m.IsDead() {
|
|
mhpBar := renderHPBar(m.HP, m.MaxHP, 15)
|
|
taunt := ""
|
|
if m.TauntTarget {
|
|
taunt = " [TAUNTED]"
|
|
}
|
|
marker := " "
|
|
if i == targetCursor {
|
|
marker = "> "
|
|
}
|
|
sb.WriteString(enemyStyle.Render(fmt.Sprintf("%s[%d] %s %s %d/%d%s", marker, i, m.Name, mhpBar, m.HP, m.MaxHP, taunt)))
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
sb.WriteString("\n")
|
|
// Actions with skill description
|
|
actionStyle := lipgloss.NewStyle().Bold(true)
|
|
sb.WriteString(actionStyle.Render("[1]Attack [2]Skill [3]Item [4]Flee [5]Wait [Tab]Target"))
|
|
sb.WriteString("\n")
|
|
if !state.TurnDeadline.IsZero() {
|
|
remaining := time.Until(state.TurnDeadline)
|
|
if remaining < 0 {
|
|
remaining = 0
|
|
}
|
|
timerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)
|
|
sb.WriteString(timerStyle.Render(fmt.Sprintf(" Timer: %.1fs", remaining.Seconds())))
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// Skill description per class
|
|
skillStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Italic(true)
|
|
for _, p := range state.Players {
|
|
if !p.IsDead() {
|
|
var skillDesc string
|
|
switch p.Class {
|
|
case entity.ClassWarrior:
|
|
skillDesc = "Skill: Taunt - enemies attack you for 2 turns"
|
|
case entity.ClassMage:
|
|
skillDesc = "Skill: Fireball - AoE 0.8x dmg to all enemies"
|
|
case entity.ClassHealer:
|
|
skillDesc = "Skill: Heal - restore 30 HP to an ally"
|
|
case entity.ClassRogue:
|
|
skillDesc = "Skill: Scout - reveal neighboring rooms"
|
|
}
|
|
sb.WriteString(skillStyle.Render(skillDesc))
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
} else if state.Phase == game.PhaseExploring {
|
|
sb.WriteString("\nChoose a room to enter (number) or [Q] quit")
|
|
}
|
|
|
|
return border.Render(sb.String())
|
|
}
|
|
|
|
func renderCombatLog(log []string) string {
|
|
if len(log) == 0 {
|
|
return ""
|
|
}
|
|
logStyle := lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("228")).
|
|
PaddingLeft(1)
|
|
|
|
var sb strings.Builder
|
|
for _, msg := range log {
|
|
sb.WriteString(" > " + msg + "\n")
|
|
}
|
|
return logStyle.Render(sb.String())
|
|
}
|
|
|
|
func renderHPBar(current, max, width int) string {
|
|
if max == 0 {
|
|
return ""
|
|
}
|
|
filled := current * width / max
|
|
if filled < 0 {
|
|
filled = 0
|
|
}
|
|
empty := width - filled
|
|
|
|
greenStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("46"))
|
|
redStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
|
|
|
|
bar := greenStyle.Render(strings.Repeat("█", filled)) +
|
|
redStyle.Render(strings.Repeat("░", empty))
|
|
return bar
|
|
}
|
|
|
|
func roomTypeSymbol(rt dungeon.RoomType) string {
|
|
switch rt {
|
|
case dungeon.RoomCombat:
|
|
return "D"
|
|
case dungeon.RoomTreasure:
|
|
return "$"
|
|
case dungeon.RoomShop:
|
|
return "S"
|
|
case dungeon.RoomEvent:
|
|
return "?"
|
|
case dungeon.RoomEmpty:
|
|
return "."
|
|
case dungeon.RoomBoss:
|
|
return "B"
|
|
default:
|
|
return " "
|
|
}
|
|
}
|