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 {
|
if floor.Rooms[roomIdx].Visited {
|
||||||
return 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
|
return Hidden
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,41 +2,252 @@ package dungeon
|
|||||||
|
|
||||||
import "math/rand"
|
import "math/rand"
|
||||||
|
|
||||||
type Floor struct {
|
const (
|
||||||
Number int
|
MapWidth = 60
|
||||||
Rooms []*Room
|
MapHeight = 20
|
||||||
CurrentRoom int
|
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 {
|
func GenerateFloor(floorNum int) *Floor {
|
||||||
numRooms := 5 + rand.Intn(4)
|
// Create tile map filled with walls
|
||||||
rooms := make([]*Room, numRooms)
|
tiles := make([][]Tile, MapHeight)
|
||||||
for i := 0; i < numRooms; i++ {
|
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()
|
rt := RandomRoomType()
|
||||||
rooms[i] = &Room{
|
rooms[i] = &Room{
|
||||||
Type: rt,
|
Type: rt,
|
||||||
X: (i % 3) * 20,
|
X: rx,
|
||||||
Y: (i / 3) * 10,
|
Y: ry,
|
||||||
Width: 12 + rand.Intn(6),
|
W: rw,
|
||||||
Height: 6 + rand.Intn(4),
|
H: rh,
|
||||||
Neighbors: []int{},
|
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].Neighbors = append(rooms[i].Neighbors, i+1)
|
||||||
rooms[i+1].Neighbors = append(rooms[i+1].Neighbors, i)
|
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)
|
extras := 1 + rand.Intn(2)
|
||||||
for e := 0; e < extras; e++ {
|
for e := 0; e < extras; e++ {
|
||||||
a := rand.Intn(numRooms)
|
a := rand.Intn(len(rooms))
|
||||||
b := rand.Intn(numRooms)
|
b := rand.Intn(len(rooms))
|
||||||
if a != b && !hasNeighbor(rooms[a], b) {
|
if a != b && !hasNeighbor(rooms[a], b) {
|
||||||
rooms[a].Neighbors = append(rooms[a].Neighbors, b)
|
rooms[a].Neighbors = append(rooms[a].Neighbors, b)
|
||||||
rooms[b].Neighbors = append(rooms[b].Neighbors, a)
|
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 {
|
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)
|
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]
|
return [...]string{"Combat", "Treasure", "Shop", "Event", "Empty", "Boss"}[r]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Tile int
|
||||||
|
|
||||||
|
const (
|
||||||
|
TileWall Tile = iota
|
||||||
|
TileFloor
|
||||||
|
TileCorridor
|
||||||
|
TileDoor
|
||||||
|
)
|
||||||
|
|
||||||
type Room struct {
|
type Room struct {
|
||||||
Type RoomType
|
Type RoomType
|
||||||
X, Y int
|
X, Y int // top-left in tile space
|
||||||
Width, Height int
|
W, H int // dimensions in tiles
|
||||||
Visited bool
|
Visited bool
|
||||||
Cleared bool
|
Cleared bool
|
||||||
Neighbors []int
|
Neighbors []int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Floor struct {
|
||||||
|
Number int
|
||||||
|
Rooms []*Room
|
||||||
|
CurrentRoom int
|
||||||
|
Tiles [][]Tile
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
}
|
||||||
|
|
||||||
func RandomRoomType() RoomType {
|
func RandomRoomType() RoomType {
|
||||||
r := rand.Float64() * 100
|
r := rand.Float64() * 100
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ func (p *Player) EffectiveATK() int {
|
|||||||
atk += item.Bonus
|
atk += item.Bonus
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, r := range p.Relics {
|
||||||
|
if r.Effect == RelicATKBoost {
|
||||||
|
atk += r.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
return atk
|
return atk
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,5 +97,10 @@ func (p *Player) EffectiveDEF() int {
|
|||||||
def += item.Bonus
|
def += item.Bonus
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, r := range p.Relics {
|
||||||
|
if r.Effect == RelicDEFBoost {
|
||||||
|
def += r.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
return def
|
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) {
|
func TestPlayerTakeDamage(t *testing.T) {
|
||||||
p := NewPlayer("test", ClassWarrior)
|
p := NewPlayer("test", ClassWarrior)
|
||||||
p.TakeDamage(30)
|
p.TakeDamage(30)
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ func (s *GameSession) EnterRoom(roomIdx int) {
|
|||||||
s.spawnMonsters()
|
s.spawnMonsters()
|
||||||
s.state.Phase = PhaseCombat
|
s.state.Phase = PhaseCombat
|
||||||
s.state.CombatTurn = 0
|
s.state.CombatTurn = 0
|
||||||
|
s.signalCombat()
|
||||||
case dungeon.RoomBoss:
|
case dungeon.RoomBoss:
|
||||||
s.spawnBoss()
|
s.spawnBoss()
|
||||||
s.state.Phase = PhaseCombat
|
s.state.Phase = PhaseCombat
|
||||||
s.state.CombatTurn = 0
|
s.state.CombatTurn = 0
|
||||||
|
s.signalCombat()
|
||||||
case dungeon.RoomShop:
|
case dungeon.RoomShop:
|
||||||
s.generateShopItems()
|
s.generateShopItems()
|
||||||
s.state.Phase = PhaseShop
|
s.state.Phase = PhaseShop
|
||||||
@@ -43,25 +45,33 @@ func (s *GameSession) EnterRoom(roomIdx int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameSession) spawnMonsters() {
|
func (s *GameSession) spawnMonsters() {
|
||||||
count := 1 + rand.Intn(5) // 1~5 monsters
|
count := 1 + rand.Intn(5)
|
||||||
floor := s.state.FloorNum
|
floor := s.state.FloorNum
|
||||||
s.state.Monsters = make([]*entity.Monster, count)
|
s.state.Monsters = make([]*entity.Monster, count)
|
||||||
|
|
||||||
// Pick appropriate monster type for floor
|
type floorRange struct {
|
||||||
var mt entity.MonsterType
|
mt entity.MonsterType
|
||||||
switch {
|
minFloor int
|
||||||
case floor <= 5:
|
maxFloor int
|
||||||
mt = entity.MonsterSlime
|
}
|
||||||
case floor <= 10:
|
ranges := []floorRange{
|
||||||
mt = entity.MonsterSkeleton
|
{entity.MonsterSlime, 1, 5},
|
||||||
case floor <= 14:
|
{entity.MonsterSkeleton, 3, 10},
|
||||||
mt = entity.MonsterOrc
|
{entity.MonsterOrc, 6, 14},
|
||||||
default:
|
{entity.MonsterDarkKnight, 12, 20},
|
||||||
mt = entity.MonsterDarkKnight
|
}
|
||||||
|
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++ {
|
for i := 0; i < count; i++ {
|
||||||
|
mt := valid[rand.Intn(len(valid))]
|
||||||
m := entity.NewMonster(mt, floor)
|
m := entity.NewMonster(mt, floor)
|
||||||
if s.state.SoloMode {
|
if s.state.SoloMode {
|
||||||
m.HP = m.HP / 2
|
m.HP = m.HP / 2
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package game
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/tolelom/catacombs/dungeon"
|
"github.com/tolelom/catacombs/dungeon"
|
||||||
"github.com/tolelom/catacombs/entity"
|
"github.com/tolelom/catacombs/entity"
|
||||||
@@ -43,13 +44,29 @@ type GameState struct {
|
|||||||
GameOver bool
|
GameOver bool
|
||||||
Victory bool
|
Victory bool
|
||||||
ShopItems []entity.Item
|
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 {
|
type GameSession struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
state GameState
|
state GameState
|
||||||
actions map[string]PlayerAction // playerName -> action
|
started bool
|
||||||
actionCh chan playerActionMsg
|
actions map[string]PlayerAction // playerName -> action
|
||||||
|
actionCh chan playerActionMsg
|
||||||
|
combatSignal chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
type playerActionMsg struct {
|
type playerActionMsg struct {
|
||||||
@@ -62,17 +79,56 @@ func NewGameSession() *GameSession {
|
|||||||
state: GameState{
|
state: GameState{
|
||||||
FloorNum: 1,
|
FloorNum: 1,
|
||||||
},
|
},
|
||||||
actions: make(map[string]PlayerAction),
|
actions: make(map[string]PlayerAction),
|
||||||
actionCh: make(chan playerActionMsg, 4),
|
actionCh: make(chan playerActionMsg, 4),
|
||||||
|
combatSignal: make(chan struct{}, 1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartGame determines solo mode from actual player count at game start
|
// StartGame determines solo mode from actual player count at game start
|
||||||
func (s *GameSession) StartGame() {
|
func (s *GameSession) StartGame() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
if s.started {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.started = true
|
||||||
s.state.SoloMode = len(s.state.Players) == 1
|
s.state.SoloMode = len(s.state.Players) == 1
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
s.StartFloor()
|
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) {
|
func (s *GameSession) AddPlayer(p *entity.Player) {
|
||||||
|
|||||||
108
game/turn.go
108
game/turn.go
@@ -1,6 +1,7 @@
|
|||||||
package game
|
package game
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ func (s *GameSession) RunTurn() {
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.state.TurnNum++
|
s.state.TurnNum++
|
||||||
s.state.CombatTurn++
|
s.state.CombatTurn++
|
||||||
|
s.clearLog()
|
||||||
s.actions = make(map[string]PlayerAction)
|
s.actions = make(map[string]PlayerAction)
|
||||||
aliveCount := 0
|
aliveCount := 0
|
||||||
for _, p := range s.state.Players {
|
for _, p := range s.state.Players {
|
||||||
@@ -26,6 +28,9 @@ func (s *GameSession) RunTurn() {
|
|||||||
|
|
||||||
// Collect actions with timeout
|
// Collect actions with timeout
|
||||||
timer := time.NewTimer(TurnTimeout)
|
timer := time.NewTimer(TurnTimeout)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.state.TurnDeadline = time.Now().Add(TurnTimeout)
|
||||||
|
s.mu.Unlock()
|
||||||
collected := 0
|
collected := 0
|
||||||
for collected < aliveCount {
|
for collected < aliveCount {
|
||||||
select {
|
select {
|
||||||
@@ -43,6 +48,7 @@ func (s *GameSession) RunTurn() {
|
|||||||
resolve:
|
resolve:
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
s.state.TurnDeadline = time.Time{}
|
||||||
|
|
||||||
// Default action for players who didn't submit: Wait
|
// Default action for players who didn't submit: Wait
|
||||||
for _, p := range s.state.Players {
|
for _, p := range s.state.Players {
|
||||||
@@ -59,6 +65,7 @@ resolve:
|
|||||||
|
|
||||||
func (s *GameSession) resolvePlayerActions() {
|
func (s *GameSession) resolvePlayerActions() {
|
||||||
var intents []combat.AttackIntent
|
var intents []combat.AttackIntent
|
||||||
|
var intentOwners []string // track who owns each intent
|
||||||
|
|
||||||
// Track which monsters were alive before this turn (for gold awards)
|
// Track which monsters were alive before this turn (for gold awards)
|
||||||
aliveBeforeTurn := make(map[int]bool)
|
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 {
|
for _, p := range s.state.Players {
|
||||||
if p.IsDead() {
|
if p.IsDead() {
|
||||||
continue
|
continue
|
||||||
@@ -106,16 +92,17 @@ func (s *GameSession) resolvePlayerActions() {
|
|||||||
Multiplier: 1.0,
|
Multiplier: 1.0,
|
||||||
IsAoE: false,
|
IsAoE: false,
|
||||||
})
|
})
|
||||||
|
intentOwners = append(intentOwners, p.Name)
|
||||||
case ActionSkill:
|
case ActionSkill:
|
||||||
switch p.Class {
|
switch p.Class {
|
||||||
case entity.ClassWarrior:
|
case entity.ClassWarrior:
|
||||||
// Taunt: mark all monsters to target this warrior
|
|
||||||
for _, m := range s.state.Monsters {
|
for _, m := range s.state.Monsters {
|
||||||
if !m.IsDead() {
|
if !m.IsDead() {
|
||||||
m.TauntTarget = true
|
m.TauntTarget = true
|
||||||
m.TauntTurns = 2
|
m.TauntTurns = 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
s.addLog(fmt.Sprintf("%s used Taunt! Enemies focus on %s for 2 turns", p.Name, p.Name))
|
||||||
case entity.ClassMage:
|
case entity.ClassMage:
|
||||||
intents = append(intents, combat.AttackIntent{
|
intents = append(intents, combat.AttackIntent{
|
||||||
PlayerATK: p.EffectiveATK(),
|
PlayerATK: p.EffectiveATK(),
|
||||||
@@ -123,35 +110,72 @@ func (s *GameSession) resolvePlayerActions() {
|
|||||||
Multiplier: 0.8,
|
Multiplier: 0.8,
|
||||||
IsAoE: true,
|
IsAoE: true,
|
||||||
})
|
})
|
||||||
|
intentOwners = append(intentOwners, p.Name)
|
||||||
case entity.ClassHealer:
|
case entity.ClassHealer:
|
||||||
if action.TargetIdx >= 0 && action.TargetIdx < len(s.state.Players) {
|
targetIdx := action.TargetIdx
|
||||||
s.state.Players[action.TargetIdx].Heal(30)
|
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:
|
case entity.ClassRogue:
|
||||||
// Scout: reveal neighboring rooms
|
|
||||||
currentRoom := s.state.Floor.Rooms[s.state.Floor.CurrentRoom]
|
currentRoom := s.state.Floor.Rooms[s.state.Floor.CurrentRoom]
|
||||||
for _, neighborIdx := range currentRoom.Neighbors {
|
for _, neighborIdx := range currentRoom.Neighbors {
|
||||||
s.state.Floor.Rooms[neighborIdx].Visited = true
|
s.state.Floor.Rooms[neighborIdx].Visited = true
|
||||||
}
|
}
|
||||||
|
s.addLog(fmt.Sprintf("%s scouted nearby rooms!", p.Name))
|
||||||
}
|
}
|
||||||
case ActionItem:
|
case ActionItem:
|
||||||
// Use first consumable from inventory
|
found := false
|
||||||
for i, item := range p.Inventory {
|
for i, item := range p.Inventory {
|
||||||
if item.Type == entity.ItemConsumable {
|
if item.Type == entity.ItemConsumable {
|
||||||
|
before := p.HP
|
||||||
p.Heal(item.Bonus)
|
p.Heal(item.Bonus)
|
||||||
p.Inventory = append(p.Inventory[:i], p.Inventory[i+1:]...)
|
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !found {
|
||||||
|
s.addLog(fmt.Sprintf("%s has no items to use!", p.Name))
|
||||||
|
}
|
||||||
case ActionFlee:
|
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:
|
case ActionWait:
|
||||||
// Defensive stance — no action
|
s.addLog(fmt.Sprintf("%s is defending", p.Name))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(intents) > 0 && len(s.state.Monsters) > 0 {
|
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
|
// Award gold only for monsters that JUST died this turn
|
||||||
@@ -163,10 +187,20 @@ func (s *GameSession) resolvePlayerActions() {
|
|||||||
}
|
}
|
||||||
for _, p := range s.state.Players {
|
for _, p := range s.state.Players {
|
||||||
if !p.IsDead() {
|
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 {
|
if m.IsBoss {
|
||||||
s.grantBossRelic()
|
s.grantBossRelic()
|
||||||
}
|
}
|
||||||
@@ -185,7 +219,7 @@ func (s *GameSession) resolvePlayerActions() {
|
|||||||
// Check if combat is over
|
// Check if combat is over
|
||||||
if len(s.state.Monsters) == 0 {
|
if len(s.state.Monsters) == 0 {
|
||||||
s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true
|
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 {
|
if s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Type == dungeon.RoomBoss {
|
||||||
s.advanceFloor()
|
s.advanceFloor()
|
||||||
} else {
|
} else {
|
||||||
@@ -199,13 +233,14 @@ func (s *GameSession) advanceFloor() {
|
|||||||
s.state.Phase = PhaseResult
|
s.state.Phase = PhaseResult
|
||||||
s.state.Victory = true
|
s.state.Victory = true
|
||||||
s.state.GameOver = true
|
s.state.GameOver = true
|
||||||
|
s.addLog("You conquered the Catacombs!")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.state.FloorNum++
|
s.state.FloorNum++
|
||||||
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum)
|
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum)
|
||||||
s.state.Phase = PhaseExploring
|
s.state.Phase = PhaseExploring
|
||||||
s.state.CombatTurn = 0
|
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 {
|
for _, p := range s.state.Players {
|
||||||
if p.IsDead() {
|
if p.IsDead() {
|
||||||
p.Revive(0.30)
|
p.Revive(0.30)
|
||||||
@@ -224,6 +259,7 @@ func (s *GameSession) grantBossRelic() {
|
|||||||
if !p.IsDead() {
|
if !p.IsDead() {
|
||||||
r := relics[rand.Intn(len(relics))]
|
r := relics[rand.Intn(len(relics))]
|
||||||
p.Relics = append(p.Relics, r)
|
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)
|
targetIdx, isAoE := combat.MonsterAI(m, s.state.Players, s.state.CombatTurn)
|
||||||
if isAoE {
|
if isAoE {
|
||||||
// Boss AoE: 0.5x damage to all
|
|
||||||
for _, p := range s.state.Players {
|
for _, p := range s.state.Players {
|
||||||
if !p.IsDead() {
|
if !p.IsDead() {
|
||||||
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 0.5)
|
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 0.5)
|
||||||
p.TakeDamage(dmg)
|
p.TakeDamage(dmg)
|
||||||
|
s.addLog(fmt.Sprintf("%s AoE hits %s for %d dmg", m.Name, p.Name, dmg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -251,13 +287,13 @@ func (s *GameSession) resolveMonsterActions() {
|
|||||||
if !p.IsDead() {
|
if !p.IsDead() {
|
||||||
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 1.0)
|
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 1.0)
|
||||||
p.TakeDamage(dmg)
|
p.TakeDamage(dmg)
|
||||||
|
s.addLog(fmt.Sprintf("%s attacks %s for %d dmg", m.Name, p.Name, dmg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.TickTaunt()
|
m.TickTaunt()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check party wipe
|
|
||||||
allPlayersDead := true
|
allPlayersDead := true
|
||||||
for _, p := range s.state.Players {
|
for _, p := range s.state.Players {
|
||||||
if !p.IsDead() {
|
if !p.IsDead() {
|
||||||
@@ -267,5 +303,7 @@ func (s *GameSession) resolveMonsterActions() {
|
|||||||
}
|
}
|
||||||
if allPlayersDead {
|
if allPlayersDead {
|
||||||
s.state.Phase = PhaseResult
|
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 (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/tolelom/catacombs/game"
|
||||||
"github.com/tolelom/catacombs/server"
|
"github.com/tolelom/catacombs/server"
|
||||||
|
"github.com/tolelom/catacombs/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := server.Start("0.0.0.0", 2222); err != nil {
|
os.MkdirAll("data", 0755)
|
||||||
|
|
||||||
|
db, err := store.Open("data/catacombs.db")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to open database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
lobby := game.NewLobby()
|
||||||
|
|
||||||
|
log.Println("Catacombs server starting on :2222")
|
||||||
|
if err := server.Start("0.0.0.0", 2222, lobby, db); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,21 @@ import (
|
|||||||
"github.com/charmbracelet/wish/bubbletea"
|
"github.com/charmbracelet/wish/bubbletea"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
gossh "golang.org/x/crypto/ssh"
|
gossh "golang.org/x/crypto/ssh"
|
||||||
|
"github.com/tolelom/catacombs/game"
|
||||||
|
"github.com/tolelom/catacombs/store"
|
||||||
"github.com/tolelom/catacombs/ui"
|
"github.com/tolelom/catacombs/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Start(host string, port int) error {
|
func Start(host string, port int, lobby *game.Lobby, db *store.DB) error {
|
||||||
s, err := wish.NewServer(
|
s, err := wish.NewServer(
|
||||||
wish.WithAddress(fmt.Sprintf("%s:%d", host, port)),
|
wish.WithAddress(fmt.Sprintf("%s:%d", host, port)),
|
||||||
wish.WithHostKeyPath(".ssh/catacombs_host_key"),
|
wish.WithHostKeyPath(".ssh/catacombs_host_key"),
|
||||||
wish.WithPublicKeyAuth(func(_ ssh.Context, _ ssh.PublicKey) bool {
|
wish.WithPublicKeyAuth(func(_ ssh.Context, _ ssh.PublicKey) bool {
|
||||||
return true // accept all keys
|
return true // accept all keys
|
||||||
}),
|
}),
|
||||||
|
wish.WithPasswordAuth(func(_ ssh.Context, _ string) bool {
|
||||||
|
return true // accept any password (game server, not secure shell)
|
||||||
|
}),
|
||||||
wish.WithMiddleware(
|
wish.WithMiddleware(
|
||||||
bubbletea.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
bubbletea.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||||
pty, _, _ := s.Pty()
|
pty, _, _ := s.Pty()
|
||||||
@@ -26,7 +31,7 @@ func Start(host string, port int) error {
|
|||||||
if s.PublicKey() != nil {
|
if s.PublicKey() != nil {
|
||||||
fingerprint = gossh.FingerprintSHA256(s.PublicKey())
|
fingerprint = gossh.FingerprintSHA256(s.PublicKey())
|
||||||
}
|
}
|
||||||
m := ui.NewModel(pty.Window.Width, pty.Window.Height, fingerprint)
|
m := ui.NewModel(pty.Window.Width, pty.Window.Height, fingerprint, lobby, db)
|
||||||
return m, []tea.ProgramOption{tea.WithAltScreen()}
|
return m, []tea.ProgramOption{tea.WithAltScreen()}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
61
ui/class_view.go
Normal file
61
ui/class_view.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/tolelom/catacombs/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
type classSelectState struct {
|
||||||
|
cursor int
|
||||||
|
}
|
||||||
|
|
||||||
|
var classOptions = []struct {
|
||||||
|
class entity.Class
|
||||||
|
name string
|
||||||
|
desc string
|
||||||
|
}{
|
||||||
|
{entity.ClassWarrior, "Warrior", "HP:120 ATK:12 DEF:8 Skill: Taunt (draw enemy fire)"},
|
||||||
|
{entity.ClassMage, "Mage", "HP:70 ATK:20 DEF:3 Skill: Fireball (AoE damage)"},
|
||||||
|
{entity.ClassHealer, "Healer", "HP:90 ATK:8 DEF:5 Skill: Heal (restore 30 HP)"},
|
||||||
|
{entity.ClassRogue, "Rogue", "HP:85 ATK:15 DEF:4 Skill: Scout (reveal rooms)"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderClassSelect(state classSelectState, width, height int) string {
|
||||||
|
headerStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("205")).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
selectedStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("46")).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
normalStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("255"))
|
||||||
|
|
||||||
|
descStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("240"))
|
||||||
|
|
||||||
|
header := headerStyle.Render("── Choose Your Class ──")
|
||||||
|
list := ""
|
||||||
|
for i, opt := range classOptions {
|
||||||
|
marker := " "
|
||||||
|
style := normalStyle
|
||||||
|
if i == state.cursor {
|
||||||
|
marker = "> "
|
||||||
|
style = selectedStyle
|
||||||
|
}
|
||||||
|
list += fmt.Sprintf("%s%s\n %s\n\n",
|
||||||
|
marker, style.Render(opt.name), descStyle.Render(opt.desc))
|
||||||
|
}
|
||||||
|
|
||||||
|
menu := "[Up/Down] Select [Enter] Confirm"
|
||||||
|
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left,
|
||||||
|
header,
|
||||||
|
"",
|
||||||
|
list,
|
||||||
|
menu,
|
||||||
|
)
|
||||||
|
}
|
||||||
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
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/tolelom/catacombs/entity"
|
||||||
|
"github.com/tolelom/catacombs/game"
|
||||||
|
"github.com/tolelom/catacombs/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type screen int
|
type screen int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
screenTitle screen = iota
|
screenTitle screen = iota
|
||||||
|
screenLobby
|
||||||
|
screenClassSelect
|
||||||
|
screenGame
|
||||||
|
screenShop
|
||||||
|
screenResult
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// StateUpdateMsg is sent by GameSession to update the view
|
||||||
|
type StateUpdateMsg struct {
|
||||||
|
State game.GameState
|
||||||
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
fingerprint string
|
fingerprint string
|
||||||
|
playerName string
|
||||||
screen screen
|
screen screen
|
||||||
|
|
||||||
|
// Shared references (set by server)
|
||||||
|
lobby *game.Lobby
|
||||||
|
store *store.DB
|
||||||
|
|
||||||
|
// Per-session state
|
||||||
|
session *game.GameSession
|
||||||
|
roomCode string
|
||||||
|
gameState game.GameState
|
||||||
|
lobbyState lobbyState
|
||||||
|
classState classSelectState
|
||||||
|
inputBuffer string
|
||||||
|
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{
|
return Model{
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
fingerprint: fingerprint,
|
fingerprint: fingerprint,
|
||||||
screen: screenTitle,
|
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) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
|
||||||
if msg.String() == "q" || msg.String() == "ctrl+c" {
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
|
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
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) View() string {
|
func (m Model) View() string {
|
||||||
return "Welcome to Catacombs!\n\nPress q to quit."
|
if m.width < 80 || m.height < 24 {
|
||||||
|
return fmt.Sprintf("Terminal too small (%dx%d). Minimum: 80x24.", m.width, m.height)
|
||||||
|
}
|
||||||
|
switch m.screen {
|
||||||
|
case screenTitle:
|
||||||
|
return renderTitle(m.width, m.height)
|
||||||
|
case screenLobby:
|
||||||
|
return renderLobby(m.lobbyState, m.width, m.height)
|
||||||
|
case screenClassSelect:
|
||||||
|
return renderClassSelect(m.classState, m.width, m.height)
|
||||||
|
case screenGame:
|
||||||
|
return renderGame(m.gameState, m.width, m.height, 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