refactor: extract all screens from model.go into Screen implementations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,35 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/tolelom/catacombs/store"
|
"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 {
|
func renderAchievements(playerName string, achievements []store.Achievement, width, height int) string {
|
||||||
title := styleHeader.Render("── Achievements ──")
|
title := styleHeader.Render("── Achievements ──")
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,64 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/tolelom/catacombs/entity"
|
"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 {
|
type classSelectState struct {
|
||||||
cursor int
|
cursor int
|
||||||
}
|
}
|
||||||
|
|||||||
221
ui/game_view.go
221
ui/game_view.go
@@ -5,12 +5,233 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/tolelom/catacombs/dungeon"
|
"github.com/tolelom/catacombs/dungeon"
|
||||||
"github.com/tolelom/catacombs/entity"
|
"github.com/tolelom/catacombs/entity"
|
||||||
"github.com/tolelom/catacombs/game"
|
"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 {
|
func renderGame(state game.GameState, width, height int, targetCursor int, moveCursor int, chatting bool, chatInput string) string {
|
||||||
mapView := renderMap(state.Floor)
|
mapView := renderMap(state.Floor)
|
||||||
hudView := renderHUD(state, targetCursor, moveCursor)
|
hudView := renderHUD(state, targetCursor, moveCursor)
|
||||||
|
|||||||
@@ -1,9 +1,30 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"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 {
|
func renderHelp(width, height int) string {
|
||||||
title := styleHeader.Render("── Controls ──")
|
title := styleHeader.Render("── Controls ──")
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,36 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/tolelom/catacombs/store"
|
"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 {
|
func renderLeaderboard(byFloor, byGold []store.RunRecord, width, height int) string {
|
||||||
title := styleHeader.Render("── Leaderboard ──")
|
title := styleHeader.Render("── Leaderboard ──")
|
||||||
|
|
||||||
|
|||||||
141
ui/lobby_view.go
141
ui/lobby_view.go
@@ -4,20 +4,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"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 {
|
type roomInfo struct {
|
||||||
Code string
|
Code string
|
||||||
Name string
|
Name string
|
||||||
@@ -31,6 +22,134 @@ type playerInfo struct {
|
|||||||
Ready bool
|
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 {
|
func renderLobby(state lobbyState, width, height int) string {
|
||||||
headerStyle := lipgloss.NewStyle().
|
headerStyle := lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("205")).
|
Foreground(lipgloss.Color("205")).
|
||||||
|
|||||||
718
ui/model.go
718
ui/model.go
@@ -2,61 +2,22 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/tolelom/catacombs/entity"
|
|
||||||
"github.com/tolelom/catacombs/game"
|
"github.com/tolelom/catacombs/game"
|
||||||
"github.com/tolelom/catacombs/store"
|
"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
|
// StateUpdateMsg is sent by GameSession to update the view
|
||||||
type StateUpdateMsg struct {
|
type StateUpdateMsg struct {
|
||||||
State game.GameState
|
State game.GameState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type tickMsg struct{}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
width int
|
currentScreen Screen
|
||||||
height int
|
ctx *Context
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -66,13 +27,26 @@ func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *stor
|
|||||||
if height == 0 {
|
if height == 0 {
|
||||||
height = 24
|
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{
|
return Model{
|
||||||
width: width,
|
currentScreen: initialScreen,
|
||||||
height: height,
|
ctx: ctx,
|
||||||
fingerprint: fingerprint,
|
|
||||||
screen: screenTitle,
|
|
||||||
lobby: lobby,
|
|
||||||
store: db,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,95 +57,35 @@ func (m Model) Init() tea.Cmd {
|
|||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.ctx.Width = msg.Width
|
||||||
m.height = msg.Height
|
m.ctx.Height = msg.Height
|
||||||
if m.width == 0 {
|
if m.ctx.Width == 0 {
|
||||||
m.width = 80
|
m.ctx.Width = 80
|
||||||
}
|
}
|
||||||
if m.height == 0 {
|
if m.ctx.Height == 0 {
|
||||||
m.height = 24
|
m.ctx.Height = 24
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case StateUpdateMsg:
|
case StateUpdateMsg:
|
||||||
m.gameState = msg.State
|
if gs, ok := m.currentScreen.(*GameScreen); ok {
|
||||||
|
gs.gameState = msg.State
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
switch m.screen {
|
next, cmd := m.currentScreen.Update(msg, m.ctx)
|
||||||
case screenTitle:
|
m.currentScreen = next
|
||||||
return m.updateTitle(msg)
|
return m, cmd
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) View() string {
|
func (m Model) View() string {
|
||||||
if m.width < 80 || m.height < 24 {
|
if m.ctx.Width < 80 || m.ctx.Height < 24 {
|
||||||
return fmt.Sprintf("Terminal too small (%dx%d). Minimum: 80x24.", m.width, m.height)
|
return fmt.Sprintf("Terminal too small (%dx%d). Minimum: 80x24.", m.ctx.Width, m.ctx.Height)
|
||||||
}
|
}
|
||||||
switch m.screen {
|
return m.currentScreen.View(m.ctx)
|
||||||
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 ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Key helper functions used by all screens.
|
||||||
func isKey(key tea.KeyMsg, names ...string) bool {
|
func isKey(key tea.KeyMsg, names ...string) bool {
|
||||||
s := key.String()
|
s := key.String()
|
||||||
for _, n := range names {
|
for _, n := range names {
|
||||||
@@ -198,515 +112,63 @@ func isDown(key tea.KeyMsg) bool {
|
|||||||
return isKey(key, "down") || key.Type == tea.KeyDown
|
return isKey(key, "down") || key.Type == tea.KeyDown
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) {
|
// Keep these for backward compatibility with tests
|
||||||
if key, ok := msg.(tea.KeyMsg); ok {
|
// screen enum kept temporarily for test compatibility
|
||||||
if isEnter(key) {
|
type screen int
|
||||||
if m.fingerprint == "" {
|
|
||||||
m.fingerprint = fmt.Sprintf("anon-%d", time.Now().UnixNano())
|
const (
|
||||||
}
|
screenTitle screen = iota
|
||||||
if m.store != nil {
|
screenLobby
|
||||||
name, err := m.store.GetProfile(m.fingerprint)
|
screenClassSelect
|
||||||
if err != nil {
|
screenGame
|
||||||
// First time player — show nickname input
|
screenShop
|
||||||
m.screen = screenNickname
|
screenResult
|
||||||
m.nicknameInput = ""
|
screenHelp
|
||||||
return m, nil
|
screenStats
|
||||||
}
|
screenAchievements
|
||||||
m.playerName = name
|
screenLeaderboard
|
||||||
} else {
|
screenNickname
|
||||||
m.playerName = "Adventurer"
|
)
|
||||||
}
|
|
||||||
if m.lobby != nil {
|
// screenType returns the screen enum for the current screen (for test compatibility).
|
||||||
m.lobby.PlayerOnline(m.fingerprint, m.playerName)
|
func (m Model) screenType() screen {
|
||||||
}
|
switch m.currentScreen.(type) {
|
||||||
// Check for active session to reconnect
|
case *TitleScreen:
|
||||||
if m.lobby != nil {
|
return screenTitle
|
||||||
code, session := m.lobby.GetActiveSession(m.fingerprint)
|
case *LobbyScreen:
|
||||||
if session != nil {
|
return screenLobby
|
||||||
m.roomCode = code
|
case *ClassSelectScreen:
|
||||||
m.session = session
|
return screenClassSelect
|
||||||
m.gameState = m.session.GetState()
|
case *GameScreen:
|
||||||
m.screen = screenGame
|
return screenGame
|
||||||
m.session.TouchActivity(m.fingerprint)
|
case *ShopScreen:
|
||||||
m.session.SendChat("System", m.playerName+" reconnected!")
|
return screenShop
|
||||||
return m, m.pollState()
|
case *ResultScreen:
|
||||||
}
|
return screenResult
|
||||||
}
|
case *HelpScreen:
|
||||||
m.screen = screenLobby
|
return screenHelp
|
||||||
m = m.withRefreshedLobby()
|
case *StatsScreen:
|
||||||
} else if isKey(key, "h") {
|
return screenStats
|
||||||
m.screen = screenHelp
|
case *AchievementsScreen:
|
||||||
} else if isKey(key, "s") {
|
return screenAchievements
|
||||||
m.screen = screenStats
|
case *LeaderboardScreen:
|
||||||
} else if isKey(key, "a") {
|
return screenLeaderboard
|
||||||
m.screen = screenAchievements
|
case *NicknameScreen:
|
||||||
} else if isKey(key, "l") {
|
return screenNickname
|
||||||
m.screen = screenLeaderboard
|
|
||||||
} else if isQuit(key) {
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return m, nil
|
return screenTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) updateNickname(msg tea.Msg) (tea.Model, tea.Cmd) {
|
// Convenience accessors for test compatibility
|
||||||
if key, ok := msg.(tea.KeyMsg); ok {
|
func (m Model) playerName() string {
|
||||||
if isEnter(key) && len(m.nicknameInput) > 0 {
|
return m.ctx.PlayerName
|
||||||
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) roomCode() string {
|
||||||
if key, ok := msg.(tea.KeyMsg); ok {
|
return m.ctx.RoomCode
|
||||||
if isKey(key, "s") || isEnter(key) || isQuit(key) {
|
|
||||||
m.screen = screenTitle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) updateAchievements(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) session() *game.GameSession {
|
||||||
if key, ok := msg.(tea.KeyMsg); ok {
|
return m.ctx.Session
|
||||||
if isKey(key, "a") || isEnter(key) || isQuit(key) {
|
|
||||||
m.screen = screenTitle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,16 +25,16 @@ func TestTitleToLobby(t *testing.T) {
|
|||||||
|
|
||||||
m := NewModel(80, 24, "testfp", lobby, db)
|
m := NewModel(80, 24, "testfp", lobby, db)
|
||||||
|
|
||||||
if m.screen != screenTitle {
|
if m.screenType() != screenTitle {
|
||||||
t.Fatalf("initial screen: got %d, want screenTitle(0)", m.screen)
|
t.Fatalf("initial screen: got %d, want screenTitle(0)", m.screenType())
|
||||||
}
|
}
|
||||||
|
|
||||||
// First-time player: Enter goes to nickname screen
|
// 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 != screenNickname {
|
if m2.screenType() != screenNickname {
|
||||||
t.Errorf("after Enter (first time): screen=%d, want screenNickname(%d)", m2.screen, screenNickname)
|
t.Errorf("after Enter (first time): screen=%d, want screenNickname(%d)", m2.screenType(), screenNickname)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type a name
|
// Type a name
|
||||||
@@ -47,10 +47,10 @@ func TestTitleToLobby(t *testing.T) {
|
|||||||
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
m3 := result.(Model)
|
m3 := result.(Model)
|
||||||
|
|
||||||
if m3.screen != screenLobby {
|
if m3.screenType() != screenLobby {
|
||||||
t.Errorf("after nickname Enter: screen=%d, want screenLobby(1)", m3.screen)
|
t.Errorf("after nickname Enter: screen=%d, want screenLobby(1)", m3.screenType())
|
||||||
}
|
}
|
||||||
if m3.playerName == "" {
|
if m3.playerName() == "" {
|
||||||
t.Error("playerName should be set")
|
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'}})
|
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
|
||||||
m3 := result.(Model)
|
m3 := result.(Model)
|
||||||
|
|
||||||
if m3.screen != screenClassSelect {
|
if m3.screenType() != screenClassSelect {
|
||||||
t.Errorf("after 'c': screen=%d, want screenClassSelect(2)", m3.screen)
|
t.Errorf("after 'c': screen=%d, want screenClassSelect(2)", m3.screenType())
|
||||||
}
|
}
|
||||||
if m3.roomCode == "" {
|
if m3.roomCode() == "" {
|
||||||
t.Error("roomCode should be set")
|
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'}})
|
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
|
||||||
m3 := result.(Model)
|
m3 := result.(Model)
|
||||||
|
|
||||||
if m3.screen != screenClassSelect {
|
if m3.screenType() != screenClassSelect {
|
||||||
t.Fatalf("should be at class select, got %d", m3.screen)
|
t.Fatalf("should be at class select, got %d", m3.screenType())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Press Enter to select Warrior (default cursor=0)
|
// Press Enter to select Warrior (default cursor=0)
|
||||||
result, _ = m3.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
result, _ = m3.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
m4 := result.(Model)
|
m4 := result.(Model)
|
||||||
|
|
||||||
if m4.screen != screenGame {
|
if m4.screenType() != screenGame {
|
||||||
t.Errorf("after class select Enter: screen=%d, want screenGame(3)", m4.screen)
|
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")
|
t.Error("session should be set")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,64 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"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 {
|
func renderNickname(input string, width, height int) string {
|
||||||
title := styleHeader.Render("── Enter Your Name ──")
|
title := styleHeader.Render("── Enter Your Name ──")
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,53 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/tolelom/catacombs/game"
|
"github.com/tolelom/catacombs/game"
|
||||||
"github.com/tolelom/catacombs/store"
|
"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 {
|
func renderResult(state game.GameState, rankings []store.RunRecord) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,51 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/tolelom/catacombs/entity"
|
"github.com/tolelom/catacombs/entity"
|
||||||
"github.com/tolelom/catacombs/game"
|
"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 {
|
func itemTypeLabel(item entity.Item) string {
|
||||||
switch item.Type {
|
switch item.Type {
|
||||||
case entity.ItemWeapon:
|
case entity.ItemWeapon:
|
||||||
|
|||||||
@@ -3,10 +3,35 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/tolelom/catacombs/store"
|
"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 {
|
func renderStats(playerName string, stats store.PlayerStats, width, height int) string {
|
||||||
title := styleHeader.Render("── Player Statistics ──")
|
title := styleHeader.Render("── Player Statistics ──")
|
||||||
|
|
||||||
|
|||||||
64
ui/title.go
64
ui/title.go
@@ -1,11 +1,75 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"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{
|
var titleLines = []string{
|
||||||
` ██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ███╗ ███╗██████╗ ███████╗`,
|
` ██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ███╗ ███╗██████╗ ███████╗`,
|
||||||
`██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██╔════╝██╔═══██╗████╗ ████║██╔══██╗██╔════╝`,
|
`██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██╔════╝██╔═══██╗████╗ ████║██╔══██╗██╔════╝`,
|
||||||
|
|||||||
Reference in New Issue
Block a user