1023 lines
31 KiB
JavaScript
1023 lines
31 KiB
JavaScript
// 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();
|
||
|
||
// Initialize allocation UI
|
||
initAllocationUI(character.unspent_points);
|
||
} 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 = '<p class="placeholder">Failed to load expedition data.</p>';
|
||
}
|
||
}
|
||
|
||
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 = '<p class="placeholder">Failed to load expedition options.</p>';
|
||
});
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// ===== Stat allocation UI =====
|
||
|
||
const STAT_KEYS = ['str', 'agi', 'int', 'vit'];
|
||
const STAT_LABELS = { str: 'STR', agi: 'AGI', int: 'INT', vit: 'VIT' };
|
||
|
||
// Temporary allocation state (what the user has clicked, not yet sent)
|
||
let allocTemp = { str: 0, agi: 0, int: 0, vit: 0 };
|
||
|
||
/**
|
||
* Build the allocation rows (one per stat) with + buttons.
|
||
*/
|
||
function buildAllocationRows() {
|
||
const container = document.getElementById('alloc-rows');
|
||
if (!container) return;
|
||
|
||
container.replaceChildren();
|
||
|
||
for (const key of STAT_KEYS) {
|
||
const row = document.createElement('div');
|
||
row.className = 'alloc-row';
|
||
|
||
const label = document.createElement('span');
|
||
label.className = 'alloc-label';
|
||
label.textContent = STAT_LABELS[key];
|
||
|
||
const value = document.createElement('span');
|
||
value.className = 'alloc-value';
|
||
value.id = 'alloc-val-' + key;
|
||
value.textContent = '0';
|
||
|
||
const btn = document.createElement('button');
|
||
btn.className = 'alloc-btn';
|
||
btn.type = 'button';
|
||
btn.textContent = '+';
|
||
btn.dataset.stat = key;
|
||
btn.addEventListener('click', handleAllocIncrement);
|
||
|
||
row.appendChild(label);
|
||
row.appendChild(value);
|
||
row.appendChild(btn);
|
||
container.appendChild(row);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle + button click: increment temp allocation for that stat.
|
||
*/
|
||
function handleAllocIncrement(e) {
|
||
const stat = e.target.dataset.stat;
|
||
if (!stat) return;
|
||
|
||
allocTemp[stat]++;
|
||
document.getElementById('alloc-val-' + stat).textContent = allocTemp[stat];
|
||
updateAllocState();
|
||
}
|
||
|
||
/**
|
||
* Update button states and apply button enable/disable.
|
||
*/
|
||
function updateAllocState() {
|
||
const total = STAT_KEYS.reduce((sum, k) => sum + allocTemp[k], 0);
|
||
const totalEl = document.getElementById('alloc-total');
|
||
const applyBtn = document.getElementById('alloc-apply-btn');
|
||
|
||
if (totalEl) totalEl.textContent = 'Allocated: ' + total;
|
||
|
||
// Enable apply only when at least one point allocated and unspent points remain
|
||
const unspent = parseInt(document.getElementById('unspent-points').textContent, 10) || 0;
|
||
const canApply = total > 0 && total <= unspent;
|
||
if (applyBtn) applyBtn.disabled = !canApply;
|
||
|
||
// Disable + buttons when total would exceed unspent
|
||
for (const key of STAT_KEYS) {
|
||
const btn = document.querySelector('.alloc-btn[data-stat="' + key + '"]');
|
||
if (btn) {
|
||
btn.disabled = (total >= unspent);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle apply button: send allocation to server, refresh stats.
|
||
*/
|
||
async function handleAllocApply() {
|
||
const applyBtn = document.getElementById('alloc-apply-btn');
|
||
if (applyBtn.disabled) return;
|
||
|
||
// Build allocation object (only stats that were incremented)
|
||
const allocation = {};
|
||
let hasPoints = false;
|
||
for (const key of STAT_KEYS) {
|
||
if (allocTemp[key] > 0) {
|
||
allocation[key] = allocTemp[key];
|
||
hasPoints = true;
|
||
}
|
||
}
|
||
if (!hasPoints) return;
|
||
|
||
applyBtn.disabled = true;
|
||
applyBtn.textContent = 'Applying...';
|
||
|
||
try {
|
||
const data = await api.post('/character/allocate', allocation);
|
||
if (!data) {
|
||
showToast('Allocation failed.', 'error');
|
||
return;
|
||
}
|
||
|
||
showToast('Stats allocated!', 'success');
|
||
|
||
// Reset temp allocation
|
||
allocTemp = { str: 0, agi: 0, int: 0, vit: 0 };
|
||
|
||
// Reload character to get updated stats
|
||
await loadCharacter();
|
||
} catch (err) {
|
||
showToast(err.message || 'Allocation failed.', 'error');
|
||
// Reload to restore state
|
||
await loadCharacter();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Initialize or refresh the allocation UI based on current character data.
|
||
*/
|
||
function initAllocationUI(unspentPoints) {
|
||
const section = document.getElementById('allocation-section');
|
||
if (!section) return;
|
||
|
||
if (unspentPoints > 0) {
|
||
section.style.display = 'block';
|
||
document.getElementById('unspent-points').textContent = unspentPoints;
|
||
buildAllocationRows();
|
||
updateAllocState();
|
||
} else {
|
||
section.style.display = 'none';
|
||
allocTemp = { str: 0, agi: 0, int: 0, vit: 0 };
|
||
}
|
||
}
|
||
|
||
// ===== 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));
|
||
});
|
||
|
||
// Allocation apply button
|
||
const applyBtn = document.getElementById('alloc-apply-btn');
|
||
if (applyBtn) {
|
||
applyBtn.addEventListener('click', handleAllocApply);
|
||
}
|
||
});
|
||
|
||
// Logout handler
|
||
document.getElementById('logout-btn')?.addEventListener('click', async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
await api.post('/auth/logout', {});
|
||
} catch (_) {}
|
||
window.location.href = '/login.html';
|
||
});
|