Files
Catacombs/ui/game_view.go
tolelom f2ac4dbded feat: arrow-key room navigation, neighbor visibility, map UX improvements
- Exploration uses Up/Down + Enter instead of number keys
- Adjacent rooms shown with cursor selection in HUD
- Neighboring rooms visible on fog of war map
- Room numbers displayed on tile map with type-colored markers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 01:04:08 +09:00

197 lines
5.3 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, moveCursor int) string {
mapView := renderMap(state.Floor)
hudView := renderHUD(state, targetCursor, moveCursor)
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, moveCursor 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 {
if state.Floor != nil && state.Floor.CurrentRoom >= 0 && state.Floor.CurrentRoom < len(state.Floor.Rooms) {
current := state.Floor.Rooms[state.Floor.CurrentRoom]
if len(current.Neighbors) > 0 {
sb.WriteString("\n")
selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Bold(true)
normalStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
for i, n := range current.Neighbors {
if n >= 0 && n < len(state.Floor.Rooms) {
r := state.Floor.Rooms[n]
status := r.Type.String()
if r.Cleared {
status = "Cleared"
}
marker := " "
style := normalStyle
if i == moveCursor {
marker = "> "
style = selectedStyle
}
sb.WriteString(style.Render(fmt.Sprintf("%sRoom %d: %s", marker, n, status)))
sb.WriteString("\n")
}
}
}
}
sb.WriteString("[Up/Down] Select [Enter] Move [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 " "
}
}