From 9047f45d12e9ceeb2d4fb07c76813e6defc17b36 Mon Sep 17 00:00:00 2001 From: HikeMap User Date: Wed, 7 Jan 2026 22:00:28 -0600 Subject: [PATCH] Add stats sync engine, grocery geocaches, and cart wranglin' prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Dockerfile | 1 + admin.html | 187 ++++++++ animations.js | 147 +++++++ database.js | 49 ++- geocaches.json | 118 +++-- index.html | 544 +++++++++++++++++++----- mapgameimgs/monsters/moop_george100.png | Bin 0 -> 14755 bytes mapgameimgs/monsters/moop_george50.png | Bin 0 -> 5964 bytes server.js | 16 +- service-worker.js | 3 +- 10 files changed, 921 insertions(+), 144 deletions(-) create mode 100644 animations.js create mode 100755 mapgameimgs/monsters/moop_george100.png create mode 100755 mapgameimgs/monsters/moop_george50.png 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 @@ +
+

Animation Overrides

+
+
+ + +
+
+ + +
+
+ + +
+
+ +

Animation Preview

+
+ Preview +
+
+ + +
+
+
@@ -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 @@ - -
+ + + + +
- +