Compare commits
10 Commits
15956efb18
...
f2ac4dbded
| Author | SHA1 | Date | |
|---|---|---|---|
| f2ac4dbded | |||
| 26784479b7 | |||
| 92741d415d | |||
| 7556073cb5 | |||
| a1e9e0ef68 | |||
| 6809e49226 | |||
| 743b5b9058 | |||
| 3cc6f783b3 | |||
| ecf6ee64d0 | |||
| 4e76e48588 |
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
data/
|
||||
catacombs
|
||||
catacombs.exe
|
||||
.ssh/
|
||||
BIN
catacombs.exe~
Normal file
BIN
catacombs.exe~
Normal file
Binary file not shown.
652
docs/superpowers/plans/2026-03-24-catacombs-fixes.md
Normal file
652
docs/superpowers/plans/2026-03-24-catacombs-fixes.md
Normal 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"
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
237
dungeon/render.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
actions map[string]PlayerAction // playerName -> action
|
||||
actionCh chan playerActionMsg
|
||||
mu sync.Mutex
|
||||
state GameState
|
||||
started bool
|
||||
actions map[string]PlayerAction // playerName -> action
|
||||
actionCh chan playerActionMsg
|
||||
combatSignal chan struct{}
|
||||
}
|
||||
|
||||
type playerActionMsg struct {
|
||||
@@ -62,17 +79,56 @@ func NewGameSession() *GameSession {
|
||||
state: GameState{
|
||||
FloorNum: 1,
|
||||
},
|
||||
actions: make(map[string]PlayerAction),
|
||||
actionCh: make(chan playerActionMsg, 4),
|
||||
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) {
|
||||
|
||||
108
game/turn.go
108
game/turn.go
@@ -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))
|
||||
}
|
||||
}
|
||||
p.Gold += goldReward + bonus
|
||||
}
|
||||
}
|
||||
// Boss kill: drop relic
|
||||
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
16
main.go
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
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,
|
||||
)
|
||||
}
|
||||
196
ui/game_view.go
Normal file
196
ui/game_view.go
Normal 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
63
ui/lobby_view.go
Normal 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,
|
||||
)
|
||||
}
|
||||
403
ui/model.go
403
ui/model.go
@@ -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
113
ui/model_test.go
Normal 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
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