@ -77,6 +77,17 @@
.leaflet-overlay-pane {
.leaflet-overlay-pane {
z-index: 400 !important;
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 {
.controls {
position: absolute;
position: absolute;
top: 10px;
top: 10px;
@ -484,6 +495,37 @@
font-size: 36px;
font-size: 36px;
pointer-events: none; /* Parent handles all touches */
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 {
.geocache-dialog {
position: fixed !important;
position: fixed !important;
top: 0 !important;
top: 0 !important;
@ -4136,6 +4178,145 @@
};
};
L.control.layers(baseMaps, null, { position: 'bottomleft' }).addTo(map);
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)
// Admin Settings (loaded from localStorage or defaults)
let adminSettings = {
let adminSettings = {
geocacheRange: 5,
geocacheRange: 5,
@ -4163,6 +4344,8 @@
const easing = anim.easing || 'ease-out';
const easing = anim.easing || 'ease-out';
css += `@keyframes monster_${id} { ${anim.keyframes} }\n`;
css += `@keyframes monster_${id} { ${anim.keyframes} }\n`;
css += `.anim-${id} { animation: monster_${id} ${anim.duration}ms ${easing}${loopStr}${fillStr}; }\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');
const style = document.createElement('style');
style.id = 'monster-animations-css';
style.id = 'monster-animations-css';
@ -4254,6 +4437,9 @@
let navMode = false;
let navMode = false;
let destinationPin = null;
let destinationPin = null;
// Now that navMode exists, fog system can be used
fogSystemReady = true;
// Multi-user tracking
// Multi-user tracking
let ws = null;
let ws = null;
let userId = null;
let userId = null;
@ -4269,6 +4455,11 @@
const CACHE_RESET_DISTANCE = 200; // meters to reset cooldown
const CACHE_RESET_DISTANCE = 200; // meters to reset cooldown
const DESTINATION_ARRIVAL_DISTANCE = 10; // meters
const DESTINATION_ARRIVAL_DISTANCE = 10; // meters
let wsReconnectTimer = null;
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 myIcon = null;
let myColor = null;
let myColor = null;
let isNearTrack = false;
let isNearTrack = false;
@ -4446,7 +4637,9 @@
const prefixes = typeof t.prefixes === 'string' ? JSON.parse(t.prefixes || '[]') : (t.prefixes || []);
const prefixes = typeof t.prefixes === 'string' ? JSON.parse(t.prefixes || '[]') : (t.prefixes || []);
OSM_TAGS[t.id] = {
OSM_TAGS[t.id] = {
prefixes: prefixes,
prefixes: prefixes,
icon: t.icon,
artwork: t.artwork || 1,
animation: t.animation || null,
animationShadow: t.animation_shadow || null,
visibilityDistance: t.visibility_distance,
visibilityDistance: t.visibility_distance,
spawnRadius: t.spawn_radius,
spawnRadius: t.spawn_radius,
enabled: t.enabled
enabled: t.enabled
@ -4548,6 +4741,11 @@
minLevel: t.minLevel || 1,
minLevel: t.minLevel || 1,
maxLevel: t.maxLevel || 99,
maxLevel: t.maxLevel || 99,
spawnWeight: t.spawnWeight || 100,
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: {
levelScale: {
hp: t.levelScale?.hp || 10,
hp: t.levelScale?.hp || 10,
atk: t.levelScale?.atk || 2,
atk: t.levelScale?.atk || 2,
@ -4559,6 +4757,10 @@
});
});
monsterTypesLoaded = true;
monsterTypesLoaded = true;
console.log('Loaded monster types from database:', Object.keys(MONSTER_TYPES));
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) {
} catch (err) {
console.error('Failed to load monster types:', err);
console.error('Failed to load monster types:', err);
@ -4919,13 +5121,14 @@
monster_attack: new Audio('/sfx/monster_attack.mp3'),
monster_attack: new Audio('/sfx/monster_attack.mp3'),
monster_skill: new Audio('/sfx/monster_skill.mp3'),
monster_skill: new Audio('/sfx/monster_skill.mp3'),
monster_death: new Audio('/sfx/monster_death.mp3'),
monster_death: new Audio('/sfx/monster_death.mp3'),
monster_spawn: new Audio('/sfx/monster_spawn.mp3'),
volume: parseFloat(localStorage.getItem('sfxVolume') || '0.5'),
volume: parseFloat(localStorage.getItem('sfxVolume') || '0.5'),
muted: localStorage.getItem('sfxMuted') === 'true'
muted: localStorage.getItem('sfxMuted') === 'true'
};
};
// Initialize SFX
// Initialize SFX
function initSfx() {
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 => {
sfxNames.forEach(sfx => {
const audio = gameSfx[sfx];
const audio = gameSfx[sfx];
audio.preload = 'auto';
audio.preload = 'auto';
@ -6459,8 +6662,29 @@
// In edit mode, always show all geocaches
// In edit mode, always show all geocaches
if (!navMode) return true;
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
// In nav mode, only show if user is within visibility distance
if (!userLocation) return false;
if (!userLocation) return false;
@ -6468,7 +6692,7 @@
const distance = L.latLng(userLocation.lat, userLocation.lng)
const distance = L.latLng(userLocation.lat, userLocation.lng)
.distanceTo(L.latLng(geocache.lat, geocache.lng));
.distanceTo(L.latLng(geocache.lat, geocache.lng));
return distance < = geocache. visibilityDistance ;
return distance < = visibilityDist;
}
}
function placeGeocache(latlng) {
function placeGeocache(latlng) {
@ -6490,7 +6714,24 @@
showGeocacheDialog(geocache, true); // true = can add message immediately
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) {
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
// Check visibility based on mode and distance
if (!shouldShowGeocache(geocache)) {
if (!shouldShowGeocache(geocache)) {
console.log(`Geocache ${geocache.id} not visible due to distance restriction`);
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}`);
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
// In edit mode, make secret caches slightly transparent
const opacity = (!navMode & & geocache.visibilityDistance > 0) ? 0.7 : 1.0;
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 = `
< div class = "cache-icon-container" style = "opacity: ${opacity};" >
< img src = "${shadowSrc}" class = "cache-shadow ${shadowAnimClass}" onerror = "this.style.display='none'" >
< img src = "${mainSrc}" class = "cache-main ${mainAnimClass}" >
< / div >
`;
const marker = L.marker([geocache.lat, geocache.lng], {
const marker = L.marker([geocache.lat, geocache.lng], {
icon: L.divIcon({
icon: L.divIcon({
className: 'geocache-marker',
html: `< i class = "mdi ${iconClass}" style = "color: ${color}; opacity: ${opacity};" > < / i > `,
className: 'geocache-marker-png ',
html: iconHtml ,
iconSize: [64, 64],
iconSize: [64, 64],
iconAnchor: [32, 32] // Centered for intuitive mobile tapping
iconAnchor: [32, 32] // Centered for intuitive mobile tapping
}),
}),
@ -6765,14 +7033,16 @@
function updateGeocacheVisibility() {
function updateGeocacheVisibility() {
// Update visibility of all geocache markers based on current user location
// Update visibility of all geocache markers based on current user location
geocaches.forEach(cache => {
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];
const marker = geocacheMarkers[cache.id];
if (shouldShow & & !marker) {
if (shouldShow & & !marker) {
// Create marker if it should be visible but doesn't exist
// Create marker if it should be visible but doesn't exist
createGeocacheMarker(cache);
createGeocacheMarker(cache);
} else if (!shouldShow & & marker) {
} 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);
map.removeLayer(marker);
delete geocacheMarkers[cache.id];
delete geocacheMarkers[cache.id];
}
}
@ -7035,6 +7305,18 @@
ws.onopen = () => {
ws.onopen = () => {
console.log('Connected to multi-user tracking');
console.log('Connected to multi-user tracking');
clearTimeout(wsReconnectTimer);
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
// Register authenticated user for real-time updates
if (currentUser & & currentUser.id) {
if (currentUser & & currentUser.id) {
@ -7058,7 +7340,16 @@
};
};
ws.onmessage = (event) => {
ws.onmessage = (event) => {
// Update last pong time on any message received
wsLastPong = Date.now();
const data = JSON.parse(event.data);
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);
console.log('[WS] Received message type:', data.type);
switch (data.type) {
switch (data.type) {
@ -7171,12 +7462,30 @@
break;
break;
case 'admin_update':
case 'admin_update':
// Admin made a change - refresh the page
// Admin made a change
console.log('Admin update:', data.changeType, data.details);
console.log('Admin update:', data.changeType, data.details);
// 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');
showNotification(`Game data updated: ${data.changeType} - refreshing...`, 'info');
// Stop saving stats to prevent version conflicts during reload
// Stop saving stats to prevent version conflicts during reload
statsLoadedFromServer = false;
statsLoadedFromServer = false;
setTimeout(() => location.reload(), 1500);
setTimeout(() => location.reload(), 1500);
}
break;
break;
case 'geocacheUpdate':
case 'geocacheUpdate':
@ -7266,18 +7575,143 @@
ws.onclose = () => {
ws.onclose = () => {
console.log('WebSocket disconnected');
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
// Attempt reconnect after 3 seconds
wsReconnectTimer = setTimeout(connectWebSocket, 3000);
wsReconnectTimer = setTimeout(connectWebSocket, 3000);
};
};
ws.onerror = (error) => {
ws.onerror = (error) => {
console.error('WebSocket error:', error);
console.error('WebSocket error:', error);
wsConnected = false;
updateConnectionIndicator(false);
};
};
} catch (err) {
} catch (err) {
console.error('Failed to create WebSocket:', 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) {
function sendLocationToServer(lat, lng, accuracy, visible = true) {
if (ws & & ws.readyState === WebSocket.OPEN) {
if (ws & & ws.readyState === WebSocket.OPEN) {
@ -7827,6 +8261,7 @@
// Show geocache list toggle in edit mode
// Show geocache list toggle in edit mode
document.getElementById('geocacheListToggle').style.display = 'flex';
document.getElementById('geocacheListToggle').style.display = 'flex';
updateFogOfWar();
updateGeocacheVisibility();
updateGeocacheVisibility();
// In edit mode, disable auto-center
// In edit mode, disable auto-center
@ -7857,6 +8292,7 @@
// Hide geocache list toggle in nav mode
// Hide geocache list toggle in nav mode
document.getElementById('geocacheListToggle').style.display = 'none';
document.getElementById('geocacheListToggle').style.display = 'none';
document.getElementById('geocacheListSidebar').classList.remove('open');
document.getElementById('geocacheListSidebar').classList.remove('open');
updateFogOfWar();
updateGeocacheVisibility();
updateGeocacheVisibility();
// Deactivate edit tools when entering nav mode
// Deactivate edit tools when entering nav mode
@ -12235,6 +12671,60 @@
if (response.ok) {
if (response.ok) {
const serverStats = await response.json();
const serverStats = await response.json();
if (serverStats & & serverStats.name) {
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;
playerStats = serverStats;
console.log('Loaded RPG stats from server (fresh):', playerStats);
console.log('Loaded RPG stats from server (fresh):', playerStats);
@ -12249,6 +12739,11 @@
updateRpgHud();
updateRpgHud();
updateHomeBaseMarker();
updateHomeBaseMarker();
// Load reveal radius for fog of war
playerRevealRadius = serverStats.revealRadius || 800;
updateFogOfWar();
updateGeocacheVisibility();
// Fetch player buffs (like Second Wind)
// Fetch player buffs (like Second Wind)
await fetchPlayerBuffs();
await fetchPlayerBuffs();
@ -12315,6 +12810,11 @@
updateRpgHud();
updateRpgHud();
updateHomeBaseMarker();
updateHomeBaseMarker();
// Update fog of war with new reveal radius
playerRevealRadius = serverStats.revealRadius || 800;
updateFogOfWar();
updateGeocacheVisibility();
// Handle death state changes
// Handle death state changes
if (playerStats.isDead) {
if (playerStats.isDead) {
document.getElementById('deathOverlay').style.display = 'flex';
document.getElementById('deathOverlay').style.display = 'flex';
@ -12358,8 +12858,9 @@
if (!statsSyncState.dirty) return;
if (!statsSyncState.dirty) return;
if (!playerStats || !statsLoadedFromServer) 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 a save is already in flight, just mark that we need another
if (statsSyncState.saveInFlight) {
if (statsSyncState.saveInFlight) {
@ -12995,6 +13496,10 @@
updateHomeBaseMarker();
updateHomeBaseMarker();
console.log('Home base set at:', lat, lng);
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
// Discover nearby locations via Overpass API
discoverNearbyLocations(lat, lng);
discoverNearbyLocations(lat, lng);
} else {
} else {
@ -13579,6 +14084,10 @@
localStorage.setItem('hikemap_rpg_stats', JSON.stringify(playerStats));
localStorage.setItem('hikemap_rpg_stats', JSON.stringify(playerStats));
updateRpgHud();
updateRpgHud();
updateHomeBaseMarker();
updateHomeBaseMarker();
// Update fog of war
playerRevealRadius = serverStats.revealRadius || 800;
updateFogOfWar();
updateGeocacheVisibility();
updateStatus('Stats synced from server', 'info');
updateStatus('Stats synced from server', 'info');
} else {
} else {
console.log(`[SYNC] Local data is current (v${localVersion})`);
console.log(`[SYNC] Local data is current (v${localVersion})`);
@ -13615,8 +14124,11 @@
function spawnMonsterNearPlayer() {
function spawnMonsterNearPlayer() {
if (!userLocation || !playerStats) return;
if (!userLocation || !playerStats) return;
if (playerStats.isDead) return; // Don't spawn when dead
if (playerStats.isDead) return; // Don't spawn when dead
if (combatState) return; // Don't spawn during combat
if (monsterEntourage.length >= getMaxMonsters()) return;
if (monsterEntourage.length >= getMaxMonsters()) return;
if (!monsterTypesLoaded || Object.keys(MONSTER_TYPES).length === 0) 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
// Don't spawn monsters at home base
const distanceToHome = getDistanceToHome();
const distanceToHome = getDistanceToHome();
@ -13781,6 +14293,9 @@
updateRpgHud();
updateRpgHud();
saveMonsters(); // Persist to server
saveMonsters(); // Persist to server
// Play spawn sound effect
playSfx('monster_spawn');
// Update last spawn location for movement-based spawning
// Update last spawn location for movement-based spawning
lastSpawnLocation = { lat: userLocation.lat, lng: userLocation.lng };
lastSpawnLocation = { lat: userLocation.lat, lng: userLocation.lng };
@ -14976,12 +15491,15 @@
// Check if already poisoned
// Check if already poisoned
const existing = combatState.playerStatusEffects.find(e => e.type === effect.type);
const existing = combatState.playerStatusEffects.find(e => e.type === effect.type);
if (!existing) {
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({
combatState.playerStatusEffects.push({
type: effect.type,
type: effect.type,
damage: effect.damage || 5,
damage: scaledDamage ,
turnsLeft: effect.duration || 3
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
setTimeout(() => playSfx('monster_skill'), 650); // Sync with animation
} else {
} else {
addCombatLog(`🔥 ${monster.namePrefix || ''}${monster.data.name} uses ${selectedSkill.name}! ${damage} damage! (Already ${effect.type}ed)`, 'damage');
addCombatLog(`🔥 ${monster.namePrefix || ''}${monster.data.name} uses ${selectedSkill.name}! ${damage} damage! (Already ${effect.type}ed)`, 'damage');