mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
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:
26
docs/architecture/adventure-achievement-icon-prompts.json
Normal file
26
docs/architecture/adventure-achievement-icon-prompts.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"> </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 = ' '
|
||||
})
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
@@ -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():
|
||||
|
||||
BIN
docs/architecture/icons/ending-disaster.png
Normal file
BIN
docs/architecture/icons/ending-disaster.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
docs/architecture/icons/ending-good.png
Normal file
BIN
docs/architecture/icons/ending-good.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
docs/architecture/icons/ending-great.png
Normal file
BIN
docs/architecture/icons/ending-great.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
docs/architecture/icons/ending-mediocre.png
Normal file
BIN
docs/architecture/icons/ending-mediocre.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Reference in New Issue
Block a user