Add title, lobby, class select, game, shop, and result screens. Rewrite model.go with 6-screen state machine and input routing. Wire server/ssh.go and main.go with lobby and store. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
298 lines
6.7 KiB
Go
298 lines
6.7 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
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
|
|
)
|
|
|
|
// StateUpdateMsg is sent by GameSession to update the view
|
|
type StateUpdateMsg struct {
|
|
State game.GameState
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *store.DB) Model {
|
|
return Model{
|
|
width: width,
|
|
height: height,
|
|
fingerprint: fingerprint,
|
|
screen: screenTitle,
|
|
lobby: lobby,
|
|
store: db,
|
|
}
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
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
|
|
return m, nil
|
|
case StateUpdateMsg:
|
|
m.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)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
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)
|
|
}
|
|
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)
|
|
case screenShop:
|
|
return renderShop(m.gameState, m.width, m.height)
|
|
case screenResult:
|
|
var rankings []store.RunRecord
|
|
if m.store != nil {
|
|
rankings, _ = m.store.TopRuns(10)
|
|
}
|
|
return renderResult(m.gameState.Victory, m.gameState.FloorNum, rankings)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
if key, ok := msg.(tea.KeyMsg); ok {
|
|
switch key.String() {
|
|
case "enter":
|
|
if m.store != nil {
|
|
name, err := m.store.GetProfile(m.fingerprint)
|
|
if err != nil {
|
|
m.playerName = "Adventurer"
|
|
} else {
|
|
m.playerName = name
|
|
}
|
|
} else {
|
|
m.playerName = "Adventurer"
|
|
}
|
|
m.screen = screenLobby
|
|
m.refreshLobbyState()
|
|
case "q", "ctrl+c":
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateLobby(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
if key, ok := msg.(tea.KeyMsg); ok {
|
|
switch key.String() {
|
|
case "c":
|
|
if m.lobby != nil {
|
|
code := m.lobby.CreateRoom(m.playerName + "'s Room")
|
|
m.lobby.JoinRoom(code, m.playerName)
|
|
m.roomCode = code
|
|
m.screen = screenClassSelect
|
|
}
|
|
case "up":
|
|
if m.lobbyState.cursor > 0 {
|
|
m.lobbyState.cursor--
|
|
}
|
|
case "down":
|
|
if m.lobbyState.cursor < len(m.lobbyState.rooms)-1 {
|
|
m.lobbyState.cursor++
|
|
}
|
|
case "enter":
|
|
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); err == nil {
|
|
m.roomCode = r.Code
|
|
m.screen = screenClassSelect
|
|
}
|
|
}
|
|
case "q":
|
|
m.screen = screenTitle
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateClassSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
if key, ok := msg.(tea.KeyMsg); ok {
|
|
switch key.String() {
|
|
case "up":
|
|
if m.classState.cursor > 0 {
|
|
m.classState.cursor--
|
|
}
|
|
case "down":
|
|
if m.classState.cursor < len(classOptions)-1 {
|
|
m.classState.cursor++
|
|
}
|
|
case "enter":
|
|
if m.lobby != nil {
|
|
selectedClass := classOptions[m.classState.cursor].class
|
|
room := m.lobby.GetRoom(m.roomCode)
|
|
if room != nil {
|
|
if room.Session == nil {
|
|
room.Session = game.NewGameSession()
|
|
}
|
|
m.session = room.Session
|
|
player := entity.NewPlayer(m.playerName, selectedClass)
|
|
player.Fingerprint = m.fingerprint
|
|
m.session.AddPlayer(player)
|
|
m.session.StartGame()
|
|
m.gameState = m.session.GetState()
|
|
m.screen = screenGame
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
if m.gameState.GameOver {
|
|
m.screen = screenResult
|
|
return m, nil
|
|
}
|
|
if m.gameState.Phase == game.PhaseShop {
|
|
m.screen = screenShop
|
|
return m, nil
|
|
}
|
|
|
|
if key, ok := msg.(tea.KeyMsg); ok {
|
|
switch m.gameState.Phase {
|
|
case game.PhaseExploring:
|
|
if key.String() >= "0" && key.String() <= "9" {
|
|
idx := int(key.String()[0] - '0')
|
|
if m.session != nil {
|
|
m.session.EnterRoom(idx)
|
|
m.gameState = m.session.GetState()
|
|
}
|
|
}
|
|
case game.PhaseCombat:
|
|
if m.session != nil {
|
|
switch key.String() {
|
|
case "1":
|
|
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionAttack, TargetIdx: 0})
|
|
case "2":
|
|
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionSkill, TargetIdx: 0})
|
|
case "3":
|
|
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionItem})
|
|
case "4":
|
|
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionFlee})
|
|
case "5":
|
|
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionWait})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
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')
|
|
m.session.BuyItem(m.playerName, idx)
|
|
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 {
|
|
switch key.String() {
|
|
case "enter":
|
|
m.screen = screenLobby
|
|
m.refreshLobbyState()
|
|
case "q", "ctrl+c":
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) refreshLobbyState() {
|
|
if m.lobby == nil {
|
|
return
|
|
}
|
|
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"
|
|
}
|
|
m.lobbyState.rooms[i] = roomInfo{
|
|
Code: r.Code,
|
|
Name: r.Name,
|
|
Players: len(r.Players),
|
|
Status: status,
|
|
}
|
|
}
|
|
m.lobbyState.cursor = 0
|
|
}
|