feat: TUI views, full state machine, and server integration

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>
This commit is contained in:
2026-03-24 00:11:56 +09:00
parent 15956efb18
commit 4e76e48588
10 changed files with 646 additions and 10 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
data/
catacombs
catacombs.exe
.ssh/

26
main.go
View File

@@ -2,12 +2,36 @@ package main
import (
"log"
"os"
"os/signal"
"syscall"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/server"
"github.com/tolelom/catacombs/store"
)
func main() {
if err := server.Start("0.0.0.0", 2222); err != nil {
os.MkdirAll("data", 0755)
db, err := store.Open("data/catacombs.db")
if err != nil {
log.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
lobby := game.NewLobby()
go func() {
if err := server.Start("0.0.0.0", 2222, lobby, db); err != nil {
log.Fatal(err)
}
}()
log.Println("Catacombs server running on :2222")
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
<-c
log.Println("Shutting down...")
}

View File

@@ -9,10 +9,12 @@ import (
"github.com/charmbracelet/wish/bubbletea"
tea "github.com/charmbracelet/bubbletea"
gossh "golang.org/x/crypto/ssh"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/store"
"github.com/tolelom/catacombs/ui"
)
func Start(host string, port int) error {
func Start(host string, port int, lobby *game.Lobby, db *store.DB) error {
s, err := wish.NewServer(
wish.WithAddress(fmt.Sprintf("%s:%d", host, port)),
wish.WithHostKeyPath(".ssh/catacombs_host_key"),
@@ -26,7 +28,7 @@ func Start(host string, port int) error {
if s.PublicKey() != nil {
fingerprint = gossh.FingerprintSHA256(s.PublicKey())
}
m := ui.NewModel(pty.Window.Width, pty.Window.Height, fingerprint)
m := ui.NewModel(pty.Window.Width, pty.Window.Height, fingerprint, lobby, db)
return m, []tea.ProgramOption{tea.WithAltScreen()}
}),
),

61
ui/class_view.go Normal file
View File

@@ -0,0 +1,61 @@
package ui
import (
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/entity"
)
type classSelectState struct {
cursor int
}
var classOptions = []struct {
class entity.Class
name string
desc string
}{
{entity.ClassWarrior, "Warrior", "HP:120 ATK:12 DEF:8 Skill: Taunt (draw enemy fire)"},
{entity.ClassMage, "Mage", "HP:70 ATK:20 DEF:3 Skill: Fireball (AoE damage)"},
{entity.ClassHealer, "Healer", "HP:90 ATK:8 DEF:5 Skill: Heal (restore 30 HP)"},
{entity.ClassRogue, "Rogue", "HP:85 ATK:15 DEF:4 Skill: Scout (reveal rooms)"},
}
func renderClassSelect(state classSelectState, width, height int) string {
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("205")).
Bold(true)
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("46")).
Bold(true)
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("255"))
descStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240"))
header := headerStyle.Render("── Choose Your Class ──")
list := ""
for i, opt := range classOptions {
marker := " "
style := normalStyle
if i == state.cursor {
marker = "> "
style = selectedStyle
}
list += fmt.Sprintf("%s%s\n %s\n\n",
marker, style.Render(opt.name), descStyle.Render(opt.desc))
}
menu := "[Up/Down] Select [Enter] Confirm"
return lipgloss.JoinVertical(lipgloss.Left,
header,
"",
list,
menu,
)
}

133
ui/game_view.go Normal file
View File

@@ -0,0 +1,133 @@
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/dungeon"
"github.com/tolelom/catacombs/game"
)
func renderGame(state game.GameState, width, height int) string {
mapView := renderMap(state.Floor)
hudView := renderHUD(state)
return lipgloss.JoinVertical(lipgloss.Left,
mapView,
hudView,
)
}
func renderMap(floor *dungeon.Floor) string {
if floor == nil {
return ""
}
var sb strings.Builder
headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
sb.WriteString(headerStyle.Render(fmt.Sprintf("── Catacombs B%d ──", floor.Number)))
sb.WriteString("\n\n")
roomStyle := lipgloss.NewStyle().Border(lipgloss.RoundedBorder())
dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
hiddenStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
for i, room := range floor.Rooms {
vis := dungeon.GetRoomVisibility(floor, i)
symbol := roomTypeSymbol(room.Type)
label := fmt.Sprintf("[%d] %s %s", i, symbol, room.Type.String())
if i == floor.CurrentRoom {
label = ">> " + label + " <<"
}
switch vis {
case dungeon.Visible:
sb.WriteString(roomStyle.Render(label))
case dungeon.Visited:
sb.WriteString(dimStyle.Render(label))
case dungeon.Hidden:
sb.WriteString(hiddenStyle.Render("[?] ???"))
}
// Show connections
for _, n := range room.Neighbors {
if n > i {
sb.WriteString(" ─── ")
}
}
sb.WriteString("\n")
}
return sb.String()
}
func renderHUD(state game.GameState) string {
var sb strings.Builder
border := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
Padding(0, 1)
for _, p := range state.Players {
hpBar := renderHPBar(p.HP, p.MaxHP, 20)
status := ""
if p.IsDead() {
status = " [DEAD]"
}
sb.WriteString(fmt.Sprintf("%s (%s) %s %d/%d%s Gold: %d\n",
p.Name, p.Class, hpBar, p.HP, p.MaxHP, status, p.Gold))
}
if state.Phase == game.PhaseCombat {
sb.WriteString("\n")
for i, m := range state.Monsters {
if !m.IsDead() {
mhpBar := renderHPBar(m.HP, m.MaxHP, 15)
sb.WriteString(fmt.Sprintf(" [%d] %s %s %d/%d\n", i, m.Name, mhpBar, m.HP, m.MaxHP))
}
}
sb.WriteString("\n[1]Attack [2]Skill [3]Item [4]Flee [5]Wait")
} else if state.Phase == game.PhaseExploring {
sb.WriteString("\nChoose a room to enter (number) or [Q] quit")
}
return border.Render(sb.String())
}
func renderHPBar(current, max, width int) string {
if max == 0 {
return ""
}
filled := current * width / max
if filled < 0 {
filled = 0
}
empty := width - filled
greenStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("46"))
redStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
bar := greenStyle.Render(strings.Repeat("█", filled)) +
redStyle.Render(strings.Repeat("░", empty))
return bar
}
func roomTypeSymbol(rt dungeon.RoomType) string {
switch rt {
case dungeon.RoomCombat:
return "D"
case dungeon.RoomTreasure:
return "$"
case dungeon.RoomShop:
return "S"
case dungeon.RoomEvent:
return "?"
case dungeon.RoomEmpty:
return "."
case dungeon.RoomBoss:
return "B"
default:
return " "
}
}

56
ui/lobby_view.go Normal file
View File

@@ -0,0 +1,56 @@
package ui
import (
"fmt"
"github.com/charmbracelet/lipgloss"
)
type lobbyState struct {
rooms []roomInfo
input string
cursor int
creating bool
roomName string
}
type roomInfo struct {
Code string
Name string
Players int
Status string
}
func renderLobby(state lobbyState, width, height int) string {
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("205")).
Bold(true)
roomStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
Padding(0, 1)
header := headerStyle.Render("── Lobby ──")
menu := "[C] Create Room [J] Join by Code [Q] Back"
roomList := ""
for i, r := range state.rooms {
marker := " "
if i == state.cursor {
marker = "> "
}
roomList += fmt.Sprintf("%s%s [%s] (%d/4) %s\n",
marker, r.Name, r.Code, r.Players, r.Status)
}
if roomList == "" {
roomList = " No rooms available. Create one!"
}
return lipgloss.JoinVertical(lipgloss.Left,
header,
"",
roomStyle.Render(roomList),
"",
menu,
)
}

View File

@@ -1,28 +1,58 @@
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) Model {
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,
}
}
@@ -32,17 +62,236 @@ func (m Model) Init() tea.Cmd {
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" || msg.String() == "ctrl+c" {
return m, tea.Quit
}
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 {
return "Welcome to Catacombs!\n\nPress q to quit."
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
}

40
ui/result_view.go Normal file
View File

@@ -0,0 +1,40 @@
package ui
import (
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/store"
)
func renderResult(won bool, floorReached int, rankings []store.RunRecord) string {
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205"))
var title string
if won {
title = titleStyle.Render("VICTORY! You escaped the Catacombs!")
} else {
title = titleStyle.Render("GAME OVER")
}
floorInfo := fmt.Sprintf("Floor Reached: B%d", floorReached)
rankHeader := lipgloss.NewStyle().Bold(true).Render("── Rankings ──")
rankList := ""
for i, r := range rankings {
rankList += fmt.Sprintf(" %d. %s — B%d (Score: %d)\n", i+1, r.Player, r.Floor, r.Score)
}
menu := "[Enter] Return to Lobby [Q] Quit"
return lipgloss.JoinVertical(lipgloss.Center,
title,
"",
floorInfo,
"",
rankHeader,
rankList,
"",
menu,
)
}

30
ui/shop_view.go Normal file
View File

@@ -0,0 +1,30 @@
package ui
import (
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/game"
)
func renderShop(state game.GameState, width, height int) string {
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true)
header := headerStyle.Render("── Shop ──")
items := ""
for i, item := range state.ShopItems {
items += fmt.Sprintf(" [%d] %s (+%d) — %d gold\n", i+1, item.Name, item.Bonus, item.Price)
}
menu := "[1-3] Buy [Q] Leave Shop"
return lipgloss.JoinVertical(lipgloss.Left,
header,
"",
items,
"",
menu,
)
}

37
ui/title.go Normal file
View File

@@ -0,0 +1,37 @@
package ui
import (
"github.com/charmbracelet/lipgloss"
)
var titleArt = `
██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ███╗ ███╗██████╗ ███████╗
██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██╔════╝██╔═══██╗████╗ ████║██╔══██╗██╔════╝
██║ ███████║ ██║ ███████║██║ ██║ ██║██╔████╔██║██████╔╝███████╗
██║ ██╔══██║ ██║ ██╔══██║██║ ██║ ██║██║╚██╔╝██║██╔══██╗╚════██║
╚██████╗██║ ██║ ██║ ██║ ██║╚██████╗╚██████╔╝██║ ╚═╝ ██║██████╔╝███████║
╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚══════╝
`
func renderTitle(width, height int) string {
titleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("205")).
Bold(true).
Align(lipgloss.Center)
subtitleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Align(lipgloss.Center)
menuStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Align(lipgloss.Center)
return lipgloss.JoinVertical(lipgloss.Center,
titleStyle.Render(titleArt),
"",
subtitleStyle.Render("A Co-op Roguelike Adventure"),
"",
menuStyle.Render("[Enter] Start [Q] Quit"),
)
}