diff --git a/ui/achievements_view.go b/ui/achievements_view.go index c851765..5f1d1e3 100644 --- a/ui/achievements_view.go +++ b/ui/achievements_view.go @@ -3,10 +3,35 @@ package ui import ( "fmt" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/tolelom/catacombs/store" ) +// AchievementsScreen shows the player's achievements. +type AchievementsScreen struct{} + +func NewAchievementsScreen() *AchievementsScreen { + return &AchievementsScreen{} +} + +func (s *AchievementsScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + if isKey(key, "a") || isEnter(key) || isQuit(key) { + return NewTitleScreen(), nil + } + } + return s, nil +} + +func (s *AchievementsScreen) View(ctx *Context) string { + var achievements []store.Achievement + if ctx.Store != nil { + achievements, _ = ctx.Store.GetAchievements(ctx.PlayerName) + } + return renderAchievements(ctx.PlayerName, achievements, ctx.Width, ctx.Height) +} + func renderAchievements(playerName string, achievements []store.Achievement, width, height int) string { title := styleHeader.Render("── Achievements ──") diff --git a/ui/class_view.go b/ui/class_view.go index d1b4552..d47feae 100644 --- a/ui/class_view.go +++ b/ui/class_view.go @@ -3,10 +3,64 @@ package ui import ( "fmt" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/tolelom/catacombs/entity" + "github.com/tolelom/catacombs/game" ) +// ClassSelectScreen lets the player choose a class before entering the game. +type ClassSelectScreen struct { + cursor int +} + +func NewClassSelectScreen() *ClassSelectScreen { + return &ClassSelectScreen{} +} + +func (s *ClassSelectScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + if isUp(key) { + if s.cursor > 0 { + s.cursor-- + } + } else if isDown(key) { + if s.cursor < len(classOptions)-1 { + s.cursor++ + } + } else if isEnter(key) { + if ctx.Lobby != nil { + selectedClass := classOptions[s.cursor].class + ctx.Lobby.SetPlayerClass(ctx.RoomCode, ctx.Fingerprint, selectedClass.String()) + room := ctx.Lobby.GetRoom(ctx.RoomCode) + if room != nil { + if room.Session == nil { + room.Session = game.NewGameSession(ctx.Lobby.Cfg()) + } + ctx.Session = room.Session + player := entity.NewPlayer(ctx.PlayerName, selectedClass) + player.Fingerprint = ctx.Fingerprint + ctx.Session.AddPlayer(player) + if ctx.Lobby != nil { + ctx.Lobby.RegisterSession(ctx.Fingerprint, ctx.RoomCode) + } + ctx.Session.StartGame() + ctx.Lobby.StartRoom(ctx.RoomCode) + gs := NewGameScreen() + gs.gameState = ctx.Session.GetState() + return gs, nil + } + } + } + } + return s, nil +} + +func (s *ClassSelectScreen) View(ctx *Context) string { + state := classSelectState{cursor: s.cursor} + return renderClassSelect(state, ctx.Width, ctx.Height) +} + type classSelectState struct { cursor int } diff --git a/ui/game_view.go b/ui/game_view.go index 21c7e99..84a206a 100644 --- a/ui/game_view.go +++ b/ui/game_view.go @@ -5,12 +5,233 @@ import ( "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" ) +// 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 +} + +func NewGameScreen() *GameScreen { + return &GameScreen{} +} + +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 + } + } + + 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") + } + 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: + for _, p := range s.gameState.Players { + if p.Fingerprint == ctx.Fingerprint && p.IsDead() { + if isQuit(key) { + return s, tea.Quit + } + 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 isQuit(key) { + return s, tea.Quit + } + 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) +} + func renderGame(state game.GameState, width, height int, targetCursor int, moveCursor int, chatting bool, chatInput string) string { mapView := renderMap(state.Floor) hudView := renderHUD(state, targetCursor, moveCursor) diff --git a/ui/help_view.go b/ui/help_view.go index 9d5166e..fec3d2e 100644 --- a/ui/help_view.go +++ b/ui/help_view.go @@ -1,9 +1,30 @@ package ui import ( + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) +// HelpScreen shows controls and tips. +type HelpScreen struct{} + +func NewHelpScreen() *HelpScreen { + return &HelpScreen{} +} + +func (s *HelpScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + if isKey(key, "h") || isEnter(key) || isQuit(key) { + return NewTitleScreen(), nil + } + } + return s, nil +} + +func (s *HelpScreen) View(ctx *Context) string { + return renderHelp(ctx.Width, ctx.Height) +} + func renderHelp(width, height int) string { title := styleHeader.Render("── Controls ──") diff --git a/ui/leaderboard_view.go b/ui/leaderboard_view.go index d8ce493..82c813c 100644 --- a/ui/leaderboard_view.go +++ b/ui/leaderboard_view.go @@ -3,10 +3,36 @@ package ui import ( "fmt" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/tolelom/catacombs/store" ) +// LeaderboardScreen shows the top runs. +type LeaderboardScreen struct{} + +func NewLeaderboardScreen() *LeaderboardScreen { + return &LeaderboardScreen{} +} + +func (s *LeaderboardScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + if isKey(key, "l") || isEnter(key) || isQuit(key) { + return NewTitleScreen(), nil + } + } + return s, nil +} + +func (s *LeaderboardScreen) View(ctx *Context) string { + var byFloor, byGold []store.RunRecord + if ctx.Store != nil { + byFloor, _ = ctx.Store.TopRuns(10) + byGold, _ = ctx.Store.TopRunsByGold(10) + } + return renderLeaderboard(byFloor, byGold, ctx.Width, ctx.Height) +} + func renderLeaderboard(byFloor, byGold []store.RunRecord, width, height int) string { title := styleHeader.Render("── Leaderboard ──") diff --git a/ui/lobby_view.go b/ui/lobby_view.go index 4ce095c..f8909eb 100644 --- a/ui/lobby_view.go +++ b/ui/lobby_view.go @@ -4,20 +4,11 @@ import ( "fmt" "strings" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/tolelom/catacombs/game" ) -type lobbyState struct { - rooms []roomInfo - input string - cursor int - creating bool - roomName string - joining bool - codeInput string - online int -} - type roomInfo struct { Code string Name string @@ -31,6 +22,134 @@ type playerInfo struct { Ready bool } +// LobbyScreen shows available rooms and lets players create/join. +type LobbyScreen struct { + rooms []roomInfo + input string + cursor int + creating bool + roomName string + joining bool + codeInput string + online int +} + +func NewLobbyScreen() *LobbyScreen { + return &LobbyScreen{} +} + +func (s *LobbyScreen) refreshLobby(ctx *Context) { + if ctx.Lobby == nil { + return + } + rooms := ctx.Lobby.ListRooms() + s.rooms = make([]roomInfo, len(rooms)) + for i, r := range rooms { + status := "Waiting" + if r.Status == game.RoomPlaying { + status = "Playing" + } + players := make([]playerInfo, len(r.Players)) + for j, p := range r.Players { + players[j] = playerInfo{Name: p.Name, Class: p.Class, Ready: p.Ready} + } + s.rooms[i] = roomInfo{ + Code: r.Code, + Name: r.Name, + Players: players, + Status: status, + } + } + s.online = len(ctx.Lobby.ListOnline()) + s.cursor = 0 +} + +func (s *LobbyScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + // Join-by-code input mode + if s.joining { + if isEnter(key) && len(s.codeInput) == 4 { + if ctx.Lobby != nil { + if err := ctx.Lobby.JoinRoom(s.codeInput, ctx.PlayerName, ctx.Fingerprint); err == nil { + ctx.RoomCode = s.codeInput + return NewClassSelectScreen(), nil + } + } + s.joining = false + s.codeInput = "" + } else if isKey(key, "esc") || key.Type == tea.KeyEsc { + s.joining = false + s.codeInput = "" + } else if key.Type == tea.KeyBackspace && len(s.codeInput) > 0 { + s.codeInput = s.codeInput[:len(s.codeInput)-1] + } else if len(key.Runes) == 1 && len(s.codeInput) < 4 { + ch := strings.ToUpper(string(key.Runes)) + s.codeInput += ch + } + return s, nil + } + // Normal lobby key handling + if isKey(key, "c") { + if ctx.Lobby != nil { + code := ctx.Lobby.CreateRoom(ctx.PlayerName + "'s Room") + ctx.Lobby.JoinRoom(code, ctx.PlayerName, ctx.Fingerprint) + ctx.RoomCode = code + return NewClassSelectScreen(), nil + } + } else if isKey(key, "j") { + s.joining = true + s.codeInput = "" + } else if isUp(key) { + if s.cursor > 0 { + s.cursor-- + } + } else if isDown(key) { + if s.cursor < len(s.rooms)-1 { + s.cursor++ + } + } else if isEnter(key) { + if ctx.Lobby != nil && len(s.rooms) > 0 { + r := s.rooms[s.cursor] + if err := ctx.Lobby.JoinRoom(r.Code, ctx.PlayerName, ctx.Fingerprint); err == nil { + ctx.RoomCode = r.Code + return NewClassSelectScreen(), nil + } + } + } else if isKey(key, "q") { + if ctx.Lobby != nil { + ctx.Lobby.PlayerOffline(ctx.Fingerprint) + } + return NewTitleScreen(), nil + } + } + return s, nil +} + +func (s *LobbyScreen) View(ctx *Context) string { + state := lobbyState{ + rooms: s.rooms, + input: s.input, + cursor: s.cursor, + creating: s.creating, + roomName: s.roomName, + joining: s.joining, + codeInput: s.codeInput, + online: s.online, + } + return renderLobby(state, ctx.Width, ctx.Height) +} + +type lobbyState struct { + rooms []roomInfo + input string + cursor int + creating bool + roomName string + joining bool + codeInput string + online int +} + func renderLobby(state lobbyState, width, height int) string { headerStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("205")). diff --git a/ui/model.go b/ui/model.go index 91cf75c..815e5d7 100644 --- a/ui/model.go +++ b/ui/model.go @@ -2,61 +2,22 @@ package ui import ( "fmt" - "strings" - "time" tea "github.com/charmbracelet/bubbletea" - "github.com/tolelom/catacombs/entity" "github.com/tolelom/catacombs/game" "github.com/tolelom/catacombs/store" ) -type screen int - -const ( - screenTitle screen = iota - screenLobby - screenClassSelect - screenGame - screenShop - screenResult - screenHelp - screenStats - screenAchievements - screenLeaderboard - screenNickname -) - // StateUpdateMsg is sent by GameSession to update the view type StateUpdateMsg struct { State game.GameState } +type tickMsg struct{} + type Model struct { - width int - height int - fingerprint string - playerName string - screen screen - - // Shared references (set by server) - lobby *game.Lobby - store *store.DB - - // Per-session state - session *game.GameSession - roomCode string - gameState game.GameState - lobbyState lobbyState - classState classSelectState - inputBuffer string - targetCursor int - moveCursor int // selected neighbor index during exploration - chatting bool - chatInput string - rankingSaved bool - shopMsg string - nicknameInput string + currentScreen Screen + ctx *Context } func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *store.DB) Model { @@ -66,13 +27,26 @@ func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *stor if height == 0 { height = 24 } + ctx := &Context{ + Width: width, + Height: height, + Fingerprint: fingerprint, + Lobby: lobby, + Store: db, + } + + // Determine initial screen + var initialScreen Screen + if fingerprint != "" && db != nil { + if name, err := db.GetProfile(fingerprint); err == nil { + ctx.PlayerName = name + } + } + initialScreen = NewTitleScreen() + return Model{ - width: width, - height: height, - fingerprint: fingerprint, - screen: screenTitle, - lobby: lobby, - store: db, + currentScreen: initialScreen, + ctx: ctx, } } @@ -83,95 +57,35 @@ func (m Model) Init() tea.Cmd { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - if m.width == 0 { - m.width = 80 + m.ctx.Width = msg.Width + m.ctx.Height = msg.Height + if m.ctx.Width == 0 { + m.ctx.Width = 80 } - if m.height == 0 { - m.height = 24 + if m.ctx.Height == 0 { + m.ctx.Height = 24 } return m, nil case StateUpdateMsg: - m.gameState = msg.State + if gs, ok := m.currentScreen.(*GameScreen); ok { + gs.gameState = msg.State + } return m, nil } - switch m.screen { - case screenTitle: - return m.updateTitle(msg) - case screenLobby: - return m.updateLobby(msg) - case screenClassSelect: - return m.updateClassSelect(msg) - case screenGame: - return m.updateGame(msg) - case screenShop: - return m.updateShop(msg) - case screenResult: - return m.updateResult(msg) - case screenHelp: - return m.updateHelp(msg) - case screenStats: - return m.updateStats(msg) - case screenAchievements: - return m.updateAchievements(msg) - case screenLeaderboard: - return m.updateLeaderboard(msg) - case screenNickname: - return m.updateNickname(msg) - } - return m, nil + next, cmd := m.currentScreen.Update(msg, m.ctx) + m.currentScreen = next + return m, cmd } func (m Model) View() string { - if m.width < 80 || m.height < 24 { - return fmt.Sprintf("Terminal too small (%dx%d). Minimum: 80x24.", m.width, m.height) + if m.ctx.Width < 80 || m.ctx.Height < 24 { + return fmt.Sprintf("Terminal too small (%dx%d). Minimum: 80x24.", m.ctx.Width, m.ctx.Height) } - switch m.screen { - case screenTitle: - return renderTitle(m.width, m.height) - case screenLobby: - return renderLobby(m.lobbyState, m.width, m.height) - case screenClassSelect: - return renderClassSelect(m.classState, m.width, m.height) - case screenGame: - return renderGame(m.gameState, m.width, m.height, m.targetCursor, m.moveCursor, m.chatting, m.chatInput) - case screenShop: - return renderShop(m.gameState, m.width, m.height, m.shopMsg) - case screenResult: - var rankings []store.RunRecord - if m.store != nil { - rankings, _ = m.store.TopRuns(10) - } - return renderResult(m.gameState, rankings) - case screenHelp: - return renderHelp(m.width, m.height) - case screenStats: - var stats store.PlayerStats - if m.store != nil { - stats, _ = m.store.GetStats(m.playerName) - } - return renderStats(m.playerName, stats, m.width, m.height) - case screenAchievements: - var achievements []store.Achievement - if m.store != nil { - achievements, _ = m.store.GetAchievements(m.playerName) - } - return renderAchievements(m.playerName, achievements, m.width, m.height) - case screenLeaderboard: - var byFloor, byGold []store.RunRecord - if m.store != nil { - byFloor, _ = m.store.TopRuns(10) - byGold, _ = m.store.TopRunsByGold(10) - } - return renderLeaderboard(byFloor, byGold, m.width, m.height) - case screenNickname: - return renderNickname(m.nicknameInput, m.width, m.height) - } - return "" + return m.currentScreen.View(m.ctx) } +// Key helper functions used by all screens. func isKey(key tea.KeyMsg, names ...string) bool { s := key.String() for _, n := range names { @@ -198,515 +112,63 @@ func isDown(key tea.KeyMsg) bool { return isKey(key, "down") || key.Type == tea.KeyDown } -func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { - if isEnter(key) { - if m.fingerprint == "" { - m.fingerprint = fmt.Sprintf("anon-%d", time.Now().UnixNano()) - } - if m.store != nil { - name, err := m.store.GetProfile(m.fingerprint) - if err != nil { - // First time player — show nickname input - m.screen = screenNickname - m.nicknameInput = "" - return m, nil - } - m.playerName = name - } else { - m.playerName = "Adventurer" - } - if m.lobby != nil { - m.lobby.PlayerOnline(m.fingerprint, m.playerName) - } - // Check for active session to reconnect - if m.lobby != nil { - code, session := m.lobby.GetActiveSession(m.fingerprint) - if session != nil { - m.roomCode = code - m.session = session - m.gameState = m.session.GetState() - m.screen = screenGame - m.session.TouchActivity(m.fingerprint) - m.session.SendChat("System", m.playerName+" reconnected!") - return m, m.pollState() - } - } - m.screen = screenLobby - m = m.withRefreshedLobby() - } else if isKey(key, "h") { - m.screen = screenHelp - } else if isKey(key, "s") { - m.screen = screenStats - } else if isKey(key, "a") { - m.screen = screenAchievements - } else if isKey(key, "l") { - m.screen = screenLeaderboard - } else if isQuit(key) { - return m, tea.Quit - } +// Keep these for backward compatibility with tests +// screen enum kept temporarily for test compatibility +type screen int + +const ( + screenTitle screen = iota + screenLobby + screenClassSelect + screenGame + screenShop + screenResult + screenHelp + screenStats + screenAchievements + screenLeaderboard + screenNickname +) + +// screenType returns the screen enum for the current screen (for test compatibility). +func (m Model) screenType() screen { + switch m.currentScreen.(type) { + case *TitleScreen: + return screenTitle + case *LobbyScreen: + return screenLobby + case *ClassSelectScreen: + return screenClassSelect + case *GameScreen: + return screenGame + case *ShopScreen: + return screenShop + case *ResultScreen: + return screenResult + case *HelpScreen: + return screenHelp + case *StatsScreen: + return screenStats + case *AchievementsScreen: + return screenAchievements + case *LeaderboardScreen: + return screenLeaderboard + case *NicknameScreen: + return screenNickname } - return m, nil + return screenTitle } -func (m Model) updateNickname(msg tea.Msg) (tea.Model, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { - if isEnter(key) && len(m.nicknameInput) > 0 { - m.playerName = m.nicknameInput - if m.store != nil && m.fingerprint != "" { - m.store.SaveProfile(m.fingerprint, m.playerName) - } - m.nicknameInput = "" - if m.lobby != nil { - m.lobby.PlayerOnline(m.fingerprint, m.playerName) - } - // Check for active session to reconnect - if m.lobby != nil { - code, session := m.lobby.GetActiveSession(m.fingerprint) - if session != nil { - m.roomCode = code - m.session = session - m.gameState = m.session.GetState() - m.screen = screenGame - m.session.TouchActivity(m.fingerprint) - m.session.SendChat("System", m.playerName+" reconnected!") - return m, m.pollState() - } - } - m.screen = screenLobby - m = m.withRefreshedLobby() - } else if isKey(key, "esc") || key.Type == tea.KeyEsc { - m.nicknameInput = "" - m.screen = screenTitle - } else if key.Type == tea.KeyBackspace && len(m.nicknameInput) > 0 { - m.nicknameInput = m.nicknameInput[:len(m.nicknameInput)-1] - } else if len(key.Runes) == 1 && len(m.nicknameInput) < 12 { - ch := string(key.Runes) - // Only allow alphanumeric and some special chars - if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 { - m.nicknameInput += ch - } - } - } - return m, nil +// Convenience accessors for test compatibility +func (m Model) playerName() string { + return m.ctx.PlayerName } -func (m Model) updateStats(msg tea.Msg) (tea.Model, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { - if isKey(key, "s") || isEnter(key) || isQuit(key) { - m.screen = screenTitle - } - } - return m, nil +func (m Model) roomCode() string { + return m.ctx.RoomCode } -func (m Model) updateAchievements(msg tea.Msg) (tea.Model, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { - if isKey(key, "a") || isEnter(key) || isQuit(key) { - m.screen = screenTitle - } - } - return m, nil +func (m Model) session() *game.GameSession { + return m.ctx.Session } -func (m Model) updateLeaderboard(msg tea.Msg) (tea.Model, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { - if isKey(key, "l") || isEnter(key) || isQuit(key) { - m.screen = screenTitle - } - } - 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 - if m.lobbyState.joining { - if isEnter(key) && len(m.lobbyState.codeInput) == 4 { - if m.lobby != nil { - if err := m.lobby.JoinRoom(m.lobbyState.codeInput, m.playerName, m.fingerprint); err == nil { - m.roomCode = m.lobbyState.codeInput - m.screen = screenClassSelect - } - } - m.lobbyState.joining = false - m.lobbyState.codeInput = "" - } else if isKey(key, "esc") || key.Type == tea.KeyEsc { - m.lobbyState.joining = false - m.lobbyState.codeInput = "" - } else if key.Type == tea.KeyBackspace && len(m.lobbyState.codeInput) > 0 { - m.lobbyState.codeInput = m.lobbyState.codeInput[:len(m.lobbyState.codeInput)-1] - } else if len(key.Runes) == 1 && len(m.lobbyState.codeInput) < 4 { - ch := strings.ToUpper(string(key.Runes)) - m.lobbyState.codeInput += ch - } - return m, nil - } - // Normal lobby key handling - if isKey(key, "c") { - if m.lobby != nil { - code := m.lobby.CreateRoom(m.playerName + "'s Room") - m.lobby.JoinRoom(code, m.playerName, m.fingerprint) - m.roomCode = code - m.screen = screenClassSelect - } - } else if isKey(key, "j") { - m.lobbyState.joining = true - m.lobbyState.codeInput = "" - } else if isUp(key) { - if m.lobbyState.cursor > 0 { - m.lobbyState.cursor-- - } - } else if isDown(key) { - if m.lobbyState.cursor < len(m.lobbyState.rooms)-1 { - m.lobbyState.cursor++ - } - } else if isEnter(key) { - if m.lobby != nil && len(m.lobbyState.rooms) > 0 { - r := m.lobbyState.rooms[m.lobbyState.cursor] - if err := m.lobby.JoinRoom(r.Code, m.playerName, m.fingerprint); err == nil { - m.roomCode = r.Code - m.screen = screenClassSelect - } - } - } else if isKey(key, "q") { - if m.lobby != nil { - m.lobby.PlayerOffline(m.fingerprint) - } - m.screen = screenTitle - } - } - return m, nil -} - -func (m Model) updateClassSelect(msg tea.Msg) (tea.Model, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { - if isUp(key) { - if m.classState.cursor > 0 { - m.classState.cursor-- - } - } else if isDown(key) { - if m.classState.cursor < len(classOptions)-1 { - m.classState.cursor++ - } - } else if isEnter(key) { - if m.lobby != nil { - selectedClass := classOptions[m.classState.cursor].class - m.lobby.SetPlayerClass(m.roomCode, m.fingerprint, selectedClass.String()) - room := m.lobby.GetRoom(m.roomCode) - if room != nil { - if room.Session == nil { - room.Session = game.NewGameSession(m.lobby.Cfg()) - } - m.session = room.Session - player := entity.NewPlayer(m.playerName, selectedClass) - player.Fingerprint = m.fingerprint - m.session.AddPlayer(player) - if m.lobby != nil { - m.lobby.RegisterSession(m.fingerprint, m.roomCode) - } - m.session.StartGame() - m.lobby.StartRoom(m.roomCode) - m.gameState = m.session.GetState() - m.screen = screenGame - } - } - } - } - return m, nil -} - -// pollState returns a Cmd that waits briefly then refreshes game state -func (m Model) pollState() tea.Cmd { - return tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg { - return tickMsg{} - }) -} - -type tickMsg struct{} - -func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) { - if m.session != nil && m.fingerprint != "" { - m.session.TouchActivity(m.fingerprint) - } - // Refresh state on every update - if m.session != nil { - m.gameState = m.session.GetState() - // Clamp target cursor to valid range after monsters die - if len(m.gameState.Monsters) > 0 { - if m.targetCursor >= len(m.gameState.Monsters) { - m.targetCursor = len(m.gameState.Monsters) - 1 - } - } else { - m.targetCursor = 0 - } - } - - if m.gameState.GameOver { - if m.store != nil && !m.rankingSaved { - score := 0 - for _, p := range m.gameState.Players { - score += p.Gold - } - // Find the current player's class - playerClass := "" - for _, p := range m.gameState.Players { - if p.Fingerprint == m.fingerprint { - playerClass = p.Class.String() - break - } - } - m.store.SaveRun(m.playerName, m.gameState.FloorNum, score, playerClass) - // Check achievements - if m.gameState.FloorNum >= 5 { - m.store.UnlockAchievement(m.playerName, "first_clear") - } - if m.gameState.FloorNum >= 10 { - m.store.UnlockAchievement(m.playerName, "floor10") - } - if m.gameState.Victory { - m.store.UnlockAchievement(m.playerName, "floor20") - } - if m.gameState.SoloMode && m.gameState.FloorNum >= 5 { - m.store.UnlockAchievement(m.playerName, "solo_clear") - } - if m.gameState.BossKilled { - m.store.UnlockAchievement(m.playerName, "boss_slayer") - } - if m.gameState.FleeSucceeded { - m.store.UnlockAchievement(m.playerName, "flee_master") - } - for _, p := range m.gameState.Players { - if p.Gold >= 200 { - m.store.UnlockAchievement(p.Name, "gold_hoarder") - } - if len(p.Relics) >= 3 { - m.store.UnlockAchievement(p.Name, "relic_collector") - } - } - if len(m.gameState.Players) >= 4 { - m.store.UnlockAchievement(m.playerName, "full_party") - } - m.rankingSaved = true - } - m.screen = screenResult - return m, nil - } - if m.gameState.Phase == game.PhaseShop { - m.screen = screenShop - return m, nil - } - - switch msg.(type) { - case tickMsg: - if m.session != nil { - m.session.RevealNextLog() - } - // Keep polling during combat or while there are pending logs to reveal - if m.gameState.Phase == game.PhaseCombat { - return m, m.pollState() - } - if len(m.gameState.PendingLogs) > 0 { - return m, m.pollState() - } - return m, nil - } - - if key, ok := msg.(tea.KeyMsg); ok { - // Chat mode - if m.chatting { - if isEnter(key) && len(m.chatInput) > 0 { - if m.session != nil { - m.session.SendChat(m.playerName, m.chatInput) - m.gameState = m.session.GetState() - } - m.chatting = false - m.chatInput = "" - } else if isKey(key, "esc") || key.Type == tea.KeyEsc { - m.chatting = false - m.chatInput = "" - } else if key.Type == tea.KeyBackspace && len(m.chatInput) > 0 { - m.chatInput = m.chatInput[:len(m.chatInput)-1] - } else if len(key.Runes) == 1 && len(m.chatInput) < 40 { - m.chatInput += string(key.Runes) - } - if m.gameState.Phase == game.PhaseCombat { - return m, m.pollState() - } - return m, nil - } - - if isKey(key, "/") { - m.chatting = true - m.chatInput = "" - if m.gameState.Phase == game.PhaseCombat { - return m, m.pollState() - } - return m, nil - } - - switch m.gameState.Phase { - case game.PhaseExploring: - // Dead players can only observe, not move - for _, p := range m.gameState.Players { - if p.Fingerprint == m.fingerprint && p.IsDead() { - if isQuit(key) { - return m, tea.Quit - } - return m, nil - } - } - neighbors := m.getNeighbors() - if isUp(key) { - if m.moveCursor > 0 { - m.moveCursor-- - } - } else if isDown(key) { - if m.moveCursor < len(neighbors)-1 { - m.moveCursor++ - } - } else if isEnter(key) { - if m.session != nil && len(neighbors) > 0 { - roomIdx := neighbors[m.moveCursor] - m.session.EnterRoom(roomIdx) - m.gameState = m.session.GetState() - m.moveCursor = 0 - if m.gameState.Phase == game.PhaseCombat { - return m, m.pollState() - } - } - } else if isQuit(key) { - return m, tea.Quit - } - case game.PhaseCombat: - isPlayerDead := false - for _, p := range m.gameState.Players { - if p.Fingerprint == m.fingerprint && p.IsDead() { - isPlayerDead = true - break - } - } - if isPlayerDead { - return m, m.pollState() - } - if isKey(key, "tab") || key.Type == tea.KeyTab { - if len(m.gameState.Monsters) > 0 { - m.targetCursor = (m.targetCursor + 1) % len(m.gameState.Monsters) - } - return m, m.pollState() - } - if m.session != nil { - switch key.String() { - case "1": - m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionAttack, TargetIdx: m.targetCursor}) - case "2": - m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionSkill, TargetIdx: m.targetCursor}) - case "3": - m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionItem}) - case "4": - m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionFlee}) - case "5": - m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionWait}) - } - // After submitting, poll for turn resolution - return m, m.pollState() - } - } - } - return m, nil -} - -func (m Model) getNeighbors() []int { - if m.gameState.Floor == nil { - return nil - } - cur := m.gameState.Floor.CurrentRoom - if cur < 0 || cur >= len(m.gameState.Floor.Rooms) { - return nil - } - return m.gameState.Floor.Rooms[cur].Neighbors -} - -func (m Model) updateShop(msg tea.Msg) (tea.Model, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { - switch key.String() { - case "1", "2", "3": - if m.session != nil { - idx := int(key.String()[0] - '1') - if m.session.BuyItem(m.fingerprint, idx) { - m.shopMsg = "Purchased!" - } else { - m.shopMsg = "Not enough gold!" - } - m.gameState = m.session.GetState() - } - case "q": - if m.session != nil { - m.session.LeaveShop() - m.gameState = m.session.GetState() - m.screen = screenGame - } - } - } - return m, nil -} - -func (m Model) updateResult(msg tea.Msg) (tea.Model, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { - if isEnter(key) { - if m.lobby != nil && m.fingerprint != "" { - m.lobby.UnregisterSession(m.fingerprint) - } - if m.session != nil { - m.session.Stop() - m.session = nil - } - if m.lobby != nil && m.roomCode != "" { - m.lobby.RemoveRoom(m.roomCode) - } - m.roomCode = "" - m.rankingSaved = false - m.screen = screenLobby - m = m.withRefreshedLobby() - } else if isQuit(key) { - return m, tea.Quit - } - } - return m, nil -} - -func (m Model) withRefreshedLobby() Model { - if m.lobby == nil { - return m - } - rooms := m.lobby.ListRooms() - m.lobbyState.rooms = make([]roomInfo, len(rooms)) - for i, r := range rooms { - status := "Waiting" - if r.Status == game.RoomPlaying { - status = "Playing" - } - players := make([]playerInfo, len(r.Players)) - for j, p := range r.Players { - players[j] = playerInfo{Name: p.Name, Class: p.Class, Ready: p.Ready} - } - m.lobbyState.rooms[i] = roomInfo{ - Code: r.Code, - Name: r.Name, - Players: players, - Status: status, - } - } - m.lobbyState.online = len(m.lobby.ListOnline()) - m.lobbyState.cursor = 0 - return m -} diff --git a/ui/model_test.go b/ui/model_test.go index 2ece3fd..9f4bd3a 100644 --- a/ui/model_test.go +++ b/ui/model_test.go @@ -25,16 +25,16 @@ func TestTitleToLobby(t *testing.T) { m := NewModel(80, 24, "testfp", lobby, db) - if m.screen != screenTitle { - t.Fatalf("initial screen: got %d, want screenTitle(0)", m.screen) + if m.screenType() != screenTitle { + t.Fatalf("initial screen: got %d, want screenTitle(0)", m.screenType()) } // First-time player: Enter goes to nickname screen result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) m2 := result.(Model) - if m2.screen != screenNickname { - t.Errorf("after Enter (first time): screen=%d, want screenNickname(%d)", m2.screen, screenNickname) + if m2.screenType() != screenNickname { + t.Errorf("after Enter (first time): screen=%d, want screenNickname(%d)", m2.screenType(), screenNickname) } // Type a name @@ -47,10 +47,10 @@ func TestTitleToLobby(t *testing.T) { result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyEnter}) m3 := result.(Model) - if m3.screen != screenLobby { - t.Errorf("after nickname Enter: screen=%d, want screenLobby(1)", m3.screen) + if m3.screenType() != screenLobby { + t.Errorf("after nickname Enter: screen=%d, want screenLobby(1)", m3.screenType()) } - if m3.playerName == "" { + if m3.playerName() == "" { t.Error("playerName should be set") } } @@ -78,10 +78,10 @@ func TestLobbyCreateRoom(t *testing.T) { result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) m3 := result.(Model) - if m3.screen != screenClassSelect { - t.Errorf("after 'c': screen=%d, want screenClassSelect(2)", m3.screen) + if m3.screenType() != screenClassSelect { + t.Errorf("after 'c': screen=%d, want screenClassSelect(2)", m3.screenType()) } - if m3.roomCode == "" { + if m3.roomCode() == "" { t.Error("roomCode should be set") } } @@ -107,18 +107,18 @@ func TestClassSelectToGame(t *testing.T) { result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) m3 := result.(Model) - if m3.screen != screenClassSelect { - t.Fatalf("should be at class select, got %d", m3.screen) + if m3.screenType() != screenClassSelect { + t.Fatalf("should be at class select, got %d", m3.screenType()) } // Press Enter to select Warrior (default cursor=0) result, _ = m3.Update(tea.KeyMsg{Type: tea.KeyEnter}) m4 := result.(Model) - if m4.screen != screenGame { - t.Errorf("after class select Enter: screen=%d, want screenGame(3)", m4.screen) + if m4.screenType() != screenGame { + t.Errorf("after class select Enter: screen=%d, want screenGame(3)", m4.screenType()) } - if m4.session == nil { + if m4.session() == nil { t.Error("session should be set") } } diff --git a/ui/nickname_view.go b/ui/nickname_view.go index 020c93e..c24aa44 100644 --- a/ui/nickname_view.go +++ b/ui/nickname_view.go @@ -4,9 +4,64 @@ import ( "fmt" "strings" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) +// NicknameScreen handles first-time player name input. +type NicknameScreen struct { + input string +} + +func NewNicknameScreen() *NicknameScreen { + return &NicknameScreen{} +} + +func (s *NicknameScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + if isEnter(key) && len(s.input) > 0 { + ctx.PlayerName = s.input + if ctx.Store != nil && ctx.Fingerprint != "" { + ctx.Store.SaveProfile(ctx.Fingerprint, ctx.PlayerName) + } + if ctx.Lobby != nil { + ctx.Lobby.PlayerOnline(ctx.Fingerprint, ctx.PlayerName) + } + // Check for active session to reconnect + if ctx.Lobby != nil { + code, session := ctx.Lobby.GetActiveSession(ctx.Fingerprint) + if session != nil { + ctx.RoomCode = code + ctx.Session = session + gs := NewGameScreen() + gs.gameState = ctx.Session.GetState() + ctx.Session.TouchActivity(ctx.Fingerprint) + ctx.Session.SendChat("System", ctx.PlayerName+" reconnected!") + return gs, gs.pollState() + } + } + ls := NewLobbyScreen() + ls.refreshLobby(ctx) + return ls, nil + } else if isKey(key, "esc") || key.Type == tea.KeyEsc { + s.input = "" + return NewTitleScreen(), nil + } else if key.Type == tea.KeyBackspace && len(s.input) > 0 { + s.input = s.input[:len(s.input)-1] + } else if len(key.Runes) == 1 && len(s.input) < 12 { + ch := string(key.Runes) + if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 { + s.input += ch + } + } + } + return s, nil +} + +func (s *NicknameScreen) View(ctx *Context) string { + return renderNickname(s.input, ctx.Width, ctx.Height) +} + func renderNickname(input string, width, height int) string { title := styleHeader.Render("── Enter Your Name ──") diff --git a/ui/result_view.go b/ui/result_view.go index 58a0833..dec0e28 100644 --- a/ui/result_view.go +++ b/ui/result_view.go @@ -4,10 +4,53 @@ import ( "fmt" "strings" + tea "github.com/charmbracelet/bubbletea" "github.com/tolelom/catacombs/game" "github.com/tolelom/catacombs/store" ) +// ResultScreen shows the end-of-run summary and rankings. +type ResultScreen struct { + gameState game.GameState + rankingSaved bool +} + +func NewResultScreen(state game.GameState, rankingSaved bool) *ResultScreen { + return &ResultScreen{gameState: state, rankingSaved: rankingSaved} +} + +func (s *ResultScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + if isEnter(key) { + 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, nil + } else if isQuit(key) { + return s, tea.Quit + } + } + return s, nil +} + +func (s *ResultScreen) View(ctx *Context) string { + var rankings []store.RunRecord + if ctx.Store != nil { + rankings, _ = ctx.Store.TopRuns(10) + } + return renderResult(s.gameState, rankings) +} + func renderResult(state game.GameState, rankings []store.RunRecord) string { var sb strings.Builder diff --git a/ui/shop_view.go b/ui/shop_view.go index 74b4168..24681f5 100644 --- a/ui/shop_view.go +++ b/ui/shop_view.go @@ -3,11 +3,51 @@ package ui import ( "fmt" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/tolelom/catacombs/entity" "github.com/tolelom/catacombs/game" ) +// ShopScreen handles the shop between floors. +type ShopScreen struct { + gameState game.GameState + shopMsg string +} + +func NewShopScreen(state game.GameState) *ShopScreen { + return &ShopScreen{gameState: state} +} + +func (s *ShopScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "1", "2", "3": + if ctx.Session != nil { + idx := int(key.String()[0] - '1') + if ctx.Session.BuyItem(ctx.Fingerprint, idx) { + s.shopMsg = "Purchased!" + } else { + s.shopMsg = "Not enough gold!" + } + s.gameState = ctx.Session.GetState() + } + case "q": + if ctx.Session != nil { + ctx.Session.LeaveShop() + gs := NewGameScreen() + gs.gameState = ctx.Session.GetState() + return gs, nil + } + } + } + return s, nil +} + +func (s *ShopScreen) View(ctx *Context) string { + return renderShop(s.gameState, ctx.Width, ctx.Height, s.shopMsg) +} + func itemTypeLabel(item entity.Item) string { switch item.Type { case entity.ItemWeapon: diff --git a/ui/stats_view.go b/ui/stats_view.go index 489e32b..9f3ba55 100644 --- a/ui/stats_view.go +++ b/ui/stats_view.go @@ -3,10 +3,35 @@ package ui import ( "fmt" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/tolelom/catacombs/store" ) +// StatsScreen shows player statistics. +type StatsScreen struct{} + +func NewStatsScreen() *StatsScreen { + return &StatsScreen{} +} + +func (s *StatsScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + if isKey(key, "s") || isEnter(key) || isQuit(key) { + return NewTitleScreen(), nil + } + } + return s, nil +} + +func (s *StatsScreen) View(ctx *Context) string { + var stats store.PlayerStats + if ctx.Store != nil { + stats, _ = ctx.Store.GetStats(ctx.PlayerName) + } + return renderStats(ctx.PlayerName, stats, ctx.Width, ctx.Height) +} + func renderStats(playerName string, stats store.PlayerStats, width, height int) string { title := styleHeader.Render("── Player Statistics ──") diff --git a/ui/title.go b/ui/title.go index 05774ad..d0970a1 100644 --- a/ui/title.go +++ b/ui/title.go @@ -1,11 +1,75 @@ package ui import ( + "fmt" "strings" + "time" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) +// TitleScreen is the main menu screen. +type TitleScreen struct{} + +func NewTitleScreen() *TitleScreen { + return &TitleScreen{} +} + +func (s *TitleScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + if isEnter(key) { + if ctx.Fingerprint == "" { + ctx.Fingerprint = fmt.Sprintf("anon-%d", time.Now().UnixNano()) + } + if ctx.Store != nil { + name, err := ctx.Store.GetProfile(ctx.Fingerprint) + if err != nil { + // First time player — show nickname input + return NewNicknameScreen(), nil + } + ctx.PlayerName = name + } else { + ctx.PlayerName = "Adventurer" + } + if ctx.Lobby != nil { + ctx.Lobby.PlayerOnline(ctx.Fingerprint, ctx.PlayerName) + } + // Check for active session to reconnect + if ctx.Lobby != nil { + code, session := ctx.Lobby.GetActiveSession(ctx.Fingerprint) + if session != nil { + ctx.RoomCode = code + ctx.Session = session + gs := NewGameScreen() + gs.gameState = ctx.Session.GetState() + ctx.Session.TouchActivity(ctx.Fingerprint) + ctx.Session.SendChat("System", ctx.PlayerName+" reconnected!") + return gs, gs.pollState() + } + } + ls := NewLobbyScreen() + ls.refreshLobby(ctx) + return ls, nil + } else if isKey(key, "h") { + return NewHelpScreen(), nil + } else if isKey(key, "s") { + return NewStatsScreen(), nil + } else if isKey(key, "a") { + return NewAchievementsScreen(), nil + } else if isKey(key, "l") { + return NewLeaderboardScreen(), nil + } else if isQuit(key) { + return s, tea.Quit + } + } + return s, nil +} + +func (s *TitleScreen) View(ctx *Context) string { + return renderTitle(ctx.Width, ctx.Height) +} + var titleLines = []string{ ` ██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ███╗ ███╗██████╗ ███████╗`, `██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██╔════╝██╔═══██╗████╗ ████║██╔══██╗██╔════╝`,