diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e925469 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +data/ +catacombs +catacombs.exe +.ssh/ diff --git a/main.go b/main.go index 992bdf6..adba40b 100644 --- a/main.go +++ b/main.go @@ -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 { - log.Fatal(err) + 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...") } diff --git a/server/ssh.go b/server/ssh.go index 9f63b14..746c4e1 100644 --- a/server/ssh.go +++ b/server/ssh.go @@ -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()} }), ), diff --git a/ui/class_view.go b/ui/class_view.go new file mode 100644 index 0000000..d1b4552 --- /dev/null +++ b/ui/class_view.go @@ -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, + ) +} diff --git a/ui/game_view.go b/ui/game_view.go new file mode 100644 index 0000000..8ea5c8d --- /dev/null +++ b/ui/game_view.go @@ -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 " " + } +} diff --git a/ui/lobby_view.go b/ui/lobby_view.go new file mode 100644 index 0000000..3c53a13 --- /dev/null +++ b/ui/lobby_view.go @@ -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, + ) +} diff --git a/ui/model.go b/ui/model.go index 2d6fb68..7d30f7f 100644 --- a/ui/model.go +++ b/ui/model.go @@ -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 } diff --git a/ui/result_view.go b/ui/result_view.go new file mode 100644 index 0000000..dc35fd9 --- /dev/null +++ b/ui/result_view.go @@ -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, + ) +} diff --git a/ui/shop_view.go b/ui/shop_view.go new file mode 100644 index 0000000..31760b0 --- /dev/null +++ b/ui/shop_view.go @@ -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, + ) +} diff --git a/ui/title.go b/ui/title.go new file mode 100644 index 0000000..53895b0 --- /dev/null +++ b/ui/title.go @@ -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"), + ) +}