diff --git a/ui/model.go b/ui/model.go index e6bda97..9340363 100644 --- a/ui/model.go +++ b/ui/model.go @@ -24,6 +24,7 @@ const ( screenStats screenAchievements screenLeaderboard + screenNickname ) // StateUpdateMsg is sent by GameSession to update the view @@ -53,8 +54,9 @@ type Model struct { moveCursor int // selected neighbor index during exploration chatting bool chatInput string - rankingSaved bool - shopMsg string + rankingSaved bool + shopMsg string + nicknameInput string } func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *store.DB) Model { @@ -116,6 +118,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateAchievements(msg) case screenLeaderboard: return m.updateLeaderboard(msg) + case screenNickname: + return m.updateNickname(msg) } return m, nil } @@ -162,6 +166,8 @@ func (m Model) View() string { byGold, _ = m.store.TopRunsByGold(10) } return renderLeaderboard(byFloor, byGold, m.width, m.height) + case screenNickname: + return renderNickname(m.nicknameInput, m.width, m.height) } return "" } @@ -195,25 +201,37 @@ func isDown(key tea.KeyMsg) bool { func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { if isEnter(key) { - if m.store != nil { - name, err := m.store.GetProfile(m.fingerprint) - if err != nil { - m.playerName = "Adventurer" - if m.store != nil && m.fingerprint != "" { - m.store.SaveProfile(m.fingerprint, m.playerName) - } - } else { - m.playerName = name - } - } else { - m.playerName = "Adventurer" - } 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") { @@ -231,6 +249,48 @@ func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +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 +} + 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) { @@ -351,6 +411,9 @@ func (m Model) updateClassSelect(msg tea.Msg) (tea.Model, tea.Cmd) { 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() @@ -600,6 +663,9 @@ func (m Model) updateShop(msg tea.Msg) (tea.Model, tea.Cmd) { 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 diff --git a/ui/model_test.go b/ui/model_test.go index b9a043d..e33ea4d 100644 --- a/ui/model_test.go +++ b/ui/model_test.go @@ -28,14 +28,28 @@ func TestTitleToLobby(t *testing.T) { t.Fatalf("initial screen: got %d, want screenTitle(0)", m.screen) } - // Press Enter + // First-time player: Enter goes to nickname screen result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) m2 := result.(Model) - if m2.screen != screenLobby { - t.Errorf("after Enter: screen=%d, want screenLobby(1)", m2.screen) + if m2.screen != screenNickname { + t.Errorf("after Enter (first time): screen=%d, want screenNickname(%d)", m2.screen, screenNickname) } - if m2.playerName == "" { + + // Type a name + for _, ch := range []rune("Hero") { + result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}}) + m2 = result.(Model) + } + + // Confirm nickname + 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.playerName == "" { t.Error("playerName should be set") } } @@ -45,12 +59,20 @@ func TestLobbyCreateRoom(t *testing.T) { db := testDB(t) defer func() { db.Close(); os.Remove("test_ui.db") }() - m := NewModel(80, 24, "testfp", lobby, db) + m := NewModel(80, 24, "testfp2", lobby, db) - // Go to lobby + // Go to nickname screen (first-time player) result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) m2 := result.(Model) + // Type name and confirm + for _, ch := range []rune("Hero") { + result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}}) + m2 = result.(Model) + } + result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m2 = result.(Model) + // Press 'c' to create room result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) m3 := result.(Model) @@ -68,11 +90,19 @@ func TestClassSelectToGame(t *testing.T) { db := testDB(t) defer func() { db.Close(); os.Remove("test_ui.db") }() - m := NewModel(80, 24, "testfp", lobby, db) + m := NewModel(80, 24, "testfp3", lobby, db) - // Title -> Lobby -> Class Select -> Game + // Title -> Nickname -> Lobby result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) m2 := result.(Model) + for _, ch := range []rune("Hero") { + result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}}) + m2 = result.(Model) + } + result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m2 = result.(Model) + + // Lobby -> Class Select result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) m3 := result.(Model) diff --git a/ui/nickname_view.go b/ui/nickname_view.go new file mode 100644 index 0000000..020c93e --- /dev/null +++ b/ui/nickname_view.go @@ -0,0 +1,31 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +func renderNickname(input string, width, height int) string { + title := styleHeader.Render("── Enter Your Name ──") + + display := input + if display == "" { + display = strings.Repeat("_", 12) + } else { + display = input + "_" + } + + inputBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorCyan). + Padding(0, 2). + Render(stylePlayer.Render(display)) + + hint := styleSystem.Render(fmt.Sprintf("(%d/12 characters)", len(input))) + footer := styleAction.Render("[Enter] Confirm [Esc] Cancel") + + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, + lipgloss.JoinVertical(lipgloss.Center, title, "", inputBox, hint, "", footer)) +}