feat: display turn countdown timer in combat HUD

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 00:53:17 +09:00
parent a1e9e0ef68
commit 7556073cb5
3 changed files with 96 additions and 32 deletions

View File

@@ -2,6 +2,7 @@ package game
import (
"sync"
"time"
"github.com/tolelom/catacombs/dungeon"
"github.com/tolelom/catacombs/entity"
@@ -44,6 +45,7 @@ type GameState struct {
Victory bool
ShopItems []entity.Item
CombatLog []string // recent combat messages
TurnDeadline time.Time
}
func (s *GameSession) addLog(msg string) {

View File

@@ -28,6 +28,9 @@ func (s *GameSession) RunTurn() {
// Collect actions with timeout
timer := time.NewTimer(TurnTimeout)
s.mu.Lock()
s.state.TurnDeadline = time.Now().Add(TurnTimeout)
s.mu.Unlock()
collected := 0
for collected < aliveCount {
select {
@@ -45,6 +48,7 @@ func (s *GameSession) RunTurn() {
resolve:
s.mu.Lock()
defer s.mu.Unlock()
s.state.TurnDeadline = time.Time{}
// Default action for players who didn't submit: Wait
for _, p := range s.state.Players {
@@ -71,28 +75,6 @@ func (s *GameSession) resolvePlayerActions() {
}
}
// Check if ALL alive players chose flee
fleeCount := 0
aliveCount := 0
for _, p := range s.state.Players {
if p.IsDead() {
continue
}
aliveCount++
if action, ok := s.actions[p.Name]; ok && action.Type == ActionFlee {
fleeCount++
}
}
if fleeCount == aliveCount && aliveCount > 0 {
if combat.AttemptFlee() {
s.addLog("Fled from battle!")
s.state.Phase = PhaseExploring
return
}
s.addLog("Failed to flee!")
return
}
for _, p := range s.state.Players {
if p.IsDead() {
continue
@@ -161,7 +143,15 @@ func (s *GameSession) resolvePlayerActions() {
s.addLog(fmt.Sprintf("%s has no items to use!", p.Name))
}
case ActionFlee:
s.addLog(fmt.Sprintf("%s tried to flee but party didn't agree", p.Name))
if combat.AttemptFlee() {
s.addLog(fmt.Sprintf("%s fled from battle!", p.Name))
if s.state.SoloMode {
s.state.Phase = PhaseExploring
return
}
} else {
s.addLog(fmt.Sprintf("%s failed to flee!", p.Name))
}
case ActionWait:
s.addLog(fmt.Sprintf("%s is defending", p.Name))
}

View File

@@ -3,19 +3,23 @@ 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) string {
func renderGame(state game.GameState, width, height int, targetCursor int) string {
mapView := renderMap(state.Floor)
hudView := renderHUD(state)
hudView := renderHUD(state, targetCursor)
logView := renderCombatLog(state.CombatLog)
return lipgloss.JoinVertical(lipgloss.Left,
mapView,
hudView,
logView,
)
}
@@ -51,10 +55,9 @@ func renderMap(floor *dungeon.Floor) string {
sb.WriteString(hiddenStyle.Render("[?] ???"))
}
// Show connections
for _, n := range room.Neighbors {
if n > i {
sb.WriteString(" ─── ")
sb.WriteString(" --- ")
}
}
sb.WriteString("\n")
@@ -63,31 +66,85 @@ func renderMap(floor *dungeon.Floor) string {
return sb.String()
}
func renderHUD(state game.GameState) string {
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\n",
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)
sb.WriteString(fmt.Sprintf(" [%d] %s %s %d/%d\n", i, m.Name, mhpBar, m.HP, m.MaxHP))
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")
}
}
sb.WriteString("\n[1]Attack [2]Skill [3]Item [4]Flee [5]Wait")
} else if state.Phase == game.PhaseExploring {
sb.WriteString("\nChoose a room to enter (number) or [Q] quit")
}
@@ -95,6 +152,21 @@ func renderHUD(state game.GameState) string {
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 ""