diff --git a/Dockerfile b/Dockerfile
index f0eab75..9bab8af 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -16,6 +16,7 @@ COPY server.js ./
COPY database.js ./
COPY index.html ./
COPY admin.html ./
+COPY animations.js ./
COPY manifest.json ./
COPY service-worker.js ./
diff --git a/admin.html b/admin.html
index 3f38dd1..7cd77be 100644
--- a/admin.html
+++ b/admin.html
@@ -563,6 +563,16 @@
text-align: center;
}
+ .monster-skill-item .skill-animation {
+ width: 100px;
+ padding: 4px;
+ font-size: 12px;
+ background: rgba(255,255,255,0.1);
+ border: 1px solid rgba(255,255,255,0.2);
+ border-radius: 4px;
+ color: inherit;
+ }
+
.monster-skill-item label {
font-size: 11px;
color: #888;
@@ -653,7 +663,34 @@
color: #aaa;
margin-bottom: 20px;
}
+
+ /* Animation preview styles */
+ .animation-preview-container {
+ background: #2a2a2a;
+ border-radius: 8px;
+ padding: 20px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 150px;
+ margin-bottom: 15px;
+ }
+ .animation-preview-icon {
+ width: 100px;
+ height: 100px;
+ object-fit: contain;
+ }
+ .animation-test-row {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ }
+ .animation-test-row select {
+ flex: 1;
+ }
+
+
@@ -1065,6 +1102,41 @@
+
@@ -1560,11 +1632,18 @@
container.innerHTML = '
No skills assigned. Monster will only use basic attack.
';
return;
}
+ // Build animation options once
+ const animations = typeof MONSTER_ANIMATIONS !== 'undefined' ? MONSTER_ANIMATIONS : {};
+ const animOptions = '
' + Object.entries(animations).map(([id, anim]) =>
+ `
`
+ ).join('');
+
container.innerHTML = currentMonsterSkills.map(ms => {
const skill = allSkills.find(s => s.id === ms.skill_id);
const baseName = skill ? skill.name : ms.skill_id;
const displayName = ms.custom_name || baseName;
const hasCustomName = !!ms.custom_name;
+ const currentAnim = ms.animation || '';
return `
@@ -1584,6 +1663,12 @@
+
+
+
+
`;
@@ -1677,6 +1762,20 @@
}
}
+ async function updateMonsterSkillAnimation(id, value) {
+ try {
+ await api(`/api/admin/monster-skills/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify({ animation: value || null })
+ });
+ // Update local state
+ const ms = currentMonsterSkills.find(s => s.id === id);
+ if (ms) ms.animation = value || null;
+ } catch (e) {
+ showToast('Failed to update skill animation: ' + e.message, 'error');
+ }
+ }
+
async function removeMonsterSkill(id) {
try {
await api(`/api/admin/monster-skills/${id}`, { method: 'DELETE' });
@@ -1780,6 +1879,16 @@
document.getElementById('dialoguePhilosophical').value = (dialogues.philosophical || []).join('\n');
document.getElementById('dialogueExistential').value = (dialogues.existential || []).join('\n');
+ // Set animation overrides
+ populateAnimationDropdowns();
+ document.getElementById('monsterAttackAnim').value = monster.attack_animation || 'attack';
+ document.getElementById('monsterDeathAnim').value = monster.death_animation || 'death';
+ document.getElementById('monsterIdleAnim').value = monster.idle_animation || 'idle';
+
+ // Update preview icon
+ document.getElementById('animPreviewIcon').src = `/mapgameimgs/monsters/${monster.key}100.png`;
+ document.getElementById('animPreviewIcon').onerror = function() { this.src = '/mapgameimgs/monsters/default100.png'; };
+
// Load monster skills
await loadMonsterSkills(monster.id);
@@ -1822,6 +1931,13 @@
document.getElementById('dialoguePhilosophical').value = (dialogues.philosophical || []).join('\n');
document.getElementById('dialogueExistential').value = (dialogues.existential || []).join('\n');
+ // Copy animation settings
+ populateAnimationDropdowns();
+ document.getElementById('monsterAttackAnim').value = monster.attack_animation || 'attack';
+ document.getElementById('monsterDeathAnim').value = monster.death_animation || 'death';
+ document.getElementById('monsterIdleAnim').value = monster.idle_animation || 'idle';
+ document.getElementById('animPreviewIcon').src = '/mapgameimgs/monsters/default100.png';
+
// Clear skills (cloned monster needs to be saved first)
currentMonsterSkills = [];
document.getElementById('monsterSkillsList').innerHTML = '
Save monster first, then edit to add skills.
';
@@ -1834,6 +1950,12 @@
document.getElementById('monsterForm').reset();
document.getElementById('monsterId').value = '';
document.getElementById('monsterEnabled').checked = true;
+ // Set default animations
+ populateAnimationDropdowns();
+ document.getElementById('monsterAttackAnim').value = 'attack';
+ document.getElementById('monsterDeathAnim').value = 'death';
+ document.getElementById('monsterIdleAnim').value = 'idle';
+ document.getElementById('animPreviewIcon').src = '/mapgameimgs/monsters/default100.png';
// Clear skills (new monster needs to be saved first)
currentMonsterSkills = [];
document.getElementById('monsterSkillsList').innerHTML = '
Save monster first, then edit to add skills.
';
@@ -1844,6 +1966,68 @@
document.getElementById('monsterModal').classList.remove('active');
}
+ // Populate animation dropdowns from MONSTER_ANIMATIONS
+ function populateAnimationDropdowns() {
+ const animations = typeof MONSTER_ANIMATIONS !== 'undefined' ? MONSTER_ANIMATIONS : {};
+ const animIds = Object.keys(animations);
+
+ const dropdowns = ['monsterAttackAnim', 'monsterDeathAnim', 'monsterIdleAnim', 'testAnimationSelect'];
+
+ dropdowns.forEach(dropdownId => {
+ const dropdown = document.getElementById(dropdownId);
+ if (!dropdown) return;
+
+ dropdown.innerHTML = animIds.map(id => {
+ const anim = animations[id];
+ return `
`;
+ }).join('');
+ });
+ }
+
+ // Test animation preview
+ function testMonsterAnimation() {
+ const animId = document.getElementById('testAnimationSelect').value;
+ const previewIcon = document.getElementById('animPreviewIcon');
+
+ if (!previewIcon || !animId) return;
+
+ const anim = typeof MONSTER_ANIMATIONS !== 'undefined' ? MONSTER_ANIMATIONS[animId] : null;
+ if (!anim) {
+ showToast('Animation not found', 'error');
+ return;
+ }
+
+ // Reset animation
+ previewIcon.style.animation = 'none';
+ previewIcon.offsetHeight; // Force reflow
+
+ // Apply animation
+ const loopStr = anim.loop ? ' infinite' : '';
+ const fillStr = anim.fillMode ? ` ${anim.fillMode}` : '';
+ const easing = anim.easing || 'ease-out';
+ previewIcon.style.animation = `monster_${animId} ${anim.duration}ms ${easing}${loopStr}${fillStr}`;
+ }
+
+ // Generate animation CSS for preview
+ function generateAdminAnimationCSS() {
+ if (typeof MONSTER_ANIMATIONS === 'undefined') 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`;
+ }
+ const style = document.createElement('style');
+ style.id = 'monster-animations-css';
+ style.textContent = css;
+ document.head.appendChild(style);
+ }
+
+ // Initialize animation CSS on page load
+ document.addEventListener('DOMContentLoaded', generateAdminAnimationCSS);
+
document.getElementById('monsterForm').addEventListener('submit', async (e) => {
e.preventDefault();
@@ -1869,6 +2053,9 @@
spawn_weight: parseInt(document.getElementById('monsterWeight').value),
levelScale: { mp: parseInt(document.getElementById('monsterMpScale').value) || 5 },
enabled: document.getElementById('monsterEnabled').checked,
+ attack_animation: document.getElementById('monsterAttackAnim').value,
+ death_animation: document.getElementById('monsterDeathAnim').value,
+ idle_animation: document.getElementById('monsterIdleAnim').value,
dialogues: JSON.stringify(dialogues)
};
diff --git a/animations.js b/animations.js
new file mode 100644
index 0000000..b805f13
--- /dev/null
+++ b/animations.js
@@ -0,0 +1,147 @@
+// HikeMap Monster Animation Definitions
+// This file defines all available animations for monster icons
+// Edit the keyframes to customize how animations look
+
+const MONSTER_ANIMATIONS = {
+ // Default attack animation - rubber band snap towards player
+ attack: {
+ name: 'Attack',
+ description: 'Rubber band snap towards player',
+ duration: 500,
+ loop: false,
+ easing: 'ease-out',
+ keyframes: `
+ 0% { transform: translateX(0); }
+ 20% { transform: translateX(20px) scale(0.9); }
+ 50% { transform: translateX(-30px) scale(1.15); }
+ 70% { transform: translateX(-5px) scale(1.05); }
+ 100% { transform: translateX(0) scale(1); }
+ `
+ },
+
+ // Default skill animation - quick shake back and forth
+ skill: {
+ name: 'Skill',
+ description: 'Quick shake back and forth',
+ duration: 400,
+ loop: false,
+ easing: 'ease-in-out',
+ keyframes: `
+ 0%, 100% { transform: translateX(0); }
+ 10%, 30%, 50%, 70%, 90% { transform: translateX(-8px); }
+ 20%, 40%, 60%, 80% { transform: translateX(8px); }
+ `
+ },
+
+ // Default miss animation - attack motion then fall counter-clockwise, hold, recover
+ miss: {
+ name: 'Miss',
+ description: 'Attack then fall over counter-clockwise and recover',
+ duration: 2000,
+ loop: false,
+ easing: 'ease-out',
+ keyframes: `
+ 0% { transform: translateX(0) rotate(0deg); }
+ 10% { transform: translateX(20px) scale(0.9) rotate(0deg); }
+ 20% { transform: translateX(-30px) scale(1.15) rotate(0deg); }
+ 30% { transform: translateX(-15px) rotate(-90deg); }
+ 70% { transform: translateX(-15px) rotate(-90deg); }
+ 85% { transform: translateX(-5px) rotate(-30deg); }
+ 100% { transform: translateX(0) rotate(0deg); }
+ `
+ },
+
+ // Default death animation - fall over counter-clockwise permanently
+ death: {
+ name: 'Death',
+ description: 'Fall over permanently',
+ duration: 600,
+ loop: false,
+ easing: 'ease-out',
+ fillMode: 'forwards',
+ keyframes: `
+ 0% { transform: rotate(0deg); opacity: 1; }
+ 100% { transform: rotate(-90deg); opacity: 0.6; }
+ `
+ },
+
+ // Default idle animation - gentle dance/bob
+ idle: {
+ name: 'Idle',
+ description: 'Gentle dance/bob animation',
+ duration: 2000,
+ loop: true,
+ easing: 'ease-in-out',
+ keyframes: `
+ 0%, 100% { transform: rotate(-3deg) scale(1); }
+ 50% { transform: rotate(3deg) scale(0.95); }
+ `
+ },
+
+ // Flip Y animation - spin 360 degrees around vertical axis (like opening a door)
+ flipy: {
+ name: 'Flip Y',
+ description: 'Horizontal flip around vertical axis',
+ duration: 600,
+ loop: false,
+ easing: 'ease-in-out',
+ keyframes: `
+ 0% { transform: rotateY(0deg); }
+ 100% { transform: rotateY(360deg); }
+ `
+ },
+
+ // Flip XY animation - tumbling diagonal flip (somersault + spin)
+ flipxy: {
+ name: 'Flip XY',
+ description: 'Tumbling diagonal flip',
+ duration: 800,
+ loop: false,
+ easing: 'ease-in-out',
+ keyframes: `
+ 0% { transform: rotateX(0deg) rotateY(0deg); }
+ 100% { transform: rotateX(360deg) rotateY(360deg); }
+ `
+ },
+
+ // Flip Z animation - spin like a top viewed from above
+ flipz: {
+ name: 'Flip Z',
+ description: 'Spin like a top',
+ duration: 600,
+ loop: false,
+ easing: 'ease-in-out',
+ keyframes: `
+ 0% { transform: rotateZ(0deg); }
+ 100% { transform: rotateZ(360deg); }
+ `
+ },
+
+ // Shrink and grow animation
+ shrink_grow: {
+ name: 'Shrink & Grow',
+ description: 'Shrink to 50% then grow back',
+ duration: 1000,
+ loop: false,
+ easing: 'ease-in-out',
+ keyframes: `
+ 0%, 100% { transform: scale(1); }
+ 50% { transform: scale(0.5); }
+ `
+ }
+};
+
+// Helper function to get animation list for dropdowns
+function getAnimationList() {
+ return Object.entries(MONSTER_ANIMATIONS).map(([id, anim]) => ({
+ id,
+ name: anim.name,
+ description: anim.description
+ }));
+}
+
+// Export for use in browser
+if (typeof window !== 'undefined') {
+ window.MONSTER_ANIMATIONS = MONSTER_ANIMATIONS;
+ window.getAnimationList = getAnimationList;
+}
diff --git a/database.js b/database.js
index 21a3126..c5bbb68 100644
--- a/database.js
+++ b/database.js
@@ -280,6 +280,22 @@ class HikeMapDB {
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN home_base_icon TEXT DEFAULT '00'`);
} catch (e) { /* Column already exists */ }
+ // Migration: Add animation overrides to monster_types
+ try {
+ this.db.exec(`ALTER TABLE monster_types ADD COLUMN attack_animation TEXT DEFAULT 'attack'`);
+ } catch (e) { /* Column already exists */ }
+ try {
+ this.db.exec(`ALTER TABLE monster_types ADD COLUMN death_animation TEXT DEFAULT 'death'`);
+ } catch (e) { /* Column already exists */ }
+ try {
+ this.db.exec(`ALTER TABLE monster_types ADD COLUMN idle_animation TEXT DEFAULT 'idle'`);
+ } catch (e) { /* Column already exists */ }
+
+ // Migration: Add animation override to monster_skills
+ try {
+ this.db.exec(`ALTER TABLE monster_skills ADD COLUMN animation TEXT`);
+ } catch (e) { /* Column already exists */ }
+
// Game settings table - key/value store for game configuration
this.db.exec(`
CREATE TABLE IF NOT EXISTS game_settings (
@@ -852,8 +868,8 @@ class HikeMapDB {
const stmt = this.db.prepare(`
INSERT INTO monster_types (id, name, icon, base_hp, base_atk, base_def, xp_reward,
level_scale_hp, level_scale_atk, level_scale_def, min_level, max_level, spawn_weight, dialogues, enabled,
- base_mp, level_scale_mp)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ base_mp, level_scale_mp, attack_animation, death_animation, idle_animation)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
// Support both camelCase (legacy) and snake_case (new admin UI) field names
const baseHp = monsterData.baseHp || monsterData.base_hp;
@@ -875,6 +891,10 @@ class HikeMapDB {
const dialogues = typeof monsterData.dialogues === 'string'
? monsterData.dialogues
: JSON.stringify(monsterData.dialogues);
+ // Animation overrides
+ const attackAnim = monsterData.attack_animation || monsterData.attackAnimation || 'attack';
+ const deathAnim = monsterData.death_animation || monsterData.deathAnimation || 'death';
+ const idleAnim = monsterData.idle_animation || monsterData.idleAnimation || 'idle';
return stmt.run(
monsterData.id || monsterData.key,
@@ -893,7 +913,10 @@ class HikeMapDB {
dialogues,
monsterData.enabled !== false ? 1 : 0,
baseMp,
- levelScale.mp || 5
+ levelScale.mp || 5,
+ attackAnim,
+ deathAnim,
+ idleAnim
);
}
@@ -903,7 +926,7 @@ class HikeMapDB {
name = ?, icon = ?, base_hp = ?, base_atk = ?, base_def = ?,
xp_reward = ?, level_scale_hp = ?, level_scale_atk = ?, level_scale_def = ?,
min_level = ?, max_level = ?, spawn_weight = ?, dialogues = ?, enabled = ?,
- base_mp = ?, level_scale_mp = ?
+ base_mp = ?, level_scale_mp = ?, attack_animation = ?, death_animation = ?, idle_animation = ?
WHERE id = ?
`);
// Support both camelCase (legacy) and snake_case (new admin UI) field names
@@ -926,6 +949,10 @@ class HikeMapDB {
const dialogues = typeof monsterData.dialogues === 'string'
? monsterData.dialogues
: JSON.stringify(monsterData.dialogues);
+ // Animation overrides
+ const attackAnim = monsterData.attack_animation || monsterData.attackAnimation || 'attack';
+ const deathAnim = monsterData.death_animation || monsterData.deathAnimation || 'death';
+ const idleAnim = monsterData.idle_animation || monsterData.idleAnimation || 'idle';
return stmt.run(
monsterData.name,
@@ -944,6 +971,9 @@ class HikeMapDB {
monsterData.enabled !== false ? 1 : 0,
baseMp,
levelScale.mp || 5,
+ attackAnim,
+ deathAnim,
+ idleAnim,
id
);
}
@@ -1206,15 +1236,16 @@ class HikeMapDB {
createMonsterSkill(data) {
const stmt = this.db.prepare(`
- INSERT INTO monster_skills (monster_type_id, skill_id, weight, min_level, custom_name)
- VALUES (?, ?, ?, ?, ?)
+ INSERT INTO monster_skills (monster_type_id, skill_id, weight, min_level, custom_name, animation)
+ VALUES (?, ?, ?, ?, ?, ?)
`);
return stmt.run(
data.monsterTypeId || data.monster_type_id,
data.skillId || data.skill_id,
data.weight || 10,
data.minLevel || data.min_level || 1,
- data.customName || data.custom_name || null
+ data.customName || data.custom_name || null,
+ data.animation || null
);
}
@@ -1235,6 +1266,10 @@ class HikeMapDB {
updates.push('custom_name = ?');
values.push(data.custom_name || data.customName || null);
}
+ if (data.animation !== undefined) {
+ updates.push('animation = ?');
+ values.push(data.animation || null);
+ }
if (updates.length === 0) return;
diff --git a/geocaches.json b/geocaches.json
index f9b082f..e8c2c4d 100644
--- a/geocaches.json
+++ b/geocaches.json
@@ -1,28 +1,4 @@
[
- {
- "id": "gc_1767115055219_ge0toyjos",
- "lat": 30.5253513240288,
- "lng": -97.83657789230348,
- "messages": [
- {
- "author": "BortzMcgortz",
- "text": "Best not-really-a-dog park within 1/4 miles of my house.",
- "timestamp": 1767115098838
- },
- {
- "author": "DogDaddy",
- "text": "My dogs poop here a lot.",
- "timestamp": 1767115207491
- },
- {
- "author": "test",
- "text": "test",
- "timestamp": 1767200305966
- }
- ],
- "createdAt": 1767115055219,
- "alerted": true
- },
{
"id": "gc_1767140463979_69hvt9x5v",
"lat": 30.489440035930812,
@@ -67,8 +43,100 @@
"author": "Riker",
"text": "wuz hurr!",
"timestamp": 1767206476888
+ },
+ {
+ "author": "asd",
+ "text": "asd",
+ "timestamp": 1767327460051
+ }
+ ],
+ "createdAt": 1767206407844,
+ "alerted": false
+ },
+ {
+ "id": "gc_1767292681001_b2jcawv9y",
+ "lat": 30.52226911225213,
+ "lng": -97.82819598913194,
+ "title": "George's Lock box",
+ "icon": "pistol",
+ "color": "#ff2424",
+ "visibilityDistance": 40,
+ "messages": [
+ {
+ "author": "Georges evil twin.",
+ "text": "I am going to lick all of the raw meat.",
+ "timestamp": 1767292839859
+ }
+ ],
+ "createdAt": 1767292681001
+ },
+ {
+ "id": "gc_1767305353106_gkmumehh5",
+ "lat": 30.533063356593672,
+ "lng": -97.83526897430421,
+ "title": "Herptest",
+ "icon": "package-variant",
+ "color": "#fb00ff",
+ "visibilityDistance": 0,
+ "messages": [
+ {
+ "author": "test",
+ "text": "test",
+ "timestamp": 1767305371320
}
],
- "createdAt": 1767206407844
+ "createdAt": 1767305353106
+ },
+ {
+ "id": "gc_1767326938800_xync99v0u",
+ "lat": 30.52536114351277,
+ "lng": -97.83653430640699,
+ "title": "Da Secret Poop",
+ "icon": "emoticon-poop",
+ "color": "#8a5300",
+ "visibilityDistance": 0,
+ "messages": [
+ {
+ "author": "God",
+ "text": "My dogs like pooping here.",
+ "timestamp": 1767327329353
+ },
+ {
+ "author": "eat",
+ "text": "eat",
+ "timestamp": 1767415366750
+ },
+ {
+ "author": "melancholytron",
+ "text": "i am here",
+ "timestamp": 1767540398833
+ }
+ ],
+ "createdAt": 1767326938800,
+ "alerted": false
+ },
+ {
+ "id": "gc_grocery_walmart",
+ "lat": 30.5224542,
+ "lng": -97.8345161,
+ "title": "Walmart Supercenter",
+ "icon": "cart",
+ "color": "#0071ce",
+ "visibilityDistance": 50,
+ "messages": [],
+ "createdAt": 1736309000000,
+ "alerted": false
+ },
+ {
+ "id": "gc_grocery_heb",
+ "lat": 30.5222602,
+ "lng": -97.8283677,
+ "title": "H-E-B",
+ "icon": "cart",
+ "color": "#e31837",
+ "visibilityDistance": 50,
+ "messages": [],
+ "createdAt": 1736309000000,
+ "alerted": false
}
]
\ No newline at end of file
diff --git a/index.html b/index.html
index 72519d3..bf63c29 100644
--- a/index.html
+++ b/index.html
@@ -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 @@
}
+
+
@@ -3288,8 +3327,11 @@
-
-
+
+
+
+
+
TEST MODE
@@ -3382,7 +3424,7 @@
-
+
@@ -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 = `
`;
- // 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 = `
-
@@ -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 = `
@@ -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, 800);
+ setTimeout(executeMonsterTurns, 1700);
}
// 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) {
diff --git a/mapgameimgs/monsters/moop_george100.png b/mapgameimgs/monsters/moop_george100.png
new file mode 100755
index 0000000..ca472c8
Binary files /dev/null and b/mapgameimgs/monsters/moop_george100.png differ
diff --git a/mapgameimgs/monsters/moop_george50.png b/mapgameimgs/monsters/moop_george50.png
new file mode 100755
index 0000000..ac491b9
Binary files /dev/null and b/mapgameimgs/monsters/moop_george50.png differ
diff --git a/server.js b/server.js
index b58f3c0..c943c01 100644
--- a/server.js
+++ b/server.js
@@ -1309,7 +1309,11 @@ app.get('/api/monster-types', (req, res) => {
def: t.level_scale_def,
mp: t.level_scale_mp || 5
},
- dialogues: JSON.parse(t.dialogues)
+ dialogues: JSON.parse(t.dialogues),
+ // Animation overrides
+ attackAnimation: t.attack_animation || 'attack',
+ deathAnimation: t.death_animation || 'death',
+ idleAnimation: t.idle_animation || 'idle'
}));
res.json(formatted);
} catch (err) {
@@ -1381,6 +1385,7 @@ app.get('/api/monster-types/:id/skills', (req, res) => {
weight: s.weight,
minLevel: s.min_level,
customName: s.custom_name,
+ animation: s.animation || null, // Skill animation override
// Include skill details - use custom_name if set, otherwise base name
name: s.custom_name || s.name,
baseName: s.name,
@@ -1492,7 +1497,11 @@ app.get('/api/admin/monster-types', adminOnly, (req, res) => {
level_scale_mp: t.level_scale_mp || 5,
dialogues: t.dialogues,
enabled: !!t.enabled,
- created_at: t.created_at
+ created_at: t.created_at,
+ // Animation overrides
+ attack_animation: t.attack_animation || 'attack',
+ death_animation: t.death_animation || 'death',
+ idle_animation: t.idle_animation || 'idle'
}));
res.json({ monsterTypes: formatted });
} catch (err) {
@@ -1877,7 +1886,8 @@ app.get('/api/admin/monster-skills', adminOnly, (req, res) => {
skill_id: s.skill_id,
weight: s.weight,
min_level: s.min_level,
- custom_name: s.custom_name
+ custom_name: s.custom_name,
+ animation: s.animation || null
}));
res.json({ monsterSkills: formatted });
} catch (err) {
diff --git a/service-worker.js b/service-worker.js
index db477eb..a9e2f47 100644
--- a/service-worker.js
+++ b/service-worker.js
@@ -1,11 +1,12 @@
// HikeMap Service Worker
// Increment version to force cache refresh
-const CACHE_NAME = 'hikemap-v1.1.0';
+const CACHE_NAME = 'hikemap-v1.2.0';
const urlsToCache = [
'/',
'/index.html',
'/manifest.json',
'/default.kml',
+ '/animations.js',
'/icon-192x192.png',
'/icon-512x512.png',
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css',