Files
Emberfall/public/js/game.js
T
2026-10-20 21:31:12 +11:00

391 lines
12 KiB
JavaScript

// Game dashboard: load character profile, zones, hunt, and combat log.
let energyInterval = null;
let energyData = null; // store latest server energy data for sync
let isHunting = false;
/**
* 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;
// 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 (!data || !data.ok) {
zoneList.innerHTML = '';
const p = document.createElement('p');
p.className = 'placeholder';
p.textContent = 'Failed to load zones.';
zoneList.appendChild(p);
return;
}
const zones = data.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 || !data.ok) {
showToast(data?.error?.message || 'Hunt failed.', 'error');
return;
}
const result = data.data;
displayHuntResult(result);
loadCharacter();
loadCombatLog();
loadZones(); // refresh energy cost buttons
} catch (err) {
showToast('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 (!data || !data.ok) {
logList.innerHTML = '';
const p = document.createElement('p');
p.className = 'placeholder';
p.textContent = 'No combat history.';
logList.appendChild(p);
return;
}
const logs = data.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);
}
}
// ===== Toast notifications =====
function showToast(message, type = 'info') {
// Remove existing toasts
document.querySelectorAll('.toast').forEach((t) => t.remove());
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 4000);
}
// ===== Init =====
document.addEventListener('DOMContentLoaded', async () => {
await loadCharacter();
await loadZones();
await loadCombatLog();
startEnergySync();
});
// Logout handler
document.getElementById('logout-btn')?.addEventListener('click', async (e) => {
e.preventDefault();
try {
await api.post('/auth/logout', {});
} catch (_) {}
window.location.href = '/login.html';
});