diff --git a/Dockerfile b/Dockerfile
index 5eeeabf..f0eab75 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -28,6 +28,9 @@ COPY icon-*.png ./
# Copy monster images
COPY mapgameimgs ./mapgameimgs
+# Copy game music
+COPY mapgamemusic ./mapgamemusic
+
# Copy .well-known directory for app verification
COPY .well-known ./.well-known
diff --git a/admin.html b/admin.html
index 5e47de7..a1d5875 100644
--- a/admin.html
+++ b/admin.html
@@ -137,6 +137,15 @@
background: #d32f2f;
}
+ .btn-warning {
+ background: #ff9800;
+ color: #fff;
+ }
+
+ .btn-warning:hover {
+ background: #f57c00;
+ }
+
.btn-small {
padding: 6px 12px;
font-size: 0.85rem;
@@ -827,6 +836,16 @@
+
@@ -993,6 +1012,7 @@
+
@@ -2288,6 +2308,20 @@
}
}
+ async function resetUserHomeBase() {
+ const id = document.getElementById('userId').value;
+ if (!confirm('Are you sure you want to reset this user\'s home base? They will need to set a new one.')) return;
+
+ try {
+ await api(`/api/admin/users/${id}/home-base`, { method: 'DELETE' });
+ showToast('Home base reset');
+ closeUserModal();
+ loadUsers();
+ } catch (e) {
+ showToast('Failed to reset home base: ' + e.message, 'error');
+ }
+ }
+
document.getElementById('userForm').addEventListener('submit', async (e) => {
e.preventDefault();
@@ -2331,6 +2365,7 @@
document.getElementById('setting-maxMonstersPerPlayer').value = settings.maxMonstersPerPlayer || 10;
document.getElementById('setting-xpMultiplier').value = settings.xpMultiplier || 1.0;
document.getElementById('setting-combatEnabled').checked = settings.combatEnabled !== 'false' && settings.combatEnabled !== false;
+ document.getElementById('setting-mpRegenDistance').value = settings.mpRegenDistance || 5;
} catch (e) {
showToast('Failed to load settings: ' + e.message, 'error');
}
@@ -2345,7 +2380,8 @@
monsterSpawnDistance: parseInt(document.getElementById('setting-monsterSpawnDistance').value) || 10,
maxMonstersPerPlayer: parseInt(document.getElementById('setting-maxMonstersPerPlayer').value) || 10,
xpMultiplier: parseFloat(document.getElementById('setting-xpMultiplier').value) || 1.0,
- combatEnabled: document.getElementById('setting-combatEnabled').checked
+ combatEnabled: document.getElementById('setting-combatEnabled').checked,
+ mpRegenDistance: parseInt(document.getElementById('setting-mpRegenDistance').value) || 5
};
try {
diff --git a/database.js b/database.js
index b5b1b4f..727da12 100644
--- a/database.js
+++ b/database.js
@@ -1823,6 +1823,19 @@ class HikeMapDB {
return result;
}
+ // Admin: Reset user home base
+ resetUserHomeBase(userId) {
+ const stmt = this.db.prepare(`
+ UPDATE rpg_stats SET
+ home_base_lat = NULL,
+ home_base_lng = NULL,
+ last_home_set = NULL,
+ updated_at = datetime('now')
+ WHERE user_id = ?
+ `);
+ return stmt.run(userId);
+ }
+
// Game settings methods
getSetting(key) {
const stmt = this.db.prepare(`SELECT value FROM game_settings WHERE key = ?`);
@@ -1861,7 +1874,8 @@ class HikeMapDB {
monsterSpawnDistance: 10, // Meters player must move for new spawns (10m)
maxMonstersPerPlayer: 10,
xpMultiplier: 1.0,
- combatEnabled: true
+ combatEnabled: true,
+ mpRegenDistance: 5 // Meters walked per 1 MP regenerated
};
for (const [key, value] of Object.entries(defaults)) {
diff --git a/docker-compose.yml b/docker-compose.yml
index a56bcb5..2c846e0 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,6 +10,7 @@ services:
- ./database.js:/app/database.js:ro
- ./:/app/data
- ./mapgameimgs:/app/mapgameimgs
+ - ./mapgamemusic:/app/mapgamemusic
restart: unless-stopped
environment:
- NODE_ENV=production
diff --git a/index.html b/index.html
index 338ce98..222dd7f 100644
--- a/index.html
+++ b/index.html
@@ -2432,6 +2432,99 @@
0%, 100% { box-shadow: 0 0 0 0 rgba(243, 156, 18, 0.7); }
50% { box-shadow: 0 0 0 15px rgba(243, 156, 18, 0); }
}
+ .music-toggle-btn {
+ position: fixed;
+ bottom: 160px;
+ right: 15px;
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%);
+ border: 3px solid #fff;
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
+ cursor: pointer;
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 24px;
+ transition: all 0.2s;
+ }
+ .music-toggle-btn:hover {
+ transform: scale(1.1);
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
+ }
+ .music-toggle-btn.muted {
+ background: linear-gradient(135deg, #7f8c8d 0%, #606c70 100%);
+ }
+
+ /* WASD Control Pad */
+ .wasd-controls {
+ position: fixed;
+ bottom: 20px;
+ left: 20px;
+ z-index: 2100;
+ display: grid;
+ grid-template-columns: 50px 50px 50px;
+ grid-template-rows: 50px 50px;
+ gap: 4px;
+ opacity: 0.85;
+ }
+ .wasd-controls.hidden {
+ display: none;
+ }
+ .wasd-btn {
+ width: 50px;
+ height: 50px;
+ border-radius: 8px;
+ background: linear-gradient(135deg, #2c3e50 0%, #1a252f 100%);
+ border: 2px solid #4a6785;
+ color: #fff;
+ font-size: 18px;
+ font-weight: bold;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 3px 10px rgba(0, 0, 0, 0.4);
+ transition: all 0.1s;
+ user-select: none;
+ -webkit-user-select: none;
+ touch-action: manipulation;
+ }
+ .wasd-btn:active {
+ transform: scale(0.95);
+ background: linear-gradient(135deg, #34495e 0%, #2c3e50 100%);
+ box-shadow: 0 1px 5px rgba(0, 0, 0, 0.4);
+ }
+ .wasd-btn.w-btn {
+ grid-column: 2;
+ grid-row: 1;
+ }
+ .wasd-btn.a-btn {
+ grid-column: 1;
+ grid-row: 2;
+ }
+ .wasd-btn.s-btn {
+ grid-column: 2;
+ grid-row: 2;
+ }
+ .wasd-btn.d-btn {
+ grid-column: 3;
+ grid-row: 2;
+ }
+ .wasd-mode-indicator {
+ position: absolute;
+ top: -25px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: rgba(0, 0, 0, 0.7);
+ color: #4fc3f7;
+ padding: 3px 8px;
+ border-radius: 4px;
+ font-size: 10px;
+ white-space: nowrap;
+ }
/* Home Base Marker */
.home-base-marker {
@@ -2658,8 +2751,20 @@
+
+
+
+
+
+
TEST MODE
+
+
+
+
+
+
- Tap on the map to set your home base
+ Double-tap on the map to set your home base
@@ -3697,6 +3802,7 @@
spawnSettings.spawnInterval = settings.spawnInterval || 20000;
spawnSettings.spawnChance = settings.spawnChance || 50;
spawnSettings.spawnDistance = settings.spawnDistance || 10;
+ spawnSettings.mpRegenDistance = settings.mpRegenDistance || 5;
console.log('Loaded spawn settings:', spawnSettings);
}
} catch (err) {
@@ -3860,16 +3966,35 @@
return Math.random() * 100 < hitChance;
}
+ // Apply damage variance (±15% randomness)
+ function applyDamageVariance(baseDamage) {
+ const variance = 0.15; // 15% variance
+ const multiplier = 1 + (Math.random() * variance * 2 - variance); // 0.85 to 1.15
+ return Math.max(1, Math.floor(baseDamage * multiplier));
+ }
+
+ // Calculate damage with percentage-based defense reduction
+ // Formula: damage = rawDamage * (100 / (100 + DEF))
+ // This prevents DEF from completely nullifying attacks
+ function calculateDamage(rawDamage, defense) {
+ const reduction = 100 / (100 + defense);
+ const damage = Math.floor(rawDamage * reduction);
+ return Math.max(1, damage);
+ }
+
// Select a monster skill using weighted random
function selectMonsterSkill(monsterTypeId, monsterLevel) {
const skills = MONSTER_SKILLS[monsterTypeId] || [];
+ console.log('[DEBUG] selectMonsterSkill:', monsterTypeId, 'level:', monsterLevel, 'skills loaded:', skills.length);
// Filter by level requirement
const validSkills = skills.filter(s => monsterLevel >= s.minLevel);
+ console.log('[DEBUG] validSkills after level filter:', validSkills.length);
if (validSkills.length === 0) {
// Fallback to basic attack
- return SKILLS_DB['basic_attack'] || { id: 'basic_attack', name: 'Attack', basePower: 100, accuracy: 95 };
+ console.log('[DEBUG] Using fallback basic_attack, SKILLS_DB has it:', !!SKILLS_DB['basic_attack']);
+ return SKILLS_DB['basic_attack'] || { id: 'basic_attack', name: 'Attack', basePower: 100, accuracy: 95, type: 'damage', hitCount: 1 };
}
// Weighted random selection
@@ -3925,18 +4050,194 @@
let spawnSettings = {
spawnInterval: 20000, // Timer interval in ms
spawnChance: 50, // Percent chance per interval
- spawnDistance: 10 // Meters player must move
+ spawnDistance: 10, // Meters player must move
+ mpRegenDistance: 5 // Meters per 1 MP regen
};
+ // MP regen tracking
+ let lastMpRegenLocation = null; // Track location for MP regen distance
+ let mpRegenAccumulator = 0; // Accumulated distance for MP regen
+
// Home Base state variables
let homeBaseMarker = null; // Leaflet marker for home base
let homeBaseSelectionMode = false; // Whether we're in home base selection mode
let xpLostOnDeath = 0; // Track XP lost for display
let lastHomeRegenTime = 0; // Track last HP/MP regen at home base
+ let wasAtHomeBase = false; // Track if player was at home base last check
const HOME_REGEN_INTERVAL = 3000; // Regen every 3 seconds when at home
const HOME_REGEN_PERCENT = 5; // Regen 5% of max HP/MP per tick
const HOME_BASE_RADIUS = 20; // Meters - radius for home base effects
+ // ==========================================
+ // MUSIC SYSTEM
+ // ==========================================
+ const gameMusic = {
+ overworld: new Audio('/mapgamemusic/over_world.mp3'),
+ battle: new Audio('/mapgamemusic/in_fight.mp3'),
+ victory: new Audio('/mapgamemusic/victory.mp3'),
+ death: new Audio('/mapgamemusic/he_ded.mp3'),
+ homebase: new Audio('/mapgamemusic/homebase.mp3'),
+ current: null,
+ currentTrack: null, // Track name for easy comparison
+ muted: localStorage.getItem('musicMuted') === 'true',
+ volume: parseFloat(localStorage.getItem('musicVolume') || '0.5'),
+ pausedTracks: {} // Store paused positions for resumable tracks
+ };
+
+ // Initialize music settings
+ function initMusic() {
+ // Set up looping for ambient tracks
+ gameMusic.overworld.loop = true;
+ gameMusic.battle.loop = true;
+ gameMusic.homebase.loop = true;
+ gameMusic.victory.loop = false;
+ gameMusic.death.loop = false;
+
+ // Set initial volume for all tracks
+ gameMusic.overworld.volume = gameMusic.volume;
+ gameMusic.battle.volume = gameMusic.volume;
+ gameMusic.victory.volume = gameMusic.volume;
+ gameMusic.death.volume = gameMusic.volume;
+ gameMusic.homebase.volume = gameMusic.volume;
+
+ // Preload all audio tracks and add error handling
+ const tracks = ['overworld', 'battle', 'victory', 'death', 'homebase'];
+ tracks.forEach(track => {
+ const audio = gameMusic[track];
+ audio.preload = 'auto';
+ audio.load();
+
+ audio.addEventListener('canplaythrough', () => {
+ console.log('Music track loaded:', track);
+ }, { once: true });
+
+ audio.addEventListener('error', (e) => {
+ console.error('Failed to load music track:', track, audio.error);
+ });
+ });
+
+ // When victory music ends, go back to appropriate music
+ gameMusic.victory.addEventListener('ended', () => {
+ if (!combatState && playerStats && !playerStats.isDead) {
+ // Check if at home base
+ const distToHome = getDistanceToHome();
+ if (distToHome !== null && distToHome <= HOME_BASE_RADIUS) {
+ playMusic('homebase');
+ } else {
+ playMusic('overworld');
+ }
+ }
+ });
+
+ // When death music ends, stay silent (they need to get home)
+ gameMusic.death.addEventListener('ended', () => {
+ // Could loop or stay silent - staying silent for now
+ });
+ }
+
+ // Play a specific music track
+ function playMusic(track) {
+ if (gameMusic.muted) return;
+
+ const audio = gameMusic[track];
+ if (!audio) {
+ console.error('Music track not found:', track);
+ return;
+ }
+
+ // Don't restart if already playing this track
+ if (gameMusic.currentTrack === track && !audio.paused) return;
+
+ // Pause current music (save position for overworld)
+ pauseCurrentMusic();
+
+ // Play new track
+ gameMusic.current = audio;
+ gameMusic.currentTrack = track;
+
+ // Resume from saved position for overworld, otherwise start fresh
+ if (track === 'overworld' && gameMusic.pausedTracks.overworld !== undefined) {
+ audio.currentTime = gameMusic.pausedTracks.overworld;
+ delete gameMusic.pausedTracks.overworld;
+ } else if (track !== 'overworld') {
+ // Non-overworld tracks always start from beginning
+ audio.currentTime = 0;
+ }
+ // If overworld with no saved position, let it continue or start from 0
+
+ audio.play().then(() => {
+ console.log('Playing music:', track);
+ }).catch(err => {
+ console.log('Music autoplay blocked:', err.message);
+ });
+ }
+
+ // Pause current music (save position for resumable tracks)
+ function pauseCurrentMusic() {
+ if (gameMusic.current && gameMusic.currentTrack) {
+ // Save position for overworld so we can resume later
+ if (gameMusic.currentTrack === 'overworld') {
+ gameMusic.pausedTracks.overworld = gameMusic.current.currentTime;
+ }
+ gameMusic.current.pause();
+ }
+ }
+
+ // Stop current music
+ function stopMusic() {
+ if (gameMusic.current) {
+ gameMusic.current.pause();
+ gameMusic.current.currentTime = 0;
+ }
+ gameMusic.currentTrack = null;
+ }
+
+ // Toggle mute
+ function toggleMusicMute() {
+ gameMusic.muted = !gameMusic.muted;
+ localStorage.setItem('musicMuted', gameMusic.muted);
+
+ if (gameMusic.muted) {
+ stopMusic();
+ } else {
+ // Resume appropriate music based on game state
+ if (playerStats && playerStats.isDead) {
+ playMusic('death');
+ } else if (combatState && combatState.inCombat) {
+ playMusic('battle');
+ } else {
+ // Check if at home base
+ const distToHome = getDistanceToHome();
+ if (distToHome !== null && distToHome <= HOME_BASE_RADIUS) {
+ playMusic('homebase');
+ } else {
+ playMusic('overworld');
+ }
+ }
+ }
+ updateMusicButton();
+ }
+
+ // Set music volume
+ function setMusicVolume(vol) {
+ gameMusic.volume = Math.max(0, Math.min(1, vol));
+ localStorage.setItem('musicVolume', gameMusic.volume);
+ gameMusic.overworld.volume = gameMusic.volume;
+ gameMusic.battle.volume = gameMusic.volume;
+ gameMusic.victory.volume = gameMusic.volume;
+ gameMusic.death.volume = gameMusic.volume;
+ gameMusic.homebase.volume = gameMusic.volume;
+ }
+
+ // Update music button icon
+ function updateMusicButton() {
+ const btn = document.getElementById('musicToggleBtn');
+ if (btn) {
+ btn.innerHTML = gameMusic.muted ? '🔇' : '🎵';
+ btn.title = gameMusic.muted ? 'Unmute Music' : 'Mute Music';
+ }
+ }
+
// Find nearest monster to a location (for double-tap to battle on mobile)
function findNearestMonster(latlng, maxDistanceMeters = 50) {
if (monsterEntourage.length === 0) return null;
@@ -4136,6 +4437,12 @@
// Check for HP/MP regeneration at home base
checkHomeBaseRegen();
+ // Check for MP regeneration while walking
+ checkWalkingMpRegen(lat, lng);
+
+ // Check for monster clear at home base
+ checkHomeBaseMonsterClear();
+
// Update geocache visibility based on new location
if (navMode) {
updateGeocacheVisibility();
@@ -4373,6 +4680,116 @@
// Add keydown listener for GPS test mode
document.addEventListener('keydown', handleGpsTestKeydown);
+ // WASD control pad button handlers
+ let wasdMoveInterval = null;
+ let wasdCurrentDir = null;
+
+ function initWasdControls() {
+ const wasdControls = document.getElementById('wasdControls');
+ const buttons = wasdControls.querySelectorAll('.wasd-btn');
+
+ // Move once in the specified direction
+ const doMove = (dir) => {
+ // Auto-enable GPS test mode if not already enabled
+ if (!gpsTestMode) {
+ // Initialize test position to current map center or user location
+ if (userLocation) {
+ testPosition = { lat: userLocation.lat, lng: userLocation.lng };
+ } else {
+ const center = map.getCenter();
+ testPosition = { lat: center.lat, lng: center.lng };
+ }
+ gpsTestMode = true;
+
+ // Stop real GPS
+ if (gpsWatchId !== null) {
+ navigator.geolocation.clearWatch(gpsWatchId);
+ gpsWatchId = null;
+ }
+ if (gpsBackupInterval) {
+ clearInterval(gpsBackupInterval);
+ gpsBackupInterval = null;
+ }
+
+ // Update admin toggle if it exists
+ const toggle = document.getElementById('gpsTestModeToggle');
+ if (toggle) toggle.checked = true;
+
+ updateStatus('Test mode enabled via controls', 'info');
+ }
+
+ // Move in the specified direction
+ switch (dir) {
+ case 'w':
+ testPosition.lat += GPS_TEST_STEP;
+ break;
+ case 's':
+ testPosition.lat -= GPS_TEST_STEP;
+ break;
+ case 'a':
+ testPosition.lng -= GPS_TEST_STEP;
+ break;
+ case 'd':
+ testPosition.lng += GPS_TEST_STEP;
+ break;
+ }
+
+ simulateGpsPosition();
+ };
+
+ // Start moving (on press/touch start)
+ const startMove = (e, dir) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Move immediately
+ doMove(dir);
+
+ // Start repeating after a short delay
+ wasdCurrentDir = dir;
+ if (wasdMoveInterval) clearInterval(wasdMoveInterval);
+ wasdMoveInterval = setInterval(() => {
+ if (wasdCurrentDir) {
+ doMove(wasdCurrentDir);
+ }
+ }, 100); // Move every 100ms while held
+ };
+
+ // Stop moving (on release)
+ const stopMove = () => {
+ wasdCurrentDir = null;
+ if (wasdMoveInterval) {
+ clearInterval(wasdMoveInterval);
+ wasdMoveInterval = null;
+ }
+ };
+
+ buttons.forEach(btn => {
+ const dir = btn.dataset.dir;
+
+ // Touch events
+ btn.addEventListener('touchstart', (e) => startMove(e, dir), { passive: false });
+ btn.addEventListener('touchend', stopMove, { passive: false });
+ btn.addEventListener('touchcancel', stopMove, { passive: false });
+
+ // Mouse events for desktop
+ btn.addEventListener('mousedown', (e) => startMove(e, dir));
+ btn.addEventListener('mouseup', stopMove);
+ btn.addEventListener('mouseleave', stopMove);
+ });
+
+ // Also stop if touch/mouse ends outside buttons
+ document.addEventListener('touchend', stopMove);
+ document.addEventListener('mouseup', stopMove);
+ }
+
+ // Initialize WASD controls after DOM is ready
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initWasdControls);
+ } else {
+ initWasdControls();
+ }
+
// Navigation functions
function setDestination(track, index) {
// Remove old pin if exists
@@ -7577,8 +7994,20 @@
// Direct touch event binding for mobile (Leaflet doesn't support touchstart through map.on)
const mapContainer = map.getContainer();
+ // Variables for home base double-tap detection
+ let homeBaseTapTime = 0;
+ let homeBaseTapLocation = null;
+
// Fix for Chrome and PWA - use native addEventListener with passive: false
mapContainer.addEventListener('touchstart', function(e) {
+ // Home base selection mode takes priority - capture touch for double-tap detection
+ if (homeBaseSelectionMode && e.touches.length === 1) {
+ e.preventDefault();
+ e.stopPropagation();
+ touchStartTime = Date.now();
+ return;
+ }
+
if (navMode && e.touches.length === 1) {
// Check if touch target is a geocache marker - let those through to Leaflet
if (e.target.closest('.geocache-marker')) {
@@ -7605,6 +8034,51 @@
}, { passive: false, capture: true });
mapContainer.addEventListener('touchend', function(e) {
+ // Home base selection mode - handle double-tap to place home base
+ if (homeBaseSelectionMode) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const now = Date.now();
+ const timeSinceLastTap = now - homeBaseTapTime;
+
+ // Get current tap location
+ let currentTapLocation = null;
+ if (e.changedTouches && e.changedTouches.length > 0) {
+ const touch = e.changedTouches[0];
+ currentTapLocation = { x: touch.clientX, y: touch.clientY };
+ }
+
+ // Check for double-tap (two taps within 400ms at roughly same location)
+ if (timeSinceLastTap < 400 && homeBaseTapLocation && currentTapLocation) {
+ const dx = currentTapLocation.x - homeBaseTapLocation.x;
+ const dy = currentTapLocation.y - homeBaseTapLocation.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+
+ // Only trigger if taps are within 50 pixels of each other
+ if (distance < 50) {
+ // Convert touch to latlng and set home base
+ const rect = mapContainer.getBoundingClientRect();
+ const x = currentTapLocation.x - rect.left;
+ const y = currentTapLocation.y - rect.top;
+ const containerPoint = L.point(x, y);
+ const latlng = map.containerPointToLatLng(containerPoint);
+
+ setHomeBase(latlng.lat, latlng.lng);
+
+ // Reset double-tap detection
+ homeBaseTapTime = 0;
+ homeBaseTapLocation = null;
+ return;
+ }
+ }
+
+ // Store this tap for double-tap detection
+ homeBaseTapTime = now;
+ homeBaseTapLocation = currentTapLocation;
+ return;
+ }
+
if (navMode) {
const now = Date.now();
const timeSinceLastTap = now - lastTapTime;
@@ -7744,6 +8218,14 @@
// This prevents the 50/50 race condition between handlers
if ('ontouchstart' in window) return;
+ // Home base selection mode - double-click to place
+ if (homeBaseSelectionMode) {
+ L.DomEvent.stopPropagation(e);
+ L.DomEvent.preventDefault(e);
+ setHomeBase(e.latlng.lat, e.latlng.lng);
+ return;
+ }
+
if (navMode) {
L.DomEvent.stopPropagation(e);
L.DomEvent.preventDefault(e);
@@ -10179,6 +10661,14 @@
// Show/hide home base button
document.getElementById('homeBaseBtn').style.display = playerStats ? 'flex' : 'none';
+
+ // Show/hide music button and update state
+ const musicBtn = document.getElementById('musicToggleBtn');
+ if (musicBtn) {
+ musicBtn.style.display = playerStats ? 'flex' : 'none';
+ musicBtn.classList.toggle('muted', gameMusic.muted);
+ updateMusicButton();
+ }
}
// ==========================================
@@ -10526,6 +11016,122 @@
}, 500);
}
+ // Check for MP regeneration while walking
+ function checkWalkingMpRegen(lat, lng) {
+ // Skip if dead, in combat, or no player stats
+ if (!playerStats || playerStats.isDead) return;
+ if (combatState && combatState.inCombat) return;
+
+ // Skip if already at max MP
+ if (playerStats.mp >= playerStats.maxMp) return;
+
+ // Get the regen distance setting (default 5 meters per 1 MP)
+ const regenDistance = spawnSettings.mpRegenDistance || 5;
+ if (regenDistance <= 0) return; // Disabled if 0 or negative
+
+ // Initialize last location if not set
+ if (!lastMpRegenLocation) {
+ lastMpRegenLocation = { lat, lng };
+ return;
+ }
+
+ // Calculate distance walked since last check (in meters)
+ const metersPerDegLat = 111320;
+ const metersPerDegLng = 111320 * Math.cos(lat * Math.PI / 180);
+ const dx = (lng - lastMpRegenLocation.lng) * metersPerDegLng;
+ const dy = (lat - lastMpRegenLocation.lat) * metersPerDegLat;
+ const distanceWalked = Math.sqrt(dx * dx + dy * dy);
+
+ // Update last location
+ lastMpRegenLocation = { lat, lng };
+
+ // Skip very small movements (GPS jitter) or very large jumps (teleport/error)
+ if (distanceWalked < 0.5 || distanceWalked > 50) return;
+
+ // Accumulate distance
+ mpRegenAccumulator += distanceWalked;
+
+ // Check if we've walked enough for MP regen
+ if (mpRegenAccumulator >= regenDistance) {
+ const mpToRegen = Math.floor(mpRegenAccumulator / regenDistance);
+ mpRegenAccumulator = mpRegenAccumulator % regenDistance; // Keep remainder
+
+ const oldMp = playerStats.mp;
+ playerStats.mp = Math.min(playerStats.maxMp, playerStats.mp + mpToRegen);
+
+ if (playerStats.mp > oldMp) {
+ updateRpgHud();
+ savePlayerStats();
+
+ // Show subtle MP regen indicator
+ showWalkingMpRegenEffect();
+ }
+ }
+ }
+
+ // Show a subtle visual effect for walking MP regen
+ function showWalkingMpRegenEffect() {
+ const hud = document.getElementById('rpgHud');
+ if (!hud) return;
+
+ // Add a brief blue glow for MP regen
+ hud.style.boxShadow = '0 0 15px rgba(33, 150, 243, 0.6)';
+ setTimeout(() => {
+ hud.style.boxShadow = '';
+ }, 300);
+ }
+
+ // Check if at home base and clear monsters if entering
+ function checkHomeBaseMonsterClear() {
+ const distance = getDistanceToHome();
+ const isAtHome = distance !== null && distance <= HOME_BASE_RADIUS;
+
+ // Check if player just entered home base
+ if (isAtHome && !wasAtHomeBase) {
+ clearAllMonsters();
+
+ // Play homebase music (unless dead or in combat)
+ if (playerStats && !playerStats.isDead && !combatState) {
+ playMusic('homebase');
+ }
+ }
+
+ // Check if player just left home base
+ if (!isAtHome && wasAtHomeBase) {
+ // Switch to overworld music (unless dead or in combat)
+ if (playerStats && !playerStats.isDead && !combatState) {
+ playMusic('overworld');
+ }
+ }
+
+ // Update tracking state
+ wasAtHomeBase = isAtHome;
+ }
+
+ // Clear all monsters from the map (used when entering home base)
+ function clearAllMonsters() {
+ if (monsterEntourage.length === 0) return;
+
+ // Remove all monster markers from the map
+ for (const monster of monsterEntourage) {
+ if (monster.marker) {
+ monster.marker.remove();
+ }
+ }
+
+ // Clear the array
+ const count = monsterEntourage.length;
+ monsterEntourage.length = 0;
+
+ // Show notification
+ if (count > 0) {
+ showToast(`🏠 Entered home base - ${count} monster${count > 1 ? 's' : ''} fled!`, 'info');
+ }
+
+ // Update HUD
+ updateRpgHud();
+ }
+
// Handle player death
async function handlePlayerDeath() {
const token = localStorage.getItem('accessToken');
@@ -10557,6 +11163,9 @@
document.getElementById('xpLostText').textContent = `-${xpLostOnDeath} XP`;
document.getElementById('deathOverlay').style.display = 'flex';
+ // Play death music
+ playMusic('death');
+
// Update HUD
updateRpgHud();
@@ -10590,6 +11199,9 @@
// Hide death overlay
document.getElementById('deathOverlay').style.display = 'none';
+ // Play homebase music (player respawns at home)
+ playMusic('homebase');
+
// Update HUD
updateRpgHud();
savePlayerStats();
@@ -10762,6 +11374,10 @@
if (monsterEntourage.length >= getMaxMonsters()) return;
if (!monsterTypesLoaded || Object.keys(MONSTER_TYPES).length === 0) return;
+ // Don't spawn monsters at home base
+ const distanceToHome = getDistanceToHome();
+ if (distanceToHome !== null && distanceToHome <= HOME_BASE_RADIUS) return;
+
// Movement-based spawning: first monster can spawn standing still,
// but subsequent monsters require player to move the configured distance
if (monsterEntourage.length > 0 && lastSpawnLocation) {
@@ -11009,6 +11625,9 @@
const overlay = document.getElementById('combatOverlay');
overlay.style.display = 'flex';
+ // Play battle music
+ playMusic('battle');
+
// Render monster list
renderMonsterList();
@@ -11220,8 +11839,12 @@
// Update skill button states
document.querySelectorAll('.skill-btn').forEach(btn => {
const skillId = btn.dataset.skillId;
- const skill = SKILLS[skillId];
- btn.disabled = combatState.player.mp < skill.mpCost || combatState.turn !== 'player';
+ const skill = SKILLS_DB[skillId] || SKILLS[skillId];
+ if (!skill) {
+ btn.disabled = true;
+ return;
+ }
+ btn.disabled = combatState.player.mp < (skill.mpCost || 0) || combatState.turn !== 'player';
});
// Disable flee button during monster turns
@@ -11291,12 +11914,17 @@
// Execute a player skill
function executePlayerSkill(skillId) {
- if (!combatState || combatState.turn !== 'player') return;
+ console.log('[DEBUG] executePlayerSkill called with:', skillId);
+ if (!combatState || combatState.turn !== 'player') {
+ console.log('[DEBUG] Early return - combatState:', !!combatState, 'turn:', combatState?.turn);
+ return;
+ }
// Get skill from DB first, then fall back to hardcoded SKILLS
const dbSkill = SKILLS_DB[skillId];
const hardcodedSkill = SKILLS[skillId];
const skill = dbSkill || hardcodedSkill;
+ console.log('[DEBUG] Skill found:', skill?.name, 'type:', skill?.type);
if (!skill) {
addCombatLog(`Unknown skill: ${skillId}`);
@@ -11395,7 +12023,8 @@
}
for (let hit = 0; hit < hitCount; hit++) {
- const damage = Math.max(1, rawDamage - effectiveMonsterDef);
+ const baseDamage = calculateDamage(rawDamage, effectiveMonsterDef);
+ const damage = applyDamageVariance(baseDamage);
totalDamage += damage;
currentTarget.hp -= damage;
}
@@ -11414,9 +12043,22 @@
// Check if this monster died
if (currentTarget.hp <= 0) {
monstersKilled++;
+ // Award XP immediately for this kill
+ const xpReward = (currentTarget.data?.xpReward || 10) * currentTarget.level;
+ playerStats.xp += xpReward;
+ combatState.player.xpGained = (combatState.player.xpGained || 0) + xpReward;
+
if (targets.length === 1) {
- addCombatLog(`💀 ${currentTarget.data.name} was defeated!`, 'victory');
+ addCombatLog(`💀 ${currentTarget.data.name} was defeated! +${xpReward} XP`, 'victory');
}
+
+ // Remove this monster from entourage immediately
+ removeMonster(currentTarget.id);
+
+ // Check for level up
+ checkLevelUp();
+ savePlayerStats();
+ updateRpgHud();
}
}
@@ -11427,7 +12069,8 @@
} else {
addCombatLog(`🌟 ${displayName} hits ${monstersHit} enemies for ${grandTotalDamage} total damage!`, 'damage');
if (monstersKilled > 0) {
- addCombatLog(`💀 ${monstersKilled} enemy${monstersKilled > 1 ? 'ies' : ''} defeated!`, 'victory');
+ const totalXpGained = combatState.player.xpGained || 0;
+ addCombatLog(`💀 ${monstersKilled} enemy${monstersKilled > 1 ? 'ies' : ''} defeated! +${totalXpGained} XP`, 'victory');
}
}
}
@@ -11463,7 +12106,9 @@
// Check if ALL monsters are defeated
const livingMonsters = combatState.monsters.filter(m => m.hp > 0);
+ console.log('[DEBUG] Victory check - livingMonsters:', livingMonsters.length, 'monsters:', combatState.monsters.map(m => ({type: m.type, hp: m.hp})));
if (livingMonsters.length === 0) {
+ console.log('[DEBUG] All monsters defeated, calling handleCombatVictory');
handleCombatVictory();
return;
}
@@ -11473,9 +12118,11 @@
// End player turn and start monster turns
function endPlayerTurn() {
+ console.log('[DEBUG] endPlayerTurn called');
combatState.turn = 'monsters';
combatState.currentMonsterTurn = 0;
updateCombatUI();
+ console.log('[DEBUG] Scheduling executeMonsterTurns in 800ms');
setTimeout(executeMonsterTurns, 800);
}
@@ -11498,8 +12145,10 @@
// Execute all monster turns sequentially
function executeMonsterTurns() {
+ console.log('[DEBUG] executeMonsterTurns called, combatState:', !!combatState);
if (!combatState) return;
+ console.log('[DEBUG] currentMonsterTurn:', combatState.currentMonsterTurn, 'total:', combatState.monsters.length);
// Find next living monster starting from currentMonsterTurn
while (combatState.currentMonsterTurn < combatState.monsters.length) {
const monster = combatState.monsters[combatState.currentMonsterTurn];
@@ -11526,9 +12175,11 @@
// Execute one monster's attack
function executeOneMonsterAttack(monsterIndex) {
+ console.log('[DEBUG] executeOneMonsterAttack called, monsterIndex:', monsterIndex);
if (!combatState) return;
const monster = combatState.monsters[monsterIndex];
+ console.log('[DEBUG] Monster:', monster?.type, 'HP:', monster?.hp);
combatState.currentMonsterTurn = monsterIndex;
updateCombatUI();
@@ -11544,6 +12195,7 @@
// Select a skill using weighted random (or basic attack if none)
const selectedSkill = selectMonsterSkill(monster.type, monster.level);
+ console.log('[DEBUG] Selected skill:', selectedSkill?.id, selectedSkill?.name, 'type:', selectedSkill?.type);
// Calculate hit chance
const skillAccuracy = selectedSkill.accuracy || 85;
@@ -11622,7 +12274,8 @@
// But we should factor in monster's attack buff if it has one
for (let hit = 0; hit < hitCount; hit++) {
- const damage = Math.max(1, rawDamage - effectiveDef);
+ const baseDamage = calculateDamage(rawDamage, effectiveDef);
+ const damage = applyDamageVariance(baseDamage);
totalDamage += damage;
combatState.player.hp -= damage;
}
@@ -11654,31 +12307,32 @@
// Handle combat victory
function handleCombatVictory() {
- // Calculate total XP from all defeated monsters
- let totalXp = 0;
- const monsterIds = [];
- combatState.monsters.forEach(monster => {
- totalXp += monster.data.xpReward * monster.level;
- monsterIds.push(monster.id);
- });
+ console.log('[DEBUG] handleCombatVictory called');
- const monsterCount = combatState.monsters.length;
- addCombatLog(`Victory! Defeated ${monsterCount} ${monsterCount === 1 ? 'enemy' : 'enemies'}! Gained ${totalXp} XP!`, 'victory');
+ // Play victory music
+ playMusic('victory');
- // Remove all monsters from entourage
- monsterIds.forEach(id => removeMonster(id));
+ try {
+ // XP was already awarded per-kill, just show total
+ const totalXp = combatState.player.xpGained || 0;
+ const monsterCount = combatState.monsters.length;
+ addCombatLog(`Victory! Defeated ${monsterCount} ${monsterCount === 1 ? 'enemy' : 'enemies'}!`, 'victory');
- // Update player stats
- playerStats.hp = combatState.player.hp;
- playerStats.mp = combatState.player.mp;
- playerStats.xp += totalXp;
+ // Monsters already removed per-kill, just update player HP/MP
+ playerStats.hp = combatState.player.hp;
+ playerStats.mp = combatState.player.mp;
- // Check for level up
- checkLevelUp();
+ // Check for level up
+ checkLevelUp();
- savePlayerStats();
- updateRpgHud();
+ savePlayerStats();
+ updateRpgHud();
+ } catch (err) {
+ console.error('[DEBUG] Error in handleCombatVictory:', err);
+ }
+ // Always close combat UI, even if there were errors above
+ console.log('[DEBUG] Scheduling closeCombatUI in 2500ms');
setTimeout(closeCombatUI, 2500);
}
@@ -11725,8 +12379,19 @@
// Close combat UI
function closeCombatUI() {
+ console.log('[DEBUG] closeCombatUI called');
document.getElementById('combatOverlay').style.display = 'none';
combatState = null;
+
+ // If victory music isn't playing, switch to appropriate ambient music
+ if (gameMusic.currentTrack !== 'victory' || gameMusic.victory.paused) {
+ const distToHome = getDistanceToHome();
+ if (distToHome !== null && distToHome <= HOME_BASE_RADIUS) {
+ playMusic('homebase');
+ } else {
+ playMusic('overworld');
+ }
+ }
}
// Check for level up
@@ -11770,6 +12435,30 @@
loadCurrentUser();
});
+ // Initialize music system
+ initMusic();
+
+ // Start appropriate music on first user interaction (required due to autoplay restrictions)
+ let musicStarted = false;
+ function startMusicOnInteraction() {
+ if (!musicStarted && playerStats && !gameMusic.muted) {
+ musicStarted = true;
+ // Play appropriate music based on game state
+ if (playerStats.isDead) {
+ playMusic('death');
+ } else {
+ const distToHome = getDistanceToHome();
+ if (distToHome !== null && distToHome <= HOME_BASE_RADIUS) {
+ playMusic('homebase');
+ } else {
+ playMusic('overworld');
+ }
+ }
+ }
+ }
+ document.addEventListener('click', startMusicOnInteraction, { once: false });
+ document.addEventListener('touchstart', startMusicOnInteraction, { once: false });
+
// Show auth modal if not logged in (guest mode available)
if (!localStorage.getItem('accessToken') && !sessionStorage.getItem('guestMode')) {
setTimeout(() => {
diff --git a/mapgameimgs/monsters/moop_sub_par100.png b/mapgameimgs/monsters/moop_sub_par100.png
index d08e384..9f8167d 100755
Binary files a/mapgameimgs/monsters/moop_sub_par100.png and b/mapgameimgs/monsters/moop_sub_par100.png differ
diff --git a/mapgameimgs/monsters/moop_sub_par50.png b/mapgameimgs/monsters/moop_sub_par50.png
index 5283d19..3ec78c8 100755
Binary files a/mapgameimgs/monsters/moop_sub_par50.png and b/mapgameimgs/monsters/moop_sub_par50.png differ
diff --git a/mapgamemusic/he_ded.mp3 b/mapgamemusic/he_ded.mp3
new file mode 100755
index 0000000..be2df8e
Binary files /dev/null and b/mapgamemusic/he_ded.mp3 differ
diff --git a/mapgamemusic/homebase.mp3 b/mapgamemusic/homebase.mp3
new file mode 100755
index 0000000..6938070
Binary files /dev/null and b/mapgamemusic/homebase.mp3 differ
diff --git a/mapgamemusic/in_fight.mp3 b/mapgamemusic/in_fight.mp3
new file mode 100755
index 0000000..2189cba
Binary files /dev/null and b/mapgamemusic/in_fight.mp3 differ
diff --git a/mapgamemusic/over_world.mp3 b/mapgamemusic/over_world.mp3
new file mode 100755
index 0000000..919dbd9
Binary files /dev/null and b/mapgamemusic/over_world.mp3 differ
diff --git a/mapgamemusic/victory.mp3 b/mapgamemusic/victory.mp3
new file mode 100755
index 0000000..9f6a1d2
Binary files /dev/null and b/mapgamemusic/victory.mp3 differ
diff --git a/server.js b/server.js
index cb23723..2521082 100644
--- a/server.js
+++ b/server.js
@@ -82,6 +82,9 @@ app.use('/.well-known', express.static(path.join(__dirname, '.well-known')));
// Serve monster images
app.use('/mapgameimgs', express.static(path.join(__dirname, 'mapgameimgs')));
+// Serve game music
+app.use('/mapgamemusic', express.static(path.join(__dirname, 'mapgamemusic')));
+
// Serve other static files
app.use(express.static(path.join(__dirname)));
@@ -926,7 +929,8 @@ app.get('/api/spawn-settings', (req, res) => {
const settings = {
spawnInterval: JSON.parse(db.getSetting('monsterSpawnInterval') || '20000'),
spawnChance: JSON.parse(db.getSetting('monsterSpawnChance') || '50'),
- spawnDistance: JSON.parse(db.getSetting('monsterSpawnDistance') || '10')
+ spawnDistance: JSON.parse(db.getSetting('monsterSpawnDistance') || '10'),
+ mpRegenDistance: JSON.parse(db.getSetting('mpRegenDistance') || '5')
};
res.json(settings);
} catch (err) {
@@ -1358,11 +1362,27 @@ app.delete('/api/admin/users/:id/reset', adminOnly, (req, res) => {
}
});
+// Reset user home base
+app.delete('/api/admin/users/:id/home-base', adminOnly, (req, res) => {
+ try {
+ const targetUserId = parseInt(req.params.id);
+ db.resetUserHomeBase(targetUserId);
+
+ // Notify the user in real-time to refresh their stats
+ sendToAuthUser(targetUserId, { type: 'statsUpdated' });
+
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Admin reset home base error:', err);
+ res.status(500).json({ error: 'Failed to reset home base' });
+ }
+});
+
// Get game settings
app.get('/api/admin/settings', adminOnly, (req, res) => {
try {
const settings = db.getAllSettings();
- res.json(settings);
+ res.json({ settings });
} catch (err) {
console.error('Admin get settings error:', err);
res.status(500).json({ error: 'Failed to get settings' });