feat: localStorage persistence, restart button, and ending achievements

- Game state persists across page refreshes via localStorage
- Restart button in HUD resets game without losing achievements
- Endings sidebar tracks which of the 4 endings have been unlocked
- Achievement icons generated for each ending (locked/unlocked states)
- Achievements persist across runs in separate storage key

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alexander Brown
2026-03-24 15:44:22 -07:00
committed by DrJKL
parent 716629adbc
commit 6e06280642
7 changed files with 187 additions and 3 deletions

View File

@@ -0,0 +1,26 @@
{
"meta": {
"style": "Pixel art badge/medal icon, 128x128, dark background, achievement unlock style",
"usage": "Each key matches an ending ID. Shown in achievements panel when that ending has been reached.",
"model": "Z-Image Turbo (no LoRA)",
"resolution": "128x128"
},
"achievements": {
"great": {
"title": "The ECS Enlightenment",
"prompt": "Pixel art achievement badge of a radiant crystal temple with clean geometric architecture, bright green and gold triumphant glow, laurel wreath border, dark background"
},
"good": {
"title": "The Clean Architecture",
"prompt": "Pixel art achievement badge of a solid fortress with neat organized blocks, blue and silver steady glow, star emblem, dark background"
},
"mediocre": {
"title": "The Eternal Refactor",
"prompt": "Pixel art achievement badge of an hourglass with sand still flowing endlessly, amber and grey weary glow, circular border, dark background"
},
"disaster": {
"title": "The Spaghetti Singularity",
"prompt": "Pixel art achievement badge of a tangled mass of spaghetti code wires collapsing into a black hole, red and purple chaotic glow, cracked border, dark background"
}
}
}

View File

@@ -860,6 +860,62 @@
color: var(--border);
}
/* --- Achievements --- */
.ach-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
gap: 6px;
padding: 8px;
}
.ach-slot {
aspect-ratio: 1;
border-radius: 6px;
background: var(--bg);
border: 2px solid var(--border);
overflow: hidden;
cursor: default;
position: relative;
transition: border-color 0.15s;
}
.ach-slot:hover { border-color: var(--accent); }
.ach-slot img { width: 100%; height: 100%; object-fit: cover; display: block; }
.ach-slot.locked {
border-style: dashed;
display: flex;
align-items: center;
justify-content: center;
}
.ach-slot.locked img { filter: brightness(0) saturate(0); opacity: 0.2; }
.ach-slot.unlocked { border-color: var(--purple); }
#ach-label {
padding: 4px 12px;
font-size: 11px;
color: var(--purple);
min-height: 22px;
border-bottom: 1px solid var(--border);
user-select: none;
}
/* --- Restart button --- */
#restart-btn {
background: none;
border: 1px solid var(--border);
color: var(--muted);
padding: 4px 10px;
border-radius: 4px;
font-family: inherit;
font-size: 11px;
cursor: pointer;
}
#restart-btn:hover { border-color: var(--red); color: var(--red); }
/* --- Room transition --- */
#narrative {
transition: opacity 0.2s ease, transform 0.2s ease;
@@ -931,6 +987,7 @@
</div>
</div>
<span id="challenge-progress">Challenges: <span class="progress-value" id="val-challenges">0/7</span></span>
<button id="restart-btn">Restart</button>
<button id="toggle-map">Map [M]</button>
</div>
</header>
@@ -991,6 +1048,14 @@
<div class="log-entry" style="color:var(--muted)">No decisions yet.</div>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-header">
<span>Endings</span>
<span id="ach-count">0/4</span>
</div>
<div id="ach-label">&nbsp;</div>
<div class="sidebar-body" id="achievements"></div>
</div>
<details class="sidebar-section" id="log-section">
<summary class="sidebar-header">
<span style="flex:1">Log</span>
@@ -1019,6 +1084,9 @@
// Game Engine
// =============================================
const STORAGE_KEY = 'codebase-caverns-state'
const ACH_KEY = 'codebase-caverns-achievements'
function createInitialState() {
return {
currentRoom: 'entry',
@@ -1032,7 +1100,44 @@
}
}
let state = createInitialState()
function saveState() {
try {
const s = {
...state,
visited: [...state.visited],
challengesCompleted: [...state.challengesCompleted],
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(s))
} catch {}
}
function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const s = JSON.parse(raw)
s.visited = new Set(s.visited)
s.challengesCompleted = new Set(s.challengesCompleted)
return s
} catch { return null }
}
function loadAchievements() {
try {
const raw = localStorage.getItem(ACH_KEY)
return raw ? JSON.parse(raw) : []
} catch { return [] }
}
function saveAchievement(endingId) {
const achs = loadAchievements()
if (!achs.includes(endingId)) {
achs.push(endingId)
try { localStorage.setItem(ACH_KEY, JSON.stringify(achs)) } catch {}
}
}
let state = loadState() || createInitialState()
const TOTAL_CHALLENGES = 7
const GH = 'https://github.com/Comfy-Org/ComfyUI_frontend/blob/main'
@@ -1562,6 +1667,10 @@
mapBackdrop: $('#map-backdrop'),
toggleMap: $('#toggle-map'),
valChallenges: $('#val-challenges'),
restartBtn: $('#restart-btn'),
achievements: $('#achievements'),
achCount: $('#ach-count'),
achLabel: $('#ach-label'),
narrative: $('#narrative'),
endingOverlay: $('#ending-overlay'),
endingTitle: $('#ending-title'),
@@ -1735,6 +1844,7 @@
addLog(`Entered: ${room.title}`)
preloadAdjacentImages(room)
saveState()
}
// --- Image preloading ---
@@ -1842,6 +1952,7 @@
// Show navigation choices
renderChoices(room.choices)
renderDecisions()
saveState()
// Log
addLog(`Challenge resolved: ${challenge.title}`, choice.rating === 'good' ? 'discovery' : choice.rating === 'bad' ? 'error' : 'warning')
@@ -2025,16 +2136,49 @@
statColor('ECS Migration', s.migrationProgress + ' / 5', s.migrationProgress >= 4),
].join('')
// Save achievement
saveAchievement(ending.id)
renderAchievements()
els.endingOverlay.classList.add('active')
}
function renderAchievements() {
const unlocked = loadAchievements()
els.achievements.innerHTML = '<div class="ach-grid">' + endings.map(e => {
const got = unlocked.includes(e.id)
const cls = got ? 'ach-slot unlocked' : 'ach-slot locked'
return `<div class="${cls}" data-ending="${e.id}">
<img src="icons/ending-${e.id}.png" alt="${got ? e.title : '?'}"
onerror="this.style.display='none'">
</div>`
}).join('') + '</div>'
els.achievements.querySelectorAll('.ach-slot').forEach(slot => {
const e = endings.find(x => x.id === slot.dataset.ending)
const got = unlocked.includes(e.id)
slot.addEventListener('mouseenter', () => {
els.achLabel.textContent = got ? e.title : 'Locked'
})
slot.addEventListener('mouseleave', () => {
els.achLabel.innerHTML = '&nbsp;'
})
})
els.achCount.textContent = unlocked.length + '/' + endings.length
}
// --- Reset ---
function resetGame() {
state = createInitialState()
isFirstRender = true
localStorage.removeItem(STORAGE_KEY)
els.endingOverlay.classList.remove('active')
els.resultBanner.classList.remove('active')
els.resultBanner.className = ''
render('entry')
renderAchievements()
addLog('Welcome back, architect. The codebase awaits.', 'discovery')
}
@@ -2048,6 +2192,10 @@
els.mapBackdrop.addEventListener('click', toggleMap)
els.playAgainBtn.addEventListener('click', resetGame)
els.restartBtn.addEventListener('click', () => {
if (state.challengesCompleted.size === 0 && state.visited.size <= 1) return
resetGame()
})
// --- Keyboard Navigation ---
document.addEventListener('keydown', (e) => {
@@ -2080,9 +2228,12 @@
})
// --- Boot ---
render('entry')
render(state.currentRoom)
renderStats()
addLog('Welcome, architect. Explore the codebase. Challenges await in every room.', 'discovery')
renderAchievements()
if (state.log.length === 0) {
addLog('Welcome, architect. Explore the codebase. Challenges await in every room.', 'discovery')
}
</script>
</body>

View File

@@ -16,6 +16,7 @@ COMFY_URL = "http://localhost:8188"
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
ARTIFACT_PROMPTS = os.path.join(SCRIPT_DIR, "adventure-icon-prompts.json")
CHOICE_PROMPTS = os.path.join(SCRIPT_DIR, "adventure-choice-icon-prompts.json")
ACHIEVEMENT_PROMPTS = os.path.join(SCRIPT_DIR, "adventure-achievement-icon-prompts.json")
OUTPUT_DIR = os.path.join(SCRIPT_DIR, "icons")
BASE_SEED = 7777
WIDTH = 128
@@ -137,6 +138,12 @@ def main():
for icon_id, entry in data["choices"].items():
all_icons[icon_id] = entry["prompt"]
if os.path.exists(ACHIEVEMENT_PROMPTS):
with open(ACHIEVEMENT_PROMPTS) as f:
data = json.load(f)
for icon_id, entry in data["achievements"].items():
all_icons[f"ending-{icon_id}"] = entry["prompt"]
# Filter out already-generated icons
to_generate = {}
for icon_id, prompt in all_icons.items():

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB