5 tasks: styles, ASCII art, colored log, combat layout, title screen. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
559 lines
14 KiB
Markdown
559 lines
14 KiB
Markdown
# Terminal Visuals Enhancement — Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Enhance Catacombs terminal visuals — monster ASCII art, combat layout with box-drawing panels, semantic color effects, and improved title screen.
|
|
|
|
**Architecture:** New files for styles and ASCII art data. Rewrite of `game_view.go` render functions. All changes are UI-only — no game logic modifications.
|
|
|
|
**Tech Stack:** Go, charmbracelet/lipgloss (box-drawing, colors, layout)
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-03-24-terminal-visuals-design.md`
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
```
|
|
ui/styles.go — NEW: shared lipgloss style constants
|
|
ui/ascii_art.go — NEW: monster ASCII art data + Art() function
|
|
ui/game_view.go — REWRITE: combat layout, colored log, HP bar 3-color
|
|
ui/title.go — MODIFY: gradient logo, centered layout
|
|
```
|
|
|
|
---
|
|
|
|
### Task 1: Shared Styles (ui/styles.go)
|
|
|
|
**Files:**
|
|
- Create: `ui/styles.go`
|
|
|
|
- [ ] **Step 1: Create styles.go with color constants and reusable styles**
|
|
|
|
```go
|
|
package ui
|
|
|
|
import "github.com/charmbracelet/lipgloss"
|
|
|
|
// Colors
|
|
var (
|
|
colorRed = lipgloss.Color("196")
|
|
colorGreen = lipgloss.Color("46")
|
|
colorYellow = lipgloss.Color("226")
|
|
colorCyan = lipgloss.Color("51")
|
|
colorMagenta = lipgloss.Color("201")
|
|
colorWhite = lipgloss.Color("255")
|
|
colorGray = lipgloss.Color("240")
|
|
colorOrange = lipgloss.Color("208")
|
|
colorPink = lipgloss.Color("205")
|
|
)
|
|
|
|
// Text styles
|
|
var (
|
|
styleDamage = lipgloss.NewStyle().Foreground(colorRed).Bold(true)
|
|
styleHeal = lipgloss.NewStyle().Foreground(colorGreen).Bold(true)
|
|
styleCoop = lipgloss.NewStyle().Foreground(colorYellow).Bold(true)
|
|
styleFlee = lipgloss.NewStyle().Foreground(colorCyan)
|
|
styleStatus = lipgloss.NewStyle().Foreground(colorMagenta)
|
|
styleGold = lipgloss.NewStyle().Foreground(colorYellow)
|
|
styleSystem = lipgloss.NewStyle().Foreground(colorGray).Italic(true)
|
|
styleEnemy = lipgloss.NewStyle().Foreground(colorRed)
|
|
stylePlayer = lipgloss.NewStyle().Foreground(colorWhite).Bold(true)
|
|
styleHeader = lipgloss.NewStyle().Foreground(colorPink).Bold(true)
|
|
styleAction = lipgloss.NewStyle().Bold(true)
|
|
styleTimer = lipgloss.NewStyle().Foreground(colorYellow).Bold(true)
|
|
)
|
|
|
|
// Panel styles
|
|
var (
|
|
panelBorder = lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(colorGray).
|
|
Padding(0, 1)
|
|
|
|
panelHeader = lipgloss.NewStyle().
|
|
Foreground(colorPink).
|
|
Bold(true).
|
|
Align(lipgloss.Center)
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 2: Build**
|
|
|
|
```bash
|
|
go build ./...
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add ui/styles.go
|
|
git commit -m "feat: add shared lipgloss styles for terminal visuals"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Monster ASCII Art (ui/ascii_art.go)
|
|
|
|
**Files:**
|
|
- Create: `ui/ascii_art.go`
|
|
|
|
- [ ] **Step 1: Create ascii_art.go with art data**
|
|
|
|
```go
|
|
package ui
|
|
|
|
import "github.com/tolelom/catacombs/entity"
|
|
|
|
// MonsterArt returns ASCII art lines for a monster type.
|
|
func MonsterArt(mt entity.MonsterType) []string {
|
|
switch mt {
|
|
case entity.MonsterSlime:
|
|
return []string{
|
|
` /\OO/\ `,
|
|
` \ / `,
|
|
` |__| `,
|
|
}
|
|
case entity.MonsterSkeleton:
|
|
return []string{
|
|
` ,--. `,
|
|
` |oo| `,
|
|
` /||\ `,
|
|
}
|
|
case entity.MonsterOrc:
|
|
return []string{
|
|
` .---. `,
|
|
`/o o\`,
|
|
`| --- |`,
|
|
}
|
|
case entity.MonsterDarkKnight:
|
|
return []string{
|
|
` /|||\ `,
|
|
` |===| `,
|
|
` | | `,
|
|
}
|
|
case entity.MonsterBoss5:
|
|
return []string{
|
|
` /\ /\ `,
|
|
`| @ @ |`,
|
|
`| || |`,
|
|
`| \__/ |`,
|
|
` \ / `,
|
|
}
|
|
case entity.MonsterBoss10:
|
|
return []string{
|
|
` __|__ `,
|
|
` /|o o|\ `,
|
|
` | === | `,
|
|
` |\___/| `,
|
|
` |___| `,
|
|
}
|
|
case entity.MonsterBoss15:
|
|
return []string{
|
|
` ,=====. `,
|
|
`/ \ / \`,
|
|
`| (O) |`,
|
|
` \ |=| / `,
|
|
` '===' `,
|
|
}
|
|
case entity.MonsterBoss20:
|
|
return []string{
|
|
` ___/\___ `,
|
|
`| x x |`,
|
|
`| === |`,
|
|
`|\_____/|`,
|
|
`|_| |_|`,
|
|
}
|
|
default:
|
|
return []string{` ??? `}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Build**
|
|
|
|
```bash
|
|
go build ./...
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add ui/ascii_art.go
|
|
git commit -m "feat: add monster ASCII art data"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: HP Bar 3-Color + Colored Combat Log (ui/game_view.go)
|
|
|
|
**Files:**
|
|
- Modify: `ui/game_view.go`
|
|
|
|
- [ ] **Step 1: Rewrite renderHPBar with 3-color thresholds**
|
|
|
|
Replace the `renderHPBar` function:
|
|
```go
|
|
func renderHPBar(current, max, width int) string {
|
|
if max == 0 {
|
|
return ""
|
|
}
|
|
filled := current * width / max
|
|
if filled < 0 {
|
|
filled = 0
|
|
}
|
|
if filled > width {
|
|
filled = width
|
|
}
|
|
empty := width - filled
|
|
|
|
pct := float64(current) / float64(max)
|
|
var barStyle lipgloss.Style
|
|
switch {
|
|
case pct > 0.5:
|
|
barStyle = lipgloss.NewStyle().Foreground(colorGreen)
|
|
case pct > 0.25:
|
|
barStyle = lipgloss.NewStyle().Foreground(colorYellow)
|
|
default:
|
|
barStyle = lipgloss.NewStyle().Foreground(colorRed)
|
|
}
|
|
emptyStyle := lipgloss.NewStyle().Foreground(colorGray)
|
|
|
|
return barStyle.Render(strings.Repeat("█", filled)) +
|
|
emptyStyle.Render(strings.Repeat("░", empty))
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Rewrite renderCombatLog with pattern-based coloring**
|
|
|
|
Replace `renderCombatLog`:
|
|
```go
|
|
func renderCombatLog(log []string) string {
|
|
if len(log) == 0 {
|
|
return ""
|
|
}
|
|
border := lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(colorGray).
|
|
Padding(0, 1)
|
|
|
|
var sb strings.Builder
|
|
for _, msg := range log {
|
|
colored := colorizeLog(msg)
|
|
sb.WriteString(" > " + colored + "\n")
|
|
}
|
|
return border.Render(sb.String())
|
|
}
|
|
|
|
func colorizeLog(msg string) string {
|
|
// Apply semantic colors based on keywords
|
|
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
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Build and test**
|
|
|
|
```bash
|
|
go build ./...
|
|
go test ./ui/ -timeout 15s
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add ui/game_view.go
|
|
git commit -m "feat: 3-color HP bar and semantic combat log coloring"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Combat Layout Redesign (ui/game_view.go)
|
|
|
|
**Files:**
|
|
- Modify: `ui/game_view.go`
|
|
|
|
This is the largest task. Rewrite `renderHUD` to use a 2-panel layout in combat mode.
|
|
|
|
- [ ] **Step 1: Add renderPartyPanel helper**
|
|
|
|
Add to `ui/game_view.go`:
|
|
```go
|
|
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()
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add renderEnemyPanel helper**
|
|
|
|
```go
|
|
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()
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Rewrite renderHUD for combat mode**
|
|
|
|
Replace the combat section of `renderHUD` (the `if state.Phase == game.PhaseCombat` block):
|
|
```go
|
|
if state.Phase == game.PhaseCombat {
|
|
// Two-panel layout: PARTY | ENEMIES
|
|
partyContent := renderPartyPanel(state.Players)
|
|
enemyContent := renderEnemyPanel(state.Monsters, targetCursor)
|
|
|
|
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")
|
|
|
|
// Action bar
|
|
sb.WriteString(styleAction.Render("[1]Attack [2]Skill [3]Item [4]Flee [5]Wait [Tab]Target [/]Chat"))
|
|
sb.WriteString("\n")
|
|
|
|
// Timer
|
|
if !state.TurnDeadline.IsZero() {
|
|
remaining := time.Until(state.TurnDeadline)
|
|
if remaining < 0 {
|
|
remaining = 0
|
|
}
|
|
sb.WriteString(styleTimer.Render(fmt.Sprintf(" Timer: %.1fs", remaining.Seconds())))
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// Skill description for current player (first alive)
|
|
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(styleSystem.Render(skillDesc))
|
|
sb.WriteString("\n")
|
|
break // show only current player's skill
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Update renderHUD to not wrap combat in border (panels have their own)**
|
|
|
|
The exploration mode still uses the old `border` style. Change the function to only apply `border.Render()` for exploration, not combat:
|
|
|
|
Restructure `renderHUD` so that:
|
|
- Combat: return `sb.String()` directly (panels already have borders)
|
|
- Exploring: wrap in `border.Render(sb.String())`
|
|
|
|
Replace the return at the end of `renderHUD`:
|
|
```go
|
|
if state.Phase == game.PhaseCombat {
|
|
return sb.String()
|
|
}
|
|
return border.Render(sb.String())
|
|
```
|
|
|
|
- [ ] **Step 5: Remove the unused roomTypeSymbol function**
|
|
|
|
Delete `roomTypeSymbol` (lines ~185-202) — it's dead code.
|
|
|
|
- [ ] **Step 6: Build and run all tests**
|
|
|
|
```bash
|
|
go build ./...
|
|
go test ./... -timeout 30s
|
|
```
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add ui/game_view.go
|
|
git commit -m "feat: two-panel combat layout with monster ASCII art"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Title Screen Enhancement (ui/title.go)
|
|
|
|
**Files:**
|
|
- Modify: `ui/title.go`
|
|
|
|
- [ ] **Step 1: Rewrite renderTitle with gradient colors and centered layout**
|
|
|
|
Replace `ui/title.go` entirely:
|
|
```go
|
|
package ui
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
var titleLines = []string{
|
|
` ██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ███╗ ███╗██████╗ ███████╗`,
|
|
`██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██╔════╝██╔═══██╗████╗ ████║██╔══██╗██╔════╝`,
|
|
`██║ ███████║ ██║ ███████║██║ ██║ ██║██╔████╔██║██████╔╝███████╗`,
|
|
`██║ ██╔══██║ ██║ ██╔══██║██║ ██║ ██║██║╚██╔╝██║██╔══██╗╚════██║`,
|
|
`╚██████╗██║ ██║ ██║ ██║ ██║╚██████╗╚██████╔╝██║ ╚═╝ ██║██████╔╝███████║`,
|
|
`╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚══════╝`,
|
|
}
|
|
|
|
// gradient from red → orange → yellow
|
|
var titleColors = []lipgloss.Color{
|
|
lipgloss.Color("196"), // red
|
|
lipgloss.Color("202"), // orange-red
|
|
lipgloss.Color("208"), // orange
|
|
lipgloss.Color("214"), // yellow-orange
|
|
lipgloss.Color("220"), // yellow
|
|
lipgloss.Color("226"), // bright yellow
|
|
}
|
|
|
|
func renderTitle(width, height int) string {
|
|
// Render logo with gradient
|
|
var logoLines []string
|
|
for i, line := range titleLines {
|
|
color := titleColors[i%len(titleColors)]
|
|
style := lipgloss.NewStyle().Foreground(color).Bold(true)
|
|
logoLines = append(logoLines, style.Render(line))
|
|
}
|
|
logo := strings.Join(logoLines, "\n")
|
|
|
|
subtitle := lipgloss.NewStyle().
|
|
Foreground(colorGray).
|
|
Render("⚔ A Cooperative Dungeon Crawler ⚔")
|
|
|
|
server := lipgloss.NewStyle().
|
|
Foreground(colorCyan).
|
|
Render("ssh catacombs.tolelom.xyz")
|
|
|
|
menu := lipgloss.NewStyle().
|
|
Foreground(colorWhite).
|
|
Bold(true).
|
|
Render("[Enter] Start [Q] Quit")
|
|
|
|
content := lipgloss.JoinVertical(lipgloss.Center,
|
|
logo,
|
|
"",
|
|
subtitle,
|
|
server,
|
|
"",
|
|
"",
|
|
menu,
|
|
)
|
|
|
|
// Center on screen
|
|
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, content)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Build and test**
|
|
|
|
```bash
|
|
go build ./...
|
|
go test ./ui/ -timeout 15s
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add ui/title.go
|
|
git commit -m "feat: gradient title screen with centered layout"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Final integration test
|
|
|
|
- [ ] **Step 1: Build and run all tests**
|
|
|
|
```bash
|
|
go build ./...
|
|
go test ./... -timeout 30s
|
|
```
|
|
|
|
- [ ] **Step 2: Verify no dead code or unused imports**
|
|
|
|
```bash
|
|
go vet ./...
|
|
```
|