Browse Source

Phase 4: Skill Selection System + Monster types in database

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 <noreply@anthropic.com>
master
HikeMap User 1 month ago
parent
commit
d915f0ce68
  1. 164
      database.js
  2. 355
      index.html
  3. 43
      server.js
  4. 19
      to_do.md

164
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();

355
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 @@
</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 -->
<div id="geocacheListSidebar" class="geocache-list-sidebar">
<div class="geocache-list-header">
@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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 @@
<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 = 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 `
<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-name">${skill.name} (Lv${skill.levelReq})</div>
<div class="skill-name">${skill.name}</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>
`;
@ -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
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 = `<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');
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 = `
<span class="skill-name">🔒 ${skill.name}</span>
<span class="skill-cost locked">Lv.${levelReq}</span>
`;
btn.disabled = true;
} else {
btn.innerHTML = `
<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>
`;
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
// 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')) {

43
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 {

19
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)
Loading…
Cancel
Save