|
|
|
|
|
// Simple audio utility |
|
|
function playSound(soundFile) { |
|
|
try { |
|
|
const audio = new Audio(`assets/sounds/${soundFile}`); |
|
|
audio.volume = 0.3; |
|
|
audio.play().catch(e => {}); // Silently fail if no audio |
|
|
} catch (e) { |
|
|
// Silently fail if audio not available |
|
|
} |
|
|
} |
|
|
|
|
|
export function showDamageNumber(damage, target, isPlayer = false) { |
|
|
const targetElement = isPlayer ? |
|
|
document.querySelector('.player-battle-zone') : |
|
|
document.querySelector('.enemy-battle-zone'); |
|
|
|
|
|
if (!targetElement) return; |
|
|
|
|
|
const damageNumber = document.createElement('div'); |
|
|
damageNumber.className = 'damage-number'; |
|
|
damageNumber.textContent = damage; |
|
|
|
|
|
|
|
|
const rect = targetElement.getBoundingClientRect(); |
|
|
damageNumber.style.left = `${rect.left + rect.width / 2}px`; |
|
|
damageNumber.style.top = `${rect.top + rect.height / 2}px`; |
|
|
|
|
|
document.body.appendChild(damageNumber); |
|
|
|
|
|
|
|
|
requestAnimationFrame(() => { |
|
|
damageNumber.classList.add('damage-number-animate'); |
|
|
}); |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
if (damageNumber.parentNode) { |
|
|
damageNumber.parentNode.removeChild(damageNumber); |
|
|
} |
|
|
}, 1000); |
|
|
} |
|
|
|
|
|
export async function renderBattle(root) { |
|
|
const app = root.app; |
|
|
const p = root.player, e = root.enemy; |
|
|
|
|
|
|
|
|
const { ENEMIES } = await import("../data/enemies.js"); |
|
|
const enemyData = ENEMIES[e.id]; |
|
|
const backgroundImage = enemyData?.background || null; |
|
|
|
|
|
|
|
|
const intentInfo = { |
|
|
attack: { emoji: '', text: `Will attack for ${e.intent.value} damage`, color: 'danger' }, |
|
|
block: { emoji: '', text: `Will gain ${e.intent.value} block`, color: 'info' }, |
|
|
debuff: { emoji: '', text: 'Will apply a debuff', color: 'warning' } |
|
|
}[e.intent.type] || { emoji: '', text: 'Unknown intent', color: 'neutral' }; |
|
|
|
|
|
app.innerHTML = ` |
|
|
<div class="battle-scene"> |
|
|
|
|
|
<div class="battle-background"> |
|
|
<div class="bg-particles"></div> |
|
|
<div class="bg-glow"></div> |
|
|
</div> |
|
|
|
|
|
<div class="battle-arena" ${backgroundImage ? `style="background-image: url('${backgroundImage}'); background-size: cover; background-position: center; background-repeat: no-repeat;"` : ''}> |
|
|
|
|
|
<div class="enemy-battle-zone"> |
|
|
<div class="enemy-container"> |
|
|
|
|
|
<div class="enemy-character"> |
|
|
<div class="enemy-sprite"> |
|
|
<div class="enemy-avatar">${getEnemyArt(e.id, ENEMIES)}</div> |
|
|
<div class="enemy-shadow"></div> |
|
|
${e.block > 0 ? `<div class="shield-effect"><img src="assets/card-art/shield.png" alt="Shield" class="shield-effect-img"></div>` : ''} |
|
|
${e.weak > 0 ? `<div class="debuff-effect"><img src="assets/card-art/heart_damaged.png" alt="Weak" class="debuff-effect-img"></div>` : ''} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="enemy-ui-panel"> |
|
|
<div class="enemy-nameplate"> |
|
|
<h2 class="enemy-title">${e.name}</h2> |
|
|
<div class="enemy-level">${getEnemyType(e.id)}</div> |
|
|
</div> |
|
|
<div class="enemy-health-section"> |
|
|
<div class="health-bar-container"> |
|
|
<div class="health-bar enemy-health"> |
|
|
<div class="health-fill" style="width: ${(e.hp / e.maxHp) * 100}%"></div> |
|
|
<div class="health-text">${e.hp} / ${e.maxHp}</div> |
|
|
<div class="health-glow"></div> |
|
|
</div> |
|
|
</div> |
|
|
${e.block > 0 ? ` |
|
|
<div class="status-effect block-status"> |
|
|
<img src="assets/card-art/shield.png" alt="Block" class="status-icon-img"> |
|
|
<span class="status-value">${e.block}</span> |
|
|
<span class="status-label">Block</span> |
|
|
</div> |
|
|
` : ''} |
|
|
</div> |
|
|
|
|
|
<div class="intent-panel intent-${intentInfo.color}"> |
|
|
<div class="intent-header"> |
|
|
<span class="intent-label">Next Action</span> |
|
|
</div> |
|
|
<div class="intent-content"> |
|
|
<div class="intent-icon-large">${intentInfo.emoji}</div> |
|
|
<div class="intent-description">${intentInfo.text}</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="player-battle-zone"> |
|
|
<div class="player-container"> |
|
|
|
|
|
<div class="player-character"> |
|
|
<div class="player-sprite"> |
|
|
<div class="player-avatar"> |
|
|
<img src="assets/prime.webp" alt="Prime" class="player-avatar-img" /> |
|
|
</div> |
|
|
<div class="player-shadow"></div> |
|
|
${p.block > 0 ? `<div class="shield-effect"><img src="assets/card-art/shield.png" alt="Shield" class="shield-effect-img"></div>` : ''} |
|
|
${p.weak > 0 ? `<div class="debuff-effect"><img src="assets/card-art/heart_damaged.png" alt="Weak" class="debuff-effect-img"></div>` : ''} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="player-ui-panel"> |
|
|
<div class="player-nameplate"> |
|
|
<h2 class="player-title">ThePrimeagen</h2> |
|
|
<div class="player-level">PLAYER</div> |
|
|
</div> |
|
|
|
|
|
<div class="player-health-section"> |
|
|
<div class="health-bar-container"> |
|
|
<div class="health-bar player-health"> |
|
|
<div class="health-fill" style="width: ${(p.hp / p.maxHp) * 100}%"></div> |
|
|
<div class="health-text">${p.hp} / ${p.maxHp}</div> |
|
|
<div class="health-glow"></div> |
|
|
</div> |
|
|
</div> |
|
|
${p.block > 0 ? ` |
|
|
<div class="status-effect block-status"> |
|
|
<img src="assets/card-art/shield.png" alt="Block" class="status-icon-img"> |
|
|
<span class="status-value">${p.block}</span> |
|
|
<span class="status-label">Block</span> |
|
|
</div> |
|
|
` : ''} |
|
|
${p.weak > 0 ? ` |
|
|
<div class="status-effect weak-status"> |
|
|
<img src="assets/card-art/heart_damaged.png" alt="Weak" class="status-icon-img"> |
|
|
<span class="status-value">${p.weak}</span> |
|
|
<span class="status-label">Weak</span> |
|
|
</div> |
|
|
` : ''} |
|
|
</div> |
|
|
|
|
|
<div class="player-energy-section"> |
|
|
<div class="energy-display"> |
|
|
<span class="energy-label">⚡</span> |
|
|
<div class="energy-orbs"> |
|
|
${Array.from({ length: p.maxEnergy }, (_, i) => |
|
|
`<div class="energy-orb ${i < p.energy ? 'active' : 'inactive'}"></div>` |
|
|
).join('')} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="battle-action-zone"> |
|
|
<div class="hand-area"> |
|
|
<div class="hand-header"> |
|
|
<div class="deck-counters"> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="cards-battlefield"> |
|
|
${p.hand.length === 0 ? |
|
|
'<div class="no-cards-message">🎴 No cards in hand - End turn to draw new cards</div>' : |
|
|
p.hand.map((card, i) => { |
|
|
const canPlay = p.energy >= card.cost; |
|
|
const cardType = card.type === 'attack' ? 'attack' : card.type === 'skill' ? 'skill' : 'power'; |
|
|
return ` |
|
|
<div class="battle-card ${cardType} ${!canPlay ? 'unplayable' : 'playable'}" data-play="${i}"> |
|
|
<div class="card-glow"></div> |
|
|
<div class="card-frame"> |
|
|
<div class="card-header-row"> |
|
|
<div class="card-title">${card.name}</div> |
|
|
<div class="card-cost-orb ${!canPlay ? 'insufficient' : ''}">${card.cost}</div> |
|
|
</div> |
|
|
|
|
|
<div class="card-artwork"> |
|
|
<div class="card-art-icon">${getCardArt(card.id)}</div> |
|
|
<div class="card-type-badge ${cardType}">${card.type}</div> |
|
|
</div> |
|
|
|
|
|
<div class="card-description-box"> |
|
|
<div class="card-text">${card.text}</div> |
|
|
</div> |
|
|
</div> |
|
|
${!canPlay ? `<div class="card-disabled-overlay"><span>Need ${card.cost} energy</span></div>` : ''} |
|
|
</div> |
|
|
`; |
|
|
}).join('') |
|
|
} |
|
|
</div> |
|
|
|
|
|
<div class="hand-controls"> |
|
|
<button class="end-turn-btn" data-action="end"> |
|
|
<span class="end-turn-text">End Turn</span> |
|
|
<span class="end-turn-hotkey">E</span> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="fight-log-panel"> |
|
|
<div class="fight-log-header"> |
|
|
<span class="fight-log-title">Combat Log</span> |
|
|
</div> |
|
|
<div class="fight-log-content" id="fight-log-content"> |
|
|
${root.logs.slice(-20).map(log => `<div class="log-entry">${log}</div>`).join('')} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
|
|
|
app.querySelectorAll("[data-play]").forEach(btn => { |
|
|
btn.addEventListener("mouseenter", () => { |
|
|
if (btn.classList.contains('playable')) { |
|
|
playSound('swipe.mp3'); |
|
|
root.selectedCardIndex = null; |
|
|
updateCardSelection(root); |
|
|
} |
|
|
}); |
|
|
|
|
|
btn.addEventListener("click", () => { |
|
|
const index = parseInt(btn.dataset.play, 10); |
|
|
const card = p.hand[index]; |
|
|
if (p.energy >= card.cost) { |
|
|
root.play(index); |
|
|
// Clear selection when card is played via mouse |
|
|
root.selectedCardIndex = null; |
|
|
updateCardSelection(root); |
|
|
} |
|
|
}); |
|
|
}); |
|
|
|
|
|
const endTurnBtn = app.querySelector("[data-action='end']"); |
|
|
if (endTurnBtn) { |
|
|
|
|
|
endTurnBtn.addEventListener("click", () => { |
|
|
|
|
|
try { |
|
|
root.end(); |
|
|
} catch (error) { |
|
|
console.error("Error ending turn:", error); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
// Initialize card selection state if not exists |
|
|
if (!root.selectedCardIndex) { |
|
|
root.selectedCardIndex = null; |
|
|
} |
|
|
|
|
|
window.onkeydown = (e) => { |
|
|
if (e.key.toLowerCase() === "e") { |
|
|
try { |
|
|
root.end(); |
|
|
} catch (error) { |
|
|
console.error("Error ending turn via keyboard:", error); |
|
|
} |
|
|
} |
|
|
|
|
|
const n = parseInt(e.key, 10); |
|
|
if (n >= 1 && n <= p.hand.length) { |
|
|
const cardIndex = n - 1; |
|
|
const card = p.hand[cardIndex]; |
|
|
|
|
|
if (root.selectedCardIndex === cardIndex) { |
|
|
// Second press of same key - play the card |
|
|
if (p.energy >= card.cost) { |
|
|
root.play(cardIndex); |
|
|
root.selectedCardIndex = null; // Clear selection |
|
|
updateCardSelection(root); |
|
|
} |
|
|
} else { |
|
|
// First press or different key - select the card |
|
|
root.selectedCardIndex = cardIndex; |
|
|
updateCardSelection(root); |
|
|
playSound('swipe.mp3'); // Play swipe sound on keyboard selection |
|
|
} |
|
|
} |
|
|
}; |
|
|
|
|
|
// Auto-scroll fight log to bottom |
|
|
const logContent = document.getElementById('fight-log-content'); |
|
|
if (logContent) { |
|
|
logContent.scrollTop = logContent.scrollHeight; |
|
|
} |
|
|
|
|
|
// Apply initial card selection visual state |
|
|
updateCardSelection(root); |
|
|
} |
|
|
|
|
|
export async function renderMap(root) { |
|
|
const { CARDS } = await import("../data/cards.js"); |
|
|
const { ENEMIES } = await import("../data/enemies.js"); |
|
|
const m = root.map; |
|
|
const currentId = root.nodeId; |
|
|
|
|
|
const currentNode = m.nodes.find(n => n.id === currentId); |
|
|
const nextIds = currentNode ? currentNode.next : []; |
|
|
|
|
|
const getNodeEmoji = (kind) => { |
|
|
const emojis = { |
|
|
start: '<img src="assets/card-art/staff.png" alt="Start" class="node-icon-img">', |
|
|
battle: '<img src="assets/card-art/crossed_swords.png" alt="Battle" class="node-icon-img">', |
|
|
elite: '<img src="assets/card-art/crown.png" alt="Battle" class="node-icon-img">', |
|
|
boss: '<img src="assets/card-art/skull.png" alt="Boss" class="node-icon-img">', |
|
|
rest: '<img src="assets/card-art/potion_heal.png" alt="Rest" class="node-icon-img">', |
|
|
shop: '<img src="assets/card-art/diamond.png" alt="Shop" class="node-icon-img">', |
|
|
event: '<img src="assets/card-art/crystal_cluster.png" alt="Event" class="node-icon-img">' |
|
|
}; |
|
|
return emojis[kind] || '❓'; |
|
|
}; |
|
|
|
|
|
const getNodeDescription = (node) => { |
|
|
switch (node.kind) { |
|
|
case 'start': |
|
|
return '<strong>Starting Point</strong>\nBegin your journey up ThePrimeagen Spire'; |
|
|
case 'battle': |
|
|
const enemy = ENEMIES[node.enemy]; |
|
|
return `<strong>Battle</strong>\nFight: ${enemy?.name || 'Unknown Enemy'}\nHP: ${enemy?.maxHp || '?'}`; |
|
|
case 'elite': |
|
|
const elite = ENEMIES[node.enemy]; |
|
|
return `<strong>Elite Battle</strong>\nFight: ${elite?.name || 'Unknown Elite'}\nHP: ${elite?.maxHp || '?'}\nTough enemy with better rewards`; |
|
|
case 'boss': |
|
|
const boss = ENEMIES[node.enemy]; |
|
|
return `<strong>Boss Battle</strong>\nFight: ${boss?.name || 'Unknown Boss'}\nHP: ${boss?.maxHp || '?'}\nFinal challenge of the act`; |
|
|
case 'rest': |
|
|
return '<strong>Rest Site</strong>\nHeal up to 30% max HP\nor upgrade a card'; |
|
|
case 'shop': |
|
|
return '<strong>Shop</strong>\nSpend your hard-earned gold'; |
|
|
case 'event': |
|
|
return '<strong>Random Event</strong>\nBirthday-themed encounter\nUnknown outcome\nPotential rewards or challenges'; |
|
|
default: |
|
|
return '<strong>Unknown</strong>\nMysterious node'; |
|
|
} |
|
|
}; |
|
|
|
|
|
const getNodeTooltipData = (node) => { |
|
|
const description = getNodeDescription(node); |
|
|
let avatarPath = null; |
|
|
|
|
|
if (['battle', 'elite', 'boss'].includes(node.kind) && node.enemy) { |
|
|
const enemy = ENEMIES[node.enemy]; |
|
|
if (enemy?.avatar) { |
|
|
avatarPath = enemy.avatar; |
|
|
} |
|
|
} |
|
|
|
|
|
return { description, avatarPath }; |
|
|
}; |
|
|
|
|
|
root.app.innerHTML = ` |
|
|
<div class="map-screen"> |
|
|
<div class="map-header-section"> |
|
|
<div class="game-logo"> |
|
|
<svg width="600" height="240" viewBox="0 0 600 240" xmlns="http://www.w3.org/2000/svg"> |
|
|
<defs> |
|
|
|
|
|
<linearGradient id="textGradient" x1="0%" y1="0%" x2="0%" y2="100%"> |
|
|
<stop offset="0%" style="stop-color:#ffd700;stop-opacity:1" /> |
|
|
<stop offset="100%" style="stop-color:#ff8c00;stop-opacity:1" /> |
|
|
</linearGradient> |
|
|
|
|
|
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%"> |
|
|
<feGaussianBlur stdDeviation="2" result="coloredBlur"/> |
|
|
<feMerge> |
|
|
<feMergeNode in="coloredBlur"/> |
|
|
<feMergeNode in="SourceGraphic"/> |
|
|
</feMerge> |
|
|
</filter> |
|
|
|
|
|
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%"> |
|
|
<feGaussianBlur in="SourceAlpha" stdDeviation="1"/> |
|
|
<feOffset dx="1" dy="1" result="offsetblur"/> |
|
|
<feComponentTransfer> |
|
|
<feFuncA type="linear" slope="0.3"/> |
|
|
</feComponentTransfer> |
|
|
<feMerge> |
|
|
<feMergeNode/> |
|
|
<feMergeNode in="SourceGraphic"/> |
|
|
</feMerge> |
|
|
</filter> |
|
|
</defs> |
|
|
|
|
|
<text x="300" y="80" text-anchor="middle" font-family="'Kreon', serif" font-size="55" font-weight="700" fill="url(#textGradient)" filter="url(#glow)"> |
|
|
ThePrimeagen |
|
|
</text> |
|
|
|
|
|
<text x="300" y="170" text-anchor="middle" font-family="'Kreon', serif" font-size="85" font-weight="700" fill="url(#textGradient)" filter="url(#shadow) url(#glow)"> |
|
|
Spire |
|
|
</text> |
|
|
</svg> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="player-status"> |
|
|
<div class="status-item"> |
|
|
<img src="assets/card-art/heart.png" alt="Health" class="status-icon-img"> |
|
|
<div class="hp-bar player-hp" style="width: 80px;"> |
|
|
<div class="hp-fill" style="width: ${(root.player.hp / root.player.maxHp) * 100}%"></div> |
|
|
<span class="hp-text">${root.player.hp}/${root.player.maxHp}</span> |
|
|
</div> |
|
|
</div> |
|
|
<div class="status-item"> |
|
|
<img src="assets/card-art/bag_of_gold.png" alt="Gold" class="status-icon-img"> |
|
|
<span class="status-value">${root.player.gold || 0}</span> |
|
|
</div> |
|
|
<div class="status-item"> |
|
|
<img src="assets/card-art/book.png" alt="Deck" class="status-icon-img"> |
|
|
<span class="status-value">${root.player.deck.length} cards</span> |
|
|
</div> |
|
|
${root.relicStates.length > 0 ? ` |
|
|
<div class="status-item relics-status"> |
|
|
<img src="assets/card-art/runestone.png" alt="Relics" class="status-icon-img"> |
|
|
<div class="relics-inline"> |
|
|
${root.relicStates.map(r => ` |
|
|
<div class="relic-inline" title="${getRelicText(r.id)}"> |
|
|
${getRelicEmoji(r.id)} |
|
|
</div> |
|
|
`).join('')} |
|
|
</div> |
|
|
</div> |
|
|
` : ''} |
|
|
<button class="btn-reset-status" data-reset> |
|
|
Start New Run |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="main-content"> |
|
|
<div class="map-section"> |
|
|
|
|
|
<div class="welcome-panel"> |
|
|
<div class="birthday-message"> |
|
|
<h2>Happy Birthday Prime!</h2> |
|
|
<p>With coffee in hand and code on your side,<br> |
|
|
ThePrimeagen Spire’s a treacherous ride. <br> |
|
|
Gremlins await and errors conspire, <br> |
|
|
But cake lies ahead at the top of the Spire. </p> |
|
|
</div> |
|
|
|
|
|
<div class="map-instructions"> |
|
|
<h3>How to Navigate the Spire</h3> |
|
|
<ul> |
|
|
<li><strong>Click a node</strong> to climb the way</li> |
|
|
<li><strong>Choose your battles</strong> night or day</li> |
|
|
<li><strong>Rest at fires</strong>, heal or train</li> |
|
|
<li><strong>Each new card</strong> will grow your gain. </li> |
|
|
<li><strong>At the summit</strong> face the fight</li> |
|
|
<li><strong>Defeat the boss</strong>, win the night</li> |
|
|
</ul> |
|
|
|
|
|
<div class="birthday-wish"> |
|
|
<p><em>May your code be bug-free and your coffee stay hot, |
|
|
May this birthday bring joy in each moment you’ve got. </em></p> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="spire-map"> |
|
|
|
|
|
<div class="map-legend-overlay"> |
|
|
<div class="legend-title">Legend</div> |
|
|
<div class="legend-item"><img src="assets/card-art/potion_heal.png" alt="Rest" class="legend-icon-img"> Rest</div> |
|
|
<div class="legend-item"><img src="assets/card-art/crossed_swords.png" alt="Battle" class="legend-icon-img"> Enemy</div> |
|
|
<div class="legend-item"><img src="assets/card-art/crown.png" alt="Battle" class="legend-icon-img"> Elite</div> |
|
|
<div class="legend-item"><img src="assets/card-art/skull.png" alt="Battle" class="legend-icon-img"> Boss</div> |
|
|
<div class="legend-item"><img src="assets/card-art/crystal_cluster.png" alt="Event" class="legend-icon-img"> Events</div> |
|
|
<div class="legend-item"><img src="assets/card-art/diamond.png" alt="Shop" class="legend-icon-img"> Shop</div> |
|
|
</div> |
|
|
<svg class="spire-paths" viewBox="0 0 1000 800" preserveAspectRatio="xMidYMid meet"> |
|
|
|
|
|
${(() => { |
|
|
|
|
|
const nodePositions = { |
|
|
'n1': { x: 500, y: 720 }, // Start at bottom center - moved up slightly |
|
|
'n2': { x: 350, y: 650 }, // Battle - left branch - moved right and up |
|
|
'n3': { x: 650, y: 650 }, // Event - right branch - moved left and up |
|
|
'n4': { x: 500, y: 540 }, // Battle - converge - moved up slightly |
|
|
'n5': { x: 350, y: 400 }, // Rest - left |
|
|
'n6': { x: 650, y: 400 }, // Shop - right |
|
|
'n7': { x: 500, y: 300 }, // Battle - converge |
|
|
'n8': { x: 500, y: 130 }, // Elite |
|
|
'n9': { x: 500, y: 70 }, // Rest |
|
|
'n10': { x: 500, y: 20 } // Boss at top |
|
|
}; |
|
|
|
|
|
return m.nodes.map(node => { |
|
|
if (!node.next || node.next.length === 0) return ''; |
|
|
|
|
|
return node.next.map(nextId => { |
|
|
const fromPos = nodePositions[node.id]; |
|
|
const toPos = nodePositions[nextId]; |
|
|
if (!fromPos || !toPos) return ''; |
|
|
|
|
|
const isActivePath = (node.id === currentId && nextIds.includes(nextId)) || |
|
|
(parseInt(nextId.replace('n', '')) <= parseInt(currentId.replace('n', ''))); |
|
|
|
|
|
return `<line x1="${fromPos.x}" y1="${fromPos.y}" x2="${toPos.x}" y2="${toPos.y}" |
|
|
class="spire-path ${isActivePath ? 'active' : ''}" |
|
|
stroke="${isActivePath ? '#8B7355' : '#4A3A2A'}" |
|
|
stroke-width="2" |
|
|
stroke-dasharray="8,4" |
|
|
opacity="${isActivePath ? '1' : '0.6'}"/>`; |
|
|
}).join(''); |
|
|
}).join(''); |
|
|
})()} |
|
|
</svg> |
|
|
|
|
|
<div class="spire-nodes"> |
|
|
${(() => { |
|
|
|
|
|
const nodePositions = { |
|
|
'n1': { x: 500, y: 720 }, |
|
|
'n2': { x: 360, y: 650 }, |
|
|
'n3': { x: 630, y: 650 }, |
|
|
'n4': { x: 500, y: 540 }, |
|
|
'n5': { x: 360, y: 400 }, |
|
|
'n6': { x: 630, y: 400 }, |
|
|
'n7': { x: 500, y: 300 }, |
|
|
'n8': { x: 500, y: 210 }, |
|
|
'n9': { x: 500, y: 120 }, |
|
|
'n10': { x: 500, y: 40 } |
|
|
}; |
|
|
|
|
|
return m.nodes.map(n => { |
|
|
const isNext = nextIds.includes(n.id); |
|
|
const isCurrent = n.id === currentId; |
|
|
const isCompleted = root.completedNodes.includes(n.id); |
|
|
const locked = (!isNext && !isCurrent && !isCompleted); |
|
|
|
|
|
const pos = nodePositions[n.id]; |
|
|
if (!pos) return ''; |
|
|
|
|
|
const leftPercent = (pos.x / 1000) * 100; |
|
|
const topPercent = (pos.y / 800) * 100; |
|
|
const tooltipData = getNodeTooltipData(n); |
|
|
|
|
|
return ` |
|
|
<div class="spire-node ${isCurrent ? 'current' : ''} ${isNext ? 'available' : ''} ${isCompleted ? 'completed' : ''} ${locked ? 'locked' : ''}" |
|
|
style="left: ${leftPercent}%; top: ${topPercent}%; transform: translate(-50%, -50%);" |
|
|
data-node="${isNext ? n.id : ''}" |
|
|
data-tooltip="${tooltipData.description.replace(/\n/g, '<br>')}" |
|
|
data-avatar="${tooltipData.avatarPath || ''}" |
|
|
onmouseenter="showTooltip(event)" |
|
|
onmouseleave="hideTooltip()"> |
|
|
<div class="node-background ${n.kind}"></div> |
|
|
<div class="node-content"> |
|
|
<div class="node-icon">${getNodeEmoji(n.kind)}</div> |
|
|
</div> |
|
|
${isCurrent ? '<div class="current-indicator">★</div>' : ''} |
|
|
</div> |
|
|
`; |
|
|
}).join(''); |
|
|
})()} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="deck-stack-container"> |
|
|
<div class="deck-stack-header"> |
|
|
<span class="deck-count">Your deck</span> |
|
|
</div> |
|
|
<div class="deck-stack" data-tooltip="Hover to view deck"> |
|
|
${Object.entries( |
|
|
root.player.deck.reduce((acc, cardId) => { |
|
|
acc[cardId] = (acc[cardId] || 0) + 1; |
|
|
return acc; |
|
|
}, {}) |
|
|
).map(([cardId, count], index) => { |
|
|
const card = CARDS[cardId]; |
|
|
if (!card) return ''; |
|
|
|
|
|
const cardType = card.type === 'attack' ? 'attack' : card.type === 'skill' ? 'skill' : 'power'; |
|
|
|
|
|
return ` |
|
|
<div class="deck-stack-card ${cardType}" style="--card-index: ${index}"> |
|
|
<div class="card-frame"> |
|
|
<div class="card-header-row"> |
|
|
<div class="card-title">${card.name}</div> |
|
|
<div class="card-cost-orb">${card.cost}</div> |
|
|
</div> |
|
|
<div class="card-art">${getCardArt(cardId)}</div> |
|
|
<div class="card-description-box"> |
|
|
<div class="card-text">${card.text}</div> |
|
|
</div> |
|
|
${count > 1 ? `<div class="card-count-badge">×${count}</div>` : ''} |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
}).join('')} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div id="custom-tooltip" class="custom-tooltip"></div> |
|
|
|
|
|
</div> |
|
|
`; |
|
|
|
|
|
root.app.querySelectorAll("[data-node]").forEach(el => { |
|
|
if (!el.dataset.node) return; |
|
|
el.addEventListener("click", () => root.go(el.dataset.node)); |
|
|
}); |
|
|
|
|
|
window.showTooltip = function (event) { |
|
|
const tooltip = document.getElementById('custom-tooltip'); |
|
|
const node = event.target.closest('.spire-node'); |
|
|
const content = node.dataset.tooltip; |
|
|
const avatarPath = node.dataset.avatar; |
|
|
|
|
|
let tooltipHTML = ''; |
|
|
if (avatarPath) { |
|
|
tooltipHTML = ` |
|
|
<div class="tooltip-with-avatar"> |
|
|
<div class="tooltip-avatar"> |
|
|
<img src="${avatarPath}" alt="Enemy Avatar" class="tooltip-avatar-img" |
|
|
onerror="this.style.display='none';"> |
|
|
</div> |
|
|
<div class="tooltip-content">${content}</div> |
|
|
</div> |
|
|
`; |
|
|
} else { |
|
|
tooltipHTML = content; |
|
|
} |
|
|
|
|
|
tooltip.innerHTML = tooltipHTML; |
|
|
tooltip.style.display = 'block'; |
|
|
|
|
|
|
|
|
const rect = node.getBoundingClientRect(); |
|
|
tooltip.style.left = (rect.right + 15) + 'px'; |
|
|
tooltip.style.top = (rect.top + rect.height / 2 - tooltip.offsetHeight / 2) + 'px'; |
|
|
|
|
|
|
|
|
const tooltipRect = tooltip.getBoundingClientRect(); |
|
|
if (tooltipRect.right > window.innerWidth) { |
|
|
tooltip.style.left = (rect.left - tooltip.offsetWidth - 15) + 'px'; |
|
|
} |
|
|
if (tooltipRect.top < 0) { |
|
|
tooltip.style.top = '10px'; |
|
|
} |
|
|
if (tooltipRect.bottom > window.innerHeight) { |
|
|
tooltip.style.top = (window.innerHeight - tooltip.offsetHeight - 10) + 'px'; |
|
|
} |
|
|
}; |
|
|
|
|
|
window.hideTooltip = function () { |
|
|
const tooltip = document.getElementById('custom-tooltip'); |
|
|
tooltip.style.display = 'none'; |
|
|
}; |
|
|
|
|
|
|
|
|
const resetBtn = root.app.querySelector("[data-reset]"); |
|
|
resetBtn.addEventListener("click", () => { |
|
|
root.clearSave(); |
|
|
root.reset(); |
|
|
}); |
|
|
} |
|
|
|
|
|
export function renderReward(root, choices) { |
|
|
root.app.innerHTML = ` |
|
|
<div class="reward-screen"> |
|
|
<h1>Choose a Card</h1> |
|
|
<div class="reward-cards-container"> |
|
|
${choices.map((c, idx) => { |
|
|
const cardType = c.type === 'attack' ? 'attack' : c.type === 'skill' ? 'skill' : 'power'; |
|
|
return ` |
|
|
<div class="reward-card-wrapper" data-pick="${idx}"> |
|
|
<div class="battle-card ${cardType} reward-card"> |
|
|
<div class="card-glow"></div> |
|
|
<div class="card-frame"> |
|
|
<div class="card-header-row"> |
|
|
<div class="card-title">${c.name}</div> |
|
|
<div class="card-cost-orb">${c.cost}</div> |
|
|
</div> |
|
|
|
|
|
<div class="card-artwork"> |
|
|
<div class="card-art-icon">${getCardArt(c.id)}</div> |
|
|
<div class="card-type-badge ${cardType}">${c.type}</div> |
|
|
</div> |
|
|
|
|
|
<div class="card-description-box"> |
|
|
<div class="card-text">${c.text}</div> |
|
|
</div> |
|
|
|
|
|
<div class="card-select-hint">Click to select</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
}).join("")} |
|
|
</div> |
|
|
<div class="reward-actions"> |
|
|
<button class="btn secondary skip-btn" data-skip>Skip Reward</button> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
root.app.querySelectorAll("[data-pick]").forEach(btn => { |
|
|
btn.addEventListener("click", () => { |
|
|
const idx = parseInt(btn.dataset.pick, 10); |
|
|
root.takeReward(idx); |
|
|
}); |
|
|
}); |
|
|
root.app.querySelector("[data-skip]").addEventListener("click", () => root.skipReward()); |
|
|
} |
|
|
|
|
|
export function renderRest(root) { |
|
|
root.app.innerHTML = ` |
|
|
<div class="rest-screen"> |
|
|
<div class="rest-header"> |
|
|
<h1>Rest and Recover</h1> |
|
|
<p>Take a moment to restore your strength</p> |
|
|
</div> |
|
|
|
|
|
<div class="rest-options"> |
|
|
<button class="rest-option" data-act="heal"> |
|
|
<div class="rest-icon"> |
|
|
<img src="assets/card-art/heart.png" alt="Heal" class="rest-icon-img"> |
|
|
</div> |
|
|
<div class="rest-content"> |
|
|
<h3>Rest and Heal</h3> |
|
|
<p>Restore 20% of your maximum health</p> |
|
|
</div> |
|
|
</button> |
|
|
|
|
|
<button class="rest-option" data-act="upgrade"> |
|
|
<div class="rest-icon"> |
|
|
<img src="assets/card-art/scroll.png" alt="Upgrade" class="rest-icon-img"> |
|
|
</div> |
|
|
<div class="rest-content"> |
|
|
<h3>Upgrade a Card</h3> |
|
|
<p>Permanently improve one of your cards</p> |
|
|
</div> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
root.app.querySelector("[data-act='heal']").addEventListener("click", () => { |
|
|
const heal = Math.floor(root.player.maxHp * 0.2); |
|
|
root.player.hp = Math.min(root.player.maxHp, root.player.hp + heal); |
|
|
root.log(`Rested: +${heal} HP`); |
|
|
root.afterNode(); |
|
|
}); |
|
|
root.app.querySelector("[data-act='upgrade']").addEventListener("click", () => { |
|
|
renderUpgrade(root); |
|
|
}); |
|
|
} |
|
|
|
|
|
export function renderUpgrade(root) { |
|
|
import("../data/cards.js").then(({ CARDS }) => { |
|
|
const upgradableCards = root.player.deck |
|
|
.map((cardId, index) => ({ cardId, index })) |
|
|
.filter(({ cardId }) => { |
|
|
const card = CARDS[cardId]; |
|
|
|
|
|
return card?.upgrades && !cardId.endsWith('+'); |
|
|
}) |
|
|
.slice(0, 3); // Show max 3 options |
|
|
|
|
|
if (upgradableCards.length === 0) { |
|
|
root.log("No cards can be upgraded."); |
|
|
root.afterNode(); |
|
|
return; |
|
|
} |
|
|
|
|
|
root.app.innerHTML = ` |
|
|
<div class="upgrade-screen"> |
|
|
<div class="upgrade-header"> |
|
|
<h1>⬆️ Upgrade a Card</h1> |
|
|
<p>Select a card from your deck to permanently improve it</p> |
|
|
</div> |
|
|
|
|
|
<div class="upgrade-options"> |
|
|
${upgradableCards.map(({ cardId, index }) => { |
|
|
const card = CARDS[cardId]; |
|
|
const upgradedCard = CARDS[card.upgrades]; |
|
|
|
|
|
if (!upgradedCard) { |
|
|
return ''; // Skip if no upgrade found |
|
|
} |
|
|
|
|
|
return ` |
|
|
<div class="upgrade-option" data-upgrade="${index}"> |
|
|
<div class="upgrade-preview"> |
|
|
<div class="upgrade-action-header"> |
|
|
<h3>🔧 Upgrade ${card.name}</h3> |
|
|
<p>Click to permanently improve this card</p> |
|
|
</div> |
|
|
|
|
|
<div class="upgrade-comparison"> |
|
|
<div class="upgrade-card-container"> |
|
|
<div class="upgrade-card-label">Current</div> |
|
|
<div class="battle-card ${card.type} playable upgrade-card-before"> |
|
|
<div class="card-glow"></div> |
|
|
<div class="card-frame"> |
|
|
<div class="card-header-row"> |
|
|
<div class="card-title">${card.name}</div> |
|
|
<div class="card-cost-orb">${card.cost}</div> |
|
|
</div> |
|
|
|
|
|
<div class="card-artwork"> |
|
|
<div class="card-art-icon">${getCardArt(card.id)}</div> |
|
|
<div class="card-type-badge ${card.type}">${card.type}</div> |
|
|
</div> |
|
|
|
|
|
<div class="card-description-box"> |
|
|
<div class="card-text">${card.text}</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="upgrade-card-container"> |
|
|
<div class="upgrade-card-label">Upgraded</div> |
|
|
<div class="battle-card ${upgradedCard.type} playable upgrade-card-after"> |
|
|
<div class="card-glow"></div> |
|
|
<div class="card-frame"> |
|
|
<div class="card-header-row"> |
|
|
<div class="card-title">${upgradedCard.name}</div> |
|
|
<div class="card-cost-orb">${upgradedCard.cost}</div> |
|
|
</div> |
|
|
|
|
|
<div class="card-artwork"> |
|
|
<div class="card-art-icon">${getCardArt(upgradedCard.id)}</div> |
|
|
<div class="card-type-badge ${upgradedCard.type}">${upgradedCard.type}</div> |
|
|
</div> |
|
|
|
|
|
<div class="card-description-box"> |
|
|
<div class="card-text">${upgradedCard.text}</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
}).join("")} |
|
|
</div> |
|
|
|
|
|
<div class="upgrade-actions"> |
|
|
<button class="upgrade-skip" data-skip>Skip Upgrade</button> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
|
|
|
root.app.querySelectorAll("[data-upgrade]").forEach(btn => { |
|
|
btn.addEventListener("click", () => { |
|
|
const deckIndex = parseInt(btn.dataset.upgrade, 10); |
|
|
const oldCardId = root.player.deck[deckIndex]; |
|
|
const newCardId = CARDS[oldCardId].upgrades; |
|
|
root.player.deck[deckIndex] = newCardId; |
|
|
root.log(`Upgraded ${CARDS[oldCardId].name} → ${CARDS[newCardId].name}`); |
|
|
root.afterNode(); |
|
|
}); |
|
|
}); |
|
|
root.app.querySelector("[data-skip]").addEventListener("click", () => root.afterNode()); |
|
|
}); |
|
|
} |
|
|
|
|
|
export function renderShop(root) { |
|
|
import("../data/cards.js").then(({ CARDS, CARD_POOL }) => { |
|
|
import("../data/relics.js").then(({ RELICS, START_RELIC_CHOICES }) => { |
|
|
|
|
|
const availableCards = CARD_POOL.filter(cardId => { |
|
|
|
|
|
const ownedCount = root.player.deck.filter(deckCardId => deckCardId === cardId).length; |
|
|
|
|
|
return ownedCount < 3; |
|
|
}); |
|
|
|
|
|
const cardsToShow = availableCards.length >= 3 ? availableCards : CARD_POOL; |
|
|
const shopCards = shuffle(cardsToShow.slice()).slice(0, 3).map(id => CARDS[id]); |
|
|
const ownedRelicIds = root.relicStates.map(r => r.id); |
|
|
const availableRelics = START_RELIC_CHOICES.filter(id => !ownedRelicIds.includes(id)); |
|
|
const shopRelic = availableRelics.length > 0 ? RELICS[availableRelics[0]] : null; |
|
|
|
|
|
root.app.innerHTML = ` |
|
|
<div class="shop-screen"> |
|
|
<div class="shop-header"> |
|
|
<h1>Merchant's Shop</h1> |
|
|
<p>Spend your hard-earned gold on powerful upgrades</p> |
|
|
<div class="player-gold"> |
|
|
<img src="assets/card-art/bag_of_gold.png" alt="Gold" class="gold-icon"> |
|
|
<span class="gold-amount">${root.player.gold || 100}</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="shop-inventory"> |
|
|
<div class="shop-section"> |
|
|
<div class="shop-section-header"> |
|
|
<h2>Cards for Sale</h2> |
|
|
<p>50 gold each</p> |
|
|
</div> |
|
|
<div class="shop-cards"> |
|
|
${shopCards.map((card, idx) => { |
|
|
const cardType = card.type === 'attack' ? 'attack' : card.type === 'skill' ? 'skill' : 'power'; |
|
|
const canAfford = (root.player.gold || 100) >= 50; |
|
|
const ownedCount = root.player.deck.filter(deckCardId => deckCardId === card.id).length; |
|
|
return ` |
|
|
<div class="shop-card-container"> |
|
|
<div class="battle-card ${cardType} ${canAfford ? 'playable' : 'unplayable'} shop-card" data-buy-card="${idx}"> |
|
|
<div class="card-glow"></div> |
|
|
<div class="card-frame"> |
|
|
<div class="card-header-row"> |
|
|
<div class="card-title">${card.name}</div> |
|
|
<div class="card-cost-orb">${card.cost}</div> |
|
|
</div> |
|
|
|
|
|
<div class="card-artwork"> |
|
|
<div class="card-art-icon">${getCardArt(card.id)}</div> |
|
|
<div class="card-type-badge ${cardType}">${card.type}</div> |
|
|
</div> |
|
|
|
|
|
<div class="card-description-box"> |
|
|
<div class="card-text">${card.text}</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="shop-card-price"> |
|
|
<img src="assets/card-art/bag_of_gold.png" alt="Gold" class="price-icon"> |
|
|
<span>50</span> |
|
|
</div> |
|
|
${ownedCount > 0 ? `<div class="card-owned-indicator">Owned: ${ownedCount}</div>` : ''} |
|
|
${!canAfford ? `<div class="card-disabled-overlay"><span>Need 50 gold</span></div>` : ''} |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
}).join("")} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
${shopRelic ? ` |
|
|
<div class="shop-section"> |
|
|
<div class="shop-section-header"> |
|
|
<h2>Mystical Relic</h2> |
|
|
<p>100 gold</p> |
|
|
</div> |
|
|
<div class="shop-relics"> |
|
|
<div class="shop-relic-container"> |
|
|
<div class="shop-relic ${(root.player.gold || 100) >= 100 ? 'affordable' : 'unaffordable'}" data-buy-relic> |
|
|
<div class="relic-icon">${getRelicEmoji(shopRelic.id)}</div> |
|
|
<div class="relic-info"> |
|
|
<h3>${shopRelic.name}</h3> |
|
|
<p>${shopRelic.text}</p> |
|
|
</div> |
|
|
<div class="shop-relic-price"> |
|
|
<img src="assets/card-art/bag_of_gold.png" alt="Gold" class="price-icon"> |
|
|
<span>100</span> |
|
|
</div> |
|
|
${(root.player.gold || 100) < 100 ? `<div class="relic-disabled-overlay"><span>Need 100 gold</span></div>` : ''} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
` : ''} |
|
|
</div> |
|
|
|
|
|
<div class="shop-actions"> |
|
|
<button class="shop-leave-btn" data-leave> |
|
|
<img src="assets/card-art/exit.png" alt="Leave" class="leave-icon"> |
|
|
<span>Leave Shop</span> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
|
|
|
if (!root.player.gold) root.player.gold = 100; |
|
|
|
|
|
root.app.querySelectorAll("[data-buy-card]").forEach(btn => { |
|
|
btn.addEventListener("click", () => { |
|
|
const idx = parseInt(btn.dataset.buyCard, 10); |
|
|
const card = shopCards[idx]; |
|
|
if (root.player.gold >= 50) { |
|
|
root.player.gold -= 50; |
|
|
root.player.deck.push(card.id); |
|
|
root.log(`Bought ${card.name} for 50 gold.`); |
|
|
btn.disabled = true; |
|
|
btn.textContent = "SOLD"; |
|
|
|
|
|
// Update gold display |
|
|
const goldDisplay = root.app.querySelector('.gold-amount'); |
|
|
if (goldDisplay) { |
|
|
goldDisplay.textContent = root.player.gold; |
|
|
} |
|
|
|
|
|
// Update affordability of remaining items |
|
|
updateShopAffordability(root); |
|
|
} else { |
|
|
root.log("Not enough gold!"); |
|
|
} |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
if (shopRelic) { |
|
|
root.app.querySelector("[data-buy-relic]").addEventListener("click", () => { |
|
|
if (root.player.gold >= 100) { |
|
|
root.player.gold -= 100; |
|
|
root.log(`Bought ${shopRelic.name} for 100 gold.`); |
|
|
|
|
|
import("../engine/battle.js").then(({ attachRelics }) => { |
|
|
|
|
|
const currentRelicIds = root.relicStates.map(r => r.id); |
|
|
const newRelicIds = [...currentRelicIds, shopRelic.id]; |
|
|
attachRelics(root, newRelicIds); |
|
|
}); |
|
|
root.app.querySelector("[data-buy-relic]").disabled = true; |
|
|
root.app.querySelector("[data-buy-relic]").textContent = "SOLD"; |
|
|
|
|
|
// Update gold display |
|
|
const goldDisplay = root.app.querySelector('.gold-amount'); |
|
|
if (goldDisplay) { |
|
|
goldDisplay.textContent = root.player.gold; |
|
|
} |
|
|
|
|
|
// Update affordability of remaining items |
|
|
updateShopAffordability(root); |
|
|
} else { |
|
|
root.log("Not enough gold!"); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
root.app.querySelector("[data-leave]").addEventListener("click", () => root.afterNode()); |
|
|
}); |
|
|
}); |
|
|
} |
|
|
|
|
|
function updateCardSelection(root) { |
|
|
// Remove selection from all cards |
|
|
root.app.querySelectorAll('.battle-card').forEach(card => { |
|
|
card.classList.remove('card-selected'); |
|
|
}); |
|
|
|
|
|
// Add selection to currently selected card |
|
|
if (root.selectedCardIndex !== null) { |
|
|
const selectedCard = root.app.querySelector(`[data-play="${root.selectedCardIndex}"]`); |
|
|
if (selectedCard) { |
|
|
selectedCard.classList.add('card-selected'); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
function updateShopAffordability(root) { |
|
|
// Update card affordability |
|
|
root.app.querySelectorAll("[data-buy-card]").forEach(btn => { |
|
|
if (!btn.disabled) { |
|
|
const cardContainer = btn.closest('.shop-card-container'); |
|
|
const overlay = cardContainer.querySelector('.card-disabled-overlay'); |
|
|
|
|
|
if (root.player.gold < 50) { |
|
|
btn.classList.remove('playable'); |
|
|
btn.classList.add('unplayable'); |
|
|
if (!overlay) { |
|
|
const newOverlay = document.createElement('div'); |
|
|
newOverlay.className = 'card-disabled-overlay'; |
|
|
newOverlay.innerHTML = '<span>Need 50 gold</span>'; |
|
|
cardContainer.appendChild(newOverlay); |
|
|
} |
|
|
} else { |
|
|
btn.classList.remove('unplayable'); |
|
|
btn.classList.add('playable'); |
|
|
if (overlay) { |
|
|
overlay.remove(); |
|
|
} |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
// Update relic affordability |
|
|
const relicBtn = root.app.querySelector("[data-buy-relic]"); |
|
|
if (relicBtn && !relicBtn.disabled) { |
|
|
const relicContainer = relicBtn.closest('.shop-relic-container'); |
|
|
const overlay = relicContainer.querySelector('.relic-disabled-overlay'); |
|
|
|
|
|
if (root.player.gold < 100) { |
|
|
relicBtn.classList.remove('affordable'); |
|
|
relicBtn.classList.add('unaffordable'); |
|
|
if (!overlay) { |
|
|
const newOverlay = document.createElement('div'); |
|
|
newOverlay.className = 'relic-disabled-overlay'; |
|
|
newOverlay.innerHTML = '<span>Need 100 gold</span>'; |
|
|
relicContainer.appendChild(newOverlay); |
|
|
} |
|
|
} else { |
|
|
relicBtn.classList.remove('unaffordable'); |
|
|
relicBtn.classList.add('affordable'); |
|
|
if (overlay) { |
|
|
overlay.remove(); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
function shuffle(array) { |
|
|
for (let i = array.length - 1; i > 0; i--) { |
|
|
const j = Math.floor(Math.random() * (i + 1)); |
|
|
[array[i], array[j]] = [array[j], array[i]]; |
|
|
} |
|
|
return array; |
|
|
} |
|
|
|
|
|
function getRelicEmoji(relicId) { |
|
|
const relicArt = { |
|
|
mech_kb: '<img src="assets/skill-art/Monk_29.png" alt="Kinesis" class="relic-skill-art">', |
|
|
standing_desk: '<img src="assets/skill-art/Monk_30.png" alt="Motions" class="relic-skill-art">', |
|
|
prime_hat: '<img src="assets/skill-art/Monk_31.png" alt="VS Code" class="relic-skill-art">', |
|
|
coffee_thermos: '<img src="assets/skill-art/Monk_32.png" alt="Coffee Thermos" class="relic-skill-art">', |
|
|
cpp_compiler: '<img src="assets/skill-art/Monk_33.png" alt="CPP Compiler" class="relic-skill-art">', |
|
|
chat_mod_sword: '<img src="assets/skill-art/Monk_34.png" alt="Chat Mod Sword" class="relic-skill-art">' |
|
|
}; |
|
|
return relicArt[relicId] || '💎'; |
|
|
} |
|
|
|
|
|
function getRelicName(relicId) { |
|
|
const names = { |
|
|
mech_kb: 'Kinesis', |
|
|
standing_desk: 'Motions', |
|
|
prime_hat: 'VS Code', |
|
|
coffee_thermos: 'Coffee Thermos', |
|
|
cpp_compiler: 'C++ Compiler', |
|
|
chat_mod_sword: 'Chat Mod Sword' |
|
|
}; |
|
|
return names[relicId] || relicId; |
|
|
} |
|
|
|
|
|
function getRelicText(relicId) { |
|
|
const texts = { |
|
|
mech_kb: '+1 card draw each turn.', |
|
|
standing_desk: '+10 Max HP.', |
|
|
prime_hat: '-10% damage taken.', |
|
|
coffee_thermos: 'Start each fight with Coffee Rush.', |
|
|
cpp_compiler: 'First attack each turn deals double.', |
|
|
chat_mod_sword: 'Start fights with 1 Weak on all enemies.' |
|
|
}; |
|
|
return texts[relicId] || 'Unknown relic'; |
|
|
} |
|
|
|
|
|
function getCardArt(cardId) { |
|
|
|
|
|
const artMappings = { |
|
|
|
|
|
strike: 'Monk_1.png', |
|
|
'strike+': 'Monk_2.png', |
|
|
|
|
|
defend: 'Monk_3.png', |
|
|
'defend+': 'Monk_4.png', |
|
|
|
|
|
coffee_rush: 'Monk_5.png', // Energy boost |
|
|
'coffee_rush+': 'Monk_6.png', // Upgraded energy |
|
|
macro: 'Monk_7.png', // Replay magic |
|
|
refactor: 'Monk_8.png', // Refactoring tool |
|
|
type_safety: 'Monk_9.png', // Protection |
|
|
chat_ban: 'Monk_10.png', // Ban/restriction |
|
|
|
|
|
segfault: 'Monk_11.png', // Powerful attack |
|
|
null_pointer: 'Monk_12.png', // Precise strike |
|
|
recursion: 'Monk_13.png', // Repetition |
|
|
merge_conflict: 'Monk_14.png', // Dual attack |
|
|
hotfix: 'Monk_15.png', // Emergency fix |
|
|
production_deploy: 'Monk_16.png', // High risk/reward |
|
|
|
|
|
gc: 'Monk_17.png', // Cleanup |
|
|
async_await: 'Monk_18.png', // Time manipulation |
|
|
stack_overflow: 'Monk_19.png', // Knowledge overflow |
|
|
infinite_vim: 'Monk_20.png', // Infinite power |
|
|
debug_print: 'Monk_21.png', // Information |
|
|
git_commit: 'Monk_22.png', // Recording |
|
|
memory_leak: 'Monk_23.png', // Draining effect |
|
|
code_review: 'Monk_24.png', // Investigation |
|
|
pair_programming: 'Monk_25.png', // Cooperation |
|
|
rubber_duck: 'Monk_26.png', // Helpful companion |
|
|
unit_test: 'Monk_27.png', // Testing/verification |
|
|
|
|
|
sugar_crash: 'Monk_28.png' // Negative effect |
|
|
}; |
|
|
|
|
|
const imagePath = artMappings[cardId]; |
|
|
if (imagePath) { |
|
|
return `<img src="assets/skill-art/${imagePath}" alt="${cardId}" class="card-art-image" onerror="this.style.display='none'; this.nextElementSibling.style.display='inline';"> |
|
|
<span class="card-art-fallback" style="display: none;">${getCardArtFallback(cardId)}</span>`; |
|
|
} |
|
|
|
|
|
return getCardArtFallback(cardId); |
|
|
} |
|
|
|
|
|
function getCardArtFallback(cardId) { |
|
|
const fallbacks = { |
|
|
strike: '👊', defend: '🛡️', coffee_rush: '☕', macro: '🔄', |
|
|
refactor: '⚡', type_safety: '🔒', chat_ban: '🚫', segfault: '💥', |
|
|
gc: '🗑️', async_await: '⏳', stack_overflow: '📚', infinite_vim: '♾️', |
|
|
debug_print: '🐛', null_pointer: '❌', recursion: '🔁', git_commit: '📝', |
|
|
memory_leak: '🕳️', code_review: '👀', pair_programming: '👥', hotfix: '🚨', |
|
|
rubber_duck: '🦆', merge_conflict: '⚔️', unit_test: '✅', production_deploy: '🚀', |
|
|
sugar_crash: '🍰' |
|
|
}; |
|
|
return fallbacks[cardId] || '🃏'; |
|
|
} |
|
|
|
|
|
function getEnemyArt(enemyId, ENEMIES = null) { |
|
|
|
|
|
const enemyData = ENEMIES?.[enemyId]; |
|
|
const avatarPath = enemyData?.avatar || `assets/avatars/${enemyId}.png`; |
|
|
return `<img src="${avatarPath}" alt="${enemyId}" class="enemy-avatar-img" onerror="this.style.display='none'; this.nextElementSibling.style.display='inline';"> |
|
|
<span class="enemy-fallback-emoji" style="display: none;">${getEnemyFallbackEmoji(enemyId)}</span>`; |
|
|
} |
|
|
|
|
|
function getEnemyFallbackEmoji(enemyId) { |
|
|
const arts = { |
|
|
chat_gremlin: '👹', |
|
|
type_checker: '🤖', |
|
|
js_blob: '🟢', |
|
|
infinite_loop: '🌀', |
|
|
merge_conflict_enemy: '⚔️', |
|
|
bug_404: '❌', |
|
|
elite_ts_demon: '😈', |
|
|
elite_refactor: '🐉', |
|
|
boss_birthday_bug: '🎂👾' |
|
|
}; |
|
|
return arts[enemyId] || '👾'; |
|
|
} |
|
|
|
|
|
function getEnemyType(enemyId) { |
|
|
if (enemyId.includes('boss_')) return 'BOSS'; |
|
|
if (enemyId.includes('elite_')) return 'ELITE'; |
|
|
return 'ENEMY'; |
|
|
} |
|
|
|
|
|
export function renderRelicSelection(root) { |
|
|
import("../data/relics.js").then(({ RELICS, START_RELIC_CHOICES }) => { |
|
|
const relicChoices = START_RELIC_CHOICES.slice(0, 3); // Show first 3 relics |
|
|
|
|
|
root.app.innerHTML = ` |
|
|
<div class="game-screen relic-select"> |
|
|
<div class="game-header"> |
|
|
<div class="game-logo relic-title-logo"> |
|
|
<svg width="600" height="240" viewBox="0 0 600 240" xmlns="http://www.w3.org/2000/svg"> |
|
|
<defs> |
|
|
|
|
|
<linearGradient id="textGradient" x1="0%" y1="0%" x2="0%" y2="100%"> |
|
|
<stop offset="0%" style="stop-color:#ffd700;stop-opacity:1" /> |
|
|
<stop offset="100%" style="stop-color:#ff8c00;stop-opacity:1" /> |
|
|
</linearGradient> |
|
|
|
|
|
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%"> |
|
|
<feGaussianBlur stdDeviation="2" result="coloredBlur"/> |
|
|
<feMerge> |
|
|
<feMergeNode in="coloredBlur"/> |
|
|
<feMergeNode in="SourceGraphic"/> |
|
|
</feMerge> |
|
|
</filter> |
|
|
|
|
|
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%"> |
|
|
<feGaussianBlur in="SourceAlpha" stdDeviation="1"/> |
|
|
<feOffset dx="1" dy="1" result="offsetblur"/> |
|
|
<feComponentTransfer> |
|
|
<feFuncA type="linear" slope="0.3"/> |
|
|
</feComponentTransfer> |
|
|
<feMerge> |
|
|
<feMergeNode/> |
|
|
<feMergeNode in="SourceGraphic"/> |
|
|
</feMerge> |
|
|
</filter> |
|
|
</defs> |
|
|
|
|
|
<text x="300" y="80" text-anchor="middle" font-family="'Kreon', serif" font-size="55" font-weight="700" fill="url(#textGradient)" filter="url(#glow)"> |
|
|
ThePrimeagen |
|
|
</text> |
|
|
|
|
|
<text x="300" y="170" text-anchor="middle" font-family="'Kreon', serif" font-size="85" font-weight="700" fill="url(#textGradient)" filter="url(#shadow) url(#glow)"> |
|
|
Spire |
|
|
</text> |
|
|
</svg> |
|
|
</div> |
|
|
<h1>Choose a Starting Relic</h1> |
|
|
<p>Select one of the following relics to begin your run.</p> |
|
|
</div> |
|
|
|
|
|
<div class="relic-options"> |
|
|
${relicChoices.map(relicId => { |
|
|
const relic = RELICS[relicId]; |
|
|
return ` |
|
|
<div class="relic-option" data-relic="${relicId}"> |
|
|
<div class="relic-portrait"> |
|
|
<div class="relic-icon">${getRelicEmoji(relicId)}</div> |
|
|
</div> |
|
|
<div class="relic-info"> |
|
|
<div class="relic-name">${relic.name}</div> |
|
|
<div class="relic-description">${relic.text}</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
}).join("")} |
|
|
</div> |
|
|
|
|
|
</div> |
|
|
`; |
|
|
|
|
|
root.app.querySelectorAll("[data-relic]").forEach(btn => { |
|
|
btn.addEventListener("click", () => { |
|
|
const relicId = btn.dataset.relic; |
|
|
root.selectStartingRelic(relicId); |
|
|
}); |
|
|
}); |
|
|
}); |
|
|
} |
|
|
|
|
|
export function renderEvent(root) { |
|
|
const events = [ |
|
|
{ |
|
|
title: "Birthday Cake", |
|
|
text: "You find a delicious birthday cake! But it looks suspicious...", |
|
|
artwork: "assets/card-art/bread.png", |
|
|
choices: [ |
|
|
{ |
|
|
text: "Eat the whole cake (+15 HP, gain Sugar Crash curse)", |
|
|
icon: "assets/card-art/apple.png", |
|
|
risk: "high", |
|
|
effect: () => { |
|
|
root.player.hp = Math.min(root.player.maxHp, root.player.hp + 15); |
|
|
root.player.deck.push("sugar_crash"); |
|
|
root.log("Ate cake: +15 HP, added Sugar Crash curse"); |
|
|
} |
|
|
}, |
|
|
{ |
|
|
text: "Take a small bite (+8 HP)", |
|
|
icon: "assets/card-art/heart.png", |
|
|
risk: "low", |
|
|
effect: () => { |
|
|
root.player.hp = Math.min(root.player.maxHp, root.player.hp + 8); |
|
|
root.log("Small bite: +8 HP"); |
|
|
} |
|
|
}, |
|
|
{ |
|
|
text: "Leave it alone (gain 25 gold)", |
|
|
icon: "assets/card-art/bag_of_gold.png", |
|
|
risk: "none", |
|
|
effect: () => { |
|
|
root.player.gold += 25; |
|
|
root.log("Resisted temptation: +25 gold"); |
|
|
} |
|
|
} |
|
|
] |
|
|
}, |
|
|
{ |
|
|
title: "Birthday Present", |
|
|
text: "A mysterious gift box sits before you. What could be inside?", |
|
|
artwork: "assets/card-art/chest_closed.png", |
|
|
choices: [ |
|
|
{ |
|
|
text: "Open it eagerly (Random card or lose 10 HP)", |
|
|
icon: "assets/card-art/key.png", |
|
|
risk: "high", |
|
|
effect: () => { |
|
|
if (Math.random() < 0.7) { |
|
|
import("../data/cards.js").then(({ CARDS, CARD_POOL }) => { |
|
|
const randomCard = CARD_POOL[Math.floor(Math.random() * CARD_POOL.length)]; |
|
|
root.player.deck.push(randomCard); |
|
|
root.log(`Found ${CARDS[randomCard].name}!`); |
|
|
}); |
|
|
} else { |
|
|
root.player.hp = Math.max(1, root.player.hp - 10); |
|
|
root.log("It was a trap! -10 HP"); |
|
|
} |
|
|
} |
|
|
}, |
|
|
{ |
|
|
text: "Open it carefully (+5 Max HP)", |
|
|
icon: "assets/card-art/potion_heal.png", |
|
|
risk: "low", |
|
|
effect: () => { |
|
|
root.player.maxHp += 5; |
|
|
root.player.hp += 5; |
|
|
root.log("Careful approach: +5 Max HP"); |
|
|
} |
|
|
}, |
|
|
{ |
|
|
text: "Don't touch it (gain 30 gold)", |
|
|
icon: "assets/card-art/bag_of_gold.png", |
|
|
risk: "none", |
|
|
effect: () => { |
|
|
root.player.gold += 30; |
|
|
root.log("Played it safe: +30 gold"); |
|
|
} |
|
|
} |
|
|
] |
|
|
}, |
|
|
{ |
|
|
title: "Birthday Balloons", |
|
|
text: "Colorful balloons float by. One has a note attached: 'Pop me for a surprise!'", |
|
|
artwork: "assets/card-art/feather.png", |
|
|
choices: [ |
|
|
{ |
|
|
text: "Pop the balloon (Remove a random basic card from deck)", |
|
|
icon: "assets/card-art/scroll.png", |
|
|
risk: "medium", |
|
|
effect: () => { |
|
|
const basicCards = root.player.deck.filter(id => id === "strike" || id === "defend"); |
|
|
if (basicCards.length > 0) { |
|
|
const toRemove = basicCards[0]; |
|
|
const index = root.player.deck.indexOf(toRemove); |
|
|
root.player.deck.splice(index, 1); |
|
|
root.log(`Removed ${toRemove} from deck`); |
|
|
} else { |
|
|
root.log("No basic cards to remove"); |
|
|
} |
|
|
} |
|
|
}, |
|
|
{ |
|
|
text: "Collect the balloons (+1 Energy next 3 fights)", |
|
|
icon: "assets/card-art/magic_sphere.png", |
|
|
risk: "low", |
|
|
effect: () => { |
|
|
root.flags.bonusEnergyFights = 3; |
|
|
root.log("Collected balloons: +1 Energy next 3 fights"); |
|
|
} |
|
|
}, |
|
|
{ |
|
|
text: "Ignore them (heal 12 HP)", |
|
|
icon: "assets/card-art/heart.png", |
|
|
risk: "none", |
|
|
effect: () => { |
|
|
root.player.hp = Math.min(root.player.maxHp, root.player.hp + 12); |
|
|
root.log("Focused on rest: +12 HP"); |
|
|
} |
|
|
} |
|
|
] |
|
|
} |
|
|
]; |
|
|
|
|
|
const event = events[Math.floor(Math.random() * events.length)]; |
|
|
|
|
|
root.app.innerHTML = ` |
|
|
<div class="event-screen"> |
|
|
<div class="event-header"> |
|
|
<h1>${event.title}</h1> |
|
|
<p>A birthday adventure awaits your decision</p> |
|
|
<div class="player-status-inline"> |
|
|
<div class="status-item"> |
|
|
<img src="assets/card-art/heart.png" alt="Health" class="status-icon-img"> |
|
|
<span>${root.player.hp}/${root.player.maxHp} HP</span> |
|
|
</div> |
|
|
<div class="status-item"> |
|
|
<img src="assets/card-art/bag_of_gold.png" alt="Gold" class="status-icon-img"> |
|
|
<span>${root.player.gold || 0} Gold</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="event-content"> |
|
|
<div class="event-story"> |
|
|
<div class="event-artwork"> |
|
|
<img src="${event.artwork}" alt="Event" class="event-artwork-img"> |
|
|
</div> |
|
|
<div class="event-description"> |
|
|
<p>${event.text}</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="event-choices"> |
|
|
<h3>Choose your action:</h3> |
|
|
<div class="choices-grid"> |
|
|
${event.choices.map((choice, idx) => ` |
|
|
<div class="event-choice ${choice.risk}-risk" data-choice="${idx}"> |
|
|
<div class="choice-icon"> |
|
|
<img src="${choice.icon}" alt="Choice" class="choice-icon-img"> |
|
|
</div> |
|
|
<div class="choice-content"> |
|
|
<div class="choice-text">${choice.text}</div> |
|
|
<div class="choice-risk-badge ${choice.risk}"> |
|
|
${choice.risk === 'high' ? 'High Risk' : choice.risk === 'medium' ? 'Medium Risk' : choice.risk === 'low' ? 'Low Risk' : 'Safe'} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`).join("")} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
|
|
|
root.app.querySelectorAll("[data-choice]").forEach(btn => { |
|
|
btn.addEventListener("click", () => { |
|
|
const idx = parseInt(btn.dataset.choice, 10); |
|
|
event.choices[idx].effect(); |
|
|
root.afterNode(); |
|
|
}); |
|
|
}); |
|
|
} |
|
|
|
|
|
export function renderWin(root) { |
|
|
const finalStats = { |
|
|
totalTurns: root.turnCount || 0, |
|
|
cardsPlayed: root.cardsPlayedCount || 0, |
|
|
finalHP: root.player.hp, |
|
|
maxHP: root.player.maxHp, |
|
|
finalGold: root.player.gold || 0, |
|
|
deckSize: root.player.deck.length, |
|
|
relicsCollected: root.relicStates.length |
|
|
}; |
|
|
|
|
|
root.app.innerHTML = ` |
|
|
<div class="victory-screen"> |
|
|
<div class="victory-header"> |
|
|
<div class="victory-crown"> |
|
|
<img src="assets/card-art/crown.png" alt="Victory Crown" class="crown-img"> |
|
|
</div> |
|
|
<h1>VICTORY ACHIEVED!</h1> |
|
|
<h2>ThePrimeagen Spire Has Been Conquered!</h2> |
|
|
<p>ThePrimeagen's birthday celebration can continue in peace!</p> |
|
|
</div> |
|
|
|
|
|
<div class="victory-content"> |
|
|
<div class="victory-artwork"> |
|
|
<div class="victory-scene"> |
|
|
<img src="assets/card-art/trophy.png" alt="Trophy" class="victory-trophy"> |
|
|
<div class="victory-glow"></div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="victory-stats"> |
|
|
<h3>Final Statistics</h3> |
|
|
<div class="stats-grid"> |
|
|
<div class="stat-item"> |
|
|
<img src="assets/card-art/heart.png" alt="Health" class="stat-icon"> |
|
|
<div class="stat-info"> |
|
|
<span class="stat-label">Final Health</span> |
|
|
<span class="stat-value">${finalStats.finalHP}/${finalStats.maxHP}</span> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-item"> |
|
|
<img src="assets/card-art/bag_of_gold.png" alt="Gold" class="stat-icon"> |
|
|
<div class="stat-info"> |
|
|
<span class="stat-label">Gold Remaining</span> |
|
|
<span class="stat-value">${finalStats.finalGold}</span> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-item"> |
|
|
<img src="assets/card-art/book.png" alt="Deck" class="stat-icon"> |
|
|
<div class="stat-info"> |
|
|
<span class="stat-label">Final Deck Size</span> |
|
|
<span class="stat-value">${finalStats.deckSize} cards</span> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-item"> |
|
|
<img src="assets/card-art/runestone.png" alt="Relics" class="stat-icon"> |
|
|
<div class="stat-info"> |
|
|
<span class="stat-label">Relics Collected</span> |
|
|
<span class="stat-value">${finalStats.relicsCollected}</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="victory-relics"> |
|
|
<h3>Relics Mastered</h3> |
|
|
<div class="relics-showcase"> |
|
|
${root.relicStates.length > 0 ? |
|
|
root.relicStates.map(r => ` |
|
|
<div class="relic-showcase-item" title="${getRelicText(r.id)}"> |
|
|
<div class="relic-showcase-icon">${getRelicEmoji(r.id)}</div> |
|
|
<div class="relic-showcase-name">${getRelicName(r.id)}</div> |
|
|
</div> |
|
|
`).join('') : |
|
|
'<div class="no-relics">No relics collected this run</div>' |
|
|
} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="victory-message"> |
|
|
<div class="birthday-celebration"> |
|
|
<h3>Birthday Celebration Complete!</h3> |
|
|
<p>Thanks to your heroic efforts in your old age. ThePrimeagen's boomer years shall continue!</p> |
|
|
<p class="victory-quote">"Happy Birthday Prime! Hope you have a good one!"</p> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="victory-actions"> |
|
|
<button class="victory-btn primary" data-replay> |
|
|
<img src="assets/card-art/scroll.png" alt="New Run" class="btn-icon"> |
|
|
<span>Start New Adventure</span> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
root.app.querySelector("[data-replay]").addEventListener("click", () => root.reset()); |
|
|
} |
|
|
|
|
|
export function renderLose(root) { |
|
|
const finalStats = { |
|
|
totalTurns: root.turnCount || 0, |
|
|
cardsPlayed: root.cardsPlayedCount || 0, |
|
|
finalHP: 0, // Player is defeated |
|
|
maxHP: root.player.maxHp, |
|
|
finalGold: root.player.gold || 0, |
|
|
deckSize: root.player.deck.length, |
|
|
relicsCollected: root.relicStates.length, |
|
|
nodeId: root.nodeId || 'unknown' |
|
|
}; |
|
|
|
|
|
root.app.innerHTML = ` |
|
|
<div class="defeat-screen"> |
|
|
<div class="defeat-header"> |
|
|
<h1>You Failed!</h1> |
|
|
<h2>The Spire Claims Another Developer</h2> |
|
|
<p>It seems age has slowed the CPU upstairs… |
|
|
Better luck on the next run!</p> |
|
|
</div> |
|
|
|
|
|
<div class="defeat-stats"> |
|
|
<h3>Final Debug Report</h3> |
|
|
<div class="stats-grid"> |
|
|
<div class="stat-item"> |
|
|
<div class="stat-info"> |
|
|
<div class="stat-label">Turns Survived</div> |
|
|
<div class="stat-value">${finalStats.totalTurns}</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-item"> |
|
|
<div class="stat-info"> |
|
|
<div class="stat-label">Cards Played</div> |
|
|
<div class="stat-value">${finalStats.cardsPlayed}</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-item"> |
|
|
<div class="stat-info"> |
|
|
<div class="stat-label">HP Lost</div> |
|
|
<div class="stat-value">${finalStats.maxHP}/${finalStats.maxHP}</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-item"> |
|
|
<div class="stat-info"> |
|
|
<div class="stat-label">Gold Earned</div> |
|
|
<div class="stat-value">${finalStats.finalGold}</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-item"> |
|
|
<div class="stat-info"> |
|
|
<div class="stat-label">Deck Size</div> |
|
|
<div class="stat-value">${finalStats.deckSize} cards</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-item"> |
|
|
<div class="stat-info"> |
|
|
<div class="stat-label">Relics Found</div> |
|
|
<div class="stat-value">${finalStats.relicsCollected}</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
${root.relicStates.length > 0 ? ` |
|
|
<div class="defeat-relics"> |
|
|
<h3>Tools Collected</h3> |
|
|
<div class="relics-showcase"> |
|
|
${root.relicStates.map(relic => ` |
|
|
<div class="relic-showcase-item"> |
|
|
<div class="relic-showcase-icon">${getRelicEmoji(relic.id)}</div> |
|
|
<div class="relic-showcase-name">${relic.id.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}</div> |
|
|
</div> |
|
|
`).join('')} |
|
|
</div> |
|
|
</div> |
|
|
` : ` |
|
|
<div class="defeat-relics"> |
|
|
<div class="no-relics">No relics were collected during this run.</div> |
|
|
</div> |
|
|
`} |
|
|
|
|
|
<div class="defeat-message"> |
|
|
<div class="debug-session"> |
|
|
<h3>Post-Mortem Analysis</h3> |
|
|
<div class="defeat-quote"> |
|
|
"Debugging is twice as hard as writing the code in the first place.<br/> |
|
|
Therefore, if you write the code as cleverly as possible,<br/> |
|
|
you are, by definition, not smart enough to debug it."<br/> |
|
|
<em>- Brian Kernighan</em> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="defeat-actions"> |
|
|
<button class="defeat-btn primary-btn" data-replay> |
|
|
<span class="btn-icon">🔄</span> |
|
|
<span>Try Again</span> |
|
|
</button> |
|
|
<button class="defeat-btn secondary-btn" data-menu> |
|
|
<span class="btn-icon">🏠</span> |
|
|
<span>Main Menu</span> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
|
|
|
|
|
|
root.app.querySelector("[data-replay]").addEventListener("click", () => { |
|
|
root.reset(); |
|
|
}); |
|
|
|
|
|
root.app.querySelector("[data-menu]").addEventListener("click", () => { |
|
|
root.reset(); |
|
|
}); |
|
|
} |
|
|
|
|
|
|