feat: nickname input screen for first-time players

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 15:42:06 +09:00
parent ef9a713696
commit 43a9a0d9ad
3 changed files with 150 additions and 23 deletions

View File

@@ -24,6 +24,7 @@ const (
screenStats
screenAchievements
screenLeaderboard
screenNickname
)
// StateUpdateMsg is sent by GameSession to update the view
@@ -55,6 +56,7 @@ type Model struct {
chatInput 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

View File

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

31
ui/nickname_view.go Normal file
View File

@@ -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))
}