// Game dashboard: load character profile, zones, hunt, combat log, and inventory. let energyInterval = null; let energyData = null; // store latest server energy data for sync let isHunting = false; let currentTab = 'hunt'; let inventoryCache = null; /** * Switch active tab in the action panel. */ function switchTab(tab) { currentTab = tab; document.querySelectorAll('.tab').forEach((t) => { const isActive = t.dataset.tab === tab; t.classList.toggle('active', isActive); t.setAttribute('aria-selected', isActive); }); document.getElementById('hunt-tab').style.display = tab === 'hunt' ? '' : 'none'; document.getElementById('expedition-tab').style.display = tab === 'expedition' ? '' : 'none'; document.getElementById('inventory-tab').style.display = tab === 'inventory' ? '' : 'none'; if (tab === 'expedition') { loadExpeditions(); startExpeditionSync(); } } /** * Sync mobile bottom nav active state with desktop tabs. */ function syncMobileNav() { const mobileNav = document.querySelector('.mobile-bottom-nav'); if (!mobileNav) return; mobileNav.querySelectorAll('.tab').forEach((t) => { const isActive = t.dataset.tab === currentTab; t.classList.toggle('active', isActive); t.setAttribute('aria-selected', isActive); }); } /** * Attach mobile bottom-nav click handlers. */ function initMobileNav() { const mobileNav = document.querySelector('.mobile-bottom-nav'); if (!mobileNav) return; mobileNav.querySelectorAll('.tab').forEach((btn) => { btn.addEventListener('click', () => { switchTab(btn.dataset.tab); }); }); } document.addEventListener('DOMContentLoaded', () => { initMobileNav(); }); /** * Refresh energy state from the server at a fixed interval. * Keeps client-side countdown in sync with the authoritative server. */ function startEnergySync() { if (energyInterval) clearInterval(energyInterval); // Sync every 60s (2/3 of a regen tick) to stay in sync with server energyInterval = setInterval(() => { loadCharacter(); }, 60000); } /** * Update the energy bar and countdown display. * @param {number} current - current energy value * @param {number} max - max energy * @param {number} nextTickSec - seconds until next +1 energy tick (server-derived) */ function updateEnergyBar(current, max, nextTickSec) { energyData = { current, max, nextTickSec }; // Update text const energyEl = document.getElementById('char-energy'); if (energyEl) energyEl.textContent = `${current} / ${max}`; // Update bar fill const barEl = document.getElementById('energy-bar'); if (barEl) { const pct = Math.round((current / max) * 100); barEl.style.width = `${pct}%`; barEl.title = `${current}/${max} energy`; } // Update countdown const timerEl = document.getElementById('energy-timer'); if (timerEl) { if (current >= max) { timerEl.textContent = 'Energy full'; } else { const mins = Math.floor(nextTickSec / 60); const secs = nextTickSec % 60; timerEl.textContent = `Next +1 in ${mins}:${secs.toString().padStart(2, '0')}`; } } } /** * Start a client-side countdown that decrements until the next energy tick. * When it reaches zero, refetch from server to get authoritative state. */ function startCountdown() { if (!energyData || energyData.current >= energyData.max || energyData.nextTickSec <= 0) { return; } let remaining = energyData.nextTickSec; if (energyInterval) clearInterval(energyInterval); energyInterval = setInterval(() => { remaining--; if (remaining <= 0) { clearInterval(energyInterval); energyInterval = null; loadCharacter(); return; } const mins = Math.floor(remaining / 60); const secs = remaining % 60; const timerEl = document.getElementById('energy-timer'); if (timerEl) { timerEl.textContent = `Next +1 in ${mins}:${secs.toString().padStart(2, '0')}`; } }, 1000); } async function loadCharacter() { try { const data = await api.get('/character'); if (!data) { // No character yet — redirect to create-hero window.location.href = '/create-hero.html'; return; } const { character, derived, energy } = data; window.heroLevel = character.level; // Show stats, hide loading document.getElementById('hero-loading').style.display = 'none'; document.getElementById('hero-stats').style.display = 'block'; // Basic info document.getElementById('player-name').textContent = character.name; document.getElementById('char-name').textContent = character.name; document.getElementById('char-class').textContent = character.class.charAt(0).toUpperCase() + character.class.slice(1); document.getElementById('char-level').textContent = character.level; document.getElementById('char-hp').textContent = `${character.hp_current} / ${character.maxHp}`; document.getElementById('char-energy').textContent = `${energy.current} / ${energy.max}`; document.getElementById('char-gold').textContent = character.gold; document.getElementById('char-xp').textContent = `${character.xp} / ${xpToNext(character.level)}`; document.getElementById('char-str').textContent = character.str; document.getElementById('char-agi').textContent = character.agi; document.getElementById('char-int').textContent = character.int; document.getElementById('char-vit').textContent = character.vit; document.getElementById('derived-atk').textContent = derived.attack; document.getElementById('derived-def').textContent = derived.defense; document.getElementById('derived-spd').textContent = derived.speed; document.getElementById('derived-crit').textContent = derived.critChance; // Update energy bar and start countdown updateEnergyBar(energy.current, energy.max, energy.nextTickInSec); startCountdown(); } catch (err) { // Not authenticated — redirect to login window.location.href = '/login.html'; } } function xpToNext(level) { return Math.floor(100 * Math.pow(level, 1.5)); } // ===== Zone list ===== async function loadZones() { const zoneList = document.getElementById('zone-list'); try { const data = await api.get('/zones'); if (!Array.isArray(data)) { zoneList.innerHTML = ''; const p = document.createElement('p'); p.className = 'placeholder'; p.textContent = 'Failed to load zones.'; zoneList.appendChild(p); return; } const zones = data; zoneList.innerHTML = ''; for (const zone of zones) { const card = document.createElement('div'); card.className = 'zone-card'; const zoneName = document.createElement('div'); zoneName.className = 'zone-name'; zoneName.textContent = zone.name; const zoneDesc = document.createElement('div'); zoneDesc.className = 'zone-desc'; zoneDesc.textContent = zone.description; const zoneMeta = document.createElement('div'); zoneMeta.className = 'zone-meta'; zoneMeta.textContent = `Lv ${zone.min_level}+ · ${zone.energy_cost}⚡`; const btn = document.createElement('button'); btn.className = 'btn btn-hunt'; btn.textContent = `Hunt (${zone.energy_cost}⚡)`; btn.disabled = !zone.unlocked; btn.dataset.zoneId = zone.id; if (!zone.unlocked) { const lockMsg = document.createElement('div'); lockMsg.className = 'zone-locked-msg'; lockMsg.textContent = `Reach level ${zone.min_level}`; card.appendChild(zoneName); card.appendChild(lockMsg); } else { card.appendChild(zoneName); card.appendChild(zoneDesc); card.appendChild(zoneMeta); card.appendChild(btn); } zoneList.appendChild(card); } // Attach hunt handlers zoneList.querySelectorAll('.btn-hunt').forEach((btn) => { btn.addEventListener('click', handleHunt); }); } catch (_) { zoneList.innerHTML = ''; const p = document.createElement('p'); p.className = 'placeholder'; p.textContent = 'Failed to load zones.'; zoneList.appendChild(p); } } // ===== Hunt ===== async function handleHunt(e) { if (isHunting) return; const btn = e.target; const zoneId = Number(btn.dataset.zoneId); if (isNaN(zoneId)) return; isHunting = true; btn.disabled = true; btn.textContent = 'Hunting...'; // Hide previous result document.getElementById('result-panel').style.display = 'none'; try { const data = await api.post('/hunt', { zoneId }); if (!data) { showToast(data?.error?.message || 'Hunt failed.', 'error'); return; } const result = data; displayHuntResult(result); loadCharacter(); loadCombatLog(); loadZones(); // refresh energy cost buttons } catch (err) { showToast(err.message || 'Hunt failed.', 'error'); } finally { isHunting = false; // Re-enable all hunt buttons document.querySelectorAll('.btn-hunt').forEach((b) => { b.disabled = false; // Restore text from zone card labels const zoneCard = b.closest('.zone-card'); if (zoneCard) { const meta = zoneCard.querySelector('.zone-meta'); if (meta) { const cost = meta.textContent.match(/(\d+)⚡/); if (cost) b.textContent = `Hunt (${cost[1]}⚡)`; } } }); } } // ===== Display hunt result ===== function displayHuntResult(result) { const panel = document.getElementById('result-panel'); const title = document.getElementById('result-title'); const body = document.getElementById('result-body'); panel.style.display = 'block'; const won = result.winner === 'hero'; title.textContent = won ? 'Victory!' : 'Defeat'; title.style.color = won ? '' : ''; body.innerHTML = ''; // Monster info const monsterLine = document.createElement('p'); monsterLine.textContent = `Fought: ${result.monster.name} (Lv.${result.monster.level})`; body.appendChild(monsterLine); // Rewards const rewards = document.createElement('p'); rewards.textContent = []; const parts = []; if (result.xpGained) parts.push(`+${result.xpGained} XP`); if (result.goldGained) parts.push(`+${result.goldGained} Gold`); if (parts.length) rewards.textContent = parts.join(', '); if (result.loot && result.loot.length > 0) { const lootParts = result.loot.map((l) => `${l.name}${l.quantity > 1 ? `x${l.quantity}` : ''}`); const lootLine = document.createElement('p'); lootLine.textContent = `Loot: ${lootParts.join(', ')}`; body.appendChild(lootLine); } body.appendChild(rewards); // Level up if (result.leveledUp) { const lvLine = document.createElement('p'); lvLine.style.fontWeight = 'bold'; lvLine.textContent = `⬆ Level up! Now level ${result.newLevel}`; body.appendChild(lvLine); } // HP remaining const hpLine = document.createElement('p'); hpLine.textContent = `HP: ${result.hpRemaining} / ${result.hpMax}`; body.appendChild(hpLine); // Energy remaining const energyLine = document.createElement('p'); energyLine.textContent = `Energy: ${result.energy}`; body.appendChild(energyLine); // Gold lost on defeat if (result.goldLost > 0) { const lostLine = document.createElement('p'); lostLine.style.color = '#c0392b'; lostLine.textContent = `Lost ${result.goldLost} gold to defeat.`; body.appendChild(lostLine); } } // ===== Combat log ===== async function loadCombatLog() { const logList = document.getElementById('combat-log-list'); try { const data = await api.get('/combat-log?limit=10'); if (!Array.isArray(data)) { logList.innerHTML = ''; const p = document.createElement('p'); p.className = 'placeholder'; p.textContent = 'No combat history.'; logList.appendChild(p); return; } const logs = data; logList.innerHTML = ''; if (logs.length === 0) { const p = document.createElement('p'); p.className = 'placeholder'; p.textContent = 'No combat history.'; logList.appendChild(p); return; } for (const log of logs) { const entry = document.createElement('div'); entry.className = 'log-entry'; const resultTag = document.createElement('span'); resultTag.className = `log-result ${log.won ? 'win' : 'loss'}`; resultTag.textContent = log.won ? '✓' : '✗'; const logText = document.createElement('span'); logText.className = 'log-text'; const parts = [`${log.monsterName} (Lv.${log.monsterLevel})`]; if (log.won) { if (log.xpGained) parts.push(`+${log.xpGained} XP`); if (log.goldGained) parts.push(`+${log.goldGained} G`); } else { parts.push('defeated'); } logText.textContent = parts.join(' · '); entry.appendChild(resultTag); entry.appendChild(logText); logList.appendChild(entry); } } catch (_) { logList.innerHTML = ''; const p = document.createElement('p'); p.className = 'placeholder'; p.textContent = 'Failed to load combat log.'; logList.appendChild(p); } } // ===== Item display helpers ===== const ITEM_EMOJI = { weapon: '⚔️', armor: '🛡️', trinket: '💍', consumable: '🧪', material: '📦' }; function formatBonuses(bonuses) { const parts = []; if (bonuses.bonus_attack) parts.push(`+${bonuses.bonus_attack} ATK`); if (bonuses.bonus_defense) parts.push(`+${bonuses.bonus_defense} DEF`); if (bonuses.bonus_str) parts.push(`+${bonuses.bonus_str} STR`); if (bonuses.bonus_agi) parts.push(`+${bonuses.bonus_agi} AGI`); if (bonuses.bonus_int) parts.push(`+${bonuses.bonus_int} INT`); if (bonuses.bonus_vit) parts.push(`+${bonuses.bonus_vit} VIT`); return parts.join(', '); } function formatEffect(item) { if (!item.effect) return ''; const match = item.effect.match(/^(heal|energy):(\d+)$/); if (!match) return ''; const [_full, type, value] = match; return type === 'heal' ? `Heals ${value} HP` : `Restores ${value} Energy`; } // ===== Inventory ===== const EQUIP_SLOTS = ['weapon', 'armor', 'trinket']; async function loadInventory() { try { const data = await api.get('/inventory'); if (!data) return; inventoryCache = data; renderEquipmentPanel(data.items); renderInventoryGrid(data.items); } catch (_) { // Character doesn't exist yet or not authenticated — skip. } } function renderEquipmentPanel(items) { const container = document.getElementById('equipment-slots'); container.innerHTML = ''; for (const slot of EQUIP_SLOTS) { const slotDiv = document.createElement('div'); slotDiv.className = 'equip-slot'; const label = document.createElement('span'); label.className = 'slot-label'; label.textContent = slot; const equipped = items.find((i) => i.equipped_slot === slot); const itemInfo = document.createElement('span'); itemInfo.className = 'slot-item'; if (equipped) { const nameSpan = document.createElement('span'); nameSpan.className = `item-name rarity-${equipped.rarity}`; nameSpan.textContent = equipped.name; itemInfo.appendChild(nameSpan); const statsText = formatBonuses(equipped.bonuses); if (statsText) { const statsSpan = document.createElement('span'); statsSpan.className = 'item-stats'; statsSpan.textContent = statsText; itemInfo.appendChild(statsSpan); } const unequipBtn = document.createElement('button'); unequipBtn.className = 'btn-unequip'; unequipBtn.textContent = '✕'; unequipBtn.title = `Unequip ${equipped.name}`; unequipBtn.addEventListener('click', (e) => { e.stopPropagation(); handleUnequip(slot); }); itemInfo.appendChild(unequipBtn); } else { const emptySpan = document.createElement('span'); emptySpan.className = 'slot-empty'; emptySpan.textContent = 'Empty'; itemInfo.appendChild(emptySpan); } slotDiv.appendChild(label); slotDiv.appendChild(itemInfo); container.appendChild(slotDiv); } } function renderInventoryGrid(items) { const container = document.getElementById('inventory-grid'); container.innerHTML = ''; const unequipped = items.filter((i) => !i.equipped); if (unequipped.length === 0) { const p = document.createElement('p'); p.className = 'placeholder'; p.textContent = 'No items.'; container.appendChild(p); return; } for (const item of unequipped) { const card = document.createElement('div'); card.className = 'inv-item'; // Tooltip (always shown on hover) const tooltip = document.createElement('div'); tooltip.className = 'tooltip'; const tooltipName = document.createElement('div'); tooltipName.className = `tooltip-name rarity-${item.rarity}`; tooltipName.textContent = item.name; tooltip.appendChild(tooltipName); const tooltipType = document.createElement('div'); tooltipType.className = 'tooltip-type'; tooltipType.textContent = `${item.type}${item.quantity > 1 ? ` (x${item.quantity})` : ''}`; tooltip.appendChild(tooltipType); const statsText = formatBonuses(item.bonuses); if (statsText) { const tooltipStats = document.createElement('div'); tooltipStats.className = 'tooltip-stats'; tooltipStats.textContent = statsText; tooltip.appendChild(tooltipStats); } const effectText = formatEffect(item); if (effectText) { const tooltipEffect = document.createElement('div'); tooltipEffect.className = 'tooltip-effect'; tooltipEffect.textContent = effectText; tooltip.appendChild(tooltipEffect); } if (item.min_level > 1) { const tooltipLevel = document.createElement('div'); tooltipLevel.className = 'tooltip-level'; tooltipLevel.textContent = `Requires Lv. ${item.min_level}`; tooltip.appendChild(tooltipLevel); } card.appendChild(tooltip); // Compact card content const header = document.createElement('div'); header.className = 'inv-item-header'; const icon = document.createElement('span'); icon.className = 'inv-item-icon'; icon.textContent = ITEM_EMOJI[item.type] || '❓'; header.appendChild(icon); const name = document.createElement('span'); name.className = 'inv-item-name rarity-' + item.rarity; name.textContent = item.name; header.appendChild(name); const rarityBadge = document.createElement('span'); rarityBadge.className = `inv-item-rarity bg-rarity-${item.rarity}`; rarityBadge.textContent = item.rarity; header.appendChild(rarityBadge); card.appendChild(header); const stats = document.createElement('div'); stats.className = 'inv-item-stats'; stats.textContent = formatBonuses(item.bonuses) || formatEffect(item) || '—'; card.appendChild(stats); if (item.quantity > 1) { const qty = document.createElement('span'); qty.className = 'inv-item-qty'; qty.textContent = `x${item.quantity}`; card.appendChild(qty); } card.addEventListener('click', () => handleItemClick(item)); container.appendChild(card); } } async function handleItemClick(item) { try { let data; if (item.type === 'consumable') { data = await api.post('/inventory/use', { itemId: item.itemId }); } else { data = await api.post('/inventory/equip', { itemId: item.itemId, slot: item.type }); } if (!data) { showToast('Action failed.', 'error'); return; } inventoryCache = data; renderEquipmentPanel(data.items); renderInventoryGrid(data.items); loadCharacter(); } catch (err) { showToast(err.message || 'Action failed.', 'error'); } } async function handleUnequip(slot) { try { const data = await api.post('/inventory/unequip', { slot }); if (!data) { showToast('Unequip failed.', 'error'); return; } inventoryCache = data; renderEquipmentPanel(data.items); renderInventoryGrid(data.items); loadCharacter(); } catch (err) { showToast(err.message || 'Unequip failed.', 'error'); } } // ===== Expedition UI ===== let expeditionInterval = null; function formatTimeRemaining(ms) { if (ms <= 0) return 'Finished'; const totalSec = Math.ceil(ms / 1000); const mins = Math.floor(totalSec / 60); const secs = totalSec % 60; if (mins >= 60) { const hours = Math.floor(mins / 60); const remMins = mins % 60; return `${hours}h ${remMins}m`; } return `${mins}:${secs.toString().padStart(2, '0')}`; } function formatDuration(sec) { if (sec < 3600) return `${sec / 60} min`; const hours = sec / 3600; if (hours < 24) return `${hours}h`; return `${hours / 24}d`; } async function loadExpeditions() { try { const data = await api.get('/expeditions/active'); // null when no active expedition const statusEl = document.getElementById('expedition-status'); const defsEl = document.getElementById('expedition-defs'); if (data) { renderActiveExpedition(statusEl, defsEl, data); } else { statusEl.innerHTML = ''; renderExpeditionDefs(defsEl); } } catch (_) { const statusEl = document.getElementById('expedition-status'); statusEl.innerHTML = '

Failed to load expedition data.

'; } } function renderActiveExpedition(statusEl, defsEl, expeditionData) { defsEl.innerHTML = ''; statusEl.innerHTML = ''; const { expedition, definition, timeRemaining, isFinished } = expeditionData; const elapsed = (definition.durationSec * 1000) - timeRemaining; const pct = Math.min(100, Math.max(0, (elapsed / (definition.durationSec * 1000)) * 100)); // Card wrapper const card = document.createElement('div'); card.className = 'expedition-active-card'; // Name const nameEl = document.createElement('div'); nameEl.className = 'expedition-name'; nameEl.textContent = definition.name; card.appendChild(nameEl); // Description const descEl = document.createElement('div'); descEl.className = 'expedition-desc'; descEl.textContent = definition.description; card.appendChild(descEl); // Progress bar const progressEl = document.createElement('div'); progressEl.className = 'expedition-progress'; const barEl = document.createElement('div'); barEl.className = 'expedition-progress-bar'; const fillEl = document.createElement('div'); fillEl.className = 'expedition-progress-fill'; fillEl.style.width = `${pct}%`; barEl.appendChild(fillEl); const textEl = document.createElement('div'); textEl.className = 'expedition-progress-text'; textEl.textContent = formatTimeRemaining(timeRemaining); progressEl.appendChild(barEl); progressEl.appendChild(textEl); card.appendChild(progressEl); if (isFinished) { const claimBtn = document.createElement('button'); claimBtn.className = 'btn btn-expedition btn-claim'; claimBtn.dataset.expeditionId = expedition.id; claimBtn.textContent = 'Claim Rewards'; claimBtn.addEventListener('click', handleClaimExpedition); card.appendChild(claimBtn); const rewardsEl = document.createElement('div'); rewardsEl.className = 'expedition-rewards-preview'; rewardsEl.textContent = `Rewards: ${definition.xpReward} XP, ${definition.goldMin}–${definition.goldMax} Gold`; card.appendChild(rewardsEl); } else { const infoEl = document.createElement('div'); infoEl.className = 'expedition-info'; infoEl.textContent = `Duration: ${formatDuration(definition.durationSec)} · Cost: ${definition.energyCost}⚡`; card.appendChild(infoEl); } statusEl.appendChild(card); } function renderExpeditionDefs(container) { container.innerHTML = ''; // Fetch expedition definitions from the server api.get('/expeditions/defs').then(defsData => { if (!Array.isArray(defsData)) return; const defs = defsData; for (const def of defs) { const card = document.createElement('div'); card.className = 'expedition-def-card'; const nameEl = document.createElement('div'); nameEl.className = 'expedition-name'; nameEl.textContent = def.name; const descEl = document.createElement('div'); descEl.className = 'expedition-desc'; descEl.textContent = def.description; const infoEl = document.createElement('div'); infoEl.className = 'expedition-info'; infoEl.textContent = `${formatDuration(def.durationSec)} · ${def.energyCost}⚡ · Lv${def.minLevel}+`; const rewardsEl = document.createElement('div'); rewardsEl.className = 'expedition-rewards'; rewardsEl.textContent = `+${def.xpReward} XP, ${def.goldMin}–${def.goldMax} Gold`; const btn = document.createElement('button'); btn.className = 'btn btn-expedition'; btn.textContent = `Start Expedition (${def.energyCost}⚡)`; btn.disabled = window.heroLevel < def.minLevel; btn.dataset.defId = def.id; if (window.heroLevel < def.minLevel) { btn.title = `Requires level ${def.minLevel}`; } card.appendChild(nameEl); card.appendChild(descEl); card.appendChild(infoEl); card.appendChild(rewardsEl); card.appendChild(btn); container.appendChild(card); btn.addEventListener('click', handleStartExpedition); } }).catch(() => { container.innerHTML = '

Failed to load expedition options.

'; }); } async function handleStartExpedition(e) { const btn = e.target; const defId = Number(btn.dataset.defId); if (isNaN(defId)) return; btn.disabled = true; btn.textContent = 'Starting...'; try { const data = await api.post('/expeditions/start', { defId }); if (!data) { showToast('Failed to start expedition.', 'error'); return; } showToast('Expedition started!', 'info'); loadExpeditions(); loadCharacter(); // refresh energy } catch (err) { showToast(err.message || 'Failed to start expedition.', 'error'); } } async function handleClaimExpedition(e) { const btn = e.target; const expeditionId = Number(btn.dataset.expeditionId); if (isNaN(expeditionId)) return; btn.disabled = true; btn.textContent = 'Claiming...'; try { const data = await api.post('/expeditions/claim', { expeditionId }); if (!data) { showToast('Failed to claim expedition.', 'error'); return; } const { xpGained, goldGained } = data; showToast(`Expedition complete! +${xpGained} XP, +${goldGained} Gold`, 'info'); loadExpeditions(); loadCharacter(); // refresh gold/XP } catch (err) { showToast(err.message || 'Failed to claim expedition.', 'error'); } } /** * Periodically refresh expedition progress while on the expedition tab. * Updates the progress bar and time remaining every 5 seconds. */ function startExpeditionSync() { if (expeditionInterval) clearInterval(expeditionInterval); expeditionInterval = setInterval(() => { if (currentTab === 'expedition') { loadExpeditions(); } }, 5000); } // ===== Init ===== document.addEventListener('DOMContentLoaded', async () => { await loadCharacter(); await loadZones(); await loadCombatLog(); await loadInventory(); startEnergySync(); // Tab click handlers document.querySelectorAll('.tab').forEach((tab) => { tab.addEventListener('click', () => switchTab(tab.dataset.tab)); }); }); // Logout handler document.getElementById('logout-btn')?.addEventListener('click', async (e) => { e.preventDefault(); try { await api.post('/auth/logout', {}); } catch (_) {} window.location.href = '/login.html'; });