Files
Catacombs/docs/superpowers/plans/2026-03-24-terminal-visuals.md
tolelom 84431c888a docs: terminal visuals implementation plan
5 tasks: styles, ASCII art, colored log, combat layout, title screen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:35:20 +09:00

14 KiB

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

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
go build ./...
  • Step 3: Commit
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

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
go build ./...
  • Step 3: Commit
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:

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:

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
go build ./...
go test ./ui/ -timeout 15s
  • Step 4: Commit
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:

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
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):

	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:

	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
go build ./...
go test ./... -timeout 30s
  • Step 7: Commit
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:

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
go build ./...
go test ./ui/ -timeout 15s
  • Step 3: Commit
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
go build ./...
go test ./... -timeout 30s
  • Step 2: Verify no dead code or unused imports
go vet ./...