Browse Source

Simplify fog of war, fix combat timing, update docs

- Fog of war now uses single hard edge instead of multi-layer gradient
- Monster attack animations now play before damage is applied (500ms sync)
- Update CLAUDE.md and README.md to document MapLibre (not Leaflet)
- Fix service-worker.js to cache MapLibre instead of Leaflet
- Add documentation for fog of war, virtual movement, winch system
- Add karen_base monster sprites
- Various animation and admin panel improvements

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
master
HikeMap User 3 weeks ago
parent
commit
2a07c89e1c
  1. 87
      CLAUDE.md
  2. 18
      README.md
  3. 330
      admin.html
  4. 100
      animations.js
  5. 32
      artwork_todo.md
  6. 78
      database.js
  7. 272
      geocaches.json
  8. 2234
      index.html
  9. BIN
      mapgameimgs/monsters/karen_base100.png
  10. BIN
      mapgameimgs/monsters/karen_base50.png
  11. BIN
      mapgameimgs/skills/full_restore.png
  12. BIN
      mapgamemusic/homebase.mp3
  13. 4
      server.js
  14. 7
      service-worker.js

87
CLAUDE.md

@ -29,13 +29,25 @@ Then open http://localhost:8080
| `index.html` | Frontend SPA (CSS, HTML, JavaScript) | | `index.html` | Frontend SPA (CSS, HTML, JavaScript) |
| `server.js` | Express API server, WebSocket handling | | `server.js` | Express API server, WebSocket handling |
| `database.js` | SQLite database layer (better-sqlite3) | | `database.js` | SQLite database layer (better-sqlite3) |
| `animations.js` | Combat/monster animation definitions |
| `docker-compose.yml` | Container configuration | | `docker-compose.yml` | Container configuration |
| `hikemap.db` | SQLite database (auto-created) | | `hikemap.db` | SQLite database (auto-created) |
| `SKILLS.md` | Complete skills documentation | | `SKILLS.md` | Complete skills documentation |
### Asset Directories
| Directory | Purpose |
|-----------|---------|
| `mapgameimgs/monsters/` | Monster sprites (50px and 100px versions) |
| `mapgameimgs/bases/` | Home base icons |
| `mapgameimgs/skills/` | Custom skill icons (uploaded via admin) |
| `mapgameimgs/cacheicons/` | Geocache type icons |
| `mapgamemusic/` | Background music tracks |
| `sfx/` | Sound effects (combat, spawns, etc.) |
### Key Libraries ### Key Libraries
- **Frontend**: Leaflet.js 1.9.4, leaflet-rotate 0.2.8
- **Frontend**: MapLibre GL JS 4.1.0 (WebGL vector maps), Turf.js (geospatial math)
- **Backend**: Express, better-sqlite3, jsonwebtoken, ws (WebSocket) - **Backend**: Express, better-sqlite3, jsonwebtoken, ws (WebSocket)
- **Note**: Codebase has Leaflet compatibility shims for gradual migration
--- ---
@ -63,12 +75,27 @@ Then open http://localhost:8080
- Required to respawn after death - Required to respawn after death
- Skill loadout can only be changed at home - Skill loadout can only be changed at home
**Fog of War**:
- Canvas-based overlay that hides unexplored areas
- Two reveal zones: homebase radius and player explore radius
- Geocaches only visible/interactable in revealed areas
- Multipliers can modify reveal radius (via utility skills)
- Hard-edged cutout (no gradient layers)
**Virtual Movement Mode**:
- Developer/testing feature for moving without real GPS
- WASD/arrow keys move virtual position on map
- Winch tethering system: walking away from virtual position reels it back
- Prevents unlimited virtual exploration - real GPS movement matters
- Toggle via developer tools panel
### Combat System ### Combat System
**Monster Spawning**: **Monster Spawning**:
- Monsters spawn while walking (configurable distance) - Monsters spawn while walking (configurable distance)
- No spawns within home base radius - No spawns within home base radius
- Multiple monsters can accumulate (entourage) - Multiple monsters can accumulate (entourage)
- Spawn sound effect plays when monster appears (`sfx/` directory)
**Combat Flow**: **Combat Flow**:
1. Player taps monster to engage 1. Player taps monster to engage
@ -83,6 +110,14 @@ damage = (skillPower * playerATK / 100) - enemyDEF
hitChance = skillAccuracy + (attackerAccuracy - 90) - defenderDodge hitChance = skillAccuracy + (attackerAccuracy - 90) - defenderDodge
``` ```
**Animation System** (`animations.js`):
- Separate file defines all combat animations
- Player animations: `attack`, `skill`, `miss`, `death`
- Monster animations: `attack`, `skill`, `miss`, `death`, `idle`, `bouncy`, various flips
- Each animation has: `duration`, `loop`, `easing`, `keyframes`
- Combat timing syncs damage with animation "hit" moment (500ms delay)
- Skills can override default animation via `animation` field
### Skill System ### Skill System
**Skill Tiers**: Skills unlock at level milestones (2, 3, 4, 5, 6, 7) **Skill Tiers**: Skills unlock at level milestones (2, 3, 4, 5, 6, 7)
@ -102,6 +137,12 @@ hitChance = skillAccuracy + (attackerAccuracy - 90) - defenderDodge
See `SKILLS.md` for complete skill reference. See `SKILLS.md` for complete skill reference.
**Custom Skill Icons**:
- Upload via admin panel (`/admin.html`)
- Stored in `mapgameimgs/skills/` directory
- Database field: `skills.icon` stores filename
- Falls back to emoji if no custom icon set
### Classes ### Classes
**Trail Runner** (currently the only class): **Trail Runner** (currently the only class):
@ -221,21 +262,53 @@ db.swapActiveSkill(userId, tier, newSkillId) // Swap loadout
### Key State Variables ### Key State Variables
```javascript ```javascript
// RPG State
playerStats // Player RPG data playerStats // Player RPG data
combatState // Active combat info combatState // Active combat info
monsterEntourage // Spawned monsters monsterEntourage // Spawned monsters
statsLoadedFromServer // Prevents stale saves statsLoadedFromServer // Prevents stale saves
pendingSkillChoice // Level-up skill selection pendingSkillChoice // Level-up skill selection
// GPS & Virtual Movement
userLocation // Current position (real or virtual)
realGpsPosition // Actual GPS coordinates
testPosition // Virtual position when in test mode
gpsTestMode // Virtual movement enabled flag
previousWinchRealGps // Winch system - tracks real GPS for tethering
// Fog of War
fogCanvas // Canvas element for fog overlay
fogCtx // 2D context for fog drawing
fogSystemReady // Flag indicating fog can render
exploreRadiusMultiplier // Skill-modified explore radius
homebaseRadiusMultiplier // Skill-modified homebase radius
``` ```
### Key Functions ### Key Functions
```javascript ```javascript
// RPG & Combat
initializePlayerStats(username) // Load from server initializePlayerStats(username) // Load from server
savePlayerStats() // Save to server savePlayerStats() // Save to server
showSkillChoiceModal(level) // Level-up skill pick showSkillChoiceModal(level) // Level-up skill pick
swapSkillFromHomebase(tier, id) // Change loadout swapSkillFromHomebase(tier, id) // Change loadout
startCombat(monsters) // Begin combat startCombat(monsters) // Begin combat
useSkill(skillId) // Execute skill in combat useSkill(skillId) // Execute skill in combat
// Fog of War
updateFogOfWar() // Redraw fog overlay
isInRevealedArea(lat, lng) // Check if location visible
initFogOfWar() // Create fog canvas
// Virtual Movement & Winch
enableVirtualMovement() // Enter virtual GPS mode
disableVirtualMovement() // Return to real GPS
simulateGpsPosition() // Update map with virtual position
applyWinchTether(newRealPosition) // Reel in virtual when walking away
// Animations (from animations.js)
getAnimation(animationId) // Get animation definition
getAnimationList() // List monster animations
getPlayerAnimationList() // List player animations
``` ```
--- ---
@ -284,16 +357,18 @@ Most source files are **volume-mounted** in `docker-compose.yml`, so changes are
| File | Action | Notes | | File | Action | Notes |
|------|--------|-------| |------|--------|-------|
| `index.html` | Browser refresh | Volume-mounted, served fresh |
| `admin.html` | Browser refresh | Volume-mounted, served fresh |
| `service-worker.js` | Browser refresh + bump cache version | Volume-mounted |
| `server.js` | `docker restart hikemap_hikemap_1` | Volume-mounted, Node needs restart |
| `database.js` | `docker restart hikemap_hikemap_1` | Volume-mounted, Node needs restart |
| `index.html` | **Rebuild container** | Volume mount may not sync live |
| `admin.html` | **Rebuild container** | Volume mount may not sync live |
| `service-worker.js` | **Rebuild container** + bump cache version | Volume mount may not sync live |
| `server.js` | **Rebuild container** | Volume mount may not sync live |
| `database.js` | **Rebuild container** | Volume mount may not sync live |
| `mapgameimgs/*` | Browser refresh | Volume-mounted | | `mapgameimgs/*` | Browser refresh | Volume-mounted |
| `mapgamemusic/*` | Browser refresh | Volume-mounted | | `mapgamemusic/*` | Browser refresh | Volume-mounted |
| `sfx/*` | Browser refresh | Volume-mounted | | `sfx/*` | Browser refresh | Volume-mounted |
| `hikemap.db` | Nothing | Volume-mounted, persists outside container | | `hikemap.db` | Nothing | Volume-mounted, persists outside container |
**IMPORTANT**: Despite being volume-mounted, code files (`index.html`, `server.js`, etc.) often require a container rebuild to reflect changes. Always rebuild when in doubt.
**Files requiring full rebuild** (`docker-compose up -d --build`): **Files requiring full rebuild** (`docker-compose up -d --build`):
| File | Why Rebuild? | | File | Why Rebuild? |

18
README.md

@ -15,6 +15,7 @@ A location-based RPG web application where you walk in the real world to explore
- Turn-based battles against MOOP (Matter Out Of Place) monsters - Turn-based battles against MOOP (Matter Out Of Place) monsters
- Skill-based combat with damage, healing, buffs, and multi-hit attacks - Skill-based combat with damage, healing, buffs, and multi-hit attacks
- Hit/miss mechanics based on accuracy and dodge stats - Hit/miss mechanics based on accuracy and dodge stats
- Animated combat with synchronized damage timing
- XP rewards and leveling system - XP rewards and leveling system
**Skills & Progression** **Skills & Progression**
@ -29,6 +30,12 @@ A location-based RPG web application where you walk in the real world to explore
- Required destination when defeated - Required destination when defeated
- Skill loadout management location - Skill loadout management location
**Fog of War**
- Unexplored areas hidden by dark overlay
- Reveal zones around your home base and current position
- Geocaches only visible in revealed areas
- Utility skills can expand your reveal radius
### Track Editing ### Track Editing
- Draw new GPS tracks directly on the map - Draw new GPS tracks directly on the map
@ -106,11 +113,18 @@ See [SKILLS.md](SKILLS.md) for complete skill details.
- **Frontend**: Single-page app in `index.html` - **Frontend**: Single-page app in `index.html`
- **Backend**: Node.js/Express server in `server.js` - **Backend**: Node.js/Express server in `server.js`
- **Database**: SQLite via better-sqlite3 in `database.js` - **Database**: SQLite via better-sqlite3 in `database.js`
- **Animations**: Combat animation definitions in `animations.js`
- **Real-time**: WebSocket for live updates - **Real-time**: WebSocket for live updates
### Developer Features
- Virtual movement mode for testing without real GPS
- Winch tethering system prevents unlimited virtual exploration
- Admin panel for monster/skill management and icon uploads
- Customizable spawn settings and game balance
### Dependencies ### Dependencies
- Leaflet.js 1.9.4 (maps)
- leaflet-rotate 0.2.8 (navigation rotation)
- MapLibre GL JS 4.1.0 (WebGL vector maps)
- Turf.js (geospatial calculations)
- Express (API server) - Express (API server)
- better-sqlite3 (database) - better-sqlite3 (database)
- jsonwebtoken (authentication) - jsonwebtoken (authentication)

330
admin.html

@ -580,15 +580,102 @@
} }
.monster-skill-item .skill-animation { .monster-skill-item .skill-animation {
width: 100px;
width: 90px;
padding: 4px; padding: 4px;
font-size: 12px;
font-size: 11px;
background: rgba(255,255,255,0.1); background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2); border: 1px solid rgba(255,255,255,0.2);
border-radius: 4px; border-radius: 4px;
color: inherit; color: inherit;
} }
.skill-animation-section {
display: flex;
flex-direction: column;
gap: 2px;
}
.skill-animation-row {
display: flex;
gap: 4px;
align-items: center;
}
.skill-animation-row select {
flex: 1;
}
.anim-test-btn {
width: 24px;
height: 24px;
padding: 0;
border: 1px solid rgba(255,255,255,0.3);
border-radius: 4px;
background: rgba(100,200,100,0.2);
color: #8f8;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.anim-test-btn:hover {
background: rgba(100,200,100,0.4);
}
/* Animation tester panel */
.animation-tester {
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
}
.animation-tester h4 {
margin: 0 0 10px 0;
color: #8cf;
}
.animation-tester-content {
display: flex;
gap: 20px;
align-items: center;
}
.animation-preview {
width: 64px;
height: 64px;
background: rgba(255,255,255,0.1);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.animation-preview img {
width: 48px;
height: 48px;
object-fit: contain;
}
.animation-tester-controls {
display: flex;
flex-direction: column;
gap: 8px;
}
.animation-tester-controls select {
padding: 6px 10px;
min-width: 150px;
}
.animation-tester-controls button {
padding: 6px 15px;
}
.monster-skill-item label { .monster-skill-item label {
font-size: 11px; font-size: 11px;
color: #888; color: #888;
@ -1322,6 +1409,12 @@
<!-- Populated dynamically --> <!-- Populated dynamically -->
</select> </select>
</div> </div>
<div class="form-group">
<label>Miss Animation</label>
<select id="monsterMissAnim">
<!-- Populated dynamically -->
</select>
</div>
</div> </div>
<h4>Animation Preview</h4> <h4>Animation Preview</h4>
@ -1506,6 +1599,33 @@
</div> </div>
</div> </div>
<!-- Combat Buff Configuration (hidden by default, shown for buff type) -->
<div class="status-effect-section" id="buffConfigSection" style="display: none;">
<h4>Combat Buff Configuration</h4>
<p style="font-size: 11px; color: #666; margin-bottom: 10px;">
Configure the in-combat buff effect (e.g., Defense Up, Accuracy Up)
</p>
<div class="form-row-3">
<div class="form-group">
<label>Buff Type</label>
<select id="buffEffectType">
<option value="defense_up">Defense Up</option>
<option value="accuracy_up">Accuracy Up</option>
<option value="attack_up">Attack Up</option>
<option value="dodge_up">Dodge Up</option>
</select>
</div>
<div class="form-group">
<label>Boost (%)</label>
<input type="number" id="buffEffectPercent" value="20" min="1" max="200">
</div>
<div class="form-group">
<label>Duration (turns)</label>
<input type="number" id="buffEffectDuration" value="3" min="1" max="10">
</div>
</div>
</div>
<!-- Utility Skill Configuration (hidden by default) --> <!-- Utility Skill Configuration (hidden by default) -->
<div class="status-effect-section" id="utilityConfigSection" style="display: none;"> <div class="status-effect-section" id="utilityConfigSection" style="display: none;">
<h4>Utility Skill Configuration</h4> <h4>Utility Skill Configuration</h4>
@ -1525,6 +1645,7 @@
<option value="xp_multiplier">XP Multiplier</option> <option value="xp_multiplier">XP Multiplier</option>
<option value="explore_radius_multiplier">Explore Radius Multiplier</option> <option value="explore_radius_multiplier">Explore Radius Multiplier</option>
<option value="homebase_radius_multiplier">Homebase Radius Multiplier</option> <option value="homebase_radius_multiplier">Homebase Radius Multiplier</option>
<option value="wander_range_multiplier">Wander Range Multiplier</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -1672,6 +1793,23 @@
Assign skills to this class. Set unlock level and choice group for skill selection at level-up. Assign skills to this class. Set unlock level and choice group for skill selection at level-up.
<br>Skills with the same choice group at the same level = player picks one. <br>Skills with the same choice group at the same level = player picks one.
</p> </p>
<!-- Animation Tester -->
<div class="animation-tester">
<h4>🎬 Animation Tester</h4>
<div class="animation-tester-content">
<div class="animation-preview" id="animationPreview">
<img src="/mapgameimgs/player/runner.png" id="animationPreviewImg">
</div>
<div class="animation-tester-controls">
<select id="animationTesterSelect">
<option value="">-- Select Animation --</option>
</select>
<button type="button" class="btn btn-primary" onclick="testAnimation()">▶ Play</button>
</div>
</div>
</div>
<div id="classSkillsList"></div> <div id="classSkillsList"></div>
<div class="add-skill-row"> <div class="add-skill-row">
<select id="addClassSkillSelect"> <select id="addClassSkillSelect">
@ -2111,6 +2249,7 @@
document.getElementById('monsterAttackAnim').value = monster.attack_animation || 'attack'; document.getElementById('monsterAttackAnim').value = monster.attack_animation || 'attack';
document.getElementById('monsterDeathAnim').value = monster.death_animation || 'death'; document.getElementById('monsterDeathAnim').value = monster.death_animation || 'death';
document.getElementById('monsterIdleAnim').value = monster.idle_animation || 'idle'; document.getElementById('monsterIdleAnim').value = monster.idle_animation || 'idle';
document.getElementById('monsterMissAnim').value = monster.miss_animation || 'miss';
// Update preview icon // Update preview icon
document.getElementById('animPreviewIcon').src = `/mapgameimgs/monsters/${monster.key}100.png`; document.getElementById('animPreviewIcon').src = `/mapgameimgs/monsters/${monster.key}100.png`;
@ -2164,6 +2303,7 @@
document.getElementById('monsterAttackAnim').value = monster.attack_animation || 'attack'; document.getElementById('monsterAttackAnim').value = monster.attack_animation || 'attack';
document.getElementById('monsterDeathAnim').value = monster.death_animation || 'death'; document.getElementById('monsterDeathAnim').value = monster.death_animation || 'death';
document.getElementById('monsterIdleAnim').value = monster.idle_animation || 'idle'; document.getElementById('monsterIdleAnim').value = monster.idle_animation || 'idle';
document.getElementById('monsterMissAnim').value = monster.miss_animation || 'miss';
document.getElementById('animPreviewIcon').src = '/mapgameimgs/monsters/default100.png'; document.getElementById('animPreviewIcon').src = '/mapgameimgs/monsters/default100.png';
// Clear skills (cloned monster needs to be saved first) // Clear skills (cloned monster needs to be saved first)
@ -2183,6 +2323,7 @@
document.getElementById('monsterAttackAnim').value = 'attack'; document.getElementById('monsterAttackAnim').value = 'attack';
document.getElementById('monsterDeathAnim').value = 'death'; document.getElementById('monsterDeathAnim').value = 'death';
document.getElementById('monsterIdleAnim').value = 'idle'; document.getElementById('monsterIdleAnim').value = 'idle';
document.getElementById('monsterMissAnim').value = 'miss';
document.getElementById('animPreviewIcon').src = '/mapgameimgs/monsters/default100.png'; document.getElementById('animPreviewIcon').src = '/mapgameimgs/monsters/default100.png';
// Clear skills (new monster needs to be saved first) // Clear skills (new monster needs to be saved first)
currentMonsterSkills = []; currentMonsterSkills = [];
@ -2199,7 +2340,7 @@
const animations = typeof MONSTER_ANIMATIONS !== 'undefined' ? MONSTER_ANIMATIONS : {}; const animations = typeof MONSTER_ANIMATIONS !== 'undefined' ? MONSTER_ANIMATIONS : {};
const animIds = Object.keys(animations); const animIds = Object.keys(animations);
const dropdowns = ['monsterAttackAnim', 'monsterDeathAnim', 'monsterIdleAnim', 'testAnimationSelect'];
const dropdowns = ['monsterAttackAnim', 'monsterDeathAnim', 'monsterIdleAnim', 'monsterMissAnim', 'testAnimationSelect'];
dropdowns.forEach(dropdownId => { dropdowns.forEach(dropdownId => {
const dropdown = document.getElementById(dropdownId); const dropdown = document.getElementById(dropdownId);
@ -2227,6 +2368,119 @@
}).join(''); }).join('');
} }
// Get player animation options HTML (for class skill dropdowns)
function getPlayerAnimationOptionsHtml(selectedValue) {
let options = '<option value="">Default</option>';
// Add player animations first (if available)
if (typeof PLAYER_ANIMATIONS !== 'undefined') {
options += '<optgroup label="Player Animations">';
for (const [id, anim] of Object.entries(PLAYER_ANIMATIONS)) {
const selected = selectedValue === id ? 'selected' : '';
options += `<option value="${id}" ${selected}>${anim.name}</option>`;
}
options += '</optgroup>';
}
// Add monster animations (player can use these too)
if (typeof MONSTER_ANIMATIONS !== 'undefined') {
options += '<optgroup label="Monster Animations">';
for (const [id, anim] of Object.entries(MONSTER_ANIMATIONS)) {
const selected = selectedValue === id ? 'selected' : '';
options += `<option value="${id}" ${selected}>${anim.name}</option>`;
}
options += '</optgroup>';
}
return options;
}
// Initialize animation tester dropdown
function initAnimationTester() {
const select = document.getElementById('animationTesterSelect');
if (!select) return;
select.innerHTML = getPlayerAnimationOptionsHtml('');
}
// Test animation from the main tester panel
function testAnimation() {
const select = document.getElementById('animationTesterSelect');
const animationId = select.value;
if (!animationId) {
showToast('Select an animation first', 'error');
return;
}
playAnimationPreview(animationId, 'animationPreviewImg');
}
// Test animation for a specific skill
function testSkillAnimation(classSkillId) {
const select = document.getElementById(`anim-select-${classSkillId}`);
let animationId = select ? select.value : '';
// If no animation selected, use default attack
if (!animationId) {
animationId = 'attack';
}
playAnimationPreview(animationId, 'animationPreviewImg');
}
// Play animation on the preview element
function playAnimationPreview(animationId, imgElementId) {
const img = document.getElementById(imgElementId);
if (!img) return;
// Get animation definition
let anim = null;
let prefix = 'player';
if (typeof PLAYER_ANIMATIONS !== 'undefined' && PLAYER_ANIMATIONS[animationId]) {
anim = PLAYER_ANIMATIONS[animationId];
prefix = 'player';
} else if (typeof MONSTER_ANIMATIONS !== 'undefined' && MONSTER_ANIMATIONS[animationId]) {
anim = MONSTER_ANIMATIONS[animationId];
prefix = 'monster';
}
if (!anim) {
showToast('Animation not found', 'error');
return;
}
// Inject keyframes if not already present
injectAnimationKeyframes(animationId, anim, prefix);
// Reset and play animation
const loopStr = anim.loop ? ' infinite' : '';
const fillStr = anim.fillMode ? ` ${anim.fillMode}` : '';
const easing = anim.easing || 'ease-out';
img.style.animation = 'none';
img.offsetHeight; // Force reflow
img.style.animation = `${prefix}_${animationId} ${anim.duration}ms ${easing}${loopStr}${fillStr}`;
// Remove animation after it completes (unless looping)
if (!anim.loop) {
setTimeout(() => {
img.style.animation = '';
}, anim.duration);
}
}
// Inject CSS keyframes for an animation if not already present
function injectAnimationKeyframes(animationId, anim, prefix) {
const animName = `${prefix}_${animationId}`;
// Check if already injected
if (document.getElementById(`keyframe-${animName}`)) return;
const style = document.createElement('style');
style.id = `keyframe-${animName}`;
style.textContent = `@keyframes ${animName} { ${anim.keyframes} }`;
document.head.appendChild(style);
}
// Update artwork preview in OSM tag modal // Update artwork preview in OSM tag modal
function updateArtworkPreview() { function updateArtworkPreview() {
const artworkNum = parseInt(document.getElementById('osmTagArtwork').value) || 1; const artworkNum = parseInt(document.getElementById('osmTagArtwork').value) || 1;
@ -2322,6 +2576,7 @@
attack_animation: document.getElementById('monsterAttackAnim').value, attack_animation: document.getElementById('monsterAttackAnim').value,
death_animation: document.getElementById('monsterDeathAnim').value, death_animation: document.getElementById('monsterDeathAnim').value,
idle_animation: document.getElementById('monsterIdleAnim').value, idle_animation: document.getElementById('monsterIdleAnim').value,
miss_animation: document.getElementById('monsterMissAnim').value,
dialogues: JSON.stringify(dialogues) dialogues: JSON.stringify(dialogues)
}; };
@ -2424,7 +2679,8 @@
'def_boost_percent': 'DEF %', 'def_boost_percent': 'DEF %',
'xp_multiplier': 'XP', 'xp_multiplier': 'XP',
'explore_radius_multiplier': 'Explore Radius', 'explore_radius_multiplier': 'Explore Radius',
'homebase_radius_multiplier': 'Homebase Radius'
'homebase_radius_multiplier': 'Homebase Radius',
'wander_range_multiplier': 'Wander Range'
}; };
tbody.innerHTML = utilitySkills.map(s => { tbody.innerHTML = utilitySkills.map(s => {
@ -2465,19 +2721,29 @@
function handleSkillTypeChange() { function handleSkillTypeChange() {
const skillType = document.getElementById('skillType').value; const skillType = document.getElementById('skillType').value;
const utilitySection = document.getElementById('utilityConfigSection'); const utilitySection = document.getElementById('utilityConfigSection');
const statusEffectSection = document.querySelector('.status-effect-section:not(#utilityConfigSection)');
const buffSection = document.getElementById('buffConfigSection');
const statusEffectSection = document.querySelector('.status-effect-section:not(#utilityConfigSection):not(#buffConfigSection)');
// Hide all special sections first
utilitySection.style.display = 'none';
buffSection.style.display = 'none';
if (statusEffectSection) statusEffectSection.style.display = 'none';
if (skillType === 'utility') { if (skillType === 'utility') {
utilitySection.style.display = 'block'; utilitySection.style.display = 'block';
if (statusEffectSection) statusEffectSection.style.display = 'none';
// Auto-set defaults for utility skills // Auto-set defaults for utility skills
document.getElementById('skillPlayerUsable').checked = false; document.getElementById('skillPlayerUsable').checked = false;
document.getElementById('skillMonsterUsable').checked = false; document.getElementById('skillMonsterUsable').checked = false;
document.getElementById('skillTarget').value = 'self'; document.getElementById('skillTarget').value = 'self';
document.getElementById('skillMpCost').value = '0'; document.getElementById('skillMpCost').value = '0';
document.getElementById('skillBasePower').value = '0'; document.getElementById('skillBasePower').value = '0';
} else if (skillType === 'buff') {
buffSection.style.display = 'block';
// Auto-set defaults for buff skills
document.getElementById('skillTarget').value = 'self';
document.getElementById('skillBasePower').value = '0';
} else { } else {
utilitySection.style.display = 'none';
// Show status effect section for damage/heal/status/debuff types
if (statusEffectSection) statusEffectSection.style.display = 'block'; if (statusEffectSection) statusEffectSection.style.display = 'block';
} }
} }
@ -2543,31 +2809,51 @@
document.getElementById('utilityEffectValue').value = effect.effectValue || 2.0; document.getElementById('utilityEffectValue').value = effect.effectValue || 2.0;
document.getElementById('utilityDurationHours').value = effect.durationHours || 1; document.getElementById('utilityDurationHours').value = effect.durationHours || 1;
document.getElementById('utilityCooldownHours').value = effect.cooldownHours || 24; document.getElementById('utilityCooldownHours').value = effect.cooldownHours || 24;
// Reset combat status effect fields
// Reset other fields
document.getElementById('skillStatusType').value = '';
document.getElementById('buffEffectType').value = 'defense_up';
document.getElementById('buffEffectPercent').value = 20;
document.getElementById('buffEffectDuration').value = 3;
} else if (skill.type === 'buff') {
// Combat buff config (defense_up, accuracy_up, etc.)
document.getElementById('buffEffectType').value = effect.type || 'defense_up';
document.getElementById('buffEffectPercent').value = effect.percent || 20;
document.getElementById('buffEffectDuration').value = effect.duration || 3;
// Reset other fields
document.getElementById('skillStatusType').value = ''; document.getElementById('skillStatusType').value = '';
document.getElementById('utilityEffectType').value = 'mp_regen_multiplier';
document.getElementById('utilityEffectValue').value = 2.0;
document.getElementById('utilityDurationHours').value = 1;
document.getElementById('utilityCooldownHours').value = 24;
} else { } else {
// Combat status effect
// Combat status effect (poison, burn, etc.)
document.getElementById('skillStatusType').value = effect.type || ''; document.getElementById('skillStatusType').value = effect.type || '';
document.getElementById('skillStatusDamage').value = effect.damage || 5; document.getElementById('skillStatusDamage').value = effect.damage || 5;
document.getElementById('skillStatusDuration').value = effect.duration || 3; document.getElementById('skillStatusDuration').value = effect.duration || 3;
// Reset utility fields
// Reset other fields
document.getElementById('utilityEffectType').value = 'mp_regen_multiplier'; document.getElementById('utilityEffectType').value = 'mp_regen_multiplier';
document.getElementById('utilityEffectValue').value = 2.0; document.getElementById('utilityEffectValue').value = 2.0;
document.getElementById('utilityDurationHours').value = 1; document.getElementById('utilityDurationHours').value = 1;
document.getElementById('utilityCooldownHours').value = 24; document.getElementById('utilityCooldownHours').value = 24;
document.getElementById('buffEffectType').value = 'defense_up';
document.getElementById('buffEffectPercent').value = 20;
document.getElementById('buffEffectDuration').value = 3;
} }
} catch { } catch {
document.getElementById('skillStatusType').value = ''; document.getElementById('skillStatusType').value = '';
} }
} else { } else {
// Reset all effect fields to defaults
document.getElementById('skillStatusType').value = ''; document.getElementById('skillStatusType').value = '';
document.getElementById('skillStatusDamage').value = 5; document.getElementById('skillStatusDamage').value = 5;
document.getElementById('skillStatusDuration').value = 3; document.getElementById('skillStatusDuration').value = 3;
// Reset utility fields to defaults
document.getElementById('utilityEffectType').value = 'mp_regen_multiplier'; document.getElementById('utilityEffectType').value = 'mp_regen_multiplier';
document.getElementById('utilityEffectValue').value = 2.0; document.getElementById('utilityEffectValue').value = 2.0;
document.getElementById('utilityDurationHours').value = 1; document.getElementById('utilityDurationHours').value = 1;
document.getElementById('utilityCooldownHours').value = 24; document.getElementById('utilityCooldownHours').value = 24;
document.getElementById('buffEffectType').value = 'defense_up';
document.getElementById('buffEffectPercent').value = 20;
document.getElementById('buffEffectDuration').value = 3;
} }
// Toggle visibility of form sections based on skill type // Toggle visibility of form sections based on skill type
@ -2782,8 +3068,15 @@
durationHours: parseFloat(document.getElementById('utilityDurationHours').value) || 1, durationHours: parseFloat(document.getElementById('utilityDurationHours').value) || 1,
cooldownHours: parseFloat(document.getElementById('utilityCooldownHours').value) || 24 cooldownHours: parseFloat(document.getElementById('utilityCooldownHours').value) || 24
}); });
} else if (skillType === 'buff') {
// Combat buff skill - use buff config fields
statusEffect = JSON.stringify({
type: document.getElementById('buffEffectType').value,
percent: parseInt(document.getElementById('buffEffectPercent').value) || 20,
duration: parseInt(document.getElementById('buffEffectDuration').value) || 3
});
} else { } else {
// Combat skill - use status effect fields
// Combat skill - use status effect fields (poison, burn, etc.)
const statusType = document.getElementById('skillStatusType').value; const statusType = document.getElementById('skillStatusType').value;
if (statusType) { if (statusType) {
statusEffect = JSON.stringify({ statusEffect = JSON.stringify({
@ -2954,6 +3247,9 @@
// Populate skill dropdown for adding // Populate skill dropdown for adding
populateClassSkillSelect(); populateClassSkillSelect();
// Initialize animation tester
initAnimationTester();
// Load class skills // Load class skills
await loadClassSkills(classData.id); await loadClassSkills(classData.id);
@ -2969,6 +3265,7 @@
currentClassSkills = []; currentClassSkills = [];
document.getElementById('classSkillsList').innerHTML = '<p style="color: #666; font-size: 12px;">Save class first, then edit to add skills.</p>'; document.getElementById('classSkillsList').innerHTML = '<p style="color: #666; font-size: 12px;">Save class first, then edit to add skills.</p>';
populateClassSkillSelect(); populateClassSkillSelect();
initAnimationTester();
document.getElementById('classModal').classList.add('active'); document.getElementById('classModal').classList.add('active');
}); });
@ -3110,6 +3407,15 @@
onchange="updateClassSkill(${cs.id}, 'choice_group', this.value || null)" onchange="updateClassSkill(${cs.id}, 'choice_group', this.value || null)"
placeholder="-" title="Choice group (empty = auto-learn)"> placeholder="-" title="Choice group (empty = auto-learn)">
</div> </div>
<div class="skill-animation-section">
<label>Anim</label>
<div class="skill-animation-row">
<select class="skill-animation" id="anim-select-${cs.id}" onchange="updateClassSkill(${cs.id}, 'player_animation', this.value || null)" title="Player animation override">
${getPlayerAnimationOptionsHtml(cs.player_animation)}
</select>
<button type="button" class="anim-test-btn" onclick="testSkillAnimation(${cs.id})" title="Test animation"></button>
</div>
</div>
<button type="button" class="btn btn-danger btn-small" onclick="removeClassSkill(${cs.id})"></button> <button type="button" class="btn btn-danger btn-small" onclick="removeClassSkill(${cs.id})"></button>
</div> </div>
`; `;

100
animations.js

@ -1,7 +1,71 @@
// HikeMap Monster Animation Definitions
// This file defines all available animations for monster icons
// HikeMap Animation Definitions
// This file defines all available animations for monster and player icons
// Edit the keyframes to customize how animations look // Edit the keyframes to customize how animations look
// Player-specific animations (defaults used when no skill override is set)
const PLAYER_ANIMATIONS = {
// Default attack animation - lunge forward towards enemies
attack: {
name: 'Attack',
description: 'Lunge forward towards enemy',
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 pulse/glow effect
skill: {
name: 'Skill',
description: 'Quick pulse effect for skills',
duration: 400,
loop: false,
easing: 'ease-in-out',
keyframes: `
0%, 100% { transform: scale(1); }
50% { transform: scale(1.2); }
`
},
// Miss animation - attack motion then fall clockwise, hold, recover (mirrors monster miss)
miss: {
name: 'Miss',
description: 'Attack then fall over 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); }
`
},
// Death animation - fall over
death: {
name: 'Death',
description: 'Fall over permanently',
duration: 800,
loop: false,
easing: 'ease-out',
fillMode: 'forwards',
keyframes: `
0% { transform: rotate(0deg); opacity: 1; }
100% { transform: rotate(90deg); opacity: 0.5; }
`
}
};
const MONSTER_ANIMATIONS = { const MONSTER_ANIMATIONS = {
// Default attack animation - rubber band snap towards player // Default attack animation - rubber band snap towards player
attack: { attack: {
@ -169,8 +233,40 @@ function getAnimationList() {
})); }));
} }
// Helper function to get player animation list for dropdowns
function getPlayerAnimationList() {
// Player can use both player-specific and monster animations
const playerAnims = Object.entries(PLAYER_ANIMATIONS).map(([id, anim]) => ({
id,
name: anim.name,
description: anim.description,
source: 'player'
}));
const monsterAnims = Object.entries(MONSTER_ANIMATIONS).map(([id, anim]) => ({
id,
name: anim.name,
description: anim.description,
source: 'monster'
}));
return [...playerAnims, ...monsterAnims];
}
// Get an animation by ID, checking both player and monster animations
function getAnimation(animationId) {
if (PLAYER_ANIMATIONS[animationId]) {
return PLAYER_ANIMATIONS[animationId];
}
if (MONSTER_ANIMATIONS[animationId]) {
return MONSTER_ANIMATIONS[animationId];
}
return null;
}
// Export for use in browser // Export for use in browser
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.MONSTER_ANIMATIONS = MONSTER_ANIMATIONS; window.MONSTER_ANIMATIONS = MONSTER_ANIMATIONS;
window.PLAYER_ANIMATIONS = PLAYER_ANIMATIONS;
window.getAnimationList = getAnimationList; window.getAnimationList = getAnimationList;
window.getPlayerAnimationList = getPlayerAnimationList;
window.getAnimation = getAnimation;
} }

32
artwork_todo.md

@ -47,10 +47,10 @@ Track all emoji replacements and custom artwork needed. All icons should follow
| Emoji | Race | Art File | Status | | Emoji | Race | Art File | Status |
|-------|------|----------|--------| |-------|------|----------|--------|
| 👤 | Human | `races/human.png` | [ ] |
| 🧑 | Human | `races/human.png` | [ ] |
| 🧝 | Elf | `races/elf.png` | [ ] | | 🧝 | Elf | `races/elf.png` | [ ] |
| ⛏️ | Dwarf | `races/dwarf.png` | [ ] |
| 🦶 | Halfling | `races/halfling.png` | [ ] |
| 🧔 | Dwarf | `races/dwarf.png` | [ ] |
| 🧒 | Halfling | `races/halfling.png` | [ ] |
--- ---
@ -83,20 +83,20 @@ Need player character art to display in combat instead of class emoji.
--- ---
## Skill Icons (Optional - currently use class icons or ⚔️)
## Skill Icons (Optional - currently use emojis in skill buttons)
Could add unique icons per skill for the combat UI skill buttons. Could add unique icons per skill for the combat UI skill buttons.
| Skill ID | Skill Name | Art File | Status |
|----------|------------|----------|--------|
| basic_attack | Attack / Kickems | `skills/basic_attack.png` | [ ] |
| double_attack | Double Attack / Brand New Hokas | `skills/double_attack.png` | [ ] |
| power_strike | Power Strike / Downhill Sprint | `skills/power_strike.png` | [ ] |
| heal | Heal / Gel Pack | `skills/heal.png` | [ ] |
| defend | Defend / Pace Yourself | `skills/defend.png` | [ ] |
| quick_step | Quick Step | `skills/quick_step.png` | [ ] |
| second_wind | Second Wind | `skills/second_wind.png` | [ ] |
| finish_line_sprint | Finish Line Sprint | `skills/finish_line_sprint.png` | [ ] |
| Emoji | Skill ID | Skill Name | Art File | Status |
|-------|----------|------------|----------|--------|
| 👊 | basic_attack | Attack | `skills/basic_attack.png` | [ ] |
| 👟 | brand_new_hokas | Brand New Hokas | `skills/brand_new_hokas.png` | [ ] |
| 🏃‍♂️ | downhill_sprint | Downhill Sprint | `skills/downhill_sprint.png` | [ ] |
| 🦵 | shin_kick | Shin Kick | `skills/shin_kick.png` | [ ] |
| ⚡ | quick_step | Quick Step | `skills/quick_step.png` | [ ] |
| 💨 | second_wind | Second Wind | `skills/second_wind.png` | [ ] |
| 🏁 | finish_line_sprint | Finish Line Sprint | `skills/finish_line_sprint.png` | [ ] |
| 🌀 | whirlwind | Whirlwind | `skills/whirlwind.png` | [ ] |
--- ---
@ -110,9 +110,9 @@ Could add unique icons per skill for the combat UI skill buttons.
| Race Icons | 4 | Medium | | Race Icons | 4 | Medium |
| UI Elements | 9 | Medium | | UI Elements | 9 | Medium |
| Player Portraits | 4 (x2 sizes) | High | | Player Portraits | 4 (x2 sizes) | High |
| Skill Icons | 8+ | Low |
| Skill Icons | 8 | Low |
**Total unique artwork pieces needed: ~35-45**
**Total unique artwork pieces needed: ~43**
--- ---

78
database.js

@ -291,6 +291,11 @@ class HikeMapDB {
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN map_theme TEXT`); this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN map_theme TEXT`);
} catch (e) { /* Column already exists */ } } catch (e) { /* Column already exists */ }
// Migration: Add wander_range column for virtual movement limit
try {
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN wander_range INTEGER DEFAULT 200`);
} catch (e) { /* Column already exists */ }
// Migration: Add animation overrides to monster_types // Migration: Add animation overrides to monster_types
try { try {
this.db.exec(`ALTER TABLE monster_types ADD COLUMN attack_animation TEXT DEFAULT 'attack'`); this.db.exec(`ALTER TABLE monster_types ADD COLUMN attack_animation TEXT DEFAULT 'attack'`);
@ -301,6 +306,9 @@ class HikeMapDB {
try { try {
this.db.exec(`ALTER TABLE monster_types ADD COLUMN idle_animation TEXT DEFAULT 'idle'`); this.db.exec(`ALTER TABLE monster_types ADD COLUMN idle_animation TEXT DEFAULT 'idle'`);
} catch (e) { /* Column already exists */ } } catch (e) { /* Column already exists */ }
try {
this.db.exec(`ALTER TABLE monster_types ADD COLUMN miss_animation TEXT DEFAULT 'miss'`);
} catch (e) { /* Column already exists */ }
// Migration: Add animation override to monster_skills // Migration: Add animation override to monster_skills
try { try {
@ -369,6 +377,11 @@ class HikeMapDB {
this.db.exec(`ALTER TABLE monster_skills ADD COLUMN custom_icon TEXT`); this.db.exec(`ALTER TABLE monster_skills ADD COLUMN custom_icon TEXT`);
} catch (e) { /* Column already exists */ } } catch (e) { /* Column already exists */ }
// Player animation for class skills - allows admin to assign animations per class skill override
try {
this.db.exec(`ALTER TABLE class_skills ADD COLUMN player_animation TEXT DEFAULT NULL`);
} catch (e) { /* Column already exists */ }
// OSM Tag settings - global prefix configuration // OSM Tag settings - global prefix configuration
this.db.exec(` this.db.exec(`
CREATE TABLE IF NOT EXISTS osm_tag_settings ( CREATE TABLE IF NOT EXISTS osm_tag_settings (
@ -655,7 +668,7 @@ class HikeMapDB {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
SELECT character_name, race, class, level, xp, hp, max_hp, mp, max_mp, atk, def, accuracy, dodge, SELECT character_name, race, class, level, xp, hp, max_hp, mp, max_mp, atk, def, accuracy, dodge,
unlocked_skills, active_skills, unlocked_skills, active_skills,
home_base_lat, home_base_lng, last_home_set, is_dead, home_base_icon, data_version
home_base_lat, home_base_lng, last_home_set, is_dead, home_base_icon, data_version, wander_range
FROM rpg_stats WHERE user_id = ? FROM rpg_stats WHERE user_id = ?
`); `);
return stmt.get(userId); return stmt.get(userId);
@ -736,8 +749,8 @@ class HikeMapDB {
const newVersion = currentVersion + 1; const newVersion = currentVersion + 1;
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
INSERT INTO rpg_stats (user_id, character_name, race, class, level, xp, hp, max_hp, mp, max_mp, atk, def, accuracy, dodge, unlocked_skills, active_skills, home_base_lat, home_base_lng, last_home_set, is_dead, data_version, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
INSERT INTO rpg_stats (user_id, character_name, race, class, level, xp, hp, max_hp, mp, max_mp, atk, def, accuracy, dodge, unlocked_skills, active_skills, home_base_lat, home_base_lng, last_home_set, is_dead, wander_range, data_version, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET ON CONFLICT(user_id) DO UPDATE SET
character_name = COALESCE(excluded.character_name, rpg_stats.character_name), character_name = COALESCE(excluded.character_name, rpg_stats.character_name),
race = COALESCE(excluded.race, rpg_stats.race), race = COALESCE(excluded.race, rpg_stats.race),
@ -758,6 +771,7 @@ class HikeMapDB {
home_base_lng = COALESCE(excluded.home_base_lng, rpg_stats.home_base_lng), home_base_lng = COALESCE(excluded.home_base_lng, rpg_stats.home_base_lng),
last_home_set = COALESCE(excluded.last_home_set, rpg_stats.last_home_set), last_home_set = COALESCE(excluded.last_home_set, rpg_stats.last_home_set),
is_dead = COALESCE(excluded.is_dead, rpg_stats.is_dead), is_dead = COALESCE(excluded.is_dead, rpg_stats.is_dead),
wander_range = COALESCE(excluded.wander_range, rpg_stats.wander_range),
data_version = excluded.data_version, data_version = excluded.data_version,
updated_at = datetime('now') updated_at = datetime('now')
`); `);
@ -785,6 +799,7 @@ class HikeMapDB {
stats.homeBaseLng || null, stats.homeBaseLng || null,
stats.lastHomeSet || null, stats.lastHomeSet || null,
stats.isDead !== undefined ? (stats.isDead ? 1 : 0) : null, stats.isDead !== undefined ? (stats.isDead ? 1 : 0) : null,
stats.wanderRange || 200,
newVersion newVersion
); );
@ -965,8 +980,8 @@ class HikeMapDB {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
INSERT INTO monster_types (id, name, icon, base_hp, base_atk, base_def, xp_reward, 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, level_scale_hp, level_scale_atk, level_scale_def, min_level, max_level, spawn_weight, dialogues, enabled,
base_mp, level_scale_mp, attack_animation, death_animation, idle_animation, spawn_location)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
base_mp, level_scale_mp, attack_animation, death_animation, idle_animation, miss_animation, spawn_location)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
// Support both camelCase (legacy) and snake_case (new admin UI) field names // Support both camelCase (legacy) and snake_case (new admin UI) field names
const baseHp = monsterData.baseHp || monsterData.base_hp; const baseHp = monsterData.baseHp || monsterData.base_hp;
@ -992,6 +1007,7 @@ class HikeMapDB {
const attackAnim = monsterData.attack_animation || monsterData.attackAnimation || 'attack'; const attackAnim = monsterData.attack_animation || monsterData.attackAnimation || 'attack';
const deathAnim = monsterData.death_animation || monsterData.deathAnimation || 'death'; const deathAnim = monsterData.death_animation || monsterData.deathAnimation || 'death';
const idleAnim = monsterData.idle_animation || monsterData.idleAnimation || 'idle'; const idleAnim = monsterData.idle_animation || monsterData.idleAnimation || 'idle';
const missAnim = monsterData.miss_animation || monsterData.missAnimation || 'miss';
// Spawn location restriction // Spawn location restriction
const spawnLocation = monsterData.spawn_location || monsterData.spawnLocation || 'anywhere'; const spawnLocation = monsterData.spawn_location || monsterData.spawnLocation || 'anywhere';
@ -1016,6 +1032,7 @@ class HikeMapDB {
attackAnim, attackAnim,
deathAnim, deathAnim,
idleAnim, idleAnim,
missAnim,
spawnLocation spawnLocation
); );
} }
@ -1027,7 +1044,7 @@ class HikeMapDB {
xp_reward = ?, level_scale_hp = ?, level_scale_atk = ?, level_scale_def = ?, xp_reward = ?, level_scale_hp = ?, level_scale_atk = ?, level_scale_def = ?,
min_level = ?, max_level = ?, spawn_weight = ?, dialogues = ?, enabled = ?, min_level = ?, max_level = ?, spawn_weight = ?, dialogues = ?, enabled = ?,
base_mp = ?, level_scale_mp = ?, attack_animation = ?, death_animation = ?, idle_animation = ?, base_mp = ?, level_scale_mp = ?, attack_animation = ?, death_animation = ?, idle_animation = ?,
spawn_location = ?
miss_animation = ?, spawn_location = ?
WHERE id = ? WHERE id = ?
`); `);
// Support both camelCase (legacy) and snake_case (new admin UI) field names // Support both camelCase (legacy) and snake_case (new admin UI) field names
@ -1054,6 +1071,7 @@ class HikeMapDB {
const attackAnim = monsterData.attack_animation || monsterData.attackAnimation || 'attack'; const attackAnim = monsterData.attack_animation || monsterData.attackAnimation || 'attack';
const deathAnim = monsterData.death_animation || monsterData.deathAnimation || 'death'; const deathAnim = monsterData.death_animation || monsterData.deathAnimation || 'death';
const idleAnim = monsterData.idle_animation || monsterData.idleAnimation || 'idle'; const idleAnim = monsterData.idle_animation || monsterData.idleAnimation || 'idle';
const missAnim = monsterData.miss_animation || monsterData.missAnimation || 'miss';
// Spawn location restriction // Spawn location restriction
const spawnLocation = monsterData.spawn_location || monsterData.spawnLocation || 'anywhere'; const spawnLocation = monsterData.spawn_location || monsterData.spawnLocation || 'anywhere';
@ -1077,6 +1095,7 @@ class HikeMapDB {
attackAnim, attackAnim,
deathAnim, deathAnim,
idleAnim, idleAnim,
missAnim,
spawnLocation, spawnLocation,
id id
); );
@ -1802,8 +1821,8 @@ class HikeMapDB {
createClassSkill(data) { createClassSkill(data) {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
INSERT INTO class_skills (class_id, skill_id, unlock_level, choice_group, custom_name, custom_description)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO class_skills (class_id, skill_id, unlock_level, choice_group, custom_name, custom_description, player_animation)
VALUES (?, ?, ?, ?, ?, ?, ?)
`); `);
return stmt.run( return stmt.run(
data.class_id || data.classId, data.class_id || data.classId,
@ -1811,23 +1830,44 @@ class HikeMapDB {
data.unlock_level || data.unlockLevel || 1, data.unlock_level || data.unlockLevel || 1,
data.choice_group || data.choiceGroup || null, data.choice_group || data.choiceGroup || null,
data.custom_name || data.customName || null, data.custom_name || data.customName || null,
data.custom_description || data.customDescription || null
data.custom_description || data.customDescription || null,
data.player_animation || data.playerAnimation || null
); );
} }
updateClassSkill(id, data) { updateClassSkill(id, data) {
// Build dynamic update - only update fields that are provided
const updates = [];
const values = [];
if (data.unlock_level !== undefined || data.unlockLevel !== undefined) {
updates.push('unlock_level = ?');
values.push(data.unlock_level || data.unlockLevel || 1);
}
if (data.choice_group !== undefined || data.choiceGroup !== undefined) {
updates.push('choice_group = ?');
values.push(data.choice_group !== undefined ? data.choice_group : (data.choiceGroup || null));
}
if (data.custom_name !== undefined || data.customName !== undefined) {
updates.push('custom_name = ?');
values.push(data.custom_name !== undefined ? data.custom_name : (data.customName || null));
}
if (data.custom_description !== undefined || data.customDescription !== undefined) {
updates.push('custom_description = ?');
values.push(data.custom_description !== undefined ? data.custom_description : (data.customDescription || null));
}
if (data.player_animation !== undefined || data.playerAnimation !== undefined) {
updates.push('player_animation = ?');
values.push(data.player_animation !== undefined ? data.player_animation : (data.playerAnimation || null));
}
if (updates.length === 0) return { changes: 0 };
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
UPDATE class_skills SET
unlock_level = ?, choice_group = ?, custom_name = ?, custom_description = ?
WHERE id = ?
UPDATE class_skills SET ${updates.join(', ')} WHERE id = ?
`); `);
return stmt.run(
data.unlock_level || data.unlockLevel || 1,
data.choice_group || data.choiceGroup || null,
data.custom_name || data.customName || null,
data.custom_description || data.customDescription || null,
id
);
values.push(id);
return stmt.run(...values);
} }
deleteClassSkill(id) { deleteClassSkill(id) {

272
geocaches.json

@ -126,9 +126,18 @@
"tags": [ "tags": [
"grocery" "grocery"
], ],
"messages": [],
"messages": [
{
"author": "Melancholytron",
"text": "Beware the George!",
"timestamp": 1767887532080
}
],
"createdAt": 1736309000000, "createdAt": 1736309000000,
"alerted": false
"alerted": false,
"artwork": null,
"spawnRadius": 200,
"artworkNum": 1
}, },
{ {
"id": "gc_grocery_heb", "id": "gc_grocery_heb",
@ -143,7 +152,10 @@
], ],
"messages": [], "messages": [],
"createdAt": 1736309000000, "createdAt": 1736309000000,
"alerted": false
"alerted": false,
"artwork": null,
"spawnRadius": 150,
"artworkNum": 1
}, },
{ {
"id": "gc_osm_588035848", "id": "gc_osm_588035848",
@ -779,7 +791,7 @@
"id": "gc_osm_7744500838", "id": "gc_osm_7744500838",
"lat": 30.5026623, "lat": 30.5026623,
"lng": -97.8210497, "lng": -97.8210497,
"title": "Teriyaki Tom\u2019s",
"title": "Teriyaki Toms",
"icon": "silverware-fork-knife", "icon": "silverware-fork-knife",
"color": "#e91e63", "color": "#e91e63",
"visibilityDistance": 50, "visibilityDistance": 50,
@ -2394,5 +2406,257 @@
"messages": [], "messages": [],
"createdAt": 1736400000000, "createdAt": 1736400000000,
"alerted": false "alerted": false
},
{
"id": "gc_osm_29169603",
"lat": 30.5236988,
"lng": -97.8321183,
"title": "Chili's",
"icon": "silverware-fork-knife",
"color": "#4CAF50",
"tags": [
"restaurant"
],
"messages": [],
"createdAt": 1768018168162,
"autoDiscovered": true
},
{
"id": "gc_osm_29169617",
"lat": 30.5208648,
"lng": -97.8307028,
"title": "Taqueria La Tapatia",
"icon": "silverware-fork-knife",
"color": "#4CAF50",
"tags": [
"restaurant"
],
"messages": [],
"createdAt": 1768018168162,
"autoDiscovered": true
},
{
"id": "gc_osm_29195102",
"lat": 30.5115987,
"lng": -97.8245959,
"title": "El Patron Tacos & More",
"icon": "silverware-fork-knife",
"color": "#4CAF50",
"tags": [
"restaurant"
],
"messages": [],
"createdAt": 1768018168162,
"autoDiscovered": true
},
{
"id": "gc_osm_103477381",
"lat": 30.5298695,
"lng": -97.8160544,
"title": "Buffalo Wild Wings",
"icon": "silverware-fork-knife",
"color": "#4CAF50",
"tags": [
"restaurant"
],
"messages": [],
"createdAt": 1768018168162,
"autoDiscovered": true
},
{
"id": "gc_osm_103480088",
"lat": 30.5229788,
"lng": -97.8212726,
"title": "IHOP",
"icon": "silverware-fork-knife",
"color": "#4CAF50",
"tags": [
"restaurant"
],
"messages": [],
"createdAt": 1768018168162,
"autoDiscovered": true
},
{
"id": "gc_osm_131466568",
"lat": 30.5267884,
"lng": -97.8134028,
"title": "Jack Allen's Kitchen",
"icon": "silverware-fork-knife",
"color": "#4CAF50",
"tags": [
"restaurant"
],
"messages": [],
"createdAt": 1768018168162,
"autoDiscovered": true
},
{
"id": "gc_osm_344620444",
"lat": 30.5255136,
"lng": -97.8173785,
"title": "Tumble 22",
"icon": "silverware-fork-knife",
"color": "#4CAF50",
"tags": [
"restaurant"
],
"messages": [],
"createdAt": 1768018168162,
"autoDiscovered": true
},
{
"id": "gc_osm_344620447",
"lat": 30.5251465,
"lng": -97.8188497,
"title": "BJ's",
"icon": "silverware-fork-knife",
"color": "#4CAF50",
"tags": [
"restaurant"
],
"messages": [],
"createdAt": 1768018168162,
"autoDiscovered": true
},
{
"id": "gc_osm_344620450",
"lat": 30.5260406,
"lng": -97.8177921,
"title": "Lupe Tortilla Mexican Restaurant",
"icon": "silverware-fork-knife",
"color": "#4CAF50",
"tags": [
"restaurant"
],
"messages": [],
"createdAt": 1768018168162,
"autoDiscovered": true
},
{
"id": "gc_osm_744508526",
"lat": 30.5322274,
"lng": -97.8183006,
"title": "Chuy's",
"icon": "silverware-fork-knife",
"color": "#4CAF50",
"tags": [
"restaurant"
],
"messages": [],
"createdAt": 1768018168162,
"autoDiscovered": true
},
{
"id": "gc_osm_865199012",
"lat": 30.5300743,
"lng": -97.8184667,
"title": "Jason's Deli",
"icon": "silverware-fork-knife",
"color": "#4CAF50",
"tags": [
"restaurant"
],
"messages": [],
"createdAt": 1768018168162,
"autoDiscovered": true
},
{
"id": "gc_osm_865199014",
"lat": 30.5330055,
"lng": -97.8181929,
"title": "Red Robin",
"icon": "silverware-fork-knife",
"color": "#4CAF50",
"tags": [
"restaurant"
],
"messages": [],
"createdAt": 1768018168162,
"autoDiscovered": true
},
{
"id": "gc_osm_1452010758",
"lat": 30.5153358,
"lng": -97.8491592,
"title": "Masala Grill",
"icon": "silverware-fork-knife",
"color": "#4CAF50",
"tags": [
"restaurant"
],
"messages": [],
"createdAt": 1768018168162,
"autoDiscovered": true
},
{
"id": "gc_osm_1452010759",
"lat": 30.5156305,
"lng": -97.8489589,
"title": "Tacos Las Mamis",
"icon": "silverware-fork-knife",
"color": "#4CAF50",
"tags": [
"restaurant"
],
"messages": [],
"createdAt": 1768018168162,
"autoDiscovered": true
},
{
"id": "gc_osm_1453752902",
"lat": 30.5358753,
"lng": -97.815246,
"title": "Five Four Restaurant & Drafthouse - Cedar Park",
"icon": "silverware-fork-knife",
"color": "#4CAF50",
"tags": [
"restaurant"
],
"messages": [],
"createdAt": 1768018168162,
"autoDiscovered": true
},
{
"id": "gc_osm_555259415",
"lat": 30.5358552,
"lng": -97.8177075,
"title": "Whole Foods Market",
"icon": "cart",
"color": "#4CAF50",
"tags": [
"grocery"
],
"messages": [],
"createdAt": 1768077245617,
"autoDiscovered": true
},
{
"id": "gc_osm_605094231",
"lat": 30.5430089,
"lng": -97.8652297,
"title": "Randalls",
"icon": "cart",
"color": "#4CAF50",
"tags": [
"grocery"
],
"messages": [],
"createdAt": 1768077245617,
"autoDiscovered": true
},
{
"id": "gc_osm_964446665",
"lat": 30.5277795,
"lng": -97.8157961,
"title": "Natural Grocers",
"icon": "cart",
"color": "#4CAF50",
"tags": [
"grocery"
],
"messages": [],
"createdAt": 1768077245617,
"autoDiscovered": true
} }
] ]

2234
index.html
File diff suppressed because it is too large
View File

BIN
mapgameimgs/monsters/karen_base100.png

After

Width: 100  |  Height: 100  |  Size: 11 KiB

BIN
mapgameimgs/monsters/karen_base50.png

After

Width: 50  |  Height: 50  |  Size: 4.8 KiB

BIN
mapgameimgs/skills/full_restore.png

Before

Width: 50  |  Height: 50  |  Size: 3.1 KiB

After

Width: 50  |  Height: 50  |  Size: 3.1 KiB

BIN
mapgamemusic/homebase.mp3

4
server.js

@ -853,6 +853,7 @@ app.get('/api/user/rpg-stats', authenticateToken, (req, res) => {
isDead: !!stats.is_dead, isDead: !!stats.is_dead,
homeBaseIcon: stats.home_base_icon || '00', homeBaseIcon: stats.home_base_icon || '00',
revealRadius: stats.reveal_radius || 800, revealRadius: stats.reveal_radius || 800,
wanderRange: stats.wander_range || 200,
dataVersion: stats.data_version || 1 dataVersion: stats.data_version || 1
}); });
} else { } else {
@ -1378,6 +1379,7 @@ app.get('/api/monster-types', (req, res) => {
attackAnimation: t.attack_animation || 'attack', attackAnimation: t.attack_animation || 'attack',
deathAnimation: t.death_animation || 'death', deathAnimation: t.death_animation || 'death',
idleAnimation: t.idle_animation || 'idle', idleAnimation: t.idle_animation || 'idle',
missAnimation: t.miss_animation || 'miss',
// Spawn location restriction // Spawn location restriction
spawnLocation: t.spawn_location || 'anywhere' spawnLocation: t.spawn_location || 'anywhere'
})); }));
@ -2742,9 +2744,11 @@ wss.on('connection', (ws) => {
} else if (data.type === 'geocacheUpdate') { } else if (data.type === 'geocacheUpdate') {
// Handle geocache creation/update // Handle geocache creation/update
if (data.geocache) { if (data.geocache) {
console.log(`[GEOCACHE] Received update for cache ${data.geocache.id}, spawnRadius=${data.geocache.spawnRadius}, tags=${JSON.stringify(data.geocache.tags)}`);
const existingIndex = geocaches.findIndex(g => g.id === data.geocache.id); const existingIndex = geocaches.findIndex(g => g.id === data.geocache.id);
if (existingIndex >= 0) { if (existingIndex >= 0) {
// Update existing geocache // Update existing geocache
console.log(`[GEOCACHE] Updating existing cache at index ${existingIndex}`);
geocaches[existingIndex] = data.geocache; geocaches[existingIndex] = data.geocache;
} else { } else {
// Add new geocache // Add new geocache

7
service-worker.js

@ -1,6 +1,6 @@
// HikeMap Service Worker // HikeMap Service Worker
// Increment version to force cache refresh // Increment version to force cache refresh
const CACHE_NAME = 'hikemap-v1.2.0';
const CACHE_NAME = 'hikemap-v1.5.0';
const urlsToCache = [ const urlsToCache = [
'/', '/',
'/index.html', '/index.html',
@ -9,9 +9,8 @@ const urlsToCache = [
'/animations.js', '/animations.js',
'/icon-192x192.png', '/icon-192x192.png',
'/icon-512x512.png', '/icon-512x512.png',
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css',
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js',
'https://unpkg.com/leaflet-rotate@0.2.8/dist/leaflet-rotate-src.js',
'https://unpkg.com/maplibre-gl@4.1.0/dist/maplibre-gl.css',
'https://unpkg.com/maplibre-gl@4.1.0/dist/maplibre-gl.js',
'https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css' 'https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css'
]; ];

Loading…
Cancel
Save