diff --git a/database.js b/database.js index cc9250b..d429a44 100644 --- a/database.js +++ b/database.js @@ -65,6 +65,8 @@ class HikeMapDB { this.db.exec(` CREATE TABLE IF NOT EXISTS rpg_stats ( user_id INTEGER PRIMARY KEY, + character_name TEXT, + race TEXT DEFAULT 'human', class TEXT NOT NULL DEFAULT 'trail_runner', level INTEGER DEFAULT 1, xp INTEGER DEFAULT 0, @@ -79,6 +81,33 @@ class HikeMapDB { ) `); + // Migration: Add character_name and race columns if they don't exist + try { + this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN character_name TEXT`); + } catch (e) { /* Column already exists */ } + try { + this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN race TEXT DEFAULT 'human'`); + } catch (e) { /* Column already exists */ } + + // Monster entourage table - stores monsters following the player + this.db.exec(` + CREATE TABLE IF NOT EXISTS monster_entourage ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + monster_type TEXT NOT NULL, + level INTEGER NOT NULL, + hp INTEGER NOT NULL, + max_hp INTEGER NOT NULL, + atk INTEGER NOT NULL, + def INTEGER NOT NULL, + position_lat REAL, + position_lng REAL, + spawn_time INTEGER NOT NULL, + last_dialogue_time INTEGER DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + `); + // Create indexes for performance this.db.exec(` CREATE INDEX IF NOT EXISTS idx_geocache_finds_user ON geocache_finds(user_id); @@ -86,6 +115,7 @@ class HikeMapDB { CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + CREATE INDEX IF NOT EXISTS idx_monster_entourage_user ON monster_entourage(user_id); `); } @@ -324,17 +354,60 @@ class HikeMapDB { // RPG Stats methods getRpgStats(userId) { const stmt = this.db.prepare(` - SELECT class, level, xp, hp, max_hp, mp, max_mp, atk, def + SELECT character_name, race, class, level, xp, hp, max_hp, mp, max_mp, atk, def FROM rpg_stats WHERE user_id = ? `); return stmt.get(userId); } + hasCharacter(userId) { + const stmt = this.db.prepare(` + SELECT 1 FROM rpg_stats WHERE user_id = ? AND character_name IS NOT NULL + `); + return !!stmt.get(userId); + } + + createCharacter(userId, characterData) { + const stmt = this.db.prepare(` + INSERT INTO rpg_stats (user_id, character_name, race, class, level, xp, hp, max_hp, mp, max_mp, atk, def, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(user_id) DO UPDATE SET + character_name = excluded.character_name, + race = excluded.race, + class = excluded.class, + level = excluded.level, + xp = excluded.xp, + hp = excluded.hp, + max_hp = excluded.max_hp, + mp = excluded.mp, + max_mp = excluded.max_mp, + atk = excluded.atk, + def = excluded.def, + updated_at = datetime('now') + `); + return stmt.run( + userId, + characterData.name, + characterData.race || 'human', + characterData.class || 'trail_runner', + characterData.level || 1, + characterData.xp || 0, + characterData.hp || 100, + characterData.maxHp || 100, + characterData.mp || 50, + characterData.maxMp || 50, + characterData.atk || 12, + characterData.def || 8 + ); + } + saveRpgStats(userId, stats) { const stmt = this.db.prepare(` - INSERT INTO rpg_stats (user_id, class, level, xp, hp, max_hp, mp, max_mp, atk, def, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) + INSERT INTO rpg_stats (user_id, character_name, race, class, level, xp, hp, max_hp, mp, max_mp, atk, def, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(user_id) DO UPDATE SET + character_name = COALESCE(excluded.character_name, rpg_stats.character_name), + race = COALESCE(excluded.race, rpg_stats.race), class = excluded.class, level = excluded.level, xp = excluded.xp, @@ -348,6 +421,8 @@ class HikeMapDB { `); return stmt.run( userId, + stats.name || null, + stats.race || null, stats.class || 'trail_runner', stats.level || 1, stats.xp || 0, @@ -360,6 +435,52 @@ class HikeMapDB { ); } + // Monster entourage methods + getMonsterEntourage(userId) { + const stmt = this.db.prepare(` + SELECT id, monster_type, level, hp, max_hp, atk, def, + position_lat, position_lng, spawn_time, last_dialogue_time + FROM monster_entourage WHERE user_id = ? + `); + return stmt.all(userId); + } + + saveMonsterEntourage(userId, monsters) { + const deleteStmt = this.db.prepare(`DELETE FROM monster_entourage WHERE user_id = ?`); + const insertStmt = this.db.prepare(` + INSERT INTO monster_entourage + (id, user_id, monster_type, level, hp, max_hp, atk, def, position_lat, position_lng, spawn_time, last_dialogue_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const transaction = this.db.transaction(() => { + deleteStmt.run(userId); + for (const monster of monsters) { + insertStmt.run( + monster.id, + userId, + monster.type, + monster.level, + monster.hp, + monster.maxHp, + monster.atk, + monster.def, + monster.position?.lat || null, + monster.position?.lng || null, + monster.spawnTime, + monster.lastDialogueTime || 0 + ); + } + }); + + return transaction(); + } + + removeMonster(userId, monsterId) { + const stmt = this.db.prepare(`DELETE FROM monster_entourage WHERE user_id = ? AND id = ?`); + return stmt.run(userId, monsterId); + } + close() { if (this.db) { this.db.close(); diff --git a/index.html b/index.html index 1c62954..b138796 100644 --- a/index.html +++ b/index.html @@ -46,6 +46,14 @@ position: relative; z-index: 1; } + /* In nav mode, disable pointer events on trails so markers are easier to tap */ + body.nav-mode .leaflet-overlay-pane path.leaflet-interactive { + pointer-events: none; + } + /* But keep route highlight interactive for potential future use */ + body.nav-mode .leaflet-overlay-pane path.route-highlight { + pointer-events: auto; + } /* Ensure Leaflet doesn't block our popups */ .leaflet-container { z-index: 1 !important; @@ -443,6 +451,12 @@ border: none; font-size: 20px; cursor: pointer; + /* Larger tap target for mobile */ + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; } .geocache-marker:hover { transform: scale(1.2); @@ -452,6 +466,10 @@ box-shadow: 0 0 20px rgba(255, 167, 38, 0.8); border-radius: 50%; } + /* Increase tap area for geocache icon */ + .geocache-marker i { + padding: 10px; + } .geocache-dialog { position: fixed !important; top: 0 !important; @@ -1153,6 +1171,433 @@ cursor: pointer; color: #666; } + .auth-guest-divider { + display: flex; + align-items: center; + margin: 20px 0 15px 0; + color: #999; + font-size: 13px; + } + .auth-guest-divider::before, + .auth-guest-divider::after { + content: ''; + flex: 1; + height: 1px; + background: #ddd; + } + .auth-guest-divider span { + padding: 0 15px; + } + .auth-guest-btn { + width: 100%; + padding: 12px; + background: transparent; + color: #666; + border: 2px solid #ddd; + border-radius: 4px; + font-size: 14px; + font-weight: bold; + cursor: pointer; + transition: all 0.2s; + } + .auth-guest-btn:hover { + border-color: #999; + color: #333; + background: #f5f5f5; + } + /* Character Creator Modal */ + .char-creator-modal { + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + padding: 0; + border-radius: 16px; + width: 95%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + color: white; + } + .char-creator-header { + background: linear-gradient(90deg, #4CAF50 0%, #8BC34A 100%); + padding: 20px; + text-align: center; + border-radius: 16px 16px 0 0; + } + .char-creator-header h2 { + margin: 0; + font-size: 24px; + text-shadow: 0 2px 4px rgba(0,0,0,0.3); + } + .char-creator-header p { + margin: 5px 0 0 0; + font-size: 14px; + opacity: 0.9; + } + .char-creator-content { + padding: 20px; + } + .char-creator-step { + display: none; + } + .char-creator-step.active { + display: block; + } + .char-creator-section { + margin-bottom: 20px; + } + .char-creator-section h3 { + margin: 0 0 10px 0; + font-size: 16px; + color: #8BC34A; + } + .char-creator-input { + width: 100%; + padding: 12px; + border: 2px solid #333; + border-radius: 8px; + background: #0f0f23; + color: white; + font-size: 16px; + box-sizing: border-box; + } + .char-creator-input:focus { + border-color: #4CAF50; + outline: none; + } + .char-creator-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + } + .char-creator-option { + background: #0f0f23; + border: 2px solid #333; + border-radius: 12px; + padding: 15px; + cursor: pointer; + transition: all 0.2s; + text-align: center; + } + .char-creator-option:hover { + border-color: #4CAF50; + background: #1a1a2e; + } + .char-creator-option.selected { + border-color: #4CAF50; + background: rgba(76, 175, 80, 0.2); + } + .char-creator-option.disabled { + opacity: 0.5; + cursor: not-allowed; + } + .char-creator-option.disabled:hover { + border-color: #333; + background: #0f0f23; + } + .char-creator-option-icon { + font-size: 36px; + margin-bottom: 8px; + } + .char-creator-option-name { + font-weight: bold; + margin-bottom: 4px; + } + .char-creator-option-desc { + font-size: 12px; + color: #aaa; + } + .char-creator-option-badge { + display: inline-block; + background: #666; + color: #ccc; + font-size: 10px; + padding: 2px 8px; + border-radius: 10px; + margin-top: 5px; + } + .char-creator-stats { + display: flex; + justify-content: center; + gap: 15px; + margin-top: 8px; + font-size: 11px; + } + .char-creator-stat { + display: flex; + align-items: center; + gap: 3px; + } + .char-creator-stat.positive { color: #4CAF50; } + .char-creator-stat.negative { color: #f44336; } + .char-creator-stat.neutral { color: #888; } + .char-creator-preview { + background: #0f0f23; + border: 2px solid #333; + border-radius: 12px; + padding: 20px; + text-align: center; + margin-bottom: 20px; + } + .char-creator-preview-icon { + font-size: 48px; + margin-bottom: 10px; + } + .char-creator-preview-name { + font-size: 20px; + font-weight: bold; + color: #8BC34A; + } + .char-creator-preview-info { + font-size: 14px; + color: #aaa; + margin-top: 5px; + } + .char-creator-preview-stats { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + margin-top: 15px; + text-align: left; + } + .char-creator-preview-stat { + display: flex; + justify-content: space-between; + padding: 8px 12px; + background: #1a1a2e; + border-radius: 6px; + } + .char-creator-preview-stat-label { + color: #888; + } + .char-creator-preview-stat-value { + font-weight: bold; + } + .char-creator-buttons { + display: flex; + gap: 10px; + margin-top: 20px; + } + .char-creator-btn { + flex: 1; + padding: 14px; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: bold; + cursor: pointer; + transition: all 0.2s; + } + .char-creator-btn-back { + background: #333; + color: white; + } + .char-creator-btn-back:hover { + background: #444; + } + .char-creator-btn-next { + background: linear-gradient(90deg, #4CAF50 0%, #8BC34A 100%); + color: white; + } + .char-creator-btn-next:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(76, 175, 80, 0.4); + } + .char-creator-btn-next:disabled { + background: #333; + cursor: not-allowed; + transform: none; + box-shadow: none; + } + .char-step-indicator { + display: flex; + justify-content: center; + gap: 8px; + padding: 15px 0; + background: rgba(0,0,0,0.2); + } + .char-step-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: #333; + transition: all 0.2s; + } + .char-step-dot.active { + background: #4CAF50; + transform: scale(1.2); + } + .char-step-dot.completed { + background: #8BC34A; + } + + /* Character Sheet Modal */ + .char-sheet-modal { + background: linear-gradient(135deg, #1a1a2e 0%, #0f0f23 100%); + border-radius: 20px; + padding: 0; + width: 90%; + max-width: 400px; + max-height: 85vh; + overflow-y: auto; + position: relative; + border: 2px solid #4CAF50; + box-shadow: 0 20px 60px rgba(0,0,0,0.5); + } + .char-sheet-close { + position: absolute; + top: 15px; + right: 15px; + background: none; + border: none; + color: #aaa; + font-size: 28px; + cursor: pointer; + z-index: 10; + transition: color 0.2s; + } + .char-sheet-close:hover { + color: #fff; + } + .char-sheet-header { + background: linear-gradient(135deg, #4CAF50, #8BC34A); + padding: 25px 20px; + text-align: center; + border-radius: 18px 18px 0 0; + } + .char-sheet-icon { + font-size: 48px; + margin-bottom: 10px; + } + .char-sheet-name { + font-size: 22px; + font-weight: bold; + color: #fff; + text-shadow: 0 2px 4px rgba(0,0,0,0.3); + } + .char-sheet-info { + font-size: 14px; + color: rgba(255,255,255,0.9); + margin-top: 5px; + } + .char-sheet-content { + padding: 20px; + } + .char-sheet-section { + background: rgba(255,255,255,0.05); + border-radius: 12px; + padding: 15px; + margin-bottom: 15px; + } + .char-sheet-section h3 { + color: #8BC34A; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 1px; + margin: 0 0 12px 0; + padding-bottom: 8px; + border-bottom: 1px solid rgba(139,195,74,0.3); + } + .char-sheet-stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + } + .char-sheet-stat { + display: flex; + flex-direction: column; + gap: 4px; + } + .char-sheet-stat .stat-label { + font-size: 12px; + color: #aaa; + } + .char-sheet-stat .stat-value { + font-size: 16px; + font-weight: bold; + color: #fff; + } + .char-sheet-stat .stat-bar { + height: 8px; + background: rgba(0,0,0,0.3); + border-radius: 4px; + overflow: hidden; + } + .char-sheet-stat .stat-bar.hp-bar .stat-fill { + background: linear-gradient(90deg, #ff6b6b, #ee5a5a); + } + .char-sheet-stat .stat-bar.mp-bar .stat-fill { + background: linear-gradient(90deg, #4ecdc4, #45b7aa); + } + .char-sheet-stat .stat-fill { + height: 100%; + transition: width 0.3s ease; + } + .char-sheet-xp .xp-bar-container { + height: 12px; + background: rgba(0,0,0,0.3); + border-radius: 6px; + overflow: hidden; + margin-bottom: 8px; + } + .char-sheet-xp .xp-bar-fill { + height: 100%; + background: linear-gradient(90deg, #ffd93d, #f0c419); + transition: width 0.3s ease; + } + .char-sheet-xp .xp-text { + font-size: 14px; + font-weight: bold; + color: #ffd93d; + text-align: center; + } + .char-sheet-xp .xp-next { + font-size: 12px; + color: #888; + text-align: center; + margin-top: 4px; + } + .char-sheet-skill { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 10px; + background: rgba(0,0,0,0.2); + border-radius: 8px; + margin-bottom: 8px; + } + .char-sheet-skill:last-child { + margin-bottom: 0; + } + .char-sheet-skill .skill-icon { + font-size: 24px; + min-width: 30px; + text-align: center; + } + .char-sheet-skill .skill-info { + flex: 1; + } + .char-sheet-skill .skill-name { + font-size: 14px; + font-weight: bold; + color: #fff; + } + .char-sheet-skill .skill-desc { + font-size: 12px; + color: #aaa; + margin-top: 2px; + } + .char-sheet-skill .skill-cost { + font-size: 11px; + color: #4ecdc4; + margin-top: 4px; + } + .char-sheet-skill.locked { + opacity: 0.5; + } + .char-sheet-skill.locked .skill-name { + color: #666; + } + /* User Profile Display */ .user-profile { display: flex; @@ -1383,13 +1828,26 @@ position: relative; cursor: pointer; transition: transform 0.2s; + /* Large tap target for mobile */ + display: flex; + align-items: center; + justify-content: center; + width: 70px; + height: 70px; + /* Semi-transparent background for tap area */ + background: radial-gradient(circle, rgba(255,100,100,0.25) 0%, rgba(255,100,100,0) 70%); + border-radius: 50%; + } + .monster-marker:hover { + transform: scale(1.2); } - .monster-marker:hover { - transform: scale(1.3); + .monster-marker:active { + transform: scale(0.95); + background: radial-gradient(circle, rgba(255,100,100,0.4) 0%, rgba(255,100,100,0) 70%); } .monster-icon { - font-size: 36px; - filter: drop-shadow(2px 2px 3px rgba(0,0,0,0.5)); + font-size: 44px; + filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.6)); animation: monster-bob 2s ease-in-out infinite; } @keyframes monster-bob { @@ -1784,7 +2242,7 @@ @@ -2059,6 +2523,115 @@ + + + + + +
@@ -2505,16 +3078,82 @@ // ========================================== // Player class definitions + // Character races with stat bonuses + const RACES = { + 'human': { + name: 'Human', + icon: '🧑', + description: 'Balanced and adaptable', + bonuses: { hp: 5, mp: 5, atk: 0, def: 0 } + }, + 'elf': { + name: 'Elf', + icon: '🧝', + description: 'Swift and magical', + bonuses: { hp: -5, mp: 15, atk: 0, def: -2 } + }, + 'dwarf': { + name: 'Dwarf', + icon: '🧔', + description: 'Tough and sturdy', + bonuses: { hp: 15, mp: -5, atk: 0, def: 3 } + }, + 'halfling': { + name: 'Halfling', + icon: '🧒', + description: 'Quick and nimble', + bonuses: { hp: -5, mp: 0, atk: 2, def: 5 } + } + }; + const PLAYER_CLASSES = { 'trail_runner': { name: 'Trail Runner', icon: '🏃', + available: true, + description: 'Masters of endurance and speed', baseStats: { hp: 100, mp: 50, atk: 12, def: 8 }, hpPerLevel: 20, mpPerLevel: 10, atkPerLevel: 3, defPerLevel: 2, skills: ['basic_attack', 'brand_new_hokas', 'runners_high', 'shin_kick'] + }, + 'gym_bro': { + name: 'Gym Bro', + icon: '💪', + available: false, + description: 'Raw power and protein', + baseStats: { hp: 130, mp: 30, atk: 18, def: 10 }, + hpPerLevel: 25, + mpPerLevel: 5, + atkPerLevel: 5, + defPerLevel: 3, + skills: [] + }, + 'yoga_master': { + name: 'Yoga Master', + icon: '🧘', + available: false, + description: 'Flexible and mystical', + baseStats: { hp: 80, mp: 80, atk: 8, def: 6 }, + hpPerLevel: 15, + mpPerLevel: 15, + atkPerLevel: 2, + defPerLevel: 1, + skills: [] + }, + 'crossfit_crusader': { + name: 'CrossFit Crusader', + icon: '🏋️', + available: false, + description: 'Jack of all workouts', + baseStats: { hp: 110, mp: 40, atk: 15, def: 12 }, + hpPerLevel: 22, + mpPerLevel: 8, + atkPerLevel: 4, + defPerLevel: 3, + skills: [] } }; @@ -2628,6 +3267,30 @@ let monsterSpawnTimer = null; // Interval for spawning monsters let monsterUpdateTimer = null; // Interval for updating monster positions/dialogue + // Find nearest monster to a location (for double-tap to battle on mobile) + function findNearestMonster(latlng, maxDistanceMeters = 50) { + if (monsterEntourage.length === 0) return null; + + let nearest = null; + let nearestDist = Infinity; + + const metersPerDegLat = 111320; + const metersPerDegLng = 111320 * Math.cos(latlng.lat * Math.PI / 180); + + monsterEntourage.forEach(monster => { + const dx = (latlng.lng - monster.position.lng) * metersPerDegLng; + const dy = (latlng.lat - monster.position.lat) * metersPerDegLat; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < nearestDist) { + nearestDist = dist; + nearest = monster; + } + }); + + return nearestDist <= maxDistanceMeters ? { monster: nearest, distance: nearestDist } : null; + } + // Max monsters = 2 per player level const getMaxMonsters = () => 2 * (playerStats?.level || 1); @@ -2832,7 +3495,8 @@ color: '#4285f4', fillColor: '#4285f4', fillOpacity: 0.15, - weight: 1 + weight: 1, + interactive: false // Don't capture touch events }).addTo(map); } else { gpsAccuracyCircle.setLatLng([lat, lng]); @@ -3698,12 +4362,8 @@ myIcon = localStorage.getItem('userIcon'); myColor = localStorage.getItem('userColor'); - if (!myIcon || !myColor) { - // Show selector if no icon chosen yet - delay to ensure DOM is ready - setTimeout(() => { - showIconSelector(); - }, 100); - } + // Note: Icon selector now accessible from profile, not auto-shown on load + // Users can set their icon after logging in or from the profile section } // Calculate minimum distance from a point to any track @@ -3829,9 +4489,9 @@ const marker = L.marker([geocache.lat, geocache.lng], { icon: L.divIcon({ className: 'geocache-marker', - html: ``, - iconSize: [28, 28], - iconAnchor: [14, 28] + html: ``, + iconSize: [48, 48], + iconAnchor: [24, 40] }), zIndexOffset: 1000 // Ensure geocaches appear above GPS markers }); @@ -4619,7 +5279,8 @@ color: color, fillColor: color, fillOpacity: 0.1, - weight: 1 + weight: 1, + interactive: false // Don't capture touch events }).addTo(map), color: color, icon: icon @@ -5050,6 +5711,7 @@ editOverlay.classList.add('active'); } navMode = false; + document.body.classList.remove('nav-mode'); // Show geocache list toggle in edit mode document.getElementById('geocacheListToggle').style.display = 'flex'; @@ -5078,6 +5740,7 @@ navTab.classList.add('active'); navContent.classList.add('active'); navMode = true; + document.body.classList.add('nav-mode'); // Hide geocache list toggle in nav mode document.getElementById('geocacheListToggle').style.display = 'none'; @@ -6186,6 +6849,9 @@ function startPressHold(e) { if (!navMode) return false; + // Don't allow press-hold navigation when monsters are present + if (monsterEntourage.length > 0) return false; + const nearest = findNearestTrackPoint(e.latlng, 100); if (!nearest) return false; @@ -6269,7 +6935,7 @@ } // Check for double-tap (two taps within 300ms at roughly same location) - if (timeSinceLastTap < 300 && lastTapLocation && currentTapLocation && pendingDestination) { + if (timeSinceLastTap < 300 && lastTapLocation && currentTapLocation) { // Calculate distance between taps const dx = currentTapLocation.x - lastTapLocation.x; const dy = currentTapLocation.y - lastTapLocation.y; @@ -6277,15 +6943,37 @@ // Only trigger if taps are within 30 pixels of each other if (distance < 30) { - // Double-tap detected at same location - show navigation dialog e.preventDefault(); e.stopPropagation(); - document.getElementById('pressHoldIndicator').style.display = 'none'; - const message = `Navigate to ${pendingDestination.track.name}?`; - document.getElementById('navConfirmMessage').textContent = message; - ensurePopupInBody('navConfirmDialog'); - document.getElementById('navConfirmDialog').style.display = 'flex'; + + // Get latlng from touch position + 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); + + // If monsters are present, double-tap is for combat only + if (monsterEntourage.length > 0) { + const nearestMonster = findNearestMonster(latlng, 50); + if (nearestMonster) { + initiateCombat(nearestMonster.monster); + } + // Don't allow navigation when monsters are around + lastTapTime = 0; + lastTapLocation = null; + cancelPressHold(); + return; + } + + // No monsters - show navigation dialog if we have a destination + if (pendingDestination) { + const message = `Navigate to ${pendingDestination.track.name}?`; + document.getElementById('navConfirmMessage').textContent = message; + ensurePopupInBody('navConfirmDialog'); + document.getElementById('navConfirmDialog').style.display = 'flex'; + } lastTapTime = 0; // Reset to prevent triple tap lastTapLocation = null; @@ -6365,14 +7053,22 @@ map.on('dblclick', (e) => { if (navMode) { - // In navigation mode, double-click sets destination L.DomEvent.stopPropagation(e); L.DomEvent.preventDefault(e); - // Find nearest track point + // If monsters are present, double-tap is for combat only + if (monsterEntourage.length > 0) { + const nearestMonster = findNearestMonster(e.latlng, 50); + if (nearestMonster) { + initiateCombat(nearestMonster.monster); + } + // Don't allow navigation when monsters are around + return; + } + + // No monsters - double-tap sets navigation destination const nearest = findNearestTrackPoint(e.latlng); if (nearest && nearest.distance < 50) { - // Show navigation dialog pendingDestination = nearest; const message = `Navigate to ${nearest.track.name}?`; document.getElementById('navConfirmMessage').textContent = message; @@ -7672,6 +8368,7 @@ // Start in navigation mode navMode = true; + document.body.classList.add('nav-mode'); // Load admin settings loadAdminSettings(); @@ -8032,6 +8729,322 @@ document.getElementById('authCloseBtn').addEventListener('click', hideAuthModal); document.getElementById('logoutBtn').addEventListener('click', logout); + // Guest mode button + document.getElementById('guestModeBtn').addEventListener('click', () => { + sessionStorage.setItem('guestMode', 'true'); + hideAuthModal(); + updateStatus('Continuing as guest - log in to save progress!', 'info'); + }); + + // ========================================== + // CHARACTER CREATOR + // ========================================== + + let charCreatorState = { + step: 1, + name: '', + race: null, + class: null + }; + + function showCharCreatorModal() { + // Reset state + charCreatorState = { step: 1, name: '', race: null, class: null }; + document.getElementById('charNameInput').value = ''; + + // Populate race options + const raceGrid = document.getElementById('raceSelection'); + raceGrid.innerHTML = Object.entries(RACES).map(([id, race]) => { + const bonusHtml = Object.entries(race.bonuses) + .filter(([stat, val]) => val !== 0) + .map(([stat, val]) => { + const statLabel = stat.toUpperCase(); + const sign = val > 0 ? '+' : ''; + const cls = val > 0 ? 'positive' : 'negative'; + return `${sign}${val} ${statLabel}`; + }).join(''); + + return ` +
+
${race.icon}
+
${race.name}
+
${race.description}
+
${bonusHtml || 'Balanced'}
+
+ `; + }).join(''); + + // Add click handlers to race options + raceGrid.querySelectorAll('.char-creator-option').forEach(opt => { + opt.addEventListener('click', () => { + raceGrid.querySelectorAll('.char-creator-option').forEach(o => o.classList.remove('selected')); + opt.classList.add('selected'); + charCreatorState.race = opt.dataset.race; + document.getElementById('charStep2Next').disabled = false; + }); + }); + + // Populate class options + const classGrid = document.getElementById('classSelection'); + classGrid.innerHTML = Object.entries(PLAYER_CLASSES).map(([id, cls]) => { + const disabled = !cls.available; + const stats = cls.baseStats; + + return ` +
+
${cls.icon}
+
${cls.name}
+
${cls.description}
+ ${disabled ? '
Coming Soon
' : ` +
+ ${stats.hp} HP + ${stats.mp} MP +
+ `} +
+ `; + }).join(''); + + // Add click handlers to class options + classGrid.querySelectorAll('.char-creator-option:not(.disabled)').forEach(opt => { + opt.addEventListener('click', () => { + classGrid.querySelectorAll('.char-creator-option').forEach(o => o.classList.remove('selected')); + opt.classList.add('selected'); + charCreatorState.class = opt.dataset.class; + document.getElementById('charStep3Next').disabled = false; + }); + }); + + // Show modal and first step + goToCharStep(1); + document.getElementById('charCreatorModal').style.display = 'flex'; + } + + function hideCharCreatorModal() { + document.getElementById('charCreatorModal').style.display = 'none'; + } + + function goToCharStep(step) { + charCreatorState.step = step; + + // Update step visibility + document.querySelectorAll('.char-creator-step').forEach(s => { + s.classList.toggle('active', parseInt(s.dataset.step) === step); + }); + + // Update step indicators + document.querySelectorAll('.char-step-dot').forEach(dot => { + const dotStep = parseInt(dot.dataset.step); + dot.classList.remove('active', 'completed'); + if (dotStep === step) dot.classList.add('active'); + else if (dotStep < step) dot.classList.add('completed'); + }); + + // Update preview on step 4 + if (step === 4) { + updateCharPreview(); + } + } + + function updateCharPreview() { + const race = RACES[charCreatorState.race]; + const cls = PLAYER_CLASSES[charCreatorState.class]; + + if (!race || !cls) return; + + // Calculate final stats (base + race bonuses) + const finalStats = { + hp: cls.baseStats.hp + (race.bonuses.hp || 0), + mp: cls.baseStats.mp + (race.bonuses.mp || 0), + atk: cls.baseStats.atk + (race.bonuses.atk || 0), + def: cls.baseStats.def + (race.bonuses.def || 0) + }; + + document.getElementById('charPreviewIcon').textContent = race.icon; + document.getElementById('charPreviewName').textContent = charCreatorState.name; + document.getElementById('charPreviewInfo').textContent = `${race.name} ${cls.name}`; + + document.getElementById('charPreviewStats').innerHTML = ` +
+ ❤️ HP + ${finalStats.hp} +
+
+ 💙 MP + ${finalStats.mp} +
+
+ ⚔️ ATK + ${finalStats.atk} +
+
+ 🛡️ DEF + ${finalStats.def} +
+ `; + } + + async function createCharacter() { + const race = RACES[charCreatorState.race]; + const cls = PLAYER_CLASSES[charCreatorState.class]; + + if (!race || !cls || !charCreatorState.name) { + updateStatus('Please complete all character creation steps', 'error'); + return; + } + + // Calculate final stats with race bonuses + const finalStats = { + hp: cls.baseStats.hp + (race.bonuses.hp || 0), + mp: cls.baseStats.mp + (race.bonuses.mp || 0), + atk: cls.baseStats.atk + (race.bonuses.atk || 0), + def: cls.baseStats.def + (race.bonuses.def || 0) + }; + + const characterData = { + name: charCreatorState.name, + race: charCreatorState.race, + class: charCreatorState.class, + level: 1, + xp: 0, + hp: finalStats.hp, + maxHp: finalStats.hp, + mp: finalStats.mp, + maxMp: finalStats.mp, + atk: finalStats.atk, + def: finalStats.def + }; + + try { + const token = localStorage.getItem('accessToken'); + const response = await fetch('/api/user/character', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(characterData) + }); + + if (response.ok) { + hideCharCreatorModal(); + updateStatus(`Welcome, ${charCreatorState.name}! Your adventure begins!`, 'success'); + + // Initialize RPG with the new character + playerStats = characterData; + savePlayerStats(); + document.getElementById('rpgHud').style.display = 'flex'; + updateRpgHud(); + startMonsterSpawning(); + } else { + const error = await response.json(); + updateStatus(error.error || 'Failed to create character', 'error'); + } + } catch (err) { + console.error('Character creation error:', err); + updateStatus('Failed to create character', 'error'); + } + } + + // Character creator event listeners + document.getElementById('charNameInput').addEventListener('input', (e) => { + charCreatorState.name = e.target.value.trim(); + document.getElementById('charStep1Next').disabled = charCreatorState.name.length < 2; + }); + + document.getElementById('charStep1Next').addEventListener('click', () => goToCharStep(2)); + document.getElementById('charStep2Back').addEventListener('click', () => goToCharStep(1)); + document.getElementById('charStep2Next').addEventListener('click', () => goToCharStep(3)); + document.getElementById('charStep3Back').addEventListener('click', () => goToCharStep(2)); + document.getElementById('charStep3Next').addEventListener('click', () => goToCharStep(4)); + document.getElementById('charStep4Back').addEventListener('click', () => goToCharStep(3)); + document.getElementById('charCreateBtn').addEventListener('click', createCharacter); + + // Character Sheet + function showCharacterSheet() { + if (!playerStats) return; + + const race = RACES[playerStats.race] || RACES['human']; + const cls = PLAYER_CLASSES[playerStats.class] || PLAYER_CLASSES['trail_runner']; + + // Update header + document.getElementById('charSheetIcon').textContent = race.icon; + document.getElementById('charSheetName').textContent = playerStats.name || 'Adventurer'; + document.getElementById('charSheetInfo').textContent = + `${race.name} ${cls.name} - Level ${playerStats.level}`; + + // Update stats grid + const hpPercent = (playerStats.hp / playerStats.maxHp) * 100; + const mpPercent = (playerStats.mp / playerStats.maxMp) * 100; + document.getElementById('charSheetStats').innerHTML = ` +
+ ❤️ HP +
+
+
+ ${playerStats.hp}/${playerStats.maxHp} +
+
+ 💙 MP +
+
+
+ ${playerStats.mp}/${playerStats.maxMp} +
+
+ ⚔️ ATK + ${playerStats.atk} +
+
+ 🛡️ DEF + ${playerStats.def} +
+ `; + + // Update XP section + const xpNeeded = playerStats.level * 100; + const xpPercent = Math.min((playerStats.xp / xpNeeded) * 100, 100); + document.getElementById('charSheetXp').innerHTML = ` +
+
+
+
${playerStats.xp}/${xpNeeded} XP
+
Next level: ${Math.max(0, xpNeeded - playerStats.xp)} XP needed
+ `; + + // Update skills + const classSkills = cls.skills || []; + document.getElementById('charSheetSkills').innerHTML = classSkills.map(skillId => { + const skill = SKILLS[skillId]; + if (!skill) return ''; + const locked = playerStats.level < skill.levelReq; + return ` +
+ ${locked ? '🔒' : skill.icon} +
+
${skill.name} (Lv${skill.levelReq})
+
${skill.description}
+ ${!locked ? `
${skill.mpCost} MP
` : ''} +
+
+ `; + }).join(''); + + document.getElementById('charSheetModal').style.display = 'flex'; + } + + function hideCharacterSheet() { + document.getElementById('charSheetModal').style.display = 'none'; + } + + // Character Sheet event listeners + document.getElementById('charSheetClose').addEventListener('click', hideCharacterSheet); + document.getElementById('charSheetModal').addEventListener('click', (e) => { + if (e.target.id === 'charSheetModal') { + hideCharacterSheet(); + } + }); + // Leaderboard async function loadLeaderboard(period = 'all') { try { @@ -8164,74 +9177,80 @@ // Initialize player stats for RPG system async function initializePlayerStats(username) { - // Only melancholytron gets Trail Runner class for now - if (username !== 'melancholytron') { + const token = localStorage.getItem('accessToken'); + if (!token) { playerStats = null; document.getElementById('rpgHud').style.display = 'none'; return; } - const classData = PLAYER_CLASSES['trail_runner']; + // Check if user has a character + try { + const hasCharResponse = await fetch('/api/user/has-character', { + headers: { 'Authorization': `Bearer ${token}` } + }); - // Try to load from server first - const token = localStorage.getItem('accessToken'); - if (token) { - try { - const response = await fetch('/api/user/rpg-stats', { - headers: { 'Authorization': `Bearer ${token}` } - }); - if (response.ok) { - const serverStats = await response.json(); - if (serverStats) { - playerStats = serverStats; - console.log('Loaded RPG stats from server:', playerStats); - } + if (hasCharResponse.ok) { + const { hasCharacter } = await hasCharResponse.json(); + + if (!hasCharacter) { + // No character - show character creator + console.log('No character found, showing character creator'); + showCharCreatorModal(); + return; } - } catch (e) { - console.error('Failed to load RPG stats from server:', e); } + } catch (e) { + console.error('Failed to check character status:', e); } - // Fall back to localStorage if server didn't have stats - if (!playerStats) { - const saved = localStorage.getItem('hikemap_rpg_stats'); - if (saved) { - try { - playerStats = JSON.parse(saved); - console.log('Loaded saved RPG stats from localStorage:', playerStats); - // Sync to server since we loaded from localStorage - savePlayerStats(); - } catch (e) { - console.error('Failed to parse saved RPG stats:', e); - playerStats = null; + // Try to load character from server + try { + const response = await fetch('/api/user/rpg-stats', { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (response.ok) { + const serverStats = await response.json(); + if (serverStats && serverStats.name) { + playerStats = serverStats; + console.log('Loaded RPG stats from server:', playerStats); + + // Show RPG HUD and start game + document.getElementById('rpgHud').style.display = 'flex'; + updateRpgHud(); + startMonsterSpawning(); + + console.log('RPG system initialized for', username); + return; } } + } catch (e) { + console.error('Failed to load RPG stats from server:', e); } - // Create new stats if none exist - if (!playerStats) { - playerStats = { - class: 'trail_runner', - level: 1, - xp: 0, - hp: classData.baseStats.hp, - maxHp: classData.baseStats.hp, - mp: classData.baseStats.mp, - maxMp: classData.baseStats.mp, - atk: classData.baseStats.atk, - def: classData.baseStats.def - }; - savePlayerStats(); - } - - // Show RPG HUD - document.getElementById('rpgHud').style.display = 'flex'; - updateRpgHud(); + // Fall back to localStorage if server didn't have valid stats + const saved = localStorage.getItem('hikemap_rpg_stats'); + if (saved) { + try { + playerStats = JSON.parse(saved); + if (playerStats && playerStats.name) { + console.log('Loaded saved RPG stats from localStorage:', playerStats); + savePlayerStats(); // Sync to server - // Start monster spawning - startMonsterSpawning(); + document.getElementById('rpgHud').style.display = 'flex'; + updateRpgHud(); + startMonsterSpawning(); + return; + } + } catch (e) { + console.error('Failed to parse saved RPG stats:', e); + } + } - console.log('RPG system initialized for', username); + // No valid stats found - show character creator + console.log('No valid character data, showing character creator'); + showCharCreatorModal(); } // Save player stats to server (and localStorage as backup) @@ -8272,11 +9291,116 @@ document.getElementById('hudXpText').textContent = `${playerStats.xp}/${xpNeeded}`; } + // Save monsters to server (debounced) + let monsterSaveTimeout = null; + function saveMonsters() { + if (monsterSaveTimeout) clearTimeout(monsterSaveTimeout); + monsterSaveTimeout = setTimeout(() => { + const token = localStorage.getItem('accessToken'); + if (!token) return; + + // Prepare monsters for saving (remove marker reference) + const monstersToSave = monsterEntourage.map(m => ({ + id: m.id, + type: m.type, + level: m.level, + hp: m.hp, + maxHp: m.maxHp, + atk: m.atk, + def: m.def, + position: m.position, + spawnTime: m.spawnTime, + lastDialogueTime: m.lastDialogueTime || 0 + })); + + fetch('/api/user/monsters', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(monstersToSave) + }).catch(err => console.error('Failed to save monsters:', err)); + }, 1000); // Debounce 1 second + } + + // Load monsters from server + async function loadMonsters() { + const token = localStorage.getItem('accessToken'); + if (!token) return false; + + try { + const response = await fetch('/api/user/monsters', { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (response.ok) { + const savedMonsters = await response.json(); + if (savedMonsters && savedMonsters.length > 0) { + console.log('Loading', savedMonsters.length, 'saved monsters'); + + // Clear any existing monsters first + monsterEntourage.forEach(m => { + if (m.marker) m.marker.remove(); + }); + monsterEntourage = []; + + // Recreate each monster with markers + for (const m of savedMonsters) { + // If monster has no position, place near player or map center + let position = m.position; + if (!position || !position.lat || !position.lng) { + if (userLocation) { + // Random offset 30-50 meters from player + const angle = Math.random() * 2 * Math.PI; + const distance = 30 + Math.random() * 20; + const metersPerDegLat = 111320; + const metersPerDegLng = 111320 * Math.cos(userLocation.lat * Math.PI / 180); + position = { + lat: userLocation.lat + (distance * Math.cos(angle)) / metersPerDegLat, + lng: userLocation.lng + (distance * Math.sin(angle)) / metersPerDegLng + }; + } else { + // Default to map center + const center = map.getCenter(); + position = { lat: center.lat, lng: center.lng }; + } + } + + const monster = { + id: m.id, + type: m.type, + level: m.level, + hp: m.hp, + maxHp: m.maxHp, + atk: m.atk, + def: m.def, + position: position, + spawnTime: m.spawnTime, + lastDialogueTime: m.lastDialogueTime || 0, + marker: null + }; + createMonsterMarker(monster); + monsterEntourage.push(monster); + } + updateRpgHud(); + return true; + } + } + } catch (e) { + console.error('Failed to load monsters:', e); + } + return false; + } + // Start monster spawning timer - function startMonsterSpawning() { + async function startMonsterSpawning() { if (monsterSpawnTimer) clearInterval(monsterSpawnTimer); if (monsterUpdateTimer) clearInterval(monsterUpdateTimer); + // First, try to load existing monsters + await loadMonsters(); + // Spawn check every 20 seconds monsterSpawnTimer = setInterval(() => { if (Math.random() < 0.5) { // 50% chance each interval @@ -8289,10 +9413,12 @@ updateMonsterPositions(); }, 2000); - // Initial spawn after 5 seconds - setTimeout(() => { - spawnMonsterNearPlayer(); - }, 5000); + // Only do initial spawn if no monsters were loaded + if (monsterEntourage.length === 0) { + setTimeout(() => { + spawnMonsterNearPlayer(); + }, 5000); + } } // Stop monster spawning @@ -8346,6 +9472,7 @@ createMonsterMarker(monster); monsterEntourage.push(monster); updateRpgHud(); + saveMonsters(); // Persist to server console.log('Spawned monster:', monster.id, 'at level', monsterLevel); } @@ -8364,13 +9491,13 @@ const divIcon = L.divIcon({ html: iconHtml, className: 'monster-marker-container', - iconSize: [40, 40], - iconAnchor: [20, 20] + iconSize: [70, 70], + iconAnchor: [35, 35] }); monster.marker = L.marker([monster.position.lat, monster.position.lng], { icon: divIcon, - zIndexOffset: 500 + zIndexOffset: 2000 // High z-index so monsters are on top and easy to tap }).addTo(map); // Click to initiate combat @@ -8464,6 +9591,7 @@ } monsterEntourage.splice(idx, 1); updateRpgHud(); + saveMonsters(); // Persist removal to server } } @@ -8874,6 +10002,13 @@ // Initialize auth on load loadCurrentUser(); + // Show auth modal if not logged in (guest mode available) + if (!localStorage.getItem('accessToken') && !sessionStorage.getItem('guestMode')) { + setTimeout(() => { + showAuthModal(); + }, 500); + } + diff --git a/server.js b/server.js index 114abb7..8221457 100644 --- a/server.js +++ b/server.js @@ -731,6 +731,8 @@ app.get('/api/user/rpg-stats', authenticateToken, (req, res) => { if (stats) { // Convert snake_case from DB to camelCase for client res.json({ + name: stats.character_name, + race: stats.race, class: stats.class, level: stats.level, xp: stats.xp, @@ -751,6 +753,48 @@ app.get('/api/user/rpg-stats', authenticateToken, (req, res) => { } }); +// Check if user has a character +app.get('/api/user/has-character', authenticateToken, (req, res) => { + try { + const hasCharacter = db.hasCharacter(req.user.userId); + res.json({ hasCharacter }); + } catch (err) { + console.error('Check character error:', err); + res.status(500).json({ error: 'Failed to check character' }); + } +}); + +// Create a new character +app.post('/api/user/character', authenticateToken, (req, res) => { + try { + const characterData = req.body; + + // Validate required fields + if (!characterData.name || characterData.name.length < 2 || characterData.name.length > 20) { + return res.status(400).json({ error: 'Character name must be 2-20 characters' }); + } + + if (!characterData.race) { + return res.status(400).json({ error: 'Race is required' }); + } + + if (!characterData.class) { + return res.status(400).json({ error: 'Class is required' }); + } + + // Only trail_runner is available for now + if (characterData.class !== 'trail_runner') { + return res.status(400).json({ error: 'This class is not available yet' }); + } + + db.createCharacter(req.user.userId, characterData); + res.json({ success: true }); + } catch (err) { + console.error('Create character error:', err); + res.status(500).json({ error: 'Failed to create character' }); + } +}); + // Save RPG stats for current user app.put('/api/user/rpg-stats', authenticateToken, (req, res) => { try { @@ -769,6 +813,61 @@ app.put('/api/user/rpg-stats', authenticateToken, (req, res) => { } }); +// Get monster entourage for current user +app.get('/api/user/monsters', authenticateToken, (req, res) => { + try { + const monsters = db.getMonsterEntourage(req.user.userId); + // Convert snake_case to camelCase for client + const formatted = monsters.map(m => ({ + id: m.id, + type: m.monster_type, + level: m.level, + hp: m.hp, + maxHp: m.max_hp, + atk: m.atk, + def: m.def, + position: m.position_lat && m.position_lng ? { + lat: m.position_lat, + lng: m.position_lng + } : null, + spawnTime: m.spawn_time, + lastDialogueTime: m.last_dialogue_time + })); + res.json(formatted); + } catch (err) { + console.error('Get monsters error:', err); + res.status(500).json({ error: 'Failed to get monsters' }); + } +}); + +// Save monster entourage for current user +app.put('/api/user/monsters', authenticateToken, (req, res) => { + try { + const monsters = req.body; + + if (!Array.isArray(monsters)) { + return res.status(400).json({ error: 'Invalid monsters data' }); + } + + db.saveMonsterEntourage(req.user.userId, monsters); + res.json({ success: true }); + } catch (err) { + console.error('Save monsters error:', err); + res.status(500).json({ error: 'Failed to save monsters' }); + } +}); + +// Remove a specific monster (after combat) +app.delete('/api/user/monsters/:monsterId', authenticateToken, (req, res) => { + try { + db.removeMonster(req.user.userId, req.params.monsterId); + res.json({ success: true }); + } catch (err) { + console.error('Remove monster error:', err); + res.status(500).json({ error: 'Failed to remove monster' }); + } +}); + // Function to send push notification to all subscribers async function sendPushNotification(title, body, data = {}) { const notification = { diff --git a/to_do.md b/to_do.md new file mode 100644 index 0000000..b62d0bf --- /dev/null +++ b/to_do.md @@ -0,0 +1,66 @@ +# HikeMap RPG System - Todo List + +## Phase 1: Login on Load - COMPLETED +- [x] Remove icon chooser from initial page load +- [x] Show auth modal if no `accessToken` in localStorage +- [x] Add "Continue as Guest" button to auth modal + +## Phase 2: Character Creator - COMPLETED +- [x] Add character creator modal HTML/CSS +- [x] Implement race selection with stat preview + - Human, Elf, Dwarf, Halfling +- [x] Implement class selection (Trail Runner available, others grayed "Coming Soon") + - Trail Runner (available) + - Gym Bro (coming soon) + - Yoga Master (coming soon) + - CrossFit Crusader (coming soon) +- [x] Add character name input +- [x] Update database schema (character_name, race columns in rpg_stats) +- [x] Create `/api/user/character` endpoint +- [x] Wire up creation flow after login + +## Phase 3: Character Sheet - COMPLETED +- [x] Create character sheet modal UI +- [x] Display all stats (HP, MP, ATK, DEF, etc.) +- [x] Show XP progress bar with level milestones +- [x] Show unlocked skills with descriptions (locked skills shown as grayed out) +- [ ] Display equipped items (pending equipment system - Phase 5) +- [ ] Add combat statistics (future enhancement) + +## Phase 4: Skill Selection System +- [ ] Create skill pools per class +- [ ] Add level-up skill choice modal (2-3 options per level) +- [ ] Update database to store unlocked_skills (JSON array) +- [ ] Add pending_skill_level field for pending choices +- [ ] Wire into level-up flow + +## Phase 5: Equipment System +- [ ] Create items table in database +- [ ] Create player_inventory table +- [ ] Define equipment slots: Weapon, Armor, Accessory +- [ ] Add class-specific accessories +- [ ] Implement monster loot tables +- [ ] Add equipment UI to character sheet +- [ ] Calculate effective stats with equipment bonuses + +## Phase 6: Admin Editor +- [ ] Create admin.html (separate page) +- [ ] Add admin authentication middleware +- [ ] User management (list, edit stats, grant admin) +- [ ] Monster management (CRUD for monster_types) +- [ ] Spawn control (manual monster spawning) +- [ ] Game balance settings + +--- + +## Completed +- [x] RPG combat system with turn-based battles +- [x] Trail Runner class with skills +- [x] Discarded GU monster with variants +- [x] Multi-monster combat encounters +- [x] XP bar in HUD +- [x] Stat persistence fix (authToken → accessToken) +- [x] Phase 1: Login on Load +- [x] Phase 2: Character Creator +- [x] Monster persistence (monsters saved to database, persist across login/logout) +- [x] Phase 3: Character Sheet (click class name in HUD to view)