From 9139b1c94de7bf00bef93e2b693288d3a5a42be1 Mon Sep 17 00:00:00 2001 From: HikeMap User Date: Fri, 9 Jan 2026 08:44:15 -0600 Subject: [PATCH] Add fog of war system and monster spawn sound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fog of war: map dimmed in nav mode, homebase reveals 800m radius - Geocaches only visible within revealed area - Added reveal_radius to player stats for future leveling - Added monster_spawn.mp3 sound effect on monster spawn - Fixed spin_grow animation (smoother, counter-clockwise) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- animations.js | 29 +++ database.js | 32 ++- index.html | 560 ++++++++++++++++++++++++++++++++++++++++-- server.js | 7 + sfx/monster_spawn.mp3 | Bin 0 -> 12717 bytes 5 files changed, 601 insertions(+), 27 deletions(-) create mode 100755 sfx/monster_spawn.mp3 diff --git a/animations.js b/animations.js index b805f13..b747c8a 100644 --- a/animations.js +++ b/animations.js @@ -78,6 +78,21 @@ const MONSTER_ANIMATIONS = { ` }, + // Bouncy dance - faster, more energetic idle + bouncy: { + name: 'Bouncy Dance', + description: 'Fast energetic bouncing dance', + duration: 600, + loop: true, + easing: 'ease-in-out', + keyframes: ` + 0%, 100% { transform: translateY(0) rotate(-5deg) scale(1); } + 25% { transform: translateY(-8px) rotate(5deg) scale(1.1); } + 50% { transform: translateY(0) rotate(-5deg) scale(0.9); } + 75% { transform: translateY(-8px) rotate(5deg) scale(1.1); } + ` + }, + // Flip Y animation - spin 360 degrees around vertical axis (like opening a door) flipy: { name: 'Flip Y', @@ -128,6 +143,20 @@ const MONSTER_ANIMATIONS = { 0%, 100% { transform: scale(1); } 50% { transform: scale(0.5); } ` + }, + + // Slow spinning with grow/shrink - loopable, smooth like a rotating plant + spin_grow: { + name: 'Spin & Grow', + description: 'Smooth slow spin with pulsing size', + duration: 4000, + loop: true, + easing: 'linear', + keyframes: ` + 0% { transform: rotateZ(0deg) scale(1); } + 50% { transform: rotateZ(-180deg) scale(1.15); } + 100% { transform: rotateZ(-360deg) scale(1); } + ` } }; diff --git a/database.js b/database.js index 33b0829..8fdf3ea 100644 --- a/database.js +++ b/database.js @@ -282,6 +282,9 @@ class HikeMapDB { try { this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN home_base_icon TEXT DEFAULT '00'`); } catch (e) { /* Column already exists */ } + try { + this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN reveal_radius INTEGER DEFAULT 800`); + } catch (e) { /* Column already exists */ } // Migration: Add animation overrides to monster_types try { @@ -329,7 +332,9 @@ class HikeMapDB { CREATE TABLE IF NOT EXISTS osm_tags ( id TEXT PRIMARY KEY, prefixes TEXT NOT NULL DEFAULT '[]', - icon TEXT DEFAULT 'map-marker', + artwork INTEGER DEFAULT 1, + animation TEXT DEFAULT NULL, + animation_shadow TEXT DEFAULT NULL, visibility_distance INTEGER DEFAULT 400, spawn_radius INTEGER DEFAULT 400, enabled BOOLEAN DEFAULT 0, @@ -337,6 +342,17 @@ class HikeMapDB { ) `); + // Migration: Add artwork and animation columns if they don't exist (for existing databases) + try { + this.db.exec(`ALTER TABLE osm_tags ADD COLUMN artwork INTEGER DEFAULT 1`); + } catch (e) { /* Column already exists */ } + try { + this.db.exec(`ALTER TABLE osm_tags ADD COLUMN animation TEXT DEFAULT NULL`); + } catch (e) { /* Column already exists */ } + try { + this.db.exec(`ALTER TABLE osm_tags ADD COLUMN animation_shadow 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 ( @@ -2265,8 +2281,8 @@ class HikeMapDB { createOsmTag(tagData) { const stmt = this.db.prepare(` - INSERT INTO osm_tags (id, prefixes, icon, visibility_distance, spawn_radius, enabled) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO osm_tags (id, prefixes, artwork, animation, animation_shadow, visibility_distance, spawn_radius, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) `); const prefixes = Array.isArray(tagData.prefixes) ? JSON.stringify(tagData.prefixes) @@ -2277,7 +2293,9 @@ class HikeMapDB { return stmt.run( tagData.id, prefixes, - tagData.icon || 'map-marker', + tagData.artwork || 1, + tagData.animation || null, + tagData.animation_shadow || null, tagData.visibility_distance || tagData.visibilityDistance || 400, tagData.spawn_radius || tagData.spawnRadius || 400, enabled @@ -2287,7 +2305,7 @@ class HikeMapDB { updateOsmTag(id, tagData) { const stmt = this.db.prepare(` UPDATE osm_tags SET - prefixes = ?, icon = ?, visibility_distance = ?, spawn_radius = ?, enabled = ? + prefixes = ?, artwork = ?, animation = ?, animation_shadow = ?, visibility_distance = ?, spawn_radius = ?, enabled = ? WHERE id = ? `); const prefixes = Array.isArray(tagData.prefixes) @@ -2298,7 +2316,9 @@ class HikeMapDB { const enabled = parsedPrefixes.length > 0 ? 1 : 0; return stmt.run( prefixes, - tagData.icon || 'map-marker', + tagData.artwork || 1, + tagData.animation || null, + tagData.animation_shadow || null, tagData.visibility_distance || tagData.visibilityDistance || 400, tagData.spawn_radius || tagData.spawnRadius || 400, enabled, diff --git a/index.html b/index.html index 5567ddf..7104e0a 100644 --- a/index.html +++ b/index.html @@ -77,6 +77,17 @@ .leaflet-overlay-pane { z-index: 400 !important; } + /* Fog of War overlay - between overlay and marker panes */ + .leaflet-fog-pane { + z-index: 450 !important; + pointer-events: none; + } + #fogCanvas { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + } .controls { position: absolute; top: 10px; @@ -484,6 +495,37 @@ font-size: 36px; pointer-events: none; /* Parent handles all touches */ } + + /* PNG cache icon styles */ + .geocache-marker-png { + background: none !important; + border: none !important; + } + .cache-icon-container { + position: relative; + width: 64px; + height: 64px; + } + .cache-shadow { + position: absolute; + width: 64px; + height: 64px; + top: 4px; + left: 4px; + z-index: 0; + opacity: 0.5; + pointer-events: none; + } + .cache-main { + position: absolute; + width: 64px; + height: 64px; + top: 0; + left: 0; + z-index: 1; + pointer-events: none; + } + .geocache-dialog { position: fixed !important; top: 0 !important; @@ -4136,6 +4178,145 @@ }; L.control.layers(baseMaps, null, { position: 'bottomleft' }).addTo(map); + // ===================== + // FOG OF WAR SYSTEM + // ===================== + + // Fog of War state variables (must be declared before use) + let fogCanvas = null; + let fogCtx = null; + let playerRevealRadius = 800; + + // Initialize fog of war canvas (directly in map container for simple viewport alignment) + function initFogOfWar() { + const container = map.getContainer(); + fogCanvas = document.createElement('canvas'); + fogCanvas.id = 'fogCanvas'; + fogCanvas.style.position = 'absolute'; + fogCanvas.style.top = '0'; + fogCanvas.style.left = '0'; + fogCanvas.style.zIndex = '450'; + fogCanvas.style.pointerEvents = 'none'; + container.appendChild(fogCanvas); + fogCtx = fogCanvas.getContext('2d'); + resizeFogCanvas(); + } + + // Resize fog canvas to match map container + function resizeFogCanvas() { + if (!fogCanvas) return; + const container = map.getContainer(); + fogCanvas.width = container.clientWidth; + fogCanvas.height = container.clientHeight; + updateFogOfWar(); + } + + // Helper: Calculate destination point from lat/lng + distance (meters) + bearing (degrees) + function destinationPoint(latlng, distance, bearing) { + const R = 6371000; // Earth radius in meters + const d = distance / R; + const brng = bearing * Math.PI / 180; + const lat1 = latlng.lat * Math.PI / 180; + const lng1 = latlng.lng * Math.PI / 180; + + const lat2 = Math.asin( + Math.sin(lat1) * Math.cos(d) + Math.cos(lat1) * Math.sin(d) * Math.cos(brng) + ); + const lng2 = lng1 + Math.atan2( + Math.sin(brng) * Math.sin(d) * Math.cos(lat1), + Math.cos(d) - Math.sin(lat1) * Math.sin(lat2) + ); + + return L.latLng(lat2 * 180 / Math.PI, lng2 * 180 / Math.PI); + } + + // Flag to track if fog system is ready (navMode must exist) + let fogSystemReady = false; + + // Main fog rendering function + function updateFogOfWar() { + // Skip if fog system not ready yet (navMode not defined) + if (!fogSystemReady) return; + + // Lazy initialization + if (!fogCanvas) { + initFogOfWar(); + } + + if (!fogCtx || !fogCanvas) return; + + const width = fogCanvas.width; + const height = fogCanvas.height; + + // Clear canvas + fogCtx.clearRect(0, 0, width, height); + + // In edit mode, no fog + if (!navMode) { + return; + } + + // Draw semi-transparent fog over entire canvas + fogCtx.fillStyle = 'rgba(0, 0, 0, 0.6)'; + fogCtx.fillRect(0, 0, width, height); + + // If no homebase set, full fog + if (!playerStats || playerStats.homeBaseLat == null || playerStats.homeBaseLng == null) { + return; + } + + // Calculate reveal circle center using container coordinates (viewport-relative) + const homeLatLng = L.latLng(playerStats.homeBaseLat, playerStats.homeBaseLng); + const centerPoint = map.latLngToContainerPoint(homeLatLng); + + // Calculate radius in pixels using map projection + const edgeLatLng = destinationPoint(homeLatLng, playerRevealRadius, 90); + const edgePoint = map.latLngToContainerPoint(edgeLatLng); + const radiusPixels = Math.abs(edgePoint.x - centerPoint.x); + + // Cut out revealed area using composite operation + fogCtx.save(); + fogCtx.globalCompositeOperation = 'destination-out'; + + // Create gradient for soft edge + const gradient = fogCtx.createRadialGradient( + centerPoint.x, centerPoint.y, radiusPixels * 0.85, + centerPoint.x, centerPoint.y, radiusPixels + ); + gradient.addColorStop(0, 'rgba(0, 0, 0, 1)'); + gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); + + fogCtx.beginPath(); + fogCtx.arc(centerPoint.x, centerPoint.y, radiusPixels, 0, Math.PI * 2); + fogCtx.fillStyle = gradient; + fogCtx.fill(); + + fogCtx.restore(); + } + + // Check if a location is within the revealed area + function isInRevealedArea(lat, lng) { + // If no homebase, nothing is revealed + if (!playerStats || playerStats.homeBaseLat == null || playerStats.homeBaseLng == null) { + return false; + } + + // Calculate distance from homebase + const distance = L.latLng(playerStats.homeBaseLat, playerStats.homeBaseLng) + .distanceTo(L.latLng(lat, lng)); + + return distance <= playerRevealRadius; + } + + // Hook fog updates to map events (fog will be initialized lazily) + map.on('move', updateFogOfWar); + map.on('zoom', updateFogOfWar); + map.on('resize', resizeFogCanvas); + map.on('rotate', updateFogOfWar); + + // NOTE: initFogOfWar() is called lazily from updateFogOfWar() to avoid + // temporal dead zone issues with navMode variable + // Admin Settings (loaded from localStorage or defaults) let adminSettings = { geocacheRange: 5, @@ -4163,6 +4344,8 @@ const easing = anim.easing || 'ease-out'; css += `@keyframes monster_${id} { ${anim.keyframes} }\n`; css += `.anim-${id} { animation: monster_${id} ${anim.duration}ms ${easing}${loopStr}${fillStr}; }\n`; + // Also generate cache animation classes (reuse same keyframes) + css += `.cache-anim-${id} { animation: monster_${id} ${anim.duration}ms ${easing}${loopStr}${fillStr}; }\n`; } const style = document.createElement('style'); style.id = 'monster-animations-css'; @@ -4254,6 +4437,9 @@ let navMode = false; let destinationPin = null; + // Now that navMode exists, fog system can be used + fogSystemReady = true; + // Multi-user tracking let ws = null; let userId = null; @@ -4269,6 +4455,11 @@ const CACHE_RESET_DISTANCE = 200; // meters to reset cooldown const DESTINATION_ARRIVAL_DISTANCE = 10; // meters let wsReconnectTimer = null; + let wsHeartbeatTimer = null; // Client-side ping timer + let wsLastPong = 0; // Timestamp of last successful pong/message + let wsConnected = false; // Connection state for UI indicator + const WS_HEARTBEAT_INTERVAL = 20000; // Send ping every 20 seconds + const WS_PONG_TIMEOUT = 10000; // Consider dead if no response in 10s let myIcon = null; let myColor = null; let isNearTrack = false; @@ -4446,7 +4637,9 @@ const prefixes = typeof t.prefixes === 'string' ? JSON.parse(t.prefixes || '[]') : (t.prefixes || []); OSM_TAGS[t.id] = { prefixes: prefixes, - icon: t.icon, + artwork: t.artwork || 1, + animation: t.animation || null, + animationShadow: t.animation_shadow || null, visibilityDistance: t.visibility_distance, spawnRadius: t.spawn_radius, enabled: t.enabled @@ -4548,6 +4741,11 @@ minLevel: t.minLevel || 1, maxLevel: t.maxLevel || 99, spawnWeight: t.spawnWeight || 100, + spawnLocation: t.spawnLocation || 'anywhere', // Location restriction for spawning + // Animation settings + idleAnimation: t.idleAnimation || 'idle', + attackAnimation: t.attackAnimation || 'attack', + deathAnimation: t.deathAnimation || 'death', levelScale: { hp: t.levelScale?.hp || 10, atk: t.levelScale?.atk || 2, @@ -4559,6 +4757,10 @@ }); monsterTypesLoaded = true; console.log('Loaded monster types from database:', Object.keys(MONSTER_TYPES)); + // DEBUG: Show spawnLocation for each monster type + Object.entries(MONSTER_TYPES).forEach(([id, type]) => { + console.log(`[MONSTER TYPE] ${id}: spawnLocation = "${type.spawnLocation}"`); + }); } } catch (err) { console.error('Failed to load monster types:', err); @@ -4919,13 +5121,14 @@ monster_attack: new Audio('/sfx/monster_attack.mp3'), monster_skill: new Audio('/sfx/monster_skill.mp3'), monster_death: new Audio('/sfx/monster_death.mp3'), + monster_spawn: new Audio('/sfx/monster_spawn.mp3'), volume: parseFloat(localStorage.getItem('sfxVolume') || '0.5'), muted: localStorage.getItem('sfxMuted') === 'true' }; // Initialize SFX function initSfx() { - const sfxNames = ['missed', 'player_attack', 'player_skill', 'monster_attack', 'monster_skill', 'monster_death']; + const sfxNames = ['missed', 'player_attack', 'player_skill', 'monster_attack', 'monster_skill', 'monster_death', 'monster_spawn']; sfxNames.forEach(sfx => { const audio = gameSfx[sfx]; audio.preload = 'auto'; @@ -6459,8 +6662,29 @@ // In edit mode, always show all geocaches if (!navMode) return true; - // If no visibility restriction, always show - if (!geocache.visibilityDistance || geocache.visibilityDistance === 0) return true; + // Check fog of war - cache must be in revealed area + if (!isInRevealedArea(geocache.lat, geocache.lng)) { + return false; + } + + // Determine visibility distance: OSM tag setting takes precedence over per-cache setting + let visibilityDist = 0; + + // Check OSM tag configuration first (this is the admin-controlled setting) + if (geocache.tags && geocache.tags.length > 0) { + const tagConfig = OSM_TAGS[geocache.tags[0]]; + if (tagConfig && tagConfig.visibilityDistance) { + visibilityDist = tagConfig.visibilityDistance; + } + } + + // Fall back to per-cache setting if OSM tag doesn't have one + if (!visibilityDist && geocache.visibilityDistance) { + visibilityDist = geocache.visibilityDistance; + } + + // If no visibility restriction from either source, always show + if (!visibilityDist || visibilityDist === 0) return true; // In nav mode, only show if user is within visibility distance if (!userLocation) return false; @@ -6468,7 +6692,7 @@ const distance = L.latLng(userLocation.lat, userLocation.lng) .distanceTo(L.latLng(geocache.lat, geocache.lng)); - return distance <= geocache.visibilityDistance; + return distance <= visibilityDist; } function placeGeocache(latlng) { @@ -6490,7 +6714,24 @@ showGeocacheDialog(geocache, true); // true = can add message immediately } + // Check if a geocache has any tags with prefixes configured + function geocacheHasPrefixes(geocache) { + if (!geocache.tags || geocache.tags.length === 0) return false; + for (const tagId of geocache.tags) { + const tagConfig = OSM_TAGS[tagId]; + if (tagConfig && tagConfig.prefixes && tagConfig.prefixes.length > 0) { + return true; + } + } + return false; + } + function createGeocacheMarker(geocache) { + // Only show geocaches that have tags with prefixes configured + if (!geocacheHasPrefixes(geocache)) { + return; // Skip caches without any prefix-enabled tags + } + // Check visibility based on mode and distance if (!shouldShowGeocache(geocache)) { console.log(`Geocache ${geocache.id} not visible due to distance restriction`); @@ -6498,17 +6739,44 @@ } console.log(`Creating geocache marker for ${geocache.id} at ${geocache.lat}, ${geocache.lng}`); - // Use geocache's custom icon and color - const iconClass = `mdi-${geocache.icon || 'package-variant'}`; - const color = geocache.color || '#FFA726'; + + // Get artwork number and animations from the first OSM tag + let artworkNum = 1; + let animation = null; + let animationShadow = null; + if (geocache.tags && geocache.tags.length > 0) { + const tagConfig = OSM_TAGS[geocache.tags[0]]; + if (tagConfig) { + artworkNum = tagConfig.artwork || 1; + animation = tagConfig.animation || null; + animationShadow = tagConfig.animationShadow || null; + } + } + + // Build PNG icon with shadow layer + const basePath = 'mapgameimgs/cacheicons/cacheIcon100-'; + const padNum = String(artworkNum).padStart(2, '0'); + const mainSrc = `${basePath}${padNum}.png`; + const shadowSrc = `${basePath}${padNum}_shadow.png`; // In edit mode, make secret caches slightly transparent const opacity = (!navMode && geocache.visibilityDistance > 0) ? 0.7 : 1.0; + // Animation classes for main and shadow layers (independent) + const mainAnimClass = animation ? `cache-anim-${animation}` : ''; + const shadowAnimClass = animationShadow ? `cache-anim-${animationShadow}` : ''; + + const iconHtml = ` +
+ + +
+ `; + const marker = L.marker([geocache.lat, geocache.lng], { icon: L.divIcon({ - className: 'geocache-marker', - html: ``, + className: 'geocache-marker-png', + html: iconHtml, iconSize: [64, 64], iconAnchor: [32, 32] // Centered for intuitive mobile tapping }), @@ -6765,14 +7033,16 @@ function updateGeocacheVisibility() { // Update visibility of all geocache markers based on current user location geocaches.forEach(cache => { - const shouldShow = shouldShowGeocache(cache); + // Only consider caches that have tags with prefixes configured + const hasPrefixes = geocacheHasPrefixes(cache); + const shouldShow = hasPrefixes && shouldShowGeocache(cache); const marker = geocacheMarkers[cache.id]; if (shouldShow && !marker) { // Create marker if it should be visible but doesn't exist createGeocacheMarker(cache); } else if (!shouldShow && marker) { - // Remove marker if it shouldn't be visible + // Remove marker if it shouldn't be visible (or lost its prefixes) map.removeLayer(marker); delete geocacheMarkers[cache.id]; } @@ -7035,6 +7305,18 @@ ws.onopen = () => { console.log('Connected to multi-user tracking'); clearTimeout(wsReconnectTimer); + wsConnected = true; + wsLastPong = Date.now(); + updateConnectionIndicator(true); + + // Start client-side heartbeat + startWsHeartbeat(); + + // Flush any pending stats immediately on reconnect + if (statsSyncState.dirty) { + console.log('[WS] Reconnected - flushing pending stats'); + flushStatsSync(); + } // Register authenticated user for real-time updates if (currentUser && currentUser.id) { @@ -7058,7 +7340,16 @@ }; ws.onmessage = (event) => { + // Update last pong time on any message received + wsLastPong = Date.now(); + const data = JSON.parse(event.data); + + // Handle pong response from server + if (data.type === 'pong') { + return; // Just update lastPong timestamp, no further processing + } + console.log('[WS] Received message type:', data.type); switch (data.type) { @@ -7171,12 +7462,30 @@ break; case 'admin_update': - // Admin made a change - refresh the page + // Admin made a change console.log('Admin update:', data.changeType, data.details); - showNotification(`Game data updated: ${data.changeType} - refreshing...`, 'info'); - // Stop saving stats to prevent version conflicts during reload - statsLoadedFromServer = false; - setTimeout(() => location.reload(), 1500); + + // Handle monster type updates without full page reload + if (data.changeType === 'monster') { + showNotification('Monster types updated - applying changes...', 'info'); + // Reload monster types and update existing monster markers + loadMonsterTypes().then(() => { + // Update markers for any spawned monsters to reflect new animations + monsterEntourage.forEach(monster => { + if (monster.marker) { + monster.marker.remove(); + } + createMonsterMarker(monster); + }); + console.log('Monster types reloaded, markers updated'); + }); + } else { + // For other changes, refresh the page + showNotification(`Game data updated: ${data.changeType} - refreshing...`, 'info'); + // Stop saving stats to prevent version conflicts during reload + statsLoadedFromServer = false; + setTimeout(() => location.reload(), 1500); + } break; case 'geocacheUpdate': @@ -7266,19 +7575,144 @@ ws.onclose = () => { console.log('WebSocket disconnected'); + wsConnected = false; + stopWsHeartbeat(); + updateConnectionIndicator(false); + + // Save progress to localStorage immediately as safety net (with timestamp) + if (playerStats) { + const backupStats = { ...playerStats, localSaveTimestamp: Date.now() }; + localStorage.setItem('hikemap_rpg_stats', JSON.stringify(backupStats)); + console.log('[WS] Disconnected - saved backup to localStorage'); + } + // Attempt reconnect after 3 seconds wsReconnectTimer = setTimeout(connectWebSocket, 3000); }; ws.onerror = (error) => { console.error('WebSocket error:', error); + wsConnected = false; + updateConnectionIndicator(false); }; } catch (err) { console.error('Failed to create WebSocket:', err); + wsConnected = false; + updateConnectionIndicator(false); } } + // Client-side heartbeat to detect zombie connections + function startWsHeartbeat() { + stopWsHeartbeat(); // Clear any existing timer + + wsHeartbeatTimer = setInterval(() => { + if (!ws || ws.readyState !== WebSocket.OPEN) { + stopWsHeartbeat(); + return; + } + + // Check if we've received any message recently + const timeSinceLastPong = Date.now() - wsLastPong; + if (timeSinceLastPong > WS_HEARTBEAT_INTERVAL + WS_PONG_TIMEOUT) { + console.warn('[WS] Connection appears dead - no response in', timeSinceLastPong, 'ms'); + wsConnected = false; + updateConnectionIndicator(false); + + // Force close and reconnect + ws.close(); + return; + } + + // Send ping to server + try { + ws.send(JSON.stringify({ type: 'ping' })); + } catch (err) { + console.error('[WS] Failed to send ping:', err); + } + }, WS_HEARTBEAT_INTERVAL); + } + + function stopWsHeartbeat() { + if (wsHeartbeatTimer) { + clearInterval(wsHeartbeatTimer); + wsHeartbeatTimer = null; + } + } + + // Connection state indicator + function updateConnectionIndicator(connected) { + let indicator = document.getElementById('connectionIndicator'); + if (!indicator) { + // Create indicator if it doesn't exist + indicator = document.createElement('div'); + indicator.id = 'connectionIndicator'; + indicator.style.cssText = ` + position: fixed; + top: 10px; + right: 10px; + padding: 6px 12px; + border-radius: 16px; + font-size: 12px; + font-weight: 500; + z-index: 2000; + display: none; + transition: opacity 0.3s, background-color 0.3s; + pointer-events: none; + `; + document.body.appendChild(indicator); + } + + if (connected) { + // Hide indicator when connected (only show when there's a problem) + indicator.style.display = 'none'; + } else { + // Show warning when disconnected + indicator.style.display = 'block'; + indicator.style.backgroundColor = 'rgba(255, 100, 100, 0.9)'; + indicator.style.color = 'white'; + indicator.innerHTML = '⚠️ Reconnecting...'; + } + } + + // Handle tab visibility changes + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + console.log('[WS] Tab became visible - checking connection'); + + // Check if connection is stale + const timeSinceLastPong = Date.now() - wsLastPong; + if (timeSinceLastPong > WS_HEARTBEAT_INTERVAL + WS_PONG_TIMEOUT) { + console.warn('[WS] Connection stale after tab visibility change'); + if (ws) { + ws.close(); + } + // Reconnect immediately + clearTimeout(wsReconnectTimer); + connectWebSocket(); + } else if (ws && ws.readyState === WebSocket.OPEN) { + // Send immediate ping to verify connection + try { + ws.send(JSON.stringify({ type: 'ping' })); + } catch (err) { + console.error('[WS] Failed to send ping on visibility change:', err); + connectWebSocket(); + } + } else { + // WebSocket not open, reconnect + clearTimeout(wsReconnectTimer); + connectWebSocket(); + } + + // Also flush any pending stats + if (statsSyncState.dirty) { + console.log('[WS] Tab visible - flushing pending stats'); + flushStatsSync(); + } + } + }); + function sendLocationToServer(lat, lng, accuracy, visible = true) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ @@ -7827,6 +8261,7 @@ // Show geocache list toggle in edit mode document.getElementById('geocacheListToggle').style.display = 'flex'; + updateFogOfWar(); updateGeocacheVisibility(); // In edit mode, disable auto-center @@ -7857,6 +8292,7 @@ // Hide geocache list toggle in nav mode document.getElementById('geocacheListToggle').style.display = 'none'; document.getElementById('geocacheListSidebar').classList.remove('open'); + updateFogOfWar(); updateGeocacheVisibility(); // Deactivate edit tools when entering nav mode @@ -12235,6 +12671,60 @@ if (response.ok) { const serverStats = await response.json(); if (serverStats && serverStats.name) { + // Check if localStorage has a more recent version (in case of sync issues) + const localSaved = localStorage.getItem('hikemap_rpg_stats'); + if (localSaved) { + try { + const localStats = JSON.parse(localSaved); + const serverVersion = serverStats.dataVersion || 0; + const localVersion = localStats.dataVersion || 0; + const localTimestamp = localStats.localSaveTimestamp || 0; + + // Use localStorage if it has higher version AND matches the same character + // OR if same version but local was saved more recently (failed server sync) + const useLocal = localStats.name === serverStats.name && ( + localVersion > serverVersion || + (localVersion === serverVersion && localTimestamp > Date.now() - 60000) // Local save within last minute + ); + + if (useLocal) { + console.warn(`[SYNC] localStorage has newer data (v${localVersion}) than server (v${serverVersion}) - using local`); + playerStats = localStats; + + // Mark dirty to push local changes to server + statsLoadedFromServer = true; + statsSyncState.dirty = true; + + showNotification('Restored unsaved progress from local backup', 'info'); + + // Show RPG HUD and start game + document.getElementById('rpgHud').style.display = 'flex'; + updateRpgHud(); + updateHomeBaseMarker(); + + // Fetch player buffs (like Second Wind) + await fetchPlayerBuffs(); + + // If player is dead, show death overlay + if (playerStats.isDead) { + document.getElementById('deathOverlay').style.display = 'flex'; + } else { + startMonsterSpawning(); + } + + // Start auto-save and immediately flush + startAutoSave(); + flushStatsSync(); + + console.log('RPG system initialized (from local backup) for', username); + return; + } + } catch (e) { + console.error('Failed to parse local stats for comparison:', e); + } + } + + // Use server stats (normal case) playerStats = serverStats; console.log('Loaded RPG stats from server (fresh):', playerStats); @@ -12249,6 +12739,11 @@ updateRpgHud(); updateHomeBaseMarker(); + // Load reveal radius for fog of war + playerRevealRadius = serverStats.revealRadius || 800; + updateFogOfWar(); + updateGeocacheVisibility(); + // Fetch player buffs (like Second Wind) await fetchPlayerBuffs(); @@ -12315,6 +12810,11 @@ updateRpgHud(); updateHomeBaseMarker(); + // Update fog of war with new reveal radius + playerRevealRadius = serverStats.revealRadius || 800; + updateFogOfWar(); + updateGeocacheVisibility(); + // Handle death state changes if (playerStats.isDead) { document.getElementById('deathOverlay').style.display = 'flex'; @@ -12358,8 +12858,9 @@ if (!statsSyncState.dirty) return; if (!playerStats || !statsLoadedFromServer) return; - // Always save to localStorage immediately as backup - localStorage.setItem('hikemap_rpg_stats', JSON.stringify(playerStats)); + // Always save to localStorage immediately as backup (with local timestamp) + const statsWithTimestamp = { ...playerStats, localSaveTimestamp: Date.now() }; + localStorage.setItem('hikemap_rpg_stats', JSON.stringify(statsWithTimestamp)); // If a save is already in flight, just mark that we need another if (statsSyncState.saveInFlight) { @@ -12995,6 +13496,10 @@ updateHomeBaseMarker(); console.log('Home base set at:', lat, lng); + // Update fog of war to reveal area around new homebase + updateFogOfWar(); + updateGeocacheVisibility(); + // Discover nearby locations via Overpass API discoverNearbyLocations(lat, lng); } else { @@ -13579,6 +14084,10 @@ localStorage.setItem('hikemap_rpg_stats', JSON.stringify(playerStats)); updateRpgHud(); updateHomeBaseMarker(); + // Update fog of war + playerRevealRadius = serverStats.revealRadius || 800; + updateFogOfWar(); + updateGeocacheVisibility(); updateStatus('Stats synced from server', 'info'); } else { console.log(`[SYNC] Local data is current (v${localVersion})`); @@ -13615,8 +14124,11 @@ function spawnMonsterNearPlayer() { if (!userLocation || !playerStats) return; if (playerStats.isDead) return; // Don't spawn when dead + if (combatState) return; // Don't spawn during combat if (monsterEntourage.length >= getMaxMonsters()) return; if (!monsterTypesLoaded || Object.keys(MONSTER_TYPES).length === 0) return; + // Wait for geocaches and OSM tags to load before spawning location-restricted monsters + if (geocaches.length === 0 || !osmTagsLoaded) return; // Don't spawn monsters at home base const distanceToHome = getDistanceToHome(); @@ -13781,6 +14293,9 @@ updateRpgHud(); saveMonsters(); // Persist to server + // Play spawn sound effect + playSfx('monster_spawn'); + // Update last spawn location for movement-based spawning lastSpawnLocation = { lat: userLocation.lat, lng: userLocation.lng }; @@ -14976,12 +15491,15 @@ // Check if already poisoned const existing = combatState.playerStatusEffects.find(e => e.type === effect.type); if (!existing) { + // Scale status effect damage with monster ATK: baseDamage × (1 + ATK/20) + const baseDmg = effect.damage || 5; + const scaledDamage = Math.ceil(baseDmg * (1 + monster.atk / 20)); combatState.playerStatusEffects.push({ type: effect.type, - damage: effect.damage || 5, + damage: scaledDamage, turnsLeft: effect.duration || 3 }); - addCombatLog(`🔥 ${monster.namePrefix || ''}${monster.data.name} uses ${selectedSkill.name}! ${damage} damage + ${effect.type} applied!`, 'damage'); + addCombatLog(`🔥 ${monster.namePrefix || ''}${monster.data.name} uses ${selectedSkill.name}! ${damage} damage + ${effect.type} (${scaledDamage}/turn) applied!`, 'damage'); setTimeout(() => playSfx('monster_skill'), 650); // Sync with animation } else { addCombatLog(`🔥 ${monster.namePrefix || ''}${monster.data.name} uses ${selectedSkill.name}! ${damage} damage! (Already ${effect.type}ed)`, 'damage'); diff --git a/server.js b/server.js index 36b2483..5c291db 100644 --- a/server.js +++ b/server.js @@ -818,6 +818,7 @@ app.get('/api/user/rpg-stats', authenticateToken, (req, res) => { lastHomeSet: stats.last_home_set, isDead: !!stats.is_dead, homeBaseIcon: stats.home_base_icon || '00', + revealRadius: stats.reveal_radius || 800, dataVersion: stats.data_version || 1 }); } else { @@ -2509,6 +2510,12 @@ wss.on('connection', (ws) => { try { const data = JSON.parse(message); + // Handle client ping with pong response + if (data.type === 'ping') { + ws.send(JSON.stringify({ type: 'pong' })); + return; + } + if (data.type === 'auth') { // Check if client has a stale session (server restarted) if (data.serverSessionId && data.serverSessionId !== serverSessionId) { diff --git a/sfx/monster_spawn.mp3 b/sfx/monster_spawn.mp3 new file mode 100755 index 0000000000000000000000000000000000000000..0ef370488e37e016c194ced826db9e3762e3675c GIT binary patch literal 12717 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