feat: two-panel combat layout with colored log and 3-color HP bars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 12:38:02 +09:00
parent 7fc13a6a32
commit a951f94f3e

View File

@@ -64,57 +64,59 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int) string {
} }
if state.Phase == game.PhaseCombat { if state.Phase == game.PhaseCombat {
sb.WriteString("\n") // Two-panel layout: PARTY | ENEMIES
// Enemies partyContent := renderPartyPanel(state.Players)
enemyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")) enemyContent := renderEnemyPanel(state.Monsters, targetCursor)
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")
}
}
partyPanel := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorGray).
Width(35).
Padding(0, 1).
Render(partyContent)
enemyPanel := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorGray).
Width(38).
Padding(0, 1).
Render(enemyContent)
panels := lipgloss.JoinHorizontal(lipgloss.Top, partyPanel, enemyPanel)
sb.WriteString(panels)
sb.WriteString("\n") sb.WriteString("\n")
// Actions with skill description
actionStyle := lipgloss.NewStyle().Bold(true) // Action bar
sb.WriteString(actionStyle.Render("[1]Attack [2]Skill [3]Item [4]Flee [5]Wait [Tab]Target")) sb.WriteString(styleAction.Render("[1]Attack [2]Skill [3]Item [4]Flee [5]Wait [Tab]Target [/]Chat"))
sb.WriteString("\n") sb.WriteString("\n")
// Timer
if !state.TurnDeadline.IsZero() { if !state.TurnDeadline.IsZero() {
remaining := time.Until(state.TurnDeadline) remaining := time.Until(state.TurnDeadline)
if remaining < 0 { if remaining < 0 {
remaining = 0 remaining = 0
} }
timerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true) sb.WriteString(styleTimer.Render(fmt.Sprintf(" Timer: %.1fs", remaining.Seconds())))
sb.WriteString(timerStyle.Render(fmt.Sprintf(" Timer: %.1fs", remaining.Seconds())))
sb.WriteString("\n") sb.WriteString("\n")
} }
// Skill description per class // Skill description for first alive player only
skillStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Italic(true)
for _, p := range state.Players { for _, p := range state.Players {
if !p.IsDead() { if !p.IsDead() {
var skillDesc string var skillDesc string
switch p.Class { switch p.Class {
case entity.ClassWarrior: case entity.ClassWarrior:
skillDesc = "Skill: Taunt - enemies attack you for 2 turns" skillDesc = "Skill: Taunt enemies attack you for 2 turns"
case entity.ClassMage: case entity.ClassMage:
skillDesc = "Skill: Fireball - AoE 0.8x dmg to all enemies" skillDesc = "Skill: Fireball AoE 0.8x dmg to all enemies"
case entity.ClassHealer: case entity.ClassHealer:
skillDesc = "Skill: Heal - restore 30 HP to an ally" skillDesc = "Skill: Heal restore 30 HP to an ally"
case entity.ClassRogue: case entity.ClassRogue:
skillDesc = "Skill: Scout - reveal neighboring rooms" skillDesc = "Skill: Scout reveal neighboring rooms"
} }
sb.WriteString(skillStyle.Render(skillDesc)) sb.WriteString(styleSystem.Render(skillDesc))
sb.WriteString("\n") sb.WriteString("\n")
break
} }
} }
} else if state.Phase == game.PhaseExploring { } else if state.Phase == game.PhaseExploring {
@@ -146,6 +148,9 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int) string {
sb.WriteString("[Up/Down] Select [Enter] Move [Q] Quit") sb.WriteString("[Up/Down] Select [Enter] Move [Q] Quit")
} }
if state.Phase == game.PhaseCombat {
return sb.String()
}
return border.Render(sb.String()) return border.Render(sb.String())
} }
@@ -153,15 +158,38 @@ func renderCombatLog(log []string) string {
if len(log) == 0 { if len(log) == 0 {
return "" return ""
} }
logStyle := lipgloss.NewStyle(). border := lipgloss.NewStyle().
Foreground(lipgloss.Color("228")). Border(lipgloss.RoundedBorder()).
PaddingLeft(1) BorderForeground(colorGray).
Padding(0, 1)
var sb strings.Builder var sb strings.Builder
for _, msg := range log { for _, msg := range log {
sb.WriteString(" > " + msg + "\n") colored := colorizeLog(msg)
sb.WriteString(" > " + colored + "\n")
}
return border.Render(sb.String())
}
func colorizeLog(msg string) string {
switch {
case strings.Contains(msg, "fled"):
return styleFlee.Render(msg)
case strings.Contains(msg, "co-op"):
return styleCoop.Render(msg)
case strings.Contains(msg, "healed") || strings.Contains(msg, "Heal") || strings.Contains(msg, "Blessing"):
return styleHeal.Render(msg)
case strings.Contains(msg, "dmg") || strings.Contains(msg, "hit") || strings.Contains(msg, "attacks") || strings.Contains(msg, "Trap"):
return styleDamage.Render(msg)
case strings.Contains(msg, "Taunt") || strings.Contains(msg, "scouted"):
return styleStatus.Render(msg)
case strings.Contains(msg, "gold") || strings.Contains(msg, "Gold") || strings.Contains(msg, "found"):
return styleGold.Render(msg)
case strings.Contains(msg, "defeated") || strings.Contains(msg, "cleared") || strings.Contains(msg, "Descending"):
return styleSystem.Render(msg)
default:
return msg
} }
return logStyle.Render(sb.String())
} }
func renderHPBar(current, max, width int) string { func renderHPBar(current, max, width int) string {
@@ -172,31 +200,76 @@ func renderHPBar(current, max, width int) string {
if filled < 0 { if filled < 0 {
filled = 0 filled = 0
} }
if filled > width {
filled = width
}
empty := width - filled empty := width - filled
greenStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("46")) pct := float64(current) / float64(max)
redStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")) var barStyle lipgloss.Style
switch {
bar := greenStyle.Render(strings.Repeat("█", filled)) + case pct > 0.5:
redStyle.Render(strings.Repeat("░", empty)) barStyle = lipgloss.NewStyle().Foreground(colorGreen)
return bar case pct > 0.25:
} barStyle = lipgloss.NewStyle().Foreground(colorYellow)
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: default:
return " " barStyle = lipgloss.NewStyle().Foreground(colorRed)
} }
emptyStyle := lipgloss.NewStyle().Foreground(colorGray)
return barStyle.Render(strings.Repeat("█", filled)) +
emptyStyle.Render(strings.Repeat("░", empty))
}
func renderPartyPanel(players []*entity.Player) string {
var sb strings.Builder
sb.WriteString(styleHeader.Render(" PARTY") + "\n\n")
for _, p := range players {
nameStr := stylePlayer.Render(fmt.Sprintf(" ♦ %s", p.Name))
classStr := styleSystem.Render(fmt.Sprintf(" (%s)", p.Class))
status := ""
if p.IsDead() {
status = styleDamage.Render(" [DEAD]")
}
sb.WriteString(nameStr + classStr + status + "\n")
hpBar := renderHPBar(p.HP, p.MaxHP, 16)
sb.WriteString(fmt.Sprintf(" %s %d/%d\n", hpBar, p.HP, p.MaxHP))
sb.WriteString(fmt.Sprintf(" ATK:%-3d DEF:%-3d ", p.EffectiveATK(), p.EffectiveDEF()))
sb.WriteString(styleGold.Render(fmt.Sprintf("G:%d", p.Gold)))
sb.WriteString("\n\n")
}
return sb.String()
}
func renderEnemyPanel(monsters []*entity.Monster, targetCursor int) string {
var sb strings.Builder
sb.WriteString(styleHeader.Render(" ENEMIES") + "\n\n")
for i, m := range monsters {
if m.IsDead() {
continue
}
// ASCII art
art := MonsterArt(m.Type)
for _, line := range art {
sb.WriteString(styleEnemy.Render(" "+line) + "\n")
}
// Name + HP
marker := " "
if i == targetCursor {
marker = "> "
}
hpBar := renderHPBar(m.HP, m.MaxHP, 12)
taunt := ""
if m.TauntTarget {
taunt = styleStatus.Render(" [TAUNTED]")
}
sb.WriteString(fmt.Sprintf(" %s[%d] %s %s %d/%d%s\n\n",
marker, i, styleEnemy.Render(m.Name), hpBar, m.HP, m.MaxHP, taunt))
}
return sb.String()
} }