Compare commits
38 Commits
f2ac4dbded
...
604ca00e8b
| Author | SHA1 | Date | |
|---|---|---|---|
| 604ca00e8b | |||
| 43a9a0d9ad | |||
| ef9a713696 | |||
| 5c5070502a | |||
| fb0e64a109 | |||
| 57e56ae7a4 | |||
| f396066428 | |||
| afdda5d72b | |||
| 0dce30f23f | |||
| d3d7e2a76a | |||
| 533e460968 | |||
| 29387ebaa0 | |||
| 80c1988719 | |||
| 9221cfa7c6 | |||
| 15199bd26f | |||
| 9ed71eeccd | |||
| 1104c6e4e9 | |||
| c555ff6e92 | |||
| 09af632ed9 | |||
| a3bffbecb4 | |||
| a951f94f3e | |||
| 7fc13a6a32 | |||
| 84431c888a | |||
| 1ea6db406e | |||
| 01edb488f7 | |||
| ee9aec0b32 | |||
| ce2f03baf5 | |||
| 46afd82060 | |||
| e8887cd69a | |||
| cd2013a917 | |||
| 6f35bc1172 | |||
| 15614b966a | |||
| b6c28ddd80 | |||
| 4db3ba1fc5 | |||
| e13e1e7a7d | |||
| b0766c488c | |||
| ae3375a023 | |||
| e3e6c5105c |
18
.claude/settings.local.json
Normal file
18
.claude/settings.local.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(go build:*)",
|
||||||
|
"Bash(find . -name \"*.go\" -type f -exec wc -l {} +)",
|
||||||
|
"Bash(sort -k2)",
|
||||||
|
"Bash(where go:*)",
|
||||||
|
"Read(//c/Users/SSAFY/sdk/**)",
|
||||||
|
"Read(//c/Users/98kim/**)",
|
||||||
|
"Bash(go test:*)",
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"disabledMcpjsonServers": [
|
||||||
|
"unity-mcp"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
catacombs.exe~
BIN
catacombs.exe~
Binary file not shown.
@@ -82,8 +82,8 @@ func AttemptFlee() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func MonsterAI(m *entity.Monster, players []*entity.Player, turnNumber int) (targetIdx int, isAoE bool) {
|
func MonsterAI(m *entity.Monster, players []*entity.Player, turnNumber int) (targetIdx int, isAoE bool) {
|
||||||
if m.IsBoss && turnNumber%3 == 0 {
|
if m.IsBoss && turnNumber > 0 && turnNumber%3 == 0 {
|
||||||
return -1, true
|
return -1, true // AoE every 3 turns for all bosses
|
||||||
}
|
}
|
||||||
if m.TauntTarget {
|
if m.TauntTarget {
|
||||||
for i, p := range players {
|
for i, p := range players {
|
||||||
@@ -91,17 +91,23 @@ func MonsterAI(m *entity.Monster, players []*entity.Player, turnNumber int) (tar
|
|||||||
return i, false
|
return i, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// No living warrior found — clear taunt
|
||||||
|
m.TauntTarget = false
|
||||||
|
m.TauntTurns = 0
|
||||||
}
|
}
|
||||||
if rand.Float64() < 0.3 {
|
if rand.Float64() < 0.3 {
|
||||||
minHP := int(^uint(0) >> 1)
|
minHP := int(^uint(0) >> 1)
|
||||||
minIdx := 0
|
minIdx := -1
|
||||||
for i, p := range players {
|
for i, p := range players {
|
||||||
if !p.IsDead() && p.HP < minHP {
|
if !p.IsDead() && p.HP < minHP {
|
||||||
minHP = p.HP
|
minHP = p.HP
|
||||||
minIdx = i
|
minIdx = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return minIdx, false
|
if minIdx >= 0 {
|
||||||
|
return minIdx, false
|
||||||
|
}
|
||||||
|
// Fall through to default targeting if no alive player found
|
||||||
}
|
}
|
||||||
for i, p := range players {
|
for i, p := range players {
|
||||||
if !p.IsDead() {
|
if !p.IsDead() {
|
||||||
|
|||||||
@@ -46,6 +46,25 @@ func TestAoENoCoopBonus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMonsterAITauntDeadWarrior(t *testing.T) {
|
||||||
|
warrior := entity.NewPlayer("Tank", entity.ClassWarrior)
|
||||||
|
warrior.TakeDamage(warrior.HP) // kill warrior
|
||||||
|
mage := entity.NewPlayer("Mage", entity.ClassMage)
|
||||||
|
|
||||||
|
m := &entity.Monster{Name: "Orc", HP: 50, ATK: 10, DEF: 5, TauntTarget: true, TauntTurns: 2}
|
||||||
|
idx, isAoE := MonsterAI(m, []*entity.Player{warrior, mage}, 1)
|
||||||
|
|
||||||
|
if isAoE {
|
||||||
|
t.Error("should not AoE")
|
||||||
|
}
|
||||||
|
if idx != 1 {
|
||||||
|
t.Errorf("expected target mage at index 1, got %d", idx)
|
||||||
|
}
|
||||||
|
if m.TauntTarget {
|
||||||
|
t.Error("TauntTarget should be cleared when warrior is dead")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFleeChance(t *testing.T) {
|
func TestFleeChance(t *testing.T) {
|
||||||
successes := 0
|
successes := 0
|
||||||
for i := 0; i < 100; i++ {
|
for i := 0; i < 100; i++ {
|
||||||
@@ -57,3 +76,63 @@ func TestFleeChance(t *testing.T) {
|
|||||||
t.Errorf("Flee success rate suspicious: %d/100", successes)
|
t.Errorf("Flee success rate suspicious: %d/100", successes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMonsterAIBossAoE(t *testing.T) {
|
||||||
|
boss := &entity.Monster{Name: "Boss", HP: 100, IsBoss: true}
|
||||||
|
players := []*entity.Player{entity.NewPlayer("P1", entity.ClassWarrior)}
|
||||||
|
|
||||||
|
// Turn 0 should NOT AoE
|
||||||
|
_, isAoE := MonsterAI(boss, players, 0)
|
||||||
|
if isAoE {
|
||||||
|
t.Error("boss should not AoE on turn 0")
|
||||||
|
}
|
||||||
|
// Turn 3 should AoE
|
||||||
|
_, isAoE = MonsterAI(boss, players, 3)
|
||||||
|
if !isAoE {
|
||||||
|
t.Error("boss should AoE on turn 3")
|
||||||
|
}
|
||||||
|
// Turn 6 should AoE
|
||||||
|
_, isAoE = MonsterAI(boss, players, 6)
|
||||||
|
if !isAoE {
|
||||||
|
t.Error("boss should AoE on turn 6")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonsterAILowestHP(t *testing.T) {
|
||||||
|
p1 := entity.NewPlayer("Tank", entity.ClassWarrior) // 120 HP
|
||||||
|
p2 := entity.NewPlayer("Mage", entity.ClassMage) // 70 HP
|
||||||
|
p2.HP = 10 // very low
|
||||||
|
|
||||||
|
// Run many times — at least some should target p2 (30% chance)
|
||||||
|
targetedLow := 0
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
m := &entity.Monster{Name: "Orc", HP: 50}
|
||||||
|
idx, _ := MonsterAI(m, []*entity.Player{p1, p2}, 1)
|
||||||
|
if idx == 1 {
|
||||||
|
targetedLow++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Should target low HP player roughly 30% of time
|
||||||
|
if targetedLow < 10 || targetedLow > 60 {
|
||||||
|
t.Errorf("lowest HP targeting out of expected range: %d/100", targetedLow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalcDamageWithMultiplier(t *testing.T) {
|
||||||
|
// AoE multiplier 0.8: ATK=20, DEF=5, mult=0.8 → base = 20*0.8 - 5 = 11
|
||||||
|
// Range: 11 * 0.85 to 11 * 1.15 = ~9.35 to ~12.65
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
dmg := CalcDamage(20, 5, 0.8)
|
||||||
|
if dmg < 9 || dmg > 13 {
|
||||||
|
t.Errorf("AoE damage %d out of expected range 9-13", dmg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalcDamageHighDEF(t *testing.T) {
|
||||||
|
// When DEF > ATK*mult, should deal minimum 1 damage
|
||||||
|
dmg := CalcDamage(5, 100, 1.0)
|
||||||
|
if dmg != 1 {
|
||||||
|
t.Errorf("expected minimum damage 1, got %d", dmg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "2222:2222"
|
- "2222:2222"
|
||||||
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- catacombs-data:/app/data
|
- catacombs-data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
1208
docs/superpowers/plans/2026-03-24-bugfix-and-spec-alignment.md
Normal file
1208
docs/superpowers/plans/2026-03-24-bugfix-and-spec-alignment.md
Normal file
File diff suppressed because it is too large
Load Diff
558
docs/superpowers/plans/2026-03-24-terminal-visuals.md
Normal file
558
docs/superpowers/plans/2026-03-24-terminal-visuals.md
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
# Terminal Visuals Enhancement — Implementation 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:** Enhance Catacombs terminal visuals — monster ASCII art, combat layout with box-drawing panels, semantic color effects, and improved title screen.
|
||||||
|
|
||||||
|
**Architecture:** New files for styles and ASCII art data. Rewrite of `game_view.go` render functions. All changes are UI-only — no game logic modifications.
|
||||||
|
|
||||||
|
**Tech Stack:** Go, charmbracelet/lipgloss (box-drawing, colors, layout)
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-03-24-terminal-visuals-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
```
|
||||||
|
ui/styles.go — NEW: shared lipgloss style constants
|
||||||
|
ui/ascii_art.go — NEW: monster ASCII art data + Art() function
|
||||||
|
ui/game_view.go — REWRITE: combat layout, colored log, HP bar 3-color
|
||||||
|
ui/title.go — MODIFY: gradient logo, centered layout
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Shared Styles (ui/styles.go)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `ui/styles.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create styles.go with color constants and reusable styles**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
var (
|
||||||
|
colorRed = lipgloss.Color("196")
|
||||||
|
colorGreen = lipgloss.Color("46")
|
||||||
|
colorYellow = lipgloss.Color("226")
|
||||||
|
colorCyan = lipgloss.Color("51")
|
||||||
|
colorMagenta = lipgloss.Color("201")
|
||||||
|
colorWhite = lipgloss.Color("255")
|
||||||
|
colorGray = lipgloss.Color("240")
|
||||||
|
colorOrange = lipgloss.Color("208")
|
||||||
|
colorPink = lipgloss.Color("205")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Text styles
|
||||||
|
var (
|
||||||
|
styleDamage = lipgloss.NewStyle().Foreground(colorRed).Bold(true)
|
||||||
|
styleHeal = lipgloss.NewStyle().Foreground(colorGreen).Bold(true)
|
||||||
|
styleCoop = lipgloss.NewStyle().Foreground(colorYellow).Bold(true)
|
||||||
|
styleFlee = lipgloss.NewStyle().Foreground(colorCyan)
|
||||||
|
styleStatus = lipgloss.NewStyle().Foreground(colorMagenta)
|
||||||
|
styleGold = lipgloss.NewStyle().Foreground(colorYellow)
|
||||||
|
styleSystem = lipgloss.NewStyle().Foreground(colorGray).Italic(true)
|
||||||
|
styleEnemy = lipgloss.NewStyle().Foreground(colorRed)
|
||||||
|
stylePlayer = lipgloss.NewStyle().Foreground(colorWhite).Bold(true)
|
||||||
|
styleHeader = lipgloss.NewStyle().Foreground(colorPink).Bold(true)
|
||||||
|
styleAction = lipgloss.NewStyle().Bold(true)
|
||||||
|
styleTimer = lipgloss.NewStyle().Foreground(colorYellow).Bold(true)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Panel styles
|
||||||
|
var (
|
||||||
|
panelBorder = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(colorGray).
|
||||||
|
Padding(0, 1)
|
||||||
|
|
||||||
|
panelHeader = lipgloss.NewStyle().
|
||||||
|
Foreground(colorPink).
|
||||||
|
Bold(true).
|
||||||
|
Align(lipgloss.Center)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ui/styles.go
|
||||||
|
git commit -m "feat: add shared lipgloss styles for terminal visuals"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Monster ASCII Art (ui/ascii_art.go)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `ui/ascii_art.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create ascii_art.go with art data**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import "github.com/tolelom/catacombs/entity"
|
||||||
|
|
||||||
|
// MonsterArt returns ASCII art lines for a monster type.
|
||||||
|
func MonsterArt(mt entity.MonsterType) []string {
|
||||||
|
switch mt {
|
||||||
|
case entity.MonsterSlime:
|
||||||
|
return []string{
|
||||||
|
` /\OO/\ `,
|
||||||
|
` \ / `,
|
||||||
|
` |__| `,
|
||||||
|
}
|
||||||
|
case entity.MonsterSkeleton:
|
||||||
|
return []string{
|
||||||
|
` ,--. `,
|
||||||
|
` |oo| `,
|
||||||
|
` /||\ `,
|
||||||
|
}
|
||||||
|
case entity.MonsterOrc:
|
||||||
|
return []string{
|
||||||
|
` .---. `,
|
||||||
|
`/o o\`,
|
||||||
|
`| --- |`,
|
||||||
|
}
|
||||||
|
case entity.MonsterDarkKnight:
|
||||||
|
return []string{
|
||||||
|
` /|||\ `,
|
||||||
|
` |===| `,
|
||||||
|
` | | `,
|
||||||
|
}
|
||||||
|
case entity.MonsterBoss5:
|
||||||
|
return []string{
|
||||||
|
` /\ /\ `,
|
||||||
|
`| @ @ |`,
|
||||||
|
`| || |`,
|
||||||
|
`| \__/ |`,
|
||||||
|
` \ / `,
|
||||||
|
}
|
||||||
|
case entity.MonsterBoss10:
|
||||||
|
return []string{
|
||||||
|
` __|__ `,
|
||||||
|
` /|o o|\ `,
|
||||||
|
` | === | `,
|
||||||
|
` |\___/| `,
|
||||||
|
` |___| `,
|
||||||
|
}
|
||||||
|
case entity.MonsterBoss15:
|
||||||
|
return []string{
|
||||||
|
` ,=====. `,
|
||||||
|
`/ \ / \`,
|
||||||
|
`| (O) |`,
|
||||||
|
` \ |=| / `,
|
||||||
|
` '===' `,
|
||||||
|
}
|
||||||
|
case entity.MonsterBoss20:
|
||||||
|
return []string{
|
||||||
|
` ___/\___ `,
|
||||||
|
`| x x |`,
|
||||||
|
`| === |`,
|
||||||
|
`|\_____/|`,
|
||||||
|
`|_| |_|`,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return []string{` ??? `}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ui/ascii_art.go
|
||||||
|
git commit -m "feat: add monster ASCII art data"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: HP Bar 3-Color + Colored Combat Log (ui/game_view.go)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/game_view.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite renderHPBar with 3-color thresholds**
|
||||||
|
|
||||||
|
Replace the `renderHPBar` function:
|
||||||
|
```go
|
||||||
|
func renderHPBar(current, max, width int) string {
|
||||||
|
if max == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
filled := current * width / max
|
||||||
|
if filled < 0 {
|
||||||
|
filled = 0
|
||||||
|
}
|
||||||
|
if filled > width {
|
||||||
|
filled = width
|
||||||
|
}
|
||||||
|
empty := width - filled
|
||||||
|
|
||||||
|
pct := float64(current) / float64(max)
|
||||||
|
var barStyle lipgloss.Style
|
||||||
|
switch {
|
||||||
|
case pct > 0.5:
|
||||||
|
barStyle = lipgloss.NewStyle().Foreground(colorGreen)
|
||||||
|
case pct > 0.25:
|
||||||
|
barStyle = lipgloss.NewStyle().Foreground(colorYellow)
|
||||||
|
default:
|
||||||
|
barStyle = lipgloss.NewStyle().Foreground(colorRed)
|
||||||
|
}
|
||||||
|
emptyStyle := lipgloss.NewStyle().Foreground(colorGray)
|
||||||
|
|
||||||
|
return barStyle.Render(strings.Repeat("█", filled)) +
|
||||||
|
emptyStyle.Render(strings.Repeat("░", empty))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rewrite renderCombatLog with pattern-based coloring**
|
||||||
|
|
||||||
|
Replace `renderCombatLog`:
|
||||||
|
```go
|
||||||
|
func renderCombatLog(log []string) string {
|
||||||
|
if len(log) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
border := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(colorGray).
|
||||||
|
Padding(0, 1)
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, msg := range log {
|
||||||
|
colored := colorizeLog(msg)
|
||||||
|
sb.WriteString(" > " + colored + "\n")
|
||||||
|
}
|
||||||
|
return border.Render(sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func colorizeLog(msg string) string {
|
||||||
|
// Apply semantic colors based on keywords
|
||||||
|
switch {
|
||||||
|
case strings.Contains(msg, "fled"):
|
||||||
|
return styleFlee.Render(msg)
|
||||||
|
case strings.Contains(msg, "co-op"):
|
||||||
|
return styleCoop.Render(msg)
|
||||||
|
case strings.Contains(msg, "healed") || strings.Contains(msg, "Heal") || strings.Contains(msg, "Blessing"):
|
||||||
|
return styleHeal.Render(msg)
|
||||||
|
case strings.Contains(msg, "dmg") || strings.Contains(msg, "hit") || strings.Contains(msg, "attacks") || strings.Contains(msg, "Trap"):
|
||||||
|
return styleDamage.Render(msg)
|
||||||
|
case strings.Contains(msg, "Taunt") || strings.Contains(msg, "scouted"):
|
||||||
|
return styleStatus.Render(msg)
|
||||||
|
case strings.Contains(msg, "gold") || strings.Contains(msg, "Gold") || strings.Contains(msg, "found"):
|
||||||
|
return styleGold.Render(msg)
|
||||||
|
case strings.Contains(msg, "defeated") || strings.Contains(msg, "cleared") || strings.Contains(msg, "Descending"):
|
||||||
|
return styleSystem.Render(msg)
|
||||||
|
default:
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build and test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./...
|
||||||
|
go test ./ui/ -timeout 15s
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ui/game_view.go
|
||||||
|
git commit -m "feat: 3-color HP bar and semantic combat log coloring"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Combat Layout Redesign (ui/game_view.go)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/game_view.go`
|
||||||
|
|
||||||
|
This is the largest task. Rewrite `renderHUD` to use a 2-panel layout in combat mode.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add renderPartyPanel helper**
|
||||||
|
|
||||||
|
Add to `ui/game_view.go`:
|
||||||
|
```go
|
||||||
|
func renderPartyPanel(players []*entity.Player) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(styleHeader.Render(" PARTY") + "\n\n")
|
||||||
|
|
||||||
|
for _, p := range players {
|
||||||
|
nameStr := stylePlayer.Render(fmt.Sprintf(" ♦ %s", p.Name))
|
||||||
|
classStr := styleSystem.Render(fmt.Sprintf(" (%s)", p.Class))
|
||||||
|
status := ""
|
||||||
|
if p.IsDead() {
|
||||||
|
status = styleDamage.Render(" [DEAD]")
|
||||||
|
}
|
||||||
|
sb.WriteString(nameStr + classStr + status + "\n")
|
||||||
|
|
||||||
|
hpBar := renderHPBar(p.HP, p.MaxHP, 16)
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s %d/%d\n", hpBar, p.HP, p.MaxHP))
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf(" ATK:%-3d DEF:%-3d ", p.EffectiveATK(), p.EffectiveDEF()))
|
||||||
|
sb.WriteString(styleGold.Render(fmt.Sprintf("G:%d", p.Gold)))
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add renderEnemyPanel helper**
|
||||||
|
|
||||||
|
```go
|
||||||
|
func renderEnemyPanel(monsters []*entity.Monster, targetCursor int) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(styleHeader.Render(" ENEMIES") + "\n\n")
|
||||||
|
|
||||||
|
for i, m := range monsters {
|
||||||
|
if m.IsDead() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// ASCII art
|
||||||
|
art := MonsterArt(m.Type)
|
||||||
|
for _, line := range art {
|
||||||
|
sb.WriteString(styleEnemy.Render(" " + line) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name + HP
|
||||||
|
marker := " "
|
||||||
|
if i == targetCursor {
|
||||||
|
marker = "> "
|
||||||
|
}
|
||||||
|
hpBar := renderHPBar(m.HP, m.MaxHP, 12)
|
||||||
|
taunt := ""
|
||||||
|
if m.TauntTarget {
|
||||||
|
taunt = styleStatus.Render(" [TAUNTED]")
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s[%d] %s %s %d/%d%s\n\n",
|
||||||
|
marker, i, styleEnemy.Render(m.Name), hpBar, m.HP, m.MaxHP, taunt))
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rewrite renderHUD for combat mode**
|
||||||
|
|
||||||
|
Replace the combat section of `renderHUD` (the `if state.Phase == game.PhaseCombat` block):
|
||||||
|
```go
|
||||||
|
if state.Phase == game.PhaseCombat {
|
||||||
|
// Two-panel layout: PARTY | ENEMIES
|
||||||
|
partyContent := renderPartyPanel(state.Players)
|
||||||
|
enemyContent := renderEnemyPanel(state.Monsters, targetCursor)
|
||||||
|
|
||||||
|
partyPanel := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(colorGray).
|
||||||
|
Width(35).
|
||||||
|
Padding(0, 1).
|
||||||
|
Render(partyContent)
|
||||||
|
|
||||||
|
enemyPanel := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(colorGray).
|
||||||
|
Width(38).
|
||||||
|
Padding(0, 1).
|
||||||
|
Render(enemyContent)
|
||||||
|
|
||||||
|
panels := lipgloss.JoinHorizontal(lipgloss.Top, partyPanel, enemyPanel)
|
||||||
|
sb.WriteString(panels)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
// Action bar
|
||||||
|
sb.WriteString(styleAction.Render("[1]Attack [2]Skill [3]Item [4]Flee [5]Wait [Tab]Target [/]Chat"))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
// Timer
|
||||||
|
if !state.TurnDeadline.IsZero() {
|
||||||
|
remaining := time.Until(state.TurnDeadline)
|
||||||
|
if remaining < 0 {
|
||||||
|
remaining = 0
|
||||||
|
}
|
||||||
|
sb.WriteString(styleTimer.Render(fmt.Sprintf(" Timer: %.1fs", remaining.Seconds())))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skill description for current player (first alive)
|
||||||
|
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(styleSystem.Render(skillDesc))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
break // show only current player's skill
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update renderHUD to not wrap combat in border (panels have their own)**
|
||||||
|
|
||||||
|
The exploration mode still uses the old `border` style. Change the function to only apply `border.Render()` for exploration, not combat:
|
||||||
|
|
||||||
|
Restructure `renderHUD` so that:
|
||||||
|
- Combat: return `sb.String()` directly (panels already have borders)
|
||||||
|
- Exploring: wrap in `border.Render(sb.String())`
|
||||||
|
|
||||||
|
Replace the return at the end of `renderHUD`:
|
||||||
|
```go
|
||||||
|
if state.Phase == game.PhaseCombat {
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
return border.Render(sb.String())
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Remove the unused roomTypeSymbol function**
|
||||||
|
|
||||||
|
Delete `roomTypeSymbol` (lines ~185-202) — it's dead code.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build and run all tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./...
|
||||||
|
go test ./... -timeout 30s
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ui/game_view.go
|
||||||
|
git commit -m "feat: two-panel combat layout with monster ASCII art"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Title Screen Enhancement (ui/title.go)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/title.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite renderTitle with gradient colors and centered layout**
|
||||||
|
|
||||||
|
Replace `ui/title.go` entirely:
|
||||||
|
```go
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
var titleLines = []string{
|
||||||
|
` ██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ███╗ ███╗██████╗ ███████╗`,
|
||||||
|
`██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██╔════╝██╔═══██╗████╗ ████║██╔══██╗██╔════╝`,
|
||||||
|
`██║ ███████║ ██║ ███████║██║ ██║ ██║██╔████╔██║██████╔╝███████╗`,
|
||||||
|
`██║ ██╔══██║ ██║ ██╔══██║██║ ██║ ██║██║╚██╔╝██║██╔══██╗╚════██║`,
|
||||||
|
`╚██████╗██║ ██║ ██║ ██║ ██║╚██████╗╚██████╔╝██║ ╚═╝ ██║██████╔╝███████║`,
|
||||||
|
`╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚══════╝`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// gradient from red → orange → yellow
|
||||||
|
var titleColors = []lipgloss.Color{
|
||||||
|
lipgloss.Color("196"), // red
|
||||||
|
lipgloss.Color("202"), // orange-red
|
||||||
|
lipgloss.Color("208"), // orange
|
||||||
|
lipgloss.Color("214"), // yellow-orange
|
||||||
|
lipgloss.Color("220"), // yellow
|
||||||
|
lipgloss.Color("226"), // bright yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTitle(width, height int) string {
|
||||||
|
// Render logo with gradient
|
||||||
|
var logoLines []string
|
||||||
|
for i, line := range titleLines {
|
||||||
|
color := titleColors[i%len(titleColors)]
|
||||||
|
style := lipgloss.NewStyle().Foreground(color).Bold(true)
|
||||||
|
logoLines = append(logoLines, style.Render(line))
|
||||||
|
}
|
||||||
|
logo := strings.Join(logoLines, "\n")
|
||||||
|
|
||||||
|
subtitle := lipgloss.NewStyle().
|
||||||
|
Foreground(colorGray).
|
||||||
|
Render("⚔ A Cooperative Dungeon Crawler ⚔")
|
||||||
|
|
||||||
|
server := lipgloss.NewStyle().
|
||||||
|
Foreground(colorCyan).
|
||||||
|
Render("ssh catacombs.tolelom.xyz")
|
||||||
|
|
||||||
|
menu := lipgloss.NewStyle().
|
||||||
|
Foreground(colorWhite).
|
||||||
|
Bold(true).
|
||||||
|
Render("[Enter] Start [Q] Quit")
|
||||||
|
|
||||||
|
content := lipgloss.JoinVertical(lipgloss.Center,
|
||||||
|
logo,
|
||||||
|
"",
|
||||||
|
subtitle,
|
||||||
|
server,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
menu,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Center on screen
|
||||||
|
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, content)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build and test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./...
|
||||||
|
go test ./ui/ -timeout 15s
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ui/title.go
|
||||||
|
git commit -m "feat: gradient title screen with centered layout"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Final integration test
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build and run all tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./...
|
||||||
|
go test ./... -timeout 30s
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify no dead code or unused imports**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go vet ./...
|
||||||
|
```
|
||||||
138
docs/superpowers/specs/2026-03-24-terminal-visuals-design.md
Normal file
138
docs/superpowers/specs/2026-03-24-terminal-visuals-design.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Terminal Visuals Enhancement — Design Spec
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Catacombs 터미널 비주얼 개선. 4가지 영역: 몬스터 아스키아트, 전투 레이아웃 리디자인, 컬러 효과, 타이틀 화면.
|
||||||
|
|
||||||
|
## 1. Monster ASCII Art
|
||||||
|
|
||||||
|
몬스터 타입별 3~4줄 아스키아트. 전투 HUD의 ENEMIES 패널에 표시.
|
||||||
|
|
||||||
|
```
|
||||||
|
일반 몬스터 (3줄):
|
||||||
|
|
||||||
|
/\OO/\ ,--. .---. /|||\
|
||||||
|
\ / |oo| /o o\ |===|
|
||||||
|
|__| /||\ | --- | | |
|
||||||
|
Slime Skeleton Orc Dark Knight
|
||||||
|
|
||||||
|
보스 (5줄):
|
||||||
|
|
||||||
|
[B5] [B10] [B15] [B20]
|
||||||
|
/\ /\ __|__ ,=====. ___/\___
|
||||||
|
| @ @ | /|o o|\ / \ / \ | x x |
|
||||||
|
| || | | === | | (O) | | === |
|
||||||
|
| \__/ | |\___/| \ |=| / |\_____/|
|
||||||
|
\ / |___| `===' |_| |_|
|
||||||
|
Guardian Warden Overlord Archlich
|
||||||
|
```
|
||||||
|
|
||||||
|
`entity/monster.go`에 `Art() []string` 메서드 추가. 타입별 하드코딩된 문자열 슬라이스 반환.
|
||||||
|
|
||||||
|
## 2. Combat Layout Redesign
|
||||||
|
|
||||||
|
lipgloss 박스드로잉으로 전투 화면을 패널 구조로 리디자인.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Catacombs B3 ──────────────────────────────────────────────┐
|
||||||
|
│ [미니맵 영역] │
|
||||||
|
├────────────────────────────┬────────────────────────────────┤
|
||||||
|
│ PARTY │ ENEMIES │
|
||||||
|
│ │ │
|
||||||
|
│ ♦ Tank (Warrior) │ /\OO/\ │
|
||||||
|
│ ██████████░░░░ 80/120 │ \ / > [0] Slime 8/20 │
|
||||||
|
│ ATK:15 DEF:10 │ |__| │
|
||||||
|
│ │ │
|
||||||
|
│ ♦ Mage (Mage) │ ,--. │
|
||||||
|
│ ██████░░░░░░░░ 45/70 │ |oo| [1] Skeleton 30/35 │
|
||||||
|
│ ATK:23 DEF:5 │ /||\ │
|
||||||
|
│ │ │
|
||||||
|
├────────────────────────────┴────────────────────────────────┤
|
||||||
|
│ > Tank hit Slime for 12 dmg │
|
||||||
|
│ > Mage hit all enemies for 18 total dmg (co-op!) │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ [1]Attack [2]Skill [3]Item [4]Flee [5]Wait [Tab]Target │
|
||||||
|
│ Skill: Taunt — enemies attack you for 2 turns Timer: 4.2s│
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
구조:
|
||||||
|
- 상단: 미니맵 (dungeon.RenderFloor 호출, 축소)
|
||||||
|
- 중단 좌: PARTY 패널 — 각 플레이어 이름, 클래스, HP바, ATK/DEF
|
||||||
|
- 중단 우: ENEMIES 패널 — 아스키아트 + 이름 + HP바 + 타겟 커서
|
||||||
|
- 하단 로그: CombatLog 메시지 (컬러 적용)
|
||||||
|
- 최하단: 액션 바 + 타이머
|
||||||
|
|
||||||
|
탐험 모드에서는 중단이 맵 + 방 목록으로 전환.
|
||||||
|
|
||||||
|
lipgloss `JoinHorizontal`/`JoinVertical` + `Border` 사용.
|
||||||
|
|
||||||
|
## 3. Color Effects
|
||||||
|
|
||||||
|
로그 메시지와 HUD 요소에 의미적 컬러 적용:
|
||||||
|
|
||||||
|
| 요소 | 색상 | lipgloss Color |
|
||||||
|
|------|------|---------------|
|
||||||
|
| 데미지 수치 | 빨강 볼드 | `"196"` Bold |
|
||||||
|
| 힐 수치 | 초록 볼드 | `"46"` Bold |
|
||||||
|
| co-op 보너스 | 노랑 볼드 | `"226"` Bold |
|
||||||
|
| 도주 | 시안 | `"51"` |
|
||||||
|
| 상태효과(도발 등) | 마젠타 | `"201"` |
|
||||||
|
| 몬스터 이름 | 빨강 | `"196"` |
|
||||||
|
| 플레이어 이름 | 흰색 볼드 | `"255"` Bold |
|
||||||
|
| 골드 | 노랑 | `"226"` |
|
||||||
|
| 시스템 메시지 | 회색 이탤릭 | `"240"` Italic |
|
||||||
|
|
||||||
|
HP 바 3단계 색상:
|
||||||
|
- 50%↑: 초록 (`"46"`)
|
||||||
|
- 25~50%: 노랑 (`"226"`)
|
||||||
|
- 25%↓: 빨강 (`"196"`)
|
||||||
|
|
||||||
|
로그 메시지에 인라인 컬러링 적용. `addLog` 대신 구조화된 로그 메시지 사용:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type LogEntry struct {
|
||||||
|
Text string
|
||||||
|
Color string // lipgloss color code
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
또는 더 간단하게: 로그 문자열에 lipgloss 스타일을 렌더링 시점에 적용. 로그 메시지에 키워드 패턴 매칭으로 자동 컬러링.
|
||||||
|
|
||||||
|
**선택: 렌더링 시점 패턴 매칭 방식.** `addLog`는 plain text 유지. `renderCombatLog`에서 숫자+`dmg` → 빨강, 숫자+`HP` → 초록, `co-op` → 노랑, `fled` → 시안 등 매칭.
|
||||||
|
|
||||||
|
## 4. Title Screen
|
||||||
|
|
||||||
|
대형 아스키아트 로고 + 분위기 서브텍스트:
|
||||||
|
|
||||||
|
```
|
||||||
|
██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ███╗ ███╗██████╗ ███████╗
|
||||||
|
██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██╔════╝██╔═══██╗████╗ ████║██╔══██╗██╔════╝
|
||||||
|
██║ ███████║ ██║ ███████║██║ ██║ ██║██╔████╔██║██████╔╝███████╗
|
||||||
|
██║ ██╔══██║ ██║ ██╔══██║██║ ██║ ██║██║╚██╔╝██║██╔══██╗╚════██║
|
||||||
|
╚██████╗██║ ██║ ██║ ██║ ██║╚██████╗╚██████╔╝██║ ╚═╝ ██║██████╔╝███████║
|
||||||
|
╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚══════╝
|
||||||
|
|
||||||
|
⚔ A Cooperative Dungeon Crawler ⚔
|
||||||
|
ssh catacombs.tolelom.xyz
|
||||||
|
```
|
||||||
|
|
||||||
|
로고는 그라데이션 컬러 (lipgloss로 행별 색상 변화: 빨강→주황→노랑).
|
||||||
|
|
||||||
|
메뉴:
|
||||||
|
```
|
||||||
|
[Enter] Start
|
||||||
|
[Q] Quit
|
||||||
|
```
|
||||||
|
|
||||||
|
센터 정렬 (lipgloss `Place` 사용).
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
```
|
||||||
|
entity/monster.go — Art() 메서드 추가
|
||||||
|
ui/ascii_art.go — NEW: 몬스터/보스 아스키아트 데이터
|
||||||
|
ui/game_view.go — 전투 레이아웃 리디자인, 컬러 로그
|
||||||
|
ui/title.go — 타이틀 화면 개선
|
||||||
|
ui/styles.go — NEW: 공유 lipgloss 스타일 상수
|
||||||
|
```
|
||||||
82
docs/superpowers/specs/2026-03-24-web-terminal-design.md
Normal file
82
docs/superpowers/specs/2026-03-24-web-terminal-design.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Web Terminal Frontend — Design Spec
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
xterm.js 기반 웹 브라우저 터미널. WebSocket → SSH 프록시 방식으로 기존 SSH 게임 서버에 접속.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser (xterm.js) ←WebSocket→ Go HTTP Server (:8080) ←SSH→ Wish SSH Server (:2222)
|
||||||
|
```
|
||||||
|
|
||||||
|
단일 Go 바이너리에서 SSH 서버와 HTTP 서버를 동시에 실행.
|
||||||
|
|
||||||
|
## Server: web/server.go
|
||||||
|
|
||||||
|
- `net/http`로 `:8080` 리스닝
|
||||||
|
- `/` — 정적 파일 서빙 (`web/static/` 디렉토리, embed 사용)
|
||||||
|
- `/ws` — WebSocket 엔드포인트
|
||||||
|
|
||||||
|
### WebSocket → SSH 프록시 흐름
|
||||||
|
|
||||||
|
1. 클라이언트가 `/ws`에 WebSocket 연결
|
||||||
|
2. 서버가 `golang.org/x/crypto/ssh`로 `localhost:2222`에 비밀번호 인증 SSH 접속
|
||||||
|
3. SSH 세션에서 PTY 요청 (초기 크기: 80x24)
|
||||||
|
4. 두 goroutine으로 양방향 중계:
|
||||||
|
- SSH stdout → WebSocket 텍스트 프레임
|
||||||
|
- WebSocket 텍스트 프레임 → SSH stdin
|
||||||
|
5. 리사이즈: WebSocket 바이너리 프레임으로 `{"type":"resize","cols":N,"rows":N}` 수신 → SSH WindowChange 요청
|
||||||
|
6. 연결 종료: 어느 쪽이든 끊기면 양쪽 모두 정리
|
||||||
|
|
||||||
|
### SSH 접속 설정
|
||||||
|
|
||||||
|
- Host: `localhost:2222`
|
||||||
|
- Auth: 비밀번호 인증 (빈 비밀번호 — 게임 서버가 모든 비밀번호 수용)
|
||||||
|
- HostKey: InsecureIgnoreHostKey (로컬 접속)
|
||||||
|
- User: `web-player`
|
||||||
|
|
||||||
|
## Client: web/static/index.html
|
||||||
|
|
||||||
|
단일 HTML 파일, 빌드 도구 없음.
|
||||||
|
|
||||||
|
### CDN 의존성
|
||||||
|
|
||||||
|
- `xterm` — 터미널 에뮬레이터
|
||||||
|
- `xterm-addon-fit` — 터미널을 컨테이너에 맞춤
|
||||||
|
|
||||||
|
### 기능
|
||||||
|
|
||||||
|
- 전체화면 다크 테마 터미널
|
||||||
|
- 자동 WebSocket 연결 (`ws://` or `wss://`)
|
||||||
|
- xterm.js `onData` → WebSocket send (키 입력)
|
||||||
|
- WebSocket `onmessage` → xterm.js `write` (출력)
|
||||||
|
- `FitAddon`으로 브라우저 리사이즈 감지 → 리사이즈 메시지 전송
|
||||||
|
- 연결 끊김 시 재연결 안내 표시
|
||||||
|
|
||||||
|
### 스타일
|
||||||
|
|
||||||
|
- 배경: `#1a1a2e` (진한 네이비)
|
||||||
|
- 폰트: 시스템 모노스페이스
|
||||||
|
- 터미널이 화면 전체를 차지 (margin: 0, overflow: hidden)
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `github.com/gorilla/websocket` — WebSocket 업그레이드/핸들링
|
||||||
|
- `golang.org/x/crypto/ssh` — 이미 프로젝트에 포함
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
```
|
||||||
|
web/
|
||||||
|
├── server.go — HTTP 서버 + WebSocket→SSH 프록시
|
||||||
|
└── static/
|
||||||
|
└── index.html — xterm.js 클라이언트 (단일 파일)
|
||||||
|
main.go — web.Start() 호출 추가
|
||||||
|
docker-compose.yml — 8080 포트 노출 추가
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- Docker에서 2222 (SSH) + 8080 (HTTP) 두 포트 노출
|
||||||
|
- Caddy/nginx 리버스 프록시로 `play.catacombs.tolelom.xyz` → `:8080` 가능
|
||||||
@@ -2,6 +2,25 @@ package dungeon
|
|||||||
|
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
|
type FloorTheme struct {
|
||||||
|
WallColor string
|
||||||
|
FloorColor string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetFloorTheme(floorNum int) FloorTheme {
|
||||||
|
switch {
|
||||||
|
case floorNum <= 5:
|
||||||
|
return FloorTheme{"90", "245", "Stone Halls"}
|
||||||
|
case floorNum <= 10:
|
||||||
|
return FloorTheme{"22", "28", "Mossy Caverns"}
|
||||||
|
case floorNum <= 15:
|
||||||
|
return FloorTheme{"88", "202", "Lava Depths"}
|
||||||
|
default:
|
||||||
|
return FloorTheme{"53", "129", "Shadow Realm"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ANSI color codes
|
// ANSI color codes
|
||||||
const (
|
const (
|
||||||
ansiReset = "\033[0m"
|
ansiReset = "\033[0m"
|
||||||
@@ -95,6 +114,7 @@ func wallVisible(floor *Floor, owner [][]int, x, y int) Visibility {
|
|||||||
|
|
||||||
// RenderFloor renders the tile map as a colored ASCII string.
|
// RenderFloor renders the tile map as a colored ASCII string.
|
||||||
func RenderFloor(floor *Floor, currentRoom int, showFog bool) string {
|
func RenderFloor(floor *Floor, currentRoom int, showFog bool) string {
|
||||||
|
theme := GetFloorTheme(floor.Number)
|
||||||
if floor == nil || floor.Tiles == nil {
|
if floor == nil || floor.Tiles == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -200,7 +220,14 @@ func RenderFloor(floor *Floor, currentRoom int, showFog bool) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if vis == Visible {
|
if vis == Visible {
|
||||||
buf = append(buf, []byte(fmt.Sprintf("%s%s%c%s", ansiBright, ansiFgWhite, ch, ansiReset))...)
|
switch tile {
|
||||||
|
case TileWall:
|
||||||
|
buf = append(buf, []byte(fmt.Sprintf("\033[38;5;%sm%c\033[0m", theme.WallColor, ch))...)
|
||||||
|
case TileFloor:
|
||||||
|
buf = append(buf, []byte(fmt.Sprintf("\033[38;5;%sm%c\033[0m", theme.FloorColor, ch))...)
|
||||||
|
default:
|
||||||
|
buf = append(buf, []byte(fmt.Sprintf("%s%s%c%s", ansiBright, ansiFgWhite, ch, ansiReset))...)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Visited but not current — dim
|
// Visited but not current — dim
|
||||||
buf = append(buf, []byte(fmt.Sprintf("%s%c%s", ansiFgGray, ch, ansiReset))...)
|
buf = append(buf, []byte(fmt.Sprintf("%s%c%s", ansiFgGray, ch, ansiReset))...)
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ const (
|
|||||||
RelicATKBoost
|
RelicATKBoost
|
||||||
RelicDEFBoost
|
RelicDEFBoost
|
||||||
RelicGoldBoost
|
RelicGoldBoost
|
||||||
|
RelicPoisonImmunity // immune to poison
|
||||||
|
RelicBurnResist // halve burn damage
|
||||||
|
RelicLifeSteal // heal 10% of damage dealt
|
||||||
)
|
)
|
||||||
|
|
||||||
type Relic struct {
|
type Relic struct {
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ var monsterDefs = map[MonsterType]monsterBase{
|
|||||||
MonsterBoss20: {"Archlich", 600, 40, 20, 20, true},
|
MonsterBoss20: {"Archlich", 600, 40, 20, 20, true},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BossPattern int
|
||||||
|
|
||||||
|
const (
|
||||||
|
PatternNone BossPattern = iota
|
||||||
|
PatternAoE // every 3 turns AoE
|
||||||
|
PatternPoison // applies poison
|
||||||
|
PatternBurn // applies burn to random player
|
||||||
|
PatternHeal // heals self
|
||||||
|
)
|
||||||
|
|
||||||
type Monster struct {
|
type Monster struct {
|
||||||
Name string
|
Name string
|
||||||
Type MonsterType
|
Type MonsterType
|
||||||
@@ -41,6 +51,7 @@ type Monster struct {
|
|||||||
IsBoss bool
|
IsBoss bool
|
||||||
TauntTarget bool
|
TauntTarget bool
|
||||||
TauntTurns int
|
TauntTurns int
|
||||||
|
Pattern BossPattern
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMonster(mt MonsterType, floor int) *Monster {
|
func NewMonster(mt MonsterType, floor int) *Monster {
|
||||||
@@ -51,13 +62,14 @@ func NewMonster(mt MonsterType, floor int) *Monster {
|
|||||||
}
|
}
|
||||||
hp := int(math.Round(float64(base.HP) * scale))
|
hp := int(math.Round(float64(base.HP) * scale))
|
||||||
atk := int(math.Round(float64(base.ATK) * scale))
|
atk := int(math.Round(float64(base.ATK) * scale))
|
||||||
|
def := int(math.Round(float64(base.DEF) * scale))
|
||||||
return &Monster{
|
return &Monster{
|
||||||
Name: base.Name,
|
Name: base.Name,
|
||||||
Type: mt,
|
Type: mt,
|
||||||
HP: hp,
|
HP: hp,
|
||||||
MaxHP: hp,
|
MaxHP: hp,
|
||||||
ATK: atk,
|
ATK: atk,
|
||||||
DEF: base.DEF,
|
DEF: def,
|
||||||
IsBoss: base.IsBoss,
|
IsBoss: base.IsBoss,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,3 +23,36 @@ func TestBossStats(t *testing.T) {
|
|||||||
t.Errorf("Boss5: got HP=%d ATK=%d DEF=%d, want 150/15/8", boss.HP, boss.ATK, boss.DEF)
|
t.Errorf("Boss5: got HP=%d ATK=%d DEF=%d, want 150/15/8", boss.HP, boss.ATK, boss.DEF)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMonsterDEFScaling(t *testing.T) {
|
||||||
|
// Slime base DEF=1, minFloor=1. At floor 5, scale = 1.15^4 ≈ 1.749
|
||||||
|
m := NewMonster(MonsterSlime, 5)
|
||||||
|
if m.DEF <= 1 {
|
||||||
|
t.Errorf("Slime DEF at floor 5 should be scaled above base 1, got %d", m.DEF)
|
||||||
|
}
|
||||||
|
// Boss DEF should NOT scale
|
||||||
|
boss := NewMonster(MonsterBoss5, 5)
|
||||||
|
if boss.DEF != 8 {
|
||||||
|
t.Errorf("Boss5 DEF should be base 8, got %d", boss.DEF)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTickTaunt(t *testing.T) {
|
||||||
|
m := &Monster{Name: "Orc", HP: 50, TauntTarget: true, TauntTurns: 2}
|
||||||
|
m.TickTaunt()
|
||||||
|
if m.TauntTurns != 1 || !m.TauntTarget {
|
||||||
|
t.Error("should still be taunted with 1 turn left")
|
||||||
|
}
|
||||||
|
m.TickTaunt()
|
||||||
|
if m.TauntTurns != 0 || m.TauntTarget {
|
||||||
|
t.Error("taunt should be cleared at 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonsterAtMinFloor(t *testing.T) {
|
||||||
|
// Slime at floor 1 (minFloor=1) should have base stats
|
||||||
|
m := NewMonster(MonsterSlime, 1)
|
||||||
|
if m.HP != 20 || m.ATK != 5 || m.DEF != 1 {
|
||||||
|
t.Errorf("Slime at min floor should be base stats, got HP=%d ATK=%d DEF=%d", m.HP, m.ATK, m.DEF)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package entity
|
package entity
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
type Class int
|
type Class int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -24,6 +26,20 @@ var classBaseStats = map[Class]classStats{
|
|||||||
ClassRogue: {85, 15, 4},
|
ClassRogue: {85, 15, 4},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StatusEffect int
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusPoison StatusEffect = iota
|
||||||
|
StatusBurn
|
||||||
|
StatusFreeze
|
||||||
|
)
|
||||||
|
|
||||||
|
type ActiveEffect struct {
|
||||||
|
Type StatusEffect
|
||||||
|
Duration int // remaining turns
|
||||||
|
Value int // damage per turn or effect strength
|
||||||
|
}
|
||||||
|
|
||||||
type Player struct {
|
type Player struct {
|
||||||
Name string
|
Name string
|
||||||
Fingerprint string
|
Fingerprint string
|
||||||
@@ -33,7 +49,10 @@ type Player struct {
|
|||||||
Gold int
|
Gold int
|
||||||
Inventory []Item
|
Inventory []Item
|
||||||
Relics []Relic
|
Relics []Relic
|
||||||
|
Effects []ActiveEffect
|
||||||
Dead bool
|
Dead bool
|
||||||
|
Fled bool
|
||||||
|
SkillUses int // remaining skill uses this combat
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPlayer(name string, class Class) *Player {
|
func NewPlayer(name string, class Class) *Player {
|
||||||
@@ -67,6 +86,10 @@ func (p *Player) IsDead() bool {
|
|||||||
return p.Dead
|
return p.Dead
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Player) IsOut() bool {
|
||||||
|
return p.Dead || p.Fled
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Player) Revive(hpPercent float64) {
|
func (p *Player) Revive(hpPercent float64) {
|
||||||
p.Dead = false
|
p.Dead = false
|
||||||
p.HP = int(float64(p.MaxHP) * hpPercent)
|
p.HP = int(float64(p.MaxHP) * hpPercent)
|
||||||
@@ -104,3 +127,59 @@ func (p *Player) EffectiveDEF() int {
|
|||||||
}
|
}
|
||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Player) AddEffect(e ActiveEffect) {
|
||||||
|
// Check relic immunities
|
||||||
|
for _, r := range p.Relics {
|
||||||
|
if e.Type == StatusPoison && r.Effect == RelicPoisonImmunity {
|
||||||
|
return // immune
|
||||||
|
}
|
||||||
|
if e.Type == StatusBurn && r.Effect == RelicBurnResist {
|
||||||
|
e.Value = e.Value / 2 // halve burn damage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Don't stack same type, refresh duration
|
||||||
|
for i, existing := range p.Effects {
|
||||||
|
if existing.Type == e.Type {
|
||||||
|
p.Effects[i] = e
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Effects = append(p.Effects, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Player) HasEffect(t StatusEffect) bool {
|
||||||
|
for _, e := range p.Effects {
|
||||||
|
if e.Type == t {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Player) TickEffects() (damages []string) {
|
||||||
|
var remaining []ActiveEffect
|
||||||
|
for _, e := range p.Effects {
|
||||||
|
switch e.Type {
|
||||||
|
case StatusPoison:
|
||||||
|
p.HP -= e.Value
|
||||||
|
if p.HP <= 0 {
|
||||||
|
p.HP = 1 // Poison can't kill, leaves at 1 HP
|
||||||
|
}
|
||||||
|
damages = append(damages, fmt.Sprintf("%s takes %d poison damage", p.Name, e.Value))
|
||||||
|
case StatusBurn:
|
||||||
|
p.HP -= e.Value
|
||||||
|
if p.HP <= 0 {
|
||||||
|
p.HP = 0
|
||||||
|
p.Dead = true
|
||||||
|
}
|
||||||
|
damages = append(damages, fmt.Sprintf("%s takes %d burn damage", p.Name, e.Value))
|
||||||
|
}
|
||||||
|
e.Duration--
|
||||||
|
if e.Duration > 0 {
|
||||||
|
remaining = append(remaining, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Effects = remaining
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,3 +63,130 @@ func TestPlayerTakeDamage(t *testing.T) {
|
|||||||
t.Error("Player should be dead")
|
t.Error("Player should be dead")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsOut(t *testing.T) {
|
||||||
|
p := NewPlayer("test", ClassWarrior)
|
||||||
|
if p.IsOut() {
|
||||||
|
t.Error("alive player should not be out")
|
||||||
|
}
|
||||||
|
p.Dead = true
|
||||||
|
if !p.IsOut() {
|
||||||
|
t.Error("dead player should be out")
|
||||||
|
}
|
||||||
|
p.Dead = false
|
||||||
|
p.Fled = true
|
||||||
|
if !p.IsOut() {
|
||||||
|
t.Error("fled player should be out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevive(t *testing.T) {
|
||||||
|
p := NewPlayer("test", ClassWarrior) // 120 MaxHP
|
||||||
|
p.TakeDamage(200)
|
||||||
|
if !p.IsDead() {
|
||||||
|
t.Error("should be dead")
|
||||||
|
}
|
||||||
|
p.Revive(0.30)
|
||||||
|
if p.IsDead() {
|
||||||
|
t.Error("should be alive after revive")
|
||||||
|
}
|
||||||
|
if p.HP != 36 { // 120 * 0.30
|
||||||
|
t.Errorf("HP should be 36, got %d", p.HP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealCap(t *testing.T) {
|
||||||
|
p := NewPlayer("test", ClassWarrior) // 120 HP
|
||||||
|
p.HP = 100
|
||||||
|
p.Heal(50) // should cap at 120
|
||||||
|
if p.HP != 120 {
|
||||||
|
t.Errorf("HP should cap at 120, got %d", p.HP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEffectiveATKWithItems(t *testing.T) {
|
||||||
|
p := NewPlayer("test", ClassWarrior) // base ATK 12
|
||||||
|
p.Inventory = append(p.Inventory, Item{Name: "Sword", Type: ItemWeapon, Bonus: 5})
|
||||||
|
p.Inventory = append(p.Inventory, Item{Name: "Sword2", Type: ItemWeapon, Bonus: 3})
|
||||||
|
if p.EffectiveATK() != 20 { // 12 + 5 + 3
|
||||||
|
t.Errorf("ATK should be 20, got %d", p.EffectiveATK())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEffectiveDEFWithItems(t *testing.T) {
|
||||||
|
p := NewPlayer("test", ClassWarrior) // base DEF 8
|
||||||
|
p.Inventory = append(p.Inventory, Item{Name: "Shield", Type: ItemArmor, Bonus: 4})
|
||||||
|
if p.EffectiveDEF() != 12 { // 8 + 4
|
||||||
|
t.Errorf("DEF should be 12, got %d", p.EffectiveDEF())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusEffectPoison(t *testing.T) {
|
||||||
|
p := NewPlayer("test", ClassWarrior) // 120 HP
|
||||||
|
p.AddEffect(ActiveEffect{Type: StatusPoison, Duration: 2, Value: 10})
|
||||||
|
if !p.HasEffect(StatusPoison) {
|
||||||
|
t.Error("should have poison")
|
||||||
|
}
|
||||||
|
msgs := p.TickEffects()
|
||||||
|
if len(msgs) != 1 {
|
||||||
|
t.Errorf("expected 1 message, got %d", len(msgs))
|
||||||
|
}
|
||||||
|
if p.HP != 110 {
|
||||||
|
t.Errorf("HP should be 110 after poison tick, got %d", p.HP)
|
||||||
|
}
|
||||||
|
// Poison can't kill
|
||||||
|
p.HP = 5
|
||||||
|
p.TickEffects() // duration expires after this tick
|
||||||
|
if p.HP != 1 {
|
||||||
|
t.Errorf("poison should leave at 1 HP, got %d", p.HP)
|
||||||
|
}
|
||||||
|
if p.IsDead() {
|
||||||
|
t.Error("poison should not kill")
|
||||||
|
}
|
||||||
|
if p.HasEffect(StatusPoison) {
|
||||||
|
t.Error("poison should have expired")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusEffectBurn(t *testing.T) {
|
||||||
|
p := NewPlayer("test", ClassMage) // 70 HP
|
||||||
|
p.AddEffect(ActiveEffect{Type: StatusBurn, Duration: 1, Value: 100})
|
||||||
|
p.TickEffects()
|
||||||
|
if !p.IsDead() {
|
||||||
|
t.Error("burn should be able to kill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelicPoisonImmunity(t *testing.T) {
|
||||||
|
p := NewPlayer("test", ClassWarrior)
|
||||||
|
p.Relics = append(p.Relics, Relic{Name: "Antidote", Effect: RelicPoisonImmunity})
|
||||||
|
p.AddEffect(ActiveEffect{Type: StatusPoison, Duration: 3, Value: 10})
|
||||||
|
if p.HasEffect(StatusPoison) {
|
||||||
|
t.Error("should be immune to poison")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelicBurnResist(t *testing.T) {
|
||||||
|
p := NewPlayer("test", ClassWarrior)
|
||||||
|
p.Relics = append(p.Relics, Relic{Name: "Flame Guard", Effect: RelicBurnResist})
|
||||||
|
p.AddEffect(ActiveEffect{Type: StatusBurn, Duration: 2, Value: 10})
|
||||||
|
// Burn value should be halved to 5
|
||||||
|
if len(p.Effects) == 0 {
|
||||||
|
t.Fatal("should have burn effect (resisted, not immune)")
|
||||||
|
}
|
||||||
|
if p.Effects[0].Value != 5 {
|
||||||
|
t.Errorf("burn value should be halved to 5, got %d", p.Effects[0].Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEffectOverwrite(t *testing.T) {
|
||||||
|
p := NewPlayer("test", ClassWarrior)
|
||||||
|
p.AddEffect(ActiveEffect{Type: StatusPoison, Duration: 1, Value: 5})
|
||||||
|
p.AddEffect(ActiveEffect{Type: StatusPoison, Duration: 3, Value: 10}) // should overwrite
|
||||||
|
if len(p.Effects) != 1 {
|
||||||
|
t.Errorf("should have 1 effect, got %d", len(p.Effects))
|
||||||
|
}
|
||||||
|
if p.Effects[0].Duration != 3 || p.Effects[0].Value != 10 {
|
||||||
|
t.Error("should have overwritten with new values")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
111
game/event.go
111
game/event.go
@@ -1,7 +1,9 @@
|
|||||||
package game
|
package game
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/tolelom/catacombs/dungeon"
|
"github.com/tolelom/catacombs/dungeon"
|
||||||
"github.com/tolelom/catacombs/entity"
|
"github.com/tolelom/catacombs/entity"
|
||||||
@@ -11,6 +13,13 @@ func (s *GameSession) EnterRoom(roomIdx int) {
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for _, p := range s.state.Players {
|
||||||
|
if p.Fingerprint != "" {
|
||||||
|
s.lastActivity[p.Fingerprint] = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
s.state.Floor.CurrentRoom = roomIdx
|
s.state.Floor.CurrentRoom = roomIdx
|
||||||
dungeon.UpdateVisibility(s.state.Floor)
|
dungeon.UpdateVisibility(s.state.Floor)
|
||||||
room := s.state.Floor.Rooms[roomIdx]
|
room := s.state.Floor.Rooms[roomIdx]
|
||||||
@@ -79,9 +88,15 @@ func (s *GameSession) spawnMonsters() {
|
|||||||
m.HP = 1
|
m.HP = 1
|
||||||
}
|
}
|
||||||
m.MaxHP = m.HP
|
m.MaxHP = m.HP
|
||||||
|
m.DEF = m.DEF / 2
|
||||||
}
|
}
|
||||||
s.state.Monsters[i] = m
|
s.state.Monsters[i] = m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset skill uses for all players at combat start
|
||||||
|
for _, p := range s.state.Players {
|
||||||
|
p.SkillUses = 3
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameSession) spawnBoss() {
|
func (s *GameSession) spawnBoss() {
|
||||||
@@ -99,50 +114,116 @@ func (s *GameSession) spawnBoss() {
|
|||||||
mt = entity.MonsterBoss5
|
mt = entity.MonsterBoss5
|
||||||
}
|
}
|
||||||
boss := entity.NewMonster(mt, s.state.FloorNum)
|
boss := entity.NewMonster(mt, s.state.FloorNum)
|
||||||
|
switch mt {
|
||||||
|
case entity.MonsterBoss5:
|
||||||
|
boss.Pattern = entity.PatternAoE
|
||||||
|
case entity.MonsterBoss10:
|
||||||
|
boss.Pattern = entity.PatternPoison
|
||||||
|
case entity.MonsterBoss15:
|
||||||
|
boss.Pattern = entity.PatternBurn
|
||||||
|
case entity.MonsterBoss20:
|
||||||
|
boss.Pattern = entity.PatternHeal
|
||||||
|
}
|
||||||
if s.state.SoloMode {
|
if s.state.SoloMode {
|
||||||
boss.HP = boss.HP / 2
|
boss.HP = boss.HP / 2
|
||||||
boss.MaxHP = boss.HP
|
boss.MaxHP = boss.HP
|
||||||
|
boss.DEF = boss.DEF / 2
|
||||||
}
|
}
|
||||||
s.state.Monsters = []*entity.Monster{boss}
|
s.state.Monsters = []*entity.Monster{boss}
|
||||||
|
|
||||||
|
// Reset skill uses for all players at combat start
|
||||||
|
for _, p := range s.state.Players {
|
||||||
|
p.SkillUses = 3
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameSession) grantTreasure() {
|
func (s *GameSession) grantTreasure() {
|
||||||
// Random item for each player
|
floor := s.state.FloorNum
|
||||||
for _, p := range s.state.Players {
|
for _, p := range s.state.Players {
|
||||||
|
if len(p.Inventory) >= 10 {
|
||||||
|
s.addLog(fmt.Sprintf("%s's inventory is full!", p.Name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
if rand.Float64() < 0.5 {
|
if rand.Float64() < 0.5 {
|
||||||
p.Inventory = append(p.Inventory, entity.Item{
|
bonus := 3 + rand.Intn(6) + floor/3
|
||||||
Name: "Iron Sword", Type: entity.ItemWeapon, Bonus: 3 + rand.Intn(6),
|
item := entity.Item{
|
||||||
})
|
Name: weaponName(floor), Type: entity.ItemWeapon, Bonus: bonus,
|
||||||
|
}
|
||||||
|
p.Inventory = append(p.Inventory, item)
|
||||||
|
s.addLog(fmt.Sprintf("%s found %s (ATK+%d)", p.Name, item.Name, item.Bonus))
|
||||||
} else {
|
} else {
|
||||||
p.Inventory = append(p.Inventory, entity.Item{
|
bonus := 2 + rand.Intn(4) + floor/4
|
||||||
Name: "Iron Shield", Type: entity.ItemArmor, Bonus: 2 + rand.Intn(4),
|
item := entity.Item{
|
||||||
})
|
Name: armorName(floor), Type: entity.ItemArmor, Bonus: bonus,
|
||||||
|
}
|
||||||
|
p.Inventory = append(p.Inventory, item)
|
||||||
|
s.addLog(fmt.Sprintf("%s found %s (DEF+%d)", p.Name, item.Name, item.Bonus))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameSession) generateShopItems() {
|
func (s *GameSession) generateShopItems() {
|
||||||
|
floor := s.state.FloorNum
|
||||||
|
// Weapon bonus scales: base 3-8 + floor/3
|
||||||
|
weaponBonus := 3 + rand.Intn(6) + floor/3
|
||||||
|
// Armor bonus scales: base 2-5 + floor/4
|
||||||
|
armorBonus := 2 + rand.Intn(4) + floor/4
|
||||||
|
// Prices scale with power
|
||||||
|
weaponPrice := 40 + weaponBonus*5
|
||||||
|
armorPrice := 30 + armorBonus*5
|
||||||
|
// Potion heals more on higher floors
|
||||||
|
potionHeal := 30 + floor
|
||||||
|
potionPrice := 20 + floor/2
|
||||||
|
|
||||||
s.state.ShopItems = []entity.Item{
|
s.state.ShopItems = []entity.Item{
|
||||||
{Name: "HP Potion", Type: entity.ItemConsumable, Bonus: 30, Price: 20},
|
{Name: "HP Potion", Type: entity.ItemConsumable, Bonus: potionHeal, Price: potionPrice},
|
||||||
{Name: "Iron Sword", Type: entity.ItemWeapon, Bonus: 3 + rand.Intn(6), Price: 40 + rand.Intn(41)},
|
{Name: weaponName(floor), Type: entity.ItemWeapon, Bonus: weaponBonus, Price: weaponPrice},
|
||||||
{Name: "Iron Shield", Type: entity.ItemArmor, Bonus: 2 + rand.Intn(4), Price: 30 + rand.Intn(31)},
|
{Name: armorName(floor), Type: entity.ItemArmor, Bonus: armorBonus, Price: armorPrice},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func weaponName(floor int) string {
|
||||||
|
switch {
|
||||||
|
case floor >= 15:
|
||||||
|
return "Mythril Blade"
|
||||||
|
case floor >= 10:
|
||||||
|
return "Steel Sword"
|
||||||
|
case floor >= 5:
|
||||||
|
return "Bronze Sword"
|
||||||
|
default:
|
||||||
|
return "Iron Sword"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func armorName(floor int) string {
|
||||||
|
switch {
|
||||||
|
case floor >= 15:
|
||||||
|
return "Mythril Shield"
|
||||||
|
case floor >= 10:
|
||||||
|
return "Steel Shield"
|
||||||
|
case floor >= 5:
|
||||||
|
return "Bronze Shield"
|
||||||
|
default:
|
||||||
|
return "Iron Shield"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameSession) triggerEvent() {
|
func (s *GameSession) triggerEvent() {
|
||||||
// Random event: 50% trap, 50% blessing
|
|
||||||
for _, p := range s.state.Players {
|
for _, p := range s.state.Players {
|
||||||
if p.IsDead() {
|
if p.IsDead() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if rand.Float64() < 0.5 {
|
if rand.Float64() < 0.5 {
|
||||||
// Trap: 10~20 damage
|
baseDmg := 10 + s.state.FloorNum
|
||||||
dmg := 10 + rand.Intn(11)
|
dmg := baseDmg + rand.Intn(baseDmg/2+1)
|
||||||
p.TakeDamage(dmg)
|
p.TakeDamage(dmg)
|
||||||
|
s.addLog(fmt.Sprintf("Trap! %s takes %d damage", p.Name, dmg))
|
||||||
} else {
|
} else {
|
||||||
// Blessing: heal 15~25
|
baseHeal := 15 + s.state.FloorNum
|
||||||
heal := 15 + rand.Intn(11)
|
heal := baseHeal + rand.Intn(baseHeal/2+1)
|
||||||
|
before := p.HP
|
||||||
p.Heal(heal)
|
p.Heal(heal)
|
||||||
|
s.addLog(fmt.Sprintf("Blessing! %s heals %d HP", p.Name, p.HP-before))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
147
game/lobby.go
147
game/lobby.go
@@ -13,21 +13,109 @@ const (
|
|||||||
RoomPlaying
|
RoomPlaying
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type LobbyPlayer struct {
|
||||||
|
Name string
|
||||||
|
Class string // empty until class selected
|
||||||
|
Fingerprint string
|
||||||
|
Ready bool
|
||||||
|
}
|
||||||
|
|
||||||
type LobbyRoom struct {
|
type LobbyRoom struct {
|
||||||
Code string
|
Code string
|
||||||
Name string
|
Name string
|
||||||
Players []string
|
Players []LobbyPlayer
|
||||||
Status RoomStatus
|
Status RoomStatus
|
||||||
Session *GameSession
|
Session *GameSession
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OnlinePlayer struct {
|
||||||
|
Name string
|
||||||
|
Fingerprint string
|
||||||
|
InRoom string // room code, empty if in lobby
|
||||||
|
}
|
||||||
|
|
||||||
type Lobby struct {
|
type Lobby struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
rooms map[string]*LobbyRoom
|
rooms map[string]*LobbyRoom
|
||||||
|
online map[string]*OnlinePlayer // fingerprint -> player
|
||||||
|
activeSessions map[string]string // fingerprint -> room code (for reconnect)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLobby() *Lobby {
|
func NewLobby() *Lobby {
|
||||||
return &Lobby{rooms: make(map[string]*LobbyRoom)}
|
return &Lobby{
|
||||||
|
rooms: make(map[string]*LobbyRoom),
|
||||||
|
online: make(map[string]*OnlinePlayer),
|
||||||
|
activeSessions: make(map[string]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lobby) RegisterSession(fingerprint, roomCode string) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
l.activeSessions[fingerprint] = roomCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lobby) UnregisterSession(fingerprint string) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
delete(l.activeSessions, fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lobby) GetActiveSession(fingerprint string) (string, *GameSession) {
|
||||||
|
l.mu.RLock()
|
||||||
|
defer l.mu.RUnlock()
|
||||||
|
code, ok := l.activeSessions[fingerprint]
|
||||||
|
if !ok {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
room, ok := l.rooms[code]
|
||||||
|
if !ok || room.Session == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
// Check if this player is still in the session
|
||||||
|
for _, p := range room.Session.GetState().Players {
|
||||||
|
if p.Fingerprint == fingerprint {
|
||||||
|
return code, room.Session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lobby) PlayerOnline(fingerprint, name string) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
l.online[fingerprint] = &OnlinePlayer{Name: name, Fingerprint: fingerprint}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lobby) PlayerOffline(fingerprint string) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
delete(l.online, fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lobby) ListOnline() []*OnlinePlayer {
|
||||||
|
l.mu.RLock()
|
||||||
|
defer l.mu.RUnlock()
|
||||||
|
result := make([]*OnlinePlayer, 0, len(l.online))
|
||||||
|
for _, p := range l.online {
|
||||||
|
result = append(result, p)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lobby) InvitePlayer(roomCode, fingerprint string) error {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
p, ok := l.online[fingerprint]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("player not online")
|
||||||
|
}
|
||||||
|
if p.InRoom != "" {
|
||||||
|
return fmt.Errorf("player already in a room")
|
||||||
|
}
|
||||||
|
// Store the invite as a pending field
|
||||||
|
p.InRoom = "invited:" + roomCode
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Lobby) CreateRoom(name string) string {
|
func (l *Lobby) CreateRoom(name string) string {
|
||||||
@@ -45,7 +133,7 @@ func (l *Lobby) CreateRoom(name string) string {
|
|||||||
return code
|
return code
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Lobby) JoinRoom(code, playerName string) error {
|
func (l *Lobby) JoinRoom(code, playerName, fingerprint string) error {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
room, ok := l.rooms[code]
|
room, ok := l.rooms[code]
|
||||||
@@ -58,10 +146,49 @@ func (l *Lobby) JoinRoom(code, playerName string) error {
|
|||||||
if room.Status != RoomWaiting {
|
if room.Status != RoomWaiting {
|
||||||
return fmt.Errorf("room %s already in progress", code)
|
return fmt.Errorf("room %s already in progress", code)
|
||||||
}
|
}
|
||||||
room.Players = append(room.Players, playerName)
|
room.Players = append(room.Players, LobbyPlayer{Name: playerName, Fingerprint: fingerprint})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *Lobby) SetPlayerClass(code, fingerprint, class string) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if room, ok := l.rooms[code]; ok {
|
||||||
|
for i := range room.Players {
|
||||||
|
if room.Players[i].Fingerprint == fingerprint {
|
||||||
|
room.Players[i].Class = class
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lobby) SetPlayerReady(code, fingerprint string, ready bool) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if room, ok := l.rooms[code]; ok {
|
||||||
|
for i := range room.Players {
|
||||||
|
if room.Players[i].Fingerprint == fingerprint {
|
||||||
|
room.Players[i].Ready = ready
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lobby) AllReady(code string) bool {
|
||||||
|
l.mu.RLock()
|
||||||
|
defer l.mu.RUnlock()
|
||||||
|
room, ok := l.rooms[code]
|
||||||
|
if !ok || len(room.Players) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, p := range room.Players {
|
||||||
|
if !p.Ready {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (l *Lobby) GetRoom(code string) *LobbyRoom {
|
func (l *Lobby) GetRoom(code string) *LobbyRoom {
|
||||||
l.mu.RLock()
|
l.mu.RLock()
|
||||||
defer l.mu.RUnlock()
|
defer l.mu.RUnlock()
|
||||||
@@ -78,6 +205,14 @@ func (l *Lobby) ListRooms() []*LobbyRoom {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *Lobby) StartRoom(code string) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if room, ok := l.rooms[code]; ok {
|
||||||
|
room.Status = RoomPlaying
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (l *Lobby) RemoveRoom(code string) {
|
func (l *Lobby) RemoveRoom(code string) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func TestCreateRoom(t *testing.T) {
|
|||||||
func TestJoinRoom(t *testing.T) {
|
func TestJoinRoom(t *testing.T) {
|
||||||
lobby := NewLobby()
|
lobby := NewLobby()
|
||||||
code := lobby.CreateRoom("Test Room")
|
code := lobby.CreateRoom("Test Room")
|
||||||
err := lobby.JoinRoom(code, "player1")
|
err := lobby.JoinRoom(code, "player1", "fp-player1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Join failed: %v", err)
|
t.Errorf("Join failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -27,14 +27,73 @@ func TestJoinRoom(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRoomStatusTransition(t *testing.T) {
|
||||||
|
l := NewLobby()
|
||||||
|
code := l.CreateRoom("Test")
|
||||||
|
l.JoinRoom(code, "Alice", "fp-alice")
|
||||||
|
r := l.GetRoom(code)
|
||||||
|
if r.Status != RoomWaiting {
|
||||||
|
t.Errorf("new room should be Waiting, got %d", r.Status)
|
||||||
|
}
|
||||||
|
l.StartRoom(code)
|
||||||
|
r = l.GetRoom(code)
|
||||||
|
if r.Status != RoomPlaying {
|
||||||
|
t.Errorf("started room should be Playing, got %d", r.Status)
|
||||||
|
}
|
||||||
|
err := l.JoinRoom(code, "Bob", "fp-bob")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("should not be able to join a Playing room")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestJoinRoomFull(t *testing.T) {
|
func TestJoinRoomFull(t *testing.T) {
|
||||||
lobby := NewLobby()
|
lobby := NewLobby()
|
||||||
code := lobby.CreateRoom("Test Room")
|
code := lobby.CreateRoom("Test Room")
|
||||||
for i := 0; i < 4; i++ {
|
for i := 0; i < 4; i++ {
|
||||||
lobby.JoinRoom(code, "player")
|
lobby.JoinRoom(code, "player", "fp-player")
|
||||||
}
|
}
|
||||||
err := lobby.JoinRoom(code, "player5")
|
err := lobby.JoinRoom(code, "player5", "fp-player5")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Should reject 5th player")
|
t.Error("Should reject 5th player")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSetPlayerClass(t *testing.T) {
|
||||||
|
l := NewLobby()
|
||||||
|
code := l.CreateRoom("Test")
|
||||||
|
l.JoinRoom(code, "Alice", "fp-alice")
|
||||||
|
l.SetPlayerClass(code, "fp-alice", "Warrior")
|
||||||
|
room := l.GetRoom(code)
|
||||||
|
if room.Players[0].Class != "Warrior" {
|
||||||
|
t.Errorf("expected class Warrior, got %s", room.Players[0].Class)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllReady(t *testing.T) {
|
||||||
|
l := NewLobby()
|
||||||
|
code := l.CreateRoom("Test")
|
||||||
|
l.JoinRoom(code, "Alice", "fp-alice")
|
||||||
|
l.JoinRoom(code, "Bob", "fp-bob")
|
||||||
|
|
||||||
|
if l.AllReady(code) {
|
||||||
|
t.Error("no one ready yet, should return false")
|
||||||
|
}
|
||||||
|
|
||||||
|
l.SetPlayerReady(code, "fp-alice", true)
|
||||||
|
if l.AllReady(code) {
|
||||||
|
t.Error("only Alice ready, should return false")
|
||||||
|
}
|
||||||
|
|
||||||
|
l.SetPlayerReady(code, "fp-bob", true)
|
||||||
|
if !l.AllReady(code) {
|
||||||
|
t.Error("both ready, should return true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllReadyEmptyRoom(t *testing.T) {
|
||||||
|
l := NewLobby()
|
||||||
|
code := l.CreateRoom("Test")
|
||||||
|
if l.AllReady(code) {
|
||||||
|
t.Error("empty room should not be all ready")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
212
game/session.go
212
game/session.go
@@ -1,6 +1,7 @@
|
|||||||
package game
|
package game
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -44,15 +45,23 @@ type GameState struct {
|
|||||||
GameOver bool
|
GameOver bool
|
||||||
Victory bool
|
Victory bool
|
||||||
ShopItems []entity.Item
|
ShopItems []entity.Item
|
||||||
CombatLog []string // recent combat messages
|
CombatLog []string // recent combat messages
|
||||||
TurnDeadline time.Time
|
TurnDeadline time.Time
|
||||||
|
SubmittedActions map[string]string // fingerprint -> action description
|
||||||
|
PendingLogs []string // logs waiting to be revealed one by one
|
||||||
|
TurnResolving bool // true while logs are being replayed
|
||||||
|
BossKilled bool
|
||||||
|
FleeSucceeded bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameSession) addLog(msg string) {
|
func (s *GameSession) addLog(msg string) {
|
||||||
s.state.CombatLog = append(s.state.CombatLog, msg)
|
if s.state.TurnResolving {
|
||||||
// Keep last 5 messages
|
s.state.PendingLogs = append(s.state.PendingLogs, msg)
|
||||||
if len(s.state.CombatLog) > 5 {
|
} else {
|
||||||
s.state.CombatLog = s.state.CombatLog[len(s.state.CombatLog)-5:]
|
s.state.CombatLog = append(s.state.CombatLog, msg)
|
||||||
|
if len(s.state.CombatLog) > 8 {
|
||||||
|
s.state.CombatLog = s.state.CombatLog[len(s.state.CombatLog)-8:]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,11 +76,13 @@ type GameSession struct {
|
|||||||
actions map[string]PlayerAction // playerName -> action
|
actions map[string]PlayerAction // playerName -> action
|
||||||
actionCh chan playerActionMsg
|
actionCh chan playerActionMsg
|
||||||
combatSignal chan struct{}
|
combatSignal chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
lastActivity map[string]time.Time // fingerprint -> last activity time
|
||||||
}
|
}
|
||||||
|
|
||||||
type playerActionMsg struct {
|
type playerActionMsg struct {
|
||||||
PlayerName string
|
PlayerID string
|
||||||
Action PlayerAction
|
Action PlayerAction
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGameSession() *GameSession {
|
func NewGameSession() *GameSession {
|
||||||
@@ -82,6 +93,17 @@ func NewGameSession() *GameSession {
|
|||||||
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),
|
combatSignal: make(chan struct{}, 1),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
lastActivity: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GameSession) Stop() {
|
||||||
|
select {
|
||||||
|
case <-s.done:
|
||||||
|
// already stopped
|
||||||
|
default:
|
||||||
|
close(s.done)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +116,10 @@ func (s *GameSession) StartGame() {
|
|||||||
}
|
}
|
||||||
s.started = true
|
s.started = true
|
||||||
s.state.SoloMode = len(s.state.Players) == 1
|
s.state.SoloMode = len(s.state.Players) == 1
|
||||||
|
now := time.Now()
|
||||||
|
for _, p := range s.state.Players {
|
||||||
|
s.lastActivity[p.Fingerprint] = now
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
s.StartFloor()
|
s.StartFloor()
|
||||||
go s.combatLoop()
|
go s.combatLoop()
|
||||||
@@ -102,6 +128,12 @@ func (s *GameSession) StartGame() {
|
|||||||
// combatLoop continuously runs turns while in combat phase
|
// combatLoop continuously runs turns while in combat phase
|
||||||
func (s *GameSession) combatLoop() {
|
func (s *GameSession) combatLoop() {
|
||||||
for {
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
phase := s.state.Phase
|
phase := s.state.Phase
|
||||||
gameOver := s.state.GameOver
|
gameOver := s.state.GameOver
|
||||||
@@ -111,14 +143,40 @@ func (s *GameSession) combatLoop() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove players inactive for >60 seconds
|
||||||
|
s.mu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
changed := false
|
||||||
|
remaining := make([]*entity.Player, 0, len(s.state.Players))
|
||||||
|
for _, p := range s.state.Players {
|
||||||
|
if p.Fingerprint != "" && !p.IsOut() {
|
||||||
|
if last, ok := s.lastActivity[p.Fingerprint]; ok {
|
||||||
|
if now.Sub(last) > 60*time.Second {
|
||||||
|
s.addLog(fmt.Sprintf("%s removed (disconnected)", p.Name))
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remaining = append(remaining, p)
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
s.state.Players = remaining
|
||||||
|
if len(s.state.Players) == 0 {
|
||||||
|
s.state.GameOver = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
if phase == PhaseCombat {
|
if phase == PhaseCombat {
|
||||||
s.RunTurn() // blocks until all actions collected or timeout
|
s.RunTurn()
|
||||||
} else {
|
} else {
|
||||||
// Not in combat, wait for an action signal to avoid busy-spinning
|
|
||||||
// We'll just sleep briefly and re-check
|
|
||||||
select {
|
select {
|
||||||
case <-s.combatSignal:
|
case <-s.combatSignal:
|
||||||
// Room entered, combat may have started
|
case <-s.done:
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,15 +213,125 @@ func (s *GameSession) StartFloor() {
|
|||||||
func (s *GameSession) GetState() GameState {
|
func (s *GameSession) GetState() GameState {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
return s.state
|
|
||||||
|
// Deep copy players
|
||||||
|
players := make([]*entity.Player, len(s.state.Players))
|
||||||
|
for i, p := range s.state.Players {
|
||||||
|
cp := *p
|
||||||
|
cp.Inventory = make([]entity.Item, len(p.Inventory))
|
||||||
|
copy(cp.Inventory, p.Inventory)
|
||||||
|
cp.Relics = make([]entity.Relic, len(p.Relics))
|
||||||
|
copy(cp.Relics, p.Relics)
|
||||||
|
cp.Effects = make([]entity.ActiveEffect, len(p.Effects))
|
||||||
|
copy(cp.Effects, p.Effects)
|
||||||
|
players[i] = &cp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep copy monsters
|
||||||
|
monsters := make([]*entity.Monster, len(s.state.Monsters))
|
||||||
|
for i, m := range s.state.Monsters {
|
||||||
|
cm := *m
|
||||||
|
monsters[i] = &cm
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep copy floor
|
||||||
|
var floorCopy *dungeon.Floor
|
||||||
|
if s.state.Floor != nil {
|
||||||
|
fc := *s.state.Floor
|
||||||
|
fc.Rooms = make([]*dungeon.Room, len(s.state.Floor.Rooms))
|
||||||
|
for i, r := range s.state.Floor.Rooms {
|
||||||
|
rc := *r
|
||||||
|
rc.Neighbors = make([]int, len(r.Neighbors))
|
||||||
|
copy(rc.Neighbors, r.Neighbors)
|
||||||
|
fc.Rooms[i] = &rc
|
||||||
|
}
|
||||||
|
floorCopy = &fc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy combat log
|
||||||
|
logCopy := make([]string, len(s.state.CombatLog))
|
||||||
|
copy(logCopy, s.state.CombatLog)
|
||||||
|
|
||||||
|
// Copy submitted actions
|
||||||
|
submittedCopy := make(map[string]string, len(s.state.SubmittedActions))
|
||||||
|
for k, v := range s.state.SubmittedActions {
|
||||||
|
submittedCopy[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy pending logs
|
||||||
|
pendingCopy := make([]string, len(s.state.PendingLogs))
|
||||||
|
copy(pendingCopy, s.state.PendingLogs)
|
||||||
|
|
||||||
|
return GameState{
|
||||||
|
Floor: floorCopy,
|
||||||
|
Players: players,
|
||||||
|
Monsters: monsters,
|
||||||
|
Phase: s.state.Phase,
|
||||||
|
FloorNum: s.state.FloorNum,
|
||||||
|
TurnNum: s.state.TurnNum,
|
||||||
|
CombatTurn: s.state.CombatTurn,
|
||||||
|
SoloMode: s.state.SoloMode,
|
||||||
|
GameOver: s.state.GameOver,
|
||||||
|
Victory: s.state.Victory,
|
||||||
|
ShopItems: append([]entity.Item{}, s.state.ShopItems...),
|
||||||
|
CombatLog: logCopy,
|
||||||
|
TurnDeadline: s.state.TurnDeadline,
|
||||||
|
SubmittedActions: submittedCopy,
|
||||||
|
PendingLogs: pendingCopy,
|
||||||
|
TurnResolving: s.state.TurnResolving,
|
||||||
|
BossKilled: s.state.BossKilled,
|
||||||
|
FleeSucceeded: s.state.FleeSucceeded,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameSession) SubmitAction(playerName string, action PlayerAction) {
|
func (s *GameSession) SubmitAction(playerID string, action PlayerAction) {
|
||||||
s.actionCh <- playerActionMsg{PlayerName: playerName, Action: action}
|
s.mu.Lock()
|
||||||
|
s.lastActivity[playerID] = time.Now()
|
||||||
|
desc := ""
|
||||||
|
switch action.Type {
|
||||||
|
case ActionAttack:
|
||||||
|
desc = "Attacking"
|
||||||
|
case ActionSkill:
|
||||||
|
desc = "Using Skill"
|
||||||
|
case ActionItem:
|
||||||
|
desc = "Using Item"
|
||||||
|
case ActionFlee:
|
||||||
|
desc = "Fleeing"
|
||||||
|
case ActionWait:
|
||||||
|
desc = "Defending"
|
||||||
|
}
|
||||||
|
if s.state.SubmittedActions == nil {
|
||||||
|
s.state.SubmittedActions = make(map[string]string)
|
||||||
|
}
|
||||||
|
s.state.SubmittedActions[playerID] = desc
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.actionCh <- playerActionMsg{PlayerID: playerID, Action: action}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevealNextLog moves one log from PendingLogs to CombatLog. Returns true if there was one to reveal.
|
||||||
|
func (s *GameSession) RevealNextLog() bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if len(s.state.PendingLogs) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := s.state.PendingLogs[0]
|
||||||
|
s.state.PendingLogs = s.state.PendingLogs[1:]
|
||||||
|
s.state.CombatLog = append(s.state.CombatLog, msg)
|
||||||
|
if len(s.state.CombatLog) > 8 {
|
||||||
|
s.state.CombatLog = s.state.CombatLog[len(s.state.CombatLog)-8:]
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GameSession) TouchActivity(fingerprint string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.lastActivity[fingerprint] = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuyItem handles shop purchases
|
// BuyItem handles shop purchases
|
||||||
func (s *GameSession) BuyItem(playerName string, itemIdx int) bool {
|
func (s *GameSession) BuyItem(playerID string, itemIdx int) bool {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
if s.state.Phase != PhaseShop || itemIdx < 0 || itemIdx >= len(s.state.ShopItems) {
|
if s.state.Phase != PhaseShop || itemIdx < 0 || itemIdx >= len(s.state.ShopItems) {
|
||||||
@@ -171,7 +339,10 @@ func (s *GameSession) BuyItem(playerName string, itemIdx int) bool {
|
|||||||
}
|
}
|
||||||
item := s.state.ShopItems[itemIdx]
|
item := s.state.ShopItems[itemIdx]
|
||||||
for _, p := range s.state.Players {
|
for _, p := range s.state.Players {
|
||||||
if p.Name == playerName && p.Gold >= item.Price {
|
if p.Fingerprint == playerID && p.Gold >= item.Price {
|
||||||
|
if len(p.Inventory) >= 10 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
p.Gold -= item.Price
|
p.Gold -= item.Price
|
||||||
p.Inventory = append(p.Inventory, item)
|
p.Inventory = append(p.Inventory, item)
|
||||||
return true
|
return true
|
||||||
@@ -180,6 +351,13 @@ func (s *GameSession) BuyItem(playerName string, itemIdx int) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendChat appends a chat message to the combat log
|
||||||
|
func (s *GameSession) SendChat(playerName, message string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.addLog(fmt.Sprintf("[%s] %s", playerName, message))
|
||||||
|
}
|
||||||
|
|
||||||
// LeaveShop exits the shop phase
|
// LeaveShop exits the shop phase
|
||||||
func (s *GameSession) LeaveShop() {
|
func (s *GameSession) LeaveShop() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|||||||
@@ -7,9 +7,42 @@ import (
|
|||||||
"github.com/tolelom/catacombs/entity"
|
"github.com/tolelom/catacombs/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestGetStateNoRace(t *testing.T) {
|
||||||
|
s := NewGameSession()
|
||||||
|
p := entity.NewPlayer("Racer", entity.ClassWarrior)
|
||||||
|
p.Fingerprint = "test-fp"
|
||||||
|
s.AddPlayer(p)
|
||||||
|
s.StartGame()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
st := s.GetState()
|
||||||
|
for _, p := range st.Players {
|
||||||
|
_ = p.HP
|
||||||
|
_ = p.Gold
|
||||||
|
}
|
||||||
|
for _, m := range st.Monsters {
|
||||||
|
_ = m.HP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
select {
|
||||||
|
case s.actionCh <- playerActionMsg{PlayerID: "test-fp", Action: PlayerAction{Type: ActionWait}}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
|
||||||
func TestSessionTurnTimeout(t *testing.T) {
|
func TestSessionTurnTimeout(t *testing.T) {
|
||||||
s := NewGameSession()
|
s := NewGameSession()
|
||||||
p := entity.NewPlayer("test", entity.ClassWarrior)
|
p := entity.NewPlayer("test", entity.ClassWarrior)
|
||||||
|
p.Fingerprint = "test-fp"
|
||||||
s.AddPlayer(p)
|
s.AddPlayer(p)
|
||||||
s.StartFloor()
|
s.StartFloor()
|
||||||
|
|
||||||
@@ -27,3 +60,91 @@ func TestSessionTurnTimeout(t *testing.T) {
|
|||||||
t.Error("Turn did not timeout within 7 seconds")
|
t.Error("Turn did not timeout within 7 seconds")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRevealNextLog(t *testing.T) {
|
||||||
|
s := NewGameSession()
|
||||||
|
|
||||||
|
// No logs to reveal
|
||||||
|
if s.RevealNextLog() {
|
||||||
|
t.Error("should return false when no pending logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manually add pending logs
|
||||||
|
s.mu.Lock()
|
||||||
|
s.state.PendingLogs = []string{"msg1", "msg2", "msg3"}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if !s.RevealNextLog() {
|
||||||
|
t.Error("should return true when log revealed")
|
||||||
|
}
|
||||||
|
|
||||||
|
st := s.GetState()
|
||||||
|
if len(st.CombatLog) != 1 || st.CombatLog[0] != "msg1" {
|
||||||
|
t.Errorf("expected [msg1], got %v", st.CombatLog)
|
||||||
|
}
|
||||||
|
if len(st.PendingLogs) != 2 {
|
||||||
|
t.Errorf("expected 2 pending, got %d", len(st.PendingLogs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reveal remaining
|
||||||
|
s.RevealNextLog()
|
||||||
|
s.RevealNextLog()
|
||||||
|
if s.RevealNextLog() {
|
||||||
|
t.Error("should return false after all revealed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeepCopyIndependence(t *testing.T) {
|
||||||
|
s := NewGameSession()
|
||||||
|
p := entity.NewPlayer("Test", entity.ClassWarrior)
|
||||||
|
p.Fingerprint = "fp-test"
|
||||||
|
p.Inventory = append(p.Inventory, entity.Item{Name: "Sword", Type: entity.ItemWeapon, Bonus: 5})
|
||||||
|
s.AddPlayer(p)
|
||||||
|
|
||||||
|
state := s.GetState()
|
||||||
|
|
||||||
|
// Mutate the copy
|
||||||
|
state.Players[0].HP = 999
|
||||||
|
state.Players[0].Inventory = append(state.Players[0].Inventory, entity.Item{Name: "Shield"})
|
||||||
|
|
||||||
|
// Original should be unchanged
|
||||||
|
origState := s.GetState()
|
||||||
|
if origState.Players[0].HP == 999 {
|
||||||
|
t.Error("deep copy failed: HP mutation leaked to original")
|
||||||
|
}
|
||||||
|
if len(origState.Players[0].Inventory) != 1 {
|
||||||
|
t.Error("deep copy failed: inventory mutation leaked to original")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuyItemInventoryFull(t *testing.T) {
|
||||||
|
s := NewGameSession()
|
||||||
|
p := entity.NewPlayer("Buyer", entity.ClassWarrior)
|
||||||
|
p.Fingerprint = "fp-buyer"
|
||||||
|
p.Gold = 1000
|
||||||
|
// Fill inventory to 10
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
p.Inventory = append(p.Inventory, entity.Item{Name: "Junk"})
|
||||||
|
}
|
||||||
|
s.AddPlayer(p)
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.state.Phase = PhaseShop
|
||||||
|
s.state.ShopItems = []entity.Item{
|
||||||
|
{Name: "Potion", Type: entity.ItemConsumable, Bonus: 30, Price: 10},
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.BuyItem("fp-buyer", 0) {
|
||||||
|
t.Error("should not buy when inventory is full")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendChat(t *testing.T) {
|
||||||
|
s := NewGameSession()
|
||||||
|
s.SendChat("Alice", "hello")
|
||||||
|
st := s.GetState()
|
||||||
|
if len(st.CombatLog) != 1 || st.CombatLog[0] != "[Alice] hello" {
|
||||||
|
t.Errorf("expected chat log, got %v", st.CombatLog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
133
game/turn.go
133
game/turn.go
@@ -18,9 +18,10 @@ func (s *GameSession) RunTurn() {
|
|||||||
s.state.CombatTurn++
|
s.state.CombatTurn++
|
||||||
s.clearLog()
|
s.clearLog()
|
||||||
s.actions = make(map[string]PlayerAction)
|
s.actions = make(map[string]PlayerAction)
|
||||||
|
s.state.SubmittedActions = make(map[string]string)
|
||||||
aliveCount := 0
|
aliveCount := 0
|
||||||
for _, p := range s.state.Players {
|
for _, p := range s.state.Players {
|
||||||
if !p.IsDead() {
|
if !p.IsOut() {
|
||||||
aliveCount++
|
aliveCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,38 +33,59 @@ func (s *GameSession) RunTurn() {
|
|||||||
s.state.TurnDeadline = time.Now().Add(TurnTimeout)
|
s.state.TurnDeadline = time.Now().Add(TurnTimeout)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
collected := 0
|
collected := 0
|
||||||
|
|
||||||
|
collecting:
|
||||||
for collected < aliveCount {
|
for collected < aliveCount {
|
||||||
select {
|
select {
|
||||||
case msg := <-s.actionCh:
|
case msg := <-s.actionCh:
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.actions[msg.PlayerName] = msg.Action
|
s.actions[msg.PlayerID] = msg.Action
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
collected++
|
collected++
|
||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
goto resolve
|
break collecting
|
||||||
|
case <-s.done:
|
||||||
|
timer.Stop()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
timer.Stop()
|
timer.Stop()
|
||||||
|
|
||||||
resolve:
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
s.state.TurnDeadline = time.Time{}
|
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 {
|
||||||
if !p.IsDead() {
|
if !p.IsOut() {
|
||||||
if _, ok := s.actions[p.Name]; !ok {
|
if _, ok := s.actions[p.Fingerprint]; !ok {
|
||||||
s.actions[p.Name] = PlayerAction{Type: ActionWait}
|
s.actions[p.Fingerprint] = PlayerAction{Type: ActionWait}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.state.TurnResolving = true
|
||||||
|
s.state.PendingLogs = nil
|
||||||
s.resolvePlayerActions()
|
s.resolvePlayerActions()
|
||||||
s.resolveMonsterActions()
|
s.resolveMonsterActions()
|
||||||
|
s.state.TurnResolving = false
|
||||||
|
// PendingLogs now contains all turn results — UI will reveal them one by one via RevealNextLog
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameSession) resolvePlayerActions() {
|
func (s *GameSession) resolvePlayerActions() {
|
||||||
|
// Tick status effects
|
||||||
|
for _, p := range s.state.Players {
|
||||||
|
if !p.IsOut() {
|
||||||
|
msgs := p.TickEffects()
|
||||||
|
for _, msg := range msgs {
|
||||||
|
s.addLog(msg)
|
||||||
|
}
|
||||||
|
if p.IsDead() {
|
||||||
|
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var intents []combat.AttackIntent
|
var intents []combat.AttackIntent
|
||||||
var intentOwners []string // track who owns each intent
|
var intentOwners []string // track who owns each intent
|
||||||
|
|
||||||
@@ -76,10 +98,10 @@ func (s *GameSession) resolvePlayerActions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, p := range s.state.Players {
|
for _, p := range s.state.Players {
|
||||||
if p.IsDead() {
|
if p.IsOut() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
action, ok := s.actions[p.Name]
|
action, ok := s.actions[p.Fingerprint]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -94,6 +116,11 @@ func (s *GameSession) resolvePlayerActions() {
|
|||||||
})
|
})
|
||||||
intentOwners = append(intentOwners, p.Name)
|
intentOwners = append(intentOwners, p.Name)
|
||||||
case ActionSkill:
|
case ActionSkill:
|
||||||
|
if p.SkillUses <= 0 {
|
||||||
|
s.addLog(fmt.Sprintf("%s has no skill uses left!", p.Name))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
p.SkillUses--
|
||||||
switch p.Class {
|
switch p.Class {
|
||||||
case entity.ClassWarrior:
|
case entity.ClassWarrior:
|
||||||
for _, m := range s.state.Monsters {
|
for _, m := range s.state.Monsters {
|
||||||
@@ -114,9 +141,19 @@ func (s *GameSession) resolvePlayerActions() {
|
|||||||
case entity.ClassHealer:
|
case entity.ClassHealer:
|
||||||
targetIdx := action.TargetIdx
|
targetIdx := action.TargetIdx
|
||||||
if targetIdx < 0 || targetIdx >= len(s.state.Players) {
|
if targetIdx < 0 || targetIdx >= len(s.state.Players) {
|
||||||
targetIdx = 0 // heal self by default
|
targetIdx = 0
|
||||||
}
|
}
|
||||||
target := s.state.Players[targetIdx]
|
target := s.state.Players[targetIdx]
|
||||||
|
if target.IsDead() {
|
||||||
|
// Find first alive player to heal instead
|
||||||
|
for j, candidate := range s.state.Players {
|
||||||
|
if !candidate.IsOut() {
|
||||||
|
target = candidate
|
||||||
|
targetIdx = j
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
before := target.HP
|
before := target.HP
|
||||||
target.Heal(30)
|
target.Heal(30)
|
||||||
s.addLog(fmt.Sprintf("%s healed %s for %d HP", p.Name, target.Name, target.HP-before))
|
s.addLog(fmt.Sprintf("%s healed %s for %d HP", p.Name, target.Name, target.HP-before))
|
||||||
@@ -145,10 +182,12 @@ func (s *GameSession) resolvePlayerActions() {
|
|||||||
case ActionFlee:
|
case ActionFlee:
|
||||||
if combat.AttemptFlee() {
|
if combat.AttemptFlee() {
|
||||||
s.addLog(fmt.Sprintf("%s fled from battle!", p.Name))
|
s.addLog(fmt.Sprintf("%s fled from battle!", p.Name))
|
||||||
|
s.state.FleeSucceeded = true
|
||||||
if s.state.SoloMode {
|
if s.state.SoloMode {
|
||||||
s.state.Phase = PhaseExploring
|
s.state.Phase = PhaseExploring
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
p.Fled = true
|
||||||
} else {
|
} else {
|
||||||
s.addLog(fmt.Sprintf("%s failed to flee!", p.Name))
|
s.addLog(fmt.Sprintf("%s failed to flee!", p.Name))
|
||||||
}
|
}
|
||||||
@@ -157,6 +196,24 @@ func (s *GameSession) resolvePlayerActions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if all alive players have fled
|
||||||
|
allFled := true
|
||||||
|
for _, p := range s.state.Players {
|
||||||
|
if !p.IsDead() && !p.Fled {
|
||||||
|
allFled = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allFled && !s.state.SoloMode {
|
||||||
|
s.state.Phase = PhaseExploring
|
||||||
|
s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true
|
||||||
|
s.addLog("All players fled!")
|
||||||
|
for _, p := range s.state.Players {
|
||||||
|
p.Fled = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if len(intents) > 0 && len(s.state.Monsters) > 0 {
|
if len(intents) > 0 && len(s.state.Monsters) > 0 {
|
||||||
results := combat.ResolveAttacks(intents, s.state.Monsters)
|
results := combat.ResolveAttacks(intents, s.state.Monsters)
|
||||||
for i, r := range results {
|
for i, r := range results {
|
||||||
@@ -181,12 +238,9 @@ func (s *GameSession) resolvePlayerActions() {
|
|||||||
// Award gold only for monsters that JUST died this turn
|
// Award gold only for monsters that JUST died this turn
|
||||||
for i, m := range s.state.Monsters {
|
for i, m := range s.state.Monsters {
|
||||||
if m.IsDead() && aliveBeforeTurn[i] {
|
if m.IsDead() && aliveBeforeTurn[i] {
|
||||||
goldReward := 5 + s.state.FloorNum
|
goldReward := 5 + s.state.FloorNum*2
|
||||||
if goldReward > 15 {
|
|
||||||
goldReward = 15
|
|
||||||
}
|
|
||||||
for _, p := range s.state.Players {
|
for _, p := range s.state.Players {
|
||||||
if !p.IsDead() {
|
if !p.IsOut() {
|
||||||
bonus := 0
|
bonus := 0
|
||||||
for _, r := range p.Relics {
|
for _, r := range p.Relics {
|
||||||
if r.Effect == entity.RelicGoldBoost {
|
if r.Effect == entity.RelicGoldBoost {
|
||||||
@@ -202,6 +256,7 @@ func (s *GameSession) resolvePlayerActions() {
|
|||||||
}
|
}
|
||||||
s.addLog(fmt.Sprintf("%s defeated! +%d gold", m.Name, goldReward))
|
s.addLog(fmt.Sprintf("%s defeated! +%d gold", m.Name, goldReward))
|
||||||
if m.IsBoss {
|
if m.IsBoss {
|
||||||
|
s.state.BossKilled = true
|
||||||
s.grantBossRelic()
|
s.grantBossRelic()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,6 +275,9 @@ func (s *GameSession) resolvePlayerActions() {
|
|||||||
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
|
||||||
s.addLog("Room cleared!")
|
s.addLog("Room cleared!")
|
||||||
|
for _, p := range s.state.Players {
|
||||||
|
p.Fled = false
|
||||||
|
}
|
||||||
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 {
|
||||||
@@ -244,7 +302,9 @@ func (s *GameSession) advanceFloor() {
|
|||||||
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)
|
||||||
|
s.addLog(fmt.Sprintf("✦ %s revived at %d HP!", p.Name, p.HP))
|
||||||
}
|
}
|
||||||
|
p.Fled = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,9 +314,12 @@ func (s *GameSession) grantBossRelic() {
|
|||||||
{Name: "Power Amulet", Effect: entity.RelicATKBoost, Value: 3, Price: 120},
|
{Name: "Power Amulet", Effect: entity.RelicATKBoost, Value: 3, Price: 120},
|
||||||
{Name: "Iron Ward", Effect: entity.RelicDEFBoost, Value: 2, Price: 100},
|
{Name: "Iron Ward", Effect: entity.RelicDEFBoost, Value: 2, Price: 100},
|
||||||
{Name: "Gold Charm", Effect: entity.RelicGoldBoost, Value: 5, Price: 150},
|
{Name: "Gold Charm", Effect: entity.RelicGoldBoost, Value: 5, Price: 150},
|
||||||
|
{Name: "Antidote Charm", Effect: entity.RelicPoisonImmunity, Value: 0, Price: 100},
|
||||||
|
{Name: "Flame Guard", Effect: entity.RelicBurnResist, Value: 0, Price: 120},
|
||||||
|
{Name: "Life Siphon", Effect: entity.RelicLifeSteal, Value: 10, Price: 150},
|
||||||
}
|
}
|
||||||
for _, p := range s.state.Players {
|
for _, p := range s.state.Players {
|
||||||
if !p.IsDead() {
|
if !p.IsOut() {
|
||||||
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))
|
s.addLog(fmt.Sprintf("%s obtained relic: %s", p.Name, r.Name))
|
||||||
@@ -275,19 +338,51 @@ 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 {
|
||||||
for _, p := range s.state.Players {
|
for _, p := range s.state.Players {
|
||||||
if !p.IsDead() {
|
if !p.IsOut() {
|
||||||
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))
|
s.addLog(fmt.Sprintf("%s AoE hits %s for %d dmg", m.Name, p.Name, dmg))
|
||||||
|
if p.IsDead() {
|
||||||
|
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.IsBoss {
|
||||||
|
// Boss special pattern
|
||||||
|
switch m.Pattern {
|
||||||
|
case entity.PatternPoison:
|
||||||
|
for _, p := range s.state.Players {
|
||||||
|
if !p.IsOut() {
|
||||||
|
p.AddEffect(entity.ActiveEffect{Type: entity.StatusPoison, Duration: 3, Value: 5})
|
||||||
|
s.addLog(fmt.Sprintf("%s poisons %s!", m.Name, p.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case entity.PatternBurn:
|
||||||
|
for _, p := range s.state.Players {
|
||||||
|
if !p.IsOut() {
|
||||||
|
p.AddEffect(entity.ActiveEffect{Type: entity.StatusBurn, Duration: 2, Value: 8})
|
||||||
|
s.addLog(fmt.Sprintf("%s burns %s!", m.Name, p.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case entity.PatternHeal:
|
||||||
|
healAmt := m.MaxHP / 10
|
||||||
|
m.HP += healAmt
|
||||||
|
if m.HP > m.MaxHP {
|
||||||
|
m.HP = m.MaxHP
|
||||||
|
}
|
||||||
|
s.addLog(fmt.Sprintf("%s regenerates %d HP!", m.Name, healAmt))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if targetIdx >= 0 && targetIdx < len(s.state.Players) {
|
if targetIdx >= 0 && targetIdx < len(s.state.Players) {
|
||||||
p := s.state.Players[targetIdx]
|
p := s.state.Players[targetIdx]
|
||||||
if !p.IsDead() {
|
if !p.IsOut() {
|
||||||
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))
|
s.addLog(fmt.Sprintf("%s attacks %s for %d dmg", m.Name, p.Name, dmg))
|
||||||
|
if p.IsDead() {
|
||||||
|
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,7 +391,7 @@ func (s *GameSession) resolveMonsterActions() {
|
|||||||
|
|
||||||
allPlayersDead := true
|
allPlayersDead := true
|
||||||
for _, p := range s.state.Players {
|
for _, p := range s.state.Players {
|
||||||
if !p.IsDead() {
|
if !p.IsOut() {
|
||||||
allPlayersDead = false
|
allPlayersDead = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -27,6 +27,7 @@ require (
|
|||||||
github.com/creack/pty v1.1.21 // indirect
|
github.com/creack/pty v1.1.21 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -40,6 +40,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
|
|||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
|||||||
10
main.go
10
main.go
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/tolelom/catacombs/game"
|
"github.com/tolelom/catacombs/game"
|
||||||
"github.com/tolelom/catacombs/server"
|
"github.com/tolelom/catacombs/server"
|
||||||
"github.com/tolelom/catacombs/store"
|
"github.com/tolelom/catacombs/store"
|
||||||
|
"github.com/tolelom/catacombs/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -20,7 +21,14 @@ func main() {
|
|||||||
|
|
||||||
lobby := game.NewLobby()
|
lobby := game.NewLobby()
|
||||||
|
|
||||||
log.Println("Catacombs server starting on :2222")
|
// Start web terminal server in background
|
||||||
|
go func() {
|
||||||
|
if err := web.Start(":8080", 2222); err != nil {
|
||||||
|
log.Printf("Web server error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Println("Catacombs server starting — SSH :2222, Web :8080")
|
||||||
if err := server.Start("0.0.0.0", 2222, lobby, db); err != nil {
|
if err := server.Start("0.0.0.0", 2222, lobby, db); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
72
store/achievements.go
Normal file
72
store/achievements.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
bolt "go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var bucketAchievements = []byte("achievements")
|
||||||
|
|
||||||
|
type Achievement struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Unlocked bool `json:"unlocked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var AchievementDefs = []Achievement{
|
||||||
|
{ID: "first_clear", Name: "Dungeon Delver", Description: "Clear floor 5 for the first time"},
|
||||||
|
{ID: "boss_slayer", Name: "Boss Slayer", Description: "Defeat any boss"},
|
||||||
|
{ID: "floor10", Name: "Deep Explorer", Description: "Reach floor 10"},
|
||||||
|
{ID: "floor20", Name: "Conqueror", Description: "Conquer the Catacombs (floor 20)"},
|
||||||
|
{ID: "solo_clear", Name: "Lone Wolf", Description: "Clear floor 5 solo"},
|
||||||
|
{ID: "gold_hoarder", Name: "Gold Hoarder", Description: "Accumulate 200+ gold in one run"},
|
||||||
|
{ID: "no_death", Name: "Untouchable", Description: "Complete a floor without anyone dying"},
|
||||||
|
{ID: "full_party", Name: "Fellowship", Description: "Start a game with 4 players"},
|
||||||
|
{ID: "relic_collector", Name: "Relic Collector", Description: "Collect 3+ relics in one run"},
|
||||||
|
{ID: "flee_master", Name: "Tactical Retreat", Description: "Successfully flee from combat"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) initAchievements() error {
|
||||||
|
return d.db.Update(func(tx *bolt.Tx) error {
|
||||||
|
_, err := tx.CreateBucketIfNotExists(bucketAchievements)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) UnlockAchievement(player, achievementID string) (bool, error) {
|
||||||
|
key := []byte(player + ":" + achievementID)
|
||||||
|
alreadyUnlocked := false
|
||||||
|
err := d.db.Update(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket(bucketAchievements)
|
||||||
|
if b.Get(key) != nil {
|
||||||
|
alreadyUnlocked = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return b.Put(key, []byte("1"))
|
||||||
|
})
|
||||||
|
// Returns true if newly unlocked (not already had it)
|
||||||
|
return !alreadyUnlocked, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) GetAchievements(player string) ([]Achievement, error) {
|
||||||
|
unlocked := make(map[string]bool)
|
||||||
|
err := d.db.View(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket(bucketAchievements)
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, a := range AchievementDefs {
|
||||||
|
key := []byte(player + ":" + a.ID)
|
||||||
|
if b.Get(key) != nil {
|
||||||
|
unlocked[a.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
result := make([]Achievement, len(AchievementDefs))
|
||||||
|
for i, a := range AchievementDefs {
|
||||||
|
result[i] = a
|
||||||
|
result[i].Unlocked = unlocked[a.ID]
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
65
store/db.go
65
store/db.go
@@ -21,6 +21,7 @@ type RunRecord struct {
|
|||||||
Player string `json:"player"`
|
Player string `json:"player"`
|
||||||
Floor int `json:"floor"`
|
Floor int `json:"floor"`
|
||||||
Score int `json:"score"`
|
Score int `json:"score"`
|
||||||
|
Class string `json:"class,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Open(path string) (*DB, error) {
|
func Open(path string) (*DB, error) {
|
||||||
@@ -35,6 +36,9 @@ func Open(path string) (*DB, error) {
|
|||||||
if _, err := tx.CreateBucketIfNotExists(bucketRankings); err != nil {
|
if _, err := tx.CreateBucketIfNotExists(bucketRankings); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if _, err := tx.CreateBucketIfNotExists(bucketAchievements); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
return &DB{db: db}, err
|
return &DB{db: db}, err
|
||||||
@@ -63,11 +67,11 @@ func (d *DB) GetProfile(fingerprint string) (string, error) {
|
|||||||
return name, err
|
return name, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) SaveRun(player string, floor, score int) error {
|
func (d *DB) SaveRun(player string, floor, score int, class string) error {
|
||||||
return d.db.Update(func(tx *bolt.Tx) error {
|
return d.db.Update(func(tx *bolt.Tx) error {
|
||||||
b := tx.Bucket(bucketRankings)
|
b := tx.Bucket(bucketRankings)
|
||||||
id, _ := b.NextSequence()
|
id, _ := b.NextSequence()
|
||||||
record := RunRecord{Player: player, Floor: floor, Score: score}
|
record := RunRecord{Player: player, Floor: floor, Score: score, Class: class}
|
||||||
data, err := json.Marshal(record)
|
data, err := json.Marshal(record)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -76,6 +80,63 @@ func (d *DB) SaveRun(player string, floor, score int) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *DB) TopRunsByGold(limit int) ([]RunRecord, error) {
|
||||||
|
var runs []RunRecord
|
||||||
|
err := d.db.View(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket(bucketRankings)
|
||||||
|
return b.ForEach(func(k, v []byte) error {
|
||||||
|
var r RunRecord
|
||||||
|
if json.Unmarshal(v, &r) == nil {
|
||||||
|
runs = append(runs, r)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Slice(runs, func(i, j int) bool {
|
||||||
|
return runs[i].Score > runs[j].Score
|
||||||
|
})
|
||||||
|
if len(runs) > limit {
|
||||||
|
runs = runs[:limit]
|
||||||
|
}
|
||||||
|
return runs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerStats struct {
|
||||||
|
TotalRuns int
|
||||||
|
BestFloor int
|
||||||
|
TotalGold int
|
||||||
|
TotalKills int
|
||||||
|
Victories int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) GetStats(player string) (PlayerStats, error) {
|
||||||
|
var stats PlayerStats
|
||||||
|
err := d.db.View(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket(bucketRankings)
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return b.ForEach(func(k, v []byte) error {
|
||||||
|
var r RunRecord
|
||||||
|
if json.Unmarshal(v, &r) == nil && r.Player == player {
|
||||||
|
stats.TotalRuns++
|
||||||
|
if r.Floor > stats.BestFloor {
|
||||||
|
stats.BestFloor = r.Floor
|
||||||
|
}
|
||||||
|
stats.TotalGold += r.Score
|
||||||
|
if r.Floor >= 20 {
|
||||||
|
stats.Victories++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
|
||||||
func (d *DB) TopRuns(limit int) ([]RunRecord, error) {
|
func (d *DB) TopRuns(limit int) ([]RunRecord, error) {
|
||||||
var runs []RunRecord
|
var runs []RunRecord
|
||||||
err := d.db.View(func(tx *bolt.Tx) error {
|
err := d.db.View(func(tx *bolt.Tx) error {
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ func TestRanking(t *testing.T) {
|
|||||||
os.Remove("test_rank.db")
|
os.Remove("test_rank.db")
|
||||||
}()
|
}()
|
||||||
|
|
||||||
db.SaveRun("Alice", 20, 1500)
|
db.SaveRun("Alice", 20, 1500, "Warrior")
|
||||||
db.SaveRun("Bob", 15, 1000)
|
db.SaveRun("Bob", 15, 1000, "Mage")
|
||||||
db.SaveRun("Charlie", 20, 2000)
|
db.SaveRun("Charlie", 20, 2000, "Rogue")
|
||||||
|
|
||||||
rankings, err := db.TopRuns(10)
|
rankings, err := db.TopRuns(10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -53,3 +53,41 @@ func TestRanking(t *testing.T) {
|
|||||||
t.Errorf("Top player: got %q, want Charlie", rankings[0].Player)
|
t.Errorf("Top player: got %q, want Charlie", rankings[0].Player)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetStats(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
db, err := Open(dir + "/test.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Save some runs
|
||||||
|
db.SaveRun("Alice", 5, 100, "Warrior")
|
||||||
|
db.SaveRun("Alice", 10, 250, "Warrior")
|
||||||
|
db.SaveRun("Alice", 20, 500, "Warrior") // victory (floor >= 20)
|
||||||
|
db.SaveRun("Bob", 3, 50, "")
|
||||||
|
|
||||||
|
stats, err := db.GetStats("Alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stats.TotalRuns != 3 {
|
||||||
|
t.Errorf("expected 3 total runs, got %d", stats.TotalRuns)
|
||||||
|
}
|
||||||
|
if stats.BestFloor != 20 {
|
||||||
|
t.Errorf("expected best floor 20, got %d", stats.BestFloor)
|
||||||
|
}
|
||||||
|
if stats.Victories != 1 {
|
||||||
|
t.Errorf("expected 1 victory, got %d", stats.Victories)
|
||||||
|
}
|
||||||
|
if stats.TotalGold != 850 { // 100+250+500
|
||||||
|
t.Errorf("expected total gold 850, got %d", stats.TotalGold)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bob's stats should be separate
|
||||||
|
bobStats, _ := db.GetStats("Bob")
|
||||||
|
if bobStats.TotalRuns != 1 {
|
||||||
|
t.Errorf("Bob should have 1 run, got %d", bobStats.TotalRuns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
33
ui/achievements_view.go
Normal file
33
ui/achievements_view.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/tolelom/catacombs/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func renderAchievements(playerName string, achievements []store.Achievement, width, height int) string {
|
||||||
|
title := styleHeader.Render("── Achievements ──")
|
||||||
|
|
||||||
|
var content string
|
||||||
|
unlocked := 0
|
||||||
|
for _, a := range achievements {
|
||||||
|
icon := styleSystem.Render(" ○ ")
|
||||||
|
nameStyle := styleSystem
|
||||||
|
if a.Unlocked {
|
||||||
|
icon = styleGold.Render(" ★ ")
|
||||||
|
nameStyle = stylePlayer
|
||||||
|
unlocked++
|
||||||
|
}
|
||||||
|
content += icon + nameStyle.Render(a.Name) + "\n"
|
||||||
|
content += styleSystem.Render(" "+a.Description) + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
progress := fmt.Sprintf("\n %s", styleGold.Render(fmt.Sprintf("%d/%d Unlocked", unlocked, len(achievements))))
|
||||||
|
|
||||||
|
footer := styleSystem.Render("\n[A] Back")
|
||||||
|
|
||||||
|
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
|
||||||
|
lipgloss.JoinVertical(lipgloss.Center, title, "", content, progress, footer))
|
||||||
|
}
|
||||||
67
ui/ascii_art.go
Normal file
67
ui/ascii_art.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import "github.com/tolelom/catacombs/entity"
|
||||||
|
|
||||||
|
// MonsterArt returns ASCII art lines for a monster type.
|
||||||
|
func MonsterArt(mt entity.MonsterType) []string {
|
||||||
|
switch mt {
|
||||||
|
case entity.MonsterSlime:
|
||||||
|
return []string{
|
||||||
|
` /\OO/\ `,
|
||||||
|
` \ / `,
|
||||||
|
` |__| `,
|
||||||
|
}
|
||||||
|
case entity.MonsterSkeleton:
|
||||||
|
return []string{
|
||||||
|
` ,--. `,
|
||||||
|
` |oo| `,
|
||||||
|
` /||\ `,
|
||||||
|
}
|
||||||
|
case entity.MonsterOrc:
|
||||||
|
return []string{
|
||||||
|
` .---. `,
|
||||||
|
`/o o\`,
|
||||||
|
`| --- |`,
|
||||||
|
}
|
||||||
|
case entity.MonsterDarkKnight:
|
||||||
|
return []string{
|
||||||
|
` /|||\ `,
|
||||||
|
` |===| `,
|
||||||
|
` | | `,
|
||||||
|
}
|
||||||
|
case entity.MonsterBoss5:
|
||||||
|
return []string{
|
||||||
|
` /\ /\ `,
|
||||||
|
`| @ @ |`,
|
||||||
|
`| || |`,
|
||||||
|
`| \__/ |`,
|
||||||
|
` \ / `,
|
||||||
|
}
|
||||||
|
case entity.MonsterBoss10:
|
||||||
|
return []string{
|
||||||
|
` __|__ `,
|
||||||
|
` /|o o|\ `,
|
||||||
|
` | === | `,
|
||||||
|
` |\___/| `,
|
||||||
|
` |___| `,
|
||||||
|
}
|
||||||
|
case entity.MonsterBoss15:
|
||||||
|
return []string{
|
||||||
|
` ,=====. `,
|
||||||
|
`/ \ / \`,
|
||||||
|
`| (O) |`,
|
||||||
|
` \ |=| / `,
|
||||||
|
` '===' `,
|
||||||
|
}
|
||||||
|
case entity.MonsterBoss20:
|
||||||
|
return []string{
|
||||||
|
` ___/\___ `,
|
||||||
|
`| x x |`,
|
||||||
|
`| === |`,
|
||||||
|
`|\_____/|`,
|
||||||
|
`|_| |_|`,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return []string{` ??? `}
|
||||||
|
}
|
||||||
|
}
|
||||||
235
ui/game_view.go
235
ui/game_view.go
@@ -11,11 +11,17 @@ import (
|
|||||||
"github.com/tolelom/catacombs/game"
|
"github.com/tolelom/catacombs/game"
|
||||||
)
|
)
|
||||||
|
|
||||||
func renderGame(state game.GameState, width, height int, targetCursor int, moveCursor int) string {
|
func renderGame(state game.GameState, width, height int, targetCursor int, moveCursor int, chatting bool, chatInput string) string {
|
||||||
mapView := renderMap(state.Floor)
|
mapView := renderMap(state.Floor)
|
||||||
hudView := renderHUD(state, targetCursor, moveCursor)
|
hudView := renderHUD(state, targetCursor, moveCursor)
|
||||||
logView := renderCombatLog(state.CombatLog)
|
logView := renderCombatLog(state.CombatLog)
|
||||||
|
|
||||||
|
if chatting {
|
||||||
|
chatStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("117"))
|
||||||
|
chatView := chatStyle.Render(fmt.Sprintf("> %s_", chatInput))
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, mapView, hudView, logView, chatView)
|
||||||
|
}
|
||||||
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left,
|
return lipgloss.JoinVertical(lipgloss.Left,
|
||||||
mapView,
|
mapView,
|
||||||
hudView,
|
hudView,
|
||||||
@@ -27,8 +33,19 @@ func renderMap(floor *dungeon.Floor) string {
|
|||||||
if floor == nil {
|
if floor == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
theme := dungeon.GetFloorTheme(floor.Number)
|
||||||
headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
|
headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
|
||||||
header := headerStyle.Render(fmt.Sprintf("── Catacombs B%d ──", floor.Number))
|
|
||||||
|
// Count explored rooms
|
||||||
|
explored := 0
|
||||||
|
for _, r := range floor.Rooms {
|
||||||
|
if r.Visited || r.Cleared {
|
||||||
|
explored++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total := len(floor.Rooms)
|
||||||
|
|
||||||
|
header := headerStyle.Render(fmt.Sprintf("── Catacombs B%d: %s ── %d/%d Rooms ──", floor.Number, theme.Name, explored, total))
|
||||||
return header + "\n" + dungeon.RenderFloor(floor, floor.CurrentRoom, true)
|
return header + "\n" + dungeon.RenderFloor(floor, floor.CurrentRoom, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,57 +75,60 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if state.Phase == game.PhaseCombat {
|
if state.Phase == game.PhaseCombat {
|
||||||
sb.WriteString("\n")
|
// Two-panel layout: PARTY | ENEMIES
|
||||||
// Enemies
|
partyContent := renderPartyPanel(state.Players, state.SubmittedActions)
|
||||||
enemyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
|
enemyContent := renderEnemyPanel(state.Monsters, targetCursor)
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
partyPanel := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(colorGray).
|
||||||
|
Width(35).
|
||||||
|
Padding(0, 1).
|
||||||
|
Render(partyContent)
|
||||||
|
|
||||||
|
enemyPanel := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(colorGray).
|
||||||
|
Width(38).
|
||||||
|
Padding(0, 1).
|
||||||
|
Render(enemyContent)
|
||||||
|
|
||||||
|
panels := lipgloss.JoinHorizontal(lipgloss.Top, partyPanel, enemyPanel)
|
||||||
|
sb.WriteString(panels)
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
// Actions with skill description
|
|
||||||
actionStyle := lipgloss.NewStyle().Bold(true)
|
// Action bar
|
||||||
sb.WriteString(actionStyle.Render("[1]Attack [2]Skill [3]Item [4]Flee [5]Wait [Tab]Target"))
|
sb.WriteString(styleAction.Render("[1]Attack [2]Skill [3]Item [4]Flee [5]Wait [Tab]Target [/]Chat"))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
// Timer
|
||||||
if !state.TurnDeadline.IsZero() {
|
if !state.TurnDeadline.IsZero() {
|
||||||
remaining := time.Until(state.TurnDeadline)
|
remaining := time.Until(state.TurnDeadline)
|
||||||
if remaining < 0 {
|
if remaining < 0 {
|
||||||
remaining = 0
|
remaining = 0
|
||||||
}
|
}
|
||||||
timerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)
|
sb.WriteString(styleTimer.Render(fmt.Sprintf(" Timer: %.1fs", remaining.Seconds())))
|
||||||
sb.WriteString(timerStyle.Render(fmt.Sprintf(" Timer: %.1fs", remaining.Seconds())))
|
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skill description per class
|
// Skill description for first alive player only
|
||||||
skillStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Italic(true)
|
|
||||||
for _, p := range state.Players {
|
for _, p := range state.Players {
|
||||||
if !p.IsDead() {
|
if !p.IsDead() {
|
||||||
var skillDesc string
|
var skillDesc string
|
||||||
switch p.Class {
|
switch p.Class {
|
||||||
case entity.ClassWarrior:
|
case entity.ClassWarrior:
|
||||||
skillDesc = "Skill: Taunt - enemies attack you for 2 turns"
|
skillDesc = "Skill: Taunt — enemies attack you for 2 turns"
|
||||||
case entity.ClassMage:
|
case entity.ClassMage:
|
||||||
skillDesc = "Skill: Fireball - AoE 0.8x dmg to all enemies"
|
skillDesc = "Skill: Fireball — AoE 0.8x dmg to all enemies"
|
||||||
case entity.ClassHealer:
|
case entity.ClassHealer:
|
||||||
skillDesc = "Skill: Heal - restore 30 HP to an ally"
|
skillDesc = "Skill: Heal — restore 30 HP to an ally"
|
||||||
case entity.ClassRogue:
|
case entity.ClassRogue:
|
||||||
skillDesc = "Skill: Scout - reveal neighboring rooms"
|
skillDesc = "Skill: Scout — reveal neighboring rooms"
|
||||||
}
|
}
|
||||||
sb.WriteString(skillStyle.Render(skillDesc))
|
skillDesc += fmt.Sprintf(" (%d uses left)", p.SkillUses)
|
||||||
|
sb.WriteString(styleSystem.Render(skillDesc))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if state.Phase == game.PhaseExploring {
|
} else if state.Phase == game.PhaseExploring {
|
||||||
@@ -140,6 +160,9 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int) string {
|
|||||||
sb.WriteString("[Up/Down] Select [Enter] Move [Q] Quit")
|
sb.WriteString("[Up/Down] Select [Enter] Move [Q] Quit")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if state.Phase == game.PhaseCombat {
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
return border.Render(sb.String())
|
return border.Render(sb.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,15 +170,38 @@ func renderCombatLog(log []string) string {
|
|||||||
if len(log) == 0 {
|
if len(log) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
logStyle := lipgloss.NewStyle().
|
border := lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("228")).
|
Border(lipgloss.RoundedBorder()).
|
||||||
PaddingLeft(1)
|
BorderForeground(colorGray).
|
||||||
|
Padding(0, 1)
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for _, msg := range log {
|
for _, msg := range log {
|
||||||
sb.WriteString(" > " + msg + "\n")
|
colored := colorizeLog(msg)
|
||||||
|
sb.WriteString(" > " + colored + "\n")
|
||||||
|
}
|
||||||
|
return border.Render(sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func colorizeLog(msg string) string {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(msg, "fled"):
|
||||||
|
return styleFlee.Render(msg)
|
||||||
|
case strings.Contains(msg, "co-op"):
|
||||||
|
return styleCoop.Render(msg)
|
||||||
|
case strings.Contains(msg, "healed") || strings.Contains(msg, "Heal") || strings.Contains(msg, "Blessing"):
|
||||||
|
return styleHeal.Render(msg)
|
||||||
|
case strings.Contains(msg, "dmg") || strings.Contains(msg, "hit") || strings.Contains(msg, "attacks") || strings.Contains(msg, "Trap"):
|
||||||
|
return styleDamage.Render(msg)
|
||||||
|
case strings.Contains(msg, "Taunt") || strings.Contains(msg, "scouted"):
|
||||||
|
return styleStatus.Render(msg)
|
||||||
|
case strings.Contains(msg, "gold") || strings.Contains(msg, "Gold") || strings.Contains(msg, "found"):
|
||||||
|
return styleGold.Render(msg)
|
||||||
|
case strings.Contains(msg, "defeated") || strings.Contains(msg, "cleared") || strings.Contains(msg, "Descending"):
|
||||||
|
return styleSystem.Render(msg)
|
||||||
|
default:
|
||||||
|
return msg
|
||||||
}
|
}
|
||||||
return logStyle.Render(sb.String())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderHPBar(current, max, width int) string {
|
func renderHPBar(current, max, width int) string {
|
||||||
@@ -166,31 +212,100 @@ func renderHPBar(current, max, width int) string {
|
|||||||
if filled < 0 {
|
if filled < 0 {
|
||||||
filled = 0
|
filled = 0
|
||||||
}
|
}
|
||||||
|
if filled > width {
|
||||||
|
filled = width
|
||||||
|
}
|
||||||
empty := width - filled
|
empty := width - filled
|
||||||
|
|
||||||
greenStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("46"))
|
pct := float64(current) / float64(max)
|
||||||
redStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
|
var barStyle lipgloss.Style
|
||||||
|
switch {
|
||||||
bar := greenStyle.Render(strings.Repeat("█", filled)) +
|
case pct > 0.5:
|
||||||
redStyle.Render(strings.Repeat("░", empty))
|
barStyle = lipgloss.NewStyle().Foreground(colorGreen)
|
||||||
return bar
|
case pct > 0.25:
|
||||||
}
|
barStyle = lipgloss.NewStyle().Foreground(colorYellow)
|
||||||
|
|
||||||
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:
|
default:
|
||||||
return " "
|
barStyle = lipgloss.NewStyle().Foreground(colorRed)
|
||||||
}
|
}
|
||||||
|
emptyStyle := lipgloss.NewStyle().Foreground(colorGray)
|
||||||
|
|
||||||
|
return barStyle.Render(strings.Repeat("█", filled)) +
|
||||||
|
emptyStyle.Render(strings.Repeat("░", empty))
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderPartyPanel(players []*entity.Player, submittedActions map[string]string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(styleHeader.Render(" PARTY") + "\n\n")
|
||||||
|
|
||||||
|
for _, p := range players {
|
||||||
|
nameStr := stylePlayer.Render(fmt.Sprintf(" ♦ %s", p.Name))
|
||||||
|
classStr := styleSystem.Render(fmt.Sprintf(" (%s)", p.Class))
|
||||||
|
status := ""
|
||||||
|
if p.IsDead() {
|
||||||
|
status = styleDamage.Render(" [DEAD]")
|
||||||
|
}
|
||||||
|
sb.WriteString(nameStr + classStr + status + "\n")
|
||||||
|
|
||||||
|
hpBar := renderHPBar(p.HP, p.MaxHP, 16)
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s %d/%d\n", hpBar, p.HP, p.MaxHP))
|
||||||
|
|
||||||
|
if len(p.Effects) > 0 {
|
||||||
|
var effects []string
|
||||||
|
for _, e := range p.Effects {
|
||||||
|
switch e.Type {
|
||||||
|
case entity.StatusPoison:
|
||||||
|
effects = append(effects, styleHeal.Render(fmt.Sprintf("☠Poison(%dt)", e.Duration)))
|
||||||
|
case entity.StatusBurn:
|
||||||
|
effects = append(effects, styleDamage.Render(fmt.Sprintf("🔥Burn(%dt)", e.Duration)))
|
||||||
|
case entity.StatusFreeze:
|
||||||
|
effects = append(effects, styleFlee.Render(fmt.Sprintf("❄Freeze(%dt)", e.Duration)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(" " + strings.Join(effects, " ") + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf(" ATK:%-3d DEF:%-3d ", p.EffectiveATK(), p.EffectiveDEF()))
|
||||||
|
sb.WriteString(styleGold.Render(fmt.Sprintf("G:%d", p.Gold)))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
if action, ok := submittedActions[p.Fingerprint]; ok {
|
||||||
|
sb.WriteString(styleHeal.Render(fmt.Sprintf(" ✓ %s", action)))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
} else if !p.IsOut() {
|
||||||
|
sb.WriteString(styleSystem.Render(" ... Waiting"))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderEnemyPanel(monsters []*entity.Monster, targetCursor int) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(styleHeader.Render(" ENEMIES") + "\n\n")
|
||||||
|
|
||||||
|
for i, m := range monsters {
|
||||||
|
if m.IsDead() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// ASCII art
|
||||||
|
art := MonsterArt(m.Type)
|
||||||
|
for _, line := range art {
|
||||||
|
sb.WriteString(styleEnemy.Render(" "+line) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name + HP
|
||||||
|
marker := " "
|
||||||
|
if i == targetCursor {
|
||||||
|
marker = "> "
|
||||||
|
}
|
||||||
|
hpBar := renderHPBar(m.HP, m.MaxHP, 12)
|
||||||
|
taunt := ""
|
||||||
|
if m.TauntTarget {
|
||||||
|
taunt = styleStatus.Render(fmt.Sprintf(" [TAUNTED %dt]", m.TauntTurns))
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s[%d] %s %s %d/%d%s\n\n",
|
||||||
|
marker, i, styleEnemy.Render(m.Name), hpBar, m.HP, m.MaxHP, taunt))
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|||||||
44
ui/help_view.go
Normal file
44
ui/help_view.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
func renderHelp(width, height int) string {
|
||||||
|
title := styleHeader.Render("── Controls ──")
|
||||||
|
|
||||||
|
sections := []struct{ header, body string }{
|
||||||
|
{"Exploration", ` [Up/Down] Select room
|
||||||
|
[Enter] Move to room
|
||||||
|
[/] Chat
|
||||||
|
[Q] Quit`},
|
||||||
|
{"Combat", ` [1] Attack [2] Skill
|
||||||
|
[3] Use Item [4] Flee
|
||||||
|
[5] Defend [Tab] Switch Target
|
||||||
|
[/] Chat`},
|
||||||
|
{"Shop", ` [1-3] Buy item
|
||||||
|
[Q] Leave shop`},
|
||||||
|
{"Classes", ` Warrior 120HP 12ATK 8DEF Taunt (draw fire 2t)
|
||||||
|
Mage 70HP 20ATK 3DEF Fireball (AoE 0.8x)
|
||||||
|
Healer 90HP 8ATK 5DEF Heal (restore 30HP)
|
||||||
|
Rogue 85HP 15ATK 4DEF Scout (reveal rooms)`},
|
||||||
|
{"Tips", ` • Skills have 3 uses per combat
|
||||||
|
• Co-op bonus: 10% extra when 2+ attack same target
|
||||||
|
• Items are limited to 10 per player
|
||||||
|
• Dead players revive next floor at 30% HP`},
|
||||||
|
}
|
||||||
|
|
||||||
|
var content string
|
||||||
|
headerStyle := lipgloss.NewStyle().Foreground(colorCyan).Bold(true)
|
||||||
|
bodyStyle := lipgloss.NewStyle().Foreground(colorWhite)
|
||||||
|
|
||||||
|
for _, s := range sections {
|
||||||
|
content += headerStyle.Render(s.header) + "\n"
|
||||||
|
content += bodyStyle.Render(s.body) + "\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
footer := styleSystem.Render("[H] Back")
|
||||||
|
|
||||||
|
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
|
||||||
|
lipgloss.JoinVertical(lipgloss.Center, title, "", content, footer))
|
||||||
|
}
|
||||||
51
ui/leaderboard_view.go
Normal file
51
ui/leaderboard_view.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/tolelom/catacombs/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func renderLeaderboard(byFloor, byGold []store.RunRecord, width, height int) string {
|
||||||
|
title := styleHeader.Render("── Leaderboard ──")
|
||||||
|
|
||||||
|
// By Floor
|
||||||
|
var floorSection string
|
||||||
|
floorSection += styleCoop.Render(" Top by Floor") + "\n"
|
||||||
|
for i, r := range byFloor {
|
||||||
|
if i >= 5 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
medal := fmt.Sprintf(" %d.", i+1)
|
||||||
|
cls := ""
|
||||||
|
if r.Class != "" {
|
||||||
|
cls = fmt.Sprintf(" [%s]", r.Class)
|
||||||
|
}
|
||||||
|
floorSection += fmt.Sprintf(" %s %s%s B%d %s\n",
|
||||||
|
medal, stylePlayer.Render(r.Player), styleSystem.Render(cls),
|
||||||
|
r.Floor, styleGold.Render(fmt.Sprintf("%dg", r.Score)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// By Gold
|
||||||
|
var goldSection string
|
||||||
|
goldSection += styleCoop.Render("\n Top by Gold") + "\n"
|
||||||
|
for i, r := range byGold {
|
||||||
|
if i >= 5 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
medal := fmt.Sprintf(" %d.", i+1)
|
||||||
|
cls := ""
|
||||||
|
if r.Class != "" {
|
||||||
|
cls = fmt.Sprintf(" [%s]", r.Class)
|
||||||
|
}
|
||||||
|
goldSection += fmt.Sprintf(" %s %s%s B%d %s\n",
|
||||||
|
medal, stylePlayer.Render(r.Player), styleSystem.Render(cls),
|
||||||
|
r.Floor, styleGold.Render(fmt.Sprintf("%dg", r.Score)))
|
||||||
|
}
|
||||||
|
|
||||||
|
footer := styleSystem.Render("\n[L] Back")
|
||||||
|
|
||||||
|
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
|
||||||
|
lipgloss.JoinVertical(lipgloss.Center, title, "", floorSection, goldSection, footer))
|
||||||
|
}
|
||||||
@@ -15,15 +15,22 @@ type lobbyState struct {
|
|||||||
roomName string
|
roomName string
|
||||||
joining bool
|
joining bool
|
||||||
codeInput string
|
codeInput string
|
||||||
|
online int
|
||||||
}
|
}
|
||||||
|
|
||||||
type roomInfo struct {
|
type roomInfo struct {
|
||||||
Code string
|
Code string
|
||||||
Name string
|
Name string
|
||||||
Players int
|
Players []playerInfo
|
||||||
Status string
|
Status string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type playerInfo struct {
|
||||||
|
Name string
|
||||||
|
Class string
|
||||||
|
Ready bool
|
||||||
|
}
|
||||||
|
|
||||||
func renderLobby(state lobbyState, width, height int) string {
|
func renderLobby(state lobbyState, width, height int) string {
|
||||||
headerStyle := lipgloss.NewStyle().
|
headerStyle := lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("205")).
|
Foreground(lipgloss.Color("205")).
|
||||||
@@ -33,7 +40,7 @@ func renderLobby(state lobbyState, width, height int) string {
|
|||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
Padding(0, 1)
|
Padding(0, 1)
|
||||||
|
|
||||||
header := headerStyle.Render("── Lobby ──")
|
header := headerStyle.Render(fmt.Sprintf("── Lobby ── %d online ──", state.online))
|
||||||
menu := "[C] Create Room [J] Join by Code [Up/Down] Select [Enter] Join [Q] Back"
|
menu := "[C] Create Room [J] Join by Code [Up/Down] Select [Enter] Join [Q] Back"
|
||||||
|
|
||||||
roomList := ""
|
roomList := ""
|
||||||
@@ -43,7 +50,21 @@ func renderLobby(state lobbyState, width, height int) string {
|
|||||||
marker = "> "
|
marker = "> "
|
||||||
}
|
}
|
||||||
roomList += fmt.Sprintf("%s%s [%s] (%d/4) %s\n",
|
roomList += fmt.Sprintf("%s%s [%s] (%d/4) %s\n",
|
||||||
marker, r.Name, r.Code, r.Players, r.Status)
|
marker, r.Name, r.Code, len(r.Players), r.Status)
|
||||||
|
// Show players in selected room
|
||||||
|
if i == state.cursor {
|
||||||
|
for _, p := range r.Players {
|
||||||
|
cls := p.Class
|
||||||
|
if cls == "" {
|
||||||
|
cls = "..."
|
||||||
|
}
|
||||||
|
readyMark := " "
|
||||||
|
if p.Ready {
|
||||||
|
readyMark = "✓ "
|
||||||
|
}
|
||||||
|
roomList += fmt.Sprintf(" %s%s (%s)\n", readyMark, p.Name, cls)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if roomList == "" {
|
if roomList == "" {
|
||||||
roomList = " No rooms available. Create one!"
|
roomList = " No rooms available. Create one!"
|
||||||
|
|||||||
319
ui/model.go
319
ui/model.go
@@ -20,6 +20,11 @@ const (
|
|||||||
screenGame
|
screenGame
|
||||||
screenShop
|
screenShop
|
||||||
screenResult
|
screenResult
|
||||||
|
screenHelp
|
||||||
|
screenStats
|
||||||
|
screenAchievements
|
||||||
|
screenLeaderboard
|
||||||
|
screenNickname
|
||||||
)
|
)
|
||||||
|
|
||||||
// StateUpdateMsg is sent by GameSession to update the view
|
// StateUpdateMsg is sent by GameSession to update the view
|
||||||
@@ -47,6 +52,11 @@ type Model struct {
|
|||||||
inputBuffer string
|
inputBuffer string
|
||||||
targetCursor int
|
targetCursor int
|
||||||
moveCursor int // selected neighbor index during exploration
|
moveCursor int // selected neighbor index during exploration
|
||||||
|
chatting bool
|
||||||
|
chatInput string
|
||||||
|
rankingSaved bool
|
||||||
|
shopMsg string
|
||||||
|
nicknameInput string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *store.DB) Model {
|
func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *store.DB) Model {
|
||||||
@@ -100,6 +110,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m.updateShop(msg)
|
return m.updateShop(msg)
|
||||||
case screenResult:
|
case screenResult:
|
||||||
return m.updateResult(msg)
|
return m.updateResult(msg)
|
||||||
|
case screenHelp:
|
||||||
|
return m.updateHelp(msg)
|
||||||
|
case screenStats:
|
||||||
|
return m.updateStats(msg)
|
||||||
|
case screenAchievements:
|
||||||
|
return m.updateAchievements(msg)
|
||||||
|
case screenLeaderboard:
|
||||||
|
return m.updateLeaderboard(msg)
|
||||||
|
case screenNickname:
|
||||||
|
return m.updateNickname(msg)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -116,15 +136,38 @@ func (m Model) View() string {
|
|||||||
case screenClassSelect:
|
case screenClassSelect:
|
||||||
return renderClassSelect(m.classState, m.width, m.height)
|
return renderClassSelect(m.classState, m.width, m.height)
|
||||||
case screenGame:
|
case screenGame:
|
||||||
return renderGame(m.gameState, m.width, m.height, m.targetCursor, m.moveCursor)
|
return renderGame(m.gameState, m.width, m.height, m.targetCursor, m.moveCursor, m.chatting, m.chatInput)
|
||||||
case screenShop:
|
case screenShop:
|
||||||
return renderShop(m.gameState, m.width, m.height)
|
return renderShop(m.gameState, m.width, m.height, m.shopMsg)
|
||||||
case screenResult:
|
case screenResult:
|
||||||
var rankings []store.RunRecord
|
var rankings []store.RunRecord
|
||||||
if m.store != nil {
|
if m.store != nil {
|
||||||
rankings, _ = m.store.TopRuns(10)
|
rankings, _ = m.store.TopRuns(10)
|
||||||
}
|
}
|
||||||
return renderResult(m.gameState.Victory, m.gameState.FloorNum, rankings)
|
return renderResult(m.gameState, rankings)
|
||||||
|
case screenHelp:
|
||||||
|
return renderHelp(m.width, m.height)
|
||||||
|
case screenStats:
|
||||||
|
var stats store.PlayerStats
|
||||||
|
if m.store != nil {
|
||||||
|
stats, _ = m.store.GetStats(m.playerName)
|
||||||
|
}
|
||||||
|
return renderStats(m.playerName, stats, m.width, m.height)
|
||||||
|
case screenAchievements:
|
||||||
|
var achievements []store.Achievement
|
||||||
|
if m.store != nil {
|
||||||
|
achievements, _ = m.store.GetAchievements(m.playerName)
|
||||||
|
}
|
||||||
|
return renderAchievements(m.playerName, achievements, m.width, m.height)
|
||||||
|
case screenLeaderboard:
|
||||||
|
var byFloor, byGold []store.RunRecord
|
||||||
|
if m.store != nil {
|
||||||
|
byFloor, _ = m.store.TopRuns(10)
|
||||||
|
byGold, _ = m.store.TopRunsByGold(10)
|
||||||
|
}
|
||||||
|
return renderLeaderboard(byFloor, byGold, m.width, m.height)
|
||||||
|
case screenNickname:
|
||||||
|
return renderNickname(m.nicknameInput, m.width, m.height)
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -158,21 +201,47 @@ func isDown(key tea.KeyMsg) bool {
|
|||||||
func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
if key, ok := msg.(tea.KeyMsg); ok {
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
if isEnter(key) {
|
if isEnter(key) {
|
||||||
|
if m.fingerprint == "" {
|
||||||
|
m.fingerprint = fmt.Sprintf("anon-%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
if m.store != nil {
|
if m.store != nil {
|
||||||
name, err := m.store.GetProfile(m.fingerprint)
|
name, err := m.store.GetProfile(m.fingerprint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.playerName = "Adventurer"
|
// First time player — show nickname input
|
||||||
if m.store != nil && m.fingerprint != "" {
|
m.screen = screenNickname
|
||||||
m.store.SaveProfile(m.fingerprint, m.playerName)
|
m.nicknameInput = ""
|
||||||
}
|
return m, nil
|
||||||
} else {
|
|
||||||
m.playerName = name
|
|
||||||
}
|
}
|
||||||
|
m.playerName = name
|
||||||
} else {
|
} else {
|
||||||
m.playerName = "Adventurer"
|
m.playerName = "Adventurer"
|
||||||
}
|
}
|
||||||
|
if m.lobby != nil {
|
||||||
|
m.lobby.PlayerOnline(m.fingerprint, m.playerName)
|
||||||
|
}
|
||||||
|
// Check for active session to reconnect
|
||||||
|
if m.lobby != nil {
|
||||||
|
code, session := m.lobby.GetActiveSession(m.fingerprint)
|
||||||
|
if session != nil {
|
||||||
|
m.roomCode = code
|
||||||
|
m.session = session
|
||||||
|
m.gameState = m.session.GetState()
|
||||||
|
m.screen = screenGame
|
||||||
|
m.session.TouchActivity(m.fingerprint)
|
||||||
|
m.session.SendChat("System", m.playerName+" reconnected!")
|
||||||
|
return m, m.pollState()
|
||||||
|
}
|
||||||
|
}
|
||||||
m.screen = screenLobby
|
m.screen = screenLobby
|
||||||
m = m.withRefreshedLobby()
|
m = m.withRefreshedLobby()
|
||||||
|
} else if isKey(key, "h") {
|
||||||
|
m.screen = screenHelp
|
||||||
|
} else if isKey(key, "s") {
|
||||||
|
m.screen = screenStats
|
||||||
|
} else if isKey(key, "a") {
|
||||||
|
m.screen = screenAchievements
|
||||||
|
} else if isKey(key, "l") {
|
||||||
|
m.screen = screenLeaderboard
|
||||||
} else if isQuit(key) {
|
} else if isQuit(key) {
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
@@ -180,13 +249,91 @@ func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) updateNickname(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
if isEnter(key) && len(m.nicknameInput) > 0 {
|
||||||
|
m.playerName = m.nicknameInput
|
||||||
|
if m.store != nil && m.fingerprint != "" {
|
||||||
|
m.store.SaveProfile(m.fingerprint, m.playerName)
|
||||||
|
}
|
||||||
|
m.nicknameInput = ""
|
||||||
|
if m.lobby != nil {
|
||||||
|
m.lobby.PlayerOnline(m.fingerprint, m.playerName)
|
||||||
|
}
|
||||||
|
// Check for active session to reconnect
|
||||||
|
if m.lobby != nil {
|
||||||
|
code, session := m.lobby.GetActiveSession(m.fingerprint)
|
||||||
|
if session != nil {
|
||||||
|
m.roomCode = code
|
||||||
|
m.session = session
|
||||||
|
m.gameState = m.session.GetState()
|
||||||
|
m.screen = screenGame
|
||||||
|
m.session.TouchActivity(m.fingerprint)
|
||||||
|
m.session.SendChat("System", m.playerName+" reconnected!")
|
||||||
|
return m, m.pollState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.screen = screenLobby
|
||||||
|
m = m.withRefreshedLobby()
|
||||||
|
} else if isKey(key, "esc") || key.Type == tea.KeyEsc {
|
||||||
|
m.nicknameInput = ""
|
||||||
|
m.screen = screenTitle
|
||||||
|
} else if key.Type == tea.KeyBackspace && len(m.nicknameInput) > 0 {
|
||||||
|
m.nicknameInput = m.nicknameInput[:len(m.nicknameInput)-1]
|
||||||
|
} else if len(key.Runes) == 1 && len(m.nicknameInput) < 12 {
|
||||||
|
ch := string(key.Runes)
|
||||||
|
// Only allow alphanumeric and some special chars
|
||||||
|
if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 {
|
||||||
|
m.nicknameInput += ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updateStats(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
if isKey(key, "s") || isEnter(key) || isQuit(key) {
|
||||||
|
m.screen = screenTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updateAchievements(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
if isKey(key, "a") || isEnter(key) || isQuit(key) {
|
||||||
|
m.screen = screenTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updateLeaderboard(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
if isKey(key, "l") || isEnter(key) || isQuit(key) {
|
||||||
|
m.screen = screenTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updateHelp(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
if isKey(key, "h") || isEnter(key) || isQuit(key) {
|
||||||
|
m.screen = screenTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) updateLobby(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) updateLobby(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
if key, ok := msg.(tea.KeyMsg); ok {
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
// Join-by-code input mode
|
// Join-by-code input mode
|
||||||
if m.lobbyState.joining {
|
if m.lobbyState.joining {
|
||||||
if isEnter(key) && len(m.lobbyState.codeInput) == 4 {
|
if isEnter(key) && len(m.lobbyState.codeInput) == 4 {
|
||||||
if m.lobby != nil {
|
if m.lobby != nil {
|
||||||
if err := m.lobby.JoinRoom(m.lobbyState.codeInput, m.playerName); err == nil {
|
if err := m.lobby.JoinRoom(m.lobbyState.codeInput, m.playerName, m.fingerprint); err == nil {
|
||||||
m.roomCode = m.lobbyState.codeInput
|
m.roomCode = m.lobbyState.codeInput
|
||||||
m.screen = screenClassSelect
|
m.screen = screenClassSelect
|
||||||
}
|
}
|
||||||
@@ -208,7 +355,7 @@ func (m Model) updateLobby(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if isKey(key, "c") {
|
if isKey(key, "c") {
|
||||||
if m.lobby != nil {
|
if m.lobby != nil {
|
||||||
code := m.lobby.CreateRoom(m.playerName + "'s Room")
|
code := m.lobby.CreateRoom(m.playerName + "'s Room")
|
||||||
m.lobby.JoinRoom(code, m.playerName)
|
m.lobby.JoinRoom(code, m.playerName, m.fingerprint)
|
||||||
m.roomCode = code
|
m.roomCode = code
|
||||||
m.screen = screenClassSelect
|
m.screen = screenClassSelect
|
||||||
}
|
}
|
||||||
@@ -226,12 +373,15 @@ func (m Model) updateLobby(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
} else if isEnter(key) {
|
} else if isEnter(key) {
|
||||||
if m.lobby != nil && len(m.lobbyState.rooms) > 0 {
|
if m.lobby != nil && len(m.lobbyState.rooms) > 0 {
|
||||||
r := m.lobbyState.rooms[m.lobbyState.cursor]
|
r := m.lobbyState.rooms[m.lobbyState.cursor]
|
||||||
if err := m.lobby.JoinRoom(r.Code, m.playerName); err == nil {
|
if err := m.lobby.JoinRoom(r.Code, m.playerName, m.fingerprint); err == nil {
|
||||||
m.roomCode = r.Code
|
m.roomCode = r.Code
|
||||||
m.screen = screenClassSelect
|
m.screen = screenClassSelect
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if isKey(key, "q") {
|
} else if isKey(key, "q") {
|
||||||
|
if m.lobby != nil {
|
||||||
|
m.lobby.PlayerOffline(m.fingerprint)
|
||||||
|
}
|
||||||
m.screen = screenTitle
|
m.screen = screenTitle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,6 +401,7 @@ func (m Model) updateClassSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
} else if isEnter(key) {
|
} else if isEnter(key) {
|
||||||
if m.lobby != nil {
|
if m.lobby != nil {
|
||||||
selectedClass := classOptions[m.classState.cursor].class
|
selectedClass := classOptions[m.classState.cursor].class
|
||||||
|
m.lobby.SetPlayerClass(m.roomCode, m.fingerprint, selectedClass.String())
|
||||||
room := m.lobby.GetRoom(m.roomCode)
|
room := m.lobby.GetRoom(m.roomCode)
|
||||||
if room != nil {
|
if room != nil {
|
||||||
if room.Session == nil {
|
if room.Session == nil {
|
||||||
@@ -260,7 +411,11 @@ func (m Model) updateClassSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
player := entity.NewPlayer(m.playerName, selectedClass)
|
player := entity.NewPlayer(m.playerName, selectedClass)
|
||||||
player.Fingerprint = m.fingerprint
|
player.Fingerprint = m.fingerprint
|
||||||
m.session.AddPlayer(player)
|
m.session.AddPlayer(player)
|
||||||
|
if m.lobby != nil {
|
||||||
|
m.lobby.RegisterSession(m.fingerprint, m.roomCode)
|
||||||
|
}
|
||||||
m.session.StartGame()
|
m.session.StartGame()
|
||||||
|
m.lobby.StartRoom(m.roomCode)
|
||||||
m.gameState = m.session.GetState()
|
m.gameState = m.session.GetState()
|
||||||
m.screen = screenGame
|
m.screen = screenGame
|
||||||
}
|
}
|
||||||
@@ -280,18 +435,68 @@ func (m Model) pollState() tea.Cmd {
|
|||||||
type tickMsg struct{}
|
type tickMsg struct{}
|
||||||
|
|
||||||
func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if m.session != nil && m.fingerprint != "" {
|
||||||
|
m.session.TouchActivity(m.fingerprint)
|
||||||
|
}
|
||||||
// Refresh state on every update
|
// Refresh state on every update
|
||||||
if m.session != nil {
|
if m.session != nil {
|
||||||
m.gameState = m.session.GetState()
|
m.gameState = m.session.GetState()
|
||||||
|
// Clamp target cursor to valid range after monsters die
|
||||||
|
if len(m.gameState.Monsters) > 0 {
|
||||||
|
if m.targetCursor >= len(m.gameState.Monsters) {
|
||||||
|
m.targetCursor = len(m.gameState.Monsters) - 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m.targetCursor = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.gameState.GameOver {
|
if m.gameState.GameOver {
|
||||||
if m.store != nil {
|
if m.store != nil && !m.rankingSaved {
|
||||||
score := 0
|
score := 0
|
||||||
for _, p := range m.gameState.Players {
|
for _, p := range m.gameState.Players {
|
||||||
score += p.Gold
|
score += p.Gold
|
||||||
}
|
}
|
||||||
m.store.SaveRun(m.playerName, m.gameState.FloorNum, score)
|
// Find the current player's class
|
||||||
|
playerClass := ""
|
||||||
|
for _, p := range m.gameState.Players {
|
||||||
|
if p.Fingerprint == m.fingerprint {
|
||||||
|
playerClass = p.Class.String()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.store.SaveRun(m.playerName, m.gameState.FloorNum, score, playerClass)
|
||||||
|
// Check achievements
|
||||||
|
if m.gameState.FloorNum >= 5 {
|
||||||
|
m.store.UnlockAchievement(m.playerName, "first_clear")
|
||||||
|
}
|
||||||
|
if m.gameState.FloorNum >= 10 {
|
||||||
|
m.store.UnlockAchievement(m.playerName, "floor10")
|
||||||
|
}
|
||||||
|
if m.gameState.Victory {
|
||||||
|
m.store.UnlockAchievement(m.playerName, "floor20")
|
||||||
|
}
|
||||||
|
if m.gameState.SoloMode && m.gameState.FloorNum >= 5 {
|
||||||
|
m.store.UnlockAchievement(m.playerName, "solo_clear")
|
||||||
|
}
|
||||||
|
if m.gameState.BossKilled {
|
||||||
|
m.store.UnlockAchievement(m.playerName, "boss_slayer")
|
||||||
|
}
|
||||||
|
if m.gameState.FleeSucceeded {
|
||||||
|
m.store.UnlockAchievement(m.playerName, "flee_master")
|
||||||
|
}
|
||||||
|
for _, p := range m.gameState.Players {
|
||||||
|
if p.Gold >= 200 {
|
||||||
|
m.store.UnlockAchievement(p.Name, "gold_hoarder")
|
||||||
|
}
|
||||||
|
if len(p.Relics) >= 3 {
|
||||||
|
m.store.UnlockAchievement(p.Name, "relic_collector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(m.gameState.Players) >= 4 {
|
||||||
|
m.store.UnlockAchievement(m.playerName, "full_party")
|
||||||
|
}
|
||||||
|
m.rankingSaved = true
|
||||||
}
|
}
|
||||||
m.screen = screenResult
|
m.screen = screenResult
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -303,16 +508,63 @@ func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
switch msg.(type) {
|
switch msg.(type) {
|
||||||
case tickMsg:
|
case tickMsg:
|
||||||
// State already refreshed above, just keep polling during combat
|
if m.session != nil {
|
||||||
|
m.session.RevealNextLog()
|
||||||
|
}
|
||||||
|
// Keep polling during combat or while there are pending logs to reveal
|
||||||
if m.gameState.Phase == game.PhaseCombat {
|
if m.gameState.Phase == game.PhaseCombat {
|
||||||
return m, m.pollState()
|
return m, m.pollState()
|
||||||
}
|
}
|
||||||
|
if len(m.gameState.PendingLogs) > 0 {
|
||||||
|
return m, m.pollState()
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if key, ok := msg.(tea.KeyMsg); ok {
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
// Chat mode
|
||||||
|
if m.chatting {
|
||||||
|
if isEnter(key) && len(m.chatInput) > 0 {
|
||||||
|
if m.session != nil {
|
||||||
|
m.session.SendChat(m.playerName, m.chatInput)
|
||||||
|
m.gameState = m.session.GetState()
|
||||||
|
}
|
||||||
|
m.chatting = false
|
||||||
|
m.chatInput = ""
|
||||||
|
} else if isKey(key, "esc") || key.Type == tea.KeyEsc {
|
||||||
|
m.chatting = false
|
||||||
|
m.chatInput = ""
|
||||||
|
} else if key.Type == tea.KeyBackspace && len(m.chatInput) > 0 {
|
||||||
|
m.chatInput = m.chatInput[:len(m.chatInput)-1]
|
||||||
|
} else if len(key.Runes) == 1 && len(m.chatInput) < 40 {
|
||||||
|
m.chatInput += string(key.Runes)
|
||||||
|
}
|
||||||
|
if m.gameState.Phase == game.PhaseCombat {
|
||||||
|
return m, m.pollState()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if isKey(key, "/") {
|
||||||
|
m.chatting = true
|
||||||
|
m.chatInput = ""
|
||||||
|
if m.gameState.Phase == game.PhaseCombat {
|
||||||
|
return m, m.pollState()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
switch m.gameState.Phase {
|
switch m.gameState.Phase {
|
||||||
case game.PhaseExploring:
|
case game.PhaseExploring:
|
||||||
|
// Dead players can only observe, not move
|
||||||
|
for _, p := range m.gameState.Players {
|
||||||
|
if p.Fingerprint == m.fingerprint && p.IsDead() {
|
||||||
|
if isQuit(key) {
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
neighbors := m.getNeighbors()
|
neighbors := m.getNeighbors()
|
||||||
if isUp(key) {
|
if isUp(key) {
|
||||||
if m.moveCursor > 0 {
|
if m.moveCursor > 0 {
|
||||||
@@ -338,7 +590,7 @@ func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case game.PhaseCombat:
|
case game.PhaseCombat:
|
||||||
isPlayerDead := false
|
isPlayerDead := false
|
||||||
for _, p := range m.gameState.Players {
|
for _, p := range m.gameState.Players {
|
||||||
if p.Name == m.playerName && p.IsDead() {
|
if p.Fingerprint == m.fingerprint && p.IsDead() {
|
||||||
isPlayerDead = true
|
isPlayerDead = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -355,15 +607,15 @@ func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.session != nil {
|
if m.session != nil {
|
||||||
switch key.String() {
|
switch key.String() {
|
||||||
case "1":
|
case "1":
|
||||||
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionAttack, TargetIdx: m.targetCursor})
|
m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionAttack, TargetIdx: m.targetCursor})
|
||||||
case "2":
|
case "2":
|
||||||
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionSkill, TargetIdx: m.targetCursor})
|
m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionSkill, TargetIdx: m.targetCursor})
|
||||||
case "3":
|
case "3":
|
||||||
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionItem})
|
m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionItem})
|
||||||
case "4":
|
case "4":
|
||||||
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionFlee})
|
m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionFlee})
|
||||||
case "5":
|
case "5":
|
||||||
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionWait})
|
m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionWait})
|
||||||
}
|
}
|
||||||
// After submitting, poll for turn resolution
|
// After submitting, poll for turn resolution
|
||||||
return m, m.pollState()
|
return m, m.pollState()
|
||||||
@@ -390,7 +642,11 @@ func (m Model) updateShop(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case "1", "2", "3":
|
case "1", "2", "3":
|
||||||
if m.session != nil {
|
if m.session != nil {
|
||||||
idx := int(key.String()[0] - '1')
|
idx := int(key.String()[0] - '1')
|
||||||
m.session.BuyItem(m.playerName, idx)
|
if m.session.BuyItem(m.fingerprint, idx) {
|
||||||
|
m.shopMsg = "Purchased!"
|
||||||
|
} else {
|
||||||
|
m.shopMsg = "Not enough gold!"
|
||||||
|
}
|
||||||
m.gameState = m.session.GetState()
|
m.gameState = m.session.GetState()
|
||||||
}
|
}
|
||||||
case "q":
|
case "q":
|
||||||
@@ -407,6 +663,18 @@ func (m Model) updateShop(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
func (m Model) updateResult(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) updateResult(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
if key, ok := msg.(tea.KeyMsg); ok {
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
if isEnter(key) {
|
if isEnter(key) {
|
||||||
|
if m.lobby != nil && m.fingerprint != "" {
|
||||||
|
m.lobby.UnregisterSession(m.fingerprint)
|
||||||
|
}
|
||||||
|
if m.session != nil {
|
||||||
|
m.session.Stop()
|
||||||
|
m.session = nil
|
||||||
|
}
|
||||||
|
if m.lobby != nil && m.roomCode != "" {
|
||||||
|
m.lobby.RemoveRoom(m.roomCode)
|
||||||
|
}
|
||||||
|
m.roomCode = ""
|
||||||
|
m.rankingSaved = false
|
||||||
m.screen = screenLobby
|
m.screen = screenLobby
|
||||||
m = m.withRefreshedLobby()
|
m = m.withRefreshedLobby()
|
||||||
} else if isQuit(key) {
|
} else if isQuit(key) {
|
||||||
@@ -427,13 +695,18 @@ func (m Model) withRefreshedLobby() Model {
|
|||||||
if r.Status == game.RoomPlaying {
|
if r.Status == game.RoomPlaying {
|
||||||
status = "Playing"
|
status = "Playing"
|
||||||
}
|
}
|
||||||
|
players := make([]playerInfo, len(r.Players))
|
||||||
|
for j, p := range r.Players {
|
||||||
|
players[j] = playerInfo{Name: p.Name, Class: p.Class, Ready: p.Ready}
|
||||||
|
}
|
||||||
m.lobbyState.rooms[i] = roomInfo{
|
m.lobbyState.rooms[i] = roomInfo{
|
||||||
Code: r.Code,
|
Code: r.Code,
|
||||||
Name: r.Name,
|
Name: r.Name,
|
||||||
Players: len(r.Players),
|
Players: players,
|
||||||
Status: status,
|
Status: status,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
m.lobbyState.online = len(m.lobby.ListOnline())
|
||||||
m.lobbyState.cursor = 0
|
m.lobbyState.cursor = 0
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,14 +28,28 @@ func TestTitleToLobby(t *testing.T) {
|
|||||||
t.Fatalf("initial screen: got %d, want screenTitle(0)", m.screen)
|
t.Fatalf("initial screen: got %d, want screenTitle(0)", m.screen)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Press Enter
|
// First-time player: Enter goes to nickname screen
|
||||||
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
m2 := result.(Model)
|
m2 := result.(Model)
|
||||||
|
|
||||||
if m2.screen != screenLobby {
|
if m2.screen != screenNickname {
|
||||||
t.Errorf("after Enter: screen=%d, want screenLobby(1)", m2.screen)
|
t.Errorf("after Enter (first time): screen=%d, want screenNickname(%d)", m2.screen, screenNickname)
|
||||||
}
|
}
|
||||||
if m2.playerName == "" {
|
|
||||||
|
// Type a name
|
||||||
|
for _, ch := range []rune("Hero") {
|
||||||
|
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}})
|
||||||
|
m2 = result.(Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm nickname
|
||||||
|
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
m3 := result.(Model)
|
||||||
|
|
||||||
|
if m3.screen != screenLobby {
|
||||||
|
t.Errorf("after nickname Enter: screen=%d, want screenLobby(1)", m3.screen)
|
||||||
|
}
|
||||||
|
if m3.playerName == "" {
|
||||||
t.Error("playerName should be set")
|
t.Error("playerName should be set")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,12 +59,20 @@ func TestLobbyCreateRoom(t *testing.T) {
|
|||||||
db := testDB(t)
|
db := testDB(t)
|
||||||
defer func() { db.Close(); os.Remove("test_ui.db") }()
|
defer func() { db.Close(); os.Remove("test_ui.db") }()
|
||||||
|
|
||||||
m := NewModel(80, 24, "testfp", lobby, db)
|
m := NewModel(80, 24, "testfp2", lobby, db)
|
||||||
|
|
||||||
// Go to lobby
|
// Go to nickname screen (first-time player)
|
||||||
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
m2 := result.(Model)
|
m2 := result.(Model)
|
||||||
|
|
||||||
|
// Type name and confirm
|
||||||
|
for _, ch := range []rune("Hero") {
|
||||||
|
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}})
|
||||||
|
m2 = result.(Model)
|
||||||
|
}
|
||||||
|
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
m2 = result.(Model)
|
||||||
|
|
||||||
// Press 'c' to create room
|
// Press 'c' to create room
|
||||||
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
|
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
|
||||||
m3 := result.(Model)
|
m3 := result.(Model)
|
||||||
@@ -68,11 +90,19 @@ func TestClassSelectToGame(t *testing.T) {
|
|||||||
db := testDB(t)
|
db := testDB(t)
|
||||||
defer func() { db.Close(); os.Remove("test_ui.db") }()
|
defer func() { db.Close(); os.Remove("test_ui.db") }()
|
||||||
|
|
||||||
m := NewModel(80, 24, "testfp", lobby, db)
|
m := NewModel(80, 24, "testfp3", lobby, db)
|
||||||
|
|
||||||
// Title -> Lobby -> Class Select -> Game
|
// Title -> Nickname -> Lobby
|
||||||
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
m2 := result.(Model)
|
m2 := result.(Model)
|
||||||
|
for _, ch := range []rune("Hero") {
|
||||||
|
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}})
|
||||||
|
m2 = result.(Model)
|
||||||
|
}
|
||||||
|
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
m2 = result.(Model)
|
||||||
|
|
||||||
|
// Lobby -> Class Select
|
||||||
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
|
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
|
||||||
m3 := result.(Model)
|
m3 := result.(Model)
|
||||||
|
|
||||||
|
|||||||
31
ui/nickname_view.go
Normal file
31
ui/nickname_view.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
func renderNickname(input string, width, height int) string {
|
||||||
|
title := styleHeader.Render("── Enter Your Name ──")
|
||||||
|
|
||||||
|
display := input
|
||||||
|
if display == "" {
|
||||||
|
display = strings.Repeat("_", 12)
|
||||||
|
} else {
|
||||||
|
display = input + "_"
|
||||||
|
}
|
||||||
|
|
||||||
|
inputBox := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(colorCyan).
|
||||||
|
Padding(0, 2).
|
||||||
|
Render(stylePlayer.Render(display))
|
||||||
|
|
||||||
|
hint := styleSystem.Render(fmt.Sprintf("(%d/12 characters)", len(input)))
|
||||||
|
footer := styleAction.Render("[Enter] Confirm [Esc] Cancel")
|
||||||
|
|
||||||
|
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
|
||||||
|
lipgloss.JoinVertical(lipgloss.Center, title, "", inputBox, hint, "", footer))
|
||||||
|
}
|
||||||
@@ -2,39 +2,56 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/tolelom/catacombs/game"
|
||||||
"github.com/tolelom/catacombs/store"
|
"github.com/tolelom/catacombs/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func renderResult(won bool, floorReached int, rankings []store.RunRecord) string {
|
func renderResult(state game.GameState, rankings []store.RunRecord) string {
|
||||||
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205"))
|
var sb strings.Builder
|
||||||
|
|
||||||
var title string
|
// Title
|
||||||
if won {
|
if state.Victory {
|
||||||
title = titleStyle.Render("VICTORY! You escaped the Catacombs!")
|
sb.WriteString(styleHeal.Render(" ✦ VICTORY ✦ ") + "\n\n")
|
||||||
|
sb.WriteString(styleSystem.Render(" You conquered the Catacombs!") + "\n\n")
|
||||||
} else {
|
} else {
|
||||||
title = titleStyle.Render("GAME OVER")
|
sb.WriteString(styleDamage.Render(" ✦ DEFEAT ✦ ") + "\n\n")
|
||||||
|
sb.WriteString(styleSystem.Render(fmt.Sprintf(" Fallen on floor B%d", state.FloorNum)) + "\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
floorInfo := fmt.Sprintf("Floor Reached: B%d", floorReached)
|
// Player summary
|
||||||
|
sb.WriteString(styleHeader.Render("── Party Summary ──") + "\n\n")
|
||||||
|
totalGold := 0
|
||||||
|
for _, p := range state.Players {
|
||||||
|
status := styleHeal.Render("Alive")
|
||||||
|
if p.IsDead() {
|
||||||
|
status = styleDamage.Render("Dead")
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s (%s) %s Gold: %d Items: %d Relics: %d\n",
|
||||||
|
stylePlayer.Render(p.Name), p.Class, status, p.Gold, len(p.Inventory), len(p.Relics)))
|
||||||
|
totalGold += p.Gold
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("\n Total Gold: %s\n", styleGold.Render(fmt.Sprintf("%d", totalGold))))
|
||||||
|
|
||||||
rankHeader := lipgloss.NewStyle().Bold(true).Render("── Rankings ──")
|
// Rankings
|
||||||
rankList := ""
|
if len(rankings) > 0 {
|
||||||
for i, r := range rankings {
|
sb.WriteString("\n" + styleHeader.Render("── Top Runs ──") + "\n\n")
|
||||||
rankList += fmt.Sprintf(" %d. %s — B%d (Score: %d)\n", i+1, r.Player, r.Floor, r.Score)
|
for i, r := range rankings {
|
||||||
|
medal := " "
|
||||||
|
switch i {
|
||||||
|
case 0:
|
||||||
|
medal = styleGold.Render("🥇")
|
||||||
|
case 1:
|
||||||
|
medal = styleSystem.Render("🥈")
|
||||||
|
case 2:
|
||||||
|
medal = styleGold.Render("🥉")
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s %s Floor B%d Score: %d\n", medal, r.Player, r.Floor, r.Score))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
menu := "[Enter] Return to Lobby [Q] Quit"
|
sb.WriteString("\n" + styleAction.Render(" [Enter] Return to Lobby") + "\n")
|
||||||
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Center,
|
return sb.String()
|
||||||
title,
|
|
||||||
"",
|
|
||||||
floorInfo,
|
|
||||||
"",
|
|
||||||
rankHeader,
|
|
||||||
rankList,
|
|
||||||
"",
|
|
||||||
menu,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,27 +4,55 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/tolelom/catacombs/entity"
|
||||||
"github.com/tolelom/catacombs/game"
|
"github.com/tolelom/catacombs/game"
|
||||||
)
|
)
|
||||||
|
|
||||||
func renderShop(state game.GameState, width, height int) string {
|
func itemTypeLabel(item entity.Item) string {
|
||||||
|
switch item.Type {
|
||||||
|
case entity.ItemWeapon:
|
||||||
|
return fmt.Sprintf("[ATK+%d]", item.Bonus)
|
||||||
|
case entity.ItemArmor:
|
||||||
|
return fmt.Sprintf("[DEF+%d]", item.Bonus)
|
||||||
|
case entity.ItemConsumable:
|
||||||
|
return fmt.Sprintf("[HP+%d]", item.Bonus)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("[+%d]", item.Bonus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderShop(state game.GameState, width, height int, shopMsg string) string {
|
||||||
headerStyle := lipgloss.NewStyle().
|
headerStyle := lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("226")).
|
Foreground(lipgloss.Color("226")).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
|
goldStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("220"))
|
||||||
|
msgStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("196")).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
header := headerStyle.Render("── Shop ──")
|
header := headerStyle.Render("── Shop ──")
|
||||||
|
|
||||||
|
// Show current player's gold
|
||||||
|
goldLine := ""
|
||||||
|
for _, p := range state.Players {
|
||||||
|
inventoryCount := len(p.Inventory)
|
||||||
|
goldLine += goldStyle.Render(fmt.Sprintf(" %s — Gold: %d Items: %d/10", p.Name, p.Gold, inventoryCount))
|
||||||
|
goldLine += "\n"
|
||||||
|
}
|
||||||
|
|
||||||
items := ""
|
items := ""
|
||||||
for i, item := range state.ShopItems {
|
for i, item := range state.ShopItems {
|
||||||
items += fmt.Sprintf(" [%d] %s (+%d) — %d gold\n", i+1, item.Name, item.Bonus, item.Price)
|
label := itemTypeLabel(item)
|
||||||
|
items += fmt.Sprintf(" [%d] %s %s — %d gold\n", i+1, item.Name, label, item.Price)
|
||||||
}
|
}
|
||||||
|
|
||||||
menu := "[1-3] Buy [Q] Leave Shop"
|
menu := "[1-3] Buy [Q] Leave Shop"
|
||||||
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left,
|
parts := []string{header, "", goldLine, items, "", menu}
|
||||||
header,
|
if shopMsg != "" {
|
||||||
"",
|
parts = append(parts, "", msgStyle.Render(shopMsg))
|
||||||
items,
|
}
|
||||||
"",
|
|
||||||
menu,
|
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
30
ui/stats_view.go
Normal file
30
ui/stats_view.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/tolelom/catacombs/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func renderStats(playerName string, stats store.PlayerStats, width, height int) string {
|
||||||
|
title := styleHeader.Render("── Player Statistics ──")
|
||||||
|
|
||||||
|
var content string
|
||||||
|
content += stylePlayer.Render(fmt.Sprintf(" %s", playerName)) + "\n\n"
|
||||||
|
content += fmt.Sprintf(" Total Runs: %s\n", styleGold.Render(fmt.Sprintf("%d", stats.TotalRuns)))
|
||||||
|
content += fmt.Sprintf(" Best Floor: %s\n", styleGold.Render(fmt.Sprintf("B%d", stats.BestFloor)))
|
||||||
|
content += fmt.Sprintf(" Total Gold: %s\n", styleGold.Render(fmt.Sprintf("%d", stats.TotalGold)))
|
||||||
|
content += fmt.Sprintf(" Victories: %s\n", styleHeal.Render(fmt.Sprintf("%d", stats.Victories)))
|
||||||
|
|
||||||
|
winRate := 0.0
|
||||||
|
if stats.TotalRuns > 0 {
|
||||||
|
winRate = float64(stats.Victories) / float64(stats.TotalRuns) * 100
|
||||||
|
}
|
||||||
|
content += fmt.Sprintf(" Win Rate: %s\n", styleSystem.Render(fmt.Sprintf("%.1f%%", winRate)))
|
||||||
|
|
||||||
|
footer := styleSystem.Render("[S] Back")
|
||||||
|
|
||||||
|
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
|
||||||
|
lipgloss.JoinVertical(lipgloss.Center, title, "", content, "", footer))
|
||||||
|
}
|
||||||
32
ui/styles.go
Normal file
32
ui/styles.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
var (
|
||||||
|
colorRed = lipgloss.Color("196")
|
||||||
|
colorGreen = lipgloss.Color("46")
|
||||||
|
colorYellow = lipgloss.Color("226")
|
||||||
|
colorCyan = lipgloss.Color("51")
|
||||||
|
colorMagenta = lipgloss.Color("201")
|
||||||
|
colorWhite = lipgloss.Color("255")
|
||||||
|
colorGray = lipgloss.Color("240")
|
||||||
|
colorOrange = lipgloss.Color("208")
|
||||||
|
colorPink = lipgloss.Color("205")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Text styles
|
||||||
|
var (
|
||||||
|
styleDamage = lipgloss.NewStyle().Foreground(colorRed).Bold(true)
|
||||||
|
styleHeal = lipgloss.NewStyle().Foreground(colorGreen).Bold(true)
|
||||||
|
styleCoop = lipgloss.NewStyle().Foreground(colorYellow).Bold(true)
|
||||||
|
styleFlee = lipgloss.NewStyle().Foreground(colorCyan)
|
||||||
|
styleStatus = lipgloss.NewStyle().Foreground(colorMagenta)
|
||||||
|
styleGold = lipgloss.NewStyle().Foreground(colorYellow)
|
||||||
|
styleSystem = lipgloss.NewStyle().Foreground(colorGray).Italic(true)
|
||||||
|
styleEnemy = lipgloss.NewStyle().Foreground(colorRed)
|
||||||
|
stylePlayer = lipgloss.NewStyle().Foreground(colorWhite).Bold(true)
|
||||||
|
styleHeader = lipgloss.NewStyle().Foreground(colorPink).Bold(true)
|
||||||
|
styleAction = lipgloss.NewStyle().Bold(true)
|
||||||
|
styleTimer = lipgloss.NewStyle().Foreground(colorYellow).Bold(true)
|
||||||
|
)
|
||||||
69
ui/title.go
69
ui/title.go
@@ -1,37 +1,60 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
var titleArt = `
|
var titleLines = []string{
|
||||||
██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ███╗ ███╗██████╗ ███████╗
|
` ██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ███╗ ███╗██████╗ ███████╗`,
|
||||||
██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██╔════╝██╔═══██╗████╗ ████║██╔══██╗██╔════╝
|
`██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██╔════╝██╔═══██╗████╗ ████║██╔══██╗██╔════╝`,
|
||||||
██║ ███████║ ██║ ███████║██║ ██║ ██║██╔████╔██║██████╔╝███████╗
|
`██║ ███████║ ██║ ███████║██║ ██║ ██║██╔████╔██║██████╔╝███████╗`,
|
||||||
██║ ██╔══██║ ██║ ██╔══██║██║ ██║ ██║██║╚██╔╝██║██╔══██╗╚════██║
|
`██║ ██╔══██║ ██║ ██╔══██║██║ ██║ ██║██║╚██╔╝██║██╔══██╗╚════██║`,
|
||||||
╚██████╗██║ ██║ ██║ ██║ ██║╚██████╗╚██████╔╝██║ ╚═╝ ██║██████╔╝███████║
|
`╚██████╗██║ ██║ ██║ ██║ ██║╚██████╗╚██████╔╝██║ ╚═╝ ██║██████╔╝███████║`,
|
||||||
╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚══════╝
|
` ╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚══════╝`,
|
||||||
`
|
}
|
||||||
|
|
||||||
|
var titleColors = []lipgloss.Color{
|
||||||
|
lipgloss.Color("196"),
|
||||||
|
lipgloss.Color("202"),
|
||||||
|
lipgloss.Color("208"),
|
||||||
|
lipgloss.Color("214"),
|
||||||
|
lipgloss.Color("220"),
|
||||||
|
lipgloss.Color("226"),
|
||||||
|
}
|
||||||
|
|
||||||
func renderTitle(width, height int) string {
|
func renderTitle(width, height int) string {
|
||||||
titleStyle := lipgloss.NewStyle().
|
var logoLines []string
|
||||||
Foreground(lipgloss.Color("205")).
|
for i, line := range titleLines {
|
||||||
|
color := titleColors[i%len(titleColors)]
|
||||||
|
style := lipgloss.NewStyle().Foreground(color).Bold(true)
|
||||||
|
logoLines = append(logoLines, style.Render(line))
|
||||||
|
}
|
||||||
|
logo := strings.Join(logoLines, "\n")
|
||||||
|
|
||||||
|
subtitle := lipgloss.NewStyle().
|
||||||
|
Foreground(colorGray).
|
||||||
|
Render("⚔ A Cooperative Dungeon Crawler ⚔")
|
||||||
|
|
||||||
|
server := lipgloss.NewStyle().
|
||||||
|
Foreground(colorCyan).
|
||||||
|
Render("ssh catacombs.tolelom.xyz")
|
||||||
|
|
||||||
|
menu := lipgloss.NewStyle().
|
||||||
|
Foreground(colorWhite).
|
||||||
Bold(true).
|
Bold(true).
|
||||||
Align(lipgloss.Center)
|
Render("[Enter] Start [H] Help [S] Stats [A] Achievements [L] Leaderboard [Q] Quit")
|
||||||
|
|
||||||
subtitleStyle := lipgloss.NewStyle().
|
content := lipgloss.JoinVertical(lipgloss.Center,
|
||||||
Foreground(lipgloss.Color("240")).
|
logo,
|
||||||
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"),
|
subtitle,
|
||||||
|
server,
|
||||||
"",
|
"",
|
||||||
menuStyle.Render("[Enter] Start [Q] Quit"),
|
"",
|
||||||
|
menu,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, content)
|
||||||
}
|
}
|
||||||
|
|||||||
161
web/server.go
Normal file
161
web/server.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed static
|
||||||
|
var staticFiles embed.FS
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
type resizeMsg struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Cols int `json:"cols"`
|
||||||
|
Rows int `json:"rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start launches the HTTP server for the web terminal.
|
||||||
|
func Start(addr string, sshPort int) error {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Serve static files from embedded FS
|
||||||
|
mux.Handle("/", http.FileServer(http.FS(staticFiles)))
|
||||||
|
|
||||||
|
// WebSocket endpoint
|
||||||
|
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handleWS(w, r, sshPort)
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("Starting web terminal on %s", addr)
|
||||||
|
return http.ListenAndServe(addr, mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleWS(w http.ResponseWriter, r *http.Request, sshPort int) {
|
||||||
|
ws, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WebSocket upgrade error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer ws.Close()
|
||||||
|
|
||||||
|
// Connect to local SSH server
|
||||||
|
sshConfig := &ssh.ClientConfig{
|
||||||
|
User: "web-player",
|
||||||
|
Auth: []ssh.AuthMethod{
|
||||||
|
ssh.Password(""),
|
||||||
|
},
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
|
}
|
||||||
|
|
||||||
|
sshAddr := fmt.Sprintf("localhost:%d", sshPort)
|
||||||
|
client, err := ssh.Dial("tcp", sshAddr, sshConfig)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("SSH dial error: %v", err)
|
||||||
|
ws.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("Failed to connect to game server: %v\r\n", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
session, err := client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("SSH session error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
// Request PTY
|
||||||
|
if err := session.RequestPty("xterm-256color", 24, 80, ssh.TerminalModes{
|
||||||
|
ssh.ECHO: 1,
|
||||||
|
ssh.TTY_OP_ISPEED: 14400,
|
||||||
|
ssh.TTY_OP_OSPEED: 14400,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("PTY request error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stdin, err := session.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("stdin pipe error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, err := session.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("stdout pipe error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.Shell(); err != nil {
|
||||||
|
log.Printf("shell error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var once sync.Once
|
||||||
|
done := make(chan struct{})
|
||||||
|
cleanup := func() {
|
||||||
|
once.Do(func() {
|
||||||
|
close(done)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSH stdout → WebSocket
|
||||||
|
go func() {
|
||||||
|
defer cleanup()
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := stdout.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if writeErr := ws.WriteMessage(websocket.TextMessage, buf[:n]); writeErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// WebSocket → SSH stdin (text frames) or resize (binary frames)
|
||||||
|
go func() {
|
||||||
|
defer cleanup()
|
||||||
|
for {
|
||||||
|
msgType, data, err := ws.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch msgType {
|
||||||
|
case websocket.TextMessage:
|
||||||
|
if _, err := stdin.Write(data); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case websocket.BinaryMessage:
|
||||||
|
var msg resizeMsg
|
||||||
|
if json.Unmarshal(data, &msg) == nil && msg.Type == "resize" {
|
||||||
|
session.WindowChange(msg.Rows, msg.Cols)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for either side to close
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure SSH session ends
|
||||||
|
_ = session.Close()
|
||||||
|
_ = client.Close()
|
||||||
|
_ = io.WriteCloser(stdin).Close()
|
||||||
|
}
|
||||||
120
web/static/index.html
Normal file
120
web/static/index.html
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Catacombs</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/@xterm/xterm@5.5.0/css/xterm.css">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; overflow: hidden; background: #1a1a2e; }
|
||||||
|
#terminal { height: 100%; width: 100%; }
|
||||||
|
#overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(26, 26, 46, 0.9);
|
||||||
|
justify-content: center; align-items: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
#overlay.visible { display: flex; }
|
||||||
|
#overlay-text {
|
||||||
|
color: #e0e0e0; font-family: monospace; font-size: 18px;
|
||||||
|
text-align: center; line-height: 2;
|
||||||
|
}
|
||||||
|
#overlay-text span { color: #51d0ff; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="terminal"></div>
|
||||||
|
<div id="overlay">
|
||||||
|
<div id="overlay-text">
|
||||||
|
Connection lost.<br>
|
||||||
|
<span>Press any key to reconnect.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/@xterm/xterm@5.5.0/lib/xterm.js"></script>
|
||||||
|
<script src="https://unpkg.com/@xterm/addon-fit@0.10.0/lib/addon-fit.js"></script>
|
||||||
|
<script>
|
||||||
|
const termEl = document.getElementById('terminal');
|
||||||
|
const overlay = document.getElementById('overlay');
|
||||||
|
|
||||||
|
const term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: "'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace",
|
||||||
|
theme: {
|
||||||
|
background: '#1a1a2e',
|
||||||
|
foreground: '#e0e0e0',
|
||||||
|
cursor: '#51d0ff',
|
||||||
|
selectionBackground: '#44475a',
|
||||||
|
},
|
||||||
|
allowProposedApi: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fitAddon = new FitAddon.FitAddon();
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
term.open(termEl);
|
||||||
|
fitAddon.fit();
|
||||||
|
|
||||||
|
let ws = null;
|
||||||
|
let connected = false;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
ws = new WebSocket(`${proto}//${location.host}/ws`);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
connected = true;
|
||||||
|
overlay.classList.remove('visible');
|
||||||
|
term.clear();
|
||||||
|
term.focus();
|
||||||
|
sendResize();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
term.write(e.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
connected = false;
|
||||||
|
overlay.classList.add('visible');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
connected = false;
|
||||||
|
overlay.classList.add('visible');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendResize() {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
const msg = JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows });
|
||||||
|
ws.send(new Blob([msg]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
term.onData((data) => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
fitAddon.fit();
|
||||||
|
sendResize();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reconnect on any key when disconnected
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (!connected) {
|
||||||
|
e.preventDefault();
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial connection
|
||||||
|
connect();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user