From 29387ebaa06cf53882da0a990a554a2ad549c911 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Tue, 24 Mar 2026 14:44:03 +0900 Subject: [PATCH] feat: help screen, detailed result screen, death/revive messages Co-Authored-By: Claude Sonnet 4.6 --- game/turn.go | 7 ++++++ ui/help_view.go | 44 +++++++++++++++++++++++++++++++++ ui/model.go | 18 +++++++++++++- ui/result_view.go | 63 ++++++++++++++++++++++++++++++----------------- ui/title.go | 2 +- 5 files changed, 109 insertions(+), 25 deletions(-) create mode 100644 ui/help_view.go diff --git a/game/turn.go b/game/turn.go index 9a89358..38a8f6c 100644 --- a/game/turn.go +++ b/game/turn.go @@ -287,6 +287,7 @@ func (s *GameSession) advanceFloor() { for _, p := range s.state.Players { if p.IsDead() { p.Revive(0.30) + s.addLog(fmt.Sprintf("✦ %s revived at %d HP!", p.Name, p.HP)) } p.Fled = false } @@ -323,6 +324,9 @@ func (s *GameSession) resolveMonsterActions() { dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 0.5) p.TakeDamage(dmg) s.addLog(fmt.Sprintf("%s AoE hits %s for %d dmg", m.Name, p.Name, dmg)) + if p.IsDead() { + s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name)) + } } } } else { @@ -332,6 +336,9 @@ func (s *GameSession) resolveMonsterActions() { dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 1.0) p.TakeDamage(dmg) s.addLog(fmt.Sprintf("%s attacks %s for %d dmg", m.Name, p.Name, dmg)) + if p.IsDead() { + s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name)) + } } } } diff --git a/ui/help_view.go b/ui/help_view.go new file mode 100644 index 0000000..9d5166e --- /dev/null +++ b/ui/help_view.go @@ -0,0 +1,44 @@ +package ui + +import ( + "github.com/charmbracelet/lipgloss" +) + +func renderHelp(width, height int) string { + title := styleHeader.Render("── Controls ──") + + sections := []struct{ header, body string }{ + {"Exploration", ` [Up/Down] Select room + [Enter] Move to room + [/] Chat + [Q] Quit`}, + {"Combat", ` [1] Attack [2] Skill + [3] Use Item [4] Flee + [5] Defend [Tab] Switch Target + [/] Chat`}, + {"Shop", ` [1-3] Buy item + [Q] Leave shop`}, + {"Classes", ` Warrior 120HP 12ATK 8DEF Taunt (draw fire 2t) + Mage 70HP 20ATK 3DEF Fireball (AoE 0.8x) + Healer 90HP 8ATK 5DEF Heal (restore 30HP) + Rogue 85HP 15ATK 4DEF Scout (reveal rooms)`}, + {"Tips", ` • Skills have 3 uses per combat + • Co-op bonus: 10% extra when 2+ attack same target + • Items are limited to 10 per player + • Dead players revive next floor at 30% HP`}, + } + + var content string + headerStyle := lipgloss.NewStyle().Foreground(colorCyan).Bold(true) + bodyStyle := lipgloss.NewStyle().Foreground(colorWhite) + + for _, s := range sections { + content += headerStyle.Render(s.header) + "\n" + content += bodyStyle.Render(s.body) + "\n\n" + } + + footer := styleSystem.Render("[H] Back") + + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, + lipgloss.JoinVertical(lipgloss.Center, title, "", content, footer)) +} diff --git a/ui/model.go b/ui/model.go index a036f11..54301c7 100644 --- a/ui/model.go +++ b/ui/model.go @@ -20,6 +20,7 @@ const ( screenGame screenShop screenResult + screenHelp ) // StateUpdateMsg is sent by GameSession to update the view @@ -104,6 +105,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateShop(msg) case screenResult: return m.updateResult(msg) + case screenHelp: + return m.updateHelp(msg) } return m, nil } @@ -128,7 +131,9 @@ func (m Model) View() string { if m.store != nil { rankings, _ = m.store.TopRuns(10) } - return renderResult(m.gameState.Victory, m.gameState.FloorNum, rankings) + return renderResult(m.gameState, rankings) + case screenHelp: + return renderHelp(m.width, m.height) } return "" } @@ -180,6 +185,8 @@ func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) { } m.screen = screenLobby m = m.withRefreshedLobby() + } else if isKey(key, "h") { + m.screen = screenHelp } else if isQuit(key) { return m, tea.Quit } @@ -187,6 +194,15 @@ func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +func (m Model) updateHelp(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + if isKey(key, "h") || isEnter(key) || isQuit(key) { + m.screen = screenTitle + } + } + return m, nil +} + func (m Model) updateLobby(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { // Join-by-code input mode diff --git a/ui/result_view.go b/ui/result_view.go index dc35fd9..58a0833 100644 --- a/ui/result_view.go +++ b/ui/result_view.go @@ -2,39 +2,56 @@ package ui import ( "fmt" + "strings" - "github.com/charmbracelet/lipgloss" + "github.com/tolelom/catacombs/game" "github.com/tolelom/catacombs/store" ) -func renderResult(won bool, floorReached int, rankings []store.RunRecord) string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205")) +func renderResult(state game.GameState, rankings []store.RunRecord) string { + var sb strings.Builder - var title string - if won { - title = titleStyle.Render("VICTORY! You escaped the Catacombs!") + // Title + if state.Victory { + sb.WriteString(styleHeal.Render(" ✦ VICTORY ✦ ") + "\n\n") + sb.WriteString(styleSystem.Render(" You conquered the Catacombs!") + "\n\n") } else { - title = titleStyle.Render("GAME OVER") + sb.WriteString(styleDamage.Render(" ✦ DEFEAT ✦ ") + "\n\n") + sb.WriteString(styleSystem.Render(fmt.Sprintf(" Fallen on floor B%d", state.FloorNum)) + "\n\n") } - floorInfo := fmt.Sprintf("Floor Reached: B%d", floorReached) + // Player summary + sb.WriteString(styleHeader.Render("── Party Summary ──") + "\n\n") + totalGold := 0 + for _, p := range state.Players { + status := styleHeal.Render("Alive") + if p.IsDead() { + status = styleDamage.Render("Dead") + } + sb.WriteString(fmt.Sprintf(" %s (%s) %s Gold: %d Items: %d Relics: %d\n", + stylePlayer.Render(p.Name), p.Class, status, p.Gold, len(p.Inventory), len(p.Relics))) + totalGold += p.Gold + } + sb.WriteString(fmt.Sprintf("\n Total Gold: %s\n", styleGold.Render(fmt.Sprintf("%d", totalGold)))) - rankHeader := lipgloss.NewStyle().Bold(true).Render("── Rankings ──") - rankList := "" - for i, r := range rankings { - rankList += fmt.Sprintf(" %d. %s — B%d (Score: %d)\n", i+1, r.Player, r.Floor, r.Score) + // Rankings + if len(rankings) > 0 { + sb.WriteString("\n" + styleHeader.Render("── Top Runs ──") + "\n\n") + for i, r := range rankings { + medal := " " + switch i { + case 0: + medal = styleGold.Render("🥇") + case 1: + medal = styleSystem.Render("🥈") + case 2: + medal = styleGold.Render("🥉") + } + sb.WriteString(fmt.Sprintf(" %s %s Floor B%d Score: %d\n", medal, r.Player, r.Floor, r.Score)) + } } - menu := "[Enter] Return to Lobby [Q] Quit" + sb.WriteString("\n" + styleAction.Render(" [Enter] Return to Lobby") + "\n") - return lipgloss.JoinVertical(lipgloss.Center, - title, - "", - floorInfo, - "", - rankHeader, - rankList, - "", - menu, - ) + return sb.String() } diff --git a/ui/title.go b/ui/title.go index 74b06ce..4a53fa9 100644 --- a/ui/title.go +++ b/ui/title.go @@ -44,7 +44,7 @@ func renderTitle(width, height int) string { menu := lipgloss.NewStyle(). Foreground(colorWhite). Bold(true). - Render("[Enter] Start [Q] Quit") + Render("[Enter] Start [H] Help [Q] Quit") content := lipgloss.JoinVertical(lipgloss.Center, logo,