diff --git a/CLAUDE.md b/CLAUDE.md
index 6ea8c5b..0d980ac 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -29,13 +29,25 @@ Then open http://localhost:8080
| `index.html` | Frontend SPA (CSS, HTML, JavaScript) |
| `server.js` | Express API server, WebSocket handling |
| `database.js` | SQLite database layer (better-sqlite3) |
+| `animations.js` | Combat/monster animation definitions |
| `docker-compose.yml` | Container configuration |
| `hikemap.db` | SQLite database (auto-created) |
| `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
-- **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)
+- **Note**: Codebase has Leaflet compatibility shims for gradual migration
---
@@ -63,12 +75,27 @@ Then open http://localhost:8080
- Required to respawn after death
- 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
**Monster Spawning**:
- Monsters spawn while walking (configurable distance)
- No spawns within home base radius
- Multiple monsters can accumulate (entourage)
+- Spawn sound effect plays when monster appears (`sfx/` directory)
**Combat Flow**:
1. Player taps monster to engage
@@ -83,6 +110,14 @@ damage = (skillPower * playerATK / 100) - enemyDEF
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 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.
+**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
**Trail Runner** (currently the only class):
@@ -221,21 +262,53 @@ db.swapActiveSkill(userId, tier, newSkillId) // Swap loadout
### Key State Variables
```javascript
+// RPG State
playerStats // Player RPG data
combatState // Active combat info
monsterEntourage // Spawned monsters
statsLoadedFromServer // Prevents stale saves
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
```javascript
+// RPG & Combat
initializePlayerStats(username) // Load from server
savePlayerStats() // Save to server
showSkillChoiceModal(level) // Level-up skill pick
swapSkillFromHomebase(tier, id) // Change loadout
startCombat(monsters) // Begin 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 |
|------|--------|-------|
-| `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 |
| `mapgamemusic/*` | Browser refresh | Volume-mounted |
| `sfx/*` | Browser refresh | Volume-mounted |
| `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`):
| File | Why Rebuild? |
diff --git a/README.md b/README.md
index a3242da..15ce56d 100644
--- a/README.md
+++ b/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
- Skill-based combat with damage, healing, buffs, and multi-hit attacks
- Hit/miss mechanics based on accuracy and dodge stats
+- Animated combat with synchronized damage timing
- XP rewards and leveling system
**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
- 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
- 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`
- **Backend**: Node.js/Express server in `server.js`
- **Database**: SQLite via better-sqlite3 in `database.js`
+- **Animations**: Combat animation definitions in `animations.js`
- **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
-- 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)
- better-sqlite3 (database)
- jsonwebtoken (authentication)
diff --git a/admin.html b/admin.html
index c7ff222..a617b3a 100644
--- a/admin.html
+++ b/admin.html
@@ -580,15 +580,102 @@
}
.monster-skill-item .skill-animation {
- width: 100px;
+ width: 90px;
padding: 4px;
- font-size: 12px;
+ font-size: 11px;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 4px;
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 {
font-size: 11px;
color: #888;
@@ -1322,6 +1409,12 @@
+
+ Miss Animation
+
+
+
+
Animation Preview
@@ -1506,6 +1599,33 @@
+
+
+
Combat Buff Configuration
+
+ Configure the in-combat buff effect (e.g., Defense Up, Accuracy Up)
+
+
+
+
Utility Skill Configuration
@@ -1525,6 +1645,7 @@
XP Multiplier
Explore Radius Multiplier
Homebase Radius Multiplier
+ Wander Range Multiplier
`;
diff --git a/animations.js b/animations.js
index b747c8a..43447a6 100644
--- a/animations.js
+++ b/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
+// 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 = {
// Default attack animation - rubber band snap towards player
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
if (typeof window !== 'undefined') {
window.MONSTER_ANIMATIONS = MONSTER_ANIMATIONS;
+ window.PLAYER_ANIMATIONS = PLAYER_ANIMATIONS;
window.getAnimationList = getAnimationList;
+ window.getPlayerAnimationList = getPlayerAnimationList;
+ window.getAnimation = getAnimation;
}
diff --git a/artwork_todo.md b/artwork_todo.md
index f2c1cc9..0a43076 100644
--- a/artwork_todo.md
+++ b/artwork_todo.md
@@ -47,10 +47,10 @@ Track all emoji replacements and custom artwork needed. All icons should follow
| Emoji | Race | Art File | Status |
|-------|------|----------|--------|
-| π€ | Human | `races/human.png` | [ ] |
+| π§ | Human | `races/human.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.
-| 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 |
| UI Elements | 9 | Medium |
| 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**
---
diff --git a/database.js b/database.js
index c893e82..1b9beab 100644
--- a/database.js
+++ b/database.js
@@ -291,6 +291,11 @@ class HikeMapDB {
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN map_theme TEXT`);
} 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
try {
this.db.exec(`ALTER TABLE monster_types ADD COLUMN attack_animation TEXT DEFAULT 'attack'`);
@@ -301,6 +306,9 @@ class HikeMapDB {
try {
this.db.exec(`ALTER TABLE monster_types ADD COLUMN idle_animation TEXT DEFAULT 'idle'`);
} 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
try {
@@ -369,6 +377,11 @@ class HikeMapDB {
this.db.exec(`ALTER TABLE monster_skills ADD COLUMN custom_icon TEXT`);
} 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
this.db.exec(`
CREATE TABLE IF NOT EXISTS osm_tag_settings (
@@ -655,7 +668,7 @@ class HikeMapDB {
const stmt = this.db.prepare(`
SELECT 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, 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 = ?
`);
return stmt.get(userId);
@@ -736,8 +749,8 @@ class HikeMapDB {
const newVersion = currentVersion + 1;
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
character_name = COALESCE(excluded.character_name, rpg_stats.character_name),
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),
last_home_set = COALESCE(excluded.last_home_set, rpg_stats.last_home_set),
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,
updated_at = datetime('now')
`);
@@ -785,6 +799,7 @@ class HikeMapDB {
stats.homeBaseLng || null,
stats.lastHomeSet || null,
stats.isDead !== undefined ? (stats.isDead ? 1 : 0) : null,
+ stats.wanderRange || 200,
newVersion
);
@@ -965,8 +980,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, 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
const baseHp = monsterData.baseHp || monsterData.base_hp;
@@ -992,6 +1007,7 @@ class HikeMapDB {
const attackAnim = monsterData.attack_animation || monsterData.attackAnimation || 'attack';
const deathAnim = monsterData.death_animation || monsterData.deathAnimation || 'death';
const idleAnim = monsterData.idle_animation || monsterData.idleAnimation || 'idle';
+ const missAnim = monsterData.miss_animation || monsterData.missAnimation || 'miss';
// Spawn location restriction
const spawnLocation = monsterData.spawn_location || monsterData.spawnLocation || 'anywhere';
@@ -1016,6 +1032,7 @@ class HikeMapDB {
attackAnim,
deathAnim,
idleAnim,
+ missAnim,
spawnLocation
);
}
@@ -1027,7 +1044,7 @@ class HikeMapDB {
xp_reward = ?, 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 = ?
+ miss_animation = ?, spawn_location = ?
WHERE id = ?
`);
// 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 deathAnim = monsterData.death_animation || monsterData.deathAnimation || 'death';
const idleAnim = monsterData.idle_animation || monsterData.idleAnimation || 'idle';
+ const missAnim = monsterData.miss_animation || monsterData.missAnimation || 'miss';
// Spawn location restriction
const spawnLocation = monsterData.spawn_location || monsterData.spawnLocation || 'anywhere';
@@ -1077,6 +1095,7 @@ class HikeMapDB {
attackAnim,
deathAnim,
idleAnim,
+ missAnim,
spawnLocation,
id
);
@@ -1802,8 +1821,8 @@ class HikeMapDB {
createClassSkill(data) {
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(
data.class_id || data.classId,
@@ -1811,23 +1830,44 @@ class HikeMapDB {
data.unlock_level || data.unlockLevel || 1,
data.choice_group || data.choiceGroup || 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) {
+ // 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(`
- 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) {
diff --git a/geocaches.json b/geocaches.json
index d0e6335..b634eb0 100644
--- a/geocaches.json
+++ b/geocaches.json
@@ -126,9 +126,18 @@
"tags": [
"grocery"
],
- "messages": [],
+ "messages": [
+ {
+ "author": "Melancholytron",
+ "text": "Beware the George!",
+ "timestamp": 1767887532080
+ }
+ ],
"createdAt": 1736309000000,
- "alerted": false
+ "alerted": false,
+ "artwork": null,
+ "spawnRadius": 200,
+ "artworkNum": 1
},
{
"id": "gc_grocery_heb",
@@ -143,7 +152,10 @@
],
"messages": [],
"createdAt": 1736309000000,
- "alerted": false
+ "alerted": false,
+ "artwork": null,
+ "spawnRadius": 150,
+ "artworkNum": 1
},
{
"id": "gc_osm_588035848",
@@ -779,7 +791,7 @@
"id": "gc_osm_7744500838",
"lat": 30.5026623,
"lng": -97.8210497,
- "title": "Teriyaki Tom\u2019s",
+ "title": "Teriyaki Tomβs",
"icon": "silverware-fork-knife",
"color": "#e91e63",
"visibilityDistance": 50,
@@ -2394,5 +2406,257 @@
"messages": [],
"createdAt": 1736400000000,
"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
}
]
\ No newline at end of file
diff --git a/index.html b/index.html
index c60f21e..03128b1 100644
--- a/index.html
+++ b/index.html
@@ -1954,6 +1954,7 @@
width: 32px;
height: 32px;
object-fit: contain;
+ image-rendering: pixelated;
}
.char-sheet-monster .monster-info {
flex: 1;
@@ -2039,6 +2040,130 @@
font-weight: bold;
}
+ /* Monster Spawn Modal */
+ .monster-spawn-modal {
+ background: linear-gradient(135deg, #1a1a2e 0%, #0f0f23 100%);
+ border-radius: 16px;
+ padding: 24px;
+ max-width: 400px;
+ width: 90%;
+ border: 2px solid #ff6b35;
+ box-shadow: 0 0 30px rgba(255, 107, 53, 0.3);
+ }
+ .monster-spawn-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+ }
+ .monster-spawn-header h2 {
+ color: #ff6b35;
+ margin: 0;
+ font-size: 24px;
+ }
+ .monster-spawn-close {
+ background: none;
+ border: none;
+ color: #aaa;
+ font-size: 28px;
+ cursor: pointer;
+ padding: 0;
+ line-height: 1;
+ }
+ .monster-spawn-close:hover {
+ color: #fff;
+ }
+ .monster-spawn-search {
+ position: relative;
+ margin-bottom: 16px;
+ }
+ .monster-spawn-search input {
+ width: 100%;
+ padding: 12px 16px;
+ font-size: 16px;
+ border: 2px solid #333;
+ border-radius: 8px;
+ background: rgba(0, 0, 0, 0.4);
+ color: #fff;
+ box-sizing: border-box;
+ }
+ .monster-spawn-search input:focus {
+ outline: none;
+ border-color: #ff6b35;
+ }
+ .monster-search-results {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ background: #1a1a2e;
+ border: 2px solid #333;
+ border-top: none;
+ border-radius: 0 0 8px 8px;
+ max-height: 250px;
+ overflow-y: auto;
+ z-index: 10;
+ display: none;
+ }
+ .monster-search-results.active {
+ display: block;
+ }
+ .monster-search-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px;
+ cursor: pointer;
+ transition: background 0.2s;
+ border-bottom: 1px solid #333;
+ }
+ .monster-search-item:last-child {
+ border-bottom: none;
+ }
+ .monster-search-item:hover {
+ background: rgba(255, 107, 53, 0.2);
+ }
+ .monster-search-item img {
+ width: 40px;
+ height: 40px;
+ object-fit: contain;
+ image-rendering: pixelated;
+ }
+ .monster-search-item .monster-info {
+ flex: 1;
+ }
+ .monster-search-item .monster-name {
+ font-weight: bold;
+ color: #fff;
+ }
+ .monster-search-item .monster-level-range {
+ font-size: 12px;
+ color: #888;
+ }
+ .monster-spawn-options {
+ margin-top: 16px;
+ }
+ .monster-spawn-option {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ color: #ccc;
+ }
+ .monster-spawn-option input[type="number"] {
+ width: 70px;
+ padding: 8px 12px;
+ font-size: 14px;
+ border: 2px solid #333;
+ border-radius: 6px;
+ background: rgba(0, 0, 0, 0.4);
+ color: #fff;
+ text-align: center;
+ }
+ .monster-spawn-option input[type="number"]:focus {
+ outline: none;
+ border-color: #ff6b35;
+ }
+
/* User Profile Display */
.user-profile {
display: flex;
@@ -2266,6 +2391,10 @@
border: none !important;
pointer-events: auto !important;
touch-action: none;
+ /* Prevent blurry subpixel rendering */
+ image-rendering: pixelated;
+ image-rendering: -moz-crisp-edges;
+ image-rendering: crisp-edges;
}
.monster-marker {
position: relative;
@@ -2275,8 +2404,8 @@
display: flex;
align-items: center;
justify-content: center;
- width: 70px;
- height: 70px;
+ width: 50px;
+ height: 50px;
/* Semi-transparent background for tap area */
background: radial-gradient(circle, rgba(255,100,100,0.25) 0%, rgba(255,100,100,0) 70%);
border-radius: 50%;
@@ -2298,18 +2427,33 @@
}
@keyframes monster-combat-zoom {
0% { transform: scale(1); }
- 100% { transform: scale(5); }
+ 100% { transform: scale(1); } /* Container stays same size */
+ }
+ /* Animate the image directly for crisp scaling */
+ .monster-marker.combat-zoom-in .monster-icon {
+ animation: monster-icon-combat-zoom 1.2s ease-in-out forwards;
+ filter: none; /* Remove filter during zoom to prevent rasterization */
+ }
+ @keyframes monster-icon-combat-zoom {
+ 0% { transform: scale(1) translateY(0) translateZ(0); }
+ 100% { transform: scale(5) translateY(0) translateZ(0); } /* 50px * 5 = 250px */
}
.monster-icon {
width: 50px;
height: 50px;
object-fit: contain;
- filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.6));
+ image-rendering: pixelated;
+ image-rendering: -moz-crisp-edges;
+ image-rendering: crisp-edges;
+ /* Prevent subpixel antialiasing blur */
+ transform: translateZ(0);
+ backface-visibility: hidden;
+ -webkit-backface-visibility: hidden;
animation: monster-bob 2s ease-in-out infinite;
}
@keyframes monster-bob {
- 0%, 100% { transform: translateY(0); }
- 50% { transform: translateY(-6px); }
+ 0%, 100% { transform: translateY(0) translateZ(0); }
+ 50% { transform: translateY(-6px) translateZ(0); }
}
.monster-dialogue-bubble {
position: absolute;
@@ -2399,6 +2543,53 @@
color: #aaa;
}
+ /* Wander Distance Indicator */
+ .wander-distance-indicator {
+ position: fixed;
+ top: 90px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: rgba(0, 0, 0, 0.85);
+ color: #ff9800;
+ padding: 6px 14px;
+ border-radius: 16px;
+ font-size: 12px;
+ font-weight: bold;
+ z-index: 999;
+ border: 2px solid #ff9800;
+ box-shadow: 0 2px 10px rgba(255, 152, 0, 0.3);
+ }
+ .wander-distance-indicator.warning {
+ color: #ff5722;
+ border-color: #ff5722;
+ animation: pulse-warning 0.5s ease-in-out infinite;
+ }
+ @keyframes pulse-warning {
+ 0%, 100% { box-shadow: 0 2px 10px rgba(255, 87, 34, 0.3); }
+ 50% { box-shadow: 0 2px 20px rgba(255, 87, 34, 0.6); }
+ }
+
+ /* Wander Danger Overlay - flashing red/black warning when beyond wander range */
+ .wander-danger-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(200, 0, 0, 0.5);
+ pointer-events: none;
+ z-index: 998;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ }
+ .wander-danger-overlay.active {
+ animation: danger-siren 0.5s ease-in-out infinite;
+ }
+ @keyframes danger-siren {
+ 0%, 100% { background: rgba(200, 0, 0, 0.5); }
+ 50% { background: rgba(0, 0, 0, 0.5); }
+ }
+
/* Combat Overlay Styles */
.combat-overlay {
position: fixed;
@@ -2498,6 +2689,45 @@
color: #aaa;
margin-top: 5px;
}
+ /* Mini enemy roster - small icons showing all enemies */
+ .mini-enemy-roster {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 8px;
+ padding: 8px;
+ margin-bottom: 8px;
+ background: rgba(0, 0, 0, 0.3);
+ border-radius: 8px;
+ min-height: 35px;
+ }
+ .mini-enemy-icon {
+ width: 25px;
+ height: 25px;
+ object-fit: contain;
+ image-rendering: pixelated;
+ border: 2px solid #444;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: border-color 0.2s, opacity 0.2s, transform 0.1s;
+ }
+ .mini-enemy-icon:hover {
+ border-color: #888;
+ transform: scale(1.1);
+ }
+ .mini-enemy-icon.selected {
+ border-color: #e94560;
+ box-shadow: 0 0 6px rgba(233, 69, 96, 0.5);
+ }
+ .mini-enemy-icon.dead {
+ opacity: 0.4;
+ filter: grayscale(100%);
+ }
+ .mini-enemy-icon.attacking {
+ border-color: #ff6b35;
+ box-shadow: 0 0 6px rgba(255, 107, 53, 0.6);
+ }
+
.combat-log {
background: rgba(0, 0, 0, 0.5);
border-radius: 8px;
@@ -2787,6 +3017,7 @@
width: var(--combat-icon-size);
height: var(--combat-icon-size);
object-fit: contain;
+ image-rendering: pixelated;
}
.monster-entry .sprite-container {
position: relative;
@@ -3023,55 +3254,6 @@
grid-column: 3;
grid-row: 2;
}
- .wasd-mode-indicator {
- position: absolute;
- top: -25px;
- left: 50%;
- transform: translateX(-50%);
- background: rgba(0, 0, 0, 0.7);
- color: #4fc3f7;
- padding: 3px 8px;
- border-radius: 4px;
- font-size: 10px;
- 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 {
@@ -3668,6 +3850,8 @@
+
+
N
@@ -3695,18 +3879,19 @@
+
+
+ 0m / 200m
+
+
π
π΅
-
- π§
-
-
-
-
TEST MODE
+
+
β²
β
βΌ
@@ -3786,6 +3971,8 @@
+
+
@@ -3891,23 +4078,26 @@
Geocache Title
-