@ -1598,6 +1598,75 @@
color: #666;
color: #666;
}
}
/* Skill Choice Modal */
.skill-choice-modal {
background: linear-gradient(135deg, #1a1a2e 0%, #0f0f23 100%);
border-radius: 16px;
padding: 24px;
max-width: 400px;
width: 90%;
border: 2px solid #4CAF50;
box-shadow: 0 0 30px rgba(76, 175, 80, 0.3);
}
.skill-choice-header {
text-align: center;
margin-bottom: 20px;
}
.skill-choice-header h2 {
color: #ffd93d;
margin: 0 0 8px 0;
font-size: 24px;
}
.skill-choice-header p {
color: #aaa;
margin: 0;
font-size: 14px;
}
.skill-choice-option {
background: rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 16px;
margin: 12px 0;
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
display: flex;
align-items: flex-start;
gap: 12px;
}
.skill-choice-option:hover {
background: rgba(76, 175, 80, 0.2);
transform: scale(1.02);
border-color: #4CAF50;
}
.skill-choice-option:active {
transform: scale(0.98);
}
.skill-choice-icon {
font-size: 32px;
flex-shrink: 0;
}
.skill-choice-details {
flex: 1;
}
.skill-choice-name {
font-weight: bold;
color: #4CAF50;
font-size: 16px;
margin-bottom: 4px;
}
.skill-choice-desc {
color: #aaa;
font-size: 13px;
line-height: 1.4;
margin-bottom: 6px;
}
.skill-choice-cost {
color: #4ecdc4;
font-size: 12px;
font-weight: bold;
}
/* User Profile Display */
/* User Profile Display */
.user-profile {
.user-profile {
display: flex;
display: flex;
@ -2632,6 +2701,19 @@
< / div >
< / div >
< / div >
< / div >
<!-- Skill Choice Modal (Level Up) -->
< div id = "skillChoiceModal" class = "auth-modal-overlay" style = "display: none;" >
< div class = "skill-choice-modal" >
< div class = "skill-choice-header" >
< h2 > 🎉 Level Up!< / h2 >
< p > Choose a new skill:< / p >
< / div >
< div class = "skill-choice-options" id = "skillChoiceOptions" >
<!-- Populated by JS -->
< / div >
< / div >
< / div >
<!-- Geocache List Sidebar -->
<!-- Geocache List Sidebar -->
< div id = "geocacheListSidebar" class = "geocache-list-sidebar" >
< div id = "geocacheListSidebar" class = "geocache-list-sidebar" >
< div class = "geocache-list-header" >
< div class = "geocache-list-header" >
@ -2937,9 +3019,13 @@
rotate: true,
rotate: true,
rotateControl: false,
rotateControl: false,
touchRotate: false,
touchRotate: false,
bearing: 0
bearing: 0,
doubleClickZoom: false // Disable to prevent interference with our double-tap handlers
}).setView([30.49, -97.84], 13);
}).setView([30.49, -97.84], 13);
// Explicitly disable doubleClickZoom (belt and suspenders - init option + explicit call)
map.doubleClickZoom.disable();
// Base layers
// Base layers
const streetMap = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
const streetMap = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© < a href = "https://www.openstreetmap.org/copyright" > OpenStreetMap< / a > contributors',
attribution: '© < a href = "https://www.openstreetmap.org/copyright" > OpenStreetMap< / a > contributors',
@ -3194,62 +3280,78 @@
type: 'damage',
type: 'damage',
calculate: (atk) => Math.floor(atk * 3),
calculate: (atk) => Math.floor(atk * 3),
description: 'Devastating kick! (3x damage)'
description: 'Devastating kick! (3x damage)'
},
// Alternative skills for skill selection system
'quick_step': {
name: 'Quick Step',
icon: '⚡',
mpCost: 8,
levelReq: 2,
type: 'buff',
effect: 'dodge',
description: 'Dodge the next enemy attack completely'
},
'second_wind': {
name: 'Second Wind',
icon: '💨',
mpCost: 12,
levelReq: 3,
type: 'restore',
effect: 'mp',
calculate: (maxMp) => Math.floor(maxMp * 0.5),
description: 'Restore 50% of max MP'
},
'finish_line_sprint': {
name: 'Finish Line Sprint',
icon: '🏁',
mpCost: 25,
levelReq: 5,
type: 'damage',
calculate: (atk) => Math.floor(atk * 2),
hits: 3,
description: 'Strike 3 times for 2x ATK each'
}
}
};
};
// Monster type definitions
const MONSTER_TYPES = {
'discarded_gu': {
name: 'Discarded GU',
icon: '🟢',
baseHp: 30,
baseAtk: 5,
baseDef: 2,
xpReward: 15,
levelScale: { hp: 10, atk: 2, def: 1 }
// Skill pools for skill selection at level-up milestones
const SKILL_POOLS = {
'trail_runner': {
2: ['brand_new_hokas', 'quick_step'], // Level 2 choice
3: ['runners_high', 'second_wind'], // Level 3 choice
5: ['shin_kick', 'finish_line_sprint'] // Level 5 choice
}
}
};
};
// Monster dialogue by time phase
const MONSTER_DIALOGUES = {
'discarded_gu': {
annoyed: [
"Hey! HEY! You dropped something!",
"Excuse me, I believe you littered me!",
"This is a Leave No Trace trail!",
"I was perfectly good, you know...",
"One squeeze left! ONE SQUEEZE!"
],
frustrated: [
"STOP IGNORING ME!",
"I gave you ELECTROLYTES!",
"You used to need me every 45 minutes!",
"I'm worth $3 per packet!",
"Fine! Just keep walking! SEE IF I CARE!"
],
desperate: [
"Please... just acknowledge me...",
"I'll be strawberry flavor! Your favorite!",
"What if I promised no sticky fingers?",
"I just want closure...",
"Remember mile 18? I COULD HAVE HELPED!"
],
philosophical: [
"What even IS a gel, when you think about it?",
"If a GU falls in the forest and no one eats it...",
"Perhaps being discarded is the true ultramarathon.",
"Do you think I have a soul? Is maltodextrin sentient?",
"We're not so different, you and I..."
],
existential: [
"I have stared into the void. The void is caffeinated.",
"We are all just temporary vessels for maltodextrin.",
"I've accepted my fate.",
"The trail will reclaim me eventually.",
"It's actually kind of nice out here. Good views."
]
}
// Monster type definitions (loaded from database via API)
let MONSTER_TYPES = {};
let MONSTER_DIALOGUES = {};
let monsterTypesLoaded = false;
// Load monster types from the database
async function loadMonsterTypes() {
try {
const response = await fetch('/api/monster-types');
if (response.ok) {
const types = await response.json();
types.forEach(t => {
MONSTER_TYPES[t.id] = {
name: t.name,
icon: t.icon,
baseHp: t.baseHp,
baseAtk: t.baseAtk,
baseDef: t.baseDef,
xpReward: t.xpReward,
levelScale: t.levelScale
};
};
MONSTER_DIALOGUES[t.id] = t.dialogues;
});
monsterTypesLoaded = true;
console.log('Loaded monster types from database:', Object.keys(MONSTER_TYPES));
}
} catch (err) {
console.error('Failed to load monster types:', err);
}
}
// Dialogue phase thresholds (in minutes)
// Dialogue phase thresholds (in minutes)
const DIALOGUE_PHASES = [
const DIALOGUE_PHASES = [
@ -5274,14 +5376,6 @@
userMarker = {
userMarker = {
marker: L.marker([lat, lng], { icon: userIcon }).addTo(map),
marker: L.marker([lat, lng], { icon: userIcon }).addTo(map),
accuracyCircle: L.circle([lat, lng], {
radius: accuracy,
color: color,
fillColor: color,
fillOpacity: 0.1,
weight: 1,
interactive: false // Don't capture touch events
}).addTo(map),
color: color,
color: color,
icon: icon
icon: icon
};
};
@ -5291,8 +5385,6 @@
} else {
} else {
// Update existing marker position
// Update existing marker position
userMarker.marker.setLatLng([lat, lng]);
userMarker.marker.setLatLng([lat, lng]);
userMarker.accuracyCircle.setLatLng([lat, lng]);
userMarker.accuracyCircle.setRadius(accuracy);
// Update icon if it changed
// Update icon if it changed
if (icon & & color & & (userMarker.icon !== icon || userMarker.color !== color)) {
if (icon & & color & & (userMarker.icon !== icon || userMarker.color !== color)) {
@ -5303,7 +5395,6 @@
className: 'custom-div-icon'
className: 'custom-div-icon'
});
});
userMarker.marker.setIcon(newIcon);
userMarker.marker.setIcon(newIcon);
userMarker.accuracyCircle.setStyle({ color: color, fillColor: color });
userMarker.icon = icon;
userMarker.icon = icon;
userMarker.color = color;
userMarker.color = color;
}
}
@ -5314,7 +5405,6 @@
const userMarker = otherUsers.get(userId);
const userMarker = otherUsers.get(userId);
if (userMarker) {
if (userMarker) {
map.removeLayer(userMarker.marker);
map.removeLayer(userMarker.marker);
map.removeLayer(userMarker.accuracyCircle);
otherUsers.delete(userId);
otherUsers.delete(userId);
}
}
}
}
@ -6863,7 +6953,8 @@
// Start timer for 500ms hold
// Start timer for 500ms hold
pressTimer = setTimeout(() => {
pressTimer = setTimeout(() => {
if (isPressing) {
// Re-check for monsters (they might have spawned during the hold)
if (isPressing & & monsterEntourage.length === 0) {
document.getElementById('pressHoldIndicator').style.display = 'none';
document.getElementById('pressHoldIndicator').style.display = 'none';
// Show confirmation dialog
// Show confirmation dialog
const message = `Navigate to ${nearest.track.name}?`;
const message = `Navigate to ${nearest.track.name}?`;
@ -6871,6 +6962,9 @@
ensurePopupInBody('navConfirmDialog');
ensurePopupInBody('navConfirmDialog');
document.getElementById('navConfirmDialog').style.display = 'flex';
document.getElementById('navConfirmDialog').style.display = 'flex';
isPressing = false;
isPressing = false;
} else if (isPressing) {
// Monsters appeared during press - cancel silently
cancelPressHold();
}
}
}, 500);
}, 500);
@ -6902,6 +6996,10 @@
// Fix for Chrome and PWA - use native addEventListener with passive: false
// Fix for Chrome and PWA - use native addEventListener with passive: false
mapContainer.addEventListener('touchstart', function(e) {
mapContainer.addEventListener('touchstart', function(e) {
if (navMode & & e.touches.length === 1) {
if (navMode & & e.touches.length === 1) {
// ALWAYS prevent default in navMode to stop Leaflet from synthesizing dblclick
// This fixes the 50/50 bug where both touchend and dblclick handlers race
e.preventDefault();
touchStartTime = Date.now();
touchStartTime = Date.now();
const touch = e.touches[0];
const touch = e.touches[0];
const rect = mapContainer.getBoundingClientRect();
const rect = mapContainer.getBoundingClientRect();
@ -6912,13 +7010,8 @@
const containerPoint = L.point(x, y);
const containerPoint = L.point(x, y);
const latlng = map.containerPointToLatLng(containerPoint);
const latlng = map.containerPointToLatLng(containerPoint);
// Pass event with correct latlng structure
if (startPressHold({ latlng: latlng })) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
return false;
}
// Start press-hold (will return false if monsters present)
startPressHold({ latlng: latlng });
}
}
}, { passive: false, capture: true });
}, { passive: false, capture: true });
@ -7052,6 +7145,10 @@
});
});
map.on('dblclick', (e) => {
map.on('dblclick', (e) => {
// Skip on touch devices - handled by touchend handler instead
// This prevents the 50/50 race condition between handlers
if ('ontouchstart' in window) return;
if (navMode) {
if (navMode) {
L.DomEvent.stopPropagation(e);
L.DomEvent.stopPropagation(e);
L.DomEvent.preventDefault(e);
L.DomEvent.preventDefault(e);
@ -8912,7 +9009,8 @@
mp: finalStats.mp,
mp: finalStats.mp,
maxMp: finalStats.mp,
maxMp: finalStats.mp,
atk: finalStats.atk,
atk: finalStats.atk,
def: finalStats.def
def: finalStats.def,
unlockedSkills: ['basic_attack'] // Start with basic attack only
};
};
try {
try {
@ -9012,19 +9110,18 @@
< div class = "xp-next" > Next level: ${Math.max(0, xpNeeded - playerStats.xp)} XP needed< / div >
< div class = "xp-next" > Next level: ${Math.max(0, xpNeeded - playerStats.xp)} XP needed< / div >
`;
`;
// Update skills
const classSkills = cls.skills || [ ];
document.getElementById('charSheetSkills').innerHTML = class Skills.map(skillId => {
// Update skills (show only unlocked skills)
const unlockedSkills = playerStats.unlockedSkills || ['basic_attack' ];
document.getElementById('charSheetSkills').innerHTML = unlocked Skills.map(skillId => {
const skill = SKILLS[skillId];
const skill = SKILLS[skillId];
if (!skill) return '';
if (!skill) return '';
const locked = playerStats.level < skill.levelReq ;
return `
return `
< div class = "char-sheet-skill ${locked ? 'locked' : ''} " >
< span class = "skill-icon" > ${locked ? '🔒' : skill.icon}< / span >
< div class = "char-sheet-skill" >
< span class = "skill-icon" > ${skill.icon}< / span >
< div class = "skill-info" >
< div class = "skill-info" >
< div class = "skill-name" > ${skill.name} (Lv${skill.levelReq}) < / div >
< div class = "skill-name" > ${skill.name}< / div >
< div class = "skill-desc" > ${skill.description}< / div >
< div class = "skill-desc" > ${skill.description}< / div >
${!locked ? ` < div class = "skill-cost" > ${skill.mpCost} MP< / div > ` : ''}
< div class = "skill-cost" > ${skill.mpCost} MP< / div >
< / div >
< / div >
< / div >
< / div >
`;
`;
@ -9045,6 +9142,60 @@
}
}
});
});
// Skill Choice Modal (Level Up)
let pendingSkillChoice = null;
function showSkillChoice(level) {
const pool = SKILL_POOLS[playerStats.class];
if (!pool || !pool[level]) return;
const options = pool[level];
pendingSkillChoice = { level, options };
const optionsHtml = options.map(skillId => {
const skill = SKILLS[skillId];
if (!skill) return '';
return `
< div class = "skill-choice-option" onclick = "selectSkill('${skillId}')" >
< span class = "skill-choice-icon" > ${skill.icon}< / span >
< div class = "skill-choice-details" >
< div class = "skill-choice-name" > ${skill.name}< / div >
< div class = "skill-choice-desc" > ${skill.description}< / div >
< div class = "skill-choice-cost" > ${skill.mpCost} MP< / div >
< / div >
< / div >
`;
}).join('');
document.getElementById('skillChoiceOptions').innerHTML = optionsHtml;
document.getElementById('skillChoiceModal').style.display = 'flex';
}
function selectSkill(skillId) {
if (!pendingSkillChoice) return;
// Initialize unlockedSkills if needed
if (!playerStats.unlockedSkills) {
playerStats.unlockedSkills = ['basic_attack'];
}
// Add the selected skill
if (!playerStats.unlockedSkills.includes(skillId)) {
playerStats.unlockedSkills.push(skillId);
}
// Save to server
savePlayerStats();
// Close modal
document.getElementById('skillChoiceModal').style.display = 'none';
pendingSkillChoice = null;
// Show notification
const skill = SKILLS[skillId];
showNotification(`Learned ${skill.name}!`, 'success');
}
// Leaderboard
// Leaderboard
async function loadLeaderboard(period = 'all') {
async function loadLeaderboard(period = 'all') {
try {
try {
@ -9437,6 +9588,12 @@
function spawnMonsterNearPlayer() {
function spawnMonsterNearPlayer() {
if (!userLocation || !playerStats) return;
if (!userLocation || !playerStats) return;
if (monsterEntourage.length >= getMaxMonsters()) return;
if (monsterEntourage.length >= getMaxMonsters()) return;
if (!monsterTypesLoaded || Object.keys(MONSTER_TYPES).length === 0) return;
// Pick a random monster type from available types
const typeIds = Object.keys(MONSTER_TYPES);
const typeId = typeIds[Math.floor(Math.random() * typeIds.length)];
const monsterType = MONSTER_TYPES[typeId];
// Random offset 30-60 meters from player
// Random offset 30-60 meters from player
const angle = Math.random() * 2 * Math.PI;
const angle = Math.random() * 2 * Math.PI;
@ -9450,11 +9607,10 @@
const offsetLng = (distance * Math.sin(angle)) / metersPerDegLng;
const offsetLng = (distance * Math.sin(angle)) / metersPerDegLng;
const monsterLevel = Math.max(1, playerStats.level + Math.floor(Math.random() * 3) - 1);
const monsterLevel = Math.max(1, playerStats.level + Math.floor(Math.random() * 3) - 1);
const monsterType = MONSTER_TYPES['discarded_gu'];
const monster = {
const monster = {
id: `monster_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
id: `monster_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
type: 'discarded_gu' ,
type: typeId ,
level: monsterLevel,
level: monsterLevel,
position: {
position: {
lat: userLocation.lat + offsetLat,
lat: userLocation.lat + offsetLat,
@ -9652,33 +9808,24 @@
const monsterCount = combatState.monsters.length;
const monsterCount = combatState.monsters.length;
log.innerHTML = `< div class = "combat-log-entry" > Combat begins! ${monsterCount} ${monsterCount === 1 ? 'enemy' : 'enemies'} engaged!< / div > `;
log.innerHTML = `< div class = "combat-log-entry" > Combat begins! ${monsterCount} ${monsterCount === 1 ? 'enemy' : 'enemies'} engaged!< / div > `;
// Populate skills
// Populate skills (only show unlocked skills)
const skillsContainer = document.getElementById('combatSkills');
const skillsContainer = document.getElementById('combatSkills');
skillsContainer.innerHTML = '';
skillsContainer.innerHTML = '';
const playerClass = PLAYER_CLASSES[playerStats.class];
playerClass.skills.forEach(skillId => {
// Use unlockedSkills if available, otherwise fall back to basic_attack only
const unlockedSkills = playerStats.unlockedSkills || ['basic_attack'];
unlockedSkills.forEach(skillId => {
const skill = SKILLS[skillId];
const skill = SKILLS[skillId];
const levelReq = skill.levelReq || 1;
const isLocked = playerStats.level < levelReq ;
if (!skill) return; // Skip if skill doesn't exist
const btn = document.createElement('button');
const btn = document.createElement('button');
btn.className = 'skill-btn' + (isLocked ? ' skill-locked' : '') ;
btn.className = 'skill-btn';
btn.dataset.skillId = skillId;
btn.dataset.skillId = skillId;
if (isLocked) {
btn.innerHTML = `
< span class = "skill-name" > 🔒 ${skill.name}< / span >
< span class = "skill-cost locked" > Lv.${levelReq}< / span >
`;
btn.disabled = true;
} else {
btn.innerHTML = `
btn.innerHTML = `
< span class = "skill-name" > ${skill.icon} ${skill.name}< / span >
< span class = "skill-name" > ${skill.icon} ${skill.name}< / span >
< span class = "skill-cost ${skill.mpCost === 0 ? 'free' : ''}" > ${skill.mpCost > 0 ? skill.mpCost + ' MP' : 'Free'}< / span >
< span class = "skill-cost ${skill.mpCost === 0 ? 'free' : ''}" > ${skill.mpCost > 0 ? skill.mpCost + ' MP' : 'Free'}< / span >
`;
`;
btn.onclick = () => executePlayerSkill(skillId);
btn.onclick = () => executePlayerSkill(skillId);
}
skillsContainer.appendChild(btn);
skillsContainer.appendChild(btn);
});
});
@ -9977,7 +10124,8 @@
function checkLevelUp() {
function checkLevelUp() {
const xpNeeded = playerStats.level * 100;
const xpNeeded = playerStats.level * 100;
if (playerStats.xp >= xpNeeded) {
if (playerStats.xp >= xpNeeded) {
playerStats.level++;
const newLevel = playerStats.level + 1;
playerStats.level = newLevel;
playerStats.xp -= xpNeeded;
playerStats.xp -= xpNeeded;
const classData = PLAYER_CLASSES[playerStats.class];
const classData = PLAYER_CLASSES[playerStats.class];
@ -9988,7 +10136,14 @@
playerStats.atk += classData.atkPerLevel;
playerStats.atk += classData.atkPerLevel;
playerStats.def += classData.defPerLevel;
playerStats.def += classData.defPerLevel;
addCombatLog(`LEVEL UP! Now level ${playerStats.level}!`, 'victory');
addCombatLog(`LEVEL UP! Now level ${newLevel}!`, 'victory');
// Check for skill choice at this level
const pool = SKILL_POOLS[playerStats.class];
if (pool & & pool[newLevel]) {
// Delay showing modal to let combat UI update first
setTimeout(() => showSkillChoice(newLevel), 500);
}
// Check for another level up (in case of huge XP gain)
// Check for another level up (in case of huge XP gain)
checkLevelUp();
checkLevelUp();
@ -9999,8 +10154,10 @@
// END RPG COMBAT SYSTEM FUNCTIONS
// END RPG COMBAT SYSTEM FUNCTIONS
// ==========================================
// ==========================================
// Initialize auth on load
// Load monster types from database, then initialize auth
loadMonsterTypes().then(() => {
loadCurrentUser();
loadCurrentUser();
});
// Show auth modal if not logged in (guest mode available)
// Show auth modal if not logged in (guest mode available)
if (!localStorage.getItem('accessToken') & & !sessionStorage.getItem('guestMode')) {
if (!localStorage.getItem('accessToken') & & !sessionStorage.getItem('guestMode')) {