@ -2860,6 +2860,43 @@
white-space: nowrap;
}
/* Compass/GPS Button */
.compass-btn {
position: fixed;
bottom: 135px;
left: 62px;
z-index: 2100;
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(135deg, #2c3e50 0%, #1a252f 100%);
border: 2px solid #4a6785;
color: #fff;
font-size: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.4);
transition: all 0.2s;
opacity: 0.85;
}
.compass-btn:active {
transform: scale(0.95);
}
.compass-btn.active {
background: linear-gradient(135deg, #27ae60 0%, #1e8449 100%);
border-color: #2ecc71;
animation: pulse-glow 2s infinite;
}
.compass-btn.hidden {
display: none;
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 3px 10px rgba(0, 0, 0, 0.4); }
50% { box-shadow: 0 3px 15px rgba(46, 204, 113, 0.6); }
}
/* Home Base Marker */
.home-base-marker {
width: 50px;
@ -3192,6 +3229,8 @@
}
< / style >
<!-- Monster Animation Definitions -->
< script src = "/animations.js" > < / script >
< / head >
< body >
<!-- Login Screen - shown before game loads -->
@ -3288,8 +3327,11 @@
<!-- Music Toggle Button -->
< button id = "musicToggleBtn" class = "music-toggle-btn" style = "display: none;" onclick = "toggleMusicMute()" title = "Toggle Music" > 🎵< / button >
<!-- WASD Control Pad -->
< div id = "wasdControls" class = "wasd-controls" >
<!-- Compass/GPS Button -->
< button id = "compassBtn" class = "compass-btn" title = "Toggle GPS Location" > 🧭< / button >
<!-- WASD Control Pad (hidden by default, shown for admins when GPS off) -->
< div id = "wasdControls" class = "wasd-controls hidden" >
< div id = "wasdModeIndicator" class = "wasd-mode-indicator" > TEST MODE< / div >
< button class = "wasd-btn w-btn" data-dir = "w" > ▲< / button >
< button class = "wasd-btn a-btn" data-dir = "a" > ◀< / button >
@ -3382,7 +3424,7 @@
< button class = "combat-flee-btn" id = "combatFleeBtn" > 🏃 Flee< / button >
< / div >
< / div >
< button id = "panelToggle" class = "panel-toggle" style = "right: 10px;" > ☰< / button >
< button id = "panelToggle" class = "panel-toggle" style = "right: 10px; display: none; " > ☰< / button >
< div class = "controls" id = "controlPanel" style = "display: none;" >
<!-- User Profile Section -->
< div id = "userProfileSection" style = "display: none;" >
@ -4074,6 +4116,49 @@
snapDistancePx: 15
};
// Generate CSS from MONSTER_ANIMATIONS object (loaded from animations.js)
function generateAnimationCSS() {
if (typeof MONSTER_ANIMATIONS === 'undefined') {
console.warn('MONSTER_ANIMATIONS not loaded, using default animations');
return;
}
let css = '';
for (const [id, anim] of Object.entries(MONSTER_ANIMATIONS)) {
const loopStr = anim.loop ? ' infinite' : '';
const fillStr = anim.fillMode ? ` ${anim.fillMode}` : '';
const easing = anim.easing || 'ease-out';
css += `@keyframes monster_${id} { ${anim.keyframes} }\n`;
css += `.anim-${id} { animation: monster_${id} ${anim.duration}ms ${easing}${loopStr}${fillStr}; }\n`;
}
const style = document.createElement('style');
style.id = 'monster-animations-css';
style.textContent = css;
document.head.appendChild(style);
console.log('Monster animation CSS generated');
}
// Play a monster animation on an element
function playMonsterAnimation(element, animationId) {
if (!element) return;
const anim = typeof MONSTER_ANIMATIONS !== 'undefined' ? MONSTER_ANIMATIONS[animationId] : null;
if (!anim) {
// Fallback to default attack animation
element.style.animation = 'none';
element.offsetHeight; // Force reflow
element.style.animation = 'monsterAttack 0.5s ease-out';
return;
}
const loopStr = anim.loop ? ' infinite' : '';
const fillStr = anim.fillMode ? ` ${anim.fillMode}` : '';
const easing = anim.easing || 'ease-out';
element.style.animation = 'none';
element.offsetHeight; // Force reflow
element.style.animation = `monster_${animationId} ${anim.duration}ms ${easing}${loopStr}${fillStr}`;
}
// Generate animation CSS on load
generateAnimationCSS();
// Store all tracks
const tracks = [];
let selectedTracks = []; // Now supports multiple selection
@ -4588,7 +4673,8 @@
hitCount: skill.hitCount || 1,
statusEffect: skill.statusEffect,
type: skill.type,
mpCost: skill.mpCost || 0
mpCost: skill.mpCost || 0,
animation: skill.animation || null
};
}
}
@ -4603,7 +4689,8 @@
hitCount: lastSkill.hitCount || 1,
statusEffect: lastSkill.statusEffect,
type: lastSkill.type,
mpCost: lastSkill.mpCost || 0
mpCost: lastSkill.mpCost || 0,
animation: lastSkill.animation || null
};
}
@ -4621,11 +4708,25 @@
let statsLoadedFromServer = false; // Flag to prevent saving until server data is loaded
let monsterEntourage = []; // Array of spawned monsters following player
let combatState = null; // Active combat state or null
let monsterActiveAnimations = {}; // Track active animations per monster index {index: {animId, startTime, duration}}
let monsterSpawnTimer = null; // Interval for spawning monsters
let monsterUpdateTimer = null; // Interval for updating monster positions/dialogue
let homeRegenTimer = null; // Interval for passive home base regen
let lastSpawnLocation = null; // Track player location at last spawn (for movement-based spawning)
// Stats synchronization engine - prevents save spam during rapid state changes
let statsSyncState = {
dirty: false, // True if local changes need saving
saveInFlight: false, // True if a save request is in progress
pendingSave: false, // True if another save was requested while one is in flight
lastSaveAttempt: 0, // Timestamp of last save attempt
consecutiveFailures: 0, // Track repeated failures
inCombat: false // Suppress non-critical errors during combat
};
const SYNC_DEBOUNCE_MS = 500; // Wait 500ms after last change before saving
const SYNC_MIN_INTERVAL_MS = 1000; // Never save more than once per second
let syncDebounceTimer = null;
// Spawn settings (loaded from server, with defaults)
let spawnSettings = {
spawnInterval: 20000, // Timer interval in ms
@ -4980,6 +5081,8 @@
// GPS functions
function toggleGPS() {
const btn = document.getElementById('gpsBtn');
const compassBtn = document.getElementById('compassBtn');
const wasdControls = document.getElementById('wasdControls');
if (gpsWatchId !== null) {
// Stop tracking
@ -5006,8 +5109,14 @@
}
gpsFirstFix = true;
btn.textContent = 'Show My Location';
btn.classList.remove('active');
if (btn) {
btn.textContent = 'Show My Location';
btn.classList.remove('active');
}
// Update compass button
if (compassBtn) compassBtn.classList.remove('active');
// Show WASD controls when GPS is off (for all users)
if (wasdControls) wasdControls.classList.remove('hidden');
updateStatus('GPS tracking stopped', 'info');
} else {
// Start tracking
@ -5025,7 +5134,10 @@
if (toggle) toggle.checked = false;
}
btn.textContent = 'Locating...';
if (btn) btn.textContent = 'Locating...';
// Update compass button and hide WASD
if (compassBtn) compassBtn.classList.add('active');
if (wasdControls) wasdControls.classList.add('hidden');
updateStatus('Requesting GPS location...', 'info');
console.log('Starting GPS tracking...');
@ -5227,6 +5339,12 @@
if (gpsFirstFix) {
btn.textContent = 'Show My Location';
btn.classList.remove('active');
// Also reset compass button
const compassBtn = document.getElementById('compassBtn');
if (compassBtn) compassBtn.classList.remove('active');
// Show WASD for all users
const wasd = document.getElementById('wasdControls');
if (wasd) wasd.classList.remove('hidden');
if (gpsWatchId !== null) {
navigator.geolocation.clearWatch(gpsWatchId);
gpsWatchId = null;
@ -9891,6 +10009,12 @@
gpsBtn.addEventListener('click', toggleGPS);
}
// Compass button - same as GPS button
const compassBtn = document.getElementById('compassBtn');
if (compassBtn) {
compassBtn.addEventListener('click', toggleGPS);
}
const el_rotateMapBtn = document.getElementById('rotateMapBtn');
if (el_rotateMapBtn) {
el_rotateMapBtn.addEventListener('click', toggleRotateMap);
@ -10508,6 +10632,7 @@
const userAvatar = document.getElementById('userAvatar');
const editTab = document.getElementById('editTab');
const adminTab = document.getElementById('adminTab');
const panelToggle = document.getElementById('panelToggle');
if (currentUser) {
profileSection.style.display = 'block';
@ -10517,10 +10642,17 @@
userFinds.textContent = currentUser.finds_count || 0;
userAvatar.innerHTML = `< i class = "mdi mdi-${currentUser.avatar_icon || 'account'}" style = "color: ${currentUser.avatar_color || '#fff'}" > < / i > `;
// Show Edit/Admin tabs only for admins
// Show Edit/Admin tabs and hamburger only for admins
if (currentUser.is_admin) {
editTab.style.display = '';
adminTab.style.display = '';
if (panelToggle) panelToggle.style.display = '';
// Show WASD controls for admins (if GPS not active)
const wasdControls = document.getElementById('wasdControls');
if (wasdControls & & gpsWatchId === null) {
wasdControls.classList.remove('hidden');
}
// Auto-enable GPS Test Mode (WASD) for admins
const gpsTestToggle = document.getElementById('gpsTestModeToggle');
@ -10531,12 +10663,24 @@
} else {
editTab.style.display = 'none';
adminTab.style.display = 'none';
if (panelToggle) panelToggle.style.display = 'none';
// Show WASD controls for all users (if GPS not active)
const wasdControls = document.getElementById('wasdControls');
if (wasdControls & & gpsWatchId === null) {
wasdControls.classList.remove('hidden');
}
}
} else {
profileSection.style.display = 'none';
loginPrompt.style.display = 'block';
editTab.style.display = 'none';
adminTab.style.display = 'none';
if (panelToggle) panelToggle.style.display = 'none';
// Show WASD controls for all users (if GPS not active)
const wasdControls = document.getElementById('wasdControls');
if (wasdControls & & gpsWatchId === null) {
wasdControls.classList.remove('hidden');
}
}
}
@ -11954,79 +12098,145 @@
}
}
// Save player stats to server (and localStorage as backup)
function savePlayerStats() {
if (!playerStats) return;
// ========== STATS SYNC ENGINE ==========
// Replaces direct saves with debounced, rate-limited sync to prevent version conflicts
// Don't save until we've loaded from server to prevent overwriting good data
if (!statsLoadedFromServer) {
console.warn('Skipping save - waiting for server data to load first');
return;
// Mark stats as needing sync (call this instead of immediate save)
function markStatsDirty() {
statsSyncState.dirty = true;
// Debounce: wait for rapid changes to settle before saving
if (syncDebounceTimer) {
clearTimeout(syncDebounceTimer);
}
syncDebounceTimer = setTimeout(() => {
flushStatsSync();
}, SYNC_DEBOUNCE_MS);
}
// Save to localStorage as backup
// Force an immediate sync (for critical moments like page unload)
function flushStatsSync() {
if (syncDebounceTimer) {
clearTimeout(syncDebounceTimer);
syncDebounceTimer = null;
}
if (!statsSyncState.dirty) return;
if (!playerStats || !statsLoadedFromServer) return;
// Always save to localStorage immediately as backup
localStorage.setItem('hikemap_rpg_stats', JSON.stringify(playerStats));
// Save to server
// If a save is already in flight, just mark that we need another
if (statsSyncState.saveInFlight) {
statsSyncState.pendingSave = true;
return;
}
// Rate limiting: don't spam the server
const now = Date.now();
const timeSinceLastSave = now - statsSyncState.lastSaveAttempt;
if (timeSinceLastSave < SYNC_MIN_INTERVAL_MS ) {
// Schedule for later
setTimeout(flushStatsSync, SYNC_MIN_INTERVAL_MS - timeSinceLastSave);
return;
}
// Execute the save
executeStatsSave();
}
// Internal: actually perform the HTTP save
async function executeStatsSave() {
const token = localStorage.getItem('accessToken');
if (token) {
fetch('/api/user/rpg-stats', {
if (!token) {
console.warn('No access token - stats only saved to localStorage');
statsSyncState.dirty = false;
return;
}
statsSyncState.saveInFlight = true;
statsSyncState.lastSaveAttempt = Date.now();
statsSyncState.dirty = false; // Clear dirty flag before save
try {
const response = await fetch('/api/user/rpg-stats', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(playerStats)
})
.then(async response => {
if (response.ok) {
const data = await response.json();
// Update local version after successful save
if (data.dataVersion) {
playerStats.dataVersion = data.dataVersion;
console.log('Stats saved, version now:', data.dataVersion);
}
} else if (response.status === 409) {
// Data conflict - our data is stale
// Instead of reloading, just fetch fresh stats from server and sync version
const error = await response.json();
console.warn('Data conflict - syncing version from server...', error);
});
// Fetch fresh stats from server (includes correct version)
try {
const freshResponse = await fetch('/api/user/rpg-stats', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (freshResponse.ok) {
const freshStats = await freshResponse.json();
if (freshStats & & freshStats.dataVersion) {
// Update our version to match server
const oldVersion = playerStats.dataVersion;
playerStats.dataVersion = freshStats.dataVersion;
console.log(`Version synced: ${oldVersion} -> ${freshStats.dataVersion}`);
// Note: We keep local HP/MP/XP changes, just fix the version
// Next save will succeed with correct version
}
}
} catch (syncErr) {
console.error('Failed to sync version from server:', syncErr);
showNotification('Sync error - try refreshing', 'error');
}
} else {
console.error('Server rejected stats save:', response.status);
response.json().then(err => console.error('Server error:', err));
showNotification('⚠️ Failed to save progress', 'error');
if (response.ok) {
const data = await response.json();
// Update version in live playerStats
if (data.dataVersion) {
playerStats.dataVersion = data.dataVersion;
console.log('Stats saved, version now:', data.dataVersion);
}
})
.catch(err => {
console.error('Failed to save RPG stats to server:', err);
showNotification('⚠️ Failed to save progress', 'error');
statsSyncState.consecutiveFailures = 0;
} else if (response.status === 409) {
// Version conflict - sync version from server silently
console.log('Version conflict - syncing silently...');
await handleVersionConflict(token);
// Mark dirty again so we retry with correct version
statsSyncState.dirty = true;
} else {
throw new Error(`Server rejected save: ${response.status}`);
}
} catch (err) {
console.error('Failed to save RPG stats to server:', err);
statsSyncState.consecutiveFailures++;
statsSyncState.dirty = true; // Retry later
// Only show error to user for persistent network failures, not during combat
if (!statsSyncState.inCombat & & statsSyncState.consecutiveFailures >= 3) {
showNotification('Connection issue - progress will sync when restored', 'warning');
statsSyncState.consecutiveFailures = 0; // Reset to avoid spam
}
} finally {
statsSyncState.saveInFlight = false;
// If another save was requested while this one was in flight, do it now
if (statsSyncState.pendingSave || statsSyncState.dirty) {
statsSyncState.pendingSave = false;
setTimeout(flushStatsSync, 100); // Small delay to prevent tight loops
}
}
}
// Handle 409 version conflicts silently
async function handleVersionConflict(token) {
try {
const response = await fetch('/api/user/rpg-stats', {
headers: { 'Authorization': `Bearer ${token}` }
});
} else {
console.warn('No access token - stats only saved to localStorage');
if (response.ok) {
const serverStats = await response.json();
if (serverStats & & serverStats.dataVersion) {
const oldVersion = playerStats.dataVersion;
playerStats.dataVersion = serverStats.dataVersion;
console.log(`Version synced silently: ${oldVersion} -> ${serverStats.dataVersion}`);
}
}
} catch (err) {
console.error('Failed to sync version from server:', err);
// Don't show error - will retry on next save
}
}
// Backward compatible alias - all existing call sites continue to work
function savePlayerStats() {
markStatsDirty();
}
// Update the RPG HUD display
function updateRpgHud() {
if (!playerStats) return;
@ -13065,8 +13275,9 @@
function startAutoSave() {
if (autoSaveTimer) clearInterval(autoSaveTimer);
autoSaveTimer = setInterval(() => {
if (playerStats) {
savePlayerStats();
// Only flush if there are pending changes (dirty flag set)
if (playerStats & & statsSyncState.dirty) {
flushStatsSync();
}
}, 30000); // Every 30 seconds
}
@ -13075,6 +13286,9 @@
window.addEventListener('beforeunload', () => {
// Only save if we've loaded from server to prevent overwriting good data
if (playerStats & & statsLoadedFromServer) {
// Flush sync engine first (saves to localStorage)
flushStatsSync();
// Use sendBeacon for reliable save on page close
const token = localStorage.getItem('accessToken');
if (token) {
@ -13089,6 +13303,9 @@
// Also save on pagehide (more reliable on mobile)
window.addEventListener('pagehide', () => {
if (playerStats & & statsLoadedFromServer) {
// Flush sync engine first (saves to localStorage)
flushStatsSync();
const token = localStorage.getItem('accessToken');
if (token) {
navigator.sendBeacon('/api/user/rpg-stats-beacon', new Blob([JSON.stringify({
@ -13183,10 +13400,20 @@
}
}
// Pick a random monster type from available types
const typeIds = Object.keys(MONSTER_TYPES);
const typeId = typeIds[Math.floor(Math.random() * typeIds.length)];
const monsterType = MONSTER_TYPES[typeId];
// Pick a random monster type that the player can encounter at their level
// Only include monsters whose minLevel < = player level
const playerLevel = playerStats.level;
const eligibleTypes = Object.entries(MONSTER_TYPES).filter(([id, type]) => {
const minLevel = type.minLevel || 1;
return minLevel < = playerLevel;
});
if (eligibleTypes.length === 0) {
console.log('No eligible monster types for player level', playerLevel);
return;
}
const [typeId, monsterType] = eligibleTypes[Math.floor(Math.random() * eligibleTypes.length)];
// Random offset 30-60 meters from player
const angle = Math.random() * 2 * Math.PI;
@ -13199,11 +13426,15 @@
const offsetLat = (distance * Math.cos(angle)) / metersPerDegLat;
const offsetLng = (distance * Math.sin(angle)) / metersPerDegLng;
// Calculate monster level based on player level, but respect monster type's min/max level
const baseLevel = Math.max(1, playerStats.level + Math.floor(Math.random() * 3) - 1);
// Calculate monster level:
// - Base is player level with slight variation (-1 to +1)
// - Must be at least the monster's minLevel
// - NEVER exceeds player level (monsters can't be higher level than player)
const minLevel = monsterType.minLevel || 1;
const maxLevel = monsterType.maxLevel || 99;
const monsterLevel = Math.max(minLevel, Math.min(maxLevel, baseLevel));
const baseLevel = Math.max(1, playerLevel + Math.floor(Math.random() * 3) - 1);
// Clamp: at least minLevel, at most the lesser of maxLevel or playerLevel
const monsterLevel = Math.max(minLevel, Math.min(baseLevel, Math.min(maxLevel, playerLevel)));
const monster = {
id: `monster_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
@ -13221,9 +13452,22 @@
atk: monsterType.baseAtk + (monsterLevel - 1) * monsterType.levelScale.atk,
def: monsterType.baseDef + (monsterLevel - 1) * monsterType.levelScale.def,
marker: null,
lastDialogueTime: 0
lastDialogueTime: 0,
namePrefix: '' // Will be set below based on location
};
// Check if spawning near a special location (e.g., grocery store)
const spawnPos = L.latLng(monster.position.lat, monster.position.lng);
for (const cache of geocaches) {
if (cache.icon === 'cart') { // Grocery stores use cart icon
const dist = spawnPos.distanceTo(L.latLng(cache.lat, cache.lng));
if (dist < = 400) {
monster.namePrefix = "Cart Wranglin' ";
break;
}
}
}
createMonsterMarker(monster);
monsterEntourage.push(monster);
updateRpgHud();
@ -13238,10 +13482,12 @@
// Create a Leaflet marker for a monster
function createMonsterMarker(monster) {
const monsterType = MONSTER_TYPES[monster.type];
// Get idle animation for this monster type (party monsters only)
const idleAnim = monsterType?.idleAnimation || 'idle';
const iconHtml = `
< div class = "monster-marker" data-monster-id = "${monster.id}" >
< img class = "monster-icon" src = "/mapgameimgs/monsters/${monster.type}50.png"
< img class = "monster-icon anim-${idleAnim} " src = "/mapgameimgs/monsters/${monster.type}50.png"
onerror="this.src='/mapgameimgs/monsters/default50.png'" alt="${monsterType.name}">
< div class = "monster-dialogue-bubble" style = "display: none;" > < / div >
< / div >
@ -13365,6 +13611,9 @@
if (monsterEntourage.length === 0) return;
if (playerStats.isDead) return; // Can't fight when dead
// Mark sync engine as in combat (suppresses non-critical save errors)
statsSyncState.inCombat = true;
// Load skills for each unique monster type
const uniqueTypes = [...new Set(monsterEntourage.map(m => m.type))];
await Promise.all(uniqueTypes.map(type => loadMonsterSkills(type)));
@ -13388,7 +13637,8 @@
def: m.def,
accuracy: monsterType?.accuracy || 85,
dodge: monsterType?.dodge || 5,
data: monsterType
data: monsterType,
namePrefix: m.namePrefix || '' // Location-based name prefix
};
});
@ -13555,8 +13805,9 @@
}
}
// Scroll to the attacking monster and trigger rubber band animation
function animateMonsterAttack(monsterIndex) {
// Scroll to the attacking monster and trigger animation
// animationType can be: 'attack', 'skill', 'miss', 'death', or a custom animation ID
function animateMonsterAttack(monsterIndex, animationType = 'attack') {
try {
const container = document.getElementById('monsterList');
if (!container) return;
@ -13568,13 +13819,47 @@
// Scroll the monster into view smoothly
entry.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
// Force restart the rubber band animation on the icon
const icon = entry.querySelector('.monster-entry-icon');
if (icon) {
icon.style.animation = 'none';
icon.offsetHeight; // Trigger reflow
icon.style.animation = 'monsterAttack 0.5s ease-out';
// Get the monster's animation override if available
const monster = combatState.monsters[monsterIndex];
let actualAnimation = animationType;
if (monster & & MONSTER_TYPES[monster.type]) {
const monsterType = MONSTER_TYPES[monster.type];
// Check for animation override based on type
if (animationType === 'attack' & & monsterType.attackAnimation) {
actualAnimation = monsterType.attackAnimation;
} else if (animationType === 'death' & & monsterType.deathAnimation) {
actualAnimation = monsterType.deathAnimation;
}
}
// Wait for scroll to complete (~300ms) plus 300ms pause, then play animation
// Re-query DOM inside timeout since updateCombatUI may have re-rendered the list
setTimeout(() => {
const currentContainer = document.getElementById('monsterList');
if (!currentContainer) return;
const currentEntries = currentContainer.querySelectorAll('.monster-entry');
const currentEntry = currentEntries[monsterIndex];
if (!currentEntry) return;
const icon = currentEntry.querySelector('.monster-entry-icon');
if (icon) {
// Track this animation so it survives re-renders
const anim = typeof MONSTER_ANIMATIONS !== 'undefined' ? MONSTER_ANIMATIONS[actualAnimation] : null;
if (anim) {
monsterActiveAnimations[monsterIndex] = {
animId: actualAnimation,
startTime: Date.now(),
duration: anim.duration
};
// Clear tracking after animation completes
setTimeout(() => {
delete monsterActiveAnimations[monsterIndex];
}, anim.duration + 100);
}
playMonsterAnimation(icon, actualAnimation);
}
}, 600);
} catch (e) {
console.error('Animation error:', e);
}
@ -13611,15 +13896,45 @@
// Generate status overlay HTML for monster
const monsterOverlayHtml = getMonsterStatusOverlayHtml(monster);
// Determine animation style - check for active animations or death state
let animStyle = '';
if (monster.hp < = 0) {
// Dead monster - apply death animation
const monsterType = monster.data;
const deathAnimId = monsterType?.deathAnimation || 'death';
const deathAnim = typeof MONSTER_ANIMATIONS !== 'undefined' ? MONSTER_ANIMATIONS[deathAnimId] : null;
if (deathAnim) {
const fillStr = deathAnim.fillMode ? ` ${deathAnim.fillMode}` : ' forwards';
const easing = deathAnim.easing || 'ease-out';
animStyle = `animation: monster_${deathAnimId} ${deathAnim.duration}ms ${easing}${fillStr};`;
}
} else if (monsterActiveAnimations[index]) {
// Living monster with active animation - preserve it
const activeAnim = monsterActiveAnimations[index];
const elapsed = Date.now() - activeAnim.startTime;
if (elapsed < activeAnim.duration ) {
const anim = MONSTER_ANIMATIONS[activeAnim.animId];
if (anim) {
const remaining = activeAnim.duration - elapsed;
const easing = anim.easing || 'ease-out';
const loopStr = anim.loop ? ' infinite' : '';
const fillStr = anim.fillMode ? ` ${anim.fillMode}` : '';
// Use remaining time to continue animation from current point
animStyle = `animation: monster_${activeAnim.animId} ${anim.duration}ms ${easing}${loopStr}${fillStr}; animation-delay: -${elapsed}ms;`;
}
}
}
entry.innerHTML = `
< div class = "monster-entry-header" >
${index === combatState.selectedTargetIndex ? '< span class = "target-arrow" > ▶< / span > ' : ''}
< div class = "sprite-container" >
< img class = "monster-entry-icon" src = "/mapgameimgs/monsters/${monster.type}100.png"
onerror="this.src='/mapgameimgs/monsters/default100.png'" alt="${monster.data.name}">
style="${animStyle}"
onerror="this.src='/mapgameimgs/monsters/default100.png'" alt="${monster.namePrefix || ''}${monster.data.name}">
< div class = "status-overlay" > ${monsterOverlayHtml}< / div >
< / div >
< span class = "monster-entry-name" > ${monster.data.name} Lv.${monster.level}< / span >
< span class = "monster-entry-name" > ${monster.namePrefix || ''}${monster. data.name} Lv.${monster.level}< / span >
< / div >
< div class = "monster-entry-hp" >
< div class = "hp-bar" > < div class = "hp-fill" style = "width: ${hpPct}%;" > < / div > < / div >
@ -13664,7 +13979,7 @@
// Normal target selection (not in targeting mode)
combatState.selectedTargetIndex = index;
renderMonsterList();
addCombatLog(`Targeting ${combatState.monsters[index].data.name}!`);
addCombatLog(`Targeting ${combatState.monsters[index].namePrefix || ''}${combatState.monsters[index]. data.name}!`);
}
// Cancel multi-hit targeting mode
@ -13757,6 +14072,8 @@
// Check if this killed the monster
if (currentTarget.hp < = 0) {
monstersKilled++;
// Play death animation
animateMonsterAttack(targetIndex, 'death');
playSfx('monster_death');
// Award XP immediately
const xpReward = (currentTarget.data?.xpReward || 10) * currentTarget.level;
@ -14259,15 +14576,12 @@
combatState.currentMonsterTurn = monsterIndex;
updateCombatUI();
// Scroll to and animate the attacking monster (after DOM update)
setTimeout(() => animateMonsterAttack(monsterIndex), 50);
// Decrement monster buff durations at start of its turn
if (monster.buffs) {
if (monster.buffs.defense & & monster.buffs.defense.turnsLeft > 0) {
monster.buffs.defense.turnsLeft--;
if (monster.buffs.defense.turnsLeft < = 0) {
addCombatLog(`${monster.data.name}'s defense buff wore off.`, 'info');
addCombatLog(`${monster.namePrefix || ''}${monster. data.name}'s defense buff wore off.`, 'info');
}
}
}
@ -14277,6 +14591,9 @@
const selectedSkill = selectMonsterSkill(monster.type, monster.level, monster.mp || 0);
console.log('[DEBUG] Selected skill:', selectedSkill?.id, selectedSkill?.name, 'type:', selectedSkill?.type, 'mpCost:', selectedSkill?.mpCost);
// Determine animation to use - check for skill-specific animation first
const skillAnimation = selectedSkill?.animation || (selectedSkill?.id === 'basic_attack' ? 'attack' : 'skill');
// Deduct MP cost from monster
const skillMpCost = selectedSkill?.mpCost || 0;
if (skillMpCost > 0 & & monster.mp !== undefined) {
@ -14296,13 +14613,18 @@
// Roll for hit
if (!rollHit(hitChance)) {
addCombatLog(`❌ ${monster.data.name}'s ${selectedSkill.name} missed! (${hitChance}% chance)`, 'miss');
playSfx('missed');
// Play miss animation (attack followed by stumble)
setTimeout(() => animateMonsterAttack(monsterIndex, 'miss'), 50);
addCombatLog(`❌ ${monster.namePrefix || ''}${monster.data.name}'s ${selectedSkill.name} missed! (${hitChance}% chance)`, 'miss');
setTimeout(() => playSfx('missed'), 650); // Sync with animation timing
combatState.currentMonsterTurn++;
setTimeout(executeMonsterTurns, 800);
setTimeout(executeMonsterTurns, 1500); // Longer delay to allow miss animation
return;
}
// Scroll to and animate the attacking monster with skill animation
setTimeout(() => animateMonsterAttack(monsterIndex, skillAnimation), 50);
// Calculate effective defense (with buff if active)
let effectiveDef = combatState.player.def;
if (combatState.defenseBuffTurns > 0) {
@ -14318,11 +14640,11 @@
const duration = selectedSkill.statusEffect.duration || 2;
const percent = selectedSkill.statusEffect.percent || 50;
monster.buffs.defense = { turnsLeft: duration, percent: percent };
addCombatLog(`🛡️ ${monster.data.name} uses ${selectedSkill.name}! Defense increased by ${percent}%!`, 'buff');
addCombatLog(`🛡️ ${monster.namePrefix || ''}${monster. data.name} uses ${selectedSkill.name}! Defense increased by ${percent}%!`, 'buff');
} else {
// Generic buff
monster.buffs.generic = { turnsLeft: 2 };
addCombatLog(`✨ ${monster.data.name} uses ${selectedSkill.name}!`, 'buff');
addCombatLog(`✨ ${monster.namePrefix || ''}${monster. data.name} uses ${selectedSkill.name}!`, 'buff');
}
} else if (selectedSkill.type === 'heal') {
// Heal skill - monster heals itself
@ -14330,7 +14652,7 @@
const oldHp = monster.hp;
monster.hp = Math.min(monster.maxHp, monster.hp + healAmount);
const actualHeal = monster.hp - oldHp;
addCombatLog(`💚 ${monster.data.name} uses ${selectedSkill.name}! Restored ${actualHeal} HP!`, 'heal');
addCombatLog(`💚 ${monster.namePrefix || ''}${monster. data.name} uses ${selectedSkill.name}! Restored ${actualHeal} HP!`, 'heal');
} else if (selectedSkill.type === 'status') {
// Status effect skill (like poison)
const baseDamage = selectedSkill.basePower || 20;
@ -14348,11 +14670,11 @@
damage: effect.damage || 5,
turnsLeft: effect.duration || 3
});
addCombatLog(`🔥 ${monster.data.name} uses ${selectedSkill.name}! ${damage} damage + ${effect.type} applied!`, 'damage');
playSfx('monster_skill');
addCombatLog(`🔥 ${monster.namePrefix || ''}${monster. data.name} uses ${selectedSkill.name}! ${damage} damage + ${effect.type} applied!`, 'damage');
setTimeout(() => playSfx('monster_skill'), 650) ; // Sync with animation
} else {
addCombatLog(`🔥 ${monster.data.name} uses ${selectedSkill.name}! ${damage} damage! (Already ${effect.type}ed)`, 'damage');
playSfx('monster_skill');
addCombatLog(`🔥 ${monster.namePrefix || ''}${monster. data.name} uses ${selectedSkill.name}! ${damage} damage! (Already ${effect.type}ed)`, 'damage');
setTimeout(() => playSfx('monster_skill'), 650) ; // Sync with animation
}
}
} else {
@ -14376,14 +14698,14 @@
const isGenericAttack = (selectedSkill.id === 'basic_attack' & & selectedSkill.name === 'Attack');
if (isGenericAttack) {
addCombatLog(`⚔️ ${monster.data.name} attacks! You take ${totalDamage} damage!`, 'damage');
playSfx('monster_attack');
addCombatLog(`⚔️ ${monster.namePrefix || ''}${monster. data.name} attacks! You take ${totalDamage} damage!`, 'damage');
setTimeout(() => playSfx('monster_attack'), 650) ; // Sync with animation
} else if (hitCount > 1) {
addCombatLog(`🔥 ${monster.data.name} uses ${selectedSkill.name}! ${hitCount} hits for ${totalDamage} total damage!`, 'damage');
playSfx('monster_skill');
addCombatLog(`🔥 ${monster.namePrefix || ''}${monster. data.name} uses ${selectedSkill.name}! ${hitCount} hits for ${totalDamage} total damage!`, 'damage');
setTimeout(() => playSfx('monster_skill'), 650) ; // Sync with animation
} else {
addCombatLog(`🔥 ${monster.data.name} uses ${selectedSkill.name}! You take ${totalDamage} damage!`, 'damage');
playSfx('monster_skill');
addCombatLog(`🔥 ${monster.namePrefix || ''}${monster. data.name} uses ${selectedSkill.name}! You take ${totalDamage} damage!`, 'damage');
setTimeout(() => playSfx('monster_skill'), 650) ; // Sync with animation
}
}
@ -14396,8 +14718,9 @@
}
// Move to next monster
// Wait for animation to complete: 650ms delay + up to 1000ms animation
combatState.currentMonsterTurn++;
setTimeout(executeMonsterTurns, 8 00);
setTimeout(executeMonsterTurns, 17 00);
}
// Handle combat victory
@ -14477,6 +14800,11 @@
console.log('[DEBUG] closeCombatUI called');
document.getElementById('combatOverlay').style.display = 'none';
combatState = null;
monsterActiveAnimations = {}; // Clear animation tracking
// Combat ended - clear sync engine combat flag and flush any pending saves
statsSyncState.inCombat = false;
flushStatsSync();
// If victory music isn't playing, switch to appropriate ambient music
if (gameMusic.currentTrack !== 'victory' || gameMusic.victory.paused) {