diff --git a/CLAUDE.md b/CLAUDE.md index 5cae854..926ba28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,55 +4,270 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -KML Track Editor is a single-page web application for viewing, editing, and navigating KML/GPS tracks on an interactive map. The entire application is contained in `index.html`. +HikeMap is a location-based RPG web application with trail/GPS track editing capabilities. Players walk in the real world to explore, battle monsters, and level up their characters. The frontend is a single-page application in `index.html` with a Node.js/Express backend in `server.js`. ## Development -To run locally, serve via HTTP (required for GPS geolocation API): +### Docker (Recommended) ```bash -python -m http.server 8000 +docker-compose up -d ``` -Then open http://localhost:8000 +Access at http://localhost:880 + +### Local Development +```bash +npm install +node server.js +``` +Then open http://localhost:8080 ## Architecture -### Single-File Application -Everything is in `index.html`: -- **Lines 1-325**: CSS styles -- **Lines 326-420**: HTML structure (map, controls sidebar, overlays) -- **Lines 421-end**: JavaScript application code - -### Key Libraries (loaded from CDN) -- **Leaflet.js 1.9.4**: Map rendering and interaction -- **leaflet-rotate 0.2.8**: Map rotation for navigation mode - -### Core State Variables (around line 457-514) -- `tracks[]`: All loaded track objects with `{id, name, coords, layer, color}` -- `selectedTracks[]`: Currently selected tracks for multi-selection operations -- `currentTool`: Active editing tool ('select', 'draw', 'reshape', 'smooth', 'delete') -- `navMode`: Whether in navigation mode vs edit mode -- `currentRoute`: Pathfinding result for navigation - -### Major Feature Areas - -**Track Editing**: -- Draw new tracks, reshape existing ones with rope physics, smooth brush tool -- Track snapping at endpoints (auto-splits target track at snap point) -- Multi-track selection and merge operations -- Undo system with 20-step history - -**Navigation Mode**: -- Graph-based pathfinding using Dijkstra's algorithm across connected trails -- Trail intersections detected within 5 meters (`INTERSECTION_THRESHOLD`) -- Real-time route recalculation when user deviates >50m from path -- Map rotation to face direction of travel (uses `map.setBearing()`) -- Auto-center on GPS position (toggleable) -- Destination persistence in localStorage - -**GPS Integration**: -- Uses `navigator.geolocation.watchPosition` with backup polling every 3 seconds -- Requires HTTPS or localhost to function - -### File Loading -- Automatically loads `default.kml` on startup if present -- KML parsing via DOMParser extracting `` coordinates +### File Structure +| File | Purpose | +|------|---------| +| `index.html` | Frontend SPA (CSS, HTML, JavaScript) | +| `server.js` | Express API server, WebSocket handling | +| `database.js` | SQLite database layer (better-sqlite3) | +| `docker-compose.yml` | Container configuration | +| `hikemap.db` | SQLite database (auto-created) | +| `SKILLS.md` | Complete skills documentation | + +### Key Libraries +- **Frontend**: Leaflet.js 1.9.4, leaflet-rotate 0.2.8 +- **Backend**: Express, better-sqlite3, jsonwebtoken, ws (WebSocket) + +--- + +## RPG System + +### Core Concepts + +**Character Creation**: +- Player chooses Race (Human, Elf, Dwarf, Halfling) and Class (Trail Runner) +- Race provides stat bonuses, Class determines skills and growth + +**Stats**: +| Stat | Description | +|------|-------------| +| HP | Health points - reach 0 and you die | +| MP | Mana points - used for skills | +| ATK | Attack power | +| DEF | Defense (reduces damage taken) | +| Accuracy | Hit chance modifier | +| Dodge | Evasion chance | + +**Home Base**: +- Set by player, 20-meter radius (`HOME_BASE_RADIUS`) +- 3x HP regeneration when at home +- Required to respawn after death +- Skill loadout can only be changed at home + +### Combat System + +**Monster Spawning**: +- Monsters spawn while walking (configurable distance) +- No spawns within home base radius +- Multiple monsters can accumulate (entourage) + +**Combat Flow**: +1. Player taps monster to engage +2. Turn-based combat with skill selection +3. Hit/miss calculated per skill accuracy +4. XP awarded on victory +5. Death sends player to respawn state + +**Damage Formula**: +```javascript +damage = (skillPower * playerATK / 100) - enemyDEF +hitChance = skillAccuracy + (attackerAccuracy - 90) - defenderDodge +``` + +### Skill System + +**Skill Tiers**: Skills unlock at level milestones (2, 3, 4, 5, 6, 7) + +**Skill Types**: +- `damage`: Deal damage to enemies +- `heal`: Restore HP +- `buff`: Temporary stat boosts (defense, accuracy) +- `utility`: Out-of-combat effects (MP regen boost) +- `status`: Apply effects like poison + +**Loadout System**: +- On level-up: ALL offered skills unlock, player picks one to activate +- `unlockedSkills`: Everything learned +- `activeSkills`: Currently equipped (one per tier) +- Swap active skills at home base only + +See `SKILLS.md` for complete skill reference. + +### Classes + +**Trail Runner** (currently the only class): +| Base Stat | Value | Per Level | +|-----------|-------|-----------| +| HP | 100 | +10 | +| MP | 50 | +5 | +| ATK | 12 | +2 | +| DEF | 8 | +1 | +| Accuracy | 90 | - | +| Dodge | 15 | - | + +### Races + +| Race | HP | MP | ATK | DEF | +|------|-----|-----|-----|-----| +| Human | +5 | +5 | +0 | +0 | +| Elf | -5 | +15 | +0 | -2 | +| Dwarf | +15 | -5 | +0 | +3 | +| Halfling | -5 | +0 | +2 | +5 | + +### Monsters + +**Current Monster Types**: +| Monster | Level Range | Notes | +|---------|-------------|-------| +| Moop | 1-5 | Basic enemy | +| Fancy Moop | 1-5 | Higher ATK | +| Fanciest Moop | 3-5 | Stronger variant | +| Sub Par Moop | 1-1 | Very weak, tutorial enemy | + +*MOOP = Matter Out Of Place (litter-themed enemies)* + +--- + +## Database Schema (database.js) + +### Key Tables + +**Users & Auth**: +- `users`: id, username, password_hash, is_admin, created_at + +**RPG Stats**: +- `rpg_stats`: player_id, name, race, class, level, xp, hp, maxHp, mp, maxMp, atk, def, home_base_lat/lng, unlocked_skills, active_skills, is_dead, home_base_icon + +**Skills**: +- `skills`: id, name, description, type, mp_cost, base_power, accuracy, hit_count, target, status_effect +- `class_skills`: Links skills to classes with unlock_level and choice_group +- `class_skill_names`: Class-specific skill renames + +**Monsters**: +- `monster_types`: id, name, icon, base_hp/atk/def, xp_reward, level scaling, dialogues + +**Buffs**: +- `player_buffs`: Active buffs with expiry times (e.g., Second Wind) + +### Important Methods + +```javascript +// database.js key methods +db.getRpgStats(userId) // Get player stats +db.saveRpgStats(userId, stats) // Save player stats +db.resetUserProgress(userId) // Admin: reset player +db.swapActiveSkill(userId, tier, newSkillId) // Swap loadout +``` + +--- + +## API Endpoints (server.js) + +### Authentication +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/auth/register` | Create account | +| POST | `/api/auth/login` | Login, returns JWT | + +### RPG +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/user/has-character` | Check if character exists | +| POST | `/api/user/character` | Create character | +| GET | `/api/user/rpg-stats` | Get player stats | +| PUT | `/api/user/rpg-stats` | Save player stats | +| POST | `/api/user/swap-skill` | Swap active skill (at home) | +| POST | `/api/user/activate-buff` | Activate utility skill | +| GET | `/api/user/buffs` | Get active buffs | + +### Game Data +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/skills` | All skills | +| GET | `/api/classes` | All classes with skills | +| GET | `/api/monster-types` | All monster types | +| GET | `/api/spawn-settings` | Monster spawn config | + +### Admin +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/admin/reset-progress/:userId` | Reset player | +| POST | `/api/admin/reset-homebase/:userId` | Clear home base | +| PUT | `/api/admin/spawn-settings` | Update spawn config | + +--- + +## Frontend Structure (index.html) + +### Line Ranges (approximate) +| Lines | Content | +|-------|---------| +| 1-3000 | CSS styles | +| 3000-3700 | HTML structure | +| 3700-4500 | Constants, state, skill definitions | +| 4500-10000 | Map, track editing, geocaches | +| 10000-11000 | Character creation, sheet | +| 11000-12000 | Stats management, saving | +| 12000-13500 | Combat system, monsters | + +### Key State Variables +```javascript +playerStats // Player RPG data +combatState // Active combat info +monsterEntourage // Spawned monsters +statsLoadedFromServer // Prevents stale saves +pendingSkillChoice // Level-up skill selection +``` + +### Key Functions +```javascript +initializePlayerStats(username) // Load from server +savePlayerStats() // Save to server +showSkillChoiceModal(level) // Level-up skill pick +swapSkillFromHomebase(tier, id) // Change loadout +startCombat(monsters) // Begin combat +useSkill(skillId) // Execute skill in combat +``` + +--- + +## Data Persistence + +**Server-Authoritative Model**: +1. On page load, fetch stats from server +2. `statsLoadedFromServer` flag prevents premature saves +3. Auto-save every 30 seconds after initial load +4. `beforeunload` uses `sendBeacon` for reliable final save +5. localStorage used as read-only backup + +**Critical**: Never save to server until `statsLoadedFromServer = true` + +--- + +## Common Tasks + +### Adding a New Skill +1. Insert into `skills` table (database.js or admin panel) +2. Add to `class_skills` with unlock_level and choice_group +3. Optionally add class rename in `class_skill_names` +4. Add icon/calculate function in `SKILLS` constant if special behavior needed + +### Adding a New Class +1. Insert into `classes` table +2. Create `class_skills` entries for skill progression +3. Add `class_skill_names` for thematic renames +4. Update `getClassIcon()` for display + +### Adding a New Monster +1. Insert into `monster_types` via admin panel or database +2. Set level range, stats, dialogues +3. Optionally add to `monster_skills` for special abilities diff --git a/README.md b/README.md index c3e1213..a3242da 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,137 @@ -# HikeMap - KML Track Editor +# HikeMap - Location-Based RPG -A single-page web application for viewing, editing, and navigating KML/GPS tracks on an interactive map. +A location-based RPG web application where you walk in the real world to explore trails, battle monsters, and level up your character. Also includes GPS track editing tools for trail mapping. ## Features +### RPG System + +**Character Creation** +- Choose from 4 races: Human, Elf, Dwarf, Halfling +- Select your class (Trail Runner with running-themed skills) +- Set your home base for respawning and skill management + +**Combat** +- Turn-based battles against MOOP (Matter Out Of Place) monsters +- Skill-based combat with damage, healing, buffs, and multi-hit attacks +- Hit/miss mechanics based on accuracy and dodge stats +- XP rewards and leveling system + +**Skills & Progression** +- Unlock new skills at levels 2, 3, 4, 5, 6, and 7 +- Choose between two skills at each milestone (both unlock, one activates) +- Swap your active skill loadout at your home base +- Class-specific skill names (e.g., "Gel Pack" instead of "Heal") + +**Home Base** +- Set anywhere on the map as your respawn point +- 3x HP regeneration when within 20 meters +- Required destination when defeated +- Skill loadout management location + ### Track Editing -- Draw new tracks directly on the map + +- Draw new GPS tracks directly on the map - Reshape existing tracks with rope physics simulation - Smooth tracks with a brush tool - Smart track snapping at endpoints with auto-splitting - Multi-track selection and merge operations - 20-step undo/redo history +- Import/export KML files ### Navigation Mode + - Graph-based pathfinding using Dijkstra's algorithm -- Automatic trail intersection detection (within 5 meters) -- Real-time route recalculation when deviating >50m from path +- Automatic trail intersection detection +- Real-time route recalculation when deviating from path - Map rotation to face direction of travel -- Auto-center on GPS position (toggleable) -- Persistent destination storage - -### GPS Integration -- Real-time location tracking using device GPS -- Automatic position updates every 3 seconds -- Requires HTTPS or localhost for geolocation API +- Auto-center on GPS position ## Quick Start -### Local Development +### Docker (Recommended) ```bash -# Serve via HTTP (required for GPS geolocation API) -python -m http.server 8000 +docker-compose up -d ``` -Then open http://localhost:8000 +Access at http://localhost:880 -### Docker Deployment +### Local Development ```bash -# Build and run with Docker Compose -docker-compose up +npm install +node server.js ``` Access at http://localhost:8080 +## Game Mechanics + +### Stats + +| Stat | Description | +|------|-------------| +| HP | Health - reach 0 and you're defeated | +| MP | Mana - used for skills, regenerates while walking | +| ATK | Attack power | +| DEF | Reduces damage taken | +| Accuracy | Affects hit chance | +| Dodge | Chance to evade attacks | + +### Trail Runner Skills + +| Level | Skill Choice A | Skill Choice B | +|-------|---------------|----------------| +| 1 | Kick-ems! (basic) | - | +| 2 | Brand New Hokas (2x hit) | Gel Pack (heal) | +| 3 | Downhill Sprint (power) | Pace Yourself (defense) | +| 4 | Trail Blaze (AoE) | Quick Feet (accurate) | +| 5 | Second Wind (MP buff) | Trail Mix (quick heal) | +| 6 | Finish Line Sprint (3x hit) | Zone In (accuracy buff) | +| 7 | Ultra Marathon (full heal) | The Bonk (devastating) | + +See [SKILLS.md](SKILLS.md) for complete skill details. + +### Monsters + +| Monster | Level Range | Description | +|---------|-------------|-------------| +| Sub Par Moop | 1 | Tutorial enemy | +| Moop | 1-5 | Standard enemy | +| Fancy Moop | 1-5 | Higher attack | +| Fanciest Moop | 3-5 | Stronger variant | + +*MOOP = Matter Out Of Place (litter-themed enemies encouraging trail cleanup)* + ## Technical Details ### Architecture -- **Single-file application**: Everything contained in `index.html` -- **No build process required**: Pure HTML/CSS/JavaScript -- **CDN dependencies**: Leaflet.js 1.9.4, leaflet-rotate 0.2.8 +- **Frontend**: Single-page app in `index.html` +- **Backend**: Node.js/Express server in `server.js` +- **Database**: SQLite via better-sqlite3 in `database.js` +- **Real-time**: WebSocket for live updates -### File Structure -The entire application is in `index.html`: -- Lines 1-325: CSS styles -- Lines 326-420: HTML structure -- Lines 421-end: JavaScript application code +### Dependencies +- Leaflet.js 1.9.4 (maps) +- leaflet-rotate 0.2.8 (navigation rotation) +- Express (API server) +- better-sqlite3 (database) +- jsonwebtoken (authentication) +- ws (WebSocket) -### Data Format -- Supports standard KML files with Placemark elements -- Automatically loads `default.kml` on startup if present -- Exports edited tracks back to KML format +### Data Storage +- User accounts with JWT authentication +- Server-authoritative game state +- LocalStorage as offline backup only ## Browser Requirements + - Modern browser with ES6 support -- HTTPS connection or localhost for GPS features -- Location services permission for navigation mode +- HTTPS or localhost for GPS features +- Location services permission + +## Documentation + +- [SKILLS.md](SKILLS.md) - Complete skill reference +- [CLAUDE.md](CLAUDE.md) - Development guide ## License -[Add your license information here] \ No newline at end of file + +[Add your license information here] diff --git a/SKILLS.md b/SKILLS.md new file mode 100644 index 0000000..036b215 --- /dev/null +++ b/SKILLS.md @@ -0,0 +1,288 @@ +# HikeMap RPG - Skills Reference + +This document contains base skill definitions for the HikeMap RPG system. + +## Table of Contents +- [Skill Types](#skill-types) +- [Base Skills](#base-skills) +- [Skill Unlock Levels](#skill-unlock-levels) +- [Skill Loadout System](#skill-loadout-system) +- [Combat Formulas](#combat-formulas) +- [Database Schema](#database-schema) + +--- + +## Skill Types + +| Type | Description | +|------|-------------| +| `damage` | Deals damage to enemy/enemies | +| `heal` | Restores HP to self | +| `buff` | Applies a temporary stat boost | +| `status` | Applies a status effect (poison, etc.) | +| `utility` | Out-of-combat effects (MP regen, etc.) | + +--- + +## Base Skills + +### Basic Attack +| Property | Value | +|----------|-------| +| **ID** | `basic_attack` | +| **Type** | Damage | +| **MP Cost** | 0 | +| **Base Power** | 100% ATK | +| **Accuracy** | 95% | +| **Target** | Single enemy | +| **Description** | A basic physical attack | + +### Double Attack +| Property | Value | +|----------|-------| +| **ID** | `double_attack` | +| **Type** | Damage | +| **MP Cost** | 5 | +| **Base Power** | 60% ATK | +| **Hits** | 2 | +| **Accuracy** | 85% | +| **Target** | Single enemy | +| **Description** | Strike twice in quick succession | + +### Heal +| Property | Value | +|----------|-------| +| **ID** | `heal` | +| **Type** | Heal | +| **MP Cost** | 8 | +| **Base Power** | 50% of max HP | +| **Accuracy** | 100% | +| **Target** | Self | +| **Description** | Restore HP | + +### Power Strike +| Property | Value | +|----------|-------| +| **ID** | `power_strike` | +| **Type** | Damage | +| **MP Cost** | 10 | +| **Base Power** | 180% ATK | +| **Accuracy** | 80% | +| **Target** | Single enemy | +| **Description** | A powerful blow with extra force | + +### Defend +| Property | Value | +|----------|-------| +| **ID** | `defend` | +| **Type** | Buff | +| **MP Cost** | 3 | +| **Effect** | DEF +50% for 2 turns | +| **Accuracy** | 100% | +| **Target** | Self | +| **Description** | Raise defense temporarily | + +### Whirlwind +| Property | Value | +|----------|-------| +| **ID** | `whirlwind` | +| **Type** | Damage | +| **MP Cost** | 12 | +| **Base Power** | 75% ATK | +| **Accuracy** | 85% | +| **Target** | All enemies | +| **Description** | A spinning attack that hits all enemies | + +### Quick Strike +| Property | Value | +|----------|-------| +| **ID** | `quick_strike` | +| **Type** | Damage | +| **MP Cost** | 4 | +| **Base Power** | 80% ATK | +| **Accuracy** | 98% | +| **Target** | Single enemy | +| **Description** | A fast attack with high accuracy | + +### Focus +| Property | Value | +|----------|-------| +| **ID** | `focus` | +| **Type** | Buff | +| **MP Cost** | 6 | +| **Effect** | Accuracy +20% for 3 turns | +| **Accuracy** | 100% | +| **Target** | Self | +| **Description** | Concentrate to boost accuracy | + +### Second Wind +| Property | Value | +|----------|-------| +| **ID** | `second_wind` | +| **Type** | Utility | +| **MP Cost** | 0 | +| **Effect** | 2x MP regen while walking for 1 hour | +| **Cooldown** | Once per day | +| **Target** | Self | +| **Description** | Double your MP regeneration while walking | + +### Heavy Blow +| Property | Value | +|----------|-------| +| **ID** | `heavy_blow` | +| **Type** | Damage | +| **MP Cost** | 15 | +| **Base Power** | 250% ATK | +| **Accuracy** | 60% | +| **Target** | Single enemy | +| **Description** | A devastating attack that is hard to land | + +### Quick Heal +| Property | Value | +|----------|-------| +| **ID** | `quick_heal` | +| **Type** | Heal | +| **MP Cost** | 4 | +| **Base Power** | 25% of max HP | +| **Accuracy** | 100% | +| **Target** | Self | +| **Description** | A small but efficient heal | + +### Triple Strike +| Property | Value | +|----------|-------| +| **ID** | `triple_strike` | +| **Type** | Damage | +| **MP Cost** | 18 | +| **Base Power** | 50% ATK | +| **Hits** | 3 | +| **Accuracy** | 80% | +| **Target** | Single enemy | +| **Description** | Strike three times in rapid succession | + +### Full Restore +| Property | Value | +|----------|-------| +| **ID** | `full_restore` | +| **Type** | Heal | +| **MP Cost** | 30 | +| **Base Power** | 100% of max HP | +| **Accuracy** | 100% | +| **Target** | Self | +| **Description** | Fully restore HP at great MP cost | + +### Poison (Monster Only) +| Property | Value | +|----------|-------| +| **ID** | `poison` | +| **Type** | Status | +| **MP Cost** | 0 | +| **Effect** | 5 damage per turn for 3 turns | +| **Accuracy** | 75% | +| **Target** | Single enemy | +| **Player Usable** | No | +| **Description** | Inflict poison that deals damage over time | + +--- + +## Skill Unlock Levels + +Skills are unlocked at level milestones. Each level offers a choice between two skills: + +| Player Level | Choice A | Choice B | +|--------------|----------|----------| +| 1 | Basic Attack | - | +| 2 | Double Attack | Heal | +| 3 | Power Strike | Defend | +| 4 | Whirlwind | Quick Strike | +| 5 | Second Wind | Quick Heal | +| 6 | Triple Strike | Focus | +| 7 | Full Restore | Heavy Blow | + +--- + +## Skill Loadout System + +### How It Works + +1. **On Level-Up**: When reaching a skill unlock level, the player chooses one skill to make **active**. However, **all skills offered at that level are unlocked**. + +2. **Active vs Unlocked**: + - `unlockedSkills`: All skills the player has learned + - `activeSkills`: Skills currently equipped for combat (one per tier) + +3. **Swapping Skills**: At home base, players can swap which skill is active for each tier. + +4. **Combat**: Only skills in `activeSkills` appear in the combat skill menu. + +### Skill Tiers + +| Tier | Level | Skills | +|------|-------|--------| +| 0 | 1 | Basic Attack (always active) | +| 1 | 2 | Double Attack, Heal | +| 2 | 3 | Power Strike, Defend | +| 3 | 4 | Whirlwind, Quick Strike | +| 4 | 5 | Second Wind, Quick Heal | +| 5 | 6 | Triple Strike, Focus | +| 6 | 7 | Full Restore, Heavy Blow | + +**Note**: Utility skills (like Second Wind) don't appear in combat loadout - they are activated from the character sheet. + +--- + +## Combat Formulas + +### Damage Calculation +``` +finalDamage = (skillPower * playerATK / 100) - enemyDEF +``` + +### Hit Chance +``` +hitChance = skillAccuracy + (attackerAccuracy - 90) - defenderDodge +``` +- Clamped between 5% and 99% + +### Defense Buff +When Defend is active: +``` +effectiveDEF = baseDEF * 1.5 +``` + +--- + +## Database Schema + +### skills table +| Column | Type | Description | +|--------|------|-------------| +| id | TEXT | Skill identifier (primary key) | +| name | TEXT | Display name | +| description | TEXT | Skill description | +| type | TEXT | damage/heal/buff/status/utility | +| mp_cost | INTEGER | MP required to use | +| base_power | INTEGER | Base power percentage | +| accuracy | INTEGER | Base accuracy (default 100) | +| hit_count | INTEGER | Number of hits (default 1) | +| target | TEXT | enemy/self/all_enemies | +| status_effect | TEXT | JSON for status effects | +| player_usable | BOOLEAN | Can players use this? | +| monster_usable | BOOLEAN | Can monsters use this? | +| enabled | BOOLEAN | Is skill active in game? | + +### class_skills table +| Column | Type | Description | +|--------|------|-------------| +| class_id | TEXT | Class ID | +| skill_id | TEXT | Base skill ID | +| unlock_level | INTEGER | Level when skill becomes available | +| choice_group | INTEGER | Skills with same group are offered together | + +### class_skill_names table +| Column | Type | Description | +|--------|------|-------------| +| skill_id | TEXT | Base skill ID | +| class_id | TEXT | Class ID | +| custom_name | TEXT | Class-specific name | +| custom_description | TEXT | Class-specific description | diff --git a/index.html b/index.html index 222dd7f..fa05987 100644 --- a/index.html +++ b/index.html @@ -1595,6 +1595,198 @@ .char-sheet-skill.locked .skill-name { color: #666; } + .char-sheet-skill-hint { + font-size: 11px; + color: #666; + text-align: center; + margin-top: 10px; + font-style: italic; + } + + /* Skill Loadout Styles */ + .skill-loadout-info { + font-size: 12px; + color: #aaa; + margin-bottom: 10px; + padding: 8px; + background: rgba(0,0,0,0.2); + border-radius: 6px; + text-align: center; + } + .skill-loadout-info.at-home { + color: #4ecdc4; + background: rgba(78, 205, 196, 0.1); + border: 1px solid rgba(78, 205, 196, 0.3); + } + .skill-loadout { + display: flex; + flex-direction: column; + gap: 12px; + } + .skill-tier-group { + background: rgba(0,0,0,0.2); + border-radius: 8px; + padding: 10px; + } + .skill-tier-header { + font-size: 12px; + color: #888; + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 6px; + } + .skill-tier-header .tier-badge { + background: #45aaf2; + color: #fff; + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: bold; + } + .skill-loadout-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px; + border-radius: 6px; + background: rgba(255,255,255,0.05); + margin-bottom: 6px; + } + .skill-loadout-item:last-child { + margin-bottom: 0; + } + .skill-loadout-item.active { + background: rgba(78, 205, 196, 0.15); + border: 1px solid rgba(78, 205, 196, 0.4); + } + .skill-loadout-item .skill-icon { + font-size: 20px; + min-width: 26px; + text-align: center; + } + .skill-loadout-item .skill-details { + flex: 1; + } + .skill-loadout-item .skill-name { + font-size: 13px; + font-weight: bold; + color: #fff; + } + .skill-loadout-item .skill-mp { + font-size: 11px; + color: #4ecdc4; + } + .skill-loadout-item .skill-badge { + font-size: 10px; + padding: 3px 8px; + border-radius: 12px; + font-weight: bold; + text-transform: uppercase; + } + .skill-loadout-item .skill-badge.active-badge { + background: #4ecdc4; + color: #000; + } + .skill-loadout-item .equip-btn { + background: #45aaf2; + color: #fff; + border: none; + padding: 5px 12px; + border-radius: 12px; + font-size: 11px; + cursor: pointer; + font-weight: bold; + } + .skill-loadout-item .equip-btn:hover:not(:disabled) { + background: #3d9ad9; + } + .skill-loadout-item .equip-btn:disabled { + background: #555; + color: #888; + cursor: not-allowed; + } + + /* Daily Skills Styles */ + .char-sheet-daily-skills { + display: flex; + flex-direction: column; + gap: 10px; + } + .daily-skill { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: rgba(0,0,0,0.2); + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.1); + } + .daily-skill .skill-icon { + font-size: 28px; + min-width: 36px; + text-align: center; + } + .daily-skill .skill-info { + flex: 1; + } + .daily-skill .skill-name { + font-size: 14px; + font-weight: bold; + color: #fff; + } + .daily-skill .skill-desc { + font-size: 11px; + color: #aaa; + margin-top: 2px; + } + .daily-skill .skill-status { + font-size: 11px; + margin-top: 4px; + } + .daily-skill .skill-status.active { + color: #4ecdc4; + } + .daily-skill .skill-status.cooldown { + color: #e94560; + } + .daily-skill .skill-status.available { + color: #4CAF50; + } + .daily-skill-btn { + padding: 8px 16px; + border-radius: 6px; + border: none; + font-size: 12px; + font-weight: bold; + cursor: pointer; + transition: all 0.2s; + } + .daily-skill-btn.activate { + background: linear-gradient(135deg, #4CAF50 0%, #388E3C 100%); + color: white; + } + .daily-skill-btn.activate:hover { + transform: scale(1.05); + box-shadow: 0 2px 8px rgba(76, 175, 80, 0.4); + } + .daily-skill-btn.active { + background: linear-gradient(135deg, #4ecdc4 0%, #3da89f 100%); + color: white; + cursor: default; + } + .daily-skill-btn.cooldown { + background: rgba(255,255,255,0.1); + color: #888; + cursor: not-allowed; + } + .no-daily-skills { + text-align: center; + color: #888; + font-size: 12px; + padding: 10px; + } + .char-sheet-monster-count { font-size: 16px; color: #ffd93d; @@ -2717,6 +2909,82 @@ margin-top: 8px; } + /* Homebase Skill Loadout */ + .homebase-skill-loadout { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; + } + .homebase-skill-tier { + background: rgba(0, 0, 0, 0.3); + border-radius: 10px; + padding: 10px; + } + .homebase-skill-tier-label { + font-size: 11px; + color: #888; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 1px; + } + .homebase-skill-options { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .homebase-skill-btn { + flex: 1; + min-width: 120px; + padding: 10px 12px; + border-radius: 8px; + border: 2px solid transparent; + background: rgba(255, 255, 255, 0.1); + color: #ccc; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 8px; + } + .homebase-skill-btn:hover { + background: rgba(255, 255, 255, 0.15); + transform: translateY(-1px); + } + .homebase-skill-btn.active { + border-color: #4ecdc4; + background: rgba(78, 205, 196, 0.2); + color: #fff; + } + .homebase-skill-btn.active::after { + content: '✓'; + margin-left: auto; + color: #4ecdc4; + font-weight: bold; + } + .homebase-skill-btn .skill-icon { + font-size: 18px; + } + .homebase-skill-btn .skill-info { + display: flex; + flex-direction: column; + align-items: flex-start; + } + .homebase-skill-btn .skill-name { + font-size: 13px; + font-weight: bold; + } + .homebase-skill-btn .skill-mp { + font-size: 10px; + color: #4ecdc4; + } + .homebase-no-skills { + color: #888; + font-size: 13px; + text-align: center; + padding: 12px; + } + @@ -2779,13 +3047,18 @@

🏠 Homebase

-

Select Icon

+

⚔️ Skill Loadout

+
+ +
+ +

🎨 Base Icon

- +
@@ -3149,8 +3422,15 @@
-

Skills

-
+

⚔️ Active Skills

+
+ +
+
Tap your home base to change loadout
+
+
+

🌟 Daily Skills

+
@@ -4040,10 +4320,12 @@ // RPG State variables let playerStats = null; // Player RPG stats + let statsLoadedFromServer = false; // Flag to prevent saving until server data is loaded let monsterEntourage = []; // Array of spawned monsters following player let combatState = null; // Active combat state or null let monsterSpawnTimer = null; // Interval for spawning monsters let monsterUpdateTimer = null; // Interval for updating monster positions/dialogue + let homeRegenTimer = null; // Interval for passive home base regen let lastSpawnLocation = null; // Track player location at last spawn (for movement-based spawning) // Spawn settings (loaded from server, with defaults) @@ -4054,10 +4336,16 @@ mpRegenDistance: 5 // Meters per 1 MP regen }; - // MP regen tracking + // MP regen tracking (distance-based) let lastMpRegenLocation = null; // Track location for MP regen distance let mpRegenAccumulator = 0; // Accumulated distance for MP regen + // HP regen settings (time-based) + let hpRegenTimer = null; // Timer for passive HP regen + const HP_REGEN_INTERVAL = 10000; // HP regens every 10 seconds + const HP_REGEN_PERCENT = 1; // Base: 1% of max HP per tick + const HOME_HP_MULTIPLIER = 3; // HP regens 3x faster at home base + // Home Base state variables let homeBaseMarker = null; // Leaflet marker for home base let homeBaseSelectionMode = false; // Whether we're in home base selection mode @@ -4068,6 +4356,10 @@ const HOME_REGEN_PERCENT = 5; // Regen 5% of max HP/MP per tick const HOME_BASE_RADIUS = 20; // Meters - radius for home base effects + // Player buffs state (loaded from server) + let playerBuffs = {}; // Buff status keyed by buffType + let mpRegenMultiplier = 1.0; // Current MP regen multiplier + // ========================================== // MUSIC SYSTEM // ========================================== @@ -9802,13 +10094,23 @@ }); monsterEntourage = []; playerStats = null; + playerBuffs = {}; + mpRegenMultiplier = 1.0; document.getElementById('rpgHud').style.display = 'none'; + document.getElementById('deathOverlay').style.display = 'none'; + + // Clear home base marker + if (homeBaseMarker) { + homeBaseMarker.remove(); + homeBaseMarker = null; + } currentUser = null; accessToken = null; refreshToken = null; localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); + localStorage.removeItem('hikemap_rpg_stats'); // Clear cached RPG stats to prevent stale data updateAuthUI(); } @@ -10128,10 +10430,17 @@ // Initialize RPG with the new character playerStats = characterData; + + // Mark as loaded from server (we just created it there) + statsLoadedFromServer = true; + savePlayerStats(); document.getElementById('rpgHud').style.display = 'flex'; updateRpgHud(); startMonsterSpawning(); + + // Start auto-save for new character + startAutoSave(); } else { const error = await response.json(); updateStatus(error.error || 'Failed to create character', 'error'); @@ -10208,60 +10517,71 @@
Next level: ${Math.max(0, xpNeeded - playerStats.xp)} XP needed
`; - // Update skills (show only unlocked skills) - const unlockedSkills = playerStats.unlockedSkills || ['basic_attack']; - document.getElementById('charSheetSkills').innerHTML = unlockedSkills.map(skillId => { - const dbSkill = SKILLS_DB[skillId]; - const hardcodedSkill = SKILLS[skillId]; - const skill = dbSkill || hardcodedSkill; - if (!skill) return ''; - - // Calculate skill stats for display - let statsText = ''; - const accuracy = dbSkill?.accuracy || 95; - const mpCost = skill.mpCost || 0; - - if (skill.type === 'damage') { - // Calculate damage based on player ATK - let damage; - const hits = skill.hitCount || skill.hits || 1; - if (hardcodedSkill && hardcodedSkill.calculate) { - damage = hardcodedSkill.calculate(playerStats.atk); - } else if (dbSkill) { - damage = Math.floor(playerStats.atk * (dbSkill.basePower / 100)); - } else { - damage = playerStats.atk; - } - const minDmg = Math.max(1, Math.floor(damage * 0.9)); - const maxDmg = Math.floor(damage * 1.1); - if (hits > 1) { - statsText = `${minDmg}-${maxDmg} x${hits} | ${accuracy}% | ${mpCost} MP`; + // Update Active Skills section (only show active combat skills) + const activeSkills = playerStats.activeSkills || playerStats.unlockedSkills || ['basic_attack']; + document.getElementById('charSheetActiveSkills').innerHTML = activeSkills + .filter(skillId => { + // Filter out utility skills (shown in Daily Skills) + const skill = SKILLS_DB[skillId] || SKILLS[skillId]; + return skill && skill.type !== 'utility'; + }) + .map(skillId => { + const dbSkill = SKILLS_DB[skillId]; + const hardcodedSkill = SKILLS[skillId]; + const skill = dbSkill || hardcodedSkill; + if (!skill) return ''; + + // Get class-specific display name and description + const skillInfo = getSkillForClass(skillId, playerStats.class); + const displayName = skillInfo?.displayName || skill.name; + const displayDesc = skillInfo?.displayDescription || skill.description; + + // Calculate skill stats for display + let statsText = ''; + const accuracy = dbSkill?.accuracy || 95; + const mpCost = skill.mpCost || 0; + + if (skill.type === 'damage') { + // Calculate damage based on player ATK + let damage; + const hits = skill.hitCount || skill.hits || 1; + if (hardcodedSkill && hardcodedSkill.calculate) { + damage = hardcodedSkill.calculate(playerStats.atk); + } else if (dbSkill) { + damage = Math.floor(playerStats.atk * (dbSkill.basePower / 100)); + } else { + damage = playerStats.atk; + } + const minDmg = Math.max(1, Math.floor(damage * 0.9)); + const maxDmg = Math.floor(damage * 1.1); + if (hits > 1) { + statsText = `${minDmg}-${maxDmg} x${hits} | ${accuracy}% | ${mpCost} MP`; + } else { + statsText = `${minDmg}-${maxDmg} dmg | ${accuracy}% | ${mpCost} MP`; + } + } else if (skill.type === 'heal') { + const healAmount = hardcodedSkill?.calculate ? hardcodedSkill.calculate(playerStats.maxHp) : 0; + statsText = `+${healAmount} HP | ${mpCost} MP`; + } else if (skill.type === 'restore') { + const restoreAmount = hardcodedSkill?.calculate ? hardcodedSkill.calculate(playerStats.maxMp) : 0; + statsText = `+${restoreAmount} MP | ${mpCost} MP`; + } else if (skill.type === 'buff') { + statsText = `Buff | ${mpCost} MP`; } else { - statsText = `${minDmg}-${maxDmg} dmg | ${accuracy}% | ${mpCost} MP`; + statsText = `${mpCost} MP`; } - } else if (skill.type === 'heal') { - const healAmount = hardcodedSkill?.calculate ? hardcodedSkill.calculate(playerStats.maxHp) : 0; - statsText = `+${healAmount} HP | ${mpCost} MP`; - } else if (skill.type === 'restore') { - const restoreAmount = hardcodedSkill?.calculate ? hardcodedSkill.calculate(playerStats.maxMp) : 0; - statsText = `+${restoreAmount} MP | ${mpCost} MP`; - } else if (skill.type === 'buff') { - statsText = `Buff | ${mpCost} MP`; - } else { - statsText = `${mpCost} MP`; - } - return ` -
- ${skill.icon} -
-
${skill.name}
-
${skill.description}
-
${statsText}
+ return ` +
+ ${skill.icon} +
+
${displayName}
+
${displayDesc}
+
${statsText}
+
-
- `; - }).join(''); + `; + }).join(''); // Update monsters section const maxMonsters = getMaxMonsters(); @@ -10285,9 +10605,152 @@ } document.getElementById('charSheetMonsters').innerHTML = monstersHtml; + // Update Daily Skills section + updateDailySkillsSection(); + document.getElementById('charSheetModal').style.display = 'flex'; } + // Format time remaining as human-readable string + function formatTimeRemaining(seconds) { + if (seconds <= 0) return 'Ready'; + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; + } + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; + } + + // Update the Daily Skills section in character sheet + function updateDailySkillsSection() { + const container = document.getElementById('charSheetDailySkills'); + if (!container) return; + + // Check if player has Second Wind unlocked + const unlockedSkills = playerStats?.unlockedSkills || ['basic_attack']; + const hasSecondWind = unlockedSkills.includes('second_wind'); + + if (!hasSecondWind) { + container.innerHTML = '
No daily skills unlocked yet
'; + return; + } + + // Get Second Wind buff status + const buff = playerBuffs['second_wind']; + let statusClass = 'available'; + let statusText = 'Ready to use'; + let buttonClass = 'activate'; + let buttonText = 'Activate'; + let buttonDisabled = false; + + if (buff) { + if (buff.isActive) { + statusClass = 'active'; + statusText = `Active - ${formatTimeRemaining(buff.expiresIn)} remaining`; + buttonClass = 'active'; + buttonText = 'Active'; + buttonDisabled = true; + } else if (buff.isOnCooldown) { + statusClass = 'cooldown'; + statusText = `On cooldown - ${formatTimeRemaining(buff.cooldownEndsIn)}`; + buttonClass = 'cooldown'; + buttonText = 'Cooldown'; + buttonDisabled = true; + } + } + + container.innerHTML = ` +
+ 💨 +
+
Second Wind
+
Double MP regen while walking for 1 hour
+
${statusText}
+
+ +
+ `; + } + + // Activate Second Wind buff + async function activateSecondWind() { + const token = localStorage.getItem('accessToken'); + if (!token) return; + + try { + const response = await fetch('/api/user/buffs/activate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ buffType: 'second_wind' }) + }); + + if (response.ok) { + const data = await response.json(); + // Update local buff state + playerBuffs['second_wind'] = { + isActive: true, + isOnCooldown: true, + expiresIn: data.expiresIn, + cooldownEndsIn: data.cooldownEndsIn, + effectValue: data.effectValue + }; + // Update multiplier + mpRegenMultiplier = data.effectValue; + + updateStatus('Second Wind activated! MP regen doubled for 1 hour.', 'success'); + updateDailySkillsSection(); + } else { + const error = await response.json(); + if (error.cooldownEndsIn) { + updateStatus(`Second Wind on cooldown for ${formatTimeRemaining(error.cooldownEndsIn)}`, 'error'); + } else { + updateStatus(error.error || 'Failed to activate Second Wind', 'error'); + } + } + } catch (err) { + console.error('Error activating Second Wind:', err); + updateStatus('Failed to activate Second Wind', 'error'); + } + } + + // Fetch player buffs from server + async function fetchPlayerBuffs() { + const token = localStorage.getItem('accessToken'); + if (!token) return; + + try { + const response = await fetch('/api/user/buffs', { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (response.ok) { + const buffs = await response.json(); + // Convert array to object keyed by buffType + playerBuffs = {}; + buffs.forEach(b => { + playerBuffs[b.buffType] = b; + // Update MP regen multiplier if active + if (b.buffType === 'second_wind' && b.isActive) { + mpRegenMultiplier = b.effectValue; + } + }); + } + } catch (err) { + console.error('Error fetching player buffs:', err); + } + } + function hideCharacterSheet() { document.getElementById('charSheetModal').style.display = 'none'; } @@ -10346,9 +10809,21 @@ playerStats.unlockedSkills = ['basic_attack']; } - // Add the selected skill - if (!playerStats.unlockedSkills.includes(skillId)) { - playerStats.unlockedSkills.push(skillId); + // Initialize activeSkills if needed + if (!playerStats.activeSkills) { + playerStats.activeSkills = ['basic_attack']; + } + + // Unlock ALL skills at this level (not just the selected one) + pendingSkillChoice.options.forEach(optionSkillId => { + if (!playerStats.unlockedSkills.includes(optionSkillId)) { + playerStats.unlockedSkills.push(optionSkillId); + } + }); + + // Add only the SELECTED skill to activeSkills + if (!playerStats.activeSkills.includes(skillId)) { + playerStats.activeSkills.push(skillId); } // Save to server @@ -10356,13 +10831,23 @@ // Close modal document.getElementById('skillChoiceModal').style.display = 'none'; + const selectedLevel = pendingSkillChoice.level; pendingSkillChoice = null; // Show notification with class-specific name const skillInfo = getSkillForClass(skillId, playerStats.class); const skill = skillInfo || SKILLS[skillId]; const displayName = skillInfo?.displayName || skill?.name || skillId; - showNotification(`Learned ${displayName}!`, 'success'); + + // Count how many skills were learned at this level + const pool = SKILL_POOLS[playerStats.class]; + const skillCount = pool && pool[selectedLevel] ? pool[selectedLevel].length : 1; + + if (skillCount > 1) { + showNotification(`Learned ${skillCount} new skills! ${displayName} is now active.`, 'success'); + } else { + showNotification(`Learned ${displayName}!`, 'success'); + } } // Leaderboard @@ -10536,11 +11021,20 @@ playerStats = serverStats; console.log('Loaded RPG stats from server:', playerStats); + // Mark that we've loaded from server - safe to save now + statsLoadedFromServer = true; + + // Update localStorage to match server (for offline viewing only) + localStorage.setItem('hikemap_rpg_stats', JSON.stringify(playerStats)); + // Show RPG HUD and start game document.getElementById('rpgHud').style.display = 'flex'; updateRpgHud(); updateHomeBaseMarker(); + // Fetch player buffs (like Second Wind) + await fetchPlayerBuffs(); + // If player is dead, show death overlay if (playerStats.isDead) { document.getElementById('deathOverlay').style.display = 'flex'; @@ -10548,6 +11042,9 @@ startMonsterSpawning(); } + // Start auto-save now that we have valid server data + startAutoSave(); + console.log('RPG system initialized for', username); return; } @@ -10556,26 +11053,16 @@ console.error('Failed to load RPG stats from server:', e); } - // Fall back to localStorage if server didn't have valid stats + // Server didn't have valid stats - check if user has never played + // (localStorage fallback is only for reading, never write back to server without explicit action) 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 - - document.getElementById('rpgHud').style.display = 'flex'; - updateRpgHud(); - updateHomeBaseMarker(); - - // If player is dead, show death overlay - if (playerStats.isDead) { - document.getElementById('deathOverlay').style.display = 'flex'; - } else { - startMonsterSpawning(); - } - return; + const localStats = JSON.parse(saved); + if (localStats && localStats.name) { + console.log('Found localStorage stats but server has no data - showing character creator'); + // Don't auto-migrate localStorage to server - user should create fresh character + // This prevents stale localStorage data from overwriting server progress } } catch (e) { console.error('Failed to parse saved RPG stats:', e); @@ -10602,7 +11089,24 @@ if (serverStats && serverStats.name) { playerStats = serverStats; console.log('Refreshed RPG stats from server:', playerStats); + + // Update localStorage backup with fresh server data + localStorage.setItem('hikemap_rpg_stats', JSON.stringify(playerStats)); + + // Update UI updateRpgHud(); + updateHomeBaseMarker(); + + // Handle death state changes + if (playerStats.isDead) { + document.getElementById('deathOverlay').style.display = 'flex'; + stopMonsterSpawning(); + } else { + document.getElementById('deathOverlay').style.display = 'none'; + startMonsterSpawning(); + } + + showNotification('Stats updated by admin', 'info'); } } } catch (e) { @@ -10614,6 +11118,12 @@ function savePlayerStats() { if (!playerStats) return; + // Don't save until we've loaded from server to prevent overwriting good data + if (!statsLoadedFromServer) { + console.warn('Skipping save - waiting for server data to load first'); + return; + } + // Save to localStorage as backup localStorage.setItem('hikemap_rpg_stats', JSON.stringify(playerStats)); @@ -10627,7 +11137,20 @@ 'Authorization': `Bearer ${token}` }, body: JSON.stringify(playerStats) - }).catch(err => console.error('Failed to save RPG stats to server:', err)); + }) + .then(response => { + if (!response.ok) { + console.error('Server rejected stats save:', response.status); + response.json().then(err => console.error('Server error:', err)); + showNotification('⚠️ Failed to save progress', 'error'); + } + }) + .catch(err => { + console.error('Failed to save RPG stats to server:', err); + showNotification('⚠️ Failed to save progress', 'error'); + }); + } else { + console.warn('No access token - stats only saved to localStorage'); } } @@ -10780,6 +11303,9 @@ const modal = document.getElementById('homebaseModal'); modal.style.display = 'flex'; + // Load skill loadout + populateHomebaseSkillLoadout(); + // Load available icons await loadHomebaseIcons(); @@ -10787,6 +11313,212 @@ await updateRelocateButton(); } + // Populate skill loadout in homebase modal + function populateHomebaseSkillLoadout() { + const container = document.getElementById('homebaseSkillLoadout'); + if (!container) return; + + const unlockedSkills = playerStats.unlockedSkills || ['basic_attack']; + const activeSkills = playerStats.activeSkills || playerStats.unlockedSkills || ['basic_attack']; + const pool = SKILL_POOLS[playerStats.class]; + + // Helper to check if a skill is a combat skill (not utility) + const isCombatSkill = (skillId) => { + const skill = SKILLS_DB[skillId] || SKILLS[skillId]; + if (!skill) return false; + return skill.type !== 'utility'; + }; + + // Build skill tiers from SKILL_POOLS + const tierSkills = {}; + + // Group unlocked skills by their tier level + if (pool) { + Object.keys(pool).forEach(level => { + const tierLevel = parseInt(level); + const skillsAtLevel = pool[level]; + + // Get ALL skills at this tier (for swapping), filter out basic_attack and utility + const allAtTier = skillsAtLevel.filter(sid => + sid !== 'basic_attack' && isCombatSkill(sid) + ); + + // Only show tier if player has unlocked at least one skill from it + const hasUnlocked = allAtTier.some(sid => unlockedSkills.includes(sid)); + if (hasUnlocked && allAtTier.length > 0) { + tierSkills[tierLevel] = allAtTier; + } + }); + } + + // Generate HTML + let html = ''; + const sortedTiers = Object.keys(tierSkills).map(Number).sort((a, b) => a - b); + + if (sortedTiers.length === 0) { + html = '
No skills to configure yet. Level up to unlock skills!
'; + } else { + sortedTiers.forEach(tier => { + const skills = tierSkills[tier]; + if (!skills || skills.length === 0) return; + + // Only show tiers where player has multiple skills unlocked (can swap) + const unlockedAtTier = skills.filter(sid => unlockedSkills.includes(sid)); + if (unlockedAtTier.length < 2) { + // Show single skill but no swap option + if (unlockedAtTier.length === 1) { + const skillId = unlockedAtTier[0]; + const skillInfo = getSkillForClass(skillId, playerStats.class); + const baseSkill = SKILLS_DB[skillId] || SKILLS[skillId]; + if (!baseSkill) return; + + const displayName = skillInfo?.displayName || baseSkill.name; + const icon = SKILLS[skillId]?.icon || baseSkill.icon || '⚔️'; + const mpCost = baseSkill.mpCost || 0; + const isActive = activeSkills.includes(skillId); + + html += ` +
+
Level ${tier}
+
+
+ ${icon} +
+ ${displayName} + ${mpCost > 0 ? mpCost + ' MP' : 'Free'} +
+
+
+
+ `; + } + return; + } + + html += `
`; + html += `
Level ${tier} - Choose One
`; + html += `
`; + + unlockedAtTier.forEach(skillId => { + const skillInfo = getSkillForClass(skillId, playerStats.class); + const baseSkill = SKILLS_DB[skillId] || SKILLS[skillId]; + if (!baseSkill) return; + + const displayName = skillInfo?.displayName || baseSkill.name; + const icon = SKILLS[skillId]?.icon || baseSkill.icon || '⚔️'; + const mpCost = baseSkill.mpCost || 0; + const isActive = activeSkills.includes(skillId); + + html += ` + + `; + }); + + html += `
`; + }); + } + + // If no swappable tiers but we generated nothing, show message + if (html === '') { + html = '
Level up to unlock more skill choices!
'; + } + + container.innerHTML = html; + } + + // Swap skill from homebase modal (with instant feedback) + async function swapSkillFromHomebase(tier, newSkillId) { + const unlockedSkills = playerStats.unlockedSkills || ['basic_attack']; + const activeSkills = playerStats.activeSkills || [...unlockedSkills]; + const pool = SKILL_POOLS[playerStats.class]; + + // Validate skill is unlocked + if (!unlockedSkills.includes(newSkillId)) { + showNotification('Skill not unlocked!', 'warning'); + return; + } + + // If already active, do nothing + if (activeSkills.includes(newSkillId)) { + return; + } + + // Find the old skill at this tier to remove + let oldSkillId = null; + if (pool && pool[tier]) { + oldSkillId = pool[tier].find(sid => activeSkills.includes(sid)); + } + + // Build new active skills array + let newActiveSkills = [...activeSkills]; + + // Remove old skill from this tier if found + if (oldSkillId && oldSkillId !== newSkillId) { + newActiveSkills = newActiveSkills.filter(sid => sid !== oldSkillId); + } + + // Add new skill + if (!newActiveSkills.includes(newSkillId)) { + newActiveSkills.push(newSkillId); + } + + // Update local state immediately for instant feedback + playerStats.activeSkills = newActiveSkills; + + // Refresh UI immediately + populateHomebaseSkillLoadout(); + + // Save to server + const token = localStorage.getItem('accessToken'); + if (token) { + try { + const response = await fetch('/api/user/swap-skill', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + tier, + newSkillId, + currentActiveSkills: newActiveSkills, + unlockedSkills + }) + }); + + if (response.ok) { + const data = await response.json(); + playerStats.activeSkills = data.activeSkills; + savePlayerStats(); + + // Get skill name for notification + const skill = SKILLS_DB[newSkillId] || SKILLS[newSkillId]; + const skillInfo = getSkillForClass(newSkillId, playerStats.class); + const skillName = skillInfo?.displayName || skill?.name || newSkillId; + showNotification(`${skillName} equipped!`, 'success'); + } else { + const error = await response.json(); + showNotification(error.error || 'Failed to swap skill', 'warning'); + // Revert local state + playerStats.activeSkills = activeSkills; + populateHomebaseSkillLoadout(); + } + } catch (err) { + console.error('Failed to swap skill:', err); + showNotification('Failed to swap skill', 'warning'); + playerStats.activeSkills = activeSkills; + populateHomebaseSkillLoadout(); + } + } + } + // Close the homebase modal function closeHomebaseModal() { document.getElementById('homebaseModal').style.display = 'none'; @@ -10968,8 +11700,8 @@ if (!playerStats || playerStats.isDead) return; if (!playerStats.homeBaseLat) return; - // Check if at full HP and MP already - if (playerStats.hp >= playerStats.maxHp && playerStats.mp >= playerStats.maxMp) return; + // Skip if already at max MP (HP is handled by time-based regen) + if (playerStats.mp >= playerStats.maxMp) return; // Check distance to home const distance = getDistanceToHome(); @@ -10979,28 +11711,21 @@ const now = Date.now(); if (now - lastHomeRegenTime < HOME_REGEN_INTERVAL) return; - // Regenerate HP and MP - let regenOccurred = false; - const hpRegen = Math.ceil(playerStats.maxHp * (HOME_REGEN_PERCENT / 100)); + // Regenerate MP only (HP is handled by time-based regen with 3x at home) const mpRegen = Math.ceil(playerStats.maxMp * (HOME_REGEN_PERCENT / 100)); - if (playerStats.hp < playerStats.maxHp) { - playerStats.hp = Math.min(playerStats.maxHp, playerStats.hp + hpRegen); - regenOccurred = true; - } - if (playerStats.mp < playerStats.maxMp) { + const oldMp = playerStats.mp; playerStats.mp = Math.min(playerStats.maxMp, playerStats.mp + mpRegen); - regenOccurred = true; - } - if (regenOccurred) { - lastHomeRegenTime = now; - updateRpgHud(); - savePlayerStats(); + if (playerStats.mp > oldMp) { + lastHomeRegenTime = now; + updateRpgHud(); + savePlayerStats(); - // Show subtle regen indicator - showHomeRegenEffect(); + // Show subtle MP regen indicator + showHomeRegenEffect(); + } } } @@ -11016,7 +11741,7 @@ }, 500); } - // Check for MP regeneration while walking + // Check for MP regeneration while walking (distance-based) function checkWalkingMpRegen(lat, lng) { // Skip if dead, in combat, or no player stats if (!playerStats || playerStats.isDead) return; @@ -11053,7 +11778,9 @@ // Check if we've walked enough for MP regen if (mpRegenAccumulator >= regenDistance) { - const mpToRegen = Math.floor(mpRegenAccumulator / regenDistance); + // Apply MP regen multiplier (e.g., 2x with Second Wind active) + const baseMpToRegen = Math.floor(mpRegenAccumulator / regenDistance); + const mpToRegen = Math.floor(baseMpToRegen * mpRegenMultiplier); mpRegenAccumulator = mpRegenAccumulator % regenDistance; // Keep remainder const oldMp = playerStats.mp; @@ -11063,22 +11790,81 @@ updateRpgHud(); savePlayerStats(); - // Show subtle MP regen indicator - showWalkingMpRegenEffect(); + // Show blue glow for MP regen (green if boosted by Second Wind) + showMpRegenEffect(mpRegenMultiplier > 1); } } } - // Show a subtle visual effect for walking MP regen - function showWalkingMpRegenEffect() { + // Check for HP regeneration (time-based, runs on timer) + function checkTimeBasedHpRegen() { + // Skip if dead, in combat, or no player stats + if (!playerStats || playerStats.isDead) return; + if (combatState && combatState.inCombat) return; + + // Skip if already at max HP + if (playerStats.hp >= playerStats.maxHp) return; + + // Check if at home base for 3x boost + const distanceToHome = getDistanceToHome(); + const isAtHome = distanceToHome !== null && distanceToHome <= HOME_BASE_RADIUS; + + // Calculate HP to regen (1% base, 3% at home) + const multiplier = isAtHome ? HOME_HP_MULTIPLIER : 1; + const hpToRegen = Math.max(1, Math.ceil(playerStats.maxHp * (HP_REGEN_PERCENT / 100) * multiplier)); + + const oldHp = playerStats.hp; + playerStats.hp = Math.min(playerStats.maxHp, playerStats.hp + hpToRegen); + + if (playerStats.hp > oldHp) { + updateRpgHud(); + savePlayerStats(); + + // Show red glow for HP regen (green if at home) + showHpRegenEffect(isAtHome); + } + } + + // Start the time-based HP regen timer + function startHpRegenTimer() { + if (hpRegenTimer) clearInterval(hpRegenTimer); + + // HP regens every 10 seconds + hpRegenTimer = setInterval(() => { + checkTimeBasedHpRegen(); + }, HP_REGEN_INTERVAL); + } + + // Stop the HP regen timer + function stopHpRegenTimer() { + if (hpRegenTimer) { + clearInterval(hpRegenTimer); + hpRegenTimer = null; + } + } + + // Show a subtle visual effect for MP regen (walking) + function showMpRegenEffect(boosted = false) { + const hud = document.getElementById('rpgHud'); + if (!hud) return; + + const glowColor = boosted ? 'rgba(76, 175, 80, 0.8)' : 'rgba(33, 150, 243, 0.6)'; + hud.style.boxShadow = `0 0 ${boosted ? '20' : '15'}px ${glowColor}`; + setTimeout(() => { + hud.style.boxShadow = ''; + }, boosted ? 500 : 300); + } + + // Show a subtle visual effect for HP regen (time-based) + function showHpRegenEffect(boosted = false) { const hud = document.getElementById('rpgHud'); if (!hud) return; - // Add a brief blue glow for MP regen - hud.style.boxShadow = '0 0 15px rgba(33, 150, 243, 0.6)'; + const glowColor = boosted ? 'rgba(76, 175, 80, 0.8)' : 'rgba(239, 83, 80, 0.6)'; + hud.style.boxShadow = `0 0 ${boosted ? '20' : '15'}px ${glowColor}`; setTimeout(() => { hud.style.boxShadow = ''; - }, 300); + }, boosted ? 500 : 300); } // Check if at home base and clear monsters if entering @@ -11206,10 +11992,8 @@ updateRpgHud(); savePlayerStats(); - // Spawn a monster after a short delay - setTimeout(() => { - spawnMonsterNearPlayer(); - }, 2000); + // Restart monster spawning and home regen timer + startMonsterSpawning(); console.log('Player respawned with full HP/MP'); } @@ -11353,8 +12137,53 @@ spawnMonsterNearPlayer(); }, 5000); } + + // Start passive home base regen timer (MP at home) + startHomeRegenTimer(); + + // Start time-based HP regen timer + startHpRegenTimer(); + } + + // Start passive home base regeneration timer (runs even when standing still) + function startHomeRegenTimer() { + if (homeRegenTimer) clearInterval(homeRegenTimer); + + // Check for home base regen every 3 seconds, independent of GPS updates + homeRegenTimer = setInterval(() => { + checkHomeBaseRegen(); + }, HOME_REGEN_INTERVAL); } + // Auto-save stats periodically (every 30 seconds) + let autoSaveTimer = null; + function startAutoSave() { + if (autoSaveTimer) clearInterval(autoSaveTimer); + autoSaveTimer = setInterval(() => { + if (playerStats) { + savePlayerStats(); + } + }, 30000); // Every 30 seconds + } + + // Save stats when page is about to close + window.addEventListener('beforeunload', () => { + // Only save if we've loaded from server to prevent overwriting good data + if (playerStats && statsLoadedFromServer) { + // Use sendBeacon for reliable save on page close + const token = localStorage.getItem('accessToken'); + if (token) { + navigator.sendBeacon('/api/user/rpg-stats-beacon', new Blob([JSON.stringify({ + token: token, + stats: playerStats + })], { type: 'application/json' })); + } + } + }); + + // Note: startAutoSave() is now called in initializePlayerStats() after server data loads + // This prevents auto-save from running before we have valid server data + // Stop monster spawning function stopMonsterSpawning() { if (monsterSpawnTimer) { @@ -11365,6 +12194,11 @@ clearInterval(monsterUpdateTimer); monsterUpdateTimer = null; } + if (homeRegenTimer) { + clearInterval(homeRegenTimer); + homeRegenTimer = null; + } + stopHpRegenTimer(); } // Spawn a monster near the player @@ -11636,13 +12470,14 @@ const monsterCount = combatState.monsters.length; log.innerHTML = `
Combat begins! ${monsterCount} ${monsterCount === 1 ? 'enemy' : 'enemies'} engaged!
`; - // Populate skills (only show unlocked skills) + // Populate skills (only show ACTIVE skills, not all unlocked) const skillsContainer = document.getElementById('combatSkills'); skillsContainer.innerHTML = ''; - // Use unlockedSkills if available, otherwise fall back to basic_attack only - const unlockedSkills = playerStats.unlockedSkills || ['basic_attack']; - unlockedSkills.forEach(skillId => { + // Use activeSkills for combat (one skill per tier) + // Fall back to unlockedSkills for migration, then to basic_attack only + const activeSkills = playerStats.activeSkills || playerStats.unlockedSkills || ['basic_attack']; + activeSkills.forEach(skillId => { // Check both hardcoded SKILLS and database SKILLS_DB const hardcodedSkill = SKILLS[skillId]; const dbSkill = SKILLS_DB[skillId]; @@ -11650,6 +12485,10 @@ if (!hardcodedSkill && !dbSkill) return; // Skip if skill doesn't exist + // Skip utility skills (like second_wind) - they're not combat skills + const baseSkill = dbSkill || hardcodedSkill; + if (baseSkill.type === 'utility') return; + const displayName = skillInfo?.displayName || hardcodedSkill?.name || dbSkill?.name || skillId; const icon = hardcodedSkill?.icon || '⚔️'; const mpCost = hardcodedSkill?.mpCost || dbSkill?.mpCost || 0;