Browse Source

Add character sheet, fix mobile double-tap combat

- Phase 3: Character sheet modal (click class name in HUD)
  - Display all stats (HP, MP, ATK, DEF)
  - Show XP progress bar with level milestones
  - Show unlocked/locked skills based on level

- Fix mobile double-tap inconsistency (50/50 bug)
  - Add monster check to native touchend handler
  - Block press-and-hold navigation when monsters present
  - Both touch handlers now consistently prioritize combat

- Increase monster touch targets to 70x70 with visible tap area
- Add pointer-events:none to trails in nav mode
- Add interactive:false to accuracy circles

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
master
HikeMap User 1 month ago
parent
commit
fb2450ac84
  1. 127
      database.js
  2. 1263
      index.html
  3. 99
      server.js
  4. 66
      to_do.md

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

1263
index.html
File diff suppressed because it is too large
View File

99
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 = {

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