Compare commits

...

10 Commits

Author SHA1 Message Date
f2ac4dbded feat: arrow-key room navigation, neighbor visibility, map UX improvements
- Exploration uses Up/Down + Enter instead of number keys
- Adjacent rooms shown with cursor selection in HUD
- Neighboring rooms visible on fog of war map
- Room numbers displayed on tile map with type-colored markers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 01:04:08 +09:00
26784479b7 feat: BSP dungeon generation with 2D ASCII tile map
Replace list-based room display with proper 2D tile map using Binary
Space Partitioning. Rooms are carved into a 60x20 grid, connected by
L-shaped corridors, and rendered with ANSI-colored ASCII art including
fog of war visibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 00:57:16 +09:00
92741d415d feat: target selection with Tab, individual flee in solo
- Add targetCursor to Model; Tab cycles through monsters in combat
- Pass targetCursor to renderGame/renderHUD; show "> " marker on selected enemy
- Attack and Skill actions use targetCursor as TargetIdx
- Update action bar to show [Tab]Target hint
- Replace unanimous-flee block with per-player flee; solo mode transitions to PhaseExploring immediately on success

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:53:40 +09:00
7556073cb5 feat: display turn countdown timer in combat HUD
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:53:17 +09:00
a1e9e0ef68 feat: lobby join-by-code with J key and 4-char input
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:52:58 +09:00
6809e49226 fix: remove debug, save rankings, block dead actions, save nickname
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:51:04 +09:00
743b5b9058 fix: prevent double StartGame, use overlapping monster floor ranges 2026-03-24 00:51:00 +09:00
3cc6f783b3 feat: apply relic passive effects (ATK/DEF boost, heal on kill, gold boost) 2026-03-24 00:50:42 +09:00
ecf6ee64d0 Add fix plan for spec compliance issues
7 tasks: quick fixes, relic effects, target selection, turn timer,
StartGame race fix, BSP tile map, lobby join-by-code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 00:48:58 +09:00
4e76e48588 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>
2026-03-24 00:11:56 +09:00
23 changed files with 2303 additions and 81 deletions

4
.gitignore vendored Normal file
View File

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

BIN
catacombs.exe~ Normal file

Binary file not shown.

View File

@@ -0,0 +1,652 @@
# Catacombs Bug Fixes & Missing Features Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix critical bugs and implement missing features identified in the spec-vs-implementation review.
**Architecture:** Incremental fixes to existing codebase. No major restructuring — patch what's broken, add what's missing. Each task is independently shippable.
**Tech Stack:** Go 1.22+, charmbracelet/wish, charmbracelet/bubbletea, charmbracelet/lipgloss, go.etcd.io/bbolt
**Spec:** `docs/superpowers/specs/2026-03-23-catacombs-design.md`
**Review:** See "Critical Issues Summary" in conversation history.
**Go binary path:** `/c/Users/98kim/sdk/go1.25.1/bin/go.exe` (not in PATH)
---
## File Map (files touched in this plan)
```
ui/model.go — remove debug, add nickname input, target selection, dead player guard, save ranking
ui/game_view.go — turn timer display, target selection indicator
game/session.go — fix StartGame race, add TurnTimeLeft to GameState, relic effects
game/turn.go — relic effect application on kill, individual flee
game/event.go — overlapping monster floor ranges
entity/player.go — relic-aware EffectiveATK/EffectiveDEF
dungeon/generator.go — BSP dungeon generation with 2D tile map
dungeon/room.go — tile-based room rendering data
dungeon/render.go — NEW: ASCII tile map renderer
store/db.go — no changes needed (SaveRun already exists)
```
---
### Task 1: Quick Fixes (debug removal, ranking save, nickname, dead player guard)
**Files:**
- Modify: `ui/model.go`
- [ ] **Step 1: Remove debug lines from View()**
Remove the `debug` variable and all `debug +` prefixes from `View()`. Remove `lastKey` field from Model struct.
- [ ] **Step 2: Save ranking on game over**
In `updateGame`, when `m.gameState.GameOver` is detected and screen transitions to result, call:
```go
if m.store != nil {
score := 0
for _, p := range m.gameState.Players {
score += p.Gold
}
m.store.SaveRun(m.playerName, m.gameState.FloorNum, score)
}
```
- [ ] **Step 3: Block dead player actions**
In `updateGame` combat key handling, add guard:
```go
// Find this player and check if dead
isPlayerDead := false
for _, p := range m.gameState.Players {
if p.Name == m.playerName && p.IsDead() {
isPlayerDead = true
break
}
}
if isPlayerDead {
return m, m.pollState() // spectator mode: just keep watching
}
```
- [ ] **Step 4: Save nickname on first connection**
In `updateTitle`, after setting `m.playerName = "Adventurer"` (when profile not found), add:
```go
if m.fingerprint != "" {
m.store.SaveProfile(m.fingerprint, m.playerName)
}
```
- [ ] **Step 5: Build and run tests**
```bash
/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./...
/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./ui/ -v -timeout 15s
```
- [ ] **Step 6: Commit**
```bash
git add ui/model.go
git commit -m "fix: remove debug, save rankings, block dead actions, save nickname"
```
---
### Task 2: Relic Effects
**Files:**
- Modify: `entity/player.go`, `game/turn.go`
- Test: `entity/player_test.go`
- [ ] **Step 1: Write relic effect test**
Add to `entity/player_test.go`:
```go
func TestRelicEffects(t *testing.T) {
p := NewPlayer("test", ClassWarrior)
p.Relics = append(p.Relics, Relic{Name: "Power Amulet", Effect: RelicATKBoost, Value: 3})
if p.EffectiveATK() != 15 { // 12 base + 3 relic
t.Errorf("ATK with relic: got %d, want 15", p.EffectiveATK())
}
p.Relics = append(p.Relics, Relic{Name: "Iron Ward", Effect: RelicDEFBoost, Value: 2})
if p.EffectiveDEF() != 10 { // 8 base + 2 relic
t.Errorf("DEF with relic: got %d, want 10", p.EffectiveDEF())
}
}
```
- [ ] **Step 2: Run test (should fail)**
```bash
/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./entity/ -run TestRelicEffects -v
```
- [ ] **Step 3: Add relic bonuses to EffectiveATK/EffectiveDEF**
In `entity/player.go`, update `EffectiveATK()`:
```go
func (p *Player) EffectiveATK() int {
atk := p.ATK
for _, item := range p.Inventory {
if item.Type == ItemWeapon {
atk += item.Bonus
}
}
for _, r := range p.Relics {
if r.Effect == RelicATKBoost {
atk += r.Value
}
}
return atk
}
```
Update `EffectiveDEF()` similarly for `RelicDEFBoost`.
- [ ] **Step 4: Add HealOnKill relic trigger in turn.go**
In `resolvePlayerActions()`, in the "Award gold" loop where `m.IsDead() && aliveBeforeTurn[i]`, add:
```go
// Apply RelicHealOnKill
for _, p := range s.state.Players {
if !p.IsDead() {
for _, r := range p.Relics {
if r.Effect == entity.RelicHealOnKill {
p.Heal(r.Value)
}
}
}
}
```
And in the gold award loop, apply `RelicGoldBoost`:
```go
for _, p := range s.state.Players {
if !p.IsDead() {
bonus := 0
for _, r := range p.Relics {
if r.Effect == entity.RelicGoldBoost {
bonus += r.Value
}
}
p.Gold += goldReward + bonus
}
}
```
- [ ] **Step 5: Run all tests**
```bash
/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./entity/ ./game/ -v -timeout 15s
```
- [ ] **Step 6: Commit**
```bash
git add entity/player.go entity/player_test.go game/turn.go
git commit -m "feat: apply relic passive effects (ATK/DEF boost, heal on kill, gold boost)"
```
---
### Task 3: Combat Target Selection & Individual Flee
**Files:**
- Modify: `ui/model.go`, `ui/game_view.go`, `game/turn.go`
- [ ] **Step 1: Add target cursor to Model**
In `ui/model.go`, add field to Model:
```go
targetCursor int // selected enemy index during combat
```
- [ ] **Step 2: Add target selection keys**
In `updateGame` combat phase, add left/right or tab for target cycling:
```go
case "tab":
if len(m.gameState.Monsters) > 0 {
m.targetCursor = (m.targetCursor + 1) % len(m.gameState.Monsters)
}
```
Update attack/skill to use `m.targetCursor`:
```go
case "1":
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionAttack, TargetIdx: m.targetCursor})
```
- [ ] **Step 3: Show target indicator in HUD**
In `ui/game_view.go` `renderHUD`, add arrow marker to selected target:
```go
marker := " "
if i == targetCursor {
marker = "> "
}
sb.WriteString(enemyStyle.Render(fmt.Sprintf("%s[%d] %s ...", marker, i, m.Name)))
```
Pass `targetCursor` through `renderGame` and `renderHUD` (add parameter or embed in state).
- [ ] **Step 4: Change flee to individual**
In `game/turn.go` `resolvePlayerActions`, replace the unanimous flee block with individual flee:
```go
// Remove the fleeCount/aliveCount block entirely.
// In the per-player action switch, change ActionFlee to:
case ActionFlee:
if combat.AttemptFlee() {
s.addLog(fmt.Sprintf("%s fled from battle!", p.Name))
// In solo, end combat
if s.state.SoloMode {
s.state.Phase = PhaseExploring
return
}
// In multiplayer, mark player as fled (treat as dead for this combat)
// For simplicity: solo flee ends combat, multiplayer flee wastes turn
} else {
s.addLog(fmt.Sprintf("%s failed to flee!", p.Name))
}
```
- [ ] **Step 5: Add [Tab] hint to action bar**
In `ui/game_view.go`, update the action line:
```go
sb.WriteString(actionStyle.Render("[1]Attack [2]Skill [3]Item [4]Flee [5]Wait [Tab]Target"))
```
- [ ] **Step 6: Build and test**
```bash
/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./...
/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./... -timeout 15s
```
- [ ] **Step 7: Commit**
```bash
git add ui/model.go ui/game_view.go game/turn.go
git commit -m "feat: target selection with Tab, individual flee in solo"
```
---
### Task 4: Turn Timer Display
**Files:**
- Modify: `game/session.go`, `game/turn.go`, `ui/game_view.go`
- [ ] **Step 1: Add TurnDeadline to GameState**
In `game/session.go` `GameState` struct:
```go
TurnDeadline time.Time // when current turn times out
```
Import `"time"` in session.go.
- [ ] **Step 2: Set deadline in RunTurn**
In `game/turn.go` `RunTurn()`, after creating the timer:
```go
s.state.TurnDeadline = time.Now().Add(TurnTimeout)
```
After turn resolves (in resolve section):
```go
s.state.TurnDeadline = time.Time{} // clear deadline
```
- [ ] **Step 3: Display countdown in HUD**
In `ui/game_view.go` `renderHUD`, when in combat phase:
```go
if !state.TurnDeadline.IsZero() {
remaining := time.Until(state.TurnDeadline)
if remaining < 0 {
remaining = 0
}
timerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)
sb.WriteString(timerStyle.Render(fmt.Sprintf(" Timer: %.1fs", remaining.Seconds())))
sb.WriteString("\n")
}
```
Import `"time"` in game_view.go.
- [ ] **Step 4: Build and test**
```bash
/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./...
```
- [ ] **Step 5: Commit**
```bash
git add game/session.go game/turn.go ui/game_view.go
git commit -m "feat: display turn countdown timer in combat HUD"
```
---
### Task 5: Fix StartGame Race & Overlapping Monster Ranges
**Files:**
- Modify: `game/session.go`, `game/event.go`
- [ ] **Step 1: Fix StartGame to only run once**
Add `started bool` field to `GameSession`:
```go
type GameSession struct {
// ...
started bool
}
```
Guard `StartGame()`:
```go
func (s *GameSession) StartGame() {
s.mu.Lock()
if s.started {
s.mu.Unlock()
return
}
s.started = true
s.state.SoloMode = len(s.state.Players) == 1
s.mu.Unlock()
s.StartFloor()
go s.combatLoop()
}
```
- [ ] **Step 2: Fix monster floor ranges to overlap**
In `game/event.go` `spawnMonsters`, replace the exclusive switch with weighted random selection from valid types:
```go
func (s *GameSession) spawnMonsters() {
count := 1 + rand.Intn(5)
floor := s.state.FloorNum
s.state.Monsters = make([]*entity.Monster, count)
// Build list of valid monster types for this floor
type floorRange struct {
mt entity.MonsterType
minFloor int
maxFloor int
}
ranges := []floorRange{
{entity.MonsterSlime, 1, 5},
{entity.MonsterSkeleton, 3, 10},
{entity.MonsterOrc, 6, 14},
{entity.MonsterDarkKnight, 12, 20},
}
var valid []entity.MonsterType
for _, r := range ranges {
if floor >= r.minFloor && floor <= r.maxFloor {
valid = append(valid, r.mt)
}
}
if len(valid) == 0 {
valid = []entity.MonsterType{entity.MonsterSlime}
}
for i := 0; i < count; i++ {
mt := valid[rand.Intn(len(valid))]
m := entity.NewMonster(mt, floor)
if s.state.SoloMode {
m.HP = m.HP / 2
if m.HP < 1 {
m.HP = 1
}
m.MaxHP = m.HP
}
s.state.Monsters[i] = m
}
}
```
- [ ] **Step 3: Build and test**
```bash
/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./...
/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./game/ -v -timeout 15s
```
- [ ] **Step 4: Commit**
```bash
git add game/session.go game/event.go
git commit -m "fix: prevent double StartGame, use overlapping monster floor ranges"
```
---
### Task 6: BSP Dungeon with 2D Tile Map
**Files:**
- Modify: `dungeon/generator.go`, `dungeon/room.go`
- Create: `dungeon/render.go`
- Modify: `ui/game_view.go`
- Test: `dungeon/generator_test.go`
This is the biggest task. The goal is to replace the list-based room display with a proper 2D ASCII map.
- [ ] **Step 1: Update Room struct for tile coordinates**
In `dungeon/room.go`, ensure Room has proper tile-space bounds:
```go
type Room struct {
Type RoomType
X, Y int // top-left corner in tile space
W, H int // width, height in tiles
CenterX int
CenterY int
Visited bool
Cleared bool
Neighbors []int
}
```
- [ ] **Step 2: Rewrite BSP generator**
Replace `dungeon/generator.go` with proper BSP:
```go
const (
MapWidth = 60
MapHeight = 20
MinRoomW = 8
MinRoomH = 5
)
type bspNode struct {
x, y, w, h int
left, right *bspNode
room *Room
}
func GenerateFloor(floorNum int) *Floor {
root := &bspNode{x: 0, y: 0, w: MapWidth, h: MapHeight}
splitBSP(root, 0)
var rooms []*Room
collectRooms(root, &rooms)
// Ensure 5-8 rooms
for len(rooms) < 5 {
// Re-generate if too few
root = &bspNode{x: 0, y: 0, w: MapWidth, h: MapHeight}
splitBSP(root, 0)
rooms = nil
collectRooms(root, &rooms)
}
if len(rooms) > 8 {
rooms = rooms[:8]
}
// Assign room types
for i := range rooms {
rooms[i].Type = RandomRoomType()
}
rooms[len(rooms)-1].Type = RoomBoss
// Connect rooms (sibling nodes + extra)
connectBSP(root, rooms)
// Build tile map
tiles := buildTileMap(rooms)
return &Floor{
Number: floorNum,
Rooms: rooms,
CurrentRoom: 0,
Tiles: tiles,
Width: MapWidth,
Height: MapHeight,
}
}
```
Add `Tiles [][]Tile`, `Width`, `Height` fields to `Floor` struct.
Define `Tile` type:
```go
type Tile int
const (
TileWall Tile = iota
TileFloor
TileCorridor
TileDoor
)
```
Implement `splitBSP`, `collectRooms`, `connectBSP`, `buildTileMap` functions.
- [ ] **Step 3: Create tile map renderer**
Create `dungeon/render.go`:
```go
func RenderFloor(floor *Floor, showFog bool) string {
// Render tiles as ASCII: '#' wall, '.' floor, '+' door, '#' corridor
// Mark current room's content
// Apply fog of war: hidden rooms render as space, visited as dim
}
```
Characters:
- `#` wall
- `.` floor
- `+` door / corridor
- `@` player position (center of current room)
- `D` monster (in combat rooms)
- `$` treasure, `S` shop, `?` event, `B` boss
- [ ] **Step 4: Update game_view.go to use tile renderer**
Replace `renderMap()` in `ui/game_view.go`:
```go
func renderMap(floor *dungeon.Floor) string {
return dungeon.RenderFloor(floor, true)
}
```
- [ ] **Step 5: Update generator_test.go**
Update existing tests to work with new generator. Verify:
- 5-8 rooms
- Exactly 1 boss room
- All rooms connected
- Tile map dimensions are MapWidth x MapHeight
- Player start room (index 0) has floor tiles
- [ ] **Step 6: Build and test**
```bash
/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./...
/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./dungeon/ -v
```
- [ ] **Step 7: Commit**
```bash
git add dungeon/ ui/game_view.go
git commit -m "feat: BSP dungeon generation with 2D ASCII tile map rendering"
```
---
### Task 7: Lobby Join-by-Code
**Files:**
- Modify: `ui/model.go`, `ui/lobby_view.go`
- [ ] **Step 1: Add join-by-code input mode to lobby**
In `ui/model.go`, add to `lobbyState`:
```go
type lobbyState struct {
rooms []roomInfo
input string
cursor int
joining bool // true when typing a code
codeInput string // accumulated code chars
}
```
- [ ] **Step 2: Handle 'j' key and code input**
In `updateLobby`:
```go
if m.lobbyState.joining {
if isEnter(key) && len(m.lobbyState.codeInput) == 4 {
if err := m.lobby.JoinRoom(m.lobbyState.codeInput, m.playerName); err == nil {
m.roomCode = m.lobbyState.codeInput
m.screen = screenClassSelect
}
m.lobbyState.joining = false
m.lobbyState.codeInput = ""
} else if isKey(key, "esc") {
m.lobbyState.joining = false
m.lobbyState.codeInput = ""
} else if len(key.String()) == 1 && len(m.lobbyState.codeInput) < 4 {
m.lobbyState.codeInput += strings.ToUpper(key.String())
}
return m, nil
}
// existing key handling...
if isKey(key, "j") {
m.lobbyState.joining = true
m.lobbyState.codeInput = ""
}
```
- [ ] **Step 3: Show code input in lobby view**
In `ui/lobby_view.go` `renderLobby`, add:
```go
if state.joining {
inputStr := state.codeInput + strings.Repeat("_", 4-len(state.codeInput))
sb.WriteString(fmt.Sprintf("\nEnter room code: [%s] (Esc to cancel)", inputStr))
}
```
- [ ] **Step 4: Build**
```bash
/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./...
```
- [ ] **Step 5: Commit**
```bash
git add ui/model.go ui/lobby_view.go
git commit -m "feat: lobby join-by-code with J key and 4-char input"
```

View File

@@ -23,5 +23,13 @@ func GetRoomVisibility(floor *Floor, roomIdx int) Visibility {
if floor.Rooms[roomIdx].Visited {
return Visited
}
// Neighbors of current room are dimly visible (so player can see where to go)
if floor.CurrentRoom >= 0 && floor.CurrentRoom < len(floor.Rooms) {
for _, n := range floor.Rooms[floor.CurrentRoom].Neighbors {
if n == roomIdx {
return Visited
}
}
}
return Hidden
}

View File

@@ -2,41 +2,252 @@ package dungeon
import "math/rand"
type Floor struct {
Number int
Rooms []*Room
CurrentRoom int
const (
MapWidth = 60
MapHeight = 20
MinLeafW = 12
MinLeafH = 8
MinRoomW = 6
MinRoomH = 4
RoomPad = 1
)
type bspNode struct {
x, y, w, h int
left, right *bspNode
room *Room
roomIdx int
}
func GenerateFloor(floorNum int) *Floor {
numRooms := 5 + rand.Intn(4)
rooms := make([]*Room, numRooms)
for i := 0; i < numRooms; i++ {
// Create tile map filled with walls
tiles := make([][]Tile, MapHeight)
for y := 0; y < MapHeight; y++ {
tiles[y] = make([]Tile, MapWidth)
// TileWall is 0, so already initialized
}
// BSP tree
root := &bspNode{x: 0, y: 0, w: MapWidth, h: MapHeight}
splitBSP(root, 0)
// Collect leaf nodes
var leaves []*bspNode
collectLeaves(root, &leaves)
// Shuffle leaves so room assignment is varied
rand.Shuffle(len(leaves), func(i, j int) {
leaves[i], leaves[j] = leaves[j], leaves[i]
})
// We want 5-8 rooms. If we have more leaves, merge some; if fewer, accept it.
// Ensure at least 5 leaves by re-generating if needed (BSP should produce enough).
// Cap at 8 rooms.
targetRooms := 5 + rand.Intn(4) // 5..8
if len(leaves) > targetRooms {
leaves = leaves[:targetRooms]
}
// If we somehow have fewer than 5, that's fine — the BSP with 60x20 and min 12x8 gives ~5-8 naturally.
// Place rooms inside each leaf
rooms := make([]*Room, len(leaves))
for i, leaf := range leaves {
// Room with padding inside the leaf
maxW := leaf.w - 2*RoomPad
maxH := leaf.h - 2*RoomPad
if maxW < MinRoomW {
maxW = MinRoomW
}
if maxH < MinRoomH {
maxH = MinRoomH
}
rw := MinRoomW
if maxW > MinRoomW {
rw = MinRoomW + rand.Intn(maxW-MinRoomW+1)
}
rh := MinRoomH
if maxH > MinRoomH {
rh = MinRoomH + rand.Intn(maxH-MinRoomH+1)
}
// Position room within the leaf
rx := leaf.x + RoomPad
if leaf.w-2*RoomPad > rw {
rx += rand.Intn(leaf.w - 2*RoomPad - rw + 1)
}
ry := leaf.y + RoomPad
if leaf.h-2*RoomPad > rh {
ry += rand.Intn(leaf.h - 2*RoomPad - rh + 1)
}
// Clamp to map bounds
if rx+rw > MapWidth-1 {
rw = MapWidth - 1 - rx
}
if ry+rh > MapHeight-1 {
rh = MapHeight - 1 - ry
}
if rx < 1 {
rx = 1
}
if ry < 1 {
ry = 1
}
rt := RandomRoomType()
rooms[i] = &Room{
Type: rt,
X: (i % 3) * 20,
Y: (i / 3) * 10,
Width: 12 + rand.Intn(6),
Height: 6 + rand.Intn(4),
X: rx,
Y: ry,
W: rw,
H: rh,
Neighbors: []int{},
}
leaf.room = rooms[i]
leaf.roomIdx = i
}
rooms[numRooms-1].Type = RoomBoss
for i := 0; i < numRooms-1; i++ {
// Last room is boss
rooms[len(rooms)-1].Type = RoomBoss
// Carve rooms into tile map
for _, room := range rooms {
for dy := 0; dy < room.H; dy++ {
for dx := 0; dx < room.W; dx++ {
ty := room.Y + dy
tx := room.X + dx
if ty >= 0 && ty < MapHeight && tx >= 0 && tx < MapWidth {
tiles[ty][tx] = TileFloor
}
}
}
}
// Connect rooms: linear chain for guaranteed connectivity
for i := 0; i < len(rooms)-1; i++ {
rooms[i].Neighbors = append(rooms[i].Neighbors, i+1)
rooms[i+1].Neighbors = append(rooms[i+1].Neighbors, i)
carveCorridor(tiles, rooms[i], rooms[i+1])
}
// Add 1-2 extra connections
extras := 1 + rand.Intn(2)
for e := 0; e < extras; e++ {
a := rand.Intn(numRooms)
b := rand.Intn(numRooms)
a := rand.Intn(len(rooms))
b := rand.Intn(len(rooms))
if a != b && !hasNeighbor(rooms[a], b) {
rooms[a].Neighbors = append(rooms[a].Neighbors, b)
rooms[b].Neighbors = append(rooms[b].Neighbors, a)
carveCorridor(tiles, rooms[a], rooms[b])
}
}
return &Floor{
Number: floorNum,
Rooms: rooms,
CurrentRoom: 0,
Tiles: tiles,
Width: MapWidth,
Height: MapHeight,
}
}
func splitBSP(node *bspNode, depth int) {
// Stop conditions
if depth > 4 {
return
}
if node.w < MinLeafW*2 && node.h < MinLeafH*2 {
return
}
// Random chance to stop splitting (more likely at deeper levels)
if depth > 2 && rand.Float64() < 0.3 {
return
}
// Decide split direction
horizontal := rand.Float64() < 0.5
if node.w < MinLeafW*2 {
horizontal = true
}
if node.h < MinLeafH*2 {
horizontal = false
}
if horizontal {
if node.h < MinLeafH*2 {
return
}
split := MinLeafH + rand.Intn(node.h-MinLeafH*2+1)
node.left = &bspNode{x: node.x, y: node.y, w: node.w, h: split}
node.right = &bspNode{x: node.x, y: node.y + split, w: node.w, h: node.h - split}
} else {
if node.w < MinLeafW*2 {
return
}
split := MinLeafW + rand.Intn(node.w-MinLeafW*2+1)
node.left = &bspNode{x: node.x, y: node.y, w: split, h: node.h}
node.right = &bspNode{x: node.x + split, y: node.y, w: node.w - split, h: node.h}
}
splitBSP(node.left, depth+1)
splitBSP(node.right, depth+1)
}
func collectLeaves(node *bspNode, leaves *[]*bspNode) {
if node == nil {
return
}
if node.left == nil && node.right == nil {
*leaves = append(*leaves, node)
return
}
collectLeaves(node.left, leaves)
collectLeaves(node.right, leaves)
}
func carveCorridor(tiles [][]Tile, a, b *Room) {
// L-shaped corridor from center of a to center of b
ax := a.X + a.W/2
ay := a.Y + a.H/2
bx := b.X + b.W/2
by := b.Y + b.H/2
// Go horizontal first, then vertical
x := ax
for x != bx {
if y := ay; y >= 0 && y < MapHeight && x >= 0 && x < MapWidth {
if tiles[y][x] == TileWall {
tiles[y][x] = TileCorridor
}
}
if x < bx {
x++
} else {
x--
}
}
y := ay
for y != by {
if x >= 0 && x < MapWidth && y >= 0 && y < MapHeight {
if tiles[y][x] == TileWall {
tiles[y][x] = TileCorridor
}
}
if y < by {
y++
} else {
y--
}
}
// Place final tile
if bx >= 0 && bx < MapWidth && by >= 0 && by < MapHeight {
if tiles[by][bx] == TileWall {
tiles[by][bx] = TileCorridor
}
}
return &Floor{Number: floorNum, Rooms: rooms, CurrentRoom: 0}
}
func hasNeighbor(r *Room, idx int) bool {

View File

@@ -40,3 +40,19 @@ func TestRoomTypeProbability(t *testing.T) {
t.Errorf("Combat room probability: got %.1f%%, want ~45%%", combatPct)
}
}
func TestFloorHasTileMap(t *testing.T) {
floor := GenerateFloor(1)
if floor.Tiles == nil {
t.Fatal("Floor should have tile map")
}
if floor.Width != 60 || floor.Height != 20 {
t.Errorf("Map size: got %dx%d, want 60x20", floor.Width, floor.Height)
}
// Current room should have floor tiles
room := floor.Rooms[0]
centerTile := floor.Tiles[room.Y+room.H/2][room.X+room.W/2]
if centerTile != TileFloor {
t.Errorf("Room center should be floor tile, got %d", centerTile)
}
}

237
dungeon/render.go Normal file
View File

@@ -0,0 +1,237 @@
package dungeon
import "fmt"
// ANSI color codes
const (
ansiReset = "\033[0m"
ansiBright = "\033[1m"
ansiDim = "\033[2m"
ansiFgWhite = "\033[97m"
ansiFgGray = "\033[90m"
ansiFgGreen = "\033[92m"
ansiFgRed = "\033[91m"
ansiFgBrRed = "\033[1;91m"
ansiFgYellow = "\033[93m"
ansiFgCyan = "\033[96m"
ansiFgMagenta = "\033[95m"
)
// roomOwnership maps each tile coordinate to the room index that contains it (-1 = none).
func roomOwnership(floor *Floor) [][]int {
owner := make([][]int, floor.Height)
for y := 0; y < floor.Height; y++ {
owner[y] = make([]int, floor.Width)
for x := 0; x < floor.Width; x++ {
owner[y][x] = -1
}
}
for i, room := range floor.Rooms {
for dy := 0; dy < room.H; dy++ {
for dx := 0; dx < room.W; dx++ {
ty := room.Y + dy
tx := room.X + dx
if ty >= 0 && ty < floor.Height && tx >= 0 && tx < floor.Width {
owner[ty][tx] = i
}
}
}
}
return owner
}
// corridorVisibility determines if a corridor tile should be visible.
// A corridor is visible if it's adjacent to a visible or visited room.
func corridorVisible(floor *Floor, owner [][]int, x, y int) Visibility {
best := Hidden
// Check neighboring tiles for room ownership
for dy := -1; dy <= 1; dy++ {
for dx := -1; dx <= 1; dx++ {
ny, nx := y+dy, x+dx
if ny >= 0 && ny < floor.Height && nx >= 0 && nx < floor.Width {
ri := owner[ny][nx]
if ri >= 0 {
v := GetRoomVisibility(floor, ri)
if v > best {
best = v
}
}
}
}
}
// Also check along the corridor path: if this corridor connects two rooms,
// it should be visible if either room is visible/visited.
// The adjacency check above handles most cases.
return best
}
// wallVisibility determines if a wall tile should be shown based on adjacent rooms.
func wallVisible(floor *Floor, owner [][]int, x, y int) Visibility {
best := Hidden
for dy := -1; dy <= 1; dy++ {
for dx := -1; dx <= 1; dx++ {
ny, nx := y+dy, x+dx
if ny >= 0 && ny < floor.Height && nx >= 0 && nx < floor.Width {
if floor.Tiles[ny][nx] == TileFloor {
ri := owner[ny][nx]
if ri >= 0 {
v := GetRoomVisibility(floor, ri)
if v > best {
best = v
}
}
}
if floor.Tiles[ny][nx] == TileCorridor {
cv := corridorVisible(floor, owner, nx, ny)
if cv > best {
best = cv
}
}
}
}
}
return best
}
// RenderFloor renders the tile map as a colored ASCII string.
func RenderFloor(floor *Floor, currentRoom int, showFog bool) string {
if floor == nil || floor.Tiles == nil {
return ""
}
owner := roomOwnership(floor)
// Find room centers for content markers
type marker struct {
symbol string
color string
}
markers := make(map[[2]int]marker)
for i, room := range floor.Rooms {
cx := room.X + room.W/2
cy := room.Y + room.H/2
vis := GetRoomVisibility(floor, i)
// Show current room and its neighbors (so player knows where to go)
showRoom := !showFog || vis != Hidden
if !showRoom && currentRoom >= 0 && currentRoom < len(floor.Rooms) {
for _, n := range floor.Rooms[currentRoom].Neighbors {
if n == i {
showRoom = true
break
}
}
}
if showRoom {
sym, col := roomMarker(room, i, i == currentRoom)
markers[[2]int{cy, cx}] = marker{sym, col}
}
}
// Player position at center of current room
var playerPos [2]int
if currentRoom >= 0 && currentRoom < len(floor.Rooms) {
r := floor.Rooms[currentRoom]
playerPos = [2]int{r.Y + r.H/2, r.X + r.W/2}
}
buf := make([]byte, 0, floor.Width*floor.Height*4)
for y := 0; y < floor.Height; y++ {
for x := 0; x < floor.Width; x++ {
tile := floor.Tiles[y][x]
// Determine visibility of this tile
var vis Visibility
if showFog {
switch tile {
case TileFloor:
ri := owner[y][x]
if ri >= 0 {
vis = GetRoomVisibility(floor, ri)
} else {
vis = Hidden
}
case TileCorridor:
vis = corridorVisible(floor, owner, x, y)
case TileWall:
vis = wallVisible(floor, owner, x, y)
default:
vis = Hidden
}
} else {
vis = Visible
}
if vis == Hidden {
buf = append(buf, ' ')
continue
}
// Check for player marker
if y == playerPos[0] && x == playerPos[1] {
buf = append(buf, []byte(fmt.Sprintf("%s%s@%s", ansiBright, ansiFgGreen, ansiReset))...)
continue
}
// Check for room content marker
if m, ok := markers[[2]int{y, x}]; ok {
if vis == Visible {
buf = append(buf, []byte(fmt.Sprintf("%s%s%s", m.color, m.symbol, ansiReset))...)
} else {
buf = append(buf, []byte(fmt.Sprintf("%s%s%s", ansiFgGray, m.symbol, ansiReset))...)
}
continue
}
// Render tile
var ch byte
switch tile {
case TileWall:
ch = '#'
case TileFloor:
ch = '.'
case TileCorridor:
ch = '+'
case TileDoor:
ch = '/'
default:
ch = ' '
}
if vis == Visible {
buf = append(buf, []byte(fmt.Sprintf("%s%s%c%s", ansiBright, ansiFgWhite, ch, ansiReset))...)
} else {
// Visited but not current — dim
buf = append(buf, []byte(fmt.Sprintf("%s%c%s", ansiFgGray, ch, ansiReset))...)
}
}
buf = append(buf, '\n')
}
return string(buf)
}
func roomMarker(room *Room, roomIdx int, isCurrent bool) (string, string) {
// Show room index number so player knows which key to press
num := fmt.Sprintf("%d", roomIdx)
if room.Cleared {
return num, ansiFgGray
}
switch room.Type {
case RoomCombat:
return num, ansiFgRed
case RoomTreasure:
return num, ansiFgYellow
case RoomShop:
return num, ansiFgCyan
case RoomEvent:
return num, ansiFgMagenta
case RoomBoss:
return num, ansiFgBrRed
case RoomEmpty:
return num, ansiFgGray
default:
return num, ansiReset
}
}

View File

@@ -17,15 +17,33 @@ func (r RoomType) String() string {
return [...]string{"Combat", "Treasure", "Shop", "Event", "Empty", "Boss"}[r]
}
type Tile int
const (
TileWall Tile = iota
TileFloor
TileCorridor
TileDoor
)
type Room struct {
Type RoomType
X, Y int
Width, Height int
X, Y int // top-left in tile space
W, H int // dimensions in tiles
Visited bool
Cleared bool
Neighbors []int
}
type Floor struct {
Number int
Rooms []*Room
CurrentRoom int
Tiles [][]Tile
Width int
Height int
}
func RandomRoomType() RoomType {
r := rand.Float64() * 100
switch {

View File

@@ -82,6 +82,11 @@ func (p *Player) EffectiveATK() int {
atk += item.Bonus
}
}
for _, r := range p.Relics {
if r.Effect == RelicATKBoost {
atk += r.Value
}
}
return atk
}
@@ -92,5 +97,10 @@ func (p *Player) EffectiveDEF() int {
def += item.Bonus
}
}
for _, r := range p.Relics {
if r.Effect == RelicDEFBoost {
def += r.Value
}
}
return def
}

View File

@@ -37,6 +37,18 @@ func TestAllClasses(t *testing.T) {
}
}
func TestRelicEffects(t *testing.T) {
p := NewPlayer("test", ClassWarrior)
p.Relics = append(p.Relics, Relic{Name: "Power Amulet", Effect: RelicATKBoost, Value: 3})
if p.EffectiveATK() != 15 {
t.Errorf("ATK with relic: got %d, want 15", p.EffectiveATK())
}
p.Relics = append(p.Relics, Relic{Name: "Iron Ward", Effect: RelicDEFBoost, Value: 2})
if p.EffectiveDEF() != 10 {
t.Errorf("DEF with relic: got %d, want 10", p.EffectiveDEF())
}
}
func TestPlayerTakeDamage(t *testing.T) {
p := NewPlayer("test", ClassWarrior)
p.TakeDamage(30)

View File

@@ -24,10 +24,12 @@ func (s *GameSession) EnterRoom(roomIdx int) {
s.spawnMonsters()
s.state.Phase = PhaseCombat
s.state.CombatTurn = 0
s.signalCombat()
case dungeon.RoomBoss:
s.spawnBoss()
s.state.Phase = PhaseCombat
s.state.CombatTurn = 0
s.signalCombat()
case dungeon.RoomShop:
s.generateShopItems()
s.state.Phase = PhaseShop
@@ -43,25 +45,33 @@ func (s *GameSession) EnterRoom(roomIdx int) {
}
func (s *GameSession) spawnMonsters() {
count := 1 + rand.Intn(5) // 1~5 monsters
count := 1 + rand.Intn(5)
floor := s.state.FloorNum
s.state.Monsters = make([]*entity.Monster, count)
// Pick appropriate monster type for floor
var mt entity.MonsterType
switch {
case floor <= 5:
mt = entity.MonsterSlime
case floor <= 10:
mt = entity.MonsterSkeleton
case floor <= 14:
mt = entity.MonsterOrc
default:
mt = entity.MonsterDarkKnight
type floorRange struct {
mt entity.MonsterType
minFloor int
maxFloor int
}
ranges := []floorRange{
{entity.MonsterSlime, 1, 5},
{entity.MonsterSkeleton, 3, 10},
{entity.MonsterOrc, 6, 14},
{entity.MonsterDarkKnight, 12, 20},
}
var valid []entity.MonsterType
for _, r := range ranges {
if floor >= r.minFloor && floor <= r.maxFloor {
valid = append(valid, r.mt)
}
}
if len(valid) == 0 {
valid = []entity.MonsterType{entity.MonsterSlime}
}
// Solo mode: 50% HP
for i := 0; i < count; i++ {
mt := valid[rand.Intn(len(valid))]
m := entity.NewMonster(mt, floor)
if s.state.SoloMode {
m.HP = m.HP / 2

View File

@@ -2,6 +2,7 @@ package game
import (
"sync"
"time"
"github.com/tolelom/catacombs/dungeon"
"github.com/tolelom/catacombs/entity"
@@ -43,13 +44,29 @@ type GameState struct {
GameOver bool
Victory bool
ShopItems []entity.Item
CombatLog []string // recent combat messages
TurnDeadline time.Time
}
func (s *GameSession) addLog(msg string) {
s.state.CombatLog = append(s.state.CombatLog, msg)
// Keep last 5 messages
if len(s.state.CombatLog) > 5 {
s.state.CombatLog = s.state.CombatLog[len(s.state.CombatLog)-5:]
}
}
func (s *GameSession) clearLog() {
s.state.CombatLog = nil
}
type GameSession struct {
mu sync.Mutex
state GameState
started bool
actions map[string]PlayerAction // playerName -> action
actionCh chan playerActionMsg
combatSignal chan struct{}
}
type playerActionMsg struct {
@@ -64,15 +81,54 @@ func NewGameSession() *GameSession {
},
actions: make(map[string]PlayerAction),
actionCh: make(chan playerActionMsg, 4),
combatSignal: make(chan struct{}, 1),
}
}
// StartGame determines solo mode from actual player count at game start
func (s *GameSession) StartGame() {
s.mu.Lock()
if s.started {
s.mu.Unlock()
return
}
s.started = true
s.state.SoloMode = len(s.state.Players) == 1
s.mu.Unlock()
s.StartFloor()
go s.combatLoop()
}
// combatLoop continuously runs turns while in combat phase
func (s *GameSession) combatLoop() {
for {
s.mu.Lock()
phase := s.state.Phase
gameOver := s.state.GameOver
s.mu.Unlock()
if gameOver {
return
}
if phase == PhaseCombat {
s.RunTurn() // blocks until all actions collected or timeout
} else {
// Not in combat, wait for an action signal to avoid busy-spinning
// We'll just sleep briefly and re-check
select {
case <-s.combatSignal:
// Room entered, combat may have started
}
}
}
}
func (s *GameSession) signalCombat() {
select {
case s.combatSignal <- struct{}{}:
default:
}
}
func (s *GameSession) AddPlayer(p *entity.Player) {

View File

@@ -1,6 +1,7 @@
package game
import (
"fmt"
"math/rand"
"time"
@@ -15,6 +16,7 @@ func (s *GameSession) RunTurn() {
s.mu.Lock()
s.state.TurnNum++
s.state.CombatTurn++
s.clearLog()
s.actions = make(map[string]PlayerAction)
aliveCount := 0
for _, p := range s.state.Players {
@@ -26,6 +28,9 @@ func (s *GameSession) RunTurn() {
// Collect actions with timeout
timer := time.NewTimer(TurnTimeout)
s.mu.Lock()
s.state.TurnDeadline = time.Now().Add(TurnTimeout)
s.mu.Unlock()
collected := 0
for collected < aliveCount {
select {
@@ -43,6 +48,7 @@ func (s *GameSession) RunTurn() {
resolve:
s.mu.Lock()
defer s.mu.Unlock()
s.state.TurnDeadline = time.Time{}
// Default action for players who didn't submit: Wait
for _, p := range s.state.Players {
@@ -59,6 +65,7 @@ resolve:
func (s *GameSession) resolvePlayerActions() {
var intents []combat.AttackIntent
var intentOwners []string // track who owns each intent
// Track which monsters were alive before this turn (for gold awards)
aliveBeforeTurn := make(map[int]bool)
@@ -68,27 +75,6 @@ func (s *GameSession) resolvePlayerActions() {
}
}
// Check if ALL alive players chose flee — only then the party flees
fleeCount := 0
aliveCount := 0
for _, p := range s.state.Players {
if p.IsDead() {
continue
}
aliveCount++
if action, ok := s.actions[p.Name]; ok && action.Type == ActionFlee {
fleeCount++
}
}
if fleeCount == aliveCount && aliveCount > 0 {
if combat.AttemptFlee() {
s.state.Phase = PhaseExploring
return
}
// Flee failed — all fleeing players waste their turn, continue to monster phase
return
}
for _, p := range s.state.Players {
if p.IsDead() {
continue
@@ -106,16 +92,17 @@ func (s *GameSession) resolvePlayerActions() {
Multiplier: 1.0,
IsAoE: false,
})
intentOwners = append(intentOwners, p.Name)
case ActionSkill:
switch p.Class {
case entity.ClassWarrior:
// Taunt: mark all monsters to target this warrior
for _, m := range s.state.Monsters {
if !m.IsDead() {
m.TauntTarget = true
m.TauntTurns = 2
}
}
s.addLog(fmt.Sprintf("%s used Taunt! Enemies focus on %s for 2 turns", p.Name, p.Name))
case entity.ClassMage:
intents = append(intents, combat.AttackIntent{
PlayerATK: p.EffectiveATK(),
@@ -123,35 +110,72 @@ func (s *GameSession) resolvePlayerActions() {
Multiplier: 0.8,
IsAoE: true,
})
intentOwners = append(intentOwners, p.Name)
case entity.ClassHealer:
if action.TargetIdx >= 0 && action.TargetIdx < len(s.state.Players) {
s.state.Players[action.TargetIdx].Heal(30)
targetIdx := action.TargetIdx
if targetIdx < 0 || targetIdx >= len(s.state.Players) {
targetIdx = 0 // heal self by default
}
target := s.state.Players[targetIdx]
before := target.HP
target.Heal(30)
s.addLog(fmt.Sprintf("%s healed %s for %d HP", p.Name, target.Name, target.HP-before))
case entity.ClassRogue:
// Scout: reveal neighboring rooms
currentRoom := s.state.Floor.Rooms[s.state.Floor.CurrentRoom]
for _, neighborIdx := range currentRoom.Neighbors {
s.state.Floor.Rooms[neighborIdx].Visited = true
}
s.addLog(fmt.Sprintf("%s scouted nearby rooms!", p.Name))
}
case ActionItem:
// Use first consumable from inventory
found := false
for i, item := range p.Inventory {
if item.Type == entity.ItemConsumable {
before := p.HP
p.Heal(item.Bonus)
p.Inventory = append(p.Inventory[:i], p.Inventory[i+1:]...)
s.addLog(fmt.Sprintf("%s used %s, restored %d HP", p.Name, item.Name, p.HP-before))
found = true
break
}
}
if !found {
s.addLog(fmt.Sprintf("%s has no items to use!", p.Name))
}
case ActionFlee:
// Individual flee does nothing if not unanimous (already handled above)
if combat.AttemptFlee() {
s.addLog(fmt.Sprintf("%s fled from battle!", p.Name))
if s.state.SoloMode {
s.state.Phase = PhaseExploring
return
}
} else {
s.addLog(fmt.Sprintf("%s failed to flee!", p.Name))
}
case ActionWait:
// Defensive stance — no action
s.addLog(fmt.Sprintf("%s is defending", p.Name))
}
}
if len(intents) > 0 && len(s.state.Monsters) > 0 {
combat.ResolveAttacks(intents, s.state.Monsters)
results := combat.ResolveAttacks(intents, s.state.Monsters)
for i, r := range results {
owner := intentOwners[i]
if r.IsAoE {
coopStr := ""
if r.CoopApplied {
coopStr = " (co-op!)"
}
s.addLog(fmt.Sprintf("%s hit all enemies for %d total dmg%s", owner, r.Damage, coopStr))
} else if r.TargetIdx >= 0 && r.TargetIdx < len(s.state.Monsters) {
target := s.state.Monsters[r.TargetIdx]
coopStr := ""
if r.CoopApplied {
coopStr = " (co-op!)"
}
s.addLog(fmt.Sprintf("%s hit %s for %d dmg%s", owner, target.Name, r.Damage, coopStr))
}
}
}
// Award gold only for monsters that JUST died this turn
@@ -163,10 +187,20 @@ func (s *GameSession) resolvePlayerActions() {
}
for _, p := range s.state.Players {
if !p.IsDead() {
p.Gold += goldReward
bonus := 0
for _, r := range p.Relics {
if r.Effect == entity.RelicGoldBoost {
bonus += r.Value
}
if r.Effect == entity.RelicHealOnKill {
p.Heal(r.Value)
s.addLog(fmt.Sprintf("%s's relic heals %d HP", p.Name, r.Value))
}
}
// Boss kill: drop relic
p.Gold += goldReward + bonus
}
}
s.addLog(fmt.Sprintf("%s defeated! +%d gold", m.Name, goldReward))
if m.IsBoss {
s.grantBossRelic()
}
@@ -185,7 +219,7 @@ func (s *GameSession) resolvePlayerActions() {
// Check if combat is over
if len(s.state.Monsters) == 0 {
s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true
// Check if this was the boss room -> advance floor
s.addLog("Room cleared!")
if s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Type == dungeon.RoomBoss {
s.advanceFloor()
} else {
@@ -199,13 +233,14 @@ func (s *GameSession) advanceFloor() {
s.state.Phase = PhaseResult
s.state.Victory = true
s.state.GameOver = true
s.addLog("You conquered the Catacombs!")
return
}
s.state.FloorNum++
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum)
s.state.Phase = PhaseExploring
s.state.CombatTurn = 0
// Revive dead players at 30% HP
s.addLog(fmt.Sprintf("Descending to B%d...", s.state.FloorNum))
for _, p := range s.state.Players {
if p.IsDead() {
p.Revive(0.30)
@@ -224,6 +259,7 @@ func (s *GameSession) grantBossRelic() {
if !p.IsDead() {
r := relics[rand.Intn(len(relics))]
p.Relics = append(p.Relics, r)
s.addLog(fmt.Sprintf("%s obtained relic: %s", p.Name, r.Name))
}
}
}
@@ -238,11 +274,11 @@ func (s *GameSession) resolveMonsterActions() {
}
targetIdx, isAoE := combat.MonsterAI(m, s.state.Players, s.state.CombatTurn)
if isAoE {
// Boss AoE: 0.5x damage to all
for _, p := range s.state.Players {
if !p.IsDead() {
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 0.5)
p.TakeDamage(dmg)
s.addLog(fmt.Sprintf("%s AoE hits %s for %d dmg", m.Name, p.Name, dmg))
}
}
} else {
@@ -251,13 +287,13 @@ func (s *GameSession) resolveMonsterActions() {
if !p.IsDead() {
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 1.0)
p.TakeDamage(dmg)
s.addLog(fmt.Sprintf("%s attacks %s for %d dmg", m.Name, p.Name, dmg))
}
}
}
m.TickTaunt()
}
// Check party wipe
allPlayersDead := true
for _, p := range s.state.Players {
if !p.IsDead() {
@@ -267,5 +303,7 @@ func (s *GameSession) resolveMonsterActions() {
}
if allPlayersDead {
s.state.Phase = PhaseResult
s.state.GameOver = true
s.addLog("Party wiped!")
}
}

16
main.go
View File

@@ -2,12 +2,26 @@ package main
import (
"log"
"os"
"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()
log.Println("Catacombs server starting on :2222")
if err := server.Start("0.0.0.0", 2222, lobby, db); err != nil {
log.Fatal(err)
}
}

View File

@@ -9,16 +9,21 @@ 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"),
wish.WithPublicKeyAuth(func(_ ssh.Context, _ ssh.PublicKey) bool {
return true // accept all keys
}),
wish.WithPasswordAuth(func(_ ssh.Context, _ string) bool {
return true // accept any password (game server, not secure shell)
}),
wish.WithMiddleware(
bubbletea.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
pty, _, _ := s.Pty()
@@ -26,7 +31,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,
)
}

196
ui/game_view.go Normal file
View File

@@ -0,0 +1,196 @@
package ui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/dungeon"
"github.com/tolelom/catacombs/entity"
"github.com/tolelom/catacombs/game"
)
func renderGame(state game.GameState, width, height int, targetCursor int, moveCursor int) string {
mapView := renderMap(state.Floor)
hudView := renderHUD(state, targetCursor, moveCursor)
logView := renderCombatLog(state.CombatLog)
return lipgloss.JoinVertical(lipgloss.Left,
mapView,
hudView,
logView,
)
}
func renderMap(floor *dungeon.Floor) string {
if floor == nil {
return ""
}
headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
header := headerStyle.Render(fmt.Sprintf("── Catacombs B%d ──", floor.Number))
return header + "\n" + dungeon.RenderFloor(floor, floor.CurrentRoom, true)
}
func renderHUD(state game.GameState, targetCursor int, moveCursor int) string {
var sb strings.Builder
border := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
Padding(0, 1)
// Player info
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",
p.Name, p.Class, hpBar, p.HP, p.MaxHP, status, p.Gold))
// Show inventory count
itemCount := len(p.Inventory)
relicCount := len(p.Relics)
if itemCount > 0 || relicCount > 0 {
sb.WriteString(fmt.Sprintf(" Items:%d Relics:%d", itemCount, relicCount))
}
sb.WriteString("\n")
}
if state.Phase == game.PhaseCombat {
sb.WriteString("\n")
// Enemies
enemyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
for i, m := range state.Monsters {
if !m.IsDead() {
mhpBar := renderHPBar(m.HP, m.MaxHP, 15)
taunt := ""
if m.TauntTarget {
taunt = " [TAUNTED]"
}
marker := " "
if i == targetCursor {
marker = "> "
}
sb.WriteString(enemyStyle.Render(fmt.Sprintf("%s[%d] %s %s %d/%d%s", marker, i, m.Name, mhpBar, m.HP, m.MaxHP, taunt)))
sb.WriteString("\n")
}
}
sb.WriteString("\n")
// Actions with skill description
actionStyle := lipgloss.NewStyle().Bold(true)
sb.WriteString(actionStyle.Render("[1]Attack [2]Skill [3]Item [4]Flee [5]Wait [Tab]Target"))
sb.WriteString("\n")
if !state.TurnDeadline.IsZero() {
remaining := time.Until(state.TurnDeadline)
if remaining < 0 {
remaining = 0
}
timerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)
sb.WriteString(timerStyle.Render(fmt.Sprintf(" Timer: %.1fs", remaining.Seconds())))
sb.WriteString("\n")
}
// Skill description per class
skillStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Italic(true)
for _, p := range state.Players {
if !p.IsDead() {
var skillDesc string
switch p.Class {
case entity.ClassWarrior:
skillDesc = "Skill: Taunt - enemies attack you for 2 turns"
case entity.ClassMage:
skillDesc = "Skill: Fireball - AoE 0.8x dmg to all enemies"
case entity.ClassHealer:
skillDesc = "Skill: Heal - restore 30 HP to an ally"
case entity.ClassRogue:
skillDesc = "Skill: Scout - reveal neighboring rooms"
}
sb.WriteString(skillStyle.Render(skillDesc))
sb.WriteString("\n")
}
}
} else if state.Phase == game.PhaseExploring {
if state.Floor != nil && state.Floor.CurrentRoom >= 0 && state.Floor.CurrentRoom < len(state.Floor.Rooms) {
current := state.Floor.Rooms[state.Floor.CurrentRoom]
if len(current.Neighbors) > 0 {
sb.WriteString("\n")
selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Bold(true)
normalStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
for i, n := range current.Neighbors {
if n >= 0 && n < len(state.Floor.Rooms) {
r := state.Floor.Rooms[n]
status := r.Type.String()
if r.Cleared {
status = "Cleared"
}
marker := " "
style := normalStyle
if i == moveCursor {
marker = "> "
style = selectedStyle
}
sb.WriteString(style.Render(fmt.Sprintf("%sRoom %d: %s", marker, n, status)))
sb.WriteString("\n")
}
}
}
}
sb.WriteString("[Up/Down] Select [Enter] Move [Q] Quit")
}
return border.Render(sb.String())
}
func renderCombatLog(log []string) string {
if len(log) == 0 {
return ""
}
logStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("228")).
PaddingLeft(1)
var sb strings.Builder
for _, msg := range log {
sb.WriteString(" > " + msg + "\n")
}
return logStyle.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 " "
}
}

63
ui/lobby_view.go Normal file
View File

@@ -0,0 +1,63 @@
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
type lobbyState struct {
rooms []roomInfo
input string
cursor int
creating bool
roomName string
joining bool
codeInput 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 [Up/Down] Select [Enter] Join [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!"
}
if state.joining {
inputStr := state.codeInput + strings.Repeat("_", 4-len(state.codeInput))
roomList += fmt.Sprintf("\n Enter room code: [%s] (Esc to cancel)\n", inputStr)
}
return lipgloss.JoinVertical(lipgloss.Left,
header,
"",
roomStyle.Render(roomList),
"",
menu,
)
}

View File

@@ -1,28 +1,68 @@
package ui
import (
"fmt"
"strings"
"time"
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
targetCursor int
moveCursor int // selected neighbor index during exploration
}
func NewModel(width, height int, fingerprint string) Model {
func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *store.DB) Model {
if width == 0 {
width = 80
}
if height == 0 {
height = 24
}
return Model{
width: width,
height: height,
fingerprint: fingerprint,
screen: screenTitle,
lobby: lobby,
store: db,
}
}
@@ -32,17 +72,368 @@ 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
if m.width == 0 {
m.width = 80
}
if m.height == 0 {
m.height = 24
}
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, m.targetCursor, m.moveCursor)
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 isKey(key tea.KeyMsg, names ...string) bool {
s := key.String()
for _, n := range names {
if s == n {
return true
}
}
return false
}
func isEnter(key tea.KeyMsg) bool {
return isKey(key, "enter") || key.Type == tea.KeyEnter
}
func isQuit(key tea.KeyMsg) bool {
return isKey(key, "q", "ctrl+c") || key.Type == tea.KeyCtrlC
}
func isUp(key tea.KeyMsg) bool {
return isKey(key, "up") || key.Type == tea.KeyUp
}
func isDown(key tea.KeyMsg) bool {
return isKey(key, "down") || key.Type == tea.KeyDown
}
func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isEnter(key) {
if m.store != nil {
name, err := m.store.GetProfile(m.fingerprint)
if err != nil {
m.playerName = "Adventurer"
if m.store != nil && m.fingerprint != "" {
m.store.SaveProfile(m.fingerprint, m.playerName)
}
} else {
m.playerName = name
}
} else {
m.playerName = "Adventurer"
}
m.screen = screenLobby
m = m.withRefreshedLobby()
} else if isQuit(key) {
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 {
// 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); 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.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); err == nil {
m.roomCode = r.Code
m.screen = screenClassSelect
}
}
} else if isKey(key, "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 {
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
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
}
// 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) {
// Refresh state on every update
if m.session != nil {
m.gameState = m.session.GetState()
}
if m.gameState.GameOver {
if m.store != nil {
score := 0
for _, p := range m.gameState.Players {
score += p.Gold
}
m.store.SaveRun(m.playerName, m.gameState.FloorNum, score)
}
m.screen = screenResult
return m, nil
}
if m.gameState.Phase == game.PhaseShop {
m.screen = screenShop
return m, nil
}
switch msg.(type) {
case tickMsg:
// State already refreshed above, just keep polling during combat
if m.gameState.Phase == game.PhaseCombat {
return m, m.pollState()
}
return m, nil
}
if key, ok := msg.(tea.KeyMsg); ok {
switch m.gameState.Phase {
case game.PhaseExploring:
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.Name == m.playerName && 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.playerName, game.PlayerAction{Type: game.ActionAttack, TargetIdx: m.targetCursor})
case "2":
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionSkill, TargetIdx: m.targetCursor})
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})
}
// 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')
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 {
if isEnter(key) {
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"
}
m.lobbyState.rooms[i] = roomInfo{
Code: r.Code,
Name: r.Name,
Players: len(r.Players),
Status: status,
}
}
m.lobbyState.cursor = 0
return m
}

113
ui/model_test.go Normal file
View File

@@ -0,0 +1,113 @@
package ui
import (
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/store"
"os"
)
func testDB(t *testing.T) *store.DB {
db, err := store.Open("test_ui.db")
if err != nil {
t.Fatal(err)
}
return db
}
func TestTitleToLobby(t *testing.T) {
lobby := game.NewLobby()
db := testDB(t)
defer func() { db.Close(); os.Remove("test_ui.db") }()
m := NewModel(80, 24, "testfp", lobby, db)
if m.screen != screenTitle {
t.Fatalf("initial screen: got %d, want screenTitle(0)", m.screen)
}
// Press Enter
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m2 := result.(Model)
if m2.screen != screenLobby {
t.Errorf("after Enter: screen=%d, want screenLobby(1)", m2.screen)
}
if m2.playerName == "" {
t.Error("playerName should be set")
}
}
func TestLobbyCreateRoom(t *testing.T) {
lobby := game.NewLobby()
db := testDB(t)
defer func() { db.Close(); os.Remove("test_ui.db") }()
m := NewModel(80, 24, "testfp", lobby, db)
// Go to lobby
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m2 := result.(Model)
// Press 'c' to create room
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
m3 := result.(Model)
if m3.screen != screenClassSelect {
t.Errorf("after 'c': screen=%d, want screenClassSelect(2)", m3.screen)
}
if m3.roomCode == "" {
t.Error("roomCode should be set")
}
}
func TestClassSelectToGame(t *testing.T) {
lobby := game.NewLobby()
db := testDB(t)
defer func() { db.Close(); os.Remove("test_ui.db") }()
m := NewModel(80, 24, "testfp", lobby, db)
// Title -> Lobby -> Class Select -> Game
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m2 := result.(Model)
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
m3 := result.(Model)
if m3.screen != screenClassSelect {
t.Fatalf("should be at class select, got %d", m3.screen)
}
// Press Enter to select Warrior (default cursor=0)
result, _ = m3.Update(tea.KeyMsg{Type: tea.KeyEnter})
m4 := result.(Model)
if m4.screen != screenGame {
t.Errorf("after class select Enter: screen=%d, want screenGame(3)", m4.screen)
}
if m4.session == nil {
t.Error("session should be set")
}
}
func TestKeyHelpers(t *testing.T) {
enter := tea.KeyMsg{Type: tea.KeyEnter}
if !isEnter(enter) {
t.Error("isEnter should match KeyEnter type")
}
enterStr := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'\r'}}
_ = enterStr // might not match, that's ok
up := tea.KeyMsg{Type: tea.KeyUp}
if !isUp(up) {
t.Error("isUp should match KeyUp type")
}
q := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}
if !isQuit(q) {
t.Error("isQuit should match 'q'")
}
}

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"),
)
}