feat: display turn countdown timer in combat HUD
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package game
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tolelom/catacombs/dungeon"
|
||||
"github.com/tolelom/catacombs/entity"
|
||||
@@ -43,7 +44,8 @@ type GameState struct {
|
||||
GameOver bool
|
||||
Victory bool
|
||||
ShopItems []entity.Item
|
||||
CombatLog []string // recent combat messages
|
||||
CombatLog []string // recent combat messages
|
||||
TurnDeadline time.Time
|
||||
}
|
||||
|
||||
func (s *GameSession) addLog(msg string) {
|
||||
|
||||
36
game/turn.go
36
game/turn.go
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user