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:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
data/
|
||||||
|
catacombs
|
||||||
|
catacombs.exe
|
||||||
|
.ssh/
|
||||||
26
main.go
26
main.go
@@ -2,12 +2,36 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/tolelom/catacombs/game"
|
||||||
"github.com/tolelom/catacombs/server"
|
"github.com/tolelom/catacombs/server"
|
||||||
|
"github.com/tolelom/catacombs/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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.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...")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import (
|
|||||||
"github.com/charmbracelet/wish/bubbletea"
|
"github.com/charmbracelet/wish/bubbletea"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
gossh "golang.org/x/crypto/ssh"
|
gossh "golang.org/x/crypto/ssh"
|
||||||
|
"github.com/tolelom/catacombs/game"
|
||||||
|
"github.com/tolelom/catacombs/store"
|
||||||
"github.com/tolelom/catacombs/ui"
|
"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(
|
s, err := wish.NewServer(
|
||||||
wish.WithAddress(fmt.Sprintf("%s:%d", host, port)),
|
wish.WithAddress(fmt.Sprintf("%s:%d", host, port)),
|
||||||
wish.WithHostKeyPath(".ssh/catacombs_host_key"),
|
wish.WithHostKeyPath(".ssh/catacombs_host_key"),
|
||||||
@@ -26,7 +28,7 @@ func Start(host string, port int) error {
|
|||||||
if s.PublicKey() != nil {
|
if s.PublicKey() != nil {
|
||||||
fingerprint = gossh.FingerprintSHA256(s.PublicKey())
|
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()}
|
return m, []tea.ProgramOption{tea.WithAltScreen()}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
61
ui/class_view.go
Normal file
61
ui/class_view.go
Normal 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
133
ui/game_view.go
Normal 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
56
ui/lobby_view.go
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
261
ui/model.go
261
ui/model.go
@@ -1,28 +1,58 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/tolelom/catacombs/entity"
|
||||||
|
"github.com/tolelom/catacombs/game"
|
||||||
|
"github.com/tolelom/catacombs/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type screen int
|
type screen int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
screenTitle screen = iota
|
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 {
|
type Model struct {
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
fingerprint string
|
fingerprint string
|
||||||
|
playerName string
|
||||||
screen screen
|
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{
|
return Model{
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
fingerprint: fingerprint,
|
fingerprint: fingerprint,
|
||||||
screen: screenTitle,
|
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) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
|
||||||
if msg.String() == "q" || msg.String() == "ctrl+c" {
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
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
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) View() string {
|
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
40
ui/result_view.go
Normal 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
30
ui/shop_view.go
Normal 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
37
ui/title.go
Normal 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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user