Browse Source

Add stats sync engine, grocery geocaches, and cart wranglin' prefix

- Implement stats sync engine with debouncing/rate limiting to fix save spam
- Add Walmart and H-E-B grocery store geocaches
- Add "Cart Wranglin'" prefix for monsters spawning near grocery stores
- Fix monster spawn levels to never exceed player level
- Show WASD controls for all users when GPS is off
- Add George the Moop monster assets
- Add animations.js for monster animation definitions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
master
HikeMap User 4 weeks ago
parent
commit
9047f45d12
  1. 1
      Dockerfile
  2. 187
      admin.html
  3. 147
      animations.js
  4. 49
      database.js
  5. 118
      geocaches.json
  6. 514
      index.html
  7. BIN
      mapgameimgs/monsters/moop_george100.png
  8. BIN
      mapgameimgs/monsters/moop_george50.png
  9. 16
      server.js
  10. 3
      service-worker.js

1
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 ./

187
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;
}
</style>
<!-- Monster Animation Definitions -->
<script src="/animations.js"></script>
</head>
<body>
<!-- Login Screen (shown if not authenticated) -->
@ -1065,6 +1102,41 @@
</div>
</div>
<div class="animation-section">
<h4>Animation Overrides</h4>
<div class="form-row-3">
<div class="form-group">
<label>Attack Animation</label>
<select id="monsterAttackAnim">
<!-- Populated dynamically -->
</select>
</div>
<div class="form-group">
<label>Death Animation</label>
<select id="monsterDeathAnim">
<!-- Populated dynamically -->
</select>
</div>
<div class="form-group">
<label>Idle Animation</label>
<select id="monsterIdleAnim">
<!-- Populated dynamically -->
</select>
</div>
</div>
<h4>Animation Preview</h4>
<div class="animation-preview-container">
<img id="animPreviewIcon" class="animation-preview-icon" src="/mapgameimgs/monsters/default100.png" alt="Preview">
</div>
<div class="animation-test-row">
<select id="testAnimationSelect">
<!-- Populated dynamically -->
</select>
<button type="button" class="btn btn-secondary" onclick="testMonsterAnimation()">Test Animation</button>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeMonsterModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Monster</button>
@ -1560,11 +1632,18 @@
container.innerHTML = '<p style="color: #666; font-size: 12px;">No skills assigned. Monster will only use basic attack.</p>';
return;
}
// Build animation options once
const animations = typeof MONSTER_ANIMATIONS !== 'undefined' ? MONSTER_ANIMATIONS : {};
const animOptions = '<option value="">Default</option>' + Object.entries(animations).map(([id, anim]) =>
`<option value="${id}">${anim.name}</option>`
).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 `
<div class="monster-skill-item" data-id="${ms.id}">
<div class="skill-name-section">
@ -1584,6 +1663,12 @@
<input type="number" class="skill-min-level" value="${ms.min_level}" min="1"
onchange="updateMonsterSkill(${ms.id}, 'min_level', this.value)">
</div>
<div>
<label>Anim</label>
<select class="skill-animation" onchange="updateMonsterSkillAnimation(${ms.id}, this.value)">
${animOptions.replace(`value="${currentAnim}"`, `value="${currentAnim}" selected`)}
</select>
</div>
<button type="button" class="btn btn-danger btn-small" onclick="removeMonsterSkill(${ms.id})"></button>
</div>
`;
@ -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 = '<p style="color: #666; font-size: 12px;">Save monster first, then edit to add skills.</p>';
@ -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 = '<p style="color: #666; font-size: 12px;">Save monster first, then edit to add skills.</p>';
@ -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 `<option value="${id}">${anim.name} - ${anim.description}</option>`;
}).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)
};

147
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;
}

49
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;

118
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
}
]

514
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 @@
}
</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;
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,77 +12098,143 @@
}
}
// 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);
}
// Force an immediate sync (for critical moments like page unload)
function flushStatsSync() {
if (syncDebounceTimer) {
clearTimeout(syncDebounceTimer);
syncDebounceTimer = null;
}
// Save to localStorage as backup
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
// Update version in live playerStats
if (data.dataVersion) {
playerStats.dataVersion = data.dataVersion;
console.log('Stats saved, version now:', data.dataVersion);
}
statsSyncState.consecutiveFailures = 0;
} 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);
// 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
}
}
}
// Fetch fresh stats from server (includes correct version)
// Handle 409 version conflicts silently
async function handleVersionConflict(token) {
try {
const freshResponse = await fetch('/api/user/rpg-stats', {
const response = 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
if (response.ok) {
const serverStats = await response.json();
if (serverStats && serverStats.dataVersion) {
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
}
playerStats.dataVersion = serverStats.dataVersion;
console.log(`Version synced silently: ${oldVersion} -> ${serverStats.dataVersion}`);
}
} 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');
} catch (err) {
console.error('Failed to sync version from server:', err);
// Don't show error - will retry on next save
}
})
.catch(err => {
console.error('Failed to save RPG stats to server:', err);
showNotification('⚠️ Failed to save progress', 'error');
});
} else {
console.warn('No access token - stats only saved to localStorage');
}
// Backward compatible alias - all existing call sites continue to work
function savePlayerStats() {
markStatsDirty();
}
// Update the RPG HUD display
@ -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');
// 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) {
icon.style.animation = 'none';
icon.offsetHeight; // Trigger reflow
icon.style.animation = 'monsterAttack 0.5s ease-out';
// 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, 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) {

BIN
mapgameimgs/monsters/moop_george100.png

After

Width: 102  |  Height: 100  |  Size: 14 KiB

BIN
mapgameimgs/monsters/moop_george50.png

After

Width: 50  |  Height: 50  |  Size: 5.8 KiB

16
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) {

3
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',

Loading…
Cancel
Save