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 0000000..0ef3704 Binary files /dev/null and b/sfx/monster_spawn.mp3 differ