feat: achievement system with 10 unlockable achievements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,8 @@ type GameState struct {
|
||||
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) {
|
||||
@@ -277,6 +279,8 @@ func (s *GameSession) GetState() GameState {
|
||||
SubmittedActions: submittedCopy,
|
||||
PendingLogs: pendingCopy,
|
||||
TurnResolving: s.state.TurnResolving,
|
||||
BossKilled: s.state.BossKilled,
|
||||
FleeSucceeded: s.state.FleeSucceeded,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,7 @@ func (s *GameSession) resolvePlayerActions() {
|
||||
case ActionFlee:
|
||||
if combat.AttemptFlee() {
|
||||
s.addLog(fmt.Sprintf("%s fled from battle!", p.Name))
|
||||
s.state.FleeSucceeded = true
|
||||
if s.state.SoloMode {
|
||||
s.state.Phase = PhaseExploring
|
||||
return
|
||||
@@ -255,6 +256,7 @@ func (s *GameSession) resolvePlayerActions() {
|
||||
}
|
||||
s.addLog(fmt.Sprintf("%s defeated! +%d gold", m.Name, goldReward))
|
||||
if m.IsBoss {
|
||||
s.state.BossKilled = true
|
||||
s.grantBossRelic()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
32
store/db.go
32
store/db.go
@@ -21,6 +21,7 @@ type RunRecord struct {
|
||||
Player string `json:"player"`
|
||||
Floor int `json:"floor"`
|
||||
Score int `json:"score"`
|
||||
Class string `json:"class,omitempty"`
|
||||
}
|
||||
|
||||
func Open(path string) (*DB, error) {
|
||||
@@ -35,6 +36,9 @@ func Open(path string) (*DB, error) {
|
||||
if _, err := tx.CreateBucketIfNotExists(bucketRankings); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.CreateBucketIfNotExists(bucketAchievements); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return &DB{db: db}, err
|
||||
@@ -63,11 +67,11 @@ func (d *DB) GetProfile(fingerprint string) (string, error) {
|
||||
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 {
|
||||
b := tx.Bucket(bucketRankings)
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -76,6 +80,30 @@ 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
|
||||
|
||||
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))
|
||||
}
|
||||
88
ui/model.go
88
ui/model.go
@@ -22,6 +22,8 @@ const (
|
||||
screenResult
|
||||
screenHelp
|
||||
screenStats
|
||||
screenAchievements
|
||||
screenLeaderboard
|
||||
)
|
||||
|
||||
// StateUpdateMsg is sent by GameSession to update the view
|
||||
@@ -110,6 +112,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.updateHelp(msg)
|
||||
case screenStats:
|
||||
return m.updateStats(msg)
|
||||
case screenAchievements:
|
||||
return m.updateAchievements(msg)
|
||||
case screenLeaderboard:
|
||||
return m.updateLeaderboard(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -143,6 +149,19 @@ func (m Model) View() string {
|
||||
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)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -192,12 +211,19 @@ func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.fingerprint == "" {
|
||||
m.fingerprint = fmt.Sprintf("anon-%d", time.Now().UnixNano())
|
||||
}
|
||||
if m.lobby != nil {
|
||||
m.lobby.PlayerOnline(m.fingerprint, m.playerName)
|
||||
}
|
||||
m.screen = screenLobby
|
||||
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) {
|
||||
return m, tea.Quit
|
||||
}
|
||||
@@ -214,6 +240,24 @@ func (m Model) updateStats(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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) {
|
||||
@@ -275,6 +319,9 @@ func (m Model) updateLobby(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
} else if isKey(key, "q") {
|
||||
if m.lobby != nil {
|
||||
m.lobby.PlayerOffline(m.fingerprint)
|
||||
}
|
||||
m.screen = screenTitle
|
||||
}
|
||||
}
|
||||
@@ -347,7 +394,45 @@ func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
for _, p := range m.gameState.Players {
|
||||
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
|
||||
@@ -555,6 +640,7 @@ func (m Model) withRefreshedLobby() Model {
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
m.lobbyState.online = len(m.lobby.ListOnline())
|
||||
m.lobbyState.cursor = 0
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func renderTitle(width, height int) string {
|
||||
menu := lipgloss.NewStyle().
|
||||
Foreground(colorWhite).
|
||||
Bold(true).
|
||||
Render("[Enter] Start [H] Help [S] Stats [Q] Quit")
|
||||
Render("[Enter] Start [H] Help [S] Stats [A] Achievements [L] Leaderboard [Q] Quit")
|
||||
|
||||
content := lipgloss.JoinVertical(lipgloss.Center,
|
||||
logo,
|
||||
|
||||
Reference in New Issue
Block a user