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 (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/tolelom/catacombs/dungeon"
|
"github.com/tolelom/catacombs/dungeon"
|
||||||
"github.com/tolelom/catacombs/entity"
|
"github.com/tolelom/catacombs/entity"
|
||||||
@@ -43,7 +44,8 @@ type GameState struct {
|
|||||||
GameOver bool
|
GameOver bool
|
||||||
Victory bool
|
Victory bool
|
||||||
ShopItems []entity.Item
|
ShopItems []entity.Item
|
||||||
CombatLog []string // recent combat messages
|
CombatLog []string // recent combat messages
|
||||||
|
TurnDeadline time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameSession) addLog(msg string) {
|
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
|
// Collect actions with timeout
|
||||||
timer := time.NewTimer(TurnTimeout)
|
timer := time.NewTimer(TurnTimeout)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.state.TurnDeadline = time.Now().Add(TurnTimeout)
|
||||||
|
s.mu.Unlock()
|
||||||
collected := 0
|
collected := 0
|
||||||
for collected < aliveCount {
|
for collected < aliveCount {
|
||||||
select {
|
select {
|
||||||
@@ -45,6 +48,7 @@ func (s *GameSession) RunTurn() {
|
|||||||
resolve:
|
resolve:
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
s.state.TurnDeadline = time.Time{}
|
||||||
|
|
||||||
// Default action for players who didn't submit: Wait
|
// Default action for players who didn't submit: Wait
|
||||||
for _, p := range s.state.Players {
|
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 {
|
for _, p := range s.state.Players {
|
||||||
if p.IsDead() {
|
if p.IsDead() {
|
||||||
continue
|
continue
|
||||||
@@ -161,7 +143,15 @@ func (s *GameSession) resolvePlayerActions() {
|
|||||||
s.addLog(fmt.Sprintf("%s has no items to use!", p.Name))
|
s.addLog(fmt.Sprintf("%s has no items to use!", p.Name))
|
||||||
}
|
}
|
||||||
case ActionFlee:
|
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:
|
case ActionWait:
|
||||||
s.addLog(fmt.Sprintf("%s is defending", p.Name))
|
s.addLog(fmt.Sprintf("%s is defending", p.Name))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,23 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/tolelom/catacombs/dungeon"
|
"github.com/tolelom/catacombs/dungeon"
|
||||||
|
"github.com/tolelom/catacombs/entity"
|
||||||
"github.com/tolelom/catacombs/game"
|
"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)
|
mapView := renderMap(state.Floor)
|
||||||
hudView := renderHUD(state)
|
hudView := renderHUD(state, targetCursor)
|
||||||
|
logView := renderCombatLog(state.CombatLog)
|
||||||
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left,
|
return lipgloss.JoinVertical(lipgloss.Left,
|
||||||
mapView,
|
mapView,
|
||||||
hudView,
|
hudView,
|
||||||
|
logView,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,10 +55,9 @@ func renderMap(floor *dungeon.Floor) string {
|
|||||||
sb.WriteString(hiddenStyle.Render("[?] ???"))
|
sb.WriteString(hiddenStyle.Render("[?] ???"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show connections
|
|
||||||
for _, n := range room.Neighbors {
|
for _, n := range room.Neighbors {
|
||||||
if n > i {
|
if n > i {
|
||||||
sb.WriteString(" ─── ")
|
sb.WriteString(" --- ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
@@ -63,31 +66,85 @@ func renderMap(floor *dungeon.Floor) string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderHUD(state game.GameState) string {
|
func renderHUD(state game.GameState, targetCursor int) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
border := lipgloss.NewStyle().
|
border := lipgloss.NewStyle().
|
||||||
Border(lipgloss.NormalBorder()).
|
Border(lipgloss.NormalBorder()).
|
||||||
Padding(0, 1)
|
Padding(0, 1)
|
||||||
|
|
||||||
|
// Player info
|
||||||
for _, p := range state.Players {
|
for _, p := range state.Players {
|
||||||
hpBar := renderHPBar(p.HP, p.MaxHP, 20)
|
hpBar := renderHPBar(p.HP, p.MaxHP, 20)
|
||||||
status := ""
|
status := ""
|
||||||
if p.IsDead() {
|
if p.IsDead() {
|
||||||
status = " [DEAD]"
|
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))
|
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 {
|
if state.Phase == game.PhaseCombat {
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
// Enemies
|
||||||
|
enemyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
|
||||||
for i, m := range state.Monsters {
|
for i, m := range state.Monsters {
|
||||||
if !m.IsDead() {
|
if !m.IsDead() {
|
||||||
mhpBar := renderHPBar(m.HP, m.MaxHP, 15)
|
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 {
|
} else if state.Phase == game.PhaseExploring {
|
||||||
sb.WriteString("\nChoose a room to enter (number) or [Q] quit")
|
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())
|
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 {
|
func renderHPBar(current, max, width int) string {
|
||||||
if max == 0 {
|
if max == 0 {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
Reference in New Issue
Block a user