feat: nickname input screen for first-time players
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
96
ui/model.go
96
ui/model.go
@@ -24,6 +24,7 @@ const (
|
|||||||
screenStats
|
screenStats
|
||||||
screenAchievements
|
screenAchievements
|
||||||
screenLeaderboard
|
screenLeaderboard
|
||||||
|
screenNickname
|
||||||
)
|
)
|
||||||
|
|
||||||
// StateUpdateMsg is sent by GameSession to update the view
|
// StateUpdateMsg is sent by GameSession to update the view
|
||||||
@@ -53,8 +54,9 @@ type Model struct {
|
|||||||
moveCursor int // selected neighbor index during exploration
|
moveCursor int // selected neighbor index during exploration
|
||||||
chatting bool
|
chatting bool
|
||||||
chatInput string
|
chatInput string
|
||||||
rankingSaved bool
|
rankingSaved bool
|
||||||
shopMsg string
|
shopMsg string
|
||||||
|
nicknameInput string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *store.DB) Model {
|
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)
|
return m.updateAchievements(msg)
|
||||||
case screenLeaderboard:
|
case screenLeaderboard:
|
||||||
return m.updateLeaderboard(msg)
|
return m.updateLeaderboard(msg)
|
||||||
|
case screenNickname:
|
||||||
|
return m.updateNickname(msg)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -162,6 +166,8 @@ func (m Model) View() string {
|
|||||||
byGold, _ = m.store.TopRunsByGold(10)
|
byGold, _ = m.store.TopRunsByGold(10)
|
||||||
}
|
}
|
||||||
return renderLeaderboard(byFloor, byGold, m.width, m.height)
|
return renderLeaderboard(byFloor, byGold, m.width, m.height)
|
||||||
|
case screenNickname:
|
||||||
|
return renderNickname(m.nicknameInput, m.width, m.height)
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -195,25 +201,37 @@ func isDown(key tea.KeyMsg) bool {
|
|||||||
func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
if key, ok := msg.(tea.KeyMsg); ok {
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
if isEnter(key) {
|
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 == "" {
|
if m.fingerprint == "" {
|
||||||
m.fingerprint = fmt.Sprintf("anon-%d", time.Now().UnixNano())
|
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 {
|
if m.lobby != nil {
|
||||||
m.lobby.PlayerOnline(m.fingerprint, m.playerName)
|
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.screen = screenLobby
|
||||||
m = m.withRefreshedLobby()
|
m = m.withRefreshedLobby()
|
||||||
} else if isKey(key, "h") {
|
} else if isKey(key, "h") {
|
||||||
@@ -231,6 +249,48 @@ func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
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) {
|
func (m Model) updateStats(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
if key, ok := msg.(tea.KeyMsg); ok {
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
if isKey(key, "s") || isEnter(key) || isQuit(key) {
|
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 := entity.NewPlayer(m.playerName, selectedClass)
|
||||||
player.Fingerprint = m.fingerprint
|
player.Fingerprint = m.fingerprint
|
||||||
m.session.AddPlayer(player)
|
m.session.AddPlayer(player)
|
||||||
|
if m.lobby != nil {
|
||||||
|
m.lobby.RegisterSession(m.fingerprint, m.roomCode)
|
||||||
|
}
|
||||||
m.session.StartGame()
|
m.session.StartGame()
|
||||||
m.lobby.StartRoom(m.roomCode)
|
m.lobby.StartRoom(m.roomCode)
|
||||||
m.gameState = m.session.GetState()
|
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) {
|
func (m Model) updateResult(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
if key, ok := msg.(tea.KeyMsg); ok {
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
if isEnter(key) {
|
if isEnter(key) {
|
||||||
|
if m.lobby != nil && m.fingerprint != "" {
|
||||||
|
m.lobby.UnregisterSession(m.fingerprint)
|
||||||
|
}
|
||||||
if m.session != nil {
|
if m.session != nil {
|
||||||
m.session.Stop()
|
m.session.Stop()
|
||||||
m.session = nil
|
m.session = nil
|
||||||
|
|||||||
@@ -28,14 +28,28 @@ func TestTitleToLobby(t *testing.T) {
|
|||||||
t.Fatalf("initial screen: got %d, want screenTitle(0)", m.screen)
|
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})
|
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
m2 := result.(Model)
|
m2 := result.(Model)
|
||||||
|
|
||||||
if m2.screen != screenLobby {
|
if m2.screen != screenNickname {
|
||||||
t.Errorf("after Enter: screen=%d, want screenLobby(1)", m2.screen)
|
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")
|
t.Error("playerName should be set")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,12 +59,20 @@ func TestLobbyCreateRoom(t *testing.T) {
|
|||||||
db := testDB(t)
|
db := testDB(t)
|
||||||
defer func() { db.Close(); os.Remove("test_ui.db") }()
|
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})
|
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
m2 := result.(Model)
|
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
|
// Press 'c' to create room
|
||||||
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
|
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
|
||||||
m3 := result.(Model)
|
m3 := result.(Model)
|
||||||
@@ -68,11 +90,19 @@ func TestClassSelectToGame(t *testing.T) {
|
|||||||
db := testDB(t)
|
db := testDB(t)
|
||||||
defer func() { db.Close(); os.Remove("test_ui.db") }()
|
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})
|
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
m2 := result.(Model)
|
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'}})
|
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
|
||||||
m3 := result.(Model)
|
m3 := result.(Model)
|
||||||
|
|
||||||
|
|||||||
31
ui/nickname_view.go
Normal file
31
ui/nickname_view.go
Normal 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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user