From d915f0ce6857b127a541cd5b976f30194ecf6f05 Mon Sep 17 00:00:00 2001 From: HikeMap User Date: Fri, 2 Jan 2026 23:35:43 -0600 Subject: [PATCH] Phase 4: Skill Selection System + Monster types in database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skill Selection System: - Added 3 new alternative skills (quick_step, second_wind, finish_line_sprint) - Created SKILL_POOLS for level 2, 3, 5 skill choices - Added skill choice modal on level up - Players now choose 1 of 2 skills at milestone levels - Combat UI shows only unlocked skills - Character sheet displays learned skills Monster Database Migration: - Renamed "Discarded GU" to "Moop" (Matter Out Of Place) - Created monster_types table for storing monster definitions - Added CRUD methods for monster types - Added /api/monster-types endpoint - Frontend now loads monster types from API - Auto-seeds Moop monster on first run - Ready for admin UI and multiple monster types 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- database.js | 164 ++++++++++++++++++++++- index.html | 367 +++++++++++++++++++++++++++++++++++++--------------- server.js | 43 +++++- to_do.md | 19 ++- 4 files changed, 473 insertions(+), 120 deletions(-) diff --git a/database.js b/database.js index d429a44..20f62b2 100644 --- a/database.js +++ b/database.js @@ -88,6 +88,9 @@ class HikeMapDB { try { this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN race TEXT DEFAULT 'human'`); } catch (e) { /* Column already exists */ } + try { + this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN unlocked_skills TEXT DEFAULT '["basic_attack"]'`); + } catch (e) { /* Column already exists */ } // Monster entourage table - stores monsters following the player this.db.exec(` @@ -108,6 +111,25 @@ class HikeMapDB { ) `); + // Monster types table - defines available monster types + this.db.exec(` + CREATE TABLE IF NOT EXISTS monster_types ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + icon TEXT NOT NULL, + base_hp INTEGER NOT NULL, + base_atk INTEGER NOT NULL, + base_def INTEGER NOT NULL, + xp_reward INTEGER NOT NULL, + level_scale_hp INTEGER NOT NULL, + level_scale_atk INTEGER NOT NULL, + level_scale_def INTEGER NOT NULL, + dialogues TEXT NOT NULL, + enabled BOOLEAN DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + // Create indexes for performance this.db.exec(` CREATE INDEX IF NOT EXISTS idx_geocache_finds_user ON geocache_finds(user_id); @@ -354,7 +376,7 @@ class HikeMapDB { // RPG Stats methods getRpgStats(userId) { const stmt = this.db.prepare(` - SELECT character_name, race, 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, unlocked_skills FROM rpg_stats WHERE user_id = ? `); return stmt.get(userId); @@ -369,8 +391,8 @@ class HikeMapDB { 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')) + INSERT INTO rpg_stats (user_id, character_name, race, class, level, xp, hp, max_hp, mp, max_mp, atk, def, unlocked_skills, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(user_id) DO UPDATE SET character_name = excluded.character_name, race = excluded.race, @@ -383,8 +405,11 @@ class HikeMapDB { max_mp = excluded.max_mp, atk = excluded.atk, def = excluded.def, + unlocked_skills = excluded.unlocked_skills, updated_at = datetime('now') `); + // New characters start with only basic_attack + const unlockedSkillsJson = JSON.stringify(characterData.unlockedSkills || ['basic_attack']); return stmt.run( userId, characterData.name, @@ -397,14 +422,15 @@ class HikeMapDB { characterData.mp || 50, characterData.maxMp || 50, characterData.atk || 12, - characterData.def || 8 + characterData.def || 8, + unlockedSkillsJson ); } saveRpgStats(userId, stats) { 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')) + INSERT INTO rpg_stats (user_id, character_name, race, class, level, xp, hp, max_hp, mp, max_mp, atk, def, unlocked_skills, 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), @@ -417,8 +443,11 @@ class HikeMapDB { max_mp = excluded.max_mp, atk = excluded.atk, def = excluded.def, + unlocked_skills = COALESCE(excluded.unlocked_skills, rpg_stats.unlocked_skills), updated_at = datetime('now') `); + // Convert unlockedSkills array to JSON string for storage + const unlockedSkillsJson = stats.unlockedSkills ? JSON.stringify(stats.unlockedSkills) : null; return stmt.run( userId, stats.name || null, @@ -431,7 +460,8 @@ class HikeMapDB { stats.mp || 50, stats.maxMp || 50, stats.atk || 12, - stats.def || 8 + stats.def || 8, + unlockedSkillsJson ); } @@ -481,6 +511,126 @@ class HikeMapDB { return stmt.run(userId, monsterId); } + // Monster type methods + getAllMonsterTypes(enabledOnly = true) { + const stmt = enabledOnly + ? this.db.prepare(`SELECT * FROM monster_types WHERE enabled = 1`) + : this.db.prepare(`SELECT * FROM monster_types`); + return stmt.all(); + } + + getMonsterType(id) { + const stmt = this.db.prepare(`SELECT * FROM monster_types WHERE id = ?`); + return stmt.get(id); + } + + createMonsterType(monsterData) { + const stmt = this.db.prepare(` + INSERT INTO monster_types (id, name, icon, base_hp, base_atk, base_def, xp_reward, level_scale_hp, level_scale_atk, level_scale_def, dialogues, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + return stmt.run( + monsterData.id, + monsterData.name, + monsterData.icon, + monsterData.baseHp, + monsterData.baseAtk, + monsterData.baseDef, + monsterData.xpReward, + monsterData.levelScale.hp, + monsterData.levelScale.atk, + monsterData.levelScale.def, + JSON.stringify(monsterData.dialogues), + monsterData.enabled !== false ? 1 : 0 + ); + } + + updateMonsterType(id, monsterData) { + const stmt = this.db.prepare(` + UPDATE monster_types SET + name = ?, icon = ?, base_hp = ?, base_atk = ?, base_def = ?, + xp_reward = ?, level_scale_hp = ?, level_scale_atk = ?, level_scale_def = ?, + dialogues = ?, enabled = ? + WHERE id = ? + `); + return stmt.run( + monsterData.name, + monsterData.icon, + monsterData.baseHp, + monsterData.baseAtk, + monsterData.baseDef, + monsterData.xpReward, + monsterData.levelScale.hp, + monsterData.levelScale.atk, + monsterData.levelScale.def, + JSON.stringify(monsterData.dialogues), + monsterData.enabled !== false ? 1 : 0, + id + ); + } + + deleteMonsterType(id) { + const stmt = this.db.prepare(`DELETE FROM monster_types WHERE id = ?`); + return stmt.run(id); + } + + seedDefaultMonsters() { + // Check if Moop already exists + const existing = this.getMonsterType('moop'); + if (existing) return; + + // Seed Moop - Matter Out Of Place + this.createMonsterType({ + id: 'moop', + name: 'Moop', + icon: '🟢', + baseHp: 30, + baseAtk: 5, + baseDef: 2, + xpReward: 15, + levelScale: { hp: 10, atk: 2, def: 1 }, + dialogues: { + annoyed: [ + "Hey! HEY! I don't belong here!", + "Excuse me, I'm MATTER OUT OF PLACE!", + "This is a Leave No Trace trail!", + "I was in your pocket! YOUR POCKET!", + "Pack it in, pack it out! Remember?!" + ], + frustrated: [ + "STOP IGNORING ME!", + "I don't belong here and YOU know it!", + "I'm literally the definition of litter!", + "MOOP! MATTER! OUT! OF! PLACE!", + "Fine! Just keep walking! SEE IF I CARE!" + ], + desperate: [ + "Please... just pick me up...", + "I promise I'll fit in your pocket!", + "What if I promised to be biodegradable?", + "I just want to go to a proper bin...", + "I didn't ask to be abandoned here!" + ], + philosophical: [ + "What even IS place, when you think about it?", + "If matter is out of place, is place out of matter?", + "Perhaps being misplaced is the true journey.", + "Am I out of place, or is place out of me?", + "We're not so different, you and I..." + ], + existential: [ + "I have accepted my displacement.", + "All matter is eventually out of place.", + "I've made peace with being moop.", + "The trail will reclaim me eventually.", + "It's actually kind of nice out here. Good views." + ] + }, + enabled: true + }); + console.log('Seeded default monster: Moop'); + } + close() { if (this.db) { this.db.close(); diff --git a/index.html b/index.html index b138796..dd24c94 100644 --- a/index.html +++ b/index.html @@ -1598,6 +1598,75 @@ 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: flex; @@ -2632,6 +2701,19 @@ + + +
@@ -2937,9 +3019,13 @@ rotate: true, rotateControl: false, touchRotate: false, - bearing: 0 + bearing: 0, + doubleClickZoom: false // Disable to prevent interference with our double-tap handlers }).setView([30.49, -97.84], 13); + // Explicitly disable doubleClickZoom (belt and suspenders - init option + explicit call) + map.doubleClickZoom.disable(); + // Base layers const streetMap = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', @@ -3194,62 +3280,78 @@ type: 'damage', calculate: (atk) => Math.floor(atk * 3), 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) const DIALOGUE_PHASES = [ @@ -5274,14 +5376,6 @@ userMarker = { 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, icon: icon }; @@ -5291,8 +5385,6 @@ } else { // Update existing marker position userMarker.marker.setLatLng([lat, lng]); - userMarker.accuracyCircle.setLatLng([lat, lng]); - userMarker.accuracyCircle.setRadius(accuracy); // Update icon if it changed if (icon && color && (userMarker.icon !== icon || userMarker.color !== color)) { @@ -5303,7 +5395,6 @@ className: 'custom-div-icon' }); userMarker.marker.setIcon(newIcon); - userMarker.accuracyCircle.setStyle({ color: color, fillColor: color }); userMarker.icon = icon; userMarker.color = color; } @@ -5314,7 +5405,6 @@ const userMarker = otherUsers.get(userId); if (userMarker) { map.removeLayer(userMarker.marker); - map.removeLayer(userMarker.accuracyCircle); otherUsers.delete(userId); } } @@ -6863,7 +6953,8 @@ // Start timer for 500ms hold 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'; // Show confirmation dialog const message = `Navigate to ${nearest.track.name}?`; @@ -6871,6 +6962,9 @@ ensurePopupInBody('navConfirmDialog'); document.getElementById('navConfirmDialog').style.display = 'flex'; isPressing = false; + } else if (isPressing) { + // Monsters appeared during press - cancel silently + cancelPressHold(); } }, 500); @@ -6902,6 +6996,10 @@ // Fix for Chrome and PWA - use native addEventListener with passive: false mapContainer.addEventListener('touchstart', function(e) { 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(); const touch = e.touches[0]; const rect = mapContainer.getBoundingClientRect(); @@ -6912,13 +7010,8 @@ const containerPoint = L.point(x, y); 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 }); @@ -7052,6 +7145,10 @@ }); 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) { L.DomEvent.stopPropagation(e); L.DomEvent.preventDefault(e); @@ -8912,7 +9009,8 @@ mp: finalStats.mp, maxMp: finalStats.mp, atk: finalStats.atk, - def: finalStats.def + def: finalStats.def, + unlockedSkills: ['basic_attack'] // Start with basic attack only }; try { @@ -9012,19 +9110,18 @@
Next level: ${Math.max(0, xpNeeded - playerStats.xp)} XP needed
`; - // Update skills - const classSkills = cls.skills || []; - document.getElementById('charSheetSkills').innerHTML = classSkills.map(skillId => { + // Update skills (show only unlocked skills) + const unlockedSkills = playerStats.unlockedSkills || ['basic_attack']; + document.getElementById('charSheetSkills').innerHTML = unlockedSkills.map(skillId => { const skill = SKILLS[skillId]; if (!skill) return ''; - const locked = playerStats.level < skill.levelReq; return ` -
- ${locked ? '🔒' : skill.icon} +
+ ${skill.icon}
-
${skill.name} (Lv${skill.levelReq})
+
${skill.name}
${skill.description}
- ${!locked ? `
${skill.mpCost} MP
` : ''} +
${skill.mpCost} MP
`; @@ -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 ` +
+ ${skill.icon} +
+
${skill.name}
+
${skill.description}
+
${skill.mpCost} MP
+
+
+ `; + }).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 async function loadLeaderboard(period = 'all') { try { @@ -9437,6 +9588,12 @@ function spawnMonsterNearPlayer() { if (!userLocation || !playerStats) 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 const angle = Math.random() * 2 * Math.PI; @@ -9450,11 +9607,10 @@ const offsetLng = (distance * Math.sin(angle)) / metersPerDegLng; const monsterLevel = Math.max(1, playerStats.level + Math.floor(Math.random() * 3) - 1); - const monsterType = MONSTER_TYPES['discarded_gu']; const monster = { id: `monster_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - type: 'discarded_gu', + type: typeId, level: monsterLevel, position: { lat: userLocation.lat + offsetLat, @@ -9652,33 +9808,24 @@ const monsterCount = combatState.monsters.length; log.innerHTML = `
Combat begins! ${monsterCount} ${monsterCount === 1 ? 'enemy' : 'enemies'} engaged!
`; - // Populate skills + // Populate skills (only show unlocked skills) const skillsContainer = document.getElementById('combatSkills'); 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 levelReq = skill.levelReq || 1; - const isLocked = playerStats.level < levelReq; + if (!skill) return; // Skip if skill doesn't exist const btn = document.createElement('button'); - btn.className = 'skill-btn' + (isLocked ? ' skill-locked' : ''); + btn.className = 'skill-btn'; btn.dataset.skillId = skillId; - - if (isLocked) { - btn.innerHTML = ` - 🔒 ${skill.name} - Lv.${levelReq} - `; - btn.disabled = true; - } else { - btn.innerHTML = ` - ${skill.icon} ${skill.name} - ${skill.mpCost > 0 ? skill.mpCost + ' MP' : 'Free'} - `; - btn.onclick = () => executePlayerSkill(skillId); - } + btn.innerHTML = ` + ${skill.icon} ${skill.name} + ${skill.mpCost > 0 ? skill.mpCost + ' MP' : 'Free'} + `; + btn.onclick = () => executePlayerSkill(skillId); skillsContainer.appendChild(btn); }); @@ -9977,7 +10124,8 @@ function checkLevelUp() { const xpNeeded = playerStats.level * 100; if (playerStats.xp >= xpNeeded) { - playerStats.level++; + const newLevel = playerStats.level + 1; + playerStats.level = newLevel; playerStats.xp -= xpNeeded; const classData = PLAYER_CLASSES[playerStats.class]; @@ -9988,7 +10136,14 @@ playerStats.atk += classData.atkPerLevel; 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) checkLevelUp(); @@ -9999,8 +10154,10 @@ // END RPG COMBAT SYSTEM FUNCTIONS // ========================================== - // Initialize auth on load - loadCurrentUser(); + // Load monster types from database, then initialize auth + loadMonsterTypes().then(() => { + loadCurrentUser(); + }); // Show auth modal if not logged in (guest mode available) if (!localStorage.getItem('accessToken') && !sessionStorage.getItem('guestMode')) { diff --git a/server.js b/server.js index 8221457..4647e9b 100644 --- a/server.js +++ b/server.js @@ -729,6 +729,16 @@ app.get('/api/user/rpg-stats', authenticateToken, (req, res) => { try { const stats = db.getRpgStats(req.user.userId); if (stats) { + // Parse unlocked_skills from JSON string + let unlockedSkills = ['basic_attack']; + if (stats.unlocked_skills) { + try { + unlockedSkills = JSON.parse(stats.unlocked_skills); + } catch (e) { + console.error('Failed to parse unlocked_skills:', e); + } + } + // Convert snake_case from DB to camelCase for client res.json({ name: stats.character_name, @@ -741,7 +751,8 @@ app.get('/api/user/rpg-stats', authenticateToken, (req, res) => { mp: stats.mp, maxMp: stats.max_mp, atk: stats.atk, - def: stats.def + def: stats.def, + unlockedSkills: unlockedSkills }); } else { // No stats yet - return null so client creates defaults @@ -813,6 +824,33 @@ app.put('/api/user/rpg-stats', authenticateToken, (req, res) => { } }); +// Get all monster types (public endpoint - needed for game rendering) +app.get('/api/monster-types', (req, res) => { + try { + const types = db.getAllMonsterTypes(true); // Only enabled monsters + // Convert snake_case to camelCase and parse JSON dialogues + const formatted = types.map(t => ({ + id: t.id, + name: t.name, + icon: t.icon, + baseHp: t.base_hp, + baseAtk: t.base_atk, + baseDef: t.base_def, + xpReward: t.xp_reward, + levelScale: { + hp: t.level_scale_hp, + atk: t.level_scale_atk, + def: t.level_scale_def + }, + dialogues: JSON.parse(t.dialogues) + })); + res.json(formatted); + } catch (err) { + console.error('Get monster types error:', err); + res.status(500).json({ error: 'Failed to get monster types' }); + } +}); + // Get monster entourage for current user app.get('/api/user/monsters', authenticateToken, (req, res) => { try { @@ -1122,6 +1160,9 @@ server.listen(PORT, async () => { db = new HikeMapDB(dbPath).init(); console.log('Database initialized'); + // Seed default monsters if they don't exist + db.seedDefaultMonsters(); + // Clean expired tokens periodically setInterval(() => { try { diff --git a/to_do.md b/to_do.md index b62d0bf..d7bc428 100644 --- a/to_do.md +++ b/to_do.md @@ -27,12 +27,14 @@ - [ ] 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 4: Skill Selection System - COMPLETED +- [x] Create skill pools per class (SKILL_POOLS object with 2 options at levels 2, 3, 5) +- [x] Add 3 new alternative skills (quick_step, second_wind, finish_line_sprint) +- [x] Add level-up skill choice modal (2 options per milestone level) +- [x] Update database to store unlocked_skills (JSON array column) +- [x] Wire into level-up flow (checkLevelUp shows modal at milestone levels) +- [x] Update combat UI to show only unlocked skills +- [x] Update character sheet to show only unlocked skills ## Phase 5: Equipment System - [ ] Create items table in database @@ -47,7 +49,8 @@ - [ ] Create admin.html (separate page) - [ ] Add admin authentication middleware - [ ] User management (list, edit stats, grant admin) -- [ ] Monster management (CRUD for monster_types) +- [x] Monster types stored in database (monster_types table created) +- [ ] Monster management UI (CRUD for monster_types) - [ ] Spawn control (manual monster spawning) - [ ] Game balance settings @@ -64,3 +67,5 @@ - [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) +- [x] Phase 4: Skill Selection System (choose 1 of 2 skills at levels 2, 3, 5) +- [x] Monster types moved to database (future-proofing for admin editor)