Files
Catacombs/ui/game_view.go
tolelom f28160d4da feat: localize all UI text to Korean
Translate all user-facing strings to Korean across 25 files:
- UI screens: title, nickname, lobby, class select, waiting, game,
  shop, result, help, leaderboard, achievements, codex, stats
- Game logic: combat logs, events, achievements, mutations, emotes,
  lobby errors, session messages
- Keep English for: class names, monster names, item names, relic names

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:47:27 +09:00

677 lines
19 KiB
Go

package ui
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/dungeon"
"github.com/tolelom/catacombs/entity"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/store"
)
// GameScreen handles the main gameplay: exploration, combat, and chat.
type GameScreen struct {
gameState game.GameState
targetCursor int
moveCursor int
chatting bool
chatInput string
rankingSaved bool
codexRecorded map[string]bool
prevPhase game.GamePhase
}
func NewGameScreen() *GameScreen {
return &GameScreen{
codexRecorded: make(map[string]bool),
}
}
func (s *GameScreen) leaveGame(ctx *Context) (Screen, tea.Cmd) {
if ctx.Lobby != nil && ctx.Fingerprint != "" {
ctx.Lobby.UnregisterSession(ctx.Fingerprint)
}
if ctx.Session != nil {
ctx.Session.Stop()
ctx.Session = nil
}
if ctx.Lobby != nil && ctx.RoomCode != "" {
ctx.Lobby.RemoveRoom(ctx.RoomCode)
}
ctx.RoomCode = ""
ls := NewLobbyScreen()
ls.refreshLobby(ctx)
return ls, ls.pollLobby()
}
func (s *GameScreen) pollState() tea.Cmd {
return tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
return tickMsg{}
})
}
func (s *GameScreen) getNeighbors() []int {
if s.gameState.Floor == nil {
return nil
}
cur := s.gameState.Floor.CurrentRoom
if cur < 0 || cur >= len(s.gameState.Floor.Rooms) {
return nil
}
return s.gameState.Floor.Rooms[cur].Neighbors
}
func (s *GameScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
if ctx.Session != nil && ctx.Fingerprint != "" {
ctx.Session.TouchActivity(ctx.Fingerprint)
}
// Refresh state on every update
if ctx.Session != nil {
s.gameState = ctx.Session.GetState()
// Clamp target cursor to valid range after monsters die
if len(s.gameState.Monsters) > 0 {
if s.targetCursor >= len(s.gameState.Monsters) {
s.targetCursor = len(s.gameState.Monsters) - 1
}
} else {
s.targetCursor = 0
}
// Record codex entries for monsters when entering combat
if ctx.Store != nil && s.gameState.Phase == game.PhaseCombat {
for _, m := range s.gameState.Monsters {
key := "monster:" + m.Name
if !s.codexRecorded[key] {
ctx.Store.RecordCodexEntry(ctx.Fingerprint, "monster", m.Name)
s.codexRecorded[key] = true
}
}
}
// Record codex entries for shop items when entering shop
if ctx.Store != nil && s.gameState.Phase == game.PhaseShop && s.prevPhase != game.PhaseShop {
for _, item := range s.gameState.ShopItems {
key := "item:" + item.Name
if !s.codexRecorded[key] {
ctx.Store.RecordCodexEntry(ctx.Fingerprint, "item", item.Name)
s.codexRecorded[key] = true
}
}
}
// Record codex entries for events
if ctx.Store != nil && s.gameState.LastEventName != "" {
key := "event:" + s.gameState.LastEventName
if !s.codexRecorded[key] {
ctx.Store.RecordCodexEntry(ctx.Fingerprint, "event", s.gameState.LastEventName)
s.codexRecorded[key] = true
}
}
s.prevPhase = s.gameState.Phase
}
if s.gameState.GameOver {
if ctx.Store != nil && !s.rankingSaved {
score := 0
for _, p := range s.gameState.Players {
score += p.Gold
}
playerClass := ""
for _, p := range s.gameState.Players {
if p.Fingerprint == ctx.Fingerprint {
playerClass = p.Class.String()
break
}
}
ctx.Store.SaveRun(ctx.PlayerName, s.gameState.FloorNum, score, playerClass)
// Check achievements
if s.gameState.FloorNum >= 5 {
ctx.Store.UnlockAchievement(ctx.PlayerName, "first_clear")
}
if s.gameState.FloorNum >= 10 {
ctx.Store.UnlockAchievement(ctx.PlayerName, "floor10")
}
if s.gameState.Victory {
ctx.Store.UnlockAchievement(ctx.PlayerName, "floor20")
}
if s.gameState.SoloMode && s.gameState.FloorNum >= 5 {
ctx.Store.UnlockAchievement(ctx.PlayerName, "solo_clear")
}
if s.gameState.BossKilled {
ctx.Store.UnlockAchievement(ctx.PlayerName, "boss_slayer")
}
if s.gameState.FleeSucceeded {
ctx.Store.UnlockAchievement(ctx.PlayerName, "flee_master")
}
for _, p := range s.gameState.Players {
if p.Gold >= 200 {
ctx.Store.UnlockAchievement(p.Name, "gold_hoarder")
}
if len(p.Relics) >= 3 {
ctx.Store.UnlockAchievement(p.Name, "relic_collector")
}
}
if len(s.gameState.Players) >= 4 {
ctx.Store.UnlockAchievement(ctx.PlayerName, "full_party")
}
// Unlock triggers
if s.gameState.FloorNum >= 10 {
ctx.Store.UnlockContent(ctx.Fingerprint, "fifth_class")
}
if len(s.gameState.Players) >= 3 && s.gameState.FloorNum >= 5 {
ctx.Store.UnlockContent(ctx.Fingerprint, "hard_mode")
}
if s.gameState.Victory {
ctx.Store.UnlockContent(ctx.Fingerprint, "mutations")
}
// Title triggers
ctx.Store.EarnTitle(ctx.Fingerprint, "novice")
if s.gameState.FloorNum >= 5 {
ctx.Store.EarnTitle(ctx.Fingerprint, "explorer")
}
if s.gameState.FloorNum >= 10 {
ctx.Store.EarnTitle(ctx.Fingerprint, "veteran")
}
if s.gameState.Victory {
ctx.Store.EarnTitle(ctx.Fingerprint, "champion")
}
// Check player gold for gold_king title
for _, p := range s.gameState.Players {
if p.Fingerprint == ctx.Fingerprint && p.Gold >= 500 {
ctx.Store.EarnTitle(ctx.Fingerprint, "gold_king")
}
}
// Save daily record if in daily mode
if ctx.Session != nil && ctx.Session.DailyMode {
playerGold := 0
for _, p := range s.gameState.Players {
if p.Fingerprint == ctx.Fingerprint {
playerGold = p.Gold
break
}
}
ctx.Store.SaveDaily(store.DailyRecord{
Date: ctx.Session.DailyDate,
Player: ctx.Fingerprint,
PlayerName: ctx.PlayerName,
FloorReached: s.gameState.FloorNum,
GoldEarned: playerGold,
})
}
s.rankingSaved = true
}
return NewResultScreen(s.gameState, s.rankingSaved), nil
}
if s.gameState.Phase == game.PhaseShop {
return NewShopScreen(s.gameState), nil
}
switch msg.(type) {
case tickMsg:
if ctx.Session != nil {
ctx.Session.RevealNextLog()
}
if s.gameState.Phase == game.PhaseCombat {
return s, s.pollState()
}
if len(s.gameState.PendingLogs) > 0 {
return s, s.pollState()
}
return s, nil
}
if key, ok := msg.(tea.KeyMsg); ok {
// Chat mode
if s.chatting {
if isEnter(key) && len(s.chatInput) > 0 {
if ctx.Session != nil {
ctx.Session.SendChat(ctx.PlayerName, s.chatInput)
s.gameState = ctx.Session.GetState()
}
s.chatting = false
s.chatInput = ""
} else if isKey(key, "esc") || key.Type == tea.KeyEsc {
s.chatting = false
s.chatInput = ""
} else if key.Type == tea.KeyBackspace && len(s.chatInput) > 0 {
s.chatInput = s.chatInput[:len(s.chatInput)-1]
} else if len(key.Runes) == 1 && len(s.chatInput) < 40 {
s.chatInput += string(key.Runes)
}
if s.gameState.Phase == game.PhaseCombat {
return s, s.pollState()
}
return s, nil
}
if isKey(key, "/") {
s.chatting = true
s.chatInput = ""
if s.gameState.Phase == game.PhaseCombat {
return s, s.pollState()
}
return s, nil
}
switch s.gameState.Phase {
case game.PhaseExploring:
if isForceQuit(key) {
return s, tea.Quit
}
for _, p := range s.gameState.Players {
if p.Fingerprint == ctx.Fingerprint && p.IsDead() {
if isKey(key, "q") {
return s.leaveGame(ctx)
}
return s, nil
}
}
// Skill point allocation
if isKey(key, "[") || isKey(key, "]") {
if ctx.Session != nil {
branchIdx := 0
if isKey(key, "]") {
branchIdx = 1
}
ctx.Session.AllocateSkillPoint(ctx.Fingerprint, branchIdx)
s.gameState = ctx.Session.GetState()
}
return s, nil
}
neighbors := s.getNeighbors()
if isUp(key) {
if s.moveCursor > 0 {
s.moveCursor--
}
} else if isDown(key) {
if s.moveCursor < len(neighbors)-1 {
s.moveCursor++
}
} else if isEnter(key) {
if ctx.Session != nil && len(neighbors) > 0 {
roomIdx := neighbors[s.moveCursor]
ctx.Session.EnterRoom(roomIdx)
s.gameState = ctx.Session.GetState()
s.moveCursor = 0
if s.gameState.Phase == game.PhaseCombat {
return s, s.pollState()
}
}
} else if isForceQuit(key) {
return s, tea.Quit
} else if isKey(key, "q") {
return s.leaveGame(ctx)
}
case game.PhaseCombat:
isPlayerDead := false
for _, p := range s.gameState.Players {
if p.Fingerprint == ctx.Fingerprint && p.IsDead() {
isPlayerDead = true
break
}
}
if isPlayerDead {
return s, s.pollState()
}
if isKey(key, "tab") || key.Type == tea.KeyTab {
if len(s.gameState.Monsters) > 0 {
s.targetCursor = (s.targetCursor + 1) % len(s.gameState.Monsters)
}
return s, s.pollState()
}
if ctx.Session != nil {
switch key.String() {
case "1":
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionAttack, TargetIdx: s.targetCursor})
case "2":
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionSkill, TargetIdx: s.targetCursor})
case "3":
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionItem})
case "4":
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionFlee})
case "5":
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionWait})
}
return s, s.pollState()
}
}
}
return s, nil
}
func (s *GameScreen) View(ctx *Context) string {
return renderGame(s.gameState, ctx.Width, ctx.Height, s.targetCursor, s.moveCursor, s.chatting, s.chatInput, ctx.Fingerprint)
}
func renderGame(state game.GameState, width, height int, targetCursor int, moveCursor int, chatting bool, chatInput string, fingerprint string) string {
mapView := renderMap(state.Floor)
hudView := renderHUD(state, targetCursor, moveCursor, fingerprint)
logView := renderCombatLog(state.CombatLog)
if chatting {
chatStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("117"))
chatView := chatStyle.Render(fmt.Sprintf("> %s_", chatInput))
return lipgloss.JoinVertical(lipgloss.Left, mapView, hudView, logView, chatView)
}
return lipgloss.JoinVertical(lipgloss.Left,
mapView,
hudView,
logView,
)
}
func renderMap(floor *dungeon.Floor) string {
if floor == nil {
return ""
}
theme := dungeon.GetFloorTheme(floor.Number)
headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
// Count explored rooms
explored := 0
for _, r := range floor.Rooms {
if r.Visited || r.Cleared {
explored++
}
}
total := len(floor.Rooms)
header := headerStyle.Render(fmt.Sprintf("── 카타콤 B%d: %s ── %d/%d 방 ──", floor.Number, theme.Name, explored, total))
return header + "\n" + dungeon.RenderFloor(floor, floor.CurrentRoom, true)
}
func renderHUD(state game.GameState, targetCursor int, moveCursor int, fingerprint string) 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 = " [사망]"
}
sb.WriteString(fmt.Sprintf("%s (%s) %s %d/%d%s 골드: %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(" 아이템:%d 유물:%d", itemCount, relicCount))
}
sb.WriteString("\n")
}
if state.Phase == game.PhaseCombat {
// Two-panel layout: PARTY | ENEMIES
partyContent := renderPartyPanel(state.Players, state.SubmittedActions)
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]공격 [2]스킬 [3]아이템 [4]도주 [5]대기 [Tab]대상 [/]채팅"))
sb.WriteString("\n")
// Timer
if !state.TurnDeadline.IsZero() {
remaining := time.Until(state.TurnDeadline)
if remaining < 0 {
remaining = 0
}
sb.WriteString(styleTimer.Render(fmt.Sprintf(" 타이머: %.1f초", remaining.Seconds())))
sb.WriteString("\n")
}
// Skill description for first alive player only
for _, p := range state.Players {
if !p.IsDead() {
var skillDesc string
switch p.Class {
case entity.ClassWarrior:
skillDesc = "스킬: Taunt — 2턴간 적의 공격을 끌어옴"
case entity.ClassMage:
skillDesc = "스킬: Fireball — 전체 적에게 0.8배 피해"
case entity.ClassHealer:
skillDesc = "스킬: Heal — 아군 HP 30 회복"
case entity.ClassRogue:
skillDesc = "스킬: Scout — 주변 방 공개"
}
skillDesc += fmt.Sprintf(" (남은 횟수: %d)", p.SkillUses)
sb.WriteString(styleSystem.Render(skillDesc))
sb.WriteString("\n")
break
}
}
} else if state.Phase == game.PhaseExploring {
if state.Floor != nil && state.Floor.CurrentRoom >= 0 && state.Floor.CurrentRoom < len(state.Floor.Rooms) {
current := state.Floor.Rooms[state.Floor.CurrentRoom]
if len(current.Neighbors) > 0 {
sb.WriteString("\n")
selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Bold(true)
normalStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
for i, n := range current.Neighbors {
if n >= 0 && n < len(state.Floor.Rooms) {
r := state.Floor.Rooms[n]
status := r.Type.String()
if r.Cleared {
status = "클리어"
}
marker := " "
style := normalStyle
if i == moveCursor {
marker = "> "
style = selectedStyle
}
sb.WriteString(style.Render(fmt.Sprintf("%s방 %d: %s", marker, n, status)))
sb.WriteString("\n")
}
}
}
}
// Show skill tree allocation UI if player has unspent points
for _, p := range state.Players {
if p.Fingerprint == fingerprint && p.Skills != nil && p.Skills.Points > p.Skills.Allocated && p.Skills.Allocated < 3 {
branches := entity.GetBranches(p.Class)
sb.WriteString("\n")
skillStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("213")).Bold(true)
sb.WriteString(skillStyle.Render(fmt.Sprintf(" 스킬 포인트 사용 가능! (미사용: %d)", p.Skills.Points-p.Skills.Allocated)))
sb.WriteString("\n")
for i, branch := range branches {
key := "["
if i == 1 {
key = "]"
}
nextNode := p.Skills.Allocated
if p.Skills.BranchIndex >= 0 && p.Skills.BranchIndex != i {
sb.WriteString(fmt.Sprintf(" [%s] %s (잠김)\n", key, branch.Name))
} else if nextNode < 3 {
node := branch.Nodes[nextNode]
sb.WriteString(fmt.Sprintf(" [%s] %s -> %s\n", key, branch.Name, node.Name))
}
}
break
}
}
sb.WriteString("[Up/Down] 선택 [Enter] 이동 [Q] 종료")
}
if state.Phase == game.PhaseCombat {
return sb.String()
}
return border.Render(sb.String())
}
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 {
switch {
case strings.Contains(msg, "도주"):
return styleFlee.Render(msg)
case strings.Contains(msg, "협동"):
return styleCoop.Render(msg)
case strings.Contains(msg, "회복") || strings.Contains(msg, "Heal") || strings.Contains(msg, "치유") || strings.Contains(msg, "부활"):
return styleHeal.Render(msg)
case strings.Contains(msg, "피해") || strings.Contains(msg, "공격") || strings.Contains(msg, "Trap") || strings.Contains(msg, "함정"):
return styleDamage.Render(msg)
case strings.Contains(msg, "Taunt") || strings.Contains(msg, "정찰"):
return styleStatus.Render(msg)
case strings.Contains(msg, "골드") || strings.Contains(msg, "Gold") || strings.Contains(msg, "발견"):
return styleGold.Render(msg)
case strings.Contains(msg, "처치") || strings.Contains(msg, "클리어") || strings.Contains(msg, "내려갑니다") || strings.Contains(msg, "정복"):
return styleSystem.Render(msg)
default:
return msg
}
}
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))
}
func renderPartyPanel(players []*entity.Player, submittedActions map[string]string) string {
var sb strings.Builder
sb.WriteString(styleHeader.Render(" 아군") + "\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(" [사망]")
}
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))
if len(p.Effects) > 0 {
var effects []string
for _, e := range p.Effects {
switch e.Type {
case entity.StatusPoison:
effects = append(effects, styleHeal.Render(fmt.Sprintf("☠Poison(%dt)", e.Duration)))
case entity.StatusBurn:
effects = append(effects, styleDamage.Render(fmt.Sprintf("🔥Burn(%dt)", e.Duration)))
case entity.StatusFreeze:
effects = append(effects, styleFlee.Render(fmt.Sprintf("❄Freeze(%dt)", e.Duration)))
}
}
sb.WriteString(" " + strings.Join(effects, " ") + "\n")
}
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")
if action, ok := submittedActions[p.Fingerprint]; ok {
sb.WriteString(styleHeal.Render(fmt.Sprintf(" ✓ %s", action)))
sb.WriteString("\n")
} else if !p.IsOut() {
sb.WriteString(styleSystem.Render(" ... 대기중"))
sb.WriteString("\n")
}
sb.WriteString("\n")
}
return sb.String()
}
func renderEnemyPanel(monsters []*entity.Monster, targetCursor int) string {
var sb strings.Builder
sb.WriteString(styleHeader.Render(" 적") + "\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(fmt.Sprintf(" [도발됨 %d턴]", m.TauntTurns))
}
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()
}