Browse Source

Add location-restricted monster spawning system

- Add spawn_location column to monster_types table (database.js)
- Add tags array to geocaches for location tagging (geocaches.json)
- Add spawn location dropdown in admin panel monster editor (admin.html)
- Update spawn logic to filter monsters by location restriction (index.html)
- Update name prefix logic to use tags instead of icons
- Add grocery tag to Walmart and H-E-B geocaches

Monsters can now be configured to only spawn near specific location types
(grocery, bank, park, restaurant). The "George the Moop" monster can be
set to spawn_location="grocery" to only appear near grocery stores.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
master
HikeMap User 1 month ago
parent
commit
3fa88f554f
  1. 13
      admin.html
  2. 18
      database.js
  3. 2
      geocaches.json
  4. 32
      index.html
  5. 8
      server.js

13
admin.html

@ -1057,6 +1057,16 @@
<label>Spawn Weight (higher = more common)</label> <label>Spawn Weight (higher = more common)</label>
<input type="number" id="monsterWeight" required value="100" min="1"> <input type="number" id="monsterWeight" required value="100" min="1">
</div> </div>
<div class="form-group">
<label>Spawn Location</label>
<select id="monsterSpawnLocation">
<option value="anywhere">Anywhere</option>
<option value="grocery">Grocery Stores Only</option>
<option value="bank">Banks Only</option>
<option value="park">Parks Only</option>
<option value="restaurant">Restaurants Only</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label> <label>
<input type="checkbox" id="monsterEnabled" checked> Enabled <input type="checkbox" id="monsterEnabled" checked> Enabled
@ -1869,6 +1879,7 @@
document.getElementById('monsterMpScale').value = monster.level_scale_mp || 5; document.getElementById('monsterMpScale').value = monster.level_scale_mp || 5;
document.getElementById('monsterXp').value = monster.base_xp; document.getElementById('monsterXp').value = monster.base_xp;
document.getElementById('monsterWeight').value = monster.spawn_weight || 100; document.getElementById('monsterWeight').value = monster.spawn_weight || 100;
document.getElementById('monsterSpawnLocation').value = monster.spawn_location || 'anywhere';
document.getElementById('monsterEnabled').checked = monster.enabled; document.getElementById('monsterEnabled').checked = monster.enabled;
// Parse dialogues // Parse dialogues
@ -1921,6 +1932,7 @@
document.getElementById('monsterMpScale').value = monster.level_scale_mp || 5; document.getElementById('monsterMpScale').value = monster.level_scale_mp || 5;
document.getElementById('monsterXp').value = monster.base_xp; document.getElementById('monsterXp').value = monster.base_xp;
document.getElementById('monsterWeight').value = monster.spawn_weight || 100; document.getElementById('monsterWeight').value = monster.spawn_weight || 100;
document.getElementById('monsterSpawnLocation').value = monster.spawn_location || 'anywhere';
document.getElementById('monsterEnabled').checked = false; // Disabled by default document.getElementById('monsterEnabled').checked = false; // Disabled by default
// Parse and copy dialogues // Parse and copy dialogues
@ -2051,6 +2063,7 @@
base_mp: parseInt(document.getElementById('monsterMp').value), base_mp: parseInt(document.getElementById('monsterMp').value),
base_xp: parseInt(document.getElementById('monsterXp').value), base_xp: parseInt(document.getElementById('monsterXp').value),
spawn_weight: parseInt(document.getElementById('monsterWeight').value), spawn_weight: parseInt(document.getElementById('monsterWeight').value),
spawn_location: document.getElementById('monsterSpawnLocation').value,
levelScale: { mp: parseInt(document.getElementById('monsterMpScale').value) || 5 }, levelScale: { mp: parseInt(document.getElementById('monsterMpScale').value) || 5 },
enabled: document.getElementById('monsterEnabled').checked, enabled: document.getElementById('monsterEnabled').checked,
attack_animation: document.getElementById('monsterAttackAnim').value, attack_animation: document.getElementById('monsterAttackAnim').value,

18
database.js

@ -149,6 +149,9 @@ class HikeMapDB {
try { try {
this.db.exec(`ALTER TABLE monster_types ADD COLUMN spawn_weight INTEGER DEFAULT 100`); this.db.exec(`ALTER TABLE monster_types ADD COLUMN spawn_weight INTEGER DEFAULT 100`);
} catch (e) { /* Column already exists */ } } catch (e) { /* Column already exists */ }
try {
this.db.exec(`ALTER TABLE monster_types ADD COLUMN spawn_location TEXT DEFAULT 'anywhere'`);
} catch (e) { /* Column already exists */ }
// Skills table - defines available skills/spells // Skills table - defines available skills/spells
this.db.exec(` this.db.exec(`
@ -868,8 +871,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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
base_mp, level_scale_mp, attack_animation, death_animation, idle_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;
@ -895,6 +898,8 @@ 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';
// Spawn location restriction
const spawnLocation = monsterData.spawn_location || monsterData.spawnLocation || 'anywhere';
return stmt.run( return stmt.run(
monsterData.id || monsterData.key, monsterData.id || monsterData.key,
@ -916,7 +921,8 @@ class HikeMapDB {
levelScale.mp || 5, levelScale.mp || 5,
attackAnim, attackAnim,
deathAnim, deathAnim,
idleAnim
idleAnim,
spawnLocation
); );
} }
@ -926,7 +932,8 @@ class HikeMapDB {
name = ?, icon = ?, base_hp = ?, base_atk = ?, base_def = ?, name = ?, icon = ?, base_hp = ?, base_atk = ?, base_def = ?,
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 = ?
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
@ -953,6 +960,8 @@ 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';
// Spawn location restriction
const spawnLocation = monsterData.spawn_location || monsterData.spawnLocation || 'anywhere';
return stmt.run( return stmt.run(
monsterData.name, monsterData.name,
@ -974,6 +983,7 @@ class HikeMapDB {
attackAnim, attackAnim,
deathAnim, deathAnim,
idleAnim, idleAnim,
spawnLocation,
id id
); );
} }

2
geocaches.json

@ -123,6 +123,7 @@
"icon": "cart", "icon": "cart",
"color": "#0071ce", "color": "#0071ce",
"visibilityDistance": 50, "visibilityDistance": 50,
"tags": ["grocery"],
"messages": [], "messages": [],
"createdAt": 1736309000000, "createdAt": 1736309000000,
"alerted": false "alerted": false
@ -135,6 +136,7 @@
"icon": "cart", "icon": "cart",
"color": "#e31837", "color": "#e31837",
"visibilityDistance": 50, "visibilityDistance": 50,
"tags": ["grocery"],
"messages": [], "messages": [],
"createdAt": 1736309000000, "createdAt": 1736309000000,
"alerted": false "alerted": false

32
index.html

@ -13401,15 +13401,35 @@
} }
// Pick a random monster type that the player can encounter at their level // Pick a random monster type that the player can encounter at their level
// Only include monsters whose minLevel <= player level
// Only include monsters whose minLevel <= player level AND spawn location matches
const playerLevel = playerStats.level; const playerLevel = playerStats.level;
const playerPos = L.latLng(userLocation.lat, userLocation.lng);
// Helper: check if player is near a geocache with a specific tag
function isNearTaggedLocation(tag, maxDist = 400) {
for (const cache of geocaches) {
if (cache.tags && cache.tags.includes(tag)) {
const dist = playerPos.distanceTo(L.latLng(cache.lat, cache.lng));
if (dist <= maxDist) return true;
}
}
return false;
}
const eligibleTypes = Object.entries(MONSTER_TYPES).filter(([id, type]) => { const eligibleTypes = Object.entries(MONSTER_TYPES).filter(([id, type]) => {
const minLevel = type.minLevel || 1; const minLevel = type.minLevel || 1;
return minLevel <= playerLevel;
if (minLevel > playerLevel) return false;
// Check spawn location restriction
const spawnLoc = type.spawnLocation || 'anywhere';
if (spawnLoc === 'anywhere') return true;
// Monster has a location restriction - check if player is near matching geocache
return isNearTaggedLocation(spawnLoc);
}); });
if (eligibleTypes.length === 0) { if (eligibleTypes.length === 0) {
console.log('No eligible monster types for player level', playerLevel);
console.log('No eligible monster types for player level', playerLevel, 'at this location');
return; return;
} }
@ -13456,10 +13476,12 @@
namePrefix: '' // Will be set below based on location namePrefix: '' // Will be set below based on location
}; };
// Check if spawning near a special location (e.g., grocery store)
// Check if spawning near a special location and set name prefix
const spawnPos = L.latLng(monster.position.lat, monster.position.lng); const spawnPos = L.latLng(monster.position.lat, monster.position.lng);
for (const cache of geocaches) { for (const cache of geocaches) {
if (cache.icon === 'cart') { // Grocery stores use cart icon
// Use tags if available, fall back to icon check for backwards compatibility
const isGrocery = (cache.tags && cache.tags.includes('grocery')) || cache.icon === 'cart';
if (isGrocery) {
const dist = spawnPos.distanceTo(L.latLng(cache.lat, cache.lng)); const dist = spawnPos.distanceTo(L.latLng(cache.lat, cache.lng));
if (dist <= 400) { if (dist <= 400) {
monster.namePrefix = "Cart Wranglin' "; monster.namePrefix = "Cart Wranglin' ";

8
server.js

@ -1313,7 +1313,9 @@ app.get('/api/monster-types', (req, res) => {
// Animation overrides // Animation overrides
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',
// Spawn location restriction
spawnLocation: t.spawn_location || 'anywhere'
})); }));
res.json(formatted); res.json(formatted);
} catch (err) { } catch (err) {
@ -1501,7 +1503,9 @@ app.get('/api/admin/monster-types', adminOnly, (req, res) => {
// Animation overrides // Animation overrides
attack_animation: t.attack_animation || 'attack', attack_animation: t.attack_animation || 'attack',
death_animation: t.death_animation || 'death', death_animation: t.death_animation || 'death',
idle_animation: t.idle_animation || 'idle'
idle_animation: t.idle_animation || 'idle',
// Spawn location restriction
spawn_location: t.spawn_location || 'anywhere'
})); }));
res.json({ monsterTypes: formatted }); res.json({ monsterTypes: formatted });
} catch (err) { } catch (err) {

Loading…
Cancel
Save