initial commit
This commit is contained in:
commit
35d2c51ff1
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
|
||||||
58
AGENTS.md
Normal file
58
AGENTS.md
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# AGENTS.md — Zelda Dungeon Quest
|
||||||
|
|
||||||
|
## Run Commands
|
||||||
|
|
||||||
|
- **Browser dev**: `npm run dev` → `npx serve . -p 3000`, open `http://localhost:3000`
|
||||||
|
- **Electron**: `npm start` (requires `--no-sandbox` on Linux, or `ELECTRON_DISABLE_SANDBOX=1`)
|
||||||
|
- **Node tests**: `node test-full-node.js` (VM-based, simulates browser env)
|
||||||
|
- **Regenerate bosses**: `node generate-bosses.js` (rewrites `src/data/bosses.js`)
|
||||||
|
|
||||||
|
## Module System — Critical
|
||||||
|
|
||||||
|
This project uses a **custom browser polyfill** (`src/browser-polyfill.js`) that makes Node-style `module.exports` / `require()` work in the browser. Every source file must follow this pattern:
|
||||||
|
|
||||||
|
1. **Export via `module.exports`** at the end of each file:
|
||||||
|
```js
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { exportedName1, exportedName2 };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Symbols go to `window`** — the polyfill's `module.exports` setter mirrors all exported properties to `window`. Cross-module access reads from `window`.
|
||||||
|
|
||||||
|
3. **`moduleKeys` registry** — `src/browser-polyfill.js` maintains a `moduleKeys` map that tells `require()` which `window` keys belong to each module path. **When you add new exports to any module, you MUST add them to the matching entry in `moduleKeys`, or `require()` will not return them.**
|
||||||
|
|
||||||
|
4. **Script load order** — `index.html` loads scripts in a strict sequence. The polyfill loads first, then modules in dependency order. Adding a new script requires inserting it at the correct position in `index.html`.
|
||||||
|
|
||||||
|
## File Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
renderer.js ← Legacy prototype (3-room, standalone). NOT used by index.html.
|
||||||
|
src/app.js ← Bootstrap: init, game loop, screen switching
|
||||||
|
src/browser-polyfill.js ← CommonJS polyfill (moduleKeys registry)
|
||||||
|
|
||||||
|
src/core/ ← Constants, RNG, utilities (loaded first)
|
||||||
|
src/data/ ← Static definitions: themes, dungeons(100), enemies(22), weapons(12), bosses(100), items
|
||||||
|
src/generator/ ← Procedural generation: dungeon layout, rooms, puzzles, placement
|
||||||
|
src/game/ ← Engine: state, movement, combat, AI, items, transitions
|
||||||
|
src/input/ ← Keyboard tracking, action bindings
|
||||||
|
src/rendering/ ← Canvas rendering: tiles, entities, HUD, effects
|
||||||
|
src/menu/ ← Title screen, dungeon select, inventory overlay, end screens
|
||||||
|
src/save/ ← localStorage save/load
|
||||||
|
```
|
||||||
|
|
||||||
|
Grid: 32×24 tiles, 32px each. Canvas: 640×480.
|
||||||
|
|
||||||
|
## Known Bug
|
||||||
|
|
||||||
|
`src/app.js:390` — The `init()` function's closing `}` appears before lines that should be inside it (screen setup, game loop start, debug logging). Lines 392-406 are outside the function scope. Move the closing brace to after line 406 to fix.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- All source files start with `'use strict'`
|
||||||
|
- Constants live in `src/core/constants.js`; other modules import via `require`
|
||||||
|
- `gameState` and `inputSystem` are module-level globals exposed to `window` by `app.js`
|
||||||
|
- `ctx`, `debugCtx`, `menuOverlay` are exposed to `window` by `app.js` for cross-module rendering
|
||||||
|
- Screen states use string constants: `SCREEN_TITLE`, `SCREEN_DUNGEON_SELECT`, etc.
|
||||||
|
- Debug mode: append `?debug=1` to URL; `F1` toggles overlay in-game
|
||||||
|
- Console API (browser): `_genDungeon(id)`, `spawnEnemy(type)`, `giveWeapon(name)`, `teleportToRoom(idx)`, `dumpState()`
|
||||||
225
PLAN.md
Normal file
225
PLAN.md
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
# Zelda Dungeon Expansion — Plan Document
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Expand from 3-room prototype to **100 procedurally-generated dungeons**, each with **20-100 rooms**, **20+ enemy types**, **12 weapons**, **100 unique bosses**, dungeon selection menu, save system, and sequential progression.
|
||||||
|
|
||||||
|
## Target: Browser-First
|
||||||
|
- Runs in a standard web browser (no Electron dependency for testing)
|
||||||
|
- Electron wrapper added later via existing `main.js` / `preload.js`
|
||||||
|
- Playtesting via `npx serve` or simple HTTP server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
```
|
||||||
|
zelda-dungeon/
|
||||||
|
├── index.html ← Main entry point (browser), loads all scripts
|
||||||
|
├── main.js ← Electron main process (untouched for now)
|
||||||
|
├── preload.js ← Electron IPC bridge (updated for save data)
|
||||||
|
├── package.json ← Add "dev" script for browser testing
|
||||||
|
│
|
||||||
|
├── src/
|
||||||
|
│ ├── core/
|
||||||
|
│ │ ├── constants.js ← TILE, COLS, ROWS, tile types, timing, COLORS
|
||||||
|
│ │ ├── rng.js ← Seeded RNG (mulberry32) for reproducibility
|
||||||
|
│ │ └── utils.js ← Helper functions (createBorderLayout, drawHeart, etc.)
|
||||||
|
│ │
|
||||||
|
│ ├── data/
|
||||||
|
│ │ ├── themes.js ← Dungeon themes (Forest, Desert, Mountain, etc.) with colors
|
||||||
|
│ │ ├── dungeons.js ← 100 dungeon definitions (name, theme, difficulty, seed, room count)
|
||||||
|
│ │ ├── enemies.js ← 22 enemy type definitions (stats, AI, draw functions)
|
||||||
|
│ │ ├── bosses.js ← 100 boss definitions (one per dungeon)
|
||||||
|
│ │ ├── weapons.js ← 12 weapon definitions (damage, range, effects, draw functions)
|
||||||
|
│ │ └── items.js ← Consumables (potions, heart pieces, arrows, bombs, keys)
|
||||||
|
│ │
|
||||||
|
│ ├── generator/
|
||||||
|
│ │ ├── dungeon.js ← Generates full dungeon from definition (rooms + connections)
|
||||||
|
│ │ ├── rooms.js ← Generates individual rooms (layout, enemies, items, puzzles)
|
||||||
|
│ │ ├── puzzles.js ← Puzzle generation (rock-push, levers, keys, switches)
|
||||||
|
│ │ └── placement.js ← Placement logic (enemy/item density by difficulty tier)
|
||||||
|
│ │
|
||||||
|
│ ├── game/
|
||||||
|
│ │ ├── state.js ← Game state management (player, room, dungeon, menus)
|
||||||
|
│ │ ├── combat.js ← Attack resolution, damage, effects
|
||||||
|
│ │ ├── movement.js ← Player movement, rock pushing, exit handling
|
||||||
|
│ │ ├── ai.js ← Enemy AI (chase, patrol, shoot, special behaviors)
|
||||||
|
│ │ ├── items.js ← Item pickup, chest opening, inventory management
|
||||||
|
│ │ └── transitions.js ← Room transitions, screen fades
|
||||||
|
│ │
|
||||||
|
│ ├── rendering/
|
||||||
|
│ │ ├── renderer.js ← Canvas drawing (floor, walls, torches, entities)
|
||||||
|
│ │ ├── draw-enemies.js ← Per-enemy-type draw functions
|
||||||
|
│ │ ├── draw-bosses.js ← Boss draw functions (larger, multi-phase visuals)
|
||||||
|
│ │ ├── draw-weapons.js ← Weapon draw functions
|
||||||
|
│ │ ├── hud.js ← HUD rendering (hearts, weapon, ammo, room info)
|
||||||
|
│ │ └── effects.js ← Particles, hit flashes, screen transitions
|
||||||
|
│ │
|
||||||
|
│ ├── input/
|
||||||
|
│ │ ├── keyboard.js ← Key state tracking, action binding
|
||||||
|
│ │ └── actions.js ← Action definitions (move, attack, use item, select)
|
||||||
|
│ │
|
||||||
|
│ ├── menu/
|
||||||
|
│ │ ├── title-screen.js ← Title screen rendering + interaction
|
||||||
|
│ │ ├── dungeon-select.js ← Dungeon grid, filtering, info panel
|
||||||
|
│ │ ├── hud-overlay.js ← In-game menu (inventory, weapon switch)
|
||||||
|
│ │ └── screens.js ← Death/win/completion screens
|
||||||
|
│ │
|
||||||
|
│ ├── save/
|
||||||
|
│ │ └── save.js ← localStorage save/load, progress tracking
|
||||||
|
│ │
|
||||||
|
│ └── app.js ← Main bootstrap (init, game loop, screen switching)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Core Infrastructure
|
||||||
|
- [ ] Create directory structure
|
||||||
|
- [ ] `constants.js` — All shared constants (TILE, COLS, ROWS, COLORS, timing)
|
||||||
|
- [ ] `rng.js` — Seeded PRNG (mulberry32)
|
||||||
|
- [ ] `utils.js` — Reusable helpers (createBorderLayout, drawHeart, DIRS, etc.)
|
||||||
|
- [ ] `index.html` — New HTML that loads all scripts, canvas, menu div
|
||||||
|
- [ ] `app.js` — Bootstrap: init → title screen → game loop
|
||||||
|
- [ ] Validate: Open in browser, see title screen
|
||||||
|
|
||||||
|
### Phase 2: Data Definitions
|
||||||
|
- [ ] `themes.js` — 8 themes (Forest, Desert, Mountain, Underground, Ocean, Sky, Shadow, Final) with color palettes
|
||||||
|
- [ ] `dungeons.js` — 100 dungeon definitions (generated procedurally from themes)
|
||||||
|
- [ ] `enemies.js` — 22 enemy types with stats, behaviors, colors, draw functions
|
||||||
|
- [ ] `weapons.js` — 12 weapons with damage, range, cooldown, effects
|
||||||
|
- [ ] `bosses.js` — 100 bosses (one per dungeon), template-based with variations
|
||||||
|
- [ ] `items.js` — Consumables definitions
|
||||||
|
- [ ] Validate: Data files load without errors, can inspect in console
|
||||||
|
|
||||||
|
### Phase 3: Procedural Generation
|
||||||
|
- [ ] `generator/dungeon.js` — BSP/DFS room layout generation from seed
|
||||||
|
- [ ] `generator/rooms.js` — Room type assignment, layout generation
|
||||||
|
- [ ] `generator/puzzles.js` — Puzzle generation (rock-push, levers, keys)
|
||||||
|
- [ ] `generator/placement.js` — Enemy/item placement by difficulty tier
|
||||||
|
- [ ] Validate: Generate dungeon #1, inspect rooms, verify connectivity
|
||||||
|
|
||||||
|
### Phase 4: Game Engine
|
||||||
|
- [ ] `game/state.js` — Game state (player, current room, inventory, progress)
|
||||||
|
- [ ] `game/movement.js` — Player movement, rock pushing, collision
|
||||||
|
- [ ] `game/combat.js` — Attack resolution, weapon effects, damage
|
||||||
|
- [ ] `game/ai.js` — Enemy AI (chase, patrol, ranged, special)
|
||||||
|
- [ ] `game/items.js` — Chest opening, item pickup, inventory
|
||||||
|
- [ ] `game/transitions.js` — Room transitions, screen fades
|
||||||
|
- [ ] `input/keyboard.js` — Key tracking, action dispatch
|
||||||
|
- [ ] `input/actions.js` — Action definitions
|
||||||
|
- [ ] Validate: Play through generated dungeon #1 in browser
|
||||||
|
|
||||||
|
### Phase 5: Rendering
|
||||||
|
- [ ] `rendering/renderer.js` — Floor, walls, torches (theme-colored)
|
||||||
|
- [ ] `rendering/draw-enemies.js` — All 22 enemy draw functions
|
||||||
|
- [ ] `rendering/draw-bosses.js` — Boss draw functions
|
||||||
|
- [ ] `rendering/draw-weapons.js` — Weapon visual indicators
|
||||||
|
- [ ] `rendering/hud.js` — Hearts, weapon icon, ammo, room counter
|
||||||
|
- [ ] `rendering/effects.js` — Particles, hit flash, screen transitions
|
||||||
|
- [ ] Validate: Visual rendering looks correct for all entity types
|
||||||
|
|
||||||
|
### Phase 6: Menu System
|
||||||
|
- [ ] `menu/title-screen.js` — Title, start button, controls
|
||||||
|
- [ ] `menu/dungeon-select.js` — 10x10 grid, theme filter, difficulty, lock status
|
||||||
|
- [ ] `menu/screens.js` — Death, win, dungeon completion screens
|
||||||
|
- [ ] Validate: Navigate menus, select dungeon, enter game
|
||||||
|
|
||||||
|
### Phase 7: Save System
|
||||||
|
- [ ] `save/save.js` — localStorage persistence
|
||||||
|
- [ ] Save format: `{ completedDungeons: [], weapons: [], heartPieces: N, totalRooms: N }`
|
||||||
|
- [ ] Auto-save on dungeon completion
|
||||||
|
- [ ] Resume functionality
|
||||||
|
- [ ] Validate: Complete dungeon → close browser → reopen → progress saved
|
||||||
|
|
||||||
|
### Phase 8: Integration & Polish
|
||||||
|
- [ ] Connect all systems end-to-end
|
||||||
|
- [ ] Playtest first 10 dungeons
|
||||||
|
- [ ] Balance difficulty curve
|
||||||
|
- [ ] Screen transition effects
|
||||||
|
- [ ] Error handling, edge cases
|
||||||
|
- [ ] Validate: Full playthrough dungeon #1 → #10
|
||||||
|
|
||||||
|
### Phase 9: Electron Integration (Later)
|
||||||
|
- [ ] Update `preload.js` — expose save/load IPC
|
||||||
|
- [ ] Update `main.js` — if needed
|
||||||
|
- [ ] Validate: `npm start` works in Electron
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Decisions
|
||||||
|
|
||||||
|
### Seeded RNG
|
||||||
|
- Mulberry32 algorithm: fast, deterministic, good quality for game generation
|
||||||
|
- Same seed → same dungeon every time
|
||||||
|
- Seeds stored in `dungeons.js` definitions
|
||||||
|
|
||||||
|
### Room Generation Algorithm
|
||||||
|
- Randomized DFS (depth-first search) for organic-feeling dungeons
|
||||||
|
- Room sizes: 5x5 to 12x12 tiles
|
||||||
|
- Corridors connect rooms (1-tile wide, L-shaped)
|
||||||
|
- Room types assigned by dungeon phase (early/mid/late)
|
||||||
|
|
||||||
|
### Difficulty Scaling
|
||||||
|
- Dungeon difficulty: 1-10 (based on index)
|
||||||
|
- Room count: 20 (difficulty 1-2) → 100 (difficulty 10)
|
||||||
|
- Enemy density: 0-1 per room (easy) → 2-4 per room (hard)
|
||||||
|
- Enemy types unlocked by difficulty tier
|
||||||
|
- Boss HP scales with dungeon difficulty
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
- Single `game` object with nested state
|
||||||
|
- Screen states: `TITLE`, `DUNGEON_SELECT`, `PLAYING`, `DEAD`, `DUNGEON_COMPLETE`, `GAME_COMPLETE`
|
||||||
|
- Room data regenerated from seed on entry (no permanent room state)
|
||||||
|
- Player state persists across rooms within a dungeon
|
||||||
|
- Inventory persists across dungeons
|
||||||
|
|
||||||
|
### Combat System
|
||||||
|
- Weapon-based damage (varies by weapon type)
|
||||||
|
- Attack cooldowns per weapon
|
||||||
|
- Ranged weapons (bow, slingshot, rods) have projectile system
|
||||||
|
- Special effects: fire (AoE), ice (freeze), boomerang (return), bombs (destroy walls)
|
||||||
|
- Enemy HP, defense, attack damage, attack cooldown
|
||||||
|
|
||||||
|
### Enemy AI Behaviors
|
||||||
|
- `chase`: Pathfind toward player (existing behavior)
|
||||||
|
- `patrol`: Wander predefined path
|
||||||
|
- `ranged`: Shoot projectiles at player
|
||||||
|
- `stationary`: Activate on proximity
|
||||||
|
- `charge`: Rush at player in straight line
|
||||||
|
- `teleport`: Random teleportation
|
||||||
|
- `explode`: Explode on contact or timer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Playtesting Hooks
|
||||||
|
|
||||||
|
### Debug Mode
|
||||||
|
- `?debug=1` URL parameter enables debug overlay
|
||||||
|
- Shows: room grid, collision boxes, AI state, generation seed
|
||||||
|
- `F1` toggles debug overlay during gameplay
|
||||||
|
- `F2` forces next room (skip puzzles)
|
||||||
|
- `F3` god mode (invincible)
|
||||||
|
- `F4` spawn debug enemy at player position
|
||||||
|
|
||||||
|
### Console API
|
||||||
|
- `generateDungeon(id)` — Generate and inspect any dungeon
|
||||||
|
- `spawnEnemy(type)` — Spawn enemy at player position
|
||||||
|
- `giveWeapon(name)` — Add weapon to inventory
|
||||||
|
- `teleportToRoom(idx)` — Jump to room by index
|
||||||
|
- `dumpState()` — Print full game state to console
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Room rendering only draws visible tiles + buffer
|
||||||
|
- Enemy AI updates throttled by type
|
||||||
|
- Particle system capped at 100 particles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Browser Testing
|
||||||
|
|
||||||
|
- `npm run dev` → `npx serve .` for local HTTP server
|
||||||
|
- Open `http://localhost:3000` in browser
|
||||||
|
- `npm start` → Electron app (for later)
|
||||||
549
generate-bosses.js
Normal file
549
generate-bosses.js
Normal file
@ -0,0 +1,549 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const { DUNGEONS } = require('./src/data/dungeons');
|
||||||
|
|
||||||
|
const themeColors = {
|
||||||
|
stone: { body: '#696969', eye: '#FFD700', accent: '#A9A9A9' },
|
||||||
|
forest: { body: '#228B22', eye: '#90EE90', accent: '#006400' },
|
||||||
|
desert: { body: '#DAA520', eye: '#8B4513', accent: '#FFD700' },
|
||||||
|
mountain: { body: '#4682B4', eye: '#E0E0E0', accent: '#87CEEB' },
|
||||||
|
underground: { body: '#4B0082', eye: '#DA70D6', accent: '#9400D3' },
|
||||||
|
ocean: { body: '#006994', eye: '#00CED1', accent: '#4682B4' },
|
||||||
|
sky: { body: '#FFD700', eye: '#FFFACD', accent: '#FF8C00' },
|
||||||
|
shadow: { body: '#1A0A2A', eye: '#9400D3', accent: '#4B0082' },
|
||||||
|
final: { body: '#8B0000', eye: '#FFD700', accent: '#FF4500' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const templateAssignments = {
|
||||||
|
0: 'gleeok', 11: 'gleeok', 22: 'gleeok', 33: 'gleeok', 44: 'gleeok',
|
||||||
|
55: 'gleeok', 66: 'gleeok', 77: 'gleeok', 88: 'gleeok', 99: 'gleeok',
|
||||||
|
1: 'darknut', 10: 'darknut', 21: 'darknut', 32: 'darknut', 43: 'darknut',
|
||||||
|
54: 'darknut', 65: 'darknut', 76: 'darknut', 87: 'darknut', 98: 'darknut',
|
||||||
|
2: 'wizzrobe', 12: 'wizzrobe', 23: 'wizzrobe', 34: 'wizzrobe', 45: 'wizzrobe',
|
||||||
|
56: 'wizzrobe', 67: 'wizzrobe', 78: 'wizzrobe', 89: 'wizzrobe', 97: 'wizzrobe',
|
||||||
|
3: 'moldorm', 13: 'moldorm', 24: 'moldorm', 35: 'moldorm', 46: 'moldorm',
|
||||||
|
57: 'moldorm', 68: 'moldorm', 79: 'moldorm', 90: 'moldorm', 96: 'moldorm',
|
||||||
|
4: 'genie', 14: 'genie', 25: 'genie', 36: 'genie', 47: 'genie',
|
||||||
|
58: 'genie', 69: 'genie', 80: 'genie', 91: 'genie', 95: 'genie',
|
||||||
|
5: 'liklike', 15: 'liklike', 26: 'liklike', 37: 'liklike', 48: 'liklike',
|
||||||
|
59: 'liklike', 70: 'liklike', 81: 'liklike', 92: 'liklike', 94: 'liklike',
|
||||||
|
6: 'aquamentus', 16: 'aquamentus', 27: 'aquamentus', 38: 'aquamentus', 49: 'aquamentus',
|
||||||
|
60: 'aquamentus', 71: 'aquamentus', 82: 'aquamentus', 93: 'aquamentus',
|
||||||
|
7: 'manhandla', 17: 'manhandla', 28: 'manhandla', 39: 'manhandla', 50: 'manhandla',
|
||||||
|
61: 'manhandla', 72: 'manhandla', 83: 'manhandla',
|
||||||
|
8: 'gleeok_variant', 18: 'gleeok_variant', 29: 'gleeok_variant', 40: 'gleeok_variant',
|
||||||
|
51: 'gleeok_variant', 62: 'gleeok_variant', 73: 'gleeok_variant', 84: 'gleeok_variant',
|
||||||
|
9: 'agahnim', 19: 'agahnim', 30: 'agahnim', 41: 'agahnim', 52: 'agahnim',
|
||||||
|
63: 'agahnim', 74: 'agahnim', 85: 'agahnim',
|
||||||
|
// Fill remaining gaps not covered by the mod-11 pattern
|
||||||
|
20: 'genie', 31: 'liklike', 42: 'moldorm', 53: 'wizzrobe', 64: 'darknut', 75: 'gleeok',
|
||||||
|
86: 'moldorm',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify all 100 indices covered
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
if (!templateAssignments[i]) {
|
||||||
|
console.error('ERROR: No template for index ' + i);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameTemplates = {
|
||||||
|
gleeok: {
|
||||||
|
stone: 'Stone Gleeok', forest: 'Verdant Gleeok', desert: 'Sand Gleeok',
|
||||||
|
mountain: 'Frost Gleeok', underground: 'Shadow Gleeok', ocean: 'Tidal Gleeok',
|
||||||
|
sky: 'Storm Gleeok', shadow: 'Dark Gleeok', final: 'Infernal Gleeok',
|
||||||
|
},
|
||||||
|
darknut: {
|
||||||
|
stone: 'Stone Darknut', forest: 'Green Darknut', desert: 'Sand Darknut',
|
||||||
|
mountain: 'Iron Darknut', underground: 'Deep Darknut', ocean: 'Abyssal Darknut',
|
||||||
|
sky: 'Sky Darknut', shadow: 'Shadow Darknut', final: 'Demon Darknut',
|
||||||
|
},
|
||||||
|
wizzrobe: {
|
||||||
|
stone: 'Stone Wizzrobe', forest: 'Thorn Wizzrobe', desert: 'Mirage Wizzrobe',
|
||||||
|
mountain: 'Frost Wizzrobe', underground: 'Crystal Wizzrobe', ocean: 'Tide Wizzrobe',
|
||||||
|
sky: 'Storm Wizzrobe', shadow: 'Void Wizzrobe', final: 'Dark Wizzrobe',
|
||||||
|
},
|
||||||
|
moldorm: {
|
||||||
|
stone: 'Stone Moldorm', forest: 'Root Moldorm', desert: 'Dune Moldorm',
|
||||||
|
mountain: 'Frost Moldorm', underground: 'Deep Moldorm', ocean: 'Abyssal Moldorm',
|
||||||
|
sky: 'Cloud Moldorm', shadow: 'Shadow Moldorm', final: 'Abyssal Moldorm',
|
||||||
|
},
|
||||||
|
genie: {
|
||||||
|
stone: 'Stone Genie', forest: 'Moss Genie', desert: 'Sand Genie',
|
||||||
|
mountain: 'Ice Genie', underground: 'Crystal Genie', ocean: 'Tidal Genie',
|
||||||
|
sky: 'Sky Genie', shadow: 'Dark Genie', final: 'Demon Genie',
|
||||||
|
},
|
||||||
|
liklike: {
|
||||||
|
stone: 'Stone Like Like', forest: 'Moss Like Like', desert: 'Dune Like Like',
|
||||||
|
mountain: 'Frost Like Like', underground: 'Deep Like Like', ocean: 'Abyssal Like Like',
|
||||||
|
sky: 'Cloud Like Like', shadow: 'Shadow Like Like', final: 'Abyssal Like Like',
|
||||||
|
},
|
||||||
|
aquamentus: {
|
||||||
|
stone: 'Stone Aquamentus', forest: 'Verdant Aquamentus', desert: 'Sand Aquamentus',
|
||||||
|
mountain: 'Frost Aquamentus', underground: 'Crystal Aquamentus', ocean: 'Tidal Aquamentus',
|
||||||
|
sky: 'Storm Aquamentus', shadow: 'Dark Aquamentus', final: 'Infernal Aquamentus',
|
||||||
|
},
|
||||||
|
manhandla: {
|
||||||
|
stone: 'Stone Manhandla', forest: 'Verdant Manhandla', desert: 'Dune Manhandla',
|
||||||
|
mountain: 'Frost Manhandla', underground: 'Crystal Manhandla', ocean: 'Abyssal Manhandla',
|
||||||
|
sky: 'Storm Manhandla', shadow: 'Dark Manhandla', final: 'Infernal Manhandla',
|
||||||
|
},
|
||||||
|
gleeok_variant: {
|
||||||
|
stone: 'Stone Ice Gleeok', forest: 'Forest Ice Gleeok', desert: 'Desert Ice Gleeok',
|
||||||
|
mountain: 'Glacier Gleeok', underground: 'Shadow Ice Gleeok', ocean: 'Abyssal Water Gleeok',
|
||||||
|
sky: 'Sky Ice Gleeok', shadow: 'Dark Ice Gleeok', final: 'Infernal Ice Gleeok',
|
||||||
|
},
|
||||||
|
agahnim: {
|
||||||
|
stone: 'Stone Agahnim', forest: 'Verdant Agahnim', desert: 'Sand Agahnim',
|
||||||
|
mountain: 'Frost Agahnim', underground: 'Crystal Agahnim', ocean: 'Tidal Agahnim',
|
||||||
|
sky: 'Storm Agahnim', shadow: 'Void Agahnim', final: 'Dark Agahnim',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function getStatsByDifficulty(diff) {
|
||||||
|
if (diff <= 2) return { hp: 20 + (diff - 1) * 10, damage: 3 + (diff - 1), defense: diff - 1 };
|
||||||
|
if (diff <= 4) return { hp: 40 + (diff - 3) * 20, damage: 4 + (diff - 3), defense: Math.min(diff - 2, 2) };
|
||||||
|
if (diff <= 6) return { hp: 80 + (diff - 5) * 35, damage: 5 + (diff - 5), defense: Math.min(diff - 4, 3) };
|
||||||
|
if (diff <= 8) return { hp: 150 + (diff - 7) * 50, damage: 6 + (diff - 7) * 2, defense: Math.min(diff - 5, 4) };
|
||||||
|
return { hp: 250 + (diff - 9) * 250, damage: 8 + (diff - 9) * 7, defense: Math.min(diff - 6, 5) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePhases(template, diff, baseDamage) {
|
||||||
|
const phases = [];
|
||||||
|
const scaledDmg = Math.round(baseDamage * 1.3);
|
||||||
|
|
||||||
|
switch (template) {
|
||||||
|
case 'gleeok':
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 100,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'aoe', damage: scaledDmg, cooldown: 2000, count: 2, spreadAngle: 360, projectileSpeed: 2 },
|
||||||
|
{ type: 'melee', damage: baseDamage, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (diff >= 4) {
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 60,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'aoe', damage: scaledDmg + 1, cooldown: 1800, count: 3, spreadAngle: 360, projectileSpeed: 2.5 },
|
||||||
|
{ type: 'melee', damage: baseDamage + 1, cooldown: 1200, count: 1, spreadAngle: 0, projectileSpeed: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (diff >= 7) {
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 30,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'aoe', damage: scaledDmg + 2, cooldown: 1200, count: 4, spreadAngle: 360, projectileSpeed: 3 },
|
||||||
|
{ type: 'melee', damage: baseDamage + 2, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'gleeok_variant':
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 100,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'aoe', damage: scaledDmg, cooldown: 2200, count: 2, spreadAngle: 360, projectileSpeed: 1.5 },
|
||||||
|
{ type: 'melee', damage: baseDamage, cooldown: 1800, count: 1, spreadAngle: 0, projectileSpeed: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (diff >= 4) {
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 60,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'aoe', damage: scaledDmg + 1, cooldown: 2000, count: 3, spreadAngle: 360, projectileSpeed: 2 },
|
||||||
|
{ type: 'melee', damage: baseDamage + 1, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (diff >= 7) {
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 30,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'aoe', damage: scaledDmg + 2, cooldown: 1400, count: 4, spreadAngle: 360, projectileSpeed: 2.5 },
|
||||||
|
{ type: 'melee', damage: baseDamage + 2, cooldown: 1100, count: 1, spreadAngle: 0, projectileSpeed: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'darknut':
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 100,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'melee', damage: baseDamage, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 50,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'melee', damage: scaledDmg, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0 },
|
||||||
|
{ type: 'melee', damage: scaledDmg, cooldown: 1500, count: 3, spreadAngle: 120, projectileSpeed: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (diff >= 6) {
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 25,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'melee', damage: scaledDmg + 2, cooldown: 600, count: 1, spreadAngle: 0, projectileSpeed: 0 },
|
||||||
|
{ type: 'aoe', damage: baseDamage, cooldown: 2000, count: 4, spreadAngle: 180, projectileSpeed: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'wizzrobe':
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 100,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'projectile', damage: scaledDmg, cooldown: 1500, count: 2, spreadAngle: 60, projectileSpeed: 3 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (diff >= 3) {
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 40,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'projectile', damage: scaledDmg + 1, cooldown: 1200, count: 3, spreadAngle: 90, projectileSpeed: 3.5 },
|
||||||
|
{ type: 'summon', damage: 0, cooldown: 4000, count: 2, spreadAngle: 0, projectileSpeed: 0, enemyType: diff < 6 ? 'keese' : 'stalfos' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'moldorm':
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 100,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'melee', damage: baseDamage, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 30,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'aoe', damage: scaledDmg, cooldown: 3000, count: 1, spreadAngle: 360, projectileSpeed: 0 },
|
||||||
|
{ type: 'melee', damage: scaledDmg, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'genie':
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 100,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'projectile', damage: scaledDmg, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 2.5 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (diff >= 3) {
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 50,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'projectile', damage: scaledDmg, cooldown: 1200, count: 2, spreadAngle: 90, projectileSpeed: 2.5 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (diff >= 6) {
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 20,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'aoe', damage: scaledDmg + 1, cooldown: 1000, count: 3, spreadAngle: 120, projectileSpeed: 3 },
|
||||||
|
{ type: 'projectile', damage: scaledDmg, cooldown: 800, count: 2, spreadAngle: 45, projectileSpeed: 3.5 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'liklike':
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 100,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'melee', damage: baseDamage, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (diff >= 3) {
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 40,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'melee', damage: scaledDmg, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0 },
|
||||||
|
{ type: 'summon', damage: 0, cooldown: 5000, count: 2, spreadAngle: 0, projectileSpeed: 0, enemyType: 'slime' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'aquamentus':
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 100,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'projectile', damage: scaledDmg, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 2 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 50,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'aoe', damage: scaledDmg, cooldown: 1800, count: 3, spreadAngle: 120, projectileSpeed: 2.5 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (diff >= 6) {
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 20,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'aoe', damage: scaledDmg + 2, cooldown: 1200, count: 3, spreadAngle: 180, projectileSpeed: 3 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'manhandla':
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 100,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'melee', damage: baseDamage, cooldown: 1200, count: 4, spreadAngle: 360, projectileSpeed: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (diff >= 3) {
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 30,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'aoe', damage: scaledDmg, cooldown: 2000, count: 4, spreadAngle: 360, projectileSpeed: 2 },
|
||||||
|
{ type: 'melee', damage: scaledDmg, cooldown: 800, count: 4, spreadAngle: 360, projectileSpeed: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'agahnim':
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 100,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'projectile', damage: scaledDmg, cooldown: 1500, count: 3, spreadAngle: 90, projectileSpeed: 2.5 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 60,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'melee', damage: scaledDmg, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0 },
|
||||||
|
{ type: 'projectile', damage: scaledDmg, cooldown: 1200, count: 3, spreadAngle: 90, projectileSpeed: 2.5 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 30,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'projectile', damage: scaledDmg, cooldown: 1500, count: 3, spreadAngle: 90, projectileSpeed: 2.5 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
phases.push({
|
||||||
|
hpThreshold: 10,
|
||||||
|
attacks: [
|
||||||
|
{ type: 'melee', damage: scaledDmg + 2, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0 },
|
||||||
|
{ type: 'aoe', damage: scaledDmg, cooldown: 1500, count: 4, spreadAngle: 180, projectileSpeed: 3 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return phases;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateDrops(template, diff) {
|
||||||
|
const drops = [{ item: 'heart_piece', weight: 100 }];
|
||||||
|
|
||||||
|
if (diff >= 2) drops.push({ item: 'healing_potion', weight: 30 });
|
||||||
|
if (diff >= 4) drops.push({ item: 'super_potion', weight: 15 });
|
||||||
|
if (diff >= 6) drops.push({ item: 'magic_potion', weight: 10 });
|
||||||
|
if (diff >= 8) drops.push({ item: 'heart_piece', weight: 20 });
|
||||||
|
|
||||||
|
switch (template) {
|
||||||
|
case 'gleeok':
|
||||||
|
case 'gleeok_variant':
|
||||||
|
if (diff >= 5) drops.push({ item: 'fire_rod', weight: 5 });
|
||||||
|
if (diff >= 8) drops.push({ item: 'master_sword', weight: 3 });
|
||||||
|
break;
|
||||||
|
case 'darknut':
|
||||||
|
if (diff >= 3) drops.push({ item: 'hammer', weight: 8 });
|
||||||
|
if (diff >= 7) drops.push({ item: 'magic_sword', weight: 3 });
|
||||||
|
break;
|
||||||
|
case 'wizzrobe':
|
||||||
|
if (diff >= 4) drops.push({ item: 'ice_rod', weight: 5 });
|
||||||
|
if (diff >= 7) drops.push({ item: 'magic_potion', weight: 15 });
|
||||||
|
break;
|
||||||
|
case 'agahnim':
|
||||||
|
if (diff >= 6) drops.push({ item: 'magic_sword', weight: 5 });
|
||||||
|
break;
|
||||||
|
case 'aquamentus':
|
||||||
|
if (diff >= 5) drops.push({ item: 'fire_rod', weight: 5 });
|
||||||
|
break;
|
||||||
|
case 'manhandla':
|
||||||
|
if (diff >= 4) drops.push({ item: 'boomerang', weight: 5 });
|
||||||
|
break;
|
||||||
|
case 'moldorm':
|
||||||
|
if (diff >= 6) drops.push({ item: 'hookshot', weight: 3 });
|
||||||
|
break;
|
||||||
|
case 'genie':
|
||||||
|
if (diff >= 5) drops.push({ item: 'magic_crystal', weight: 10 });
|
||||||
|
break;
|
||||||
|
case 'liklike':
|
||||||
|
if (diff >= 3) drops.push({ item: 'super_potion', weight: 20 });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return drops;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateBosses() {
|
||||||
|
const bosses = [];
|
||||||
|
const descriptions = {
|
||||||
|
gleeok: 'A multi-headed dragon whose fiery breath devastates all.',
|
||||||
|
darknut: 'A heavily-armored knight wielding a massive sword.',
|
||||||
|
wizzrobe: 'A teleporting mage who commands destructive magic.',
|
||||||
|
moldorm: 'A giant segmented worm that charges relentlessly.',
|
||||||
|
genie: 'A lamp spirit that unleashes devastating elemental blasts.',
|
||||||
|
liklike: 'A voracious worm that steals hearts and shields.',
|
||||||
|
aquamentus: 'A dragon that breathes flames at everything in sight.',
|
||||||
|
manhandla: 'A spinning plant monster with razor-sharp limbs.',
|
||||||
|
gleeok_variant: 'A spectral dragon wielding ice and shadow.',
|
||||||
|
agahnim: 'A dark mage protected by impenetrable barriers.',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const dungeon = DUNGEONS[i];
|
||||||
|
const template = templateAssignments[i];
|
||||||
|
const theme = dungeon.theme;
|
||||||
|
const diff = dungeon.difficulty;
|
||||||
|
const stats = getStatsByDifficulty(diff);
|
||||||
|
const colors = themeColors[theme];
|
||||||
|
|
||||||
|
let hp = stats.hp;
|
||||||
|
let damage = stats.damage;
|
||||||
|
let defense = stats.defense;
|
||||||
|
let speed = 0;
|
||||||
|
let behavior = 'chase';
|
||||||
|
let drawFunction = 'drawGeneric';
|
||||||
|
|
||||||
|
switch (template) {
|
||||||
|
case 'gleeok':
|
||||||
|
case 'gleeok_variant':
|
||||||
|
hp = Math.round(hp * 1.3);
|
||||||
|
speed = 1.5;
|
||||||
|
behavior = 'charge';
|
||||||
|
drawFunction = 'drawGleeok';
|
||||||
|
break;
|
||||||
|
case 'darknut':
|
||||||
|
defense = Math.min(defense + 1, 5);
|
||||||
|
speed = 1.2;
|
||||||
|
behavior = 'chase';
|
||||||
|
drawFunction = 'drawDarknut';
|
||||||
|
break;
|
||||||
|
case 'wizzrobe':
|
||||||
|
damage = Math.round(damage * 0.8);
|
||||||
|
defense = Math.max(defense - 1, 0);
|
||||||
|
speed = 0;
|
||||||
|
behavior = 'teleport';
|
||||||
|
drawFunction = 'drawWizzrobe';
|
||||||
|
break;
|
||||||
|
case 'moldorm':
|
||||||
|
hp = Math.round(hp * 0.7);
|
||||||
|
speed = 2.0;
|
||||||
|
behavior = 'charge';
|
||||||
|
break;
|
||||||
|
case 'genie':
|
||||||
|
hp = Math.round(hp * 1.1);
|
||||||
|
speed = 0;
|
||||||
|
behavior = 'stationary';
|
||||||
|
break;
|
||||||
|
case 'liklike':
|
||||||
|
hp = Math.round(hp * 0.8);
|
||||||
|
speed = 1.0;
|
||||||
|
behavior = 'chase';
|
||||||
|
break;
|
||||||
|
case 'aquamentus':
|
||||||
|
hp = Math.round(hp * 1.2);
|
||||||
|
speed = 0;
|
||||||
|
behavior = 'stationary';
|
||||||
|
drawFunction = 'drawGleeok';
|
||||||
|
break;
|
||||||
|
case 'manhandla':
|
||||||
|
speed = 1.8;
|
||||||
|
behavior = 'charge';
|
||||||
|
break;
|
||||||
|
case 'agahnim':
|
||||||
|
damage = Math.round(damage * 1.2);
|
||||||
|
defense = Math.min(defense + 1, 5);
|
||||||
|
speed = 0;
|
||||||
|
behavior = 'stationary';
|
||||||
|
drawFunction = 'drawWizzrobe';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = nameTemplates[template] ? nameTemplates[template][theme] : (theme + ' ' + template);
|
||||||
|
const phases = generatePhases(template, diff, damage);
|
||||||
|
const drops = generateDrops(template, diff);
|
||||||
|
|
||||||
|
bosses.push({
|
||||||
|
id: i, name, hp, damage, defense, speed, colors, behavior,
|
||||||
|
drawFunction, phases, drops, description: descriptions[template],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return bosses;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bosses = generateBosses();
|
||||||
|
|
||||||
|
let output = "'use strict';\n\nconst BOSS_TYPES = [\n";
|
||||||
|
|
||||||
|
for (const boss of bosses) {
|
||||||
|
output += ' { id: ' + boss.id + ', ';
|
||||||
|
output += "name: '" + boss.name + "', ";
|
||||||
|
output += 'hp: ' + boss.hp + ', ';
|
||||||
|
output += 'damage: ' + boss.damage + ', ';
|
||||||
|
output += 'defense: ' + boss.defense + ', ';
|
||||||
|
output += 'speed: ' + boss.speed + ', ';
|
||||||
|
output += 'colors: { body: "' + boss.colors.body + '", eye: "' + boss.colors.eye + '", accent: "' + boss.colors.accent + '" }, ';
|
||||||
|
output += 'behavior: "' + boss.behavior + '", ';
|
||||||
|
output += 'drawFunction: "' + boss.drawFunction + '", ';
|
||||||
|
output += 'description: "' + boss.description + '", ';
|
||||||
|
|
||||||
|
output += 'phases: [';
|
||||||
|
for (let p = 0; p < boss.phases.length; p++) {
|
||||||
|
const phase = boss.phases[p];
|
||||||
|
output += '{ hpThreshold: ' + phase.hpThreshold + ', attacks: [';
|
||||||
|
for (let a = 0; a < phase.attacks.length; a++) {
|
||||||
|
const attack = phase.attacks[a];
|
||||||
|
output += '{ type: "' + attack.type + '", damage: ' + attack.damage + ', cooldown: ' + attack.cooldown + ', count: ' + attack.count + ', spreadAngle: ' + attack.spreadAngle + ', projectileSpeed: ' + attack.projectileSpeed;
|
||||||
|
if (attack.enemyType) output += ', enemyType: "' + attack.enemyType + '"';
|
||||||
|
output += '}';
|
||||||
|
if (a < phase.attacks.length - 1) output += ', ';
|
||||||
|
}
|
||||||
|
output += ']}';
|
||||||
|
if (p < boss.phases.length - 1) output += ', ';
|
||||||
|
}
|
||||||
|
output += '], ';
|
||||||
|
|
||||||
|
output += 'drops: [';
|
||||||
|
for (let d = 0; d < boss.drops.length; d++) {
|
||||||
|
const drop = boss.drops[d];
|
||||||
|
output += '{ item: "' + drop.item + '", weight: ' + drop.weight + ' }';
|
||||||
|
if (d < boss.drops.length - 1) output += ', ';
|
||||||
|
}
|
||||||
|
output += ']';
|
||||||
|
output += ' },\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
output += '];\n\n';
|
||||||
|
output += 'function getBossType(id) {\n';
|
||||||
|
output += ' return BOSS_TYPES.find(b => b.id === id);\n';
|
||||||
|
output += '}\n\n';
|
||||||
|
output += 'module.exports = { BOSS_TYPES, getBossType };\n';
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(__dirname, 'src/data/bosses.js'), output);
|
||||||
|
console.log('Generated ' + bosses.length + ' bosses successfully.');
|
||||||
|
|
||||||
|
const { BOSS_TYPES } = require('./src/data/bosses');
|
||||||
|
console.log('Verification: ' + BOSS_TYPES.length + ' bosses loaded.');
|
||||||
263
index.html
Normal file
263
index.html
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Zelda Dungeon Quest</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
background: #111;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: monospace;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
#game-container {
|
||||||
|
position: relative;
|
||||||
|
width: 640px;
|
||||||
|
height: 480px;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
border: 2px solid #555;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
#menu-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 640px;
|
||||||
|
height: 480px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
#menu-overlay.active {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.menu-title {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #FFD700;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.menu-subtitle {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #AAAAAA;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.menu-button {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
border: 2px solid #FFD700;
|
||||||
|
padding: 8px 24px;
|
||||||
|
margin: 4px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.menu-button:hover, .menu-button.selected {
|
||||||
|
background: #FFD700;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.menu-button:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
.menu-button:disabled:hover {
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
.dungeon-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(10, 1fr);
|
||||||
|
gap: 3px;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.dungeon-cell {
|
||||||
|
width: 56px;
|
||||||
|
height: 42px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 42px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #555;
|
||||||
|
transition: all 0.1s;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.dungeon-cell:hover:not(.locked) {
|
||||||
|
transform: scale(1.1);
|
||||||
|
border-color: #FFD700;
|
||||||
|
}
|
||||||
|
.dungeon-cell.selected {
|
||||||
|
border: 2px solid #FFD700;
|
||||||
|
box-shadow: 0 0 6px rgba(255,215,0,0.5);
|
||||||
|
}
|
||||||
|
.dungeon-cell.locked {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.dungeon-cell.completed {
|
||||||
|
border-color: #00FF00;
|
||||||
|
}
|
||||||
|
.dungeon-cell.completed::after {
|
||||||
|
content: '✓';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 1px;
|
||||||
|
right: 2px;
|
||||||
|
font-size: 8px;
|
||||||
|
color: #00FF00;
|
||||||
|
}
|
||||||
|
.filter-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.filter-tab {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
border: 1px solid #555;
|
||||||
|
color: #AAA;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.filter-tab.active {
|
||||||
|
background: #FFD700;
|
||||||
|
color: #000;
|
||||||
|
border-color: #FFD700;
|
||||||
|
}
|
||||||
|
.info-panel {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #CCC;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
border: 1px solid #555;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.info-panel .name {
|
||||||
|
color: #FFD700;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.info-panel .difficulty {
|
||||||
|
color: #FF6347;
|
||||||
|
}
|
||||||
|
.info-panel .theme {
|
||||||
|
color: #87CEEB;
|
||||||
|
}
|
||||||
|
.info-panel .rooms {
|
||||||
|
color: #90EE90;
|
||||||
|
}
|
||||||
|
.controls-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
.debug-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 640px;
|
||||||
|
height: 480px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 20;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #0F0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.debug-overlay.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
#debug-canvas {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 19;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#debug-canvas.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="game-container">
|
||||||
|
<canvas id="game" width="640" height="480"></canvas>
|
||||||
|
<canvas id="debug-canvas" width="640" height="480"></canvas>
|
||||||
|
<div id="menu-overlay"></div>
|
||||||
|
<div id="debug-overlay" class="debug-overlay"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Browser polyfill: enables module.exports/require in browser -->
|
||||||
|
<script src="src/browser-polyfill.js"></script>
|
||||||
|
|
||||||
|
<!-- Core -->
|
||||||
|
<script src="src/core/constants.js"></script>
|
||||||
|
<script src="src/core/rng.js"></script>
|
||||||
|
<script src="src/core/utils.js"></script>
|
||||||
|
|
||||||
|
<!-- Data -->
|
||||||
|
<script src="src/data/themes.js"></script>
|
||||||
|
<script src="src/data/dungeons.js"></script>
|
||||||
|
<script src="src/data/enemies.js"></script>
|
||||||
|
<script src="src/data/weapons.js"></script>
|
||||||
|
<script src="src/data/bosses.js"></script>
|
||||||
|
<script src="src/data/items.js"></script>
|
||||||
|
|
||||||
|
<!-- Generator -->
|
||||||
|
<script src="src/generator/rooms.js"></script>
|
||||||
|
<script src="src/generator/puzzles.js"></script>
|
||||||
|
<script src="src/generator/placement.js"></script>
|
||||||
|
<script src="src/generator/dungeon.js"></script>
|
||||||
|
|
||||||
|
<!-- Save -->
|
||||||
|
<script src="src/save/save.js"></script>
|
||||||
|
|
||||||
|
<!-- Game Engine -->
|
||||||
|
<script src="src/game/state.js"></script>
|
||||||
|
<script src="src/game/movement.js"></script>
|
||||||
|
<script src="src/game/combat.js"></script>
|
||||||
|
<script src="src/game/ai.js"></script>
|
||||||
|
<script src="src/game/items.js"></script>
|
||||||
|
<script src="src/game/transitions.js"></script>
|
||||||
|
|
||||||
|
<!-- Input -->
|
||||||
|
<script src="src/input/keyboard.js"></script>
|
||||||
|
<script src="src/input/actions.js"></script>
|
||||||
|
|
||||||
|
<!-- Rendering -->
|
||||||
|
<script src="src/rendering/renderer.js"></script>
|
||||||
|
<script src="src/rendering/draw-enemies.js"></script>
|
||||||
|
<script src="src/rendering/draw-bosses.js"></script>
|
||||||
|
<script src="src/rendering/draw-weapons.js"></script>
|
||||||
|
<script src="src/rendering/hud.js"></script>
|
||||||
|
<script src="src/rendering/effects.js"></script>
|
||||||
|
|
||||||
|
<!-- Menu -->
|
||||||
|
<script src="src/menu/title-screen.js"></script>
|
||||||
|
<script src="src/menu/dungeon-select.js"></script>
|
||||||
|
<script src="src/menu/hud-overlay.js"></script>
|
||||||
|
<script src="src/menu/screens.js"></script>
|
||||||
|
|
||||||
|
<!-- Bootstrap -->
|
||||||
|
<script src="src/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
22
main.js
Normal file
22
main.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
const { app, BrowserWindow } = require("electron");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
preload: path.join(__dirname, "preload.js"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
win.loadFile(path.join(__dirname, "index.html"));
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(createWindow);
|
||||||
|
|
||||||
|
app.on("window-all-closed", () => {
|
||||||
|
if (process.platform !== "darwin") app.quit();
|
||||||
|
});
|
||||||
801
package-lock.json
generated
Normal file
801
package-lock.json
generated
Normal file
@ -0,0 +1,801 @@
|
|||||||
|
{
|
||||||
|
"name": "zelda-dungeon",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "zelda-dungeon",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"electron": "^28.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@electron/get": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.1.1",
|
||||||
|
"env-paths": "^2.2.0",
|
||||||
|
"fs-extra": "^8.1.0",
|
||||||
|
"got": "^11.8.5",
|
||||||
|
"progress": "^2.0.3",
|
||||||
|
"semver": "^6.2.0",
|
||||||
|
"sumchecker": "^3.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"global-agent": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sindresorhus/is": {
|
||||||
|
"version": "4.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
|
||||||
|
"integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sindresorhus/is?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@szmarczak/http-timer": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"defer-to-connect": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/cacheable-request": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/http-cache-semantics": "*",
|
||||||
|
"@types/keyv": "^3.1.4",
|
||||||
|
"@types/node": "*",
|
||||||
|
"@types/responselike": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/http-cache-semantics": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/keyv": {
|
||||||
|
"version": "3.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
|
||||||
|
"integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "18.19.130",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
|
||||||
|
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~5.26.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/responselike": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/yauzl": {
|
||||||
|
"version": "2.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||||
|
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/boolean": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
|
||||||
|
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/buffer-crc32": {
|
||||||
|
"version": "0.2.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||||
|
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cacheable-lookup": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cacheable-request": {
|
||||||
|
"version": "7.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz",
|
||||||
|
"integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"clone-response": "^1.0.2",
|
||||||
|
"get-stream": "^5.1.0",
|
||||||
|
"http-cache-semantics": "^4.0.0",
|
||||||
|
"keyv": "^4.0.0",
|
||||||
|
"lowercase-keys": "^2.0.0",
|
||||||
|
"normalize-url": "^6.0.1",
|
||||||
|
"responselike": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/clone-response": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mimic-response": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/decompress-response": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mimic-response": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/decompress-response/node_modules/mimic-response": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/defer-to-connect": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/define-data-property": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"es-define-property": "^1.0.0",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/define-properties": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"define-data-property": "^1.0.1",
|
||||||
|
"has-property-descriptors": "^1.0.0",
|
||||||
|
"object-keys": "^1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/detect-node": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/electron": {
|
||||||
|
"version": "28.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/electron/-/electron-28.3.3.tgz",
|
||||||
|
"integrity": "sha512-ObKMLSPNhomtCOBAxFS8P2DW/4umkh72ouZUlUKzXGtYuPzgr1SYhskhFWgzAsPtUzhL2CzyV2sfbHcEW4CXqw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@electron/get": "^2.0.0",
|
||||||
|
"@types/node": "^18.11.18",
|
||||||
|
"extract-zip": "^2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"electron": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.20.55"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/end-of-stream": {
|
||||||
|
"version": "1.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
|
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"once": "^1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/env-paths": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es6-error": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/escape-string-regexp": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/extract-zip": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.1.1",
|
||||||
|
"get-stream": "^5.1.0",
|
||||||
|
"yauzl": "^2.10.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"extract-zip": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.17.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/yauzl": "^2.9.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fd-slicer": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pend": "~1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fs-extra": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.0",
|
||||||
|
"jsonfile": "^4.0.0",
|
||||||
|
"universalify": "^0.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6 <7 || >=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-stream": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pump": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/global-agent": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"boolean": "^3.0.1",
|
||||||
|
"es6-error": "^4.1.1",
|
||||||
|
"matcher": "^3.0.0",
|
||||||
|
"roarr": "^2.15.3",
|
||||||
|
"semver": "^7.3.2",
|
||||||
|
"serialize-error": "^7.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/global-agent/node_modules/semver": {
|
||||||
|
"version": "7.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/globalthis": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"define-properties": "^1.2.1",
|
||||||
|
"gopd": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/got": {
|
||||||
|
"version": "11.8.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz",
|
||||||
|
"integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sindresorhus/is": "^4.0.0",
|
||||||
|
"@szmarczak/http-timer": "^4.0.5",
|
||||||
|
"@types/cacheable-request": "^6.0.1",
|
||||||
|
"@types/responselike": "^1.0.0",
|
||||||
|
"cacheable-lookup": "^5.0.3",
|
||||||
|
"cacheable-request": "^7.0.2",
|
||||||
|
"decompress-response": "^6.0.0",
|
||||||
|
"http2-wrapper": "^1.0.0-beta.5.2",
|
||||||
|
"lowercase-keys": "^2.0.0",
|
||||||
|
"p-cancelable": "^2.0.0",
|
||||||
|
"responselike": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sindresorhus/got?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/graceful-fs": {
|
||||||
|
"version": "4.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/has-property-descriptors": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"es-define-property": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-cache-semantics": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/http2-wrapper": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"quick-lru": "^5.1.1",
|
||||||
|
"resolve-alpn": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/json-buffer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/json-stringify-safe": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/jsonfile": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"graceful-fs": "^4.1.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/keyv": {
|
||||||
|
"version": "4.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
|
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"json-buffer": "3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lowercase-keys": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/matcher": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"escape-string-regexp": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mimic-response": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/normalize-url": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-keys": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/once": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-cancelable": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pend": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/progress": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pump": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"end-of-stream": "^1.1.0",
|
||||||
|
"once": "^1.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/quick-lru": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/resolve-alpn": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/responselike": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lowercase-keys": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/roarr": {
|
||||||
|
"version": "2.15.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
|
||||||
|
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"boolean": "^3.0.1",
|
||||||
|
"detect-node": "^2.0.4",
|
||||||
|
"globalthis": "^1.0.1",
|
||||||
|
"json-stringify-safe": "^5.0.1",
|
||||||
|
"semver-compare": "^1.0.0",
|
||||||
|
"sprintf-js": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/semver-compare": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/serialize-error": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"type-fest": "^0.13.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sprintf-js": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/sumchecker": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/type-fest": {
|
||||||
|
"version": "0.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
|
||||||
|
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
|
||||||
|
"license": "(MIT OR CC0-1.0)",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "5.26.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||||
|
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/universalify": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrappy": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/yauzl": {
|
||||||
|
"version": "2.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||||
|
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-crc32": "~0.2.3",
|
||||||
|
"fd-slicer": "~1.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
package.json
Normal file
14
package.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "zelda-dungeon",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A top-down Legend of Zelda-style dungeon game",
|
||||||
|
"main": "main.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron . --no-sandbox",
|
||||||
|
"start:safe": "ELECTRON_DISABLE_SANDBOX=1 electron . --no-sandbox",
|
||||||
|
"dev": "npx serve . -p 3000"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"electron": "^28.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
preload.js
Normal file
8
preload.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require("electron");
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld("electronAPI", {
|
||||||
|
saveGame: (data) => ipcRenderer.send('save-game', data),
|
||||||
|
loadGame: () => ipcRenderer.invoke('load-game'),
|
||||||
|
clearSave: () => ipcRenderer.send('clear-save'),
|
||||||
|
hasSave: () => ipcRenderer.invoke('has-save'),
|
||||||
|
});
|
||||||
963
renderer.js
Normal file
963
renderer.js
Normal file
@ -0,0 +1,963 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === SETUP ===
|
||||||
|
const canvas = document.getElementById('game');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// === CONSTANTS ===
|
||||||
|
const TILE = 32;
|
||||||
|
const COLS = 20;
|
||||||
|
const ROWS = 15;
|
||||||
|
const T_FLOOR = 0;
|
||||||
|
const T_WALL = 1;
|
||||||
|
const T_TORCH = 5;
|
||||||
|
|
||||||
|
const MOVE_COOLDOWN = 100;
|
||||||
|
const ATTACK_COOLDOWN = 400;
|
||||||
|
const ATTACK_ANIM = 250;
|
||||||
|
const ENEMY_MOVE_INTERVAL = 800;
|
||||||
|
const ENEMY_ATTACK_INTERVAL = 1500;
|
||||||
|
const HIT_FLASH = 200;
|
||||||
|
const MESSAGE_DURATION = 2000;
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
floor: '#C4A265',
|
||||||
|
floorDark: '#A8884D',
|
||||||
|
wall: '#505050',
|
||||||
|
wallTop: '#6E6E6E',
|
||||||
|
wallFront: '#5A5A5A',
|
||||||
|
wallBottom: '#383838',
|
||||||
|
playerBody: '#228B22',
|
||||||
|
playerTunic: '#1A6B1A',
|
||||||
|
playerHead: '#FFD700',
|
||||||
|
playerEyes: '#000080',
|
||||||
|
playerHair: '#8B4513',
|
||||||
|
rock: '#888888',
|
||||||
|
rockDark: '#666666',
|
||||||
|
rockLight: '#AAAAAA',
|
||||||
|
target: '#FFD700',
|
||||||
|
targetGlow: 'rgba(255,215,0,0.3)',
|
||||||
|
chestClosed: '#8B4513',
|
||||||
|
chestGold: '#DAA520',
|
||||||
|
chestOpen: '#A0522D',
|
||||||
|
swordBlade: '#C0C0C0',
|
||||||
|
swordHandle: '#8B4513',
|
||||||
|
swordGem: '#FF0000',
|
||||||
|
goblin: '#8B0000',
|
||||||
|
goblinLight: '#CC2222',
|
||||||
|
goblinEye: '#00FF00',
|
||||||
|
goblinTeeth: '#FFFFFF',
|
||||||
|
torchStick: '#654321',
|
||||||
|
torchFlame: '#FF8C00',
|
||||||
|
torchGlow: 'rgba(255,140,0,0.15)',
|
||||||
|
door: '#654321',
|
||||||
|
doorFrame: '#8B7355',
|
||||||
|
doorKnob: '#FFD700',
|
||||||
|
heart: '#FF0000',
|
||||||
|
heartEmpty: '#444444',
|
||||||
|
msgText: '#FFFFFF',
|
||||||
|
overlay: 'rgba(0,0,0,0.75)',
|
||||||
|
hitFlash: 'rgba(255,0,0,0.3)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIRS = [
|
||||||
|
{ x: 0, y: 1 },
|
||||||
|
{ x: 0, y: -1 },
|
||||||
|
{ x: -1, y: 0 },
|
||||||
|
{ x: 1, y: 0 },
|
||||||
|
];
|
||||||
|
const DIR_NAMES = ['Down', 'Up', 'Left', 'Right'];
|
||||||
|
|
||||||
|
// === HELPER FUNCTIONS ===
|
||||||
|
function createBorderLayout(walls, torches) {
|
||||||
|
const layout = [];
|
||||||
|
for (let y = 0; y < ROWS; y++) {
|
||||||
|
const row = [];
|
||||||
|
for (let x = 0; x < COLS; x++) {
|
||||||
|
if (y === 0 || y === ROWS - 1 || x === 0 || x === COLS - 1) {
|
||||||
|
row.push(T_WALL);
|
||||||
|
} else {
|
||||||
|
row.push(T_FLOOR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layout.push(row);
|
||||||
|
}
|
||||||
|
for (const [wx, wy] of walls) {
|
||||||
|
if (wy >= 0 && wy < ROWS && wx >= 0 && wx < COLS) {
|
||||||
|
layout[wy][wx] = T_WALL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [tx, ty] of torches) {
|
||||||
|
if (ty >= 0 && ty < ROWS && tx >= 0 && tx < COLS) {
|
||||||
|
layout[ty][tx] = T_TORCH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawHeart(ctx, cx, cy, size, filled) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = filled ? COLORS.heart : COLORS.heartEmpty;
|
||||||
|
ctx.beginPath();
|
||||||
|
const s = size * 0.5;
|
||||||
|
ctx.moveTo(cx, cy + s * 0.3);
|
||||||
|
ctx.bezierCurveTo(cx, cy - s * 0.5, cx - s * 1.2, cy - s * 0.5, cx - s, cy + s * 0.1);
|
||||||
|
ctx.bezierCurveTo(cx - s, cy + s * 0.7, cx, cy + s * 1.1, cx, cy + s * 1.1);
|
||||||
|
ctx.bezierCurveTo(cx, cy + s * 0.7, cx + s, cy + s * 0.1, cx + s, cy + s * 0.1);
|
||||||
|
ctx.bezierCurveTo(cx + s * 1.2, cy - s * 0.5, cx, cy - s * 0.5, cx, cy + s * 0.3);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === ROOM DEFINITIONS ===
|
||||||
|
const ROOMS = [
|
||||||
|
// Room 1: The Stone Chamber (Rock Puzzle)
|
||||||
|
{
|
||||||
|
name: 'The Stone Chamber',
|
||||||
|
layout: createBorderLayout(
|
||||||
|
[[5,1],[6,1],[7,1],[8,1],[9,1],[5,2],[6,2],[7,2],[8,2],[9,2],[5,3],[6,3],[7,3],[8,3],[9,3],[5,4],[6,4],[7,4],[8,4],[9,4],[5,7],[6,7],[7,7],[8,7],[9,7],[5,8],[6,8],[7,8],[8,8],[9,8],[5,9],[6,9],[7,9],[8,9],[9,9],[5,10],[6,10],[7,10],[8,10],[9,10]],
|
||||||
|
[[2,1],[17,1],[2,13],[17,13]]
|
||||||
|
),
|
||||||
|
playerStart: { x: 1, y: 1 },
|
||||||
|
rocks: [{ x: 11, y: 4 }],
|
||||||
|
targets: [{ x: 11, y: 3 }],
|
||||||
|
chests: [],
|
||||||
|
enemies: [],
|
||||||
|
exit: { x: 10, y: 14, dir: 0 },
|
||||||
|
torches: [[2, 1], [17, 1], [2, 13], [17, 13]],
|
||||||
|
exitOpen: false,
|
||||||
|
},
|
||||||
|
// Room 2: The Armory (Sword Chest)
|
||||||
|
{
|
||||||
|
name: 'The Armory',
|
||||||
|
layout: createBorderLayout(
|
||||||
|
[[9,6],[10,6],[11,6],[9,8],[10,8],[11,8]],
|
||||||
|
[[2,1],[17,1],[2,13],[17,13]]
|
||||||
|
),
|
||||||
|
playerStart: { x: 1, y: 7 },
|
||||||
|
rocks: [],
|
||||||
|
targets: [],
|
||||||
|
chests: [{ x: 10, y: 7, opened: false }],
|
||||||
|
enemies: [],
|
||||||
|
exit: { x: 18, y: 7, dir: 3 },
|
||||||
|
torches: [[2, 1], [17, 1], [2, 13], [17, 13]],
|
||||||
|
exitOpen: true,
|
||||||
|
},
|
||||||
|
// Room 3: The Goblin Lair (Boss Fight)
|
||||||
|
{
|
||||||
|
name: 'The Goblin Lair',
|
||||||
|
layout: createBorderLayout(
|
||||||
|
[[5,1],[6,1],[7,1],[8,1],[9,1],[10,1],[11,1],[12,1],[13,1],[5,12],[6,12],[7,12],[8,12],[9,12],[10,12],[11,12],[12,12],[13,12]],
|
||||||
|
[[2,1],[17,1],[2,13],[17,13]]
|
||||||
|
),
|
||||||
|
playerStart: { x: 1, y: 7 },
|
||||||
|
rocks: [],
|
||||||
|
targets: [],
|
||||||
|
chests: [],
|
||||||
|
enemies: [{ x: 16, y: 7, hp: 3, maxHp: 3, moveTimer: 0, attackTimer: 0, hitTimer: 0 }],
|
||||||
|
exit: null,
|
||||||
|
torches: [[2, 1], [17, 1], [2, 13], [17, 13]],
|
||||||
|
exitOpen: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// === GAME STATE ===
|
||||||
|
let game = {
|
||||||
|
roomIdx: 0,
|
||||||
|
layout: null,
|
||||||
|
player: {
|
||||||
|
x: 0, y: 0,
|
||||||
|
hp: 12, maxHp: 12,
|
||||||
|
hasSword: false,
|
||||||
|
dir: 0,
|
||||||
|
moveTimer: 0,
|
||||||
|
attackTimer: 0,
|
||||||
|
attackAnim: 0,
|
||||||
|
hitTimer: 0,
|
||||||
|
},
|
||||||
|
rocks: [],
|
||||||
|
targets: [],
|
||||||
|
chests: [],
|
||||||
|
enemies: [],
|
||||||
|
exit: null,
|
||||||
|
exitOpen: false,
|
||||||
|
torches: [],
|
||||||
|
message: '',
|
||||||
|
messageTimer: 0,
|
||||||
|
won: false,
|
||||||
|
dead: false,
|
||||||
|
moveCooldown: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// === INPUT ===
|
||||||
|
const keys = {};
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space','Enter'].includes(e.code)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
keys[e.code] = true;
|
||||||
|
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
if (game.won || game.dead) return;
|
||||||
|
tryAttack();
|
||||||
|
}
|
||||||
|
if (e.code === 'Enter' && (game.won || game.dead)) {
|
||||||
|
restart();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keyup', (e) => {
|
||||||
|
keys[e.code] = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// === GAME LOGIC ===
|
||||||
|
function loadRoom(idx) {
|
||||||
|
game.roomIdx = idx;
|
||||||
|
const room = ROOMS[idx];
|
||||||
|
game.layout = room.layout.map(row => [...row]);
|
||||||
|
game.player.x = room.playerStart.x;
|
||||||
|
game.player.y = room.playerStart.y;
|
||||||
|
game.rocks = room.rocks.map(r => ({ ...r }));
|
||||||
|
game.targets = room.targets.map(t => ({ ...t }));
|
||||||
|
game.chests = room.chests.map(c => ({ ...c }));
|
||||||
|
game.enemies = room.enemies.map(e => ({ ...e, moveTimer: ENEMY_MOVE_INTERVAL, attackTimer: ENEMY_ATTACK_INTERVAL, hitTimer: 0 }));
|
||||||
|
game.exit = room.exit ? { ...room.exit } : null;
|
||||||
|
game.exitOpen = room.exitOpen;
|
||||||
|
game.torches = room.torches ? room.torches.map(t => [...t]) : [];
|
||||||
|
game.message = room.name;
|
||||||
|
game.messageTimer = MESSAGE_DURATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryMove(dx, dy) {
|
||||||
|
if (game.won || game.dead) return;
|
||||||
|
|
||||||
|
const px = game.player.x;
|
||||||
|
const py = game.player.y;
|
||||||
|
const nx = px + dx;
|
||||||
|
const ny = py + dy;
|
||||||
|
|
||||||
|
if (dx === 0 && dy === 0) return;
|
||||||
|
|
||||||
|
if (dy === 1 && game.player.dir !== 0) game.player.dir = 0;
|
||||||
|
else if (dy === -1 && game.player.dir !== 1) game.player.dir = 1;
|
||||||
|
else if (dx === -1 && game.player.dir !== 2) game.player.dir = 2;
|
||||||
|
else if (dx === 1 && game.player.dir !== 3) game.player.dir = 3;
|
||||||
|
|
||||||
|
if (nx < 0 || nx >= COLS || ny < 0 || ny >= ROWS) return;
|
||||||
|
|
||||||
|
// Check exit before wall check so doors on border walls work
|
||||||
|
if (game.exit && game.exit.x === nx && game.exit.y === ny) {
|
||||||
|
tryExit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game.layout[ny][nx] === T_WALL) return;
|
||||||
|
|
||||||
|
const rock = game.rocks.find(r => r.x === nx && r.y === ny);
|
||||||
|
if (rock) {
|
||||||
|
const rnx = nx + dx;
|
||||||
|
const rny = ny + dy;
|
||||||
|
if (rnx < 0 || rnx >= COLS || rny < 0 || rny >= ROWS) return;
|
||||||
|
if (game.layout[rny][rnx] === T_WALL) return;
|
||||||
|
if (game.rocks.find(r => r.x === rnx && r.y === rny)) return;
|
||||||
|
if (game.enemies.find(e => e.x === rnx && e.y === rny)) return;
|
||||||
|
if (game.exit && game.exit.x === rnx && game.exit.y === rny) return;
|
||||||
|
if (game.chests.find(c => c.x === rnx && c.y === rny)) return;
|
||||||
|
rock.x = rnx;
|
||||||
|
rock.y = rny;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game.enemies.find(e => e.x === nx && e.y === ny)) return;
|
||||||
|
|
||||||
|
game.player.x = nx;
|
||||||
|
game.player.y = ny;
|
||||||
|
|
||||||
|
const chest = game.chests.find(c => c.x === nx && c.y === ny);
|
||||||
|
if (chest && !chest.opened) {
|
||||||
|
openChest(chest);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkPuzzle();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openChest(chest) {
|
||||||
|
chest.opened = true;
|
||||||
|
game.player.hasSword = true;
|
||||||
|
game.player.hp = Math.min(game.player.hp + 6, game.player.maxHp);
|
||||||
|
game.message = 'You found the Master Sword! (+6 HP)';
|
||||||
|
game.messageTimer = MESSAGE_DURATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryAttack() {
|
||||||
|
if (game.player.attackTimer > 0) return;
|
||||||
|
|
||||||
|
if (!game.player.hasSword) {
|
||||||
|
game.message = 'You need a weapon!';
|
||||||
|
game.messageTimer = MESSAGE_DURATION;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
game.player.attackAnim = ATTACK_ANIM;
|
||||||
|
game.player.attackTimer = ATTACK_COOLDOWN;
|
||||||
|
|
||||||
|
const dir = DIRS[game.player.dir];
|
||||||
|
const ax = game.player.x + dir.x;
|
||||||
|
const ay = game.player.y + dir.y;
|
||||||
|
|
||||||
|
for (const enemy of game.enemies) {
|
||||||
|
if (enemy.x === ax && enemy.y === ay) {
|
||||||
|
enemy.hp -= 1;
|
||||||
|
enemy.hitTimer = HIT_FLASH;
|
||||||
|
if (enemy.hp <= 0) {
|
||||||
|
game.message = 'Enemy defeated!';
|
||||||
|
game.messageTimer = MESSAGE_DURATION;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
game.enemies = game.enemies.filter(e => e.hp > 0);
|
||||||
|
|
||||||
|
if (game.enemies.length === 0 && !game.exit) {
|
||||||
|
game.won = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEnemies(dt) {
|
||||||
|
for (const enemy of [...game.enemies]) {
|
||||||
|
enemy.moveTimer -= dt;
|
||||||
|
enemy.attackTimer -= dt;
|
||||||
|
if (enemy.hitTimer > 0) enemy.hitTimer -= dt;
|
||||||
|
|
||||||
|
if (enemy.moveTimer <= 0) {
|
||||||
|
enemy.moveTimer = ENEMY_MOVE_INTERVAL;
|
||||||
|
|
||||||
|
const dx = game.player.x - enemy.x;
|
||||||
|
const dy = game.player.y - enemy.y;
|
||||||
|
|
||||||
|
let bestDir = null;
|
||||||
|
let bestDist = Math.abs(dx) + Math.abs(dy);
|
||||||
|
|
||||||
|
for (let d = 0; d < 4; d++) {
|
||||||
|
const nx = enemy.x + DIRS[d].x;
|
||||||
|
const ny = enemy.y + DIRS[d].y;
|
||||||
|
|
||||||
|
if (nx < 0 || nx >= COLS || ny < 0 || ny >= ROWS) continue;
|
||||||
|
if (game.layout[ny][nx] === T_WALL) continue;
|
||||||
|
if (game.rocks.find(r => r.x === nx && r.y === ny)) continue;
|
||||||
|
if (game.enemies.find(e => e !== enemy && e.x === nx && e.y === ny)) continue;
|
||||||
|
if (game.chests.find(c => c.x === nx && c.y === ny)) continue;
|
||||||
|
|
||||||
|
const newDist = Math.abs(game.player.x - nx) + Math.abs(game.player.y - ny);
|
||||||
|
if (newDist < bestDist) {
|
||||||
|
bestDist = newDist;
|
||||||
|
bestDir = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestDir !== null) {
|
||||||
|
enemy.x += DIRS[bestDir].x;
|
||||||
|
enemy.y += DIRS[bestDir].y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(enemy.x - game.player.x) + Math.abs(enemy.y - game.player.y) === 1) {
|
||||||
|
if (enemy.attackTimer <= 0) {
|
||||||
|
enemy.attackTimer = ENEMY_ATTACK_INTERVAL;
|
||||||
|
game.player.hp -= 2;
|
||||||
|
game.player.hitTimer = HIT_FLASH;
|
||||||
|
game.message = 'You were hit!';
|
||||||
|
game.messageTimer = MESSAGE_DURATION;
|
||||||
|
if (game.player.hp <= 0) {
|
||||||
|
game.player.hp = 0;
|
||||||
|
game.dead = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryExit() {
|
||||||
|
if (!game.exitOpen) {
|
||||||
|
game.message = 'The door is locked!';
|
||||||
|
game.messageTimer = MESSAGE_DURATION;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRoom = game.roomIdx + 1;
|
||||||
|
if (nextRoom >= ROOMS.length) {
|
||||||
|
game.won = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRoom(nextRoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkPuzzle() {
|
||||||
|
if (game.targets.length === 0) return;
|
||||||
|
const allSolved = game.targets.every(t => game.rocks.some(r => r.x === t.x && r.y === t.y));
|
||||||
|
if (allSolved) {
|
||||||
|
game.exitOpen = true;
|
||||||
|
game.message = 'The door has opened!';
|
||||||
|
game.messageTimer = MESSAGE_DURATION;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(text) {
|
||||||
|
game.message = text;
|
||||||
|
game.messageTimer = MESSAGE_DURATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restart() {
|
||||||
|
game.won = false;
|
||||||
|
game.dead = false;
|
||||||
|
game.player.hp = game.player.maxHp;
|
||||||
|
game.player.hasSword = false;
|
||||||
|
game.player.attackTimer = 0;
|
||||||
|
game.player.attackAnim = 0;
|
||||||
|
game.player.hitTimer = 0;
|
||||||
|
game.message = '';
|
||||||
|
game.messageTimer = 0;
|
||||||
|
loadRoom(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === RENDERING ===
|
||||||
|
function drawFloor(layout) {
|
||||||
|
for (let y = 0; y < ROWS; y++) {
|
||||||
|
for (let x = 0; x < COLS; x++) {
|
||||||
|
if (layout[y][x] === T_FLOOR) {
|
||||||
|
const shade = ((x + y) % 2 === 0) ? COLORS.floor : COLORS.floorDark;
|
||||||
|
ctx.fillStyle = shade;
|
||||||
|
ctx.fillRect(x * TILE, y * TILE, TILE, TILE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWalls(layout) {
|
||||||
|
for (let y = 0; y < ROWS; y++) {
|
||||||
|
for (let x = 0; x < COLS; x++) {
|
||||||
|
if (layout[y][x] === T_WALL) {
|
||||||
|
const px = x * TILE;
|
||||||
|
const py = y * TILE;
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.wall;
|
||||||
|
ctx.fillRect(px, py, TILE, TILE);
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.wallTop;
|
||||||
|
ctx.fillRect(px, py, TILE, 3);
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.wallFront;
|
||||||
|
ctx.fillRect(px, py + 3, TILE, TILE - 6);
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.wallBottom;
|
||||||
|
ctx.fillRect(px, py + TILE - 3, TILE, 3);
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#404040';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
ctx.strokeRect(px + 0.5, py + 0.5, TILE - 1, TILE - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTorches(layout) {
|
||||||
|
for (let y = 0; y < ROWS; y++) {
|
||||||
|
for (let x = 0; x < COLS; x++) {
|
||||||
|
if (layout[y][x] === T_TORCH) {
|
||||||
|
const cx = x * TILE + TILE / 2;
|
||||||
|
const cy = y * TILE + TILE / 2;
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.torchGlow;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, TILE * 1.2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.floor;
|
||||||
|
ctx.fillRect(x * TILE, y * TILE, TILE, TILE);
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.torchStick;
|
||||||
|
ctx.fillRect(cx - 2, cy + 2, 4, TILE * 0.4);
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.torchFlame;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy - 4, 4, 6, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#FFFF00';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy - 3, 2, 3, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTargets() {
|
||||||
|
for (const target of game.targets) {
|
||||||
|
const cx = target.x * TILE + TILE / 2;
|
||||||
|
const cy = target.y * TILE + TILE / 2;
|
||||||
|
const occupied = game.rocks.some(r => r.x === target.x && r.y === target.y);
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.targetGlow;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, TILE * 0.7, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = occupied ? '#00FF00' : COLORS.target;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, TILE * 0.3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.strokeStyle = occupied ? '#00AA00' : '#CC9900';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, TILE * 0.3, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRocks() {
|
||||||
|
for (const rock of game.rocks) {
|
||||||
|
const px = rock.x * TILE;
|
||||||
|
const py = rock.y * TILE;
|
||||||
|
const inset = 3;
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.rock;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(px + inset, py + inset, TILE - inset * 2, TILE - inset * 2, 4);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.rockLight;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(px + inset + 1, py + inset + 1, (TILE - inset * 2) * 0.5, (TILE - inset * 2) * 0.4, 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.rockDark;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(px + inset + (TILE - inset * 2) * 0.4, py + inset + (TILE - inset * 2) * 0.5, (TILE - inset * 2) * 0.4, (TILE - inset * 2) * 0.4, 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#555555';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(px + inset, py + inset, TILE - inset * 2, TILE - inset * 2, 4);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawChests() {
|
||||||
|
for (const chest of game.chests) {
|
||||||
|
const px = chest.x * TILE;
|
||||||
|
const py = chest.y * TILE;
|
||||||
|
|
||||||
|
if (chest.opened) {
|
||||||
|
ctx.fillStyle = COLORS.chestOpen;
|
||||||
|
ctx.fillRect(px + 2, py + 10, TILE - 4, TILE - 12);
|
||||||
|
ctx.fillStyle = COLORS.chestGold;
|
||||||
|
ctx.fillRect(px + 4, py + 12, TILE - 8, TILE - 14);
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.chestClosed;
|
||||||
|
ctx.fillRect(px + 2, py + 2, TILE - 4, 6);
|
||||||
|
ctx.fillStyle = COLORS.chestGold;
|
||||||
|
ctx.fillRect(px + 4, py + 3, TILE - 8, 4);
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = COLORS.chestClosed;
|
||||||
|
ctx.fillRect(px + 2, py + 2, TILE - 4, TILE - 4);
|
||||||
|
ctx.fillStyle = COLORS.chestGold;
|
||||||
|
ctx.fillRect(px + 4, py + 4, TILE - 8, TILE - 8);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#FFD700';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2, py + TILE / 2, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#B8860B';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(px + 2, py + 2, TILE - 4, TILE - 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawExit() {
|
||||||
|
if (!game.exit) return;
|
||||||
|
|
||||||
|
const px = game.exit.x * TILE;
|
||||||
|
const py = game.exit.y * TILE;
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.doorFrame;
|
||||||
|
ctx.fillRect(px - 1, py - 1, TILE + 2, TILE + 2);
|
||||||
|
|
||||||
|
if (game.exitOpen) {
|
||||||
|
ctx.fillStyle = '#1A1A1A';
|
||||||
|
ctx.fillRect(px + 2, py + 2, TILE - 4, TILE - 4);
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = COLORS.door;
|
||||||
|
ctx.fillRect(px + 2, py + 2, TILE - 4, TILE - 4);
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#8B7355';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(px + 2, py + 2, TILE - 4, TILE - 4);
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.doorKnob;
|
||||||
|
ctx.beginPath();
|
||||||
|
const knobX = game.exit.dir === 3 ? px + TILE - 7 : px + 7;
|
||||||
|
ctx.arc(knobX, py + TILE / 2, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
ctx.font = 'bold 10px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
const arrowX = game.exit.dir === 3 ? px + TILE + 10 : px - 10;
|
||||||
|
ctx.fillText(game.exit.dir === 3 ? '→' : game.exit.dir === 2 ? '←' : game.exit.dir === 0 ? '↓' : '↑', arrowX, py + TILE / 2 + 4);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawEnemies() {
|
||||||
|
for (const enemy of game.enemies) {
|
||||||
|
const px = enemy.x * TILE;
|
||||||
|
const py = enemy.y * TILE;
|
||||||
|
const isHit = enemy.hitTimer > 0;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
|
||||||
|
if (isHit) {
|
||||||
|
ctx.globalAlpha = 0.5 + Math.sin(Date.now() * 0.02) * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = isHit ? '#FFFFFF' : COLORS.goblin;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(px + 3, py + 4, TILE - 6, TILE - 8, 3);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.goblinLight;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(px + 4, py + 5, (TILE - 6) * 0.6, (TILE - 8) * 0.4, 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Eyes
|
||||||
|
ctx.fillStyle = COLORS.goblinEye;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 - 4, py + 10, 2.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 + 4, py + 10, 2.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Pupils
|
||||||
|
ctx.fillStyle = '#003300';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 - 4, py + 10, 1.2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 + 4, py + 10, 1.2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Teeth
|
||||||
|
ctx.fillStyle = COLORS.goblinTeeth;
|
||||||
|
ctx.fillRect(px + TILE / 2 - 3, py + 16, 2, 2);
|
||||||
|
ctx.fillRect(px + TILE / 2 + 1, py + 16, 2, 2);
|
||||||
|
|
||||||
|
// HP bar
|
||||||
|
const barW = TILE - 4;
|
||||||
|
const hpRatio = enemy.hp / enemy.maxHp;
|
||||||
|
ctx.fillStyle = '#333333';
|
||||||
|
ctx.fillRect(px + 2, py - 4, barW, 4);
|
||||||
|
ctx.fillStyle = hpRatio > 0.5 ? '#00FF00' : hpRatio > 0.25 ? '#FFAA00' : '#FF0000';
|
||||||
|
ctx.fillRect(px + 2, py - 4, barW * hpRatio, 4);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPlayer() {
|
||||||
|
const p = game.player;
|
||||||
|
const px = p.x * TILE;
|
||||||
|
const py = p.y * TILE;
|
||||||
|
const isHit = p.hitTimer > 0;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
|
||||||
|
if (isHit) {
|
||||||
|
ctx.globalAlpha = 0.5 + Math.sin(Date.now() * 0.03) * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyColor = isHit ? '#FF6666' : COLORS.playerBody;
|
||||||
|
|
||||||
|
// Body
|
||||||
|
ctx.fillStyle = bodyColor;
|
||||||
|
ctx.fillRect(px + 6, py + 14, TILE - 12, TILE - 16);
|
||||||
|
|
||||||
|
// Tunic detail
|
||||||
|
ctx.fillStyle = COLORS.playerTunic;
|
||||||
|
ctx.fillRect(px + 8, py + 16, TILE - 16, TILE - 20);
|
||||||
|
|
||||||
|
// Head
|
||||||
|
ctx.fillStyle = COLORS.playerHead;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2, py + 10, 7, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Hair
|
||||||
|
ctx.fillStyle = COLORS.playerHair;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2, py + 8, 7, Math.PI, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Eyes
|
||||||
|
ctx.fillStyle = COLORS.playerEyes;
|
||||||
|
const eyeOffX = DIRS[p.dir].x * 2;
|
||||||
|
const eyeOffY = DIRS[p.dir].y * 2;
|
||||||
|
ctx.fillRect(px + TILE / 2 - 3 + eyeOffX, py + 9 + eyeOffY, 2, 2);
|
||||||
|
ctx.fillRect(px + TILE / 2 + 1 + eyeOffX, py + 9 + eyeOffY, 2, 2);
|
||||||
|
|
||||||
|
// Sword indicator
|
||||||
|
if (p.hasSword) {
|
||||||
|
ctx.fillStyle = COLORS.swordBlade;
|
||||||
|
const sx = px + TILE / 2 + DIRS[p.dir].x * 10;
|
||||||
|
const sy = py + TILE / 2 + DIRS[p.dir].y * 10;
|
||||||
|
ctx.fillRect(sx - 2, sy - 2, 4, 4);
|
||||||
|
ctx.fillStyle = COLORS.swordHandle;
|
||||||
|
const hx = px + TILE / 2 + DIRS[p.dir].x * 5;
|
||||||
|
const hy = py + TILE / 2 + DIRS[p.dir].y * 5;
|
||||||
|
ctx.fillRect(hx - 1.5, hy - 1.5, 3, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawAttackAnim() {
|
||||||
|
const p = game.player;
|
||||||
|
if (p.attackAnim <= 0 || !p.hasSword) return;
|
||||||
|
|
||||||
|
const px = p.x * TILE;
|
||||||
|
const py = p.y * TILE;
|
||||||
|
const dir = DIRS[p.dir];
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = p.attackAnim / ATTACK_ANIM;
|
||||||
|
|
||||||
|
const ax = px + TILE / 2 + dir.x * TILE * 0.6;
|
||||||
|
const ay = py + TILE / 2 + dir.y * TILE * 0.6;
|
||||||
|
|
||||||
|
// Sword blade
|
||||||
|
ctx.fillStyle = COLORS.swordBlade;
|
||||||
|
if (dir.x !== 0) {
|
||||||
|
ctx.fillRect(ax - 3, ay - TILE * 0.4, 6, TILE * 0.8);
|
||||||
|
} else {
|
||||||
|
ctx.fillRect(ax - TILE * 0.4, ay - 3, TILE * 0.8, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slash effect
|
||||||
|
ctx.strokeStyle = '#FFFFFF';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.globalAlpha = p.attackAnim / ATTACK_ANIM * 0.7;
|
||||||
|
if (dir.x !== 0) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(ax, ay - TILE * 0.5);
|
||||||
|
ctx.lineTo(ax + dir.x * 8, ay);
|
||||||
|
ctx.lineTo(ax, ay + TILE * 0.5);
|
||||||
|
ctx.stroke();
|
||||||
|
} else {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(ax - TILE * 0.5, ay);
|
||||||
|
ctx.lineTo(ax, ay + dir.y * 8);
|
||||||
|
ctx.lineTo(ax + TILE * 0.5, ay);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawHUD() {
|
||||||
|
// Hearts
|
||||||
|
const totalHearts = Math.ceil(game.player.maxHp / 2);
|
||||||
|
const fullHearts = Math.floor(game.player.hp / 2);
|
||||||
|
const halfHeart = game.player.hp % 2 === 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < totalHearts; i++) {
|
||||||
|
const hx = 12 + i * 22;
|
||||||
|
const hy = 12;
|
||||||
|
drawHeart(ctx, hx, hy, 14, i < fullHearts);
|
||||||
|
if (i === fullHearts && halfHeart) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.clip();
|
||||||
|
drawHeart(ctx, hx, hy, 14, true);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sword icon
|
||||||
|
if (game.player.hasSword) {
|
||||||
|
ctx.fillStyle = COLORS.swordBlade;
|
||||||
|
ctx.fillRect(canvas.width - 30, 6, 4, 14);
|
||||||
|
ctx.fillStyle = COLORS.swordHandle;
|
||||||
|
ctx.fillRect(canvas.width - 32, 20, 8, 4);
|
||||||
|
ctx.fillStyle = COLORS.swordGem;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(canvas.width - 28, 6, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Room name
|
||||||
|
ctx.fillStyle = '#CCCCCC';
|
||||||
|
ctx.font = '12px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(ROOMS[game.roomIdx].name, canvas.width / 2, canvas.height - 8);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMessages() {
|
||||||
|
if (game.messageTimer <= 0) return;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = Math.min(1, game.messageTimer / 300);
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||||
|
const textW = ctx.measureText(game.message).width;
|
||||||
|
const boxW = textW + 20;
|
||||||
|
const boxX = (canvas.width - boxW) / 2;
|
||||||
|
const boxY = canvas.height / 2 - 30;
|
||||||
|
ctx.fillRect(boxX, boxY, boxW, 24);
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#FFD700';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(boxX, boxY, boxW, 24);
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.msgText;
|
||||||
|
ctx.font = '14px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(game.message, canvas.width / 2, boxY + 17);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawDeathScreen() {
|
||||||
|
ctx.fillStyle = COLORS.overlay;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#FF0000';
|
||||||
|
ctx.font = 'bold 36px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('YOU DIED', canvas.width / 2, canvas.height / 2 - 20);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#AAAAAA';
|
||||||
|
ctx.font = '16px monospace';
|
||||||
|
ctx.fillText('Press ENTER to restart', canvas.width / 2, canvas.height / 2 + 20);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWinScreen() {
|
||||||
|
ctx.fillStyle = COLORS.overlay;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#FFD700';
|
||||||
|
ctx.font = 'bold 36px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('YOU WIN!', canvas.width / 2, canvas.height / 2 - 40);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
ctx.font = '18px monospace';
|
||||||
|
ctx.fillText('The dungeon has been conquered!', canvas.width / 2, canvas.height / 2);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#AAAAAA';
|
||||||
|
ctx.font = '14px monospace';
|
||||||
|
ctx.fillText('Press ENTER to play again', canvas.width / 2, canvas.height / 2 + 35);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
if (game.dead) {
|
||||||
|
drawFloor(game.layout);
|
||||||
|
drawWalls(game.layout);
|
||||||
|
drawTorches(game.layout);
|
||||||
|
drawDeathScreen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game.won) {
|
||||||
|
drawFloor(game.layout);
|
||||||
|
drawWalls(game.layout);
|
||||||
|
drawTorches(game.layout);
|
||||||
|
drawWinScreen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawFloor(game.layout);
|
||||||
|
drawTargets();
|
||||||
|
drawWalls(game.layout);
|
||||||
|
drawTorches(game.layout);
|
||||||
|
drawExit();
|
||||||
|
drawRocks();
|
||||||
|
drawChests();
|
||||||
|
drawEnemies();
|
||||||
|
drawPlayer();
|
||||||
|
drawAttackAnim();
|
||||||
|
|
||||||
|
// Hit flash overlay
|
||||||
|
if (game.player.hitTimer > 0) {
|
||||||
|
ctx.fillStyle = COLORS.hitFlash;
|
||||||
|
ctx.globalAlpha = game.player.hitTimer / HIT_FLASH;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawMessages();
|
||||||
|
drawHUD();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === GAME LOOP ===
|
||||||
|
let lastTime = 0;
|
||||||
|
|
||||||
|
function gameLoop(timestamp) {
|
||||||
|
const dt = timestamp - lastTime;
|
||||||
|
lastTime = timestamp;
|
||||||
|
|
||||||
|
if (!game.won && !game.dead) {
|
||||||
|
// Handle movement input
|
||||||
|
if (game.player.moveTimer <= 0) {
|
||||||
|
let dx = 0, dy = 0;
|
||||||
|
if (keys['ArrowUp'] || keys['KeyW']) dy = -1;
|
||||||
|
else if (keys['ArrowDown'] || keys['KeyS']) dy = 1;
|
||||||
|
if (keys['ArrowLeft'] || keys['KeyA']) dx = -1;
|
||||||
|
else if (keys['ArrowRight'] || keys['KeyD']) dx = 1;
|
||||||
|
|
||||||
|
if (dx !== 0 || dy !== 0) {
|
||||||
|
tryMove(dx, dy);
|
||||||
|
game.player.moveTimer = MOVE_COOLDOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update timers
|
||||||
|
if (game.player.moveTimer > 0) game.player.moveTimer -= dt;
|
||||||
|
if (game.player.attackTimer > 0) game.player.attackTimer -= dt;
|
||||||
|
if (game.player.attackAnim > 0) game.player.attackAnim -= dt;
|
||||||
|
if (game.player.hitTimer > 0) game.player.hitTimer -= dt;
|
||||||
|
if (game.messageTimer > 0) game.messageTimer -= dt;
|
||||||
|
|
||||||
|
// Update enemies
|
||||||
|
updateEnemies(dt);
|
||||||
|
|
||||||
|
// Check death
|
||||||
|
if (game.player.hp <= 0) {
|
||||||
|
game.player.hp = 0;
|
||||||
|
game.dead = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
requestAnimationFrame(gameLoop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === INIT ===
|
||||||
|
function init() {
|
||||||
|
loadRoom(0);
|
||||||
|
requestAnimationFrame(gameLoop);
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
406
src/app.js
Normal file
406
src/app.js
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
// === APP BOOTSTRAP ===
|
||||||
|
|
||||||
|
const canvas = document.getElementById('game');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const debugCanvas = document.getElementById('debug-canvas');
|
||||||
|
const debugCtx = debugCanvas.getContext('2d');
|
||||||
|
const menuOverlay = document.getElementById('menu-overlay');
|
||||||
|
const debugOverlay = document.getElementById('debug-overlay');
|
||||||
|
|
||||||
|
// Expose canvas contexts and DOM elements to window for other modules
|
||||||
|
window.ctx = ctx;
|
||||||
|
window.debugCtx = debugCtx;
|
||||||
|
window.menuOverlay = menuOverlay;
|
||||||
|
|
||||||
|
// Debug mode
|
||||||
|
const debugMode = new URLSearchParams(window.location.search).has('debug');
|
||||||
|
let debugVisible = false;
|
||||||
|
|
||||||
|
// Screen state
|
||||||
|
let currentScreen = SCREEN_TITLE;
|
||||||
|
let transitionProgress = 1; // 1 = fully visible, 0 = fully faded
|
||||||
|
let transitioning = false;
|
||||||
|
let targetScreen = null;
|
||||||
|
|
||||||
|
// Game loop timing
|
||||||
|
let lastTime = 0;
|
||||||
|
let animFrameId = null;
|
||||||
|
|
||||||
|
// === SCREEN MANAGEMENT ===
|
||||||
|
|
||||||
|
function switchScreen(screen) {
|
||||||
|
if (transitioning) return;
|
||||||
|
transitioning = true;
|
||||||
|
targetScreen = screen;
|
||||||
|
transitionProgress = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTransition(dt) {
|
||||||
|
if (!transitioning) return;
|
||||||
|
transitionProgress -= dt / TRANSITION_DURATION;
|
||||||
|
if (transitionProgress <= 0) {
|
||||||
|
transitionProgress = 1;
|
||||||
|
transitioning = false;
|
||||||
|
currentScreen = targetScreen;
|
||||||
|
targetScreen = null;
|
||||||
|
onScreenChange(currentScreen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScreenChange(screen) {
|
||||||
|
menuOverlay.innerHTML = '';
|
||||||
|
menuOverlay.classList.remove('active');
|
||||||
|
inputSystem.clearKeys();
|
||||||
|
|
||||||
|
switch (screen) {
|
||||||
|
case SCREEN_TITLE:
|
||||||
|
renderTitleScreen(menuOverlay);
|
||||||
|
menuOverlay.classList.add('active');
|
||||||
|
break;
|
||||||
|
case SCREEN_DUNGEON_SELECT:
|
||||||
|
renderDungeonSelect(menuOverlay, saveData, onDungeonSelected);
|
||||||
|
menuOverlay.classList.add('active');
|
||||||
|
break;
|
||||||
|
case SCREEN_PLAYING:
|
||||||
|
if (!gameState.currentDungeon) return;
|
||||||
|
loadDungeonRoom(gameState.currentDungeon, gameState.currentRoomIdx);
|
||||||
|
break;
|
||||||
|
case SCREEN_DEAD:
|
||||||
|
renderDeathScreen(menuOverlay, gameState);
|
||||||
|
menuOverlay.classList.add('active');
|
||||||
|
break;
|
||||||
|
case SCREEN_DUNGEON_COMPLETE:
|
||||||
|
renderDungeonCompleteScreen(menuOverlay, gameState);
|
||||||
|
menuOverlay.classList.add('active');
|
||||||
|
break;
|
||||||
|
case SCREEN_GAME_COMPLETE:
|
||||||
|
renderGameCompleteScreen(menuOverlay, gameState);
|
||||||
|
menuOverlay.classList.add('active');
|
||||||
|
break;
|
||||||
|
case SCREEN_INVENTORY:
|
||||||
|
renderInventoryOverlay(menuOverlay, gameState);
|
||||||
|
menuOverlay.classList.add('active');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDungeonSelected(dungeonIdx) {
|
||||||
|
gameState.currentDungeon = dungeonIdx;
|
||||||
|
gameState.currentRoomIdx = 0;
|
||||||
|
gameState.dungeonRooms = generateDungeon(DUNGEONS[dungeonIdx]);
|
||||||
|
gameState.player.hp = gameState.player.maxHp;
|
||||||
|
gameState.player.invincibleTimer = 1000;
|
||||||
|
switchScreen(SCREEN_PLAYING);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === DEBUG ===
|
||||||
|
|
||||||
|
function toggleDebug() {
|
||||||
|
debugVisible = !debugVisible;
|
||||||
|
debugCanvas.classList.toggle('visible', debugVisible);
|
||||||
|
debugOverlay.classList.toggle('visible', debugVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDebug() {
|
||||||
|
if (!debugVisible) return;
|
||||||
|
debugCtx.clearRect(0, 0, CANVAS_W, CANVAS_H);
|
||||||
|
debugCtx.fillStyle = '#0F0';
|
||||||
|
debugCtx.font = '10px monospace';
|
||||||
|
|
||||||
|
let y = 10;
|
||||||
|
const lines = [];
|
||||||
|
lines.push(`Screen: ${currentScreen}`);
|
||||||
|
lines.push(`Dungeon: ${gameState.currentDungeon !== null ? DUNGEONS[gameState.currentDungeon].name : 'none'}`);
|
||||||
|
lines.push(`Room: ${gameState.currentRoomIdx}/${gameState.dungeonRooms ? gameState.dungeonRooms.length - 1 : 0}`);
|
||||||
|
lines.push(`Player HP: ${gameState.player.hp}/${gameState.player.maxHp}`);
|
||||||
|
lines.push(`Weapons: ${gameState.player.weapons.map(w => w.name).join(', ')}`);
|
||||||
|
lines.push(`Active: ${gameState.player.activeWeapon ? gameState.player.activeWeapon.name : 'none'}`);
|
||||||
|
lines.push(`Enemies: ${gameState.enemies.length}`);
|
||||||
|
lines.push(`Particles: ${gameState.particles ? gameState.particles.length : 0}`);
|
||||||
|
|
||||||
|
if (gameState.currentDungeon !== null && gameState.dungeonRooms && gameState.currentRoom < gameState.dungeonRooms.length) {
|
||||||
|
const room = gameState.currentRoom;
|
||||||
|
lines.push(`Room type: ${room.type}`);
|
||||||
|
lines.push(`Room enemies: ${room.enemies.length}`);
|
||||||
|
lines.push(`Exit open: ${gameState.exitOpen}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
debugCtx.fillText(line, 5, y);
|
||||||
|
y += 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw collision boxes on debug canvas
|
||||||
|
if (currentScreen === SCREEN_PLAYING && gameState.layout) {
|
||||||
|
debugCtx.strokeStyle = 'rgba(0,255,0,0.3)';
|
||||||
|
debugCtx.lineWidth = 0.5;
|
||||||
|
for (let y2 = 0; y2 < ROWS; y2++) {
|
||||||
|
for (let x = 0; x < COLS; x++) {
|
||||||
|
if (gameState.layout[y2][x] === TILE_TYPES.FLOOR) {
|
||||||
|
debugCtx.strokeRect(x * TILE, y2 * TILE, TILE, TILE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === CONSOLE API ===
|
||||||
|
|
||||||
|
window._genDungeon = function (id) {
|
||||||
|
const dungeon = DUNGEONS[id];
|
||||||
|
if (!dungeon) { console.error(`Dungeon ${id} not found`); return null; }
|
||||||
|
const rooms = generateDungeon(dungeon);
|
||||||
|
console.log(`Generated ${rooms.length} rooms for "${dungeon.name}" (seed: ${dungeon.seed})`);
|
||||||
|
console.table(rooms.map((r, i) => ({
|
||||||
|
idx: i, type: r.type, enemies: r.enemies.length, puzzles: r.puzzles ? r.puzzles.length : 0,
|
||||||
|
items: r.chests ? r.chests.length : 0, hasBoss: r.boss !== undefined
|
||||||
|
})));
|
||||||
|
return rooms;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.spawnEnemy = function (type) {
|
||||||
|
if (currentScreen !== SCREEN_PLAYING) { console.error('Not in game'); return; }
|
||||||
|
const def = ENEMY_TYPES.find(e => e.name === type || e.id === type);
|
||||||
|
if (!def) { console.error(`Enemy type "${type}" not found`); return; }
|
||||||
|
const enemy = createEnemyInstance(def, gameState.player.x + 2, gameState.player.y);
|
||||||
|
gameState.enemies.push(enemy);
|
||||||
|
console.log(`Spawned ${def.name} at (${enemy.x}, ${enemy.y})`);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.giveWeapon = function (name) {
|
||||||
|
if (currentScreen !== SCREEN_PLAYING) { console.error('Not in game'); return; }
|
||||||
|
const def = WEAPONS.find(w => w.name === name);
|
||||||
|
if (!def) { console.error(`Weapon "${name}" not found`); return; }
|
||||||
|
if (!gameState.player.weapons.find(w => w.name === def.name)) {
|
||||||
|
gameState.player.weapons.push({ ...def });
|
||||||
|
console.log(`Added ${def.name} to inventory`);
|
||||||
|
} else {
|
||||||
|
console.log(`${def.name} already in inventory`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.teleportToRoom = function (idx) {
|
||||||
|
if (currentScreen !== SCREEN_PLAYING || !gameState.dungeonRooms) {
|
||||||
|
console.error('Not in game');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (idx < 0 || idx >= gameState.dungeonRooms.length) {
|
||||||
|
console.error(`Room index ${idx} out of range [0, ${gameState.dungeonRooms.length - 1}]`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gameState.currentRoomIdx = idx;
|
||||||
|
loadDungeonRoom(gameState.currentDungeon, idx);
|
||||||
|
console.log(`Teleported to room ${idx}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.dumpState = function () {
|
||||||
|
console.log('=== Game State ===');
|
||||||
|
console.log('Screen:', currentScreen);
|
||||||
|
console.log('Dungeon:', gameState.currentDungeon !== null ? DUNGEONS[gameState.currentDungeon].name : 'none');
|
||||||
|
console.log('Room:', gameState.currentRoomIdx);
|
||||||
|
console.log('Player:', JSON.stringify(gameState.player));
|
||||||
|
console.log('Enemies:', gameState.enemies.length);
|
||||||
|
console.log('Particles:', gameState.particles ? gameState.particles.length : 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keyboard shortcut for debug
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.code === 'F1') { e.preventDefault(); toggleDebug(); }
|
||||||
|
if (e.code === 'F2' && currentScreen === SCREEN_PLAYING) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (gameState.exitOpen || gameState.currentRoomIdx >= (gameState.dungeonRooms ? gameState.dungeonRooms.length - 1 : 0)) {
|
||||||
|
if (gameState.currentRoomIdx >= (gameState.dungeonRooms ? gameState.dungeonRooms.length - 1 : 0)) {
|
||||||
|
completeDungeon();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
gameState.exitOpen = true;
|
||||||
|
showMessage('Debug: Door opened');
|
||||||
|
tryExit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.code === 'F3' && currentScreen === SCREEN_PLAYING) {
|
||||||
|
e.preventDefault();
|
||||||
|
gameState.player.invincible = true;
|
||||||
|
console.log('God mode enabled');
|
||||||
|
}
|
||||||
|
if (e.code === 'F4' && currentScreen === SCREEN_PLAYING) {
|
||||||
|
e.preventDefault();
|
||||||
|
spawnEnemy('goblin');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// === MAIN GAME LOOP ===
|
||||||
|
|
||||||
|
function gameLoop(timestamp) {
|
||||||
|
const dt = Math.min(timestamp - lastTime, 50); // Cap delta to prevent spiral of death
|
||||||
|
lastTime = timestamp;
|
||||||
|
|
||||||
|
// Update transition
|
||||||
|
updateTransition(dt);
|
||||||
|
|
||||||
|
// Screen-specific updates
|
||||||
|
if (currentScreen === SCREEN_PLAYING && !transitioning) {
|
||||||
|
updateGame(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update input
|
||||||
|
inputSystem.update(dt);
|
||||||
|
|
||||||
|
// Render
|
||||||
|
render();
|
||||||
|
|
||||||
|
// Debug overlay
|
||||||
|
renderDebug();
|
||||||
|
|
||||||
|
animFrameId = requestAnimationFrame(gameLoop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGame(dt) {
|
||||||
|
// Update player timers
|
||||||
|
if (gameState.player.moveTimer > 0) gameState.player.moveTimer -= dt;
|
||||||
|
if (gameState.player.attackTimer > 0) gameState.player.attackTimer -= dt;
|
||||||
|
if (gameState.player.attackAnim > 0) gameState.player.attackAnim -= dt;
|
||||||
|
if (gameState.player.hitTimer > 0) gameState.player.hitTimer -= dt;
|
||||||
|
if (gameState.player.invincibleTimer > 0) gameState.player.invincibleTimer -= dt;
|
||||||
|
else if (!gameState.player.invincible) gameState.player.invincibleTimer = 0;
|
||||||
|
if (gameState.messageTimer > 0) gameState.messageTimer -= dt;
|
||||||
|
|
||||||
|
// Update particles
|
||||||
|
if (gameState.particles) {
|
||||||
|
for (let i = gameState.particles.length - 1; i >= 0; i--) {
|
||||||
|
const p = gameState.particles[i];
|
||||||
|
p.life -= dt;
|
||||||
|
if (p.life <= 0) {
|
||||||
|
gameState.particles.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
p.x += p.vx * (dt / 1000);
|
||||||
|
p.y += p.vy * (dt / 1000);
|
||||||
|
p.vy += (p.gravity || 0) * (dt / 1000);
|
||||||
|
p.alpha = (p.life / p.maxLife) * (p.maxAlpha || 1);
|
||||||
|
}
|
||||||
|
if (gameState.particles.length > MAX_PARTICLES) {
|
||||||
|
gameState.particles.splice(0, gameState.particles.length - MAX_PARTICLES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle movement
|
||||||
|
if (gameState.player.moveTimer <= 0 && !gameState.player.invincibleTimer) {
|
||||||
|
const move = inputSystem.getMove();
|
||||||
|
if (move) {
|
||||||
|
tryMove(move.dx, move.dy);
|
||||||
|
gameState.player.moveTimer = MOVE_COOLDOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle actions
|
||||||
|
if (inputSystem.getAction('attack') && gameState.player.attackTimer <= 0) {
|
||||||
|
tryAttack();
|
||||||
|
}
|
||||||
|
if (inputSystem.getAction('interact')) {
|
||||||
|
tryInteract();
|
||||||
|
}
|
||||||
|
if (inputSystem.getAction('menu') && currentScreen === SCREEN_PLAYING) {
|
||||||
|
switchScreen(SCREEN_INVENTORY);
|
||||||
|
}
|
||||||
|
if (inputSystem.getAction('back') && currentScreen === SCREEN_INVENTORY) {
|
||||||
|
switchScreen(SCREEN_PLAYING);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update enemies
|
||||||
|
updateEnemies(dt);
|
||||||
|
|
||||||
|
// Check death
|
||||||
|
if (gameState.player.hp <= 0 && !gameState.player.invincible) {
|
||||||
|
gameState.player.hp = 0;
|
||||||
|
switchScreen(SCREEN_DEAD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === RENDERING ===
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
ctx.clearRect(0, 0, CANVAS_W, CANVAS_H);
|
||||||
|
|
||||||
|
// Draw black background
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||||
|
|
||||||
|
if (currentScreen === SCREEN_PLAYING) {
|
||||||
|
renderGame();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw transition overlay
|
||||||
|
if (transitioning || transitionProgress < 1) {
|
||||||
|
ctx.fillStyle = `rgba(0,0,0,${1 - transitionProgress})`;
|
||||||
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGame() {
|
||||||
|
if (!gameState.layout) return;
|
||||||
|
const theme = DUNGEONS[gameState.currentDungeon] ? DUNGEONS[gameState.currentDungeon].theme : 'stone';
|
||||||
|
const themeData = THEMES[theme] || THEMES.stone;
|
||||||
|
|
||||||
|
renderFloor(themeData);
|
||||||
|
renderTargets();
|
||||||
|
renderWalls(themeData);
|
||||||
|
renderTorches(themeData);
|
||||||
|
renderDoors();
|
||||||
|
renderSoftwalls();
|
||||||
|
renderLava();
|
||||||
|
renderWater();
|
||||||
|
renderTraps();
|
||||||
|
renderLevers();
|
||||||
|
renderSwitches();
|
||||||
|
renderRocks();
|
||||||
|
renderChests();
|
||||||
|
renderItems();
|
||||||
|
renderEnemies();
|
||||||
|
if (gameState.currentBoss) renderBoss(gameState.currentBoss);
|
||||||
|
renderPlayer();
|
||||||
|
renderAttackAnim();
|
||||||
|
renderParticles();
|
||||||
|
|
||||||
|
// Hit flash overlay
|
||||||
|
if (gameState.player.hitTimer > 0) {
|
||||||
|
ctx.fillStyle = COLORS.hitFlash;
|
||||||
|
ctx.globalAlpha = gameState.player.hitTimer / HIT_FLASH;
|
||||||
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMessages();
|
||||||
|
renderHUD();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === INITIALIZATION ===
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
// Initialize subsystems
|
||||||
|
initKeyboard();
|
||||||
|
initActions();
|
||||||
|
initGameState();
|
||||||
|
loadSaveData();
|
||||||
|
// Expose inputSystem and gameState to window for other modules
|
||||||
|
window.inputSystem = inputSystem;
|
||||||
|
window.gameState = gameState;
|
||||||
|
|
||||||
|
// Start with title screen
|
||||||
|
currentScreen = SCREEN_TITLE;
|
||||||
|
menuOverlay.innerHTML = '';
|
||||||
|
renderTitleScreen(menuOverlay);
|
||||||
|
menuOverlay.classList.add('active');
|
||||||
|
|
||||||
|
// Start game loop
|
||||||
|
lastTime = performance.now();
|
||||||
|
requestAnimationFrame(gameLoop);
|
||||||
|
|
||||||
|
console.log('Zelda Dungeon Quest initialized');
|
||||||
|
console.log('Debug mode:', debugMode ? 'ON (?debug=1)' : 'OFF');
|
||||||
|
console.log('Controls: F1=debug, F2=skip room, F3=god mode, F4=spawn enemy');
|
||||||
|
console.log('Console API: generateDungeon(id), spawnEnemy(type), giveWeapon(name), teleportToRoom(idx), dumpState()');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boot!
|
||||||
|
init();
|
||||||
131
src/browser-polyfill.js
Normal file
131
src/browser-polyfill.js
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Browser polyfill: makes Node.js CommonJS (module.exports / require) work in browser
|
||||||
|
// Load this BEFORE all other scripts in index.html
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
// Global `module` object - its exports setter mirrors to window
|
||||||
|
var moduleObj = { exports: {} };
|
||||||
|
Object.defineProperty(moduleObj, 'exports', {
|
||||||
|
set: function (val) {
|
||||||
|
this._exports = val || {};
|
||||||
|
// Mirror all exported properties to window for cross-script access
|
||||||
|
if (val && typeof val === 'object') {
|
||||||
|
for (var key in val) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(val, key)) {
|
||||||
|
window[key] = val[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
get: function () {
|
||||||
|
return this._exports || {};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
moduleObj._exports = {};
|
||||||
|
window.module = moduleObj;
|
||||||
|
|
||||||
|
// Module path -> exported keys mapping for require() lookups
|
||||||
|
var moduleKeys = {
|
||||||
|
'src/core/constants': ['TILE', 'COLS', 'ROWS', 'TILE_TYPES', 'COLORS',
|
||||||
|
'T_FLOOR', 'T_WALL', 'T_SOFTWALL', 'T_LAVA', 'T_WATER', 'T_TORCH',
|
||||||
|
'T_DOOR', 'T_LEVER', 'T_SWITCH', 'T_TRAP', 'T_TARGET', 'T_CHEST',
|
||||||
|
'T_EXIT', 'T_ROCK', 'T_ITEM',
|
||||||
|
'MOVE_COOLDOWN', 'ATTACK_COOLDOWN', 'ATTACK_ANIM',
|
||||||
|
'ENEMY_MOVE_INTERVAL', 'ENEMY_ATTACK_INTERVAL',
|
||||||
|
'HIT_FLASH', 'MESSAGE_DURATION', 'TRANSITION_DURATION',
|
||||||
|
'DIRS', 'DIR_NAMES', 'OPPOSITE_DIR',
|
||||||
|
'SCREEN_TITLE', 'SCREEN_DUNGEON_SELECT', 'SCREEN_PLAYING',
|
||||||
|
'SCREEN_DEAD', 'SCREEN_DUNGEON_COMPLETE', 'SCREEN_GAME_COMPLETE',
|
||||||
|
'SCREEN_INVENTORY',
|
||||||
|
'CANVAS_W', 'CANVAS_H',
|
||||||
|
'MAX_HEARTS', 'MAX_INVENTORY_SLOTS', 'MAX_PARTICLES'],
|
||||||
|
'src/core/rng': ['createRNG', 'mulberry32', 'randInt', 'randFloat', 'randElement', 'shuffle', 'weightedChoice'],
|
||||||
|
'src/core/utils': ['createBorderLayout', 'createEmptyLayout', 'drawHeart', 'drawHeartContainer',
|
||||||
|
'manhattanDist', 'inBounds', 'cloneLayout', 'clamp', 'lerp',
|
||||||
|
'isPassable', 'isWall', 'isSoftWall', 'getFloorColor', 'getWallColors', 'formatNumber'],
|
||||||
|
'src/generator/dungeon': ['generateDungeon'],
|
||||||
|
'src/generator/rooms': ['generateRoomLayout', 'getRoomLayoutTemplate', 'applyThemeToRoom'],
|
||||||
|
'src/generator/puzzles': ['generatePuzzles'],
|
||||||
|
'src/generator/placement': ['generatePlacement', 'placeEnemies', 'placeItems', 'placeBoss',
|
||||||
|
'findFloorPosition', 'getEnemyPool', 'getRupeeType'],
|
||||||
|
'src/generator/index': ['generateDungeon', 'generateRoomLayout', 'generatePuzzles', 'generatePlacement'],
|
||||||
|
'src/data/themes': ['THEMES'],
|
||||||
|
'src/data/dungeons': ['DUNGEONS'],
|
||||||
|
'src/data/enemies': ['ENEMY_TYPES', 'getEnemyType'],
|
||||||
|
'src/data/weapons': ['WEAPONS', 'getWeapon'],
|
||||||
|
'src/data/bosses': ['BOSS_TYPES', 'getBossType'],
|
||||||
|
'src/data/items': ['ITEMS', 'getItem'],
|
||||||
|
'src/game/state': ['initGameState', 'loadDungeonRoom', 'showMessage', 'resetPlayer'],
|
||||||
|
'src/game/movement': ['tryMove', 'tryInteract', 'tryExit', 'completeDungeon'],
|
||||||
|
'src/game/combat': ['tryAttack', 'dealDamage', 'spawnProjectile', 'updateProjectiles'],
|
||||||
|
'src/game/ai': ['updateEnemies', 'updateEnemyMovement', 'moveToward', 'enemyAttack', 'bossAttack', 'createEnemyInstance'],
|
||||||
|
'src/game/items': ['openChest', 'collectFloorItem', 'useItem', 'switchWeapon'],
|
||||||
|
'src/game/transitions': ['startTransition', 'getTransitionAlpha', 'isTransitionComplete'],
|
||||||
|
'src/input/keyboard': ['initKeyboard', 'isKeyDown', 'isKeyJustPressed', 'setKeyHandlers', 'resetKeys'],
|
||||||
|
'src/input/actions': ['initActions', 'getActionState', 'getAction', 'getActiveActions'],
|
||||||
|
'src/rendering/renderer': ['renderFloor', 'renderWalls', 'renderTorches', 'renderDoors', 'renderSoftwalls', 'renderLava', 'renderWater', 'renderTraps', 'renderLevers', 'renderSwitches', 'renderTargets', 'renderRocks', 'renderChests', 'renderItems', 'renderExit', 'renderPlayer', 'renderAttackAnim', 'renderMessages'],
|
||||||
|
'src/rendering/draw-enemies': ['renderEnemies', 'drawEnemy', 'drawSlime', 'drawKeese', 'drawOctorok', 'drawMoblin', 'drawStalfos', 'drawGoriya', 'drawArmos', 'drawLynel'],
|
||||||
|
'src/rendering/draw-bosses': ['renderBoss', 'drawGenericBoss', 'drawGleeok', 'drawDarknut', 'drawWizzrobe'],
|
||||||
|
'src/rendering/draw-weapons': ['drawWeaponHeld', 'drawWeaponInHUD', 'drawSwordHeld', 'drawMasterSwordHeld', 'drawBowHeld', 'drawBoomerangHeld', 'drawBombHeld', 'drawHammerHeld', 'drawFireRodHeld', 'drawIceRodHeld', 'drawSlingshotHeld', 'drawHookshotHeld', 'drawWeaponIcon'],
|
||||||
|
'src/rendering/hud': ['renderHUD'],
|
||||||
|
'src/rendering/effects': ['spawnParticles', 'spawnHitParticles', 'spawnDefeatParticles', 'spawnExplosionParticles', 'renderParticles', 'renderProjectiles'],
|
||||||
|
'src/menu/title-screen': ['renderTitleScreen', 'createMenuButton'],
|
||||||
|
'src/menu/dungeon-select': ['renderDungeonSelect'],
|
||||||
|
'src/menu/hud-overlay': ['renderInventoryOverlay'],
|
||||||
|
'src/menu/screens': ['renderDeathScreen', 'renderDungeonCompleteScreen', 'renderGameCompleteScreen'],
|
||||||
|
'src/save/save': ['saveData', 'loadSaveData', 'saveProgress', 'markDungeonComplete', 'addWeapon', 'addHeartPiece', 'addStats', 'isDungeonCompleted', 'isDungeonUnlocked', 'getSaveSummary', 'clearSave'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve a require path to a canonical module key
|
||||||
|
function resolvePath(req, callerPath) {
|
||||||
|
// Strip leading / for consistency with moduleKeys
|
||||||
|
if (callerPath && callerPath.startsWith('/')) callerPath = callerPath.substring(1);
|
||||||
|
// Direct match
|
||||||
|
if (moduleKeys.hasOwnProperty(req)) return req;
|
||||||
|
|
||||||
|
if (req.startsWith('./') || req.startsWith('../')) {
|
||||||
|
var dir = callerPath.substring(0, callerPath.lastIndexOf('/') + 1);
|
||||||
|
var resolved;
|
||||||
|
if (req.startsWith('./')) {
|
||||||
|
resolved = dir + req.substring(2);
|
||||||
|
} else {
|
||||||
|
var segments = req.substring(2).split('/');
|
||||||
|
var baseSegments = dir.split('/').filter(Boolean);
|
||||||
|
var dotDotCount = 0;
|
||||||
|
for (var i = 0; i + 2 < req.length && req.substring(i, i + 3) === '../'; i += 3) dotDotCount++;
|
||||||
|
for (var u = 0; u < dotDotCount; u++) baseSegments.pop();
|
||||||
|
var rest = req.substring(dotDotCount * 3);
|
||||||
|
resolved = baseSegments.join('/') + '/' + rest;
|
||||||
|
}
|
||||||
|
// Normalize
|
||||||
|
while (resolved.includes('/../')) {
|
||||||
|
resolved = resolved.replace(/[^\/]+\/\.\.\//, '');
|
||||||
|
}
|
||||||
|
if (moduleKeys.hasOwnProperty(resolved)) return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global `require` function
|
||||||
|
window.require = function (reqPath) {
|
||||||
|
var script = document.currentScript;
|
||||||
|
var origin = window.location ? window.location.origin : '';
|
||||||
|
var callerPath = script ? (script.src || script.getAttribute('src') || '').replace(origin, '') : '';
|
||||||
|
var modKey = resolvePath(reqPath, callerPath);
|
||||||
|
|
||||||
|
if (!modKey) {
|
||||||
|
console.warn('[require] Module not found: "' + reqPath + '" from "' + callerPath + '"');
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = {};
|
||||||
|
var keys = moduleKeys[modKey];
|
||||||
|
for (var i = 0; i < keys.length; i++) {
|
||||||
|
var key = keys[i];
|
||||||
|
result[key] = window[key] !== undefined ? window[key] : undefined;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
})();
|
||||||
151
src/core/constants.js
Normal file
151
src/core/constants.js
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Tile dimensions
|
||||||
|
const TILE = 32;
|
||||||
|
const COLS = 32;
|
||||||
|
const ROWS = 24;
|
||||||
|
|
||||||
|
// Tile types
|
||||||
|
const TILE_TYPES = {
|
||||||
|
FLOOR: 0,
|
||||||
|
WALL: 1,
|
||||||
|
SOFTWALL: 2,
|
||||||
|
LAVA: 3,
|
||||||
|
WATER: 4,
|
||||||
|
TORCH: 5,
|
||||||
|
DOOR: 6,
|
||||||
|
LEVER: 7,
|
||||||
|
SWITCH: 8,
|
||||||
|
TRAP: 9,
|
||||||
|
TARGET: 10,
|
||||||
|
CHEST: 11,
|
||||||
|
EXIT: 12,
|
||||||
|
ROCK: 13,
|
||||||
|
ITEM: 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Backward-compatible individual constants
|
||||||
|
const T_FLOOR = TILE_TYPES.FLOOR;
|
||||||
|
const T_WALL = TILE_TYPES.WALL;
|
||||||
|
const T_SOFTWALL = TILE_TYPES.SOFTWALL;
|
||||||
|
const T_LAVA = TILE_TYPES.LAVA;
|
||||||
|
const T_WATER = TILE_TYPES.WATER;
|
||||||
|
const T_TORCH = TILE_TYPES.TORCH;
|
||||||
|
const T_DOOR = TILE_TYPES.DOOR;
|
||||||
|
const T_LEVER = TILE_TYPES.LEVER;
|
||||||
|
const T_SWITCH = TILE_TYPES.SWITCH;
|
||||||
|
const T_TRAP = TILE_TYPES.TRAP;
|
||||||
|
const T_TARGET = TILE_TYPES.TARGET;
|
||||||
|
const T_CHEST = TILE_TYPES.CHEST;
|
||||||
|
const T_EXIT = TILE_TYPES.EXIT;
|
||||||
|
const T_ROCK = TILE_TYPES.ROCK;
|
||||||
|
const T_ITEM = TILE_TYPES.ITEM;
|
||||||
|
|
||||||
|
// Timing constants
|
||||||
|
const MOVE_COOLDOWN = 100;
|
||||||
|
const ATTACK_COOLDOWN = 400;
|
||||||
|
const ATTACK_ANIM = 250;
|
||||||
|
const ENEMY_MOVE_INTERVAL = 800;
|
||||||
|
const ENEMY_ATTACK_INTERVAL = 1500;
|
||||||
|
const HIT_FLASH = 200;
|
||||||
|
const MESSAGE_DURATION = 2000;
|
||||||
|
const TRANSITION_DURATION = 600;
|
||||||
|
|
||||||
|
// Direction vectors: [Down, Up, Left, Right]
|
||||||
|
const DIRS = [
|
||||||
|
{ x: 0, y: 1 },
|
||||||
|
{ x: 0, y: -1 },
|
||||||
|
{ x: -1, y: 0 },
|
||||||
|
{ x: 1, y: 0 },
|
||||||
|
];
|
||||||
|
const DIR_NAMES = ['Down', 'Up', 'Left', 'Right'];
|
||||||
|
|
||||||
|
// Opposite direction lookup
|
||||||
|
const OPPOSITE_DIR = [1, 0, 3, 2];
|
||||||
|
|
||||||
|
// Color palette (base stone dungeon theme)
|
||||||
|
const COLORS = {
|
||||||
|
floor: '#C4A265',
|
||||||
|
floorDark: '#A8884D',
|
||||||
|
wall: '#505050',
|
||||||
|
wallTop: '#6E6E6E',
|
||||||
|
wallFront: '#5A5A5A',
|
||||||
|
wallBottom: '#383838',
|
||||||
|
playerBody: '#228B22',
|
||||||
|
playerTunic: '#1A6B1A',
|
||||||
|
playerHead: '#FFD700',
|
||||||
|
playerEyes: '#000080',
|
||||||
|
playerHair: '#8B4513',
|
||||||
|
rock: '#888888',
|
||||||
|
rockDark: '#666666',
|
||||||
|
rockLight: '#AAAAAA',
|
||||||
|
target: '#FFD700',
|
||||||
|
targetGlow: 'rgba(255,215,0,0.3)',
|
||||||
|
chestClosed: '#8B4513',
|
||||||
|
chestGold: '#DAA520',
|
||||||
|
chestOpen: '#A0522D',
|
||||||
|
swordBlade: '#C0C0C0',
|
||||||
|
swordHandle: '#8B4513',
|
||||||
|
swordGem: '#FF0000',
|
||||||
|
goblin: '#8B0000',
|
||||||
|
goblinLight: '#CC2222',
|
||||||
|
goblinEye: '#00FF00',
|
||||||
|
goblinTeeth: '#FFFFFF',
|
||||||
|
torchStick: '#654321',
|
||||||
|
torchFlame: '#FF8C00',
|
||||||
|
torchGlow: 'rgba(255,140,0,0.15)',
|
||||||
|
door: '#654321',
|
||||||
|
doorFrame: '#8B7355',
|
||||||
|
doorKnob: '#FFD700',
|
||||||
|
heart: '#FF0000',
|
||||||
|
heartEmpty: '#444444',
|
||||||
|
msgText: '#FFFFFF',
|
||||||
|
overlay: 'rgba(0,0,0,0.75)',
|
||||||
|
hitFlash: 'rgba(255,0,0,0.3)',
|
||||||
|
softwall: '#705040',
|
||||||
|
softwallDark: '#503828',
|
||||||
|
lava: '#FF4500',
|
||||||
|
lavaLight: '#FF6347',
|
||||||
|
water: '#4169AA',
|
||||||
|
waterLight: '#5F9EA0',
|
||||||
|
lever: '#B8860B',
|
||||||
|
switchFloor: '#DAA520',
|
||||||
|
trap: '#8B008B',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Screen states
|
||||||
|
const SCREEN_TITLE = 'title';
|
||||||
|
const SCREEN_DUNGEON_SELECT = 'dungeon_select';
|
||||||
|
const SCREEN_PLAYING = 'playing';
|
||||||
|
const SCREEN_DEAD = 'dead';
|
||||||
|
const SCREEN_DUNGEON_COMPLETE = 'dungeon_complete';
|
||||||
|
const SCREEN_GAME_COMPLETE = 'game_complete';
|
||||||
|
const SCREEN_INVENTORY = 'inventory';
|
||||||
|
|
||||||
|
// Canvas dimensions
|
||||||
|
const CANVAS_W = 640;
|
||||||
|
const CANVAS_H = 480;
|
||||||
|
|
||||||
|
// Max values
|
||||||
|
const MAX_HEARTS = 13; // 26 HP
|
||||||
|
const MAX_INVENTORY_SLOTS = 12;
|
||||||
|
const MAX_PARTICLES = 100;
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = {
|
||||||
|
TILE, COLS, ROWS, TILE_TYPES,
|
||||||
|
T_FLOOR, T_WALL, T_SOFTWALL, T_LAVA, T_WATER, T_TORCH,
|
||||||
|
T_DOOR, T_LEVER, T_SWITCH, T_TRAP, T_TARGET, T_CHEST,
|
||||||
|
T_EXIT, T_ROCK, T_ITEM,
|
||||||
|
MOVE_COOLDOWN, ATTACK_COOLDOWN, ATTACK_ANIM,
|
||||||
|
ENEMY_MOVE_INTERVAL, ENEMY_ATTACK_INTERVAL,
|
||||||
|
HIT_FLASH, MESSAGE_DURATION, TRANSITION_DURATION,
|
||||||
|
DIRS, DIR_NAMES, OPPOSITE_DIR,
|
||||||
|
COLORS,
|
||||||
|
SCREEN_TITLE, SCREEN_DUNGEON_SELECT, SCREEN_PLAYING,
|
||||||
|
SCREEN_DEAD, SCREEN_DUNGEON_COMPLETE, SCREEN_GAME_COMPLETE,
|
||||||
|
SCREEN_INVENTORY,
|
||||||
|
CANVAS_W, CANVAS_H,
|
||||||
|
MAX_HEARTS, MAX_INVENTORY_SLOTS, MAX_PARTICLES,
|
||||||
|
};
|
||||||
|
}
|
||||||
58
src/core/rng.js
Normal file
58
src/core/rng.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Mulberry32 - fast, deterministic PRNG
|
||||||
|
function mulberry32(seed) {
|
||||||
|
return function () {
|
||||||
|
seed |= 0;
|
||||||
|
seed = seed + 0x602a2ebf | 0;
|
||||||
|
let t = Math.imul(seed ^ seed >>> 16, 0x6d349c2b);
|
||||||
|
t = Math.imul(t ^ t >>> 16, 0x04c35085) | 0;
|
||||||
|
t = t ^ t >>> 16;
|
||||||
|
return (t >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a seeded RNG instance
|
||||||
|
function createRNG(seed) {
|
||||||
|
return mulberry32(seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random integer in [min, max] inclusive
|
||||||
|
function randInt(rng, min, max) {
|
||||||
|
return Math.floor(rng() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random float in [0, 1)
|
||||||
|
function randFloat(rng) {
|
||||||
|
return rng();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick random element from array
|
||||||
|
function randElement(rng, arr) {
|
||||||
|
return arr[Math.floor(rng() * arr.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle array in place (Fisher-Yates)
|
||||||
|
function shuffle(rng, arr) {
|
||||||
|
const a = [...arr];
|
||||||
|
for (let i = a.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(rng() * (i + 1));
|
||||||
|
[a[i], a[j]] = [a[j], a[i]];
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random choice with weights
|
||||||
|
function weightedChoice(rng, items) {
|
||||||
|
const total = items.reduce((sum, item) => sum + item.weight, 0);
|
||||||
|
let r = rng() * total;
|
||||||
|
for (const item of items) {
|
||||||
|
r -= item.weight;
|
||||||
|
if (r <= 0) return item.value;
|
||||||
|
}
|
||||||
|
return items[items.length - 1].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { createRNG, mulberry32, randInt, randFloat, randElement, shuffle, weightedChoice };
|
||||||
|
}
|
||||||
131
src/core/utils.js
Normal file
131
src/core/utils.js
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Browser: use window globals; Node: use require
|
||||||
|
const _Const = typeof require !== 'undefined' ? require('./constants') : { TILE, COLS, ROWS, TILE_TYPES, COLORS };
|
||||||
|
|
||||||
|
// Create a layout grid with border walls and optional interior walls/torches
|
||||||
|
function createBorderLayout(walls, torches) {
|
||||||
|
const layout = [];
|
||||||
|
for (let y = 0; y < _Const.ROWS; y++) {
|
||||||
|
const row = [];
|
||||||
|
for (let x = 0; x < _Const.COLS; x++) {
|
||||||
|
if (y === 0 || y === _Const.ROWS - 1 || x === 0 || x === _Const.COLS - 1) {
|
||||||
|
row.push(_Const.TILE_TYPES.WALL);
|
||||||
|
} else {
|
||||||
|
row.push(_Const.TILE_TYPES.FLOOR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layout.push(row);
|
||||||
|
}
|
||||||
|
if (walls) {
|
||||||
|
for (const [wx, wy] of walls) {
|
||||||
|
if (wy >= 0 && wy < _Const.ROWS && wx >= 0 && wx < _Const.COLS) {
|
||||||
|
layout[wy][wx] = _Const.TILE_TYPES.WALL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (torches) {
|
||||||
|
for (const [tx, ty] of torches) {
|
||||||
|
if (ty >= 0 && ty < _Const.ROWS && tx >= 0 && tx < _Const.COLS) {
|
||||||
|
layout[ty][tx] = _Const.TILE_TYPES.TORCH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create empty layout (all floor, border walls)
|
||||||
|
function createEmptyLayout() {
|
||||||
|
return createBorderLayout([], []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw a heart shape on canvas
|
||||||
|
function drawHeart(ctx, cx, cy, size, filled) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = filled ? _Const.COLORS.heart : _Const.COLORS.heartEmpty;
|
||||||
|
ctx.beginPath();
|
||||||
|
const s = size * 0.5;
|
||||||
|
ctx.moveTo(cx, cy + s * 0.3);
|
||||||
|
ctx.bezierCurveTo(cx, cy - s * 0.5, cx - s * 1.2, cy - s * 0.5, cx - s, cy + s * 0.1);
|
||||||
|
ctx.bezierCurveTo(cx - s, cy + s * 0.7, cx, cy + s * 1.1, cx, cy + s * 1.1);
|
||||||
|
ctx.bezierCurveTo(cx, cy + s * 0.7, cx + s, cy + s * 0.1, cx + s, cy + s * 0.1);
|
||||||
|
ctx.bezierCurveTo(cx + s * 1.2, cy - s * 0.5, cx, cy - s * 0.5, cx, cy + s * 0.3);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw a heart container (full 2-HP heart)
|
||||||
|
function drawHeartContainer(ctx, cx, cy) {
|
||||||
|
drawHeart(ctx, cx, cy, 14, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manhattan distance
|
||||||
|
function manhattanDist(x1, y1, x2, y2) {
|
||||||
|
return Math.abs(x1 - x2) + Math.abs(y1 - y2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if position is within bounds
|
||||||
|
function inBounds(x, y) {
|
||||||
|
return x >= 0 && x < _Const.COLS && y >= 0 && y < _Const.ROWS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep clone a 2D array
|
||||||
|
function cloneLayout(layout) {
|
||||||
|
return layout.map(row => [...row]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp value between min and max
|
||||||
|
function clamp(val, min, max) {
|
||||||
|
return Math.max(min, Math.min(max, val));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linear interpolation
|
||||||
|
function lerp(a, b, t) {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if tile is passable (not wall)
|
||||||
|
function isPassable(layout, x, y) {
|
||||||
|
if (!inBounds(x, y)) return false;
|
||||||
|
const tile = layout[y][x];
|
||||||
|
return tile !== _Const.TILE_TYPES.WALL && tile !== _Const.TILE_TYPES.LAVA;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if tile is a wall
|
||||||
|
function isWall(layout, x, y) {
|
||||||
|
if (!inBounds(x, y)) return true;
|
||||||
|
return layout[y][x] === _Const.TILE_TYPES.WALL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if tile is a soft wall (breakable with bombs/hammer)
|
||||||
|
function isSoftWall(layout, x, y) {
|
||||||
|
if (!inBounds(x, y)) return false;
|
||||||
|
return layout[y][x] === _Const.TILE_TYPES.SOFTWALL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get floor color for theme
|
||||||
|
function getFloorColor(theme, x, y) {
|
||||||
|
const base = theme.floorColor || _Const.COLORS.floor;
|
||||||
|
const dark = theme.floorDarkColor || _Const.COLORS.floorDark;
|
||||||
|
return ((x + y) % 2 === 0) ? base : dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get wall colors for theme
|
||||||
|
function getWallColors(theme) {
|
||||||
|
return {
|
||||||
|
wall: theme.wallColor || _Const.COLORS.wall,
|
||||||
|
top: theme.wallTopColor || _Const.COLORS.wallTop,
|
||||||
|
front: theme.wallFrontColor || _Const.COLORS.wallFront,
|
||||||
|
bottom: theme.wallBottomColor || _Const.COLORS.wallBottom,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format number with commas
|
||||||
|
function formatNumber(n) {
|
||||||
|
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { createBorderLayout, createEmptyLayout, drawHeart, drawHeartContainer, manhattanDist, inBounds, cloneLayout, clamp, lerp, isPassable, isWall, isSoftWall, getFloorColor, getWallColors, formatNumber };
|
||||||
|
}
|
||||||
112
src/data/bosses.js
Normal file
112
src/data/bosses.js
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const BOSS_TYPES = [
|
||||||
|
{ id: 0, name: 'Stone Gleeok', hp: 26, damage: 3, defense: 0, speed: 1.5, colors: { body: "#696969", eye: "#FFD700", accent: "#A9A9A9" }, behavior: "charge", drawFunction: "drawGleeok", description: "A multi-headed dragon whose fiery breath devastates all.", phases: [{ hpThreshold: 100, attacks: [{ type: "aoe", damage: 4, cooldown: 2000, count: 2, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 3, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }] },
|
||||||
|
{ id: 1, name: 'Stone Darknut', hp: 20, damage: 3, defense: 1, speed: 1.2, colors: { body: "#696969", eye: "#FFD700", accent: "#A9A9A9" }, behavior: "chase", drawFunction: "drawDarknut", description: "A heavily-armored knight wielding a massive sword.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 3, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 50, attacks: [{ type: "melee", damage: 4, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "melee", damage: 4, cooldown: 1500, count: 3, spreadAngle: 120, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }] },
|
||||||
|
{ id: 2, name: 'Stone Wizzrobe', hp: 20, damage: 2, defense: 0, speed: 0, colors: { body: "#696969", eye: "#FFD700", accent: "#A9A9A9" }, behavior: "teleport", drawFunction: "drawWizzrobe", description: "A teleporting mage who commands destructive magic.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 3, cooldown: 1500, count: 2, spreadAngle: 60, projectileSpeed: 3}]}], drops: [{ item: "heart_piece", weight: 100 }] },
|
||||||
|
{ id: 3, name: 'Root Moldorm', hp: 21, damage: 4, defense: 1, speed: 2, colors: { body: "#228B22", eye: "#90EE90", accent: "#006400" }, behavior: "charge", drawFunction: "drawGeneric", description: "A giant segmented worm that charges relentlessly.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 4, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 5, cooldown: 3000, count: 1, spreadAngle: 360, projectileSpeed: 0}, { type: "melee", damage: 5, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }] },
|
||||||
|
{ id: 4, name: 'Moss Genie', hp: 33, damage: 4, defense: 1, speed: 0, colors: { body: "#228B22", eye: "#90EE90", accent: "#006400" }, behavior: "stationary", drawFunction: "drawGeneric", description: "A lamp spirit that unleashes devastating elemental blasts.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 5, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 2.5}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }] },
|
||||||
|
{ id: 5, name: 'Moss Like Like', hp: 16, damage: 3, defense: 0, speed: 1, colors: { body: "#228B22", eye: "#90EE90", accent: "#006400" }, behavior: "chase", drawFunction: "drawGeneric", description: "A voracious worm that steals hearts and shields.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 3, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }] },
|
||||||
|
{ id: 6, name: 'Verdant Aquamentus', hp: 36, damage: 4, defense: 1, speed: 0, colors: { body: "#228B22", eye: "#90EE90", accent: "#006400" }, behavior: "stationary", drawFunction: "drawGleeok", description: "A dragon that breathes flames at everything in sight.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 5, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 2}]}, { hpThreshold: 50, attacks: [{ type: "aoe", damage: 5, cooldown: 1800, count: 3, spreadAngle: 120, projectileSpeed: 2.5}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }] },
|
||||||
|
{ id: 7, name: 'Verdant Manhandla', hp: 20, damage: 3, defense: 0, speed: 1.8, colors: { body: "#228B22", eye: "#90EE90", accent: "#006400" }, behavior: "charge", drawFunction: "drawGeneric", description: "A spinning plant monster with razor-sharp limbs.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 3, cooldown: 1200, count: 4, spreadAngle: 360, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }] },
|
||||||
|
{ id: 8, name: 'Forest Ice Gleeok', hp: 39, damage: 4, defense: 1, speed: 1.5, colors: { body: "#228B22", eye: "#90EE90", accent: "#006400" }, behavior: "charge", drawFunction: "drawGleeok", description: "A spectral dragon wielding ice and shadow.", phases: [{ hpThreshold: 100, attacks: [{ type: "aoe", damage: 5, cooldown: 2200, count: 2, spreadAngle: 360, projectileSpeed: 1.5}, { type: "melee", damage: 4, cooldown: 1800, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }] },
|
||||||
|
{ id: 9, name: 'Verdant Agahnim', hp: 30, damage: 5, defense: 2, speed: 0, colors: { body: "#228B22", eye: "#90EE90", accent: "#006400" }, behavior: "stationary", drawFunction: "drawWizzrobe", description: "A dark mage protected by impenetrable barriers.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 7, cooldown: 1500, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 60, attacks: [{ type: "melee", damage: 7, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "projectile", damage: 7, cooldown: 1200, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 30, attacks: [{ type: "projectile", damage: 7, cooldown: 1500, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 10, attacks: [{ type: "melee", damage: 9, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "aoe", damage: 7, cooldown: 1500, count: 4, spreadAngle: 180, projectileSpeed: 3}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }] },
|
||||||
|
{ id: 10, name: 'Green Darknut', hp: 30, damage: 4, defense: 2, speed: 1.2, colors: { body: "#228B22", eye: "#90EE90", accent: "#006400" }, behavior: "chase", drawFunction: "drawDarknut", description: "A heavily-armored knight wielding a massive sword.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 4, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 50, attacks: [{ type: "melee", damage: 5, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "melee", damage: 5, cooldown: 1500, count: 3, spreadAngle: 120, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }] },
|
||||||
|
{ id: 11, name: 'Verdant Gleeok', hp: 52, damage: 4, defense: 1, speed: 1.5, colors: { body: "#228B22", eye: "#90EE90", accent: "#006400" }, behavior: "charge", drawFunction: "drawGleeok", description: "A multi-headed dragon whose fiery breath devastates all.", phases: [{ hpThreshold: 100, attacks: [{ type: "aoe", damage: 5, cooldown: 2000, count: 2, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 4, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }] },
|
||||||
|
{ id: 12, name: 'Thorn Wizzrobe', hp: 30, damage: 3, defense: 0, speed: 0, colors: { body: "#228B22", eye: "#90EE90", accent: "#006400" }, behavior: "teleport", drawFunction: "drawWizzrobe", description: "A teleporting mage who commands destructive magic.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 4, cooldown: 1500, count: 2, spreadAngle: 60, projectileSpeed: 3}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }] },
|
||||||
|
{ id: 13, name: 'Root Moldorm', hp: 28, damage: 4, defense: 1, speed: 2, colors: { body: "#228B22", eye: "#90EE90", accent: "#006400" }, behavior: "charge", drawFunction: "drawGeneric", description: "A giant segmented worm that charges relentlessly.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 4, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 5, cooldown: 3000, count: 1, spreadAngle: 360, projectileSpeed: 0}, { type: "melee", damage: 5, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }] },
|
||||||
|
{ id: 14, name: 'Moss Genie', hp: 33, damage: 4, defense: 1, speed: 0, colors: { body: "#228B22", eye: "#90EE90", accent: "#006400" }, behavior: "stationary", drawFunction: "drawGeneric", description: "A lamp spirit that unleashes devastating elemental blasts.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 5, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 2.5}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }] },
|
||||||
|
{ id: 15, name: 'Dune Like Like', hp: 32, damage: 4, defense: 1, speed: 1, colors: { body: "#DAA520", eye: "#8B4513", accent: "#FFD700" }, behavior: "chase", drawFunction: "drawGeneric", description: "A voracious worm that steals hearts and shields.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 4, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 40, attacks: [{ type: "melee", damage: 5, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "summon", damage: 0, cooldown: 5000, count: 2, spreadAngle: 0, projectileSpeed: 0, enemyType: "slime"}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 20 }] },
|
||||||
|
{ id: 16, name: 'Sand Aquamentus', hp: 36, damage: 4, defense: 1, speed: 0, colors: { body: "#DAA520", eye: "#8B4513", accent: "#FFD700" }, behavior: "stationary", drawFunction: "drawGleeok", description: "A dragon that breathes flames at everything in sight.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 5, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 2}]}, { hpThreshold: 50, attacks: [{ type: "aoe", damage: 5, cooldown: 1800, count: 3, spreadAngle: 120, projectileSpeed: 2.5}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }] },
|
||||||
|
{ id: 17, name: 'Dune Manhandla', hp: 30, damage: 4, defense: 1, speed: 1.8, colors: { body: "#DAA520", eye: "#8B4513", accent: "#FFD700" }, behavior: "charge", drawFunction: "drawGeneric", description: "A spinning plant monster with razor-sharp limbs.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 4, cooldown: 1200, count: 4, spreadAngle: 360, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }] },
|
||||||
|
{ id: 18, name: 'Desert Ice Gleeok', hp: 52, damage: 4, defense: 1, speed: 1.5, colors: { body: "#DAA520", eye: "#8B4513", accent: "#FFD700" }, behavior: "charge", drawFunction: "drawGleeok", description: "A spectral dragon wielding ice and shadow.", phases: [{ hpThreshold: 100, attacks: [{ type: "aoe", damage: 5, cooldown: 2200, count: 2, spreadAngle: 360, projectileSpeed: 1.5}, { type: "melee", damage: 4, cooldown: 1800, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }] },
|
||||||
|
{ id: 19, name: 'Sand Agahnim', hp: 40, damage: 5, defense: 2, speed: 0, colors: { body: "#DAA520", eye: "#8B4513", accent: "#FFD700" }, behavior: "stationary", drawFunction: "drawWizzrobe", description: "A dark mage protected by impenetrable barriers.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 7, cooldown: 1500, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 60, attacks: [{ type: "melee", damage: 7, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "projectile", damage: 7, cooldown: 1200, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 30, attacks: [{ type: "projectile", damage: 7, cooldown: 1500, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 10, attacks: [{ type: "melee", damage: 9, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "aoe", damage: 7, cooldown: 1500, count: 4, spreadAngle: 180, projectileSpeed: 3}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }] },
|
||||||
|
{ id: 20, name: 'Sand Genie', hp: 44, damage: 4, defense: 1, speed: 0, colors: { body: "#DAA520", eye: "#8B4513", accent: "#FFD700" }, behavior: "stationary", drawFunction: "drawGeneric", description: "A lamp spirit that unleashes devastating elemental blasts.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 5, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 2.5}]}, { hpThreshold: 50, attacks: [{ type: "projectile", damage: 5, cooldown: 1200, count: 2, spreadAngle: 90, projectileSpeed: 2.5}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }] },
|
||||||
|
{ id: 21, name: 'Sand Darknut', hp: 60, damage: 5, defense: 3, speed: 1.2, colors: { body: "#DAA520", eye: "#8B4513", accent: "#FFD700" }, behavior: "chase", drawFunction: "drawDarknut", description: "A heavily-armored knight wielding a massive sword.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 5, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 50, attacks: [{ type: "melee", damage: 7, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "melee", damage: 7, cooldown: 1500, count: 3, spreadAngle: 120, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "hammer", weight: 8 }] },
|
||||||
|
{ id: 22, name: 'Sand Gleeok', hp: 52, damage: 4, defense: 1, speed: 1.5, colors: { body: "#DAA520", eye: "#8B4513", accent: "#FFD700" }, behavior: "charge", drawFunction: "drawGleeok", description: "A multi-headed dragon whose fiery breath devastates all.", phases: [{ hpThreshold: 100, attacks: [{ type: "aoe", damage: 5, cooldown: 2000, count: 2, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 4, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }] },
|
||||||
|
{ id: 23, name: 'Mirage Wizzrobe', hp: 60, damage: 4, defense: 1, speed: 0, colors: { body: "#DAA520", eye: "#8B4513", accent: "#FFD700" }, behavior: "teleport", drawFunction: "drawWizzrobe", description: "A teleporting mage who commands destructive magic.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 5, cooldown: 1500, count: 2, spreadAngle: 60, projectileSpeed: 3}]}, { hpThreshold: 40, attacks: [{ type: "projectile", damage: 6, cooldown: 1200, count: 3, spreadAngle: 90, projectileSpeed: 3.5}, { type: "summon", damage: 0, cooldown: 4000, count: 2, spreadAngle: 0, projectileSpeed: 0, enemyType: "keese"}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "ice_rod", weight: 5 }] },
|
||||||
|
{ id: 24, name: 'Dune Moldorm', hp: 28, damage: 4, defense: 1, speed: 2, colors: { body: "#DAA520", eye: "#8B4513", accent: "#FFD700" }, behavior: "charge", drawFunction: "drawGeneric", description: "A giant segmented worm that charges relentlessly.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 4, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 5, cooldown: 3000, count: 1, spreadAngle: 360, projectileSpeed: 0}, { type: "melee", damage: 5, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }] },
|
||||||
|
{ id: 25, name: 'Sand Genie', hp: 66, damage: 5, defense: 2, speed: 0, colors: { body: "#DAA520", eye: "#8B4513", accent: "#FFD700" }, behavior: "stationary", drawFunction: "drawGeneric", description: "A lamp spirit that unleashes devastating elemental blasts.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 7, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 2.5}]}, { hpThreshold: 50, attacks: [{ type: "projectile", damage: 7, cooldown: 1200, count: 2, spreadAngle: 90, projectileSpeed: 2.5}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }] },
|
||||||
|
{ id: 26, name: 'Dune Like Like', hp: 32, damage: 4, defense: 1, speed: 1, colors: { body: "#DAA520", eye: "#8B4513", accent: "#FFD700" }, behavior: "chase", drawFunction: "drawGeneric", description: "A voracious worm that steals hearts and shields.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 4, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 40, attacks: [{ type: "melee", damage: 5, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "summon", damage: 0, cooldown: 5000, count: 2, spreadAngle: 0, projectileSpeed: 0, enemyType: "slime"}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 20 }] },
|
||||||
|
{ id: 27, name: 'Frost Aquamentus', hp: 72, damage: 5, defense: 2, speed: 0, colors: { body: "#4682B4", eye: "#E0E0E0", accent: "#87CEEB" }, behavior: "stationary", drawFunction: "drawGleeok", description: "A dragon that breathes flames at everything in sight.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 7, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 2}]}, { hpThreshold: 50, attacks: [{ type: "aoe", damage: 7, cooldown: 1800, count: 3, spreadAngle: 120, projectileSpeed: 2.5}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }] },
|
||||||
|
{ id: 28, name: 'Frost Manhandla', hp: 40, damage: 4, defense: 1, speed: 1.8, colors: { body: "#4682B4", eye: "#E0E0E0", accent: "#87CEEB" }, behavior: "charge", drawFunction: "drawGeneric", description: "A spinning plant monster with razor-sharp limbs.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 4, cooldown: 1200, count: 4, spreadAngle: 360, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 5, cooldown: 2000, count: 4, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 5, cooldown: 800, count: 4, spreadAngle: 360, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }] },
|
||||||
|
{ id: 29, name: 'Glacier Gleeok', hp: 78, damage: 5, defense: 2, speed: 1.5, colors: { body: "#4682B4", eye: "#E0E0E0", accent: "#87CEEB" }, behavior: "charge", drawFunction: "drawGleeok", description: "A spectral dragon wielding ice and shadow.", phases: [{ hpThreshold: 100, attacks: [{ type: "aoe", damage: 7, cooldown: 2200, count: 2, spreadAngle: 360, projectileSpeed: 1.5}, { type: "melee", damage: 5, cooldown: 1800, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 60, attacks: [{ type: "aoe", damage: 8, cooldown: 2000, count: 3, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 6, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }] },
|
||||||
|
{ id: 30, name: 'Frost Agahnim', hp: 60, damage: 6, defense: 3, speed: 0, colors: { body: "#4682B4", eye: "#E0E0E0", accent: "#87CEEB" }, behavior: "stationary", drawFunction: "drawWizzrobe", description: "A dark mage protected by impenetrable barriers.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 8, cooldown: 1500, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 60, attacks: [{ type: "melee", damage: 8, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "projectile", damage: 8, cooldown: 1200, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 30, attacks: [{ type: "projectile", damage: 8, cooldown: 1500, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 10, attacks: [{ type: "melee", damage: 10, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "aoe", damage: 8, cooldown: 1500, count: 4, spreadAngle: 180, projectileSpeed: 3}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }] },
|
||||||
|
{ id: 31, name: 'Frost Like Like', hp: 64, damage: 5, defense: 1, speed: 1, colors: { body: "#4682B4", eye: "#E0E0E0", accent: "#87CEEB" }, behavior: "chase", drawFunction: "drawGeneric", description: "A voracious worm that steals hearts and shields.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 5, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 40, attacks: [{ type: "melee", damage: 7, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "summon", damage: 0, cooldown: 5000, count: 2, spreadAngle: 0, projectileSpeed: 0, enemyType: "slime"}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "super_potion", weight: 20 }] },
|
||||||
|
{ id: 32, name: 'Iron Darknut', hp: 60, damage: 5, defense: 3, speed: 1.2, colors: { body: "#4682B4", eye: "#E0E0E0", accent: "#87CEEB" }, behavior: "chase", drawFunction: "drawDarknut", description: "A heavily-armored knight wielding a massive sword.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 5, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 50, attacks: [{ type: "melee", damage: 7, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "melee", damage: 7, cooldown: 1500, count: 3, spreadAngle: 120, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "hammer", weight: 8 }] },
|
||||||
|
{ id: 33, name: 'Frost Gleeok', hp: 104, damage: 5, defense: 1, speed: 1.5, colors: { body: "#4682B4", eye: "#E0E0E0", accent: "#87CEEB" }, behavior: "charge", drawFunction: "drawGleeok", description: "A multi-headed dragon whose fiery breath devastates all.", phases: [{ hpThreshold: 100, attacks: [{ type: "aoe", damage: 7, cooldown: 2000, count: 2, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 5, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 60, attacks: [{ type: "aoe", damage: 8, cooldown: 1800, count: 3, spreadAngle: 360, projectileSpeed: 2.5}, { type: "melee", damage: 6, cooldown: 1200, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "fire_rod", weight: 5 }] },
|
||||||
|
{ id: 34, name: 'Frost Wizzrobe', hp: 60, damage: 4, defense: 1, speed: 0, colors: { body: "#4682B4", eye: "#E0E0E0", accent: "#87CEEB" }, behavior: "teleport", drawFunction: "drawWizzrobe", description: "A teleporting mage who commands destructive magic.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 5, cooldown: 1500, count: 2, spreadAngle: 60, projectileSpeed: 3}]}, { hpThreshold: 40, attacks: [{ type: "projectile", damage: 6, cooldown: 1200, count: 3, spreadAngle: 90, projectileSpeed: 3.5}, { type: "summon", damage: 0, cooldown: 4000, count: 2, spreadAngle: 0, projectileSpeed: 0, enemyType: "keese"}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "ice_rod", weight: 5 }] },
|
||||||
|
{ id: 35, name: 'Frost Moldorm', hp: 56, damage: 5, defense: 1, speed: 2, colors: { body: "#4682B4", eye: "#E0E0E0", accent: "#87CEEB" }, behavior: "charge", drawFunction: "drawGeneric", description: "A giant segmented worm that charges relentlessly.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 5, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 7, cooldown: 3000, count: 1, spreadAngle: 360, projectileSpeed: 0}, { type: "melee", damage: 7, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }] },
|
||||||
|
{ id: 36, name: 'Ice Genie', hp: 66, damage: 5, defense: 2, speed: 0, colors: { body: "#4682B4", eye: "#E0E0E0", accent: "#87CEEB" }, behavior: "stationary", drawFunction: "drawGeneric", description: "A lamp spirit that unleashes devastating elemental blasts.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 7, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 2.5}]}, { hpThreshold: 50, attacks: [{ type: "projectile", damage: 7, cooldown: 1200, count: 2, spreadAngle: 90, projectileSpeed: 2.5}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }] },
|
||||||
|
{ id: 37, name: 'Frost Like Like', hp: 64, damage: 5, defense: 1, speed: 1, colors: { body: "#4682B4", eye: "#E0E0E0", accent: "#87CEEB" }, behavior: "chase", drawFunction: "drawGeneric", description: "A voracious worm that steals hearts and shields.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 5, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 40, attacks: [{ type: "melee", damage: 7, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "summon", damage: 0, cooldown: 5000, count: 2, spreadAngle: 0, projectileSpeed: 0, enemyType: "slime"}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "super_potion", weight: 20 }] },
|
||||||
|
{ id: 38, name: 'Frost Aquamentus', hp: 72, damage: 5, defense: 2, speed: 0, colors: { body: "#4682B4", eye: "#E0E0E0", accent: "#87CEEB" }, behavior: "stationary", drawFunction: "drawGleeok", description: "A dragon that breathes flames at everything in sight.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 7, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 2}]}, { hpThreshold: 50, attacks: [{ type: "aoe", damage: 7, cooldown: 1800, count: 3, spreadAngle: 120, projectileSpeed: 2.5}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }] },
|
||||||
|
{ id: 39, name: 'Crystal Manhandla', hp: 80, damage: 5, defense: 1, speed: 1.8, colors: { body: "#4B0082", eye: "#DA70D6", accent: "#9400D3" }, behavior: "charge", drawFunction: "drawGeneric", description: "A spinning plant monster with razor-sharp limbs.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 5, cooldown: 1200, count: 4, spreadAngle: 360, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 7, cooldown: 2000, count: 4, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 7, cooldown: 800, count: 4, spreadAngle: 360, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "boomerang", weight: 5 }] },
|
||||||
|
{ id: 40, name: 'Shadow Ice Gleeok', hp: 104, damage: 5, defense: 1, speed: 1.5, colors: { body: "#4B0082", eye: "#DA70D6", accent: "#9400D3" }, behavior: "charge", drawFunction: "drawGleeok", description: "A spectral dragon wielding ice and shadow.", phases: [{ hpThreshold: 100, attacks: [{ type: "aoe", damage: 7, cooldown: 2200, count: 2, spreadAngle: 360, projectileSpeed: 1.5}, { type: "melee", damage: 5, cooldown: 1800, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 60, attacks: [{ type: "aoe", damage: 8, cooldown: 2000, count: 3, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 6, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "fire_rod", weight: 5 }] },
|
||||||
|
{ id: 41, name: 'Crystal Agahnim', hp: 115, damage: 7, defense: 3, speed: 0, colors: { body: "#4B0082", eye: "#DA70D6", accent: "#9400D3" }, behavior: "stationary", drawFunction: "drawWizzrobe", description: "A dark mage protected by impenetrable barriers.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 9, cooldown: 1500, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 60, attacks: [{ type: "melee", damage: 9, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "projectile", damage: 9, cooldown: 1200, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 30, attacks: [{ type: "projectile", damage: 9, cooldown: 1500, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 10, attacks: [{ type: "melee", damage: 11, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "aoe", damage: 9, cooldown: 1500, count: 4, spreadAngle: 180, projectileSpeed: 3}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "magic_sword", weight: 5 }] },
|
||||||
|
{ id: 42, name: 'Deep Moldorm', hp: 56, damage: 5, defense: 1, speed: 2, colors: { body: "#4B0082", eye: "#DA70D6", accent: "#9400D3" }, behavior: "charge", drawFunction: "drawGeneric", description: "A giant segmented worm that charges relentlessly.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 5, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 7, cooldown: 3000, count: 1, spreadAngle: 360, projectileSpeed: 0}, { type: "melee", damage: 7, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }] },
|
||||||
|
{ id: 43, name: 'Deep Darknut', hp: 115, damage: 6, defense: 3, speed: 1.2, colors: { body: "#4B0082", eye: "#DA70D6", accent: "#9400D3" }, behavior: "chase", drawFunction: "drawDarknut", description: "A heavily-armored knight wielding a massive sword.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 6, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 50, attacks: [{ type: "melee", damage: 8, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "melee", damage: 8, cooldown: 1500, count: 3, spreadAngle: 120, projectileSpeed: 0}]}, { hpThreshold: 25, attacks: [{ type: "melee", damage: 10, cooldown: 600, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "aoe", damage: 6, cooldown: 2000, count: 4, spreadAngle: 180, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "hammer", weight: 8 }] },
|
||||||
|
{ id: 44, name: 'Shadow Gleeok', hp: 104, damage: 5, defense: 1, speed: 1.5, colors: { body: "#4B0082", eye: "#DA70D6", accent: "#9400D3" }, behavior: "charge", drawFunction: "drawGleeok", description: "A multi-headed dragon whose fiery breath devastates all.", phases: [{ hpThreshold: 100, attacks: [{ type: "aoe", damage: 7, cooldown: 2000, count: 2, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 5, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 60, attacks: [{ type: "aoe", damage: 8, cooldown: 1800, count: 3, spreadAngle: 360, projectileSpeed: 2.5}, { type: "melee", damage: 6, cooldown: 1200, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "fire_rod", weight: 5 }] },
|
||||||
|
{ id: 45, name: 'Crystal Wizzrobe', hp: 115, damage: 5, defense: 1, speed: 0, colors: { body: "#4B0082", eye: "#DA70D6", accent: "#9400D3" }, behavior: "teleport", drawFunction: "drawWizzrobe", description: "A teleporting mage who commands destructive magic.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 7, cooldown: 1500, count: 2, spreadAngle: 60, projectileSpeed: 3}]}, { hpThreshold: 40, attacks: [{ type: "projectile", damage: 8, cooldown: 1200, count: 3, spreadAngle: 90, projectileSpeed: 3.5}, { type: "summon", damage: 0, cooldown: 4000, count: 2, spreadAngle: 0, projectileSpeed: 0, enemyType: "stalfos"}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "ice_rod", weight: 5 }] },
|
||||||
|
{ id: 46, name: 'Deep Moldorm', hp: 56, damage: 5, defense: 1, speed: 2, colors: { body: "#4B0082", eye: "#DA70D6", accent: "#9400D3" }, behavior: "charge", drawFunction: "drawGeneric", description: "A giant segmented worm that charges relentlessly.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 5, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 7, cooldown: 3000, count: 1, spreadAngle: 360, projectileSpeed: 0}, { type: "melee", damage: 7, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }] },
|
||||||
|
{ id: 47, name: 'Crystal Genie', hp: 127, damage: 6, defense: 2, speed: 0, colors: { body: "#4B0082", eye: "#DA70D6", accent: "#9400D3" }, behavior: "stationary", drawFunction: "drawGeneric", description: "A lamp spirit that unleashes devastating elemental blasts.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 8, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 2.5}]}, { hpThreshold: 50, attacks: [{ type: "projectile", damage: 8, cooldown: 1200, count: 2, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 20, attacks: [{ type: "aoe", damage: 9, cooldown: 1000, count: 3, spreadAngle: 120, projectileSpeed: 3}, { type: "projectile", damage: 8, cooldown: 800, count: 2, spreadAngle: 45, projectileSpeed: 3.5}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "magic_crystal", weight: 10 }] },
|
||||||
|
{ id: 48, name: 'Deep Like Like', hp: 64, damage: 5, defense: 1, speed: 1, colors: { body: "#4B0082", eye: "#DA70D6", accent: "#9400D3" }, behavior: "chase", drawFunction: "drawGeneric", description: "A voracious worm that steals hearts and shields.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 5, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 40, attacks: [{ type: "melee", damage: 7, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "summon", damage: 0, cooldown: 5000, count: 2, spreadAngle: 0, projectileSpeed: 0, enemyType: "slime"}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "super_potion", weight: 20 }] },
|
||||||
|
{ id: 49, name: 'Crystal Aquamentus', hp: 138, damage: 6, defense: 2, speed: 0, colors: { body: "#4B0082", eye: "#DA70D6", accent: "#9400D3" }, behavior: "stationary", drawFunction: "drawGleeok", description: "A dragon that breathes flames at everything in sight.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 8, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 2}]}, { hpThreshold: 50, attacks: [{ type: "aoe", damage: 8, cooldown: 1800, count: 3, spreadAngle: 120, projectileSpeed: 2.5}]}, { hpThreshold: 20, attacks: [{ type: "aoe", damage: 10, cooldown: 1200, count: 3, spreadAngle: 180, projectileSpeed: 3}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "fire_rod", weight: 5 }] },
|
||||||
|
{ id: 50, name: 'Crystal Manhandla', hp: 115, damage: 6, defense: 2, speed: 1.8, colors: { body: "#4B0082", eye: "#DA70D6", accent: "#9400D3" }, behavior: "charge", drawFunction: "drawGeneric", description: "A spinning plant monster with razor-sharp limbs.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 6, cooldown: 1200, count: 4, spreadAngle: 360, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 8, cooldown: 2000, count: 4, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 8, cooldown: 800, count: 4, spreadAngle: 360, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "boomerang", weight: 5 }] },
|
||||||
|
{ id: 51, name: 'Shadow Ice Gleeok', hp: 195, damage: 6, defense: 2, speed: 1.5, colors: { body: "#4B0082", eye: "#DA70D6", accent: "#9400D3" }, behavior: "charge", drawFunction: "drawGleeok", description: "A spectral dragon wielding ice and shadow.", phases: [{ hpThreshold: 100, attacks: [{ type: "aoe", damage: 8, cooldown: 2200, count: 2, spreadAngle: 360, projectileSpeed: 1.5}, { type: "melee", damage: 6, cooldown: 1800, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 60, attacks: [{ type: "aoe", damage: 9, cooldown: 2000, count: 3, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 7, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 10, cooldown: 1400, count: 4, spreadAngle: 360, projectileSpeed: 2.5}, { type: "melee", damage: 8, cooldown: 1100, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "fire_rod", weight: 5 }] },
|
||||||
|
{ id: 52, name: 'Crystal Agahnim', hp: 115, damage: 7, defense: 3, speed: 0, colors: { body: "#4B0082", eye: "#DA70D6", accent: "#9400D3" }, behavior: "stationary", drawFunction: "drawWizzrobe", description: "A dark mage protected by impenetrable barriers.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 9, cooldown: 1500, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 60, attacks: [{ type: "melee", damage: 9, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "projectile", damage: 9, cooldown: 1200, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 30, attacks: [{ type: "projectile", damage: 9, cooldown: 1500, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 10, attacks: [{ type: "melee", damage: 11, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "aoe", damage: 9, cooldown: 1500, count: 4, spreadAngle: 180, projectileSpeed: 3}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "magic_sword", weight: 5 }] },
|
||||||
|
{ id: 53, name: 'Tide Wizzrobe', hp: 150, damage: 5, defense: 1, speed: 0, colors: { body: "#006994", eye: "#00CED1", accent: "#4682B4" }, behavior: "teleport", drawFunction: "drawWizzrobe", description: "A teleporting mage who commands destructive magic.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 7, cooldown: 1500, count: 2, spreadAngle: 60, projectileSpeed: 3}]}, { hpThreshold: 40, attacks: [{ type: "projectile", damage: 8, cooldown: 1200, count: 3, spreadAngle: 90, projectileSpeed: 3.5}, { type: "summon", damage: 0, cooldown: 4000, count: 2, spreadAngle: 0, projectileSpeed: 0, enemyType: "stalfos"}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "ice_rod", weight: 5 }, { item: "magic_potion", weight: 15 }] },
|
||||||
|
{ id: 54, name: 'Abyssal Darknut', hp: 115, damage: 6, defense: 3, speed: 1.2, colors: { body: "#006994", eye: "#00CED1", accent: "#4682B4" }, behavior: "chase", drawFunction: "drawDarknut", description: "A heavily-armored knight wielding a massive sword.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 6, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 50, attacks: [{ type: "melee", damage: 8, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "melee", damage: 8, cooldown: 1500, count: 3, spreadAngle: 120, projectileSpeed: 0}]}, { hpThreshold: 25, attacks: [{ type: "melee", damage: 10, cooldown: 600, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "aoe", damage: 6, cooldown: 2000, count: 4, spreadAngle: 180, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "hammer", weight: 8 }] },
|
||||||
|
{ id: 55, name: 'Tidal Gleeok', hp: 150, damage: 6, defense: 2, speed: 1.5, colors: { body: "#006994", eye: "#00CED1", accent: "#4682B4" }, behavior: "charge", drawFunction: "drawGleeok", description: "A multi-headed dragon whose fiery breath devastates all.", phases: [{ hpThreshold: 100, attacks: [{ type: "aoe", damage: 8, cooldown: 2000, count: 2, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 6, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 60, attacks: [{ type: "aoe", damage: 9, cooldown: 1800, count: 3, spreadAngle: 360, projectileSpeed: 2.5}, { type: "melee", damage: 7, cooldown: 1200, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "fire_rod", weight: 5 }] },
|
||||||
|
{ id: 56, name: 'Tide Wizzrobe', hp: 115, damage: 5, defense: 1, speed: 0, colors: { body: "#006994", eye: "#00CED1", accent: "#4682B4" }, behavior: "teleport", drawFunction: "drawWizzrobe", description: "A teleporting mage who commands destructive magic.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 7, cooldown: 1500, count: 2, spreadAngle: 60, projectileSpeed: 3}]}, { hpThreshold: 40, attacks: [{ type: "projectile", damage: 8, cooldown: 1200, count: 3, spreadAngle: 90, projectileSpeed: 3.5}, { type: "summon", damage: 0, cooldown: 4000, count: 2, spreadAngle: 0, projectileSpeed: 0, enemyType: "stalfos"}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "ice_rod", weight: 5 }] },
|
||||||
|
{ id: 57, name: 'Abyssal Moldorm', hp: 105, damage: 6, defense: 2, speed: 2, colors: { body: "#006994", eye: "#00CED1", accent: "#4682B4" }, behavior: "charge", drawFunction: "drawGeneric", description: "A giant segmented worm that charges relentlessly.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 6, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 8, cooldown: 3000, count: 1, spreadAngle: 360, projectileSpeed: 0}, { type: "melee", damage: 8, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "hookshot", weight: 3 }] },
|
||||||
|
{ id: 58, name: 'Tidal Genie', hp: 127, damage: 6, defense: 2, speed: 0, colors: { body: "#006994", eye: "#00CED1", accent: "#4682B4" }, behavior: "stationary", drawFunction: "drawGeneric", description: "A lamp spirit that unleashes devastating elemental blasts.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 8, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 2.5}]}, { hpThreshold: 50, attacks: [{ type: "projectile", damage: 8, cooldown: 1200, count: 2, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 20, attacks: [{ type: "aoe", damage: 9, cooldown: 1000, count: 3, spreadAngle: 120, projectileSpeed: 3}, { type: "projectile", damage: 8, cooldown: 800, count: 2, spreadAngle: 45, projectileSpeed: 3.5}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "magic_crystal", weight: 10 }] },
|
||||||
|
{ id: 59, name: 'Abyssal Like Like', hp: 92, damage: 6, defense: 2, speed: 1, colors: { body: "#006994", eye: "#00CED1", accent: "#4682B4" }, behavior: "chase", drawFunction: "drawGeneric", description: "A voracious worm that steals hearts and shields.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 6, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 40, attacks: [{ type: "melee", damage: 8, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "summon", damage: 0, cooldown: 5000, count: 2, spreadAngle: 0, projectileSpeed: 0, enemyType: "slime"}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "super_potion", weight: 20 }] },
|
||||||
|
{ id: 60, name: 'Tidal Aquamentus', hp: 180, damage: 6, defense: 2, speed: 0, colors: { body: "#006994", eye: "#00CED1", accent: "#4682B4" }, behavior: "stationary", drawFunction: "drawGleeok", description: "A dragon that breathes flames at everything in sight.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 8, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 2}]}, { hpThreshold: 50, attacks: [{ type: "aoe", damage: 8, cooldown: 1800, count: 3, spreadAngle: 120, projectileSpeed: 2.5}]}, { hpThreshold: 20, attacks: [{ type: "aoe", damage: 10, cooldown: 1200, count: 3, spreadAngle: 180, projectileSpeed: 3}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "fire_rod", weight: 5 }] },
|
||||||
|
{ id: 61, name: 'Abyssal Manhandla', hp: 200, damage: 8, defense: 3, speed: 1.8, colors: { body: "#006994", eye: "#00CED1", accent: "#4682B4" }, behavior: "charge", drawFunction: "drawGeneric", description: "A spinning plant monster with razor-sharp limbs.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 8, cooldown: 1200, count: 4, spreadAngle: 360, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 10, cooldown: 2000, count: 4, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 10, cooldown: 800, count: 4, spreadAngle: 360, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "boomerang", weight: 5 }] },
|
||||||
|
{ id: 62, name: 'Abyssal Water Gleeok', hp: 195, damage: 6, defense: 2, speed: 1.5, colors: { body: "#006994", eye: "#00CED1", accent: "#4682B4" }, behavior: "charge", drawFunction: "drawGleeok", description: "A spectral dragon wielding ice and shadow.", phases: [{ hpThreshold: 100, attacks: [{ type: "aoe", damage: 8, cooldown: 2200, count: 2, spreadAngle: 360, projectileSpeed: 1.5}, { type: "melee", damage: 6, cooldown: 1800, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 60, attacks: [{ type: "aoe", damage: 9, cooldown: 2000, count: 3, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 7, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 10, cooldown: 1400, count: 4, spreadAngle: 360, projectileSpeed: 2.5}, { type: "melee", damage: 8, cooldown: 1100, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "fire_rod", weight: 5 }] },
|
||||||
|
{ id: 63, name: 'Tidal Agahnim', hp: 200, damage: 10, defense: 4, speed: 0, colors: { body: "#006994", eye: "#00CED1", accent: "#4682B4" }, behavior: "stationary", drawFunction: "drawWizzrobe", description: "A dark mage protected by impenetrable barriers.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 13, cooldown: 1500, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 60, attacks: [{ type: "melee", damage: 13, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "projectile", damage: 13, cooldown: 1200, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 30, attacks: [{ type: "projectile", damage: 13, cooldown: 1500, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 10, attacks: [{ type: "melee", damage: 15, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "aoe", damage: 13, cooldown: 1500, count: 4, spreadAngle: 180, projectileSpeed: 3}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "magic_sword", weight: 5 }] },
|
||||||
|
{ id: 64, name: 'Abyssal Darknut', hp: 150, damage: 6, defense: 3, speed: 1.2, colors: { body: "#006994", eye: "#00CED1", accent: "#4682B4" }, behavior: "chase", drawFunction: "drawDarknut", description: "A heavily-armored knight wielding a massive sword.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 6, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 50, attacks: [{ type: "melee", damage: 8, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "melee", damage: 8, cooldown: 1500, count: 3, spreadAngle: 120, projectileSpeed: 0}]}, { hpThreshold: 25, attacks: [{ type: "melee", damage: 10, cooldown: 600, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "aoe", damage: 6, cooldown: 2000, count: 4, spreadAngle: 180, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "hammer", weight: 8 }, { item: "magic_sword", weight: 3 }] },
|
||||||
|
{ id: 65, name: 'Abyssal Darknut', hp: 200, damage: 8, defense: 4, speed: 1.2, colors: { body: "#006994", eye: "#00CED1", accent: "#4682B4" }, behavior: "chase", drawFunction: "drawDarknut", description: "A heavily-armored knight wielding a massive sword.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 8, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 50, attacks: [{ type: "melee", damage: 10, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "melee", damage: 10, cooldown: 1500, count: 3, spreadAngle: 120, projectileSpeed: 0}]}, { hpThreshold: 25, attacks: [{ type: "melee", damage: 12, cooldown: 600, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "aoe", damage: 8, cooldown: 2000, count: 4, spreadAngle: 180, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "hammer", weight: 8 }, { item: "magic_sword", weight: 3 }] },
|
||||||
|
{ id: 66, name: 'Storm Gleeok', hp: 195, damage: 6, defense: 2, speed: 1.5, colors: { body: "#FFD700", eye: "#FFFACD", accent: "#FF8C00" }, behavior: "charge", drawFunction: "drawGleeok", description: "A multi-headed dragon whose fiery breath devastates all.", phases: [{ hpThreshold: 100, attacks: [{ type: "aoe", damage: 8, cooldown: 2000, count: 2, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 6, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 60, attacks: [{ type: "aoe", damage: 9, cooldown: 1800, count: 3, spreadAngle: 360, projectileSpeed: 2.5}, { type: "melee", damage: 7, cooldown: 1200, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 10, cooldown: 1200, count: 4, spreadAngle: 360, projectileSpeed: 3}, { type: "melee", damage: 8, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "fire_rod", weight: 5 }] },
|
||||||
|
{ id: 67, name: 'Storm Wizzrobe', hp: 200, damage: 6, defense: 2, speed: 0, colors: { body: "#FFD700", eye: "#FFFACD", accent: "#FF8C00" }, behavior: "teleport", drawFunction: "drawWizzrobe", description: "A teleporting mage who commands destructive magic.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 8, cooldown: 1500, count: 2, spreadAngle: 60, projectileSpeed: 3}]}, { hpThreshold: 40, attacks: [{ type: "projectile", damage: 9, cooldown: 1200, count: 3, spreadAngle: 90, projectileSpeed: 3.5}, { type: "summon", damage: 0, cooldown: 4000, count: 2, spreadAngle: 0, projectileSpeed: 0, enemyType: "stalfos"}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "ice_rod", weight: 5 }, { item: "magic_potion", weight: 15 }] },
|
||||||
|
{ id: 68, name: 'Cloud Moldorm', hp: 105, damage: 6, defense: 2, speed: 2, colors: { body: "#FFD700", eye: "#FFFACD", accent: "#FF8C00" }, behavior: "charge", drawFunction: "drawGeneric", description: "A giant segmented worm that charges relentlessly.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 6, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 8, cooldown: 3000, count: 1, spreadAngle: 360, projectileSpeed: 0}, { type: "melee", damage: 8, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "hookshot", weight: 3 }] },
|
||||||
|
{ id: 69, name: 'Sky Genie', hp: 165, damage: 6, defense: 2, speed: 0, colors: { body: "#FFD700", eye: "#FFFACD", accent: "#FF8C00" }, behavior: "stationary", drawFunction: "drawGeneric", description: "A lamp spirit that unleashes devastating elemental blasts.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 8, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 2.5}]}, { hpThreshold: 50, attacks: [{ type: "projectile", damage: 8, cooldown: 1200, count: 2, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 20, attacks: [{ type: "aoe", damage: 9, cooldown: 1000, count: 3, spreadAngle: 120, projectileSpeed: 3}, { type: "projectile", damage: 8, cooldown: 800, count: 2, spreadAngle: 45, projectileSpeed: 3.5}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "magic_crystal", weight: 10 }] },
|
||||||
|
{ id: 70, name: 'Cloud Like Like', hp: 160, damage: 8, defense: 3, speed: 1, colors: { body: "#FFD700", eye: "#FFFACD", accent: "#FF8C00" }, behavior: "chase", drawFunction: "drawGeneric", description: "A voracious worm that steals hearts and shields.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 8, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 40, attacks: [{ type: "melee", damage: 10, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "summon", damage: 0, cooldown: 5000, count: 2, spreadAngle: 0, projectileSpeed: 0, enemyType: "slime"}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "super_potion", weight: 20 }] },
|
||||||
|
{ id: 71, name: 'Storm Aquamentus', hp: 300, damage: 8, defense: 3, speed: 0, colors: { body: "#FFD700", eye: "#FFFACD", accent: "#FF8C00" }, behavior: "stationary", drawFunction: "drawGleeok", description: "A dragon that breathes flames at everything in sight.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 10, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 2}]}, { hpThreshold: 50, attacks: [{ type: "aoe", damage: 10, cooldown: 1800, count: 3, spreadAngle: 120, projectileSpeed: 2.5}]}, { hpThreshold: 20, attacks: [{ type: "aoe", damage: 12, cooldown: 1200, count: 3, spreadAngle: 180, projectileSpeed: 3}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "fire_rod", weight: 5 }] },
|
||||||
|
{ id: 72, name: 'Storm Manhandla', hp: 200, damage: 8, defense: 3, speed: 1.8, colors: { body: "#FFD700", eye: "#FFFACD", accent: "#FF8C00" }, behavior: "charge", drawFunction: "drawGeneric", description: "A spinning plant monster with razor-sharp limbs.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 8, cooldown: 1200, count: 4, spreadAngle: 360, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 10, cooldown: 2000, count: 4, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 10, cooldown: 800, count: 4, spreadAngle: 360, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "boomerang", weight: 5 }] },
|
||||||
|
{ id: 73, name: 'Sky Ice Gleeok', hp: 325, damage: 8, defense: 3, speed: 1.5, colors: { body: "#FFD700", eye: "#FFFACD", accent: "#FF8C00" }, behavior: "charge", drawFunction: "drawGleeok", description: "A spectral dragon wielding ice and shadow.", phases: [{ hpThreshold: 100, attacks: [{ type: "aoe", damage: 10, cooldown: 2200, count: 2, spreadAngle: 360, projectileSpeed: 1.5}, { type: "melee", damage: 8, cooldown: 1800, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 60, attacks: [{ type: "aoe", damage: 11, cooldown: 2000, count: 3, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 9, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 12, cooldown: 1400, count: 4, spreadAngle: 360, projectileSpeed: 2.5}, { type: "melee", damage: 10, cooldown: 1100, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "fire_rod", weight: 5 }, { item: "master_sword", weight: 3 }] },
|
||||||
|
{ id: 74, name: 'Storm Agahnim', hp: 200, damage: 10, defense: 4, speed: 0, colors: { body: "#FFD700", eye: "#FFFACD", accent: "#FF8C00" }, behavior: "stationary", drawFunction: "drawWizzrobe", description: "A dark mage protected by impenetrable barriers.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 13, cooldown: 1500, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 60, attacks: [{ type: "melee", damage: 13, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "projectile", damage: 13, cooldown: 1200, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 30, attacks: [{ type: "projectile", damage: 13, cooldown: 1500, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 10, attacks: [{ type: "melee", damage: 15, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "aoe", damage: 13, cooldown: 1500, count: 4, spreadAngle: 180, projectileSpeed: 3}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "magic_sword", weight: 5 }] },
|
||||||
|
{ id: 75, name: 'Storm Gleeok', hp: 325, damage: 8, defense: 3, speed: 1.5, colors: { body: "#FFD700", eye: "#FFFACD", accent: "#FF8C00" }, behavior: "charge", drawFunction: "drawGleeok", description: "A multi-headed dragon whose fiery breath devastates all.", phases: [{ hpThreshold: 100, attacks: [{ type: "aoe", damage: 10, cooldown: 2000, count: 2, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 8, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 60, attacks: [{ type: "aoe", damage: 11, cooldown: 1800, count: 3, spreadAngle: 360, projectileSpeed: 2.5}, { type: "melee", damage: 9, cooldown: 1200, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 12, cooldown: 1200, count: 4, spreadAngle: 360, projectileSpeed: 3}, { type: "melee", damage: 10, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "fire_rod", weight: 5 }, { item: "master_sword", weight: 3 }] },
|
||||||
|
{ id: 76, name: 'Sky Darknut', hp: 200, damage: 8, defense: 4, speed: 1.2, colors: { body: "#FFD700", eye: "#FFFACD", accent: "#FF8C00" }, behavior: "chase", drawFunction: "drawDarknut", description: "A heavily-armored knight wielding a massive sword.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 8, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 50, attacks: [{ type: "melee", damage: 10, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "melee", damage: 10, cooldown: 1500, count: 3, spreadAngle: 120, projectileSpeed: 0}]}, { hpThreshold: 25, attacks: [{ type: "melee", damage: 12, cooldown: 600, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "aoe", damage: 8, cooldown: 2000, count: 4, spreadAngle: 180, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "hammer", weight: 8 }, { item: "magic_sword", weight: 3 }] },
|
||||||
|
{ id: 77, name: 'Storm Gleeok', hp: 325, damage: 8, defense: 3, speed: 1.5, colors: { body: "#FFD700", eye: "#FFFACD", accent: "#FF8C00" }, behavior: "charge", drawFunction: "drawGleeok", description: "A multi-headed dragon whose fiery breath devastates all.", phases: [{ hpThreshold: 100, attacks: [{ type: "aoe", damage: 10, cooldown: 2000, count: 2, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 8, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 60, attacks: [{ type: "aoe", damage: 11, cooldown: 1800, count: 3, spreadAngle: 360, projectileSpeed: 2.5}, { type: "melee", damage: 9, cooldown: 1200, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 12, cooldown: 1200, count: 4, spreadAngle: 360, projectileSpeed: 3}, { type: "melee", damage: 10, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "fire_rod", weight: 5 }, { item: "master_sword", weight: 3 }] },
|
||||||
|
{ id: 78, name: 'Storm Wizzrobe', hp: 200, damage: 6, defense: 2, speed: 0, colors: { body: "#FFD700", eye: "#FFFACD", accent: "#FF8C00" }, behavior: "teleport", drawFunction: "drawWizzrobe", description: "A teleporting mage who commands destructive magic.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 8, cooldown: 1500, count: 2, spreadAngle: 60, projectileSpeed: 3}]}, { hpThreshold: 40, attacks: [{ type: "projectile", damage: 9, cooldown: 1200, count: 3, spreadAngle: 90, projectileSpeed: 3.5}, { type: "summon", damage: 0, cooldown: 4000, count: 2, spreadAngle: 0, projectileSpeed: 0, enemyType: "stalfos"}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "ice_rod", weight: 5 }, { item: "magic_potion", weight: 15 }] },
|
||||||
|
{ id: 79, name: 'Shadow Moldorm', hp: 175, damage: 8, defense: 3, speed: 2, colors: { body: "#1A0A2A", eye: "#9400D3", accent: "#4B0082" }, behavior: "charge", drawFunction: "drawGeneric", description: "A giant segmented worm that charges relentlessly.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 8, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 10, cooldown: 3000, count: 1, spreadAngle: 360, projectileSpeed: 0}, { type: "melee", damage: 10, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "hookshot", weight: 3 }] },
|
||||||
|
{ id: 80, name: 'Dark Genie', hp: 275, damage: 8, defense: 3, speed: 0, colors: { body: "#1A0A2A", eye: "#9400D3", accent: "#4B0082" }, behavior: "stationary", drawFunction: "drawGeneric", description: "A lamp spirit that unleashes devastating elemental blasts.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 10, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 2.5}]}, { hpThreshold: 50, attacks: [{ type: "projectile", damage: 10, cooldown: 1200, count: 2, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 20, attacks: [{ type: "aoe", damage: 11, cooldown: 1000, count: 3, spreadAngle: 120, projectileSpeed: 3}, { type: "projectile", damage: 10, cooldown: 800, count: 2, spreadAngle: 45, projectileSpeed: 3.5}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "magic_crystal", weight: 10 }] },
|
||||||
|
{ id: 81, name: 'Shadow Like Like', hp: 200, damage: 8, defense: 3, speed: 1, colors: { body: "#1A0A2A", eye: "#9400D3", accent: "#4B0082" }, behavior: "chase", drawFunction: "drawGeneric", description: "A voracious worm that steals hearts and shields.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 8, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 40, attacks: [{ type: "melee", damage: 10, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "summon", damage: 0, cooldown: 5000, count: 2, spreadAngle: 0, projectileSpeed: 0, enemyType: "slime"}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "super_potion", weight: 20 }] },
|
||||||
|
{ id: 82, name: 'Dark Aquamentus', hp: 300, damage: 8, defense: 3, speed: 0, colors: { body: "#1A0A2A", eye: "#9400D3", accent: "#4B0082" }, behavior: "stationary", drawFunction: "drawGleeok", description: "A dragon that breathes flames at everything in sight.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 10, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 2}]}, { hpThreshold: 50, attacks: [{ type: "aoe", damage: 10, cooldown: 1800, count: 3, spreadAngle: 120, projectileSpeed: 2.5}]}, { hpThreshold: 20, attacks: [{ type: "aoe", damage: 12, cooldown: 1200, count: 3, spreadAngle: 180, projectileSpeed: 3}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "fire_rod", weight: 5 }] },
|
||||||
|
{ id: 83, name: 'Dark Manhandla', hp: 250, damage: 8, defense: 3, speed: 1.8, colors: { body: "#1A0A2A", eye: "#9400D3", accent: "#4B0082" }, behavior: "charge", drawFunction: "drawGeneric", description: "A spinning plant monster with razor-sharp limbs.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 8, cooldown: 1200, count: 4, spreadAngle: 360, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 10, cooldown: 2000, count: 4, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 10, cooldown: 800, count: 4, spreadAngle: 360, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "boomerang", weight: 5 }] },
|
||||||
|
{ id: 84, name: 'Dark Ice Gleeok', hp: 325, damage: 8, defense: 3, speed: 1.5, colors: { body: "#1A0A2A", eye: "#9400D3", accent: "#4B0082" }, behavior: "charge", drawFunction: "drawGleeok", description: "A spectral dragon wielding ice and shadow.", phases: [{ hpThreshold: 100, attacks: [{ type: "aoe", damage: 10, cooldown: 2200, count: 2, spreadAngle: 360, projectileSpeed: 1.5}, { type: "melee", damage: 8, cooldown: 1800, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 60, attacks: [{ type: "aoe", damage: 11, cooldown: 2000, count: 3, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 9, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 12, cooldown: 1400, count: 4, spreadAngle: 360, projectileSpeed: 2.5}, { type: "melee", damage: 10, cooldown: 1100, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "fire_rod", weight: 5 }, { item: "master_sword", weight: 3 }] },
|
||||||
|
{ id: 85, name: 'Void Agahnim', hp: 250, damage: 10, defense: 4, speed: 0, colors: { body: "#1A0A2A", eye: "#9400D3", accent: "#4B0082" }, behavior: "stationary", drawFunction: "drawWizzrobe", description: "A dark mage protected by impenetrable barriers.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 13, cooldown: 1500, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 60, attacks: [{ type: "melee", damage: 13, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "projectile", damage: 13, cooldown: 1200, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 30, attacks: [{ type: "projectile", damage: 13, cooldown: 1500, count: 3, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 10, attacks: [{ type: "melee", damage: 15, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "aoe", damage: 13, cooldown: 1500, count: 4, spreadAngle: 180, projectileSpeed: 3}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "magic_sword", weight: 5 }] },
|
||||||
|
{ id: 86, name: 'Shadow Moldorm', hp: 175, damage: 8, defense: 3, speed: 2, colors: { body: "#1A0A2A", eye: "#9400D3", accent: "#4B0082" }, behavior: "charge", drawFunction: "drawGeneric", description: "A giant segmented worm that charges relentlessly.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 8, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 10, cooldown: 3000, count: 1, spreadAngle: 360, projectileSpeed: 0}, { type: "melee", damage: 10, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "hookshot", weight: 3 }] },
|
||||||
|
{ id: 87, name: 'Shadow Darknut', hp: 250, damage: 8, defense: 4, speed: 1.2, colors: { body: "#1A0A2A", eye: "#9400D3", accent: "#4B0082" }, behavior: "chase", drawFunction: "drawDarknut", description: "A heavily-armored knight wielding a massive sword.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 8, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 50, attacks: [{ type: "melee", damage: 10, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "melee", damage: 10, cooldown: 1500, count: 3, spreadAngle: 120, projectileSpeed: 0}]}, { hpThreshold: 25, attacks: [{ type: "melee", damage: 12, cooldown: 600, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "aoe", damage: 8, cooldown: 2000, count: 4, spreadAngle: 180, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "hammer", weight: 8 }, { item: "magic_sword", weight: 3 }] },
|
||||||
|
{ id: 88, name: 'Dark Gleeok', hp: 325, damage: 8, defense: 3, speed: 1.5, colors: { body: "#1A0A2A", eye: "#9400D3", accent: "#4B0082" }, behavior: "charge", drawFunction: "drawGleeok", description: "A multi-headed dragon whose fiery breath devastates all.", phases: [{ hpThreshold: 100, attacks: [{ type: "aoe", damage: 10, cooldown: 2000, count: 2, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 8, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 60, attacks: [{ type: "aoe", damage: 11, cooldown: 1800, count: 3, spreadAngle: 360, projectileSpeed: 2.5}, { type: "melee", damage: 9, cooldown: 1200, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 12, cooldown: 1200, count: 4, spreadAngle: 360, projectileSpeed: 3}, { type: "melee", damage: 10, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "fire_rod", weight: 5 }, { item: "master_sword", weight: 3 }] },
|
||||||
|
{ id: 89, name: 'Void Wizzrobe', hp: 250, damage: 6, defense: 2, speed: 0, colors: { body: "#1A0A2A", eye: "#9400D3", accent: "#4B0082" }, behavior: "teleport", drawFunction: "drawWizzrobe", description: "A teleporting mage who commands destructive magic.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 8, cooldown: 1500, count: 2, spreadAngle: 60, projectileSpeed: 3}]}, { hpThreshold: 40, attacks: [{ type: "projectile", damage: 9, cooldown: 1200, count: 3, spreadAngle: 90, projectileSpeed: 3.5}, { type: "summon", damage: 0, cooldown: 4000, count: 2, spreadAngle: 0, projectileSpeed: 0, enemyType: "stalfos"}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "ice_rod", weight: 5 }, { item: "magic_potion", weight: 15 }] },
|
||||||
|
{ id: 90, name: 'Shadow Moldorm', hp: 175, damage: 8, defense: 3, speed: 2, colors: { body: "#1A0A2A", eye: "#9400D3", accent: "#4B0082" }, behavior: "charge", drawFunction: "drawGeneric", description: "A giant segmented worm that charges relentlessly.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 8, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 10, cooldown: 3000, count: 1, spreadAngle: 360, projectileSpeed: 0}, { type: "melee", damage: 10, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "hookshot", weight: 3 }] },
|
||||||
|
{ id: 91, name: 'Dark Genie', hp: 550, damage: 15, defense: 4, speed: 0, colors: { body: "#1A0A2A", eye: "#9400D3", accent: "#4B0082" }, behavior: "stationary", drawFunction: "drawGeneric", description: "A lamp spirit that unleashes devastating elemental blasts.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 20, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 2.5}]}, { hpThreshold: 50, attacks: [{ type: "projectile", damage: 20, cooldown: 1200, count: 2, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 20, attacks: [{ type: "aoe", damage: 21, cooldown: 1000, count: 3, spreadAngle: 120, projectileSpeed: 3}, { type: "projectile", damage: 20, cooldown: 800, count: 2, spreadAngle: 45, projectileSpeed: 3.5}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "magic_crystal", weight: 10 }] },
|
||||||
|
{ id: 92, name: 'Shadow Like Like', hp: 200, damage: 8, defense: 3, speed: 1, colors: { body: "#1A0A2A", eye: "#9400D3", accent: "#4B0082" }, behavior: "chase", drawFunction: "drawGeneric", description: "A voracious worm that steals hearts and shields.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 8, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 40, attacks: [{ type: "melee", damage: 10, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "summon", damage: 0, cooldown: 5000, count: 2, spreadAngle: 0, projectileSpeed: 0, enemyType: "slime"}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "super_potion", weight: 20 }] },
|
||||||
|
{ id: 93, name: 'Infernal Aquamentus', hp: 600, damage: 15, defense: 4, speed: 0, colors: { body: "#8B0000", eye: "#FFD700", accent: "#FF4500" }, behavior: "stationary", drawFunction: "drawGleeok", description: "A dragon that breathes flames at everything in sight.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 20, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 2}]}, { hpThreshold: 50, attacks: [{ type: "aoe", damage: 20, cooldown: 1800, count: 3, spreadAngle: 120, projectileSpeed: 2.5}]}, { hpThreshold: 20, attacks: [{ type: "aoe", damage: 22, cooldown: 1200, count: 3, spreadAngle: 180, projectileSpeed: 3}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "fire_rod", weight: 5 }] },
|
||||||
|
{ id: 94, name: 'Abyssal Like Like', hp: 200, damage: 8, defense: 3, speed: 1, colors: { body: "#8B0000", eye: "#FFD700", accent: "#FF4500" }, behavior: "chase", drawFunction: "drawGeneric", description: "A voracious worm that steals hearts and shields.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 8, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 40, attacks: [{ type: "melee", damage: 10, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "summon", damage: 0, cooldown: 5000, count: 2, spreadAngle: 0, projectileSpeed: 0, enemyType: "slime"}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "super_potion", weight: 20 }] },
|
||||||
|
{ id: 95, name: 'Demon Genie', hp: 550, damage: 15, defense: 4, speed: 0, colors: { body: "#8B0000", eye: "#FFD700", accent: "#FF4500" }, behavior: "stationary", drawFunction: "drawGeneric", description: "A lamp spirit that unleashes devastating elemental blasts.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 20, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 2.5}]}, { hpThreshold: 50, attacks: [{ type: "projectile", damage: 20, cooldown: 1200, count: 2, spreadAngle: 90, projectileSpeed: 2.5}]}, { hpThreshold: 20, attacks: [{ type: "aoe", damage: 21, cooldown: 1000, count: 3, spreadAngle: 120, projectileSpeed: 3}, { type: "projectile", damage: 20, cooldown: 800, count: 2, spreadAngle: 45, projectileSpeed: 3.5}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "magic_crystal", weight: 10 }] },
|
||||||
|
{ id: 96, name: 'Abyssal Moldorm', hp: 175, damage: 8, defense: 3, speed: 2, colors: { body: "#8B0000", eye: "#FFD700", accent: "#FF4500" }, behavior: "charge", drawFunction: "drawGeneric", description: "A giant segmented worm that charges relentlessly.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 8, cooldown: 2000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 10, cooldown: 3000, count: 1, spreadAngle: 360, projectileSpeed: 0}, { type: "melee", damage: 10, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "hookshot", weight: 3 }] },
|
||||||
|
{ id: 97, name: 'Dark Wizzrobe', hp: 500, damage: 12, defense: 3, speed: 0, colors: { body: "#8B0000", eye: "#FFD700", accent: "#FF4500" }, behavior: "teleport", drawFunction: "drawWizzrobe", description: "A teleporting mage who commands destructive magic.", phases: [{ hpThreshold: 100, attacks: [{ type: "projectile", damage: 16, cooldown: 1500, count: 2, spreadAngle: 60, projectileSpeed: 3}]}, { hpThreshold: 40, attacks: [{ type: "projectile", damage: 17, cooldown: 1200, count: 3, spreadAngle: 90, projectileSpeed: 3.5}, { type: "summon", damage: 0, cooldown: 4000, count: 2, spreadAngle: 0, projectileSpeed: 0, enemyType: "stalfos"}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "ice_rod", weight: 5 }, { item: "magic_potion", weight: 15 }] },
|
||||||
|
{ id: 98, name: 'Demon Darknut', hp: 250, damage: 8, defense: 4, speed: 1.2, colors: { body: "#8B0000", eye: "#FFD700", accent: "#FF4500" }, behavior: "chase", drawFunction: "drawDarknut", description: "A heavily-armored knight wielding a massive sword.", phases: [{ hpThreshold: 100, attacks: [{ type: "melee", damage: 8, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 50, attacks: [{ type: "melee", damage: 10, cooldown: 800, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "melee", damage: 10, cooldown: 1500, count: 3, spreadAngle: 120, projectileSpeed: 0}]}, { hpThreshold: 25, attacks: [{ type: "melee", damage: 12, cooldown: 600, count: 1, spreadAngle: 0, projectileSpeed: 0}, { type: "aoe", damage: 8, cooldown: 2000, count: 4, spreadAngle: 180, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "hammer", weight: 8 }, { item: "magic_sword", weight: 3 }] },
|
||||||
|
{ id: 99, name: 'Infernal Gleeok', hp: 650, damage: 15, defense: 4, speed: 1.5, colors: { body: "#8B0000", eye: "#FFD700", accent: "#FF4500" }, behavior: "charge", drawFunction: "drawGleeok", description: "A multi-headed dragon whose fiery breath devastates all.", phases: [{ hpThreshold: 100, attacks: [{ type: "aoe", damage: 20, cooldown: 2000, count: 2, spreadAngle: 360, projectileSpeed: 2}, { type: "melee", damage: 15, cooldown: 1500, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 60, attacks: [{ type: "aoe", damage: 21, cooldown: 1800, count: 3, spreadAngle: 360, projectileSpeed: 2.5}, { type: "melee", damage: 16, cooldown: 1200, count: 1, spreadAngle: 0, projectileSpeed: 0}]}, { hpThreshold: 30, attacks: [{ type: "aoe", damage: 22, cooldown: 1200, count: 4, spreadAngle: 360, projectileSpeed: 3}, { type: "melee", damage: 17, cooldown: 1000, count: 1, spreadAngle: 0, projectileSpeed: 0}]}], drops: [{ item: "heart_piece", weight: 100 }, { item: "healing_potion", weight: 30 }, { item: "super_potion", weight: 15 }, { item: "magic_potion", weight: 10 }, { item: "heart_piece", weight: 20 }, { item: "fire_rod", weight: 5 }, { item: "master_sword", weight: 3 }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getBossType(id) {
|
||||||
|
return BOSS_TYPES.find(b => b.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { BOSS_TYPES, getBossType };
|
||||||
|
}
|
||||||
108
src/data/dungeons.js
Normal file
108
src/data/dungeons.js
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const DUNGEONS = [
|
||||||
|
{ id: 0, name: 'The Stone Chamber', theme: 'stone', difficulty: 1, seed: 0, roomCount: 20, requiredWeapon: null, description: 'A modest stone chamber where adventurers first learn the ways of the sword.' },
|
||||||
|
{ id: 1, name: 'The Granite Halls', theme: 'stone', difficulty: 1, seed: 12346, roomCount: 21, requiredWeapon: 'Sword', description: 'Massive granite halls guard an ancient passage. A sword is required to break the stone seal.' },
|
||||||
|
{ id: 2, name: 'The Quarry Depths', theme: 'stone', difficulty: 1, seed: 24694, roomCount: 20, requiredWeapon: null, description: 'An abandoned quarry where echoes of forgotten miners still linger in the dark.' },
|
||||||
|
{ id: 3, name: 'The Verdant Grove', theme: 'forest', difficulty: 2, seed: 37044, roomCount: 23, requiredWeapon: null, description: 'A living grove where ancient trees shift and rearrange the paths beneath your feet.' },
|
||||||
|
{ id: 4, name: 'The Mosswood Sanctum', theme: 'forest', difficulty: 2, seed: 49396, roomCount: 25, requiredWeapon: null, description: 'Thick moss carpets every surface in this hidden woodland sanctuary.' },
|
||||||
|
{ id: 5, name: 'The Ancient Canopy', theme: 'forest', difficulty: 1, seed: 61750, roomCount: 22, requiredWeapon: null, description: 'High above the forest floor, platforms of woven branches form an aerial dungeon.' },
|
||||||
|
{ id: 6, name: 'The Fernmere Hollow', theme: 'forest', difficulty: 2, seed: 74106, roomCount: 24, requiredWeapon: null, description: 'A sunken hollow choked with ferns and fed by underground streams.' },
|
||||||
|
{ id: 7, name: 'The Bramblewood Labyrinth', theme: 'forest', difficulty: 1, seed: 86464, roomCount: 23, requiredWeapon: null, description: 'Thorny brambles twist and turn, forming an ever-shifting maze of thorns.' },
|
||||||
|
{ id: 8, name: 'The Oakheart Stronghold', theme: 'forest', difficulty: 2, seed: 98824, roomCount: 26, requiredWeapon: null, description: 'Built inside a colossal oak, this stronghold is defended by tree spirits.' },
|
||||||
|
{ id: 9, name: 'The Willowmere Temple', theme: 'forest', difficulty: 2, seed: 111186, roomCount: 27, requiredWeapon: null, description: 'Weeping willows drape over a waterside temple of forgotten forest gods.' },
|
||||||
|
{ id: 10, name: 'The Thornwood Bastion', theme: 'forest', difficulty: 2, seed: 123550, roomCount: 23, requiredWeapon: null, description: 'A fortified grove where every plant is a weapon and every path a trap.' },
|
||||||
|
{ id: 11, name: 'The Elderwood Ruins', theme: 'forest', difficulty: 3, seed: 135916, roomCount: 32, requiredWeapon: null, description: 'The crumbling ruins of an ancient forest civilization, overgrown with time.' },
|
||||||
|
{ id: 12, name: 'The Sylvan Depths', theme: 'forest', difficulty: 2, seed: 148284, roomCount: 25, requiredWeapon: null, description: 'Deep within the forest, roots have carved vast chambers into the earth below.' },
|
||||||
|
{ id: 13, name: 'The Myconid Caverns', theme: 'forest', difficulty: 3, seed: 160654, roomCount: 34, requiredWeapon: null, description: 'Giant fungal colonies pulse with bioluminescent light in these underground caverns.' },
|
||||||
|
{ id: 14, name: 'The Treant\'s Lair', theme: 'forest', difficulty: 2, seed: 173026, roomCount: 27, requiredWeapon: null, description: 'The ancient treant guards its hollowed heart, a labyrinth of wood and sap.' },
|
||||||
|
{ id: 15, name: 'The Sandstone Temple', theme: 'desert', difficulty: 3, seed: 185400, roomCount: 33, requiredWeapon: 'Bow', description: 'An ancient desert temple where only an arrow can open the sealed gateway.' },
|
||||||
|
{ id: 16, name: 'The Scorch Dunes', theme: 'desert', difficulty: 2, seed: 197776, roomCount: 24, requiredWeapon: null, description: 'Burning dunes hide buried chambers that shift with every shifting sandstorm.' },
|
||||||
|
{ id: 17, name: 'The Oasis of Mirages', theme: 'desert', difficulty: 2, seed: 210154, roomCount: 24, requiredWeapon: null, description: 'A shimmering oasis that may or may not be real — trust nothing in the desert heat.' },
|
||||||
|
{ id: 18, name: 'The Dunehowl Fortress', theme: 'desert', difficulty: 3, seed: 222534, roomCount: 35, requiredWeapon: null, description: 'A fortress carved into a dune, its walls howling with desert winds.' },
|
||||||
|
{ id: 19, name: 'The Sunblasted Ziggurat', theme: 'desert', difficulty: 3, seed: 234916, roomCount: 30, requiredWeapon: null, description: 'A towering ziggurat baked by millennia of relentless sun.' },
|
||||||
|
{ id: 20, name: 'The Mirage Wastes', theme: 'desert', difficulty: 3, seed: 247300, roomCount: 32, requiredWeapon: null, description: 'Endless wastes where reality distorts and paths lead nowhere but deeper.' },
|
||||||
|
{ id: 21, name: 'The Glass Desert', theme: 'desert', difficulty: 4, seed: 259686, roomCount: 37, requiredWeapon: null, description: 'A desert where ancient fire turned sand to glass, creating razor-sharp terrain.' },
|
||||||
|
{ id: 22, name: 'The Serpent\'s Burrow', theme: 'desert', difficulty: 3, seed: 272074, roomCount: 36, requiredWeapon: null, description: 'A vast network of tunnels dug by a legendary desert serpent.' },
|
||||||
|
{ id: 23, name: 'The Dune Sea', theme: 'desert', difficulty: 4, seed: 284464, roomCount: 39, requiredWeapon: null, description: 'An ocean of sand where buried cities lie beneath the shifting dunes.' },
|
||||||
|
{ id: 24, name: 'The Obsidian Crater', theme: 'desert', difficulty: 3, seed: 296856, roomCount: 31, requiredWeapon: null, description: 'A volcanic crater of black obsidian, still warm from ancient eruptions.' },
|
||||||
|
{ id: 25, name: 'The Sunken Caravan', theme: 'desert', difficulty: 4, seed: 309250, roomCount: 41, requiredWeapon: null, description: 'A buried caravan of sandships, swallowed whole by a sudden dune collapse.' },
|
||||||
|
{ id: 26, name: 'The Scorpion\'s Keep', theme: 'desert', difficulty: 3, seed: 321646, roomCount: 33, requiredWeapon: null, description: 'A fortress shaped like a giant scorpion, its pincers guarding the entrance.' },
|
||||||
|
{ id: 27, name: 'The Avalanche Peak', theme: 'mountain', difficulty: 4, seed: 334044, roomCount: 38, requiredWeapon: 'Hammer', description: 'A treacherous mountain peak where only a hammer can shatter the ice seals.' },
|
||||||
|
{ id: 28, name: 'The Summit Shrine', theme: 'mountain', difficulty: 3, seed: 346444, roomCount: 36, requiredWeapon: null, description: 'A shrine perched at the mountain\'s summit, reachable only by daring climbers.' },
|
||||||
|
{ id: 29, name: 'The Thunderhead Pass', theme: 'mountain', difficulty: 4, seed: 358846, roomCount: 40, requiredWeapon: null, description: 'A mountain pass perpetually shrouded in thunderclouds and lightning.' },
|
||||||
|
{ id: 30, name: 'The Frostbite Crags', theme: 'mountain', difficulty: 4, seed: 371250, roomCount: 36, requiredWeapon: null, description: 'Frozen crags where the cold itself is a deadly enemy.' },
|
||||||
|
{ id: 31, name: 'The Cloudspire Tower', theme: 'mountain', difficulty: 5, seed: 383656, roomCount: 47, requiredWeapon: null, description: 'A tower that pierces the clouds, its upper floors lost in mist.' },
|
||||||
|
{ id: 32, name: 'The Avalanche Caverns', theme: 'mountain', difficulty: 4, seed: 396064, roomCount: 42, requiredWeapon: null, description: 'Underground caverns beneath the peaks, filled with glacial melt and echoing thunder.' },
|
||||||
|
{ id: 33, name: 'The Geyser Springs', theme: 'mountain', difficulty: 5, seed: 408474, roomCount: 49, requiredWeapon: null, description: 'A network of caverns heated by underground geysers that erupt without warning.' },
|
||||||
|
{ id: 34, name: 'The Iron Peak', theme: 'mountain', difficulty: 4, seed: 420886, roomCount: 38, requiredWeapon: null, description: 'A mountain rich in iron ore, its mines form an intricate subterranean maze.' },
|
||||||
|
{ id: 35, name: 'The Stormridge Stronghold', theme: 'mountain', difficulty: 5, seed: 433300, roomCount: 48, requiredWeapon: null, description: 'A fortress on a lightning-battered ridge, built by storm-worshipping ancients.' },
|
||||||
|
{ id: 36, name: 'The Yeti\'s Den', theme: 'mountain', difficulty: 4, seed: 445716, roomCount: 41, requiredWeapon: null, description: 'A frozen cave system inhabited by the legendary mountain yeti.' },
|
||||||
|
{ id: 37, name: 'The Glacier\'s Edge', theme: 'mountain', difficulty: 5, seed: 458134, roomCount: 50, requiredWeapon: null, description: 'A labyrinth carved into a massive glacier, its passages shifting with the ice.' },
|
||||||
|
{ id: 38, name: 'The Thunderpeak Fortress', theme: 'mountain', difficulty: 4, seed: 470554, roomCount: 37, requiredWeapon: null, description: 'A fortress built into a peak struck by lightning every storm season.' },
|
||||||
|
{ id: 39, name: 'The Underroot Caverns', theme: 'underground', difficulty: 5, seed: 482976, roomCount: 45, requiredWeapon: 'Bomb', description: 'Massive root systems form a subterranean web. Bombs are needed to clear the way.' },
|
||||||
|
{ id: 40, name: 'The Crystal Grotto', theme: 'underground', difficulty: 5, seed: 495400, roomCount: 47, requiredWeapon: null, description: 'A vast grotto filled with glowing crystals that sing when the wind passes through.' },
|
||||||
|
{ id: 41, name: 'The Fungal Hollows', theme: 'underground', difficulty: 6, seed: 507826, roomCount: 53, requiredWeapon: null, description: 'Giant mushroom forests grow in the dark, their spores a constant hazard.' },
|
||||||
|
{ id: 42, name: 'The Deepdelve Mines', theme: 'underground', difficulty: 5, seed: 520254, roomCount: 51, requiredWeapon: null, description: 'Ancient mining tunnels that stretch deeper than any living soul has ventured.' },
|
||||||
|
{ id: 43, name: 'The Echoing Chasm', theme: 'underground', difficulty: 6, seed: 532684, roomCount: 55, requiredWeapon: null, description: 'A bottomless chasm where every sound reverberates into madness.' },
|
||||||
|
{ id: 44, name: 'The Burrow Networks', theme: 'underground', difficulty: 5, seed: 545116, roomCount: 46, requiredWeapon: null, description: 'An intricate network of burrows connecting the roots of the entire forest above.' },
|
||||||
|
{ id: 45, name: 'The Subterranean Lake', theme: 'underground', difficulty: 6, seed: 557550, roomCount: 52, requiredWeapon: null, description: 'A vast underground lake with submerged chambers accessible only by diving.' },
|
||||||
|
{ id: 46, name: 'The Limestone Vaults', theme: 'underground', difficulty: 5, seed: 569986, roomCount: 49, requiredWeapon: null, description: 'Natural limestone vaults formed by millennia of dripping mineral water.' },
|
||||||
|
{ id: 47, name: 'The Mole\'s Labyrinth', theme: 'underground', difficulty: 6, seed: 582424, roomCount: 56, requiredWeapon: null, description: 'A mole empire has constructed an impossibly complex labyrinth beneath the earth.' },
|
||||||
|
{ id: 48, name: 'The Geode Chamber', theme: 'underground', difficulty: 5, seed: 594864, roomCount: 50, requiredWeapon: null, description: 'A massive geode split open to reveal crystalline chambers of breathtaking beauty.' },
|
||||||
|
{ id: 49, name: 'The Tunnels of Ash', theme: 'underground', difficulty: 6, seed: 607306, roomCount: 54, requiredWeapon: null, description: 'Tunnels filled with volcanic ash that clouds vision and chokes the breath.' },
|
||||||
|
{ id: 50, name: 'The Darkdelve', theme: 'underground', difficulty: 6, seed: 619750, roomCount: 51, requiredWeapon: null, description: 'The deepest point of the underground realm, where darkness has a will of its own.' },
|
||||||
|
{ id: 51, name: 'The Basalt Depths', theme: 'underground', difficulty: 7, seed: 632196, roomCount: 62, requiredWeapon: null, description: 'Columns of basalt form a natural maze deep beneath the crust.' },
|
||||||
|
{ id: 52, name: 'The Core Chamber', theme: 'underground', difficulty: 6, seed: 644644, roomCount: 57, requiredWeapon: null, description: 'The legendary chamber near the earth\'s core, pulsing with raw elemental energy.' },
|
||||||
|
{ id: 53, name: 'The Coral Reef Temple', theme: 'ocean', difficulty: 7, seed: 657094, roomCount: 64, requiredWeapon: 'Fire Rod', description: 'A temple built into a living coral reef. A fire rod is needed to burn through the kelp gates.' },
|
||||||
|
{ id: 54, name: 'The Abyssal Trench', theme: 'ocean', difficulty: 6, seed: 669546, roomCount: 53, requiredWeapon: null, description: 'A trench plunging into the deepest ocean, where pressure crushes all but the brave.' },
|
||||||
|
{ id: 55, name: 'The Tidepool Sanctum', theme: 'ocean', difficulty: 6, seed: 682000, roomCount: 61, requiredWeapon: null, description: 'A network of tide pools that fill and drain with the moon, shifting the dungeon layout.' },
|
||||||
|
{ id: 56, name: 'The Kraken\'s Lair', theme: 'ocean', difficulty: 6, seed: 694456, roomCount: 55, requiredWeapon: null, description: 'The underwater lair of a legendary kraken, its tentacles blocking every exit.' },
|
||||||
|
{ id: 57, name: 'The Kelp Forest', theme: 'ocean', difficulty: 7, seed: 706914, roomCount: 67, requiredWeapon: null, description: 'A dense forest of giant kelp that sways and entangles the unwary.' },
|
||||||
|
{ id: 58, name: 'The Sunken Fortress', theme: 'ocean', difficulty: 6, seed: 719374, roomCount: 63, requiredWeapon: null, description: 'A fortress sunk by a great flood, its flooded halls now home to ocean creatures.' },
|
||||||
|
{ id: 59, name: 'The Pearl Grotto', theme: 'ocean', difficulty: 6, seed: 731836, roomCount: 60, requiredWeapon: null, description: 'A grotto lined with iridescent pearls, guarded by pearl-diving ogresses.' },
|
||||||
|
{ id: 60, name: 'The Leviathan\'s Maw', theme: 'ocean', difficulty: 7, seed: 744300, roomCount: 65, requiredWeapon: null, description: 'A dungeon inside the skeletal remains of a colossal leviathan.' },
|
||||||
|
{ id: 61, name: 'The Brine Caverns', theme: 'ocean', difficulty: 8, seed: 756766, roomCount: 70, requiredWeapon: null, description: 'Salt caves beneath the ocean floor, their walls encrusted with millennia of brine.' },
|
||||||
|
{ id: 62, name: 'The Tidal Depths', theme: 'ocean', difficulty: 7, seed: 769234, roomCount: 66, requiredWeapon: null, description: 'Chambers that flood and drain with the tides, requiring precise timing to navigate.' },
|
||||||
|
{ id: 63, name: 'The Shipwreck Cavern', theme: 'ocean', difficulty: 8, seed: 781704, roomCount: 72, requiredWeapon: null, description: 'A cavern filled with the ghostly wrecks of a hundred sunken ships.' },
|
||||||
|
{ id: 64, name: 'The Nautilus Chamber', theme: 'ocean', difficulty: 7, seed: 794176, roomCount: 68, requiredWeapon: null, description: 'A chamber shaped like a nautilus shell, its spiral passages leading to the center.' },
|
||||||
|
{ id: 65, name: 'The Abyssal Throne', theme: 'ocean', difficulty: 8, seed: 806650, roomCount: 69, requiredWeapon: null, description: 'The throne room of the ocean king, at the bottom of the deepest abyss.' },
|
||||||
|
{ id: 66, name: 'The Cloudspire Aerie', theme: 'sky', difficulty: 7, seed: 819126, roomCount: 63, requiredWeapon: 'Ice Rod', description: 'A soaring aerie among the clouds. An ice rod is required to freeze the wind bridges.' },
|
||||||
|
{ id: 67, name: 'The Stratosphere Temple', theme: 'sky', difficulty: 8, seed: 831604, roomCount: 74, requiredWeapon: null, description: 'A temple floating in the stratosphere, accessible only by enchanted flight.' },
|
||||||
|
{ id: 68, name: 'The Galewind Fortress', theme: 'sky', difficulty: 7, seed: 844084, roomCount: 68, requiredWeapon: null, description: 'A fortress battered by hurricane winds on every side.' },
|
||||||
|
{ id: 69, name: 'The Celestial Observatory', theme: 'sky', difficulty: 7, seed: 856566, roomCount: 65, requiredWeapon: null, description: 'An ancient observatory where star-aligned mechanisms guard celestial secrets.' },
|
||||||
|
{ id: 70, name: 'The Zephyr\'s Roost', theme: 'sky', difficulty: 8, seed: 869050, roomCount: 71, requiredWeapon: null, description: 'The nesting ground of legendary wind spirits, perched on floating islands.' },
|
||||||
|
{ id: 71, name: 'The Cumulus Citadel', theme: 'sky', difficulty: 9, seed: 881536, roomCount: 82, requiredWeapon: null, description: 'A citadel built entirely from solidified cloud matter, shifting with the weather.' },
|
||||||
|
{ id: 72, name: 'The Starfall Platform', theme: 'sky', difficulty: 8, seed: 894024, roomCount: 73, requiredWeapon: null, description: 'A platform where fallen stars are collected and stored as powerful weapons.' },
|
||||||
|
{ id: 73, name: 'The Nimbus Sanctum', theme: 'sky', difficulty: 9, seed: 906514, roomCount: 85, requiredWeapon: null, description: 'A sanctum hidden within a permanent storm cloud, crackling with energy.' },
|
||||||
|
{ id: 74, name: 'The Thundercloud Tower', theme: 'sky', difficulty: 8, seed: 919006, roomCount: 75, requiredWeapon: null, description: 'A tower wreathed in perpetual lightning, its floors charged with electricity.' },
|
||||||
|
{ id: 75, name: 'The Aurora Spire', theme: 'sky', difficulty: 9, seed: 931500, roomCount: 84, requiredWeapon: null, description: 'A spire illuminated by eternal auroras, its colors shifting with the dungeon\'s mood.' },
|
||||||
|
{ id: 76, name: 'The Jetstream Labyrinth', theme: 'sky', difficulty: 8, seed: 943996, roomCount: 70, requiredWeapon: null, description: 'A labyrinth of floating platforms riding the jet stream at impossible altitudes.' },
|
||||||
|
{ id: 77, name: 'The Zenith Keep', theme: 'sky', difficulty: 9, seed: 956494, roomCount: 88, requiredWeapon: null, description: 'The highest keep in the sky realm, where the boundary between heaven and earth blurs.' },
|
||||||
|
{ id: 78, name: 'The Firmament Palace', theme: 'sky', difficulty: 8, seed: 968994, roomCount: 72, requiredWeapon: null, description: 'A palace built upon the firmament itself, its walls painted with constellations.' },
|
||||||
|
{ id: 79, name: 'The Eclipse Fortress', theme: 'shadow', difficulty: 9, seed: 981496, roomCount: 83, requiredWeapon: 'Hookshot', description: 'A fortress born from a solar eclipse. A hookshot is needed to cross the void gaps.' },
|
||||||
|
{ id: 80, name: 'The Umbral Caverns', theme: 'shadow', difficulty: 9, seed: 994000, roomCount: 80, requiredWeapon: null, description: 'Caverns of pure shadow where light itself is consumed and never returns.' },
|
||||||
|
{ id: 81, name: 'The Duskwood Labyrinth', theme: 'shadow', difficulty: 9, seed: 1006506, roomCount: 86, requiredWeapon: null, description: 'A labyrinth of eternal dusk where time stands still and hope fades slowly.' },
|
||||||
|
{ id: 82, name: 'The Nightfall Temple', theme: 'shadow', difficulty: 9, seed: 1019014, roomCount: 84, requiredWeapon: null, description: 'A temple dedicated to the goddess of night, her shadow stretching across every chamber.' },
|
||||||
|
{ id: 83, name: 'The Phantom\'s Reach', theme: 'shadow', difficulty: 9, seed: 1031524, roomCount: 87, requiredWeapon: null, description: 'A realm where phantoms roam freely and the living are mere intruders.' },
|
||||||
|
{ id: 84, name: 'The Twilight Abyss', theme: 'shadow', difficulty: 9, seed: 1044036, roomCount: 82, requiredWeapon: null, description: 'An abyss of twilight where the boundary between worlds grows dangerously thin.' },
|
||||||
|
{ id: 85, name: 'The Shadowmoor', theme: 'shadow', difficulty: 9, seed: 1056550, roomCount: 89, requiredWeapon: null, description: 'A moor of shadow where the ground itself is alive and hungry.' },
|
||||||
|
{ id: 86, name: 'The Void\'s Edge', theme: 'shadow', difficulty: 9, seed: 1069066, roomCount: 85, requiredWeapon: null, description: 'The edge of the void itself, where reality unravels into nothingness.' },
|
||||||
|
{ id: 87, name: 'The Darkflame Sanctum', theme: 'shadow', difficulty: 9, seed: 1081584, roomCount: 88, requiredWeapon: null, description: 'A sanctum lit by darkflame torches that burn with cold, shadowy fire.' },
|
||||||
|
{ id: 88, name: 'The Tenebris Keep', theme: 'shadow', difficulty: 9, seed: 1094104, roomCount: 83, requiredWeapon: null, description: 'A keep named for darkness itself, its walls absorbing all light.' },
|
||||||
|
{ id: 89, name: 'The Wraith\'s Domain', theme: 'shadow', difficulty: 9, seed: 1106626, roomCount: 90, requiredWeapon: null, description: 'The domain of an ancient wraith, where the dead have long since forgotten they were alive.' },
|
||||||
|
{ id: 90, name: 'The Abyssal Shadow', theme: 'shadow', difficulty: 9, seed: 1119150, roomCount: 81, requiredWeapon: null, description: 'The deepest shadow in the realm, where even fear cannot penetrate.' },
|
||||||
|
{ id: 91, name: 'The Obsidian Void', theme: 'shadow', difficulty: 10, seed: 1131676, roomCount: 92, requiredWeapon: null, description: 'A void of obsidian darkness — the penultimate trial before the final confrontation.' },
|
||||||
|
{ id: 92, name: 'The Endless Night', theme: 'shadow', difficulty: 9, seed: 1144204, roomCount: 86, requiredWeapon: null, description: 'An eternal night with no dawn, a dungeon that never lets you leave easily.' },
|
||||||
|
{ id: 93, name: 'The Demon\'s Keep', theme: 'final', difficulty: 10, seed: 1156734, roomCount: 95, requiredWeapon: null, description: 'The keep of the great demon. All paths lead to the final battle.' },
|
||||||
|
{ id: 94, name: 'The Dark Lord\'s Throne', theme: 'final', difficulty: 9, seed: 1169266, roomCount: 97, requiredWeapon: null, description: 'The throne room of the dark lord, where the fate of the world will be decided.' },
|
||||||
|
{ id: 95, name: 'The Final Bastion', theme: 'final', difficulty: 10, seed: 1181800, roomCount: 98, requiredWeapon: null, description: 'The last bastion between the hero and the source of all evil.' },
|
||||||
|
{ id: 96, name: 'The World\'s End', theme: 'final', difficulty: 9, seed: 1194336, roomCount: 92, requiredWeapon: null, description: 'The edge of the world, where the fabric of reality tears and chaos reigns.' },
|
||||||
|
{ id: 97, name: 'The Last Stand', theme: 'final', difficulty: 10, seed: 1206874, roomCount: 100, requiredWeapon: null, description: 'The final confrontation. There is no retreat — only victory or oblivion.' },
|
||||||
|
{ id: 98, name: 'The Creator\'s Chamber', theme: 'final', difficulty: 9, seed: 1219414, roomCount: 95, requiredWeapon: null, description: 'The chamber of the world\'s creator, holding the secrets of existence itself.' },
|
||||||
|
{ id: 99, name: 'The Eternal Realm', theme: 'final', difficulty: 10, seed: 1231956, roomCount: 97, requiredWeapon: null, description: 'Beyond the final door lies the eternal realm — where the true nature of everything awaits.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { DUNGEONS };
|
||||||
|
}
|
||||||
406
src/data/enemies.js
Normal file
406
src/data/enemies.js
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const ENEMY_TYPES = [
|
||||||
|
{
|
||||||
|
id: 'slime',
|
||||||
|
name: 'Slime',
|
||||||
|
hp: 2,
|
||||||
|
damage: 1,
|
||||||
|
defense: 0,
|
||||||
|
behavior: 'patrol',
|
||||||
|
speed: 1.0,
|
||||||
|
attackRange: 1,
|
||||||
|
colors: { body: '#6B8E23', eye: '#000', accent: '#8FBC8F' },
|
||||||
|
drawFunction: 'drawSlime',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_green', weight: 80 },
|
||||||
|
{ item: 'rupee_blue', weight: 15 },
|
||||||
|
{ item: 'healing_potion', weight: 5 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'slime_red',
|
||||||
|
name: 'Red Slime',
|
||||||
|
hp: 4,
|
||||||
|
damage: 2,
|
||||||
|
defense: 0,
|
||||||
|
behavior: 'patrol',
|
||||||
|
speed: 1.2,
|
||||||
|
attackRange: 1,
|
||||||
|
colors: { body: '#B22222', eye: '#000', accent: '#FF6347' },
|
||||||
|
drawFunction: 'drawSlime',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_blue', weight: 70 },
|
||||||
|
{ item: 'rupee_red', weight: 20 },
|
||||||
|
{ item: 'healing_potion', weight: 10 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'slime_blue',
|
||||||
|
name: 'Blue Slime',
|
||||||
|
hp: 6,
|
||||||
|
damage: 3,
|
||||||
|
defense: 1,
|
||||||
|
behavior: 'patrol',
|
||||||
|
speed: 0.8,
|
||||||
|
attackRange: 1,
|
||||||
|
colors: { body: '#4169E1', eye: '#000', accent: '#87CEEB' },
|
||||||
|
drawFunction: 'drawSlime',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_red', weight: 60 },
|
||||||
|
{ item: 'rupee_yellow', weight: 30 },
|
||||||
|
{ item: 'healing_potion', weight: 10 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'keese',
|
||||||
|
name: 'Keese',
|
||||||
|
hp: 1,
|
||||||
|
damage: 1,
|
||||||
|
defense: 0,
|
||||||
|
behavior: 'chase',
|
||||||
|
speed: 2.0,
|
||||||
|
attackRange: 1,
|
||||||
|
colors: { body: '#4B0082', eye: '#FFFF00', accent: '#800080' },
|
||||||
|
drawFunction: 'drawKeese',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_green', weight: 90 },
|
||||||
|
{ item: 'healing_potion', weight: 10 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'octorok',
|
||||||
|
name: 'Octorok',
|
||||||
|
hp: 3,
|
||||||
|
damage: 0,
|
||||||
|
defense: 0,
|
||||||
|
behavior: 'ranged',
|
||||||
|
speed: 0.5,
|
||||||
|
attackRange: 4,
|
||||||
|
projectileSpeed: 3,
|
||||||
|
colors: { body: '#8B4513', eye: '#FFF', accent: '#D2691E' },
|
||||||
|
drawFunction: 'drawOctorok',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_green', weight: 70 },
|
||||||
|
{ item: 'rupee_blue', weight: 20 },
|
||||||
|
{ item: 'arrow', weight: 10 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'moblin',
|
||||||
|
name: 'Moblin',
|
||||||
|
hp: 8,
|
||||||
|
damage: 4,
|
||||||
|
defense: 2,
|
||||||
|
behavior: 'chase',
|
||||||
|
speed: 1.5,
|
||||||
|
attackRange: 1,
|
||||||
|
colors: { body: '#2E8B57', eye: '#FF0000', accent: '#228B22' },
|
||||||
|
drawFunction: 'drawMoblin',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_blue', weight: 60 },
|
||||||
|
{ item: 'rupee_red', weight: 25 },
|
||||||
|
{ item: 'healing_potion', weight: 10 },
|
||||||
|
{ item: 'sword', weight: 2 },
|
||||||
|
{ item: 'arrow', weight: 3 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stalfos',
|
||||||
|
name: 'Stalfos',
|
||||||
|
hp: 5,
|
||||||
|
damage: 3,
|
||||||
|
defense: 1,
|
||||||
|
behavior: 'chase',
|
||||||
|
speed: 1.8,
|
||||||
|
attackRange: 1,
|
||||||
|
colors: { body: '#F5F5DC', eye: '#FF0000', accent: '#DDA0DD' },
|
||||||
|
drawFunction: 'drawStalfos',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_red', weight: 50 },
|
||||||
|
{ item: 'rupee_yellow', weight: 30 },
|
||||||
|
{ item: 'healing_potion', weight: 15 },
|
||||||
|
{ item: 'bomb', weight: 5 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'goriya',
|
||||||
|
name: 'Goriya',
|
||||||
|
hp: 6,
|
||||||
|
damage: 3,
|
||||||
|
defense: 2,
|
||||||
|
behavior: 'chase',
|
||||||
|
speed: 1.6,
|
||||||
|
attackRange: 2,
|
||||||
|
colors: { body: '#DC143C', eye: '#FFF', accent: '#FF6347' },
|
||||||
|
drawFunction: 'drawGoriya',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_red', weight: 50 },
|
||||||
|
{ item: 'rupee_yellow', weight: 25 },
|
||||||
|
{ item: 'healing_potion', weight: 15 },
|
||||||
|
{ item: 'bow', weight: 5 },
|
||||||
|
{ item: 'arrow', weight: 5 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'armos',
|
||||||
|
name: 'Armos Knight',
|
||||||
|
hp: 10,
|
||||||
|
damage: 5,
|
||||||
|
defense: 3,
|
||||||
|
behavior: 'stationary',
|
||||||
|
speed: 0,
|
||||||
|
attackRange: 1,
|
||||||
|
colors: { body: '#FFD700', eye: '#000080', accent: '#FF8C00' },
|
||||||
|
drawFunction: 'drawArmos',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_yellow', weight: 50 },
|
||||||
|
{ item: 'healing_potion', weight: 30 },
|
||||||
|
{ item: 'hammer', weight: 5 },
|
||||||
|
{ item: 'heart_piece', weight: 2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lynel',
|
||||||
|
name: 'Lynel',
|
||||||
|
hp: 20,
|
||||||
|
damage: 8,
|
||||||
|
defense: 4,
|
||||||
|
behavior: 'charge',
|
||||||
|
speed: 2.5,
|
||||||
|
attackRange: 2,
|
||||||
|
colors: { body: '#8B0000', eye: '#FFD700', accent: '#FF4500' },
|
||||||
|
drawFunction: 'drawLynel',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_yellow', weight: 40 },
|
||||||
|
{ item: 'healing_potion', weight: 30 },
|
||||||
|
{ item: 'heart_piece', weight: 10 },
|
||||||
|
{ item: 'sword', weight: 5 },
|
||||||
|
{ item: 'arrow', weight: 5 },
|
||||||
|
{ item: 'bomb', weight: 5 },
|
||||||
|
{ item: 'boomerang', weight: 2 },
|
||||||
|
{ item: 'magic_potion', weight: 3 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'like',
|
||||||
|
name: 'Like Like',
|
||||||
|
hp: 4,
|
||||||
|
damage: 2,
|
||||||
|
defense: 1,
|
||||||
|
behavior: 'chase',
|
||||||
|
speed: 1.2,
|
||||||
|
attackRange: 1,
|
||||||
|
colors: { body: '#708090', eye: '#FF0000', accent: '#A9A9A9' },
|
||||||
|
drawFunction: 'drawSlime',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_blue', weight: 60 },
|
||||||
|
{ item: 'rupee_red', weight: 30 },
|
||||||
|
{ item: 'healing_potion', weight: 10 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bub',
|
||||||
|
name: 'Bub',
|
||||||
|
hp: 3,
|
||||||
|
damage: 2,
|
||||||
|
defense: 0,
|
||||||
|
behavior: 'patrol',
|
||||||
|
speed: 1.0,
|
||||||
|
attackRange: 1,
|
||||||
|
colors: { body: '#696969', eye: '#FFF', accent: '#808080' },
|
||||||
|
drawFunction: 'drawKeese',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_green', weight: 70 },
|
||||||
|
{ item: 'rupee_blue', weight: 20 },
|
||||||
|
{ item: 'healing_potion', weight: 10 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reedeath',
|
||||||
|
name: 'Reedeath',
|
||||||
|
hp: 3,
|
||||||
|
damage: 1,
|
||||||
|
defense: 0,
|
||||||
|
behavior: 'stationary',
|
||||||
|
speed: 0,
|
||||||
|
attackRange: 1,
|
||||||
|
colors: { body: '#228B22', eye: '#FFF', accent: '#32CD32' },
|
||||||
|
drawFunction: 'drawStalfos',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_green', weight: 80 },
|
||||||
|
{ item: 'healing_potion', weight: 15 },
|
||||||
|
{ item: 'bomb', weight: 5 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wallmaster',
|
||||||
|
name: 'Wallmaster',
|
||||||
|
hp: 2,
|
||||||
|
damage: 15,
|
||||||
|
defense: 0,
|
||||||
|
behavior: 'teleport',
|
||||||
|
speed: 0,
|
||||||
|
attackRange: 1,
|
||||||
|
colors: { body: '#8B0000', eye: '#FFD700', accent: '#4B0082' },
|
||||||
|
drawFunction: 'drawKeese',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_yellow', weight: 60 },
|
||||||
|
{ item: 'heart_piece', weight: 20 },
|
||||||
|
{ item: 'healing_potion', weight: 20 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'manhandla',
|
||||||
|
name: 'Manhandla',
|
||||||
|
hp: 12,
|
||||||
|
damage: 6,
|
||||||
|
defense: 2,
|
||||||
|
behavior: 'charge',
|
||||||
|
speed: 2.0,
|
||||||
|
attackRange: 1,
|
||||||
|
colors: { body: '#8B4513', eye: '#FF0000', accent: '#D2691E' },
|
||||||
|
drawFunction: 'drawSlime',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_red', weight: 50 },
|
||||||
|
{ item: 'rupee_yellow', weight: 30 },
|
||||||
|
{ item: 'healing_potion', weight: 15 },
|
||||||
|
{ item: 'heart_piece', weight: 5 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tektite',
|
||||||
|
name: 'Tektite',
|
||||||
|
hp: 2,
|
||||||
|
damage: 1,
|
||||||
|
defense: 0,
|
||||||
|
behavior: 'patrol',
|
||||||
|
speed: 1.5,
|
||||||
|
attackRange: 1,
|
||||||
|
colors: { body: '#A0522D', eye: '#FFD700', accent: '#CD853F' },
|
||||||
|
drawFunction: 'drawKeese',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_green', weight: 80 },
|
||||||
|
{ item: 'healing_potion', weight: 15 },
|
||||||
|
{ item: 'rupee_blue', weight: 5 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leever',
|
||||||
|
name: 'Leever',
|
||||||
|
hp: 4,
|
||||||
|
damage: 2,
|
||||||
|
defense: 1,
|
||||||
|
behavior: 'charge',
|
||||||
|
speed: 2.0,
|
||||||
|
attackRange: 1,
|
||||||
|
colors: { body: '#DAA520', eye: '#000', accent: '#FFD700' },
|
||||||
|
drawFunction: 'drawKeese',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_blue', weight: 60 },
|
||||||
|
{ item: 'rupee_red', weight: 25 },
|
||||||
|
{ item: 'healing_potion', weight: 15 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'skullwallma',
|
||||||
|
name: 'Skullwallma',
|
||||||
|
hp: 5,
|
||||||
|
damage: 3,
|
||||||
|
defense: 1,
|
||||||
|
behavior: 'stationary',
|
||||||
|
speed: 0,
|
||||||
|
attackRange: 1,
|
||||||
|
colors: { body: '#F5F5DC', eye: '#FF0000', accent: '#DDA0DD' },
|
||||||
|
drawFunction: 'drawStalfos',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_red', weight: 50 },
|
||||||
|
{ item: 'healing_potion', weight: 30 },
|
||||||
|
{ item: 'bomb', weight: 15 },
|
||||||
|
{ item: 'heart_piece', weight: 5 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ironknuckle',
|
||||||
|
name: 'Iron Knuckle',
|
||||||
|
hp: 15,
|
||||||
|
damage: 7,
|
||||||
|
defense: 4,
|
||||||
|
behavior: 'chase',
|
||||||
|
speed: 1.8,
|
||||||
|
attackRange: 2,
|
||||||
|
colors: { body: '#708090', eye: '#FF0000', accent: '#C0C0C0' },
|
||||||
|
drawFunction: 'drawMoblin',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_yellow', weight: 40 },
|
||||||
|
{ item: 'healing_potion', weight: 30 },
|
||||||
|
{ item: 'sword', weight: 10 },
|
||||||
|
{ item: 'heart_piece', weight: 10 },
|
||||||
|
{ item: 'arrow', weight: 10 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'darknut',
|
||||||
|
name: 'Darknut',
|
||||||
|
hp: 18,
|
||||||
|
damage: 6,
|
||||||
|
defense: 5,
|
||||||
|
behavior: 'chase',
|
||||||
|
speed: 1.4,
|
||||||
|
attackRange: 1,
|
||||||
|
colors: { body: '#2F4F4F', eye: '#FF0000', accent: '#708090' },
|
||||||
|
drawFunction: 'drawMoblin',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_yellow', weight: 40 },
|
||||||
|
{ item: 'healing_potion', weight: 25 },
|
||||||
|
{ item: 'heart_piece', weight: 15 },
|
||||||
|
{ item: 'hammer', weight: 10 },
|
||||||
|
{ item: 'boomerang', weight: 10 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wizzrobe',
|
||||||
|
name: 'Wizzrobe',
|
||||||
|
hp: 8,
|
||||||
|
damage: 5,
|
||||||
|
defense: 1,
|
||||||
|
behavior: 'teleport',
|
||||||
|
speed: 0,
|
||||||
|
attackRange: 4,
|
||||||
|
colors: { body: '#4B0082', eye: '#FFFF00', accent: '#800080' },
|
||||||
|
drawFunction: 'drawStalfos',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_yellow', weight: 40 },
|
||||||
|
{ item: 'healing_potion', weight: 20 },
|
||||||
|
{ item: 'heart_piece', weight: 15 },
|
||||||
|
{ item: 'magic_potion', weight: 15 },
|
||||||
|
{ item: 'fire_rod', weight: 5 },
|
||||||
|
{ item: 'ice_rod', weight: 5 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'peahat',
|
||||||
|
name: 'Peahat',
|
||||||
|
hp: 2,
|
||||||
|
damage: 1,
|
||||||
|
defense: 0,
|
||||||
|
behavior: 'stationary',
|
||||||
|
speed: 0,
|
||||||
|
attackRange: 1,
|
||||||
|
colors: { body: '#228B22', eye: '#FFD700', accent: '#FF69B4' },
|
||||||
|
drawFunction: 'drawStalfos',
|
||||||
|
drops: [
|
||||||
|
{ item: 'rupee_green', weight: 80 },
|
||||||
|
{ item: 'healing_potion', weight: 15 },
|
||||||
|
{ item: 'rupee_blue', weight: 5 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function getEnemyType(id) {
|
||||||
|
return ENEMY_TYPES.find(e => e.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { ENEMY_TYPES, getEnemyType };
|
||||||
|
}
|
||||||
25
src/data/items.js
Normal file
25
src/data/items.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const ITEMS = [
|
||||||
|
{ id: 'healing_potion', name: 'Healing Potion', type: 'consumable', description: 'Restores 1 heart.', color: '#FF69B4', stackable: true, value: 0 },
|
||||||
|
{ id: 'super_potion', name: 'Super Potion', type: 'consumable', description: 'Restores 3 hearts.', color: '#FF1493', stackable: true, value: 0 },
|
||||||
|
{ id: 'magic_potion', name: 'Magic Potion', type: 'consumable', description: 'Restores magic power.', color: '#9400D3', stackable: true, value: 0 },
|
||||||
|
{ id: 'heart_piece', name: 'Heart Piece', type: 'consumable', description: 'Permanently increases max HP by 1/4 heart.', color: '#FF0000', stackable: false, value: 0 },
|
||||||
|
{ id: 'arrow', name: 'Arrow', type: 'material', description: 'Ammunition for the Bow.', color: '#8B4513', stackable: true, value: 0 },
|
||||||
|
{ id: 'bomb', name: 'Bomb', type: 'material', description: 'Explodes on impact. Destroys walls.', color: '#8B4513', stackable: true, value: 0 },
|
||||||
|
{ id: 'key', name: 'Key', type: 'key', description: 'Opens locked doors.', color: '#FFD700', stackable: true, value: 0 },
|
||||||
|
{ id: 'small_key', name: 'Small Key', type: 'key', description: 'Opens chests and small doors.', color: '#DAA520', stackable: true, value: 0 },
|
||||||
|
{ id: 'rupee_green', name: 'Green Rupee', type: 'currency', description: 'Worth 1 rupee.', color: '#00FF00', stackable: true, value: 1 },
|
||||||
|
{ id: 'rupee_blue', name: 'Blue Rupee', type: 'currency', description: 'Worth 5 rupees.', color: '#0000FF', stackable: true, value: 5 },
|
||||||
|
{ id: 'rupee_red', name: 'Red Rupee', type: 'currency', description: 'Worth 20 rupees.', color: '#FF0000', stackable: true, value: 20 },
|
||||||
|
{ id: 'rupee_yellow', name: 'Yellow Rupee', type: 'currency', description: 'Worth 100 rupees.', color: '#FFD700', stackable: true, value: 100 },
|
||||||
|
{ id: 'magic_crystal', name: 'Magic Crystal', type: 'material', description: 'Used to power magic weapons.', color: '#E0FFFF', stackable: true, value: 0 }
|
||||||
|
];
|
||||||
|
|
||||||
|
function getItem(id) {
|
||||||
|
return ITEMS.find(i => i.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { ITEMS, getItem };
|
||||||
|
}
|
||||||
155
src/data/themes.js
Normal file
155
src/data/themes.js
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === DUNGEON THEMES ===
|
||||||
|
// Each theme defines color palettes and aesthetic properties for dungeon generation
|
||||||
|
|
||||||
|
const THEMES = {
|
||||||
|
stone: {
|
||||||
|
name: 'Stone',
|
||||||
|
floorColor: '#C4A265',
|
||||||
|
floorDarkColor: '#A8884D',
|
||||||
|
wallColor: '#505050',
|
||||||
|
wallTopColor: '#6E6E6E',
|
||||||
|
wallFrontColor: '#5A5A5A',
|
||||||
|
wallBottomColor: '#383838',
|
||||||
|
wallBorderColor: '#404040',
|
||||||
|
torchGlow: 'rgba(255,140,0,0.15)',
|
||||||
|
torchStickColor: '#654321',
|
||||||
|
torchFlameColor: '#FF8C00',
|
||||||
|
torchCoreColor: '#FFFF00',
|
||||||
|
cellColor: '#505050',
|
||||||
|
cellTextColor: '#FFF',
|
||||||
|
},
|
||||||
|
forest: {
|
||||||
|
name: 'Forest',
|
||||||
|
floorColor: '#4A6741',
|
||||||
|
floorDarkColor: '#3A5731',
|
||||||
|
wallColor: '#5D4037',
|
||||||
|
wallTopColor: '#6D5047',
|
||||||
|
wallFrontColor: '#5D4037',
|
||||||
|
wallBottomColor: '#4D3027',
|
||||||
|
wallBorderColor: '#3D2017',
|
||||||
|
torchGlow: 'rgba(100,200,100,0.15)',
|
||||||
|
torchStickColor: '#4A3020',
|
||||||
|
torchFlameColor: '#00FF00',
|
||||||
|
torchCoreColor: '#AAFFAA',
|
||||||
|
cellColor: '#2E5A2E',
|
||||||
|
cellTextColor: '#AFA',
|
||||||
|
},
|
||||||
|
desert: {
|
||||||
|
name: 'Desert',
|
||||||
|
floorColor: '#D4A567',
|
||||||
|
floorDarkColor: '#C49557',
|
||||||
|
wallColor: '#8B7355',
|
||||||
|
wallTopColor: '#9B8365',
|
||||||
|
wallFrontColor: '#8B7355',
|
||||||
|
wallBottomColor: '#7B6345',
|
||||||
|
wallBorderColor: '#6B5335',
|
||||||
|
torchGlow: 'rgba(255,200,100,0.15)',
|
||||||
|
torchStickColor: '#654321',
|
||||||
|
torchFlameColor: '#FFD700',
|
||||||
|
torchCoreColor: '#FFFFAA',
|
||||||
|
cellColor: '#C49557',
|
||||||
|
cellTextColor: '#3A2A1A',
|
||||||
|
},
|
||||||
|
mountain: {
|
||||||
|
name: 'Mountain',
|
||||||
|
floorColor: '#708090',
|
||||||
|
floorDarkColor: '#607080',
|
||||||
|
wallColor: '#4A5568',
|
||||||
|
wallTopColor: '#5A6578',
|
||||||
|
wallFrontColor: '#4A5568',
|
||||||
|
wallBottomColor: '#3A4558',
|
||||||
|
wallBorderColor: '#2A3548',
|
||||||
|
torchGlow: 'rgba(200,200,255,0.15)',
|
||||||
|
torchStickColor: '#4A4040',
|
||||||
|
torchFlameColor: '#87CEEB',
|
||||||
|
torchCoreColor: '#E0F0FF',
|
||||||
|
cellColor: '#4A5568',
|
||||||
|
cellTextColor: '#E0E8F0',
|
||||||
|
},
|
||||||
|
underground: {
|
||||||
|
name: 'Underground',
|
||||||
|
floorColor: '#3A3A3A',
|
||||||
|
floorDarkColor: '#2A2A2A',
|
||||||
|
wallColor: '#2D2D2D',
|
||||||
|
wallTopColor: '#3D3D3D',
|
||||||
|
wallFrontColor: '#2D2D2D',
|
||||||
|
wallBottomColor: '#1D1D1D',
|
||||||
|
wallBorderColor: '#151515',
|
||||||
|
torchGlow: 'rgba(255,100,0,0.2)',
|
||||||
|
torchStickColor: '#3A3020',
|
||||||
|
torchFlameColor: '#FF6347',
|
||||||
|
torchCoreColor: '#FFD700',
|
||||||
|
cellColor: '#2D2D2D',
|
||||||
|
cellTextColor: '#FFD700',
|
||||||
|
},
|
||||||
|
ocean: {
|
||||||
|
name: 'Ocean',
|
||||||
|
floorColor: '#4682B4',
|
||||||
|
floorDarkColor: '#3672A4',
|
||||||
|
wallColor: '#2F4F6F',
|
||||||
|
wallTopColor: '#3F5F7F',
|
||||||
|
wallFrontColor: '#2F4F6F',
|
||||||
|
wallBottomColor: '#1F3F5F',
|
||||||
|
wallBorderColor: '#0F2F4F',
|
||||||
|
torchGlow: 'rgba(100,150,255,0.15)',
|
||||||
|
torchStickColor: '#3A4050',
|
||||||
|
torchFlameColor: '#00BFFF',
|
||||||
|
torchCoreColor: '#E0FFFF',
|
||||||
|
cellColor: '#2F4F6F',
|
||||||
|
cellTextColor: '#AFDCF0',
|
||||||
|
},
|
||||||
|
sky: {
|
||||||
|
name: 'Sky',
|
||||||
|
floorColor: '#E8E0D0',
|
||||||
|
floorDarkColor: '#D8D0C0',
|
||||||
|
wallColor: '#F0E8D8',
|
||||||
|
wallTopColor: '#F8F0E8',
|
||||||
|
wallFrontColor: '#F0E8D8',
|
||||||
|
wallBottomColor: '#E0D8C8',
|
||||||
|
wallBorderColor: '#D0C8B8',
|
||||||
|
torchGlow: 'rgba(255,255,200,0.15)',
|
||||||
|
torchStickColor: '#8B7355',
|
||||||
|
torchFlameColor: '#FFD700',
|
||||||
|
torchCoreColor: '#FFFACD',
|
||||||
|
cellColor: '#F0E8D8',
|
||||||
|
cellTextColor: '#5A4A3A',
|
||||||
|
},
|
||||||
|
shadow: {
|
||||||
|
name: 'Shadow',
|
||||||
|
floorColor: '#1A0A2A',
|
||||||
|
floorDarkColor: '#0A001A',
|
||||||
|
wallColor: '#2A1A3A',
|
||||||
|
wallTopColor: '#3A2A4A',
|
||||||
|
wallFrontColor: '#2A1A3A',
|
||||||
|
wallBottomColor: '#1A0A2A',
|
||||||
|
wallBorderColor: '#0A0010',
|
||||||
|
torchGlow: 'rgba(150,0,200,0.2)',
|
||||||
|
torchStickColor: '#2A1A2A',
|
||||||
|
torchFlameColor: '#9400D3',
|
||||||
|
torchCoreColor: '#DA70D6',
|
||||||
|
cellColor: '#2A1A3A',
|
||||||
|
cellTextColor: '#D0A0E0',
|
||||||
|
},
|
||||||
|
final: {
|
||||||
|
name: 'Final',
|
||||||
|
floorColor: '#2A0A0A',
|
||||||
|
floorDarkColor: '#1A0000',
|
||||||
|
wallColor: '#3A1A1A',
|
||||||
|
wallTopColor: '#4A2A2A',
|
||||||
|
wallFrontColor: '#3A1A1A',
|
||||||
|
wallBottomColor: '#2A0A0A',
|
||||||
|
wallBorderColor: '#1A0000',
|
||||||
|
torchGlow: 'rgba(255,0,0,0.25)',
|
||||||
|
torchStickColor: '#3A0A0A',
|
||||||
|
torchFlameColor: '#FF0000',
|
||||||
|
torchCoreColor: '#FF4500',
|
||||||
|
cellColor: '#3A0A0A',
|
||||||
|
cellTextColor: '#FF6347',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { THEMES };
|
||||||
|
}
|
||||||
180
src/data/weapons.js
Normal file
180
src/data/weapons.js
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const WEAPONS = [
|
||||||
|
{
|
||||||
|
id: 'sword',
|
||||||
|
name: 'Sword',
|
||||||
|
damage: 3,
|
||||||
|
type: 'melee',
|
||||||
|
range: 1,
|
||||||
|
cooldown: 400,
|
||||||
|
ammo: null,
|
||||||
|
ammoCost: 1,
|
||||||
|
color: '#C0C0C0',
|
||||||
|
effect: null,
|
||||||
|
drawFunction: 'drawSword',
|
||||||
|
description: 'A basic sword. Reliable and versatile.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bow',
|
||||||
|
name: 'Bow',
|
||||||
|
damage: 2,
|
||||||
|
type: 'ranged',
|
||||||
|
range: 8,
|
||||||
|
cooldown: 800,
|
||||||
|
ammo: 'arrow',
|
||||||
|
ammoCost: 1,
|
||||||
|
color: '#8B4513',
|
||||||
|
effect: null,
|
||||||
|
drawFunction: 'drawBow',
|
||||||
|
description: 'Shoots arrows at distant foes.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'boomerang',
|
||||||
|
name: 'Boomerang',
|
||||||
|
damage: 2,
|
||||||
|
type: 'ranged',
|
||||||
|
range: 5,
|
||||||
|
cooldown: 1200,
|
||||||
|
ammo: null,
|
||||||
|
ammoCost: 1,
|
||||||
|
color: '#FF8C00',
|
||||||
|
effect: 'stun',
|
||||||
|
drawFunction: 'drawBoomerang',
|
||||||
|
description: 'Returns to hand. Stuns enemies on hit.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hammer',
|
||||||
|
name: 'Hammer',
|
||||||
|
damage: 5,
|
||||||
|
type: 'melee',
|
||||||
|
range: 2,
|
||||||
|
cooldown: 1000,
|
||||||
|
ammo: null,
|
||||||
|
ammoCost: 1,
|
||||||
|
color: '#8B4513',
|
||||||
|
effect: null,
|
||||||
|
drawFunction: 'drawHammer',
|
||||||
|
description: 'Heavy weapon. Breaks walls and cracks blocks.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bomb',
|
||||||
|
name: 'Bomb',
|
||||||
|
damage: 4,
|
||||||
|
type: 'consumable',
|
||||||
|
range: 2,
|
||||||
|
cooldown: 600,
|
||||||
|
ammo: 'bomb',
|
||||||
|
ammoCost: 1,
|
||||||
|
color: '#8B4513',
|
||||||
|
effect: 'explode',
|
||||||
|
drawFunction: 'drawBomb',
|
||||||
|
description: 'Creates an explosion. Destroys breakable blocks.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fire_rod',
|
||||||
|
name: 'Fire Rod',
|
||||||
|
damage: 3,
|
||||||
|
type: 'ranged',
|
||||||
|
range: 6,
|
||||||
|
cooldown: 1000,
|
||||||
|
ammo: 'magic',
|
||||||
|
ammoCost: 1,
|
||||||
|
color: '#FF4500',
|
||||||
|
effect: 'fire',
|
||||||
|
drawFunction: 'drawFireRod',
|
||||||
|
description: 'Shoots fireballs. Burns enemies over time.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ice_rod',
|
||||||
|
name: 'Ice Rod',
|
||||||
|
damage: 2,
|
||||||
|
type: 'ranged',
|
||||||
|
range: 5,
|
||||||
|
cooldown: 1200,
|
||||||
|
ammo: 'magic',
|
||||||
|
ammoCost: 1,
|
||||||
|
color: '#87CEEB',
|
||||||
|
effect: 'ice',
|
||||||
|
drawFunction: 'drawIceRod',
|
||||||
|
description: 'Freezes enemies in place.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hookshot',
|
||||||
|
name: 'Hookshot',
|
||||||
|
damage: 1,
|
||||||
|
type: 'ranged',
|
||||||
|
range: 8,
|
||||||
|
cooldown: 1500,
|
||||||
|
ammo: null,
|
||||||
|
ammoCost: 1,
|
||||||
|
color: '#C0C0C0',
|
||||||
|
effect: null,
|
||||||
|
drawFunction: 'drawHookshot',
|
||||||
|
description: 'Grapples to targets across gaps.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'master_sword',
|
||||||
|
name: 'Master Sword',
|
||||||
|
damage: 5,
|
||||||
|
type: 'melee',
|
||||||
|
range: 1,
|
||||||
|
cooldown: 350,
|
||||||
|
ammo: null,
|
||||||
|
ammoCost: 1,
|
||||||
|
color: '#FFD700',
|
||||||
|
effect: null,
|
||||||
|
drawFunction: 'drawMasterSword',
|
||||||
|
description: 'The legendary blade of the hero.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'magic_sword',
|
||||||
|
name: 'Magic Sword',
|
||||||
|
damage: 6,
|
||||||
|
type: 'melee',
|
||||||
|
range: 2,
|
||||||
|
cooldown: 500,
|
||||||
|
ammo: 'magic',
|
||||||
|
ammoCost: 1,
|
||||||
|
color: '#9400D3',
|
||||||
|
effect: 'stun',
|
||||||
|
drawFunction: 'drawMagicSword',
|
||||||
|
description: 'Deals magic damage and stuns foes.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'slingshot',
|
||||||
|
name: 'Slingshot',
|
||||||
|
damage: 1,
|
||||||
|
type: 'ranged',
|
||||||
|
range: 5,
|
||||||
|
cooldown: 600,
|
||||||
|
ammo: null,
|
||||||
|
ammoCost: 1,
|
||||||
|
color: '#8B4513',
|
||||||
|
effect: null,
|
||||||
|
drawFunction: 'drawSlingshot',
|
||||||
|
description: 'Simple but effective. No ammo needed.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bug_net',
|
||||||
|
name: 'Bug Net',
|
||||||
|
damage: 1,
|
||||||
|
type: 'melee',
|
||||||
|
range: 1,
|
||||||
|
cooldown: 500,
|
||||||
|
ammo: null,
|
||||||
|
ammoCost: 1,
|
||||||
|
color: '#87CEEB',
|
||||||
|
effect: null,
|
||||||
|
drawFunction: 'drawBugNet',
|
||||||
|
description: 'Captures small creatures.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function getWeapon(id) {
|
||||||
|
return WEAPONS.find(w => w.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { WEAPONS, getWeapon };
|
||||||
|
}
|
||||||
360
src/game/ai.js
Normal file
360
src/game/ai.js
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === ENEMY AI SYSTEM ===
|
||||||
|
|
||||||
|
function updateEnemies(dt) {
|
||||||
|
// Update regular enemies
|
||||||
|
for (const enemy of [...gameState.enemies]) {
|
||||||
|
enemy.moveTimer -= dt;
|
||||||
|
enemy.attackTimer -= dt;
|
||||||
|
if (enemy.hitTimer > 0) enemy.hitTimer -= dt;
|
||||||
|
if (enemy.specialTimer > 0) {
|
||||||
|
enemy.specialTimer -= dt;
|
||||||
|
if (enemy.specialTimer <= 0) {
|
||||||
|
enemy.stunned = false;
|
||||||
|
enemy.frozen = false;
|
||||||
|
enemy.onFire = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (enemy.onFire) {
|
||||||
|
enemy.attackTimer -= dt * 0.5; // Fire damage
|
||||||
|
if (enemy.attackTimer <= 0) {
|
||||||
|
enemy.attackTimer = 1000;
|
||||||
|
enemy.hp -= 1;
|
||||||
|
spawnParticles(enemy.x * TILE + TILE / 2, enemy.y * TILE + TILE / 2, '#FF4500', 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip AI if stunned or frozen
|
||||||
|
if (enemy.stunned || enemy.frozen) continue;
|
||||||
|
|
||||||
|
// Movement AI
|
||||||
|
if (enemy.moveTimer <= 0) {
|
||||||
|
enemy.moveTimer = enemy.moveInterval || ENEMY_MOVE_INTERVAL;
|
||||||
|
updateEnemyMovement(enemy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attack AI
|
||||||
|
if (manhattanDist(enemy.x, enemy.y, gameState.player.x, gameState.player.y) <= (enemy.attackRange || 1)) {
|
||||||
|
if (enemy.attackTimer <= 0) {
|
||||||
|
enemy.attackTimer = enemy.attackInterval || ENEMY_ATTACK_INTERVAL;
|
||||||
|
enemyAttack(enemy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special behaviors
|
||||||
|
if (enemy.behavior === 'ranged' && enemy.projectileTimer !== undefined) {
|
||||||
|
enemy.projectileTimer -= dt;
|
||||||
|
if (enemy.projectileTimer <= 0) {
|
||||||
|
enemy.projectileTimer = enemy.shootInterval || 3000;
|
||||||
|
enemyShoot(enemy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (enemy.behavior === 'charge') {
|
||||||
|
if (enemy.chargeTimer !== undefined) {
|
||||||
|
enemy.chargeTimer -= dt;
|
||||||
|
if (enemy.chargeTimer <= 0 && !enemy.charging) {
|
||||||
|
enemy.charging = true;
|
||||||
|
enemy.chargeDuration = 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (enemy.charging) {
|
||||||
|
enemy.chargeDuration -= dt;
|
||||||
|
if (enemy.chargeDuration <= 0) {
|
||||||
|
enemy.charging = false;
|
||||||
|
enemy.chargeTimer = enemy.chargeCooldown || 5000;
|
||||||
|
enemyCharge(enemy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update boss AI
|
||||||
|
if (gameState.currentBoss) {
|
||||||
|
const boss = gameState.currentBoss;
|
||||||
|
boss.moveTimer -= dt;
|
||||||
|
boss.attackTimer -= dt;
|
||||||
|
if (boss.hitTimer > 0) boss.hitTimer -= dt;
|
||||||
|
if (boss.specialTimer > 0) {
|
||||||
|
boss.specialTimer -= dt;
|
||||||
|
if (boss.specialTimer <= 0) {
|
||||||
|
boss.stunned = false;
|
||||||
|
boss.frozen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!boss.stunned && !boss.frozen) {
|
||||||
|
if (boss.moveTimer <= 0) {
|
||||||
|
boss.moveTimer = boss.moveInterval || ENEMY_MOVE_INTERVAL;
|
||||||
|
updateBossMovement(boss);
|
||||||
|
}
|
||||||
|
if (boss.attackTimer <= 0) {
|
||||||
|
boss.attackTimer = boss.attackInterval || ENEMY_ATTACK_INTERVAL;
|
||||||
|
bossAttack(boss);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update projectiles
|
||||||
|
updateProjectiles(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEnemyMovement(enemy) {
|
||||||
|
const px = gameState.player.x;
|
||||||
|
const py = gameState.player.y;
|
||||||
|
const dist = manhattanDist(enemy.x, enemy.y, px, py);
|
||||||
|
|
||||||
|
// Behavior: chase
|
||||||
|
if (enemy.behavior === 'chase' || enemy.behavior === 'ranged' || enemy.behavior === 'charge') {
|
||||||
|
if (enemy.behavior === 'ranged' && dist < (enemy.minRange || 3)) return; // Back away if too close
|
||||||
|
if (enemy.behavior === 'ranged' && dist > (enemy.maxRange || 8)) {
|
||||||
|
// Move closer if too far
|
||||||
|
moveToward(enemy, px, py);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
moveToward(enemy, px, py);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Behavior: patrol
|
||||||
|
if (enemy.behavior === 'patrol') {
|
||||||
|
if (!enemy.patrolPath || enemy.patrolIdx === undefined) {
|
||||||
|
enemy.patrolIdx = 0;
|
||||||
|
enemy.patrolPath = generatePatrolPath(enemy);
|
||||||
|
}
|
||||||
|
if (enemy.patrolPath) {
|
||||||
|
const target = enemy.patrolPath[enemy.patrolIdx];
|
||||||
|
if (target) {
|
||||||
|
moveToward(enemy, target.x, target.y);
|
||||||
|
if (enemy.x === target.x && enemy.y === target.y) {
|
||||||
|
enemy.patrolIdx = (enemy.patrolIdx + 1) % enemy.patrolPath.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Behavior: stationary
|
||||||
|
if (enemy.behavior === 'stationary') {
|
||||||
|
if (dist <= (enemy.activateRange || 2)) {
|
||||||
|
moveToward(enemy, px, py);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Behavior: teleport
|
||||||
|
if (enemy.behavior === 'teleport') {
|
||||||
|
if (enemy.teleportTimer !== undefined) {
|
||||||
|
enemy.teleportTimer -= dt;
|
||||||
|
if (enemy.teleportTimer <= 0) {
|
||||||
|
enemy.teleportTimer = enemy.teleportInterval || 4000;
|
||||||
|
// Teleport near player
|
||||||
|
const tx = clamp(px + randInt(-2, 2), 1, COLS - 2);
|
||||||
|
const ty = clamp(py + randInt(-2, 2), 1, ROWS - 2);
|
||||||
|
if (isPassable(gameState.layout, tx, ty)) {
|
||||||
|
spawnParticles(enemy.x * TILE + TILE / 2, enemy.y * TILE + TILE / 2, '#8B008B', 6);
|
||||||
|
enemy.x = tx;
|
||||||
|
enemy.y = ty;
|
||||||
|
spawnParticles(enemy.x * TILE + TILE / 2, enemy.y * TILE + TILE / 2, '#8B008B', 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Behavior: explode
|
||||||
|
if (enemy.behavior === 'explode') {
|
||||||
|
moveToward(enemy, px, py);
|
||||||
|
if (manhattanDist(enemy.x, enemy.y, px, py) === 0) {
|
||||||
|
// Explode!
|
||||||
|
spawnParticles(enemy.x * TILE + TILE / 2, enemy.y * TILE + TILE / 2, '#FF4500', 12);
|
||||||
|
enemy.hp = 0;
|
||||||
|
// Damage player if adjacent
|
||||||
|
if (manhattanDist(enemy.x, enemy.y, px, py) <= 1 && !gameState.player.invincible) {
|
||||||
|
gameState.player.hp -= (enemy.explodeDamage || 3);
|
||||||
|
gameState.player.hitTimer = HIT_FLASH;
|
||||||
|
showMessage('Explosion!');
|
||||||
|
}
|
||||||
|
// Damage in radius
|
||||||
|
for (const other of gameState.enemies) {
|
||||||
|
if (other !== enemy && manhattanDist(enemy.x, enemy.y, other.x, other.y) <= (enemy.explodeRadius || 2)) {
|
||||||
|
dealDamage(other, enemy.explodeDamage || 2, { name: 'explosion' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveToward(entity, tx, ty) {
|
||||||
|
const dx = tx - entity.x;
|
||||||
|
const dy = ty - entity.y;
|
||||||
|
let bestDir = null;
|
||||||
|
let bestDist = manhattanDist(entity.x, entity.y, tx, ty);
|
||||||
|
|
||||||
|
for (let d = 0; d < 4; d++) {
|
||||||
|
const nx = entity.x + DIRS[d].x;
|
||||||
|
const ny = entity.y + DIRS[d].y;
|
||||||
|
|
||||||
|
if (!inBounds(nx, ny)) continue;
|
||||||
|
if (isWall(gameState.layout, nx, ny)) continue;
|
||||||
|
if (gameState.layout[ny][nx] === TILE_TYPES.LAVA) continue;
|
||||||
|
if (gameState.rocks.find(r => r.x === nx && r.y === ny)) continue;
|
||||||
|
if (gameState.enemies.find(e => e !== entity && e.x === nx && e.y === ny)) continue;
|
||||||
|
if (gameState.chests.find(c => c.x === nx && c.y === ny)) continue;
|
||||||
|
if (gameState.player.x === nx && gameState.player.y === ny) continue;
|
||||||
|
if (gameState.currentBoss && gameState.currentBoss.x === nx && gameState.currentBoss.y === ny) continue;
|
||||||
|
|
||||||
|
const newDist = manhattanDist(nx, ny, tx, ty);
|
||||||
|
if (newDist < bestDist) {
|
||||||
|
bestDist = newDist;
|
||||||
|
bestDir = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestDir !== null) {
|
||||||
|
entity.x += DIRS[bestDir].x;
|
||||||
|
entity.y += DIRS[bestDir].y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBossMovement(boss) {
|
||||||
|
moveToward(boss, gameState.player.x, gameState.player.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enemyAttack(enemy) {
|
||||||
|
if (gameState.player.invincible || gameState.player.invincibleTimer > 0) return;
|
||||||
|
|
||||||
|
const damage = enemy.attackDamage || 2;
|
||||||
|
gameState.player.hp -= damage;
|
||||||
|
gameState.player.hitTimer = HIT_FLASH;
|
||||||
|
showMessage(`Hit by ${enemy.name}! (-${damage} HP)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bossAttack(boss) {
|
||||||
|
if (gameState.player.invincible || gameState.player.invincibleTimer > 0) return;
|
||||||
|
|
||||||
|
const attacks = boss.attacks || [{ type: 'melee', damage: 3 }];
|
||||||
|
const attack = attacks[Math.floor((boss.rng ? boss.rng() : Math.random()) * attacks.length)];
|
||||||
|
|
||||||
|
if (attack.type === 'melee') {
|
||||||
|
if (manhattanDist(boss.x, boss.y, gameState.player.x, gameState.player.y) <= (attack.range || 1)) {
|
||||||
|
gameState.player.hp -= attack.damage;
|
||||||
|
gameState.player.hitTimer = HIT_FLASH;
|
||||||
|
showMessage(`Boss attack! (-${attack.damage} HP)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attack.type === 'ranged') {
|
||||||
|
const dir = computeDirection(boss.x, boss.y, gameState.player.x, gameState.player.y);
|
||||||
|
spawnProjectile(boss.x, boss.y, dir, {
|
||||||
|
damage: attack.damage,
|
||||||
|
projSpeed: attack.speed || 3,
|
||||||
|
projRange: attack.range || 6,
|
||||||
|
projColor: attack.color || '#FF0000',
|
||||||
|
name: 'boss_proj',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attack.type === 'aoe') {
|
||||||
|
spawnParticles(boss.x * TILE + TILE / 2, boss.y * TILE + TILE / 2, attack.color || '#FF00FF', 15);
|
||||||
|
if (manhattanDist(boss.x, boss.y, gameState.player.x, gameState.player.y) <= (attack.radius || 3)) {
|
||||||
|
gameState.player.hp -= attack.damage;
|
||||||
|
gameState.player.hitTimer = HIT_FLASH;
|
||||||
|
showMessage(`Boss AoE attack! (-${attack.damage} HP)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enemyShoot(enemy) {
|
||||||
|
const dir = computeDirection(enemy.x, enemy.y, gameState.player.x, gameState.player.y);
|
||||||
|
spawnProjectile(enemy.x, enemy.y, dir, {
|
||||||
|
damage: enemy.shootDamage || 1,
|
||||||
|
projSpeed: enemy.shootSpeed || 3,
|
||||||
|
projRange: enemy.shootRange || 5,
|
||||||
|
projColor: enemy.projColor || '#FFFF00',
|
||||||
|
name: 'enemy_proj',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function enemyCharge(enemy) {
|
||||||
|
const dir = computeDirection(enemy.x, enemy.y, gameState.player.x, gameState.player.y);
|
||||||
|
const speed = enemy.chargeSpeed || 3;
|
||||||
|
for (let i = 0; i < speed; i++) {
|
||||||
|
const nx = enemy.x + dir.x;
|
||||||
|
const ny = enemy.y + dir.y;
|
||||||
|
if (!isPassable(gameState.layout, nx, ny)) break;
|
||||||
|
if (gameState.enemies.find(e => e !== enemy && e.x === nx && e.y === ny)) break;
|
||||||
|
if (gameState.player.x === nx && gameState.player.y === ny) {
|
||||||
|
if (!gameState.player.invincible) {
|
||||||
|
gameState.player.hp -= (enemy.chargeDamage || 3);
|
||||||
|
gameState.player.hitTimer = HIT_FLASH;
|
||||||
|
showMessage('Charged into you!');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
enemy.x = nx;
|
||||||
|
enemy.y = ny;
|
||||||
|
spawnParticles(nx * TILE + TILE / 2, ny * TILE + TILE / 2, '#FFFFFF', 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeDirection(x1, y1, x2, y2) {
|
||||||
|
const dx = x2 - x1;
|
||||||
|
const dy = y2 - y1;
|
||||||
|
if (Math.abs(dx) > Math.abs(dy)) {
|
||||||
|
return { x: dx > 0 ? 1 : -1, y: 0 };
|
||||||
|
} else {
|
||||||
|
return { x: 0, y: dy > 0 ? 1 : -1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePatrolPath(enemy) {
|
||||||
|
const path = [];
|
||||||
|
const points = enemy.patrolPoints || [];
|
||||||
|
if (points.length >= 2) return points;
|
||||||
|
|
||||||
|
// Generate random patrol points around spawn position
|
||||||
|
const sx = enemy.spawnX || enemy.x;
|
||||||
|
const sy = enemy.spawnY || enemy.y;
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const px = clamp(sx + randInt(-3, 3), 1, COLS - 2);
|
||||||
|
const py = clamp(sy + randInt(-3, 3), 1, ROWS - 2);
|
||||||
|
if (isPassable(gameState.layout, px, py)) {
|
||||||
|
path.push({ x: px, y: py });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (path.length < 2) path.push({ x: sx, y: sy }, { x: clamp(sx + 2, 1, COLS - 2), y: sy });
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEnemyInstance(def, x, y) {
|
||||||
|
return {
|
||||||
|
name: def.name,
|
||||||
|
id: def.id,
|
||||||
|
x: x, y: y,
|
||||||
|
spawnX: x, spawnY: y,
|
||||||
|
hp: def.hp,
|
||||||
|
maxHp: def.hp,
|
||||||
|
defense: def.defense || 0,
|
||||||
|
attackDamage: def.attackDamage || 1,
|
||||||
|
moveInterval: def.moveInterval || ENEMY_MOVE_INTERVAL,
|
||||||
|
attackInterval: def.attackInterval || ENEMY_ATTACK_INTERVAL,
|
||||||
|
attackRange: def.attackRange || 1,
|
||||||
|
behavior: def.behavior || 'chase',
|
||||||
|
moveTimer: ENEMY_MOVE_INTERVAL,
|
||||||
|
attackTimer: ENEMY_ATTACK_INTERVAL,
|
||||||
|
hitTimer: 0,
|
||||||
|
stunned: false,
|
||||||
|
frozen: false,
|
||||||
|
onFire: false,
|
||||||
|
specialTimer: 0,
|
||||||
|
projectileTimer: def.projectileInterval ? def.projectileInterval : undefined,
|
||||||
|
color: def.color,
|
||||||
|
colorLight: def.colorLight,
|
||||||
|
eyeColor: def.eyeColor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { updateEnemies, updateEnemyMovement, moveToward, enemyAttack, bossAttack, createEnemyInstance };
|
||||||
|
}
|
||||||
222
src/game/combat.js
Normal file
222
src/game/combat.js
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === COMBAT SYSTEM ===
|
||||||
|
|
||||||
|
function tryAttack() {
|
||||||
|
const weapon = gameState.player.activeWeapon;
|
||||||
|
if (!weapon) {
|
||||||
|
// Try to use first available weapon
|
||||||
|
if (gameState.player.weapons.length > 0) {
|
||||||
|
gameState.player.activeWeapon = gameState.player.weapons[0];
|
||||||
|
} else {
|
||||||
|
showMessage('You need a weapon!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = gameState.player.activeWeapon;
|
||||||
|
if (gameState.player.attackTimer > 0) return;
|
||||||
|
|
||||||
|
// Check ammo
|
||||||
|
if (active.ammoRequired) {
|
||||||
|
const ammoType = active.ammoType || 'arrows';
|
||||||
|
const ammoCount = ammoType === 'arrows' ? gameState.player.arrows : gameState.player.bombs;
|
||||||
|
if (ammoCount <= 0) {
|
||||||
|
showMessage(`No ${ammoType} left!`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ammoType === 'arrows') gameState.player.arrows--;
|
||||||
|
else gameState.player.bombs--;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set attack timer
|
||||||
|
gameState.player.attackTimer = active.cooldown || ATTACK_COOLDOWN;
|
||||||
|
gameState.player.attackAnim = active.animDuration || ATTACK_ANIM;
|
||||||
|
|
||||||
|
const dir = DIRS[gameState.player.dir];
|
||||||
|
const range = active.range || 1;
|
||||||
|
|
||||||
|
// Melee attack
|
||||||
|
if (active.type === 'melee') {
|
||||||
|
for (let i = 1; i <= range; i++) {
|
||||||
|
const ax = gameState.player.x + dir.x * i;
|
||||||
|
const ay = gameState.player.y + dir.y * i;
|
||||||
|
if (!inBounds(ax, ay)) break;
|
||||||
|
|
||||||
|
// Hit enemies
|
||||||
|
for (const enemy of gameState.enemies) {
|
||||||
|
if (enemy.x === ax && enemy.y === ay) {
|
||||||
|
dealDamage(enemy, active.damage, active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hit boss
|
||||||
|
if (gameState.currentBoss && gameState.currentBoss.x === ax && gameState.currentBoss.y === ay) {
|
||||||
|
dealDamage(gameState.currentBoss, active.damage, active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AoE weapons hit in arc
|
||||||
|
if (active.aoe) {
|
||||||
|
for (const enemy of gameState.enemies) {
|
||||||
|
if (manhattanDist(gameState.player.x, gameState.player.y, enemy.x, enemy.y) <= active.aoeRange) {
|
||||||
|
dealDamage(enemy, Math.floor(active.damage * 0.5), active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ranged attack
|
||||||
|
if (active.type === 'ranged') {
|
||||||
|
spawnProjectile(gameState.player.x, gameState.player.y, dir, active);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special effects
|
||||||
|
if (active.effect === 'stun') {
|
||||||
|
for (const enemy of gameState.enemies) {
|
||||||
|
if (enemy.x === gameState.player.x + dir.x && enemy.y === gameState.player.y + dir.y) {
|
||||||
|
enemy.stunned = true;
|
||||||
|
enemy.specialTimer = active.effectDuration || 2000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (active.effect === 'freeze') {
|
||||||
|
for (const enemy of gameState.enemies) {
|
||||||
|
if (enemy.x === gameState.player.x + dir.x && enemy.y === gameState.player.y + dir.y) {
|
||||||
|
enemy.frozen = true;
|
||||||
|
enemy.specialTimer = active.effectDuration || 3000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (active.effect === 'fire') {
|
||||||
|
for (const enemy of gameState.enemies) {
|
||||||
|
if (enemy.x === gameState.player.x + dir.x && enemy.y === gameState.player.y + dir.y) {
|
||||||
|
dealDamage(enemy, active.damage, active);
|
||||||
|
enemy.onFire = true;
|
||||||
|
enemy.specialTimer = active.effectDuration || 2000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up dead enemies
|
||||||
|
const beforeCount = gameState.enemies.length;
|
||||||
|
gameState.enemies = gameState.enemies.filter(e => e.hp > 0);
|
||||||
|
const killed = beforeCount - gameState.enemies.length;
|
||||||
|
if (killed > 0) {
|
||||||
|
gameState.enemiesDefeated += killed;
|
||||||
|
showMessage(`${killed} enemy${killed > 1 ? 'ies' : ''} defeated!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check boss death
|
||||||
|
if (gameState.currentBoss && gameState.currentBoss.hp <= 0) {
|
||||||
|
const boss = gameState.currentBoss;
|
||||||
|
showMessage(`${boss.name} defeated!`);
|
||||||
|
gameState.currentBoss = null;
|
||||||
|
gameState.exitOpen = true;
|
||||||
|
gameState.enemiesDefeated++;
|
||||||
|
spawnParticles(boss.x * TILE + TILE / 2, boss.y * TILE + TILE / 2, '#FFD700', 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no enemies and no exit, room is clear
|
||||||
|
if (gameState.enemies.length === 0 && !gameState.exit && !gameState.currentBoss) {
|
||||||
|
gameState.exitOpen = true;
|
||||||
|
showMessage('Room cleared!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dealDamage(target, damage, weapon) {
|
||||||
|
if (target.invincible && target.invincibleTimer > 0) return;
|
||||||
|
|
||||||
|
// Apply defense reduction
|
||||||
|
const def = target.defense || 0;
|
||||||
|
const actualDamage = Math.max(1, damage - def);
|
||||||
|
target.hp -= actualDamage;
|
||||||
|
target.hitTimer = HIT_FLASH;
|
||||||
|
|
||||||
|
// Spawn hit particles
|
||||||
|
spawnParticles(target.x * TILE + TILE / 2, target.y * TILE + TILE / 2, '#FF0000', 4);
|
||||||
|
|
||||||
|
// Check for phase change (bosses)
|
||||||
|
if (target.phases && target.phases[target.phase + 1] && target.hp <= target.phases[target.phase + 1].hpThreshold * target.maxHp / 100) {
|
||||||
|
target.phase++;
|
||||||
|
showMessage(`${target.name} enrage${target.phase > 0 ? 's' : ''}! (Phase ${target.phase + 1})`);
|
||||||
|
target.attackTimer = 0;
|
||||||
|
target.moveTimer = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnProjectile(x, y, dir, weapon) {
|
||||||
|
if (!gameState.projectiles) gameState.projectiles = [];
|
||||||
|
gameState.projectiles.push({
|
||||||
|
x: x + dir.x,
|
||||||
|
y: y + dir.y,
|
||||||
|
dir: { ...dir },
|
||||||
|
speed: weapon.projSpeed || 4,
|
||||||
|
damage: weapon.damage,
|
||||||
|
range: weapon.projRange || 8,
|
||||||
|
traveled: 0,
|
||||||
|
type: weapon.type,
|
||||||
|
effect: weapon.effect,
|
||||||
|
effectDuration: weapon.effectDuration,
|
||||||
|
color: weapon.projColor || '#FFFF00',
|
||||||
|
moveTimer: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProjectiles(dt) {
|
||||||
|
if (!gameState.projectiles) return;
|
||||||
|
for (let i = gameState.projectiles.length - 1; i >= 0; i--) {
|
||||||
|
const p = gameState.projectiles[i];
|
||||||
|
p.moveTimer -= dt;
|
||||||
|
if (p.moveTimer > 0) continue;
|
||||||
|
p.moveTimer = 100;
|
||||||
|
|
||||||
|
const nx = p.x + p.dir.x;
|
||||||
|
const ny = p.y + p.dir.y;
|
||||||
|
p.traveled++;
|
||||||
|
|
||||||
|
// Check bounds
|
||||||
|
if (!inBounds(nx, ny) || p.traveled >= p.range) {
|
||||||
|
gameState.projectiles.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check walls
|
||||||
|
if (isWall(gameState.layout, nx, ny)) {
|
||||||
|
spawnParticles(nx * TILE + TILE / 2, ny * TILE + TILE / 2, p.color, 3);
|
||||||
|
gameState.projectiles.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hit enemies
|
||||||
|
let hit = false;
|
||||||
|
for (const enemy of gameState.enemies) {
|
||||||
|
if (enemy.x === nx && enemy.y === ny) {
|
||||||
|
dealDamage(enemy, p.damage, { name: 'projectile' });
|
||||||
|
if (p.effect === 'stun') { enemy.stunned = true; enemy.specialTimer = p.effectDuration || 2000; }
|
||||||
|
if (p.effect === 'freeze') { enemy.frozen = true; enemy.specialTimer = p.effectDuration || 3000; }
|
||||||
|
hit = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hit boss
|
||||||
|
if (!hit && gameState.currentBoss && gameState.currentBoss.x === nx && gameState.currentBoss.y === ny) {
|
||||||
|
dealDamage(gameState.currentBoss, p.damage, { name: 'projectile' });
|
||||||
|
hit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hit) {
|
||||||
|
spawnParticles(nx * TILE + TILE / 2, ny * TILE + TILE / 2, '#FF0000', 5);
|
||||||
|
gameState.projectiles.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.x = nx;
|
||||||
|
p.y = ny;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { tryAttack, dealDamage, spawnProjectile, updateProjectiles };
|
||||||
|
}
|
||||||
148
src/game/items.js
Normal file
148
src/game/items.js
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === ITEM SYSTEM ===
|
||||||
|
|
||||||
|
var Save = typeof require !== 'undefined' ? require('../save/save') : {};
|
||||||
|
var saveData = Save.saveData || window.saveData;
|
||||||
|
var saveProgress = Save.saveProgress || window.saveProgress;
|
||||||
|
var addHeartPiece = Save.addHeartPiece || window.addHeartPiece;
|
||||||
|
var addWeapon = Save.addWeapon || window.addWeapon;
|
||||||
|
|
||||||
|
function openChest(chest) {
|
||||||
|
chest.opened = true;
|
||||||
|
const loot = chest.loot;
|
||||||
|
if (!loot) return;
|
||||||
|
|
||||||
|
spawnParticles(chest.x * TILE + TILE / 2, chest.y * TILE + TILE / 2, '#FFD700', 10);
|
||||||
|
|
||||||
|
if (loot.type === 'weapon') {
|
||||||
|
const weaponDef = WEAPONS.find(w => w.name === loot.name);
|
||||||
|
if (weaponDef && !gameState.player.weapons.find(w => w.name === weaponDef.name)) {
|
||||||
|
gameState.player.weapons.push({ ...weaponDef });
|
||||||
|
if (!gameState.player.activeWeapon) {
|
||||||
|
gameState.player.activeWeapon = gameState.player.weapons[0];
|
||||||
|
}
|
||||||
|
addWeapon(weaponDef.name);
|
||||||
|
showMessage(`Got ${weaponDef.name}!`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loot.type === 'consumable') {
|
||||||
|
const itemDef = ITEMS.find(i => i.name === loot.name);
|
||||||
|
if (itemDef) {
|
||||||
|
if (itemDef.id === 'potion_small') {
|
||||||
|
gameState.player.hp = Math.min(gameState.player.hp + 4, gameState.player.maxHp);
|
||||||
|
showMessage('Small Potion! (+4 HP)');
|
||||||
|
} else if (itemDef.id === 'potion_medium') {
|
||||||
|
gameState.player.hp = Math.min(gameState.player.hp + 8, gameState.player.maxHp);
|
||||||
|
showMessage('Medium Potion! (+8 HP)');
|
||||||
|
} else if (itemDef.id === 'potion_large') {
|
||||||
|
gameState.player.hp = Math.min(gameState.player.hp + 12, gameState.player.maxHp);
|
||||||
|
showMessage('Large Potion! (+12 HP)');
|
||||||
|
} else if (itemDef.id === 'heart_piece') {
|
||||||
|
gameState.player.items.push({ ...itemDef });
|
||||||
|
addHeartPiece();
|
||||||
|
showMessage(`Heart Piece! (${saveData.heartPieces}/4)`);
|
||||||
|
} else if (itemDef.id === 'heart_container') {
|
||||||
|
if (gameState.player.maxHp < MAX_HEARTS * 2) {
|
||||||
|
gameState.player.maxHp += 2;
|
||||||
|
gameState.player.hp = gameState.player.maxHp;
|
||||||
|
showMessage('Heart Container! Max HP increased!');
|
||||||
|
}
|
||||||
|
} else if (itemDef.id === 'arrows') {
|
||||||
|
gameState.player.arrows += (loot.count || 10);
|
||||||
|
showMessage(`+${loot.count || 10} Arrows!`);
|
||||||
|
} else if (itemDef.id === 'bombs') {
|
||||||
|
gameState.player.bombs += (loot.count || 10);
|
||||||
|
showMessage(`+${loot.count || 10} Bombs!`);
|
||||||
|
} else if (itemDef.id === 'key') {
|
||||||
|
gameState.player.keys += (loot.count || 1);
|
||||||
|
showMessage(`+${loot.count || 1} Key!`);
|
||||||
|
} else if (itemDef.id === 'bow') {
|
||||||
|
if (!gameState.player.weapons.find(w => w.name === 'Bow')) {
|
||||||
|
const bowDef = WEAPONS.find(w => w.name === 'Bow');
|
||||||
|
if (bowDef) {
|
||||||
|
gameState.player.weapons.push({ ...bowDef });
|
||||||
|
addWeapon('Bow');
|
||||||
|
showMessage('Got Bow!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gameState.player.arrows += (loot.count || 10);
|
||||||
|
showMessage(`+${loot.count || 10} Arrows!`);
|
||||||
|
} else {
|
||||||
|
gameState.player.items.push({ ...itemDef, count: loot.count || 1 });
|
||||||
|
showMessage(`Got ${itemDef.name}!`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loot.type === 'upgrade') {
|
||||||
|
if (loot.name === 'sword_upgrade' && gameState.player.activeWeapon) {
|
||||||
|
gameState.player.activeWeapon.damage += 1;
|
||||||
|
showMessage('Sword upgraded! (+1 damage)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFloorItem(item) {
|
||||||
|
item.collected = true;
|
||||||
|
const itemDef = ITEMS.find(i => i.id === item.id);
|
||||||
|
if (!itemDef) return;
|
||||||
|
|
||||||
|
spawnParticles(item.x * TILE + TILE / 2, item.y * TILE + TILE / 2, itemDef.color || '#FFD700', 5);
|
||||||
|
|
||||||
|
if (itemDef.type === 'potion') {
|
||||||
|
gameState.player.hp = Math.min(gameState.player.hp + (itemDef.healAmount || 4), gameState.player.maxHp);
|
||||||
|
showMessage(`${itemDef.name}! (+${itemDef.healAmount} HP)`);
|
||||||
|
} else if (itemDef.id === 'heart_piece') {
|
||||||
|
addHeartPiece();
|
||||||
|
showMessage(`Heart Piece! (${saveData.heartPieces}/4)`);
|
||||||
|
} else if (itemDef.id === 'arrows') {
|
||||||
|
gameState.player.arrows += (item.count || 5);
|
||||||
|
showMessage(`+${item.count || 5} Arrows!`);
|
||||||
|
} else if (itemDef.id === 'bombs') {
|
||||||
|
gameState.player.bombs += (item.count || 5);
|
||||||
|
showMessage(`+${item.count || 5} Bombs!`);
|
||||||
|
} else if (itemDef.id === 'key') {
|
||||||
|
gameState.player.keys++;
|
||||||
|
showMessage('+1 Key!');
|
||||||
|
} else {
|
||||||
|
gameState.player.items.push({ ...itemDef });
|
||||||
|
showMessage(`Got ${itemDef.name}!`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useItem(itemIdx) {
|
||||||
|
if (!gameState.player.items[itemIdx]) return;
|
||||||
|
const item = gameState.player.items[itemIdx];
|
||||||
|
const def = ITEMS.find(i => i.id === item.id);
|
||||||
|
if (!def || def.type !== 'consumable') return;
|
||||||
|
|
||||||
|
if (def.id.startsWith('potion')) {
|
||||||
|
gameState.player.hp = Math.min(gameState.player.hp + (def.healAmount || 4), gameState.player.maxHp);
|
||||||
|
showMessage(`Used ${def.name}! (+${def.healAmount} HP)`);
|
||||||
|
spawnParticles(gameState.player.x * TILE + TILE / 2, gameState.player.y * TILE + TILE / 2, '#00FF00', 8);
|
||||||
|
} else {
|
||||||
|
showMessage(`Can't use ${def.name} here`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.count = (item.count || 1) - 1;
|
||||||
|
if (item.count <= 0) {
|
||||||
|
gameState.player.items.splice(itemIdx, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchWeapon() {
|
||||||
|
if (gameState.player.weapons.length <= 1) return;
|
||||||
|
const currentIdx = gameState.player.weapons.findIndex(w => w === gameState.player.activeWeapon);
|
||||||
|
const nextIdx = (currentIdx + 1) % gameState.player.weapons.length;
|
||||||
|
gameState.player.activeWeapon = gameState.player.weapons[nextIdx];
|
||||||
|
showMessage(`Switched to ${gameState.player.activeWeapon.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { openChest, collectFloorItem, useItem, switchWeapon };
|
||||||
|
}
|
||||||
239
src/game/movement.js
Normal file
239
src/game/movement.js
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === PLAYER MOVEMENT ===
|
||||||
|
|
||||||
|
function tryMove(dx, dy) {
|
||||||
|
if (gameState.player.invincibleTimer > 0 && gameState.player.invincibleTimer < 1000) return;
|
||||||
|
|
||||||
|
const px = gameState.player.x;
|
||||||
|
const py = gameState.player.y;
|
||||||
|
const nx = px + dx;
|
||||||
|
const ny = py + dy;
|
||||||
|
|
||||||
|
if (dx === 0 && dy === 0) return;
|
||||||
|
|
||||||
|
// Update facing direction
|
||||||
|
if (dy === 1 && gameState.player.dir !== 0) gameState.player.dir = 0;
|
||||||
|
else if (dy === -1 && gameState.player.dir !== 1) gameState.player.dir = 1;
|
||||||
|
else if (dx === -1 && gameState.player.dir !== 2) gameState.player.dir = 2;
|
||||||
|
else if (dx === 1 && gameState.player.dir !== 3) gameState.player.dir = 3;
|
||||||
|
|
||||||
|
if (!inBounds(nx, ny)) return;
|
||||||
|
|
||||||
|
// Check exit before wall check
|
||||||
|
if (gameState.exit && gameState.exit.x === nx && gameState.exit.y === ny) {
|
||||||
|
tryExit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check walls
|
||||||
|
if (isWall(gameState.layout, nx, ny)) return;
|
||||||
|
|
||||||
|
// Check lava/water (not passable without item)
|
||||||
|
if (gameState.layout[ny][nx] === TILE_TYPES.LAVA) return;
|
||||||
|
if (gameState.layout[ny][nx] === TILE_TYPES.WATER) {
|
||||||
|
if (!gameState.player.items.find(i => i.id === 'flippers' || i.id === 'power_boomerang')) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check traps
|
||||||
|
const trap = gameState.traps.find(t => t.x === nx && t.y === ny && !t.triggered);
|
||||||
|
if (trap) {
|
||||||
|
trap.triggered = true;
|
||||||
|
if (!gameState.player.invincible) {
|
||||||
|
gameState.player.hp -= trap.damage;
|
||||||
|
gameState.player.hitTimer = HIT_FLASH;
|
||||||
|
showMessage(`Trap! (-${trap.damage} HP)`);
|
||||||
|
}
|
||||||
|
spawnParticles(nx * TILE + TILE / 2, ny * TILE + TILE / 2, trap.color || '#FF0000', 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push rocks
|
||||||
|
const rock = gameState.rocks.find(r => r.x === nx && r.y === ny);
|
||||||
|
if (rock) {
|
||||||
|
const rnx = nx + dx;
|
||||||
|
const rny = ny + dy;
|
||||||
|
if (!inBounds(rnx, rny)) return;
|
||||||
|
if (isWall(gameState.layout, rnx, rny)) return;
|
||||||
|
if (gameState.rocks.find(r => r.x === rnx && r.y === rny)) return;
|
||||||
|
if (gameState.enemies.find(e => e.x === rnx && e.y === rny)) return;
|
||||||
|
if (gameState.exit && gameState.exit.x === rnx && gameState.exit.y === rny) return;
|
||||||
|
if (gameState.chests.find(c => c.x === rnx && c.y === rny)) return;
|
||||||
|
if (gameState.layout[rny][rnx] === TILE_TYPES.LAVA) return;
|
||||||
|
rock.x = rnx;
|
||||||
|
rock.y = rny;
|
||||||
|
spawnParticles(rnx * TILE + TILE / 2, rny * TILE + TILE / 2, COLORS.rock, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check enemies (can't walk through)
|
||||||
|
if (gameState.enemies.find(e => e.x === nx && e.y === ny)) return;
|
||||||
|
if (gameState.currentBoss && gameState.currentBoss.x === nx && gameState.currentBoss.y === ny) return;
|
||||||
|
|
||||||
|
// Move player
|
||||||
|
gameState.player.x = nx;
|
||||||
|
gameState.player.y = ny;
|
||||||
|
|
||||||
|
// Check chests
|
||||||
|
const chest = gameState.chests.find(c => c.x === nx && c.y === ny && !c.opened);
|
||||||
|
if (chest) {
|
||||||
|
openChest(chest);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check floor items
|
||||||
|
const floorItem = gameState.floorItems.find(i => i.x === nx && i.y === ny && !i.collected);
|
||||||
|
if (floorItem) {
|
||||||
|
collectFloorItem(floorItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check switches
|
||||||
|
const sw = gameState.switches.find(s => s.x === nx && s.y === ny && !s.activated);
|
||||||
|
if (sw) {
|
||||||
|
sw.activated = true;
|
||||||
|
showMessage(sw.message || 'Switch activated!');
|
||||||
|
spawnParticles(nx * TILE + TILE / 2, ny * TILE + TILE / 2, '#FFD700', 6);
|
||||||
|
if (sw.targetDoor) {
|
||||||
|
const door = gameState.doors.find(d => d.id === sw.targetDoor);
|
||||||
|
if (door) {
|
||||||
|
door.opened = !door.opened;
|
||||||
|
showMessage(door.opened ? 'Door opened!' : 'Door closed!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sw.openExit) {
|
||||||
|
gameState.exitOpen = true;
|
||||||
|
showMessage('The exit has opened!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check levers
|
||||||
|
const lever = gameState.levers.find(l => l.x === nx && l.y === ny && !l.activated);
|
||||||
|
if (lever) {
|
||||||
|
lever.activated = true;
|
||||||
|
showMessage(lever.message || 'Lever pulled!');
|
||||||
|
spawnParticles(nx * TILE + TILE / 2, ny * TILE + TILE / 2, COLORS.lever, 6);
|
||||||
|
if (lever.effect === 'open_exit') {
|
||||||
|
gameState.exitOpen = true;
|
||||||
|
showMessage('The exit has opened!');
|
||||||
|
}
|
||||||
|
if (lever.effect === 'drop_item') {
|
||||||
|
gameState.player.items.push({ ...lever.item });
|
||||||
|
showMessage(`Got ${lever.item.name}!`);
|
||||||
|
}
|
||||||
|
if (lever.effect === 'remove_wall') {
|
||||||
|
for (const [wx, wy] of lever.wallsToRemove) {
|
||||||
|
if (inBounds(wx, wy)) gameState.layout[wy][wx] = TILE_TYPES.FLOOR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check doors
|
||||||
|
const door = gameState.doors.find(d => d.x === nx && d.y === ny);
|
||||||
|
if (door && !door.opened) {
|
||||||
|
if (door.requiredKey && gameState.player.keys > 0) {
|
||||||
|
gameState.player.keys--;
|
||||||
|
door.opened = true;
|
||||||
|
showMessage('Door unlocked!');
|
||||||
|
} else if (door.requiredKey) {
|
||||||
|
showMessage('Need a key!');
|
||||||
|
} else {
|
||||||
|
door.opened = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check soft walls (breakable)
|
||||||
|
if (isSoftWall(gameState.layout, nx, ny)) {
|
||||||
|
const weapon = gameState.player.activeWeapon;
|
||||||
|
if (weapon && (weapon.canBreakWalls || weapon.name === 'bomb' || weapon.name === 'hammer')) {
|
||||||
|
gameState.layout[ny][nx] = TILE_TYPES.FLOOR;
|
||||||
|
spawnParticles(nx * TILE + TILE / 2, ny * TILE + TILE / 2, COLORS.softwall, 6);
|
||||||
|
showMessage('Wall destroyed!');
|
||||||
|
} else {
|
||||||
|
showMessage('Need bombs or hammer!');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check puzzle
|
||||||
|
checkPuzzle();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryInteract() {
|
||||||
|
const px = gameState.player.x;
|
||||||
|
const py = gameState.player.y;
|
||||||
|
const dir = DIRS[gameState.player.dir];
|
||||||
|
const tx = px + dir.x;
|
||||||
|
const ty = py + dir.y;
|
||||||
|
|
||||||
|
// Try doors in facing direction
|
||||||
|
const door = gameState.doors.find(d => d.x === tx && d.y === ty && !d.opened);
|
||||||
|
if (door) {
|
||||||
|
if (door.requiredKey && gameState.player.keys > 0) {
|
||||||
|
gameState.player.keys--;
|
||||||
|
door.opened = true;
|
||||||
|
showMessage('Door unlocked!');
|
||||||
|
} else if (door.requiredKey) {
|
||||||
|
showMessage('Need a key!');
|
||||||
|
} else {
|
||||||
|
door.opened = true;
|
||||||
|
showMessage('Door opened!');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try chests in facing direction
|
||||||
|
const chest = gameState.chests.find(c => c.x === tx && c.y === ty && !c.opened);
|
||||||
|
if (chest) {
|
||||||
|
openChest(chest);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryExit() {
|
||||||
|
if (!gameState.exit) return;
|
||||||
|
|
||||||
|
if (!gameState.exitOpen) {
|
||||||
|
showMessage('The door is locked!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is the last room
|
||||||
|
if (gameState.currentRoomIdx >= (gameState.dungeonRooms ? gameState.dungeonRooms.length - 1 : 0)) {
|
||||||
|
completeDungeon();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next room
|
||||||
|
gameState.currentRoomIdx++;
|
||||||
|
loadDungeonRoom(gameState.currentDungeon, gameState.currentRoomIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeDungeon() {
|
||||||
|
const dungeon = DUNGEONS[gameState.currentDungeon];
|
||||||
|
showMessage(`${dungeon.name} cleared!`);
|
||||||
|
|
||||||
|
// Save progress
|
||||||
|
markDungeonComplete(gameState.currentDungeon);
|
||||||
|
addStats(gameState.roomsCleared, gameState.enemiesDefeated);
|
||||||
|
gameState.dungeonsCompleted++;
|
||||||
|
saveProgress();
|
||||||
|
|
||||||
|
// Check if all dungeons completed
|
||||||
|
if (gameState.currentDungeon >= DUNGEONS.length - 1) {
|
||||||
|
switchScreen(SCREEN_GAME_COMPLETE);
|
||||||
|
} else {
|
||||||
|
switchScreen(SCREEN_DUNGEON_COMPLETE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkPuzzle() {
|
||||||
|
if (gameState.targets.length === 0) return;
|
||||||
|
const allSolved = gameState.targets.every(t => gameState.rocks.some(r => r.x === t.x && r.y === t.y));
|
||||||
|
if (allSolved) {
|
||||||
|
gameState.exitOpen = true;
|
||||||
|
showMessage('Puzzle solved! The door has opened!');
|
||||||
|
spawnParticles(gameState.exit.x * TILE + TILE / 2, gameState.exit.y * TILE + TILE / 2, '#FFD700', 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { tryMove, tryInteract, tryExit, completeDungeon, checkPuzzle };
|
||||||
|
}
|
||||||
146
src/game/state.js
Normal file
146
src/game/state.js
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === GAME STATE ===
|
||||||
|
|
||||||
|
let gameState;
|
||||||
|
|
||||||
|
function initGameState() {
|
||||||
|
gameState = window.gameState = {
|
||||||
|
// Screen/dungeon state
|
||||||
|
currentDungeon: null,
|
||||||
|
currentRoomIdx: 0,
|
||||||
|
dungeonRooms: null,
|
||||||
|
currentRoom: null,
|
||||||
|
|
||||||
|
// Player
|
||||||
|
player: {
|
||||||
|
x: 0, y: 0,
|
||||||
|
hp: 12, maxHp: 12,
|
||||||
|
dir: 0, // 0=down, 1=up, 2=left, 3=right
|
||||||
|
moveTimer: 0,
|
||||||
|
attackTimer: 0,
|
||||||
|
attackAnim: 0,
|
||||||
|
hitTimer: 0,
|
||||||
|
invincibleTimer: 0,
|
||||||
|
invincible: false,
|
||||||
|
weapons: [],
|
||||||
|
activeWeapon: null,
|
||||||
|
items: [],
|
||||||
|
bombs: 0,
|
||||||
|
arrows: 0,
|
||||||
|
keys: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Room state
|
||||||
|
layout: null,
|
||||||
|
rocks: [],
|
||||||
|
targets: [],
|
||||||
|
chests: [],
|
||||||
|
enemies: [],
|
||||||
|
exit: null,
|
||||||
|
exitOpen: false,
|
||||||
|
torches: [],
|
||||||
|
doors: [],
|
||||||
|
levers: [],
|
||||||
|
switches: [],
|
||||||
|
traps: [],
|
||||||
|
softwalls: [],
|
||||||
|
currentBoss: null,
|
||||||
|
floorItems: [],
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
message: '',
|
||||||
|
messageTimer: 0,
|
||||||
|
|
||||||
|
// Particles
|
||||||
|
particles: [],
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
roomsCleared: 0,
|
||||||
|
enemiesDefeated: 0,
|
||||||
|
dungeonsCompleted: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPlayer() {
|
||||||
|
gameState.player.hp = gameState.player.maxHp;
|
||||||
|
gameState.player.x = 0;
|
||||||
|
gameState.player.y = 0;
|
||||||
|
gameState.player.dir = 0;
|
||||||
|
gameState.player.moveTimer = 0;
|
||||||
|
gameState.player.attackTimer = 0;
|
||||||
|
gameState.player.attackAnim = 0;
|
||||||
|
gameState.player.hitTimer = 0;
|
||||||
|
gameState.player.invincibleTimer = 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(text) {
|
||||||
|
gameState.message = text;
|
||||||
|
gameState.messageTimer = MESSAGE_DURATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === ROOM LOADING ===
|
||||||
|
|
||||||
|
function loadDungeonRoom(dungeonIdx, roomIdx) {
|
||||||
|
const rooms = gameState.dungeonRooms;
|
||||||
|
if (!rooms || roomIdx >= rooms.length) return;
|
||||||
|
|
||||||
|
gameState.currentDungeon = dungeonIdx;
|
||||||
|
gameState.currentRoomIdx = roomIdx;
|
||||||
|
const room = rooms[roomIdx];
|
||||||
|
|
||||||
|
// BSP generator produces room.tiles; legacy generators produce room.layout
|
||||||
|
gameState.layout = room.layout ? cloneLayout(room.layout) : (room.tiles ? cloneLayout(room.tiles) : createEmptyLayout());
|
||||||
|
|
||||||
|
// Compute player start position
|
||||||
|
if (room.playerStart) {
|
||||||
|
gameState.player.x = room.playerStart.x;
|
||||||
|
gameState.player.y = room.playerStart.y;
|
||||||
|
} else {
|
||||||
|
// Default: entrance starts at left-center, boss at left-center, others at center
|
||||||
|
if (room.type === 'entrance') {
|
||||||
|
gameState.player.x = 2;
|
||||||
|
gameState.player.y = Math.floor(ROWS / 2);
|
||||||
|
} else if (room.type === 'boss') {
|
||||||
|
gameState.player.x = 2;
|
||||||
|
gameState.player.y = Math.floor(ROWS / 2);
|
||||||
|
} else {
|
||||||
|
gameState.player.x = Math.floor(COLS / 2);
|
||||||
|
gameState.player.y = Math.floor(ROWS / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gameState.rocks = room.rocks ? room.rocks.map(r => ({ ...r })) : [];
|
||||||
|
gameState.targets = room.targets ? room.targets.map(t => ({ ...t })) : [];
|
||||||
|
gameState.chests = room.chests ? room.chests.map(c => ({ ...c, opened: false })) : [];
|
||||||
|
gameState.enemies = room.enemies ? room.enemies.map(e => ({
|
||||||
|
...e,
|
||||||
|
moveTimer: ENEMY_MOVE_INTERVAL,
|
||||||
|
attackTimer: ENEMY_ATTACK_INTERVAL,
|
||||||
|
hitTimer: 0,
|
||||||
|
projectileTimer: 0,
|
||||||
|
specialTimer: 0,
|
||||||
|
phase: 0,
|
||||||
|
stunned: false,
|
||||||
|
frozen: false,
|
||||||
|
})) : [];
|
||||||
|
gameState.exit = room.exit ? { ...room.exit } : (room.hasBoss ? { x: COLS - 1, y: Math.floor(ROWS / 2), dir: 3 } : null);
|
||||||
|
gameState.exitOpen = room.exitOpen || false;
|
||||||
|
gameState.torches = room.torches ? room.torches.map(t => [...t]) : [];
|
||||||
|
gameState.doors = room.doors ? room.doors.map(d => ({ ...d })) : [];
|
||||||
|
gameState.levers = room.levers ? room.levers.map(l => ({ ...l })) : [];
|
||||||
|
gameState.switches = room.switches ? room.switches.map(s => ({ ...s, activated: false })) : [];
|
||||||
|
gameState.traps = room.traps ? room.traps.map(t => ({ ...t, triggered: false })) : [];
|
||||||
|
gameState.softwalls = room.softwalls ? room.softwalls.map(s => [...s]) : [];
|
||||||
|
gameState.currentRoom = room;
|
||||||
|
gameState.currentBoss = room.boss ? { ...room.boss, moveTimer: ENEMY_MOVE_INTERVAL, attackTimer: ENEMY_ATTACK_INTERVAL, hitTimer: 0, phase: 0, rng: createRNG((gameState.currentDungeon + 1) * 1000 + roomIdx) } : null;
|
||||||
|
gameState.floorItems = room.floorItems ? room.floorItems.map(i => ({ ...i, collected: false })) : [];
|
||||||
|
gameState.particles = [];
|
||||||
|
|
||||||
|
gameState.message = room.name || `Room ${roomIdx + 1}`;
|
||||||
|
gameState.messageTimer = MESSAGE_DURATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { initGameState, loadDungeonRoom, showMessage, resetPlayer };
|
||||||
|
}
|
||||||
34
src/game/transitions.js
Normal file
34
src/game/transitions.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === SCREEN TRANSITIONS ===
|
||||||
|
|
||||||
|
// Transitions are handled in app.js updateTransition()
|
||||||
|
// This file provides helper functions for transition effects
|
||||||
|
|
||||||
|
function startTransition(target) {
|
||||||
|
if (transitioning) return;
|
||||||
|
transitioning = true;
|
||||||
|
targetScreen = target;
|
||||||
|
transitionProgress = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screen fade effect
|
||||||
|
function getTransitionAlpha() {
|
||||||
|
if (!transitioning) return 0;
|
||||||
|
return 1 - transitionProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if transition is complete
|
||||||
|
function isTransitionComplete() {
|
||||||
|
return !transitioning && transitionProgress <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Room transition message
|
||||||
|
function onRoomTransition(roomName) {
|
||||||
|
gameState.message = roomName;
|
||||||
|
gameState.messageTimer = MESSAGE_DURATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { startTransition, getTransitionAlpha, isTransitionComplete };
|
||||||
|
}
|
||||||
287
src/generator/dungeon.js
Normal file
287
src/generator/dungeon.js
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Browser: use globals directly; Node: use require
|
||||||
|
const _C3 = typeof require !== 'undefined' ? require('../core/constants') : { COLS, ROWS, TILE_TYPES };
|
||||||
|
const RNG = typeof require !== 'undefined' ? require('../core/rng') : { createRNG };
|
||||||
|
const Puzzles = typeof require !== 'undefined' ? require('./puzzles') : { generatePuzzles };
|
||||||
|
const Placement = typeof require !== 'undefined' ? require('./placement') : { generatePlacement };
|
||||||
|
|
||||||
|
// Abstract grid for BSP layout — scales with room count
|
||||||
|
function getLayoutSize(targetCount) {
|
||||||
|
const area = Math.ceil(targetCount * 36 * 1.5);
|
||||||
|
const w = Math.ceil(Math.sqrt(area * 1.33));
|
||||||
|
const h = Math.ceil(area / w);
|
||||||
|
return { cols: Math.ceil(w / 4) * 4, rows: Math.ceil(h / 4) * 4 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateDungeon(dungeon) {
|
||||||
|
const rng = RNG.createRNG(dungeon.seed);
|
||||||
|
const targetCount = dungeon.roomCount;
|
||||||
|
|
||||||
|
const rooms = generateBSPLayout(targetCount, rng);
|
||||||
|
if (rooms.length === 0) return [];
|
||||||
|
|
||||||
|
connectRooms(rooms, rng);
|
||||||
|
ensureConnectivity(rooms, rng);
|
||||||
|
assignRoomTypes(rooms, dungeon, rng);
|
||||||
|
|
||||||
|
// Each room gets its own COLS x ROWS tile grid for rendering
|
||||||
|
for (const room of rooms) {
|
||||||
|
room.tiles = createRoomTileGrid(room, rng);
|
||||||
|
room.decorations = generateDecorations(room, rng);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate puzzles (modifies room tiles and puzzles arrays)
|
||||||
|
Puzzles.generatePuzzles(rooms, dungeon, rng);
|
||||||
|
|
||||||
|
// Place enemies, items, bosses (needs tile grid from above)
|
||||||
|
Placement.generatePlacement(rooms, dungeon, rng);
|
||||||
|
|
||||||
|
return rooms;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateBSPLayout(targetCount, rng) {
|
||||||
|
const layout = getLayoutSize(targetCount);
|
||||||
|
const maxDepth = Math.max(6, Math.ceil(Math.log2(targetCount)) + 2);
|
||||||
|
const root = createBSP(0, 0, layout.cols, layout.rows, 0, 0, targetCount, maxDepth, rng);
|
||||||
|
|
||||||
|
const leaves = [];
|
||||||
|
collectLeaves(root, leaves);
|
||||||
|
|
||||||
|
// Sort by y then x for consistent ordering
|
||||||
|
leaves.sort((a, b) => (a.cy - b.cy) * layout.cols + (a.cx - b.cx));
|
||||||
|
|
||||||
|
const rooms = [];
|
||||||
|
for (const leaf of leaves) {
|
||||||
|
rooms.push({
|
||||||
|
id: rooms.length,
|
||||||
|
x: leaf.cx,
|
||||||
|
y: leaf.cy,
|
||||||
|
width: leaf.width,
|
||||||
|
height: leaf.height,
|
||||||
|
type: 'normal',
|
||||||
|
connections: [],
|
||||||
|
enemies: [],
|
||||||
|
items: [],
|
||||||
|
puzzles: [],
|
||||||
|
decorations: [],
|
||||||
|
hasBoss: false,
|
||||||
|
hasExit: false,
|
||||||
|
hasEntrance: false,
|
||||||
|
tiles: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rooms;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBSP(x, y, width, height, depth, roomCount, targetCount, maxDepth, rng) {
|
||||||
|
const node = { x, y, width, height, depth, cx: x + Math.floor(width / 2), cy: y + Math.floor(height / 2), left: null, right: null };
|
||||||
|
|
||||||
|
if (roomCount >= targetCount) return node;
|
||||||
|
if (depth >= maxDepth) return node;
|
||||||
|
|
||||||
|
const minRegion = 5;
|
||||||
|
const minSplit = minRegion * 2;
|
||||||
|
|
||||||
|
const canH = height >= minSplit;
|
||||||
|
const canV = width >= minSplit;
|
||||||
|
if (!canH && !canV) return node;
|
||||||
|
|
||||||
|
let dir;
|
||||||
|
if (canH && canV) {
|
||||||
|
dir = width > height ? 'v' : (height > width ? 'h' : (rng() < 0.5 ? 'h' : 'v'));
|
||||||
|
} else {
|
||||||
|
dir = canH ? 'h' : 'v';
|
||||||
|
}
|
||||||
|
|
||||||
|
let splitPos;
|
||||||
|
if (dir === 'h') {
|
||||||
|
const lo = Math.max(minRegion, Math.ceil(height * 0.35));
|
||||||
|
const hi = Math.min(height - minRegion, Math.floor(height * 0.65));
|
||||||
|
if (lo > hi) return node;
|
||||||
|
splitPos = Math.floor(rng() * (hi - lo + 1)) + lo;
|
||||||
|
} else {
|
||||||
|
const lo = Math.max(minRegion, Math.ceil(width * 0.35));
|
||||||
|
const hi = Math.min(width - minRegion, Math.floor(width * 0.65));
|
||||||
|
if (lo > hi) return node;
|
||||||
|
splitPos = Math.floor(rng() * (hi - lo + 1)) + lo;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dir === 'h') {
|
||||||
|
node.left = createBSP(x, y, width, splitPos, depth + 1, roomCount, targetCount, maxDepth, rng);
|
||||||
|
const lc = countLeaves(node.left);
|
||||||
|
node.right = createBSP(x, y + splitPos, width, height - splitPos, depth + 1, roomCount + lc, targetCount, maxDepth, rng);
|
||||||
|
} else {
|
||||||
|
node.left = createBSP(x, y, splitPos, height, depth + 1, roomCount, targetCount, maxDepth, rng);
|
||||||
|
const lc = countLeaves(node.left);
|
||||||
|
node.right = createBSP(x + splitPos, y, width - splitPos, height, depth + 1, roomCount + lc, targetCount, maxDepth, rng);
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countLeaves(node) {
|
||||||
|
if (!node.left && !node.right) return 1;
|
||||||
|
let c = 0;
|
||||||
|
if (node.left) c += countLeaves(node.left);
|
||||||
|
if (node.right) c += countLeaves(node.right);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectLeaves(node, leaves) {
|
||||||
|
if (!node.left && !node.right) {
|
||||||
|
leaves.push({ cx: node.cx, cy: node.cy, width: node.width, height: node.height });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.left) collectLeaves(node.left, leaves);
|
||||||
|
if (node.right) collectLeaves(node.right, leaves);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect rooms using MST (Prim's algorithm)
|
||||||
|
function connectRooms(rooms, rng) {
|
||||||
|
if (rooms.length < 2) return;
|
||||||
|
|
||||||
|
const connected = new Set([0]);
|
||||||
|
const unconnected = new Set(rooms.slice(1).map(r => r.id));
|
||||||
|
|
||||||
|
while (unconnected.size > 0) {
|
||||||
|
let bestDist = Infinity, bestFrom = -1, bestTo = -1;
|
||||||
|
for (const fromId of connected) {
|
||||||
|
for (const toId of unconnected) {
|
||||||
|
const d = Math.abs(rooms[fromId].x - rooms[toId].x) + Math.abs(rooms[fromId].y - rooms[toId].y);
|
||||||
|
if (d < bestDist) { bestDist = d; bestFrom = fromId; bestTo = toId; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestTo < 0) break;
|
||||||
|
rooms[bestFrom].connections.push(bestTo);
|
||||||
|
rooms[bestTo].connections.push(bestFrom);
|
||||||
|
connected.add(bestTo);
|
||||||
|
unconnected.delete(bestTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra connections for non-linear paths
|
||||||
|
for (let i = 0; i < rooms.length; i++) {
|
||||||
|
for (let j = i + 1; j < rooms.length; j++) {
|
||||||
|
if (rooms[i].connections.includes(j) || rooms[j].connections.includes(i)) continue;
|
||||||
|
const d = Math.abs(rooms[i].x - rooms[j].x) + Math.abs(rooms[i].y - rooms[j].y);
|
||||||
|
if (d < 20 && rng() < 0.25) {
|
||||||
|
rooms[i].connections.push(j);
|
||||||
|
rooms[j].connections.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BFS from room 0, connect unreachable rooms to nearest connected room
|
||||||
|
function ensureConnectivity(rooms, rng) {
|
||||||
|
if (rooms.length < 2) return;
|
||||||
|
|
||||||
|
const visited = new Set([0]);
|
||||||
|
const queue = [0];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const rid = queue.shift();
|
||||||
|
for (const cid of rooms[rid].connections) {
|
||||||
|
if (!visited.has(cid)) { visited.add(cid); queue.push(cid); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
if (visited.has(room.id)) continue;
|
||||||
|
let nearest = null, nearestDist = Infinity;
|
||||||
|
for (const other of rooms) {
|
||||||
|
if (!visited.has(other.id)) continue;
|
||||||
|
const d = Math.abs(room.x - other.x) + Math.abs(room.y - other.y);
|
||||||
|
if (d < nearestDist) { nearestDist = d; nearest = other; }
|
||||||
|
}
|
||||||
|
if (nearest) {
|
||||||
|
room.connections.push(nearest.id);
|
||||||
|
nearest.connections.push(room.id);
|
||||||
|
visited.add(room.id);
|
||||||
|
const q2 = [room.id];
|
||||||
|
while (q2.length > 0) {
|
||||||
|
const rid = q2.shift();
|
||||||
|
for (const cid of rooms[rid].connections) {
|
||||||
|
if (!visited.has(cid)) { visited.add(cid); q2.push(cid); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignRoomTypes(rooms, dungeon, rng) {
|
||||||
|
if (rooms.length === 0) return;
|
||||||
|
|
||||||
|
rooms[0].type = 'entrance';
|
||||||
|
rooms[0].hasEntrance = true;
|
||||||
|
|
||||||
|
const last = rooms[rooms.length - 1];
|
||||||
|
last.type = 'boss';
|
||||||
|
last.hasBoss = true;
|
||||||
|
last.bossId = dungeon.id;
|
||||||
|
|
||||||
|
// Treasure (~20%)
|
||||||
|
const tc = Math.max(1, Math.floor(rooms.length * 0.2));
|
||||||
|
const tcands = rooms.filter((r, i) => i > 0 && i < rooms.length - 1 && r.type === 'normal');
|
||||||
|
shuffleRng(tcands, rng);
|
||||||
|
for (let i = 0; i < Math.min(tc, tcands.length); i++) tcands[i].type = 'treasure';
|
||||||
|
|
||||||
|
// Puzzle (~15%)
|
||||||
|
const pc = Math.max(1, Math.floor(rooms.length * 0.15));
|
||||||
|
const pcands = rooms.filter(r => r.type === 'normal');
|
||||||
|
shuffleRng(pcands, rng);
|
||||||
|
for (let i = 0; i < Math.min(pc, pcands.length); i++) pcands[i].type = 'puzzle';
|
||||||
|
|
||||||
|
// Trap (~10%)
|
||||||
|
const trapc = Math.max(0, Math.floor(rooms.length * 0.1));
|
||||||
|
const trapcands = rooms.filter(r => r.type === 'normal');
|
||||||
|
shuffleRng(trapcands, rng);
|
||||||
|
for (let i = 0; i < Math.min(trapc, trapcands.length); i++) trapcands[i].type = 'trap';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffleRng(arr, rng) {
|
||||||
|
for (let i = arr.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(rng() * (i + 1));
|
||||||
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRoomTileGrid(room, rng) {
|
||||||
|
const tiles = [];
|
||||||
|
for (let y = 0; y < _C3.ROWS; y++) {
|
||||||
|
tiles[y] = [];
|
||||||
|
for (let x = 0; x < _C3.COLS; x++) {
|
||||||
|
if (y === 0 || y === _C3.ROWS - 1 || x === 0 || x === _C3.COLS - 1) tiles[y][x] = _C3.TILE_TYPES.WALL;
|
||||||
|
else tiles[y][x] = _C3.TILE_TYPES.FLOOR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doors on walls based on connections
|
||||||
|
for (const connId of room.connections) {
|
||||||
|
const side = Math.floor(rng() * 4);
|
||||||
|
let dx, dy;
|
||||||
|
if (side === 0) { dx = Math.floor(rng() * (_C3.COLS - 2)) + 1; dy = 0; }
|
||||||
|
else if (side === 1) { dx = Math.floor(rng() * (_C3.COLS - 2)) + 1; dy = _C3.ROWS - 1; }
|
||||||
|
else if (side === 2) { dx = 0; dy = Math.floor(rng() * (_C3.ROWS - 2)) + 1; }
|
||||||
|
else { dx = _C3.COLS - 1; dy = Math.floor(rng() * (_C3.ROWS - 2)) + 1; }
|
||||||
|
if (tiles[dy][dx] === _C3.TILE_TYPES.WALL) tiles[dy][dx] = _C3.TILE_TYPES.DOOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (room.hasBoss) tiles[Math.floor(_C3.ROWS / 2)][_C3.COLS - 1] = _C3.TILE_TYPES.EXIT;
|
||||||
|
if (room.hasEntrance) tiles[Math.floor(_C3.ROWS / 2)][_C3.COLS - 1] = _C3.TILE_TYPES.DOOR;
|
||||||
|
|
||||||
|
return tiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateDecorations(room, rng) {
|
||||||
|
const decorations = [];
|
||||||
|
if (rng() < 0.7) decorations.push({ type: 'torch', x: 2, y: 2, lit: true });
|
||||||
|
if (rng() < 0.5) decorations.push({ type: 'torch', x: _C3.COLS - 3, y: 2, lit: true });
|
||||||
|
if (rng() < 0.5) decorations.push({ type: 'torch', x: 2, y: _C3.ROWS - 3, lit: true });
|
||||||
|
if (rng() < 0.7) decorations.push({ type: 'torch', x: _C3.COLS - 3, y: _C3.ROWS - 3, lit: true });
|
||||||
|
return decorations;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { generateDungeon };
|
||||||
|
}
|
||||||
16
src/generator/index.js
Normal file
16
src/generator/index.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Browser: use globals directly; Node: use require
|
||||||
|
const DungeonMod = typeof require !== 'undefined' ? require('./dungeon') : { generateDungeon };
|
||||||
|
const RoomsMod = typeof require !== 'undefined' ? require('./rooms') : { generateRoomLayout };
|
||||||
|
const PuzzlesMod = typeof require !== 'undefined' ? require('./puzzles') : { generatePuzzles };
|
||||||
|
const PlacementMod = typeof require !== 'undefined' ? require('./placement') : { generatePlacement };
|
||||||
|
|
||||||
|
const generateDungeon = DungeonMod.generateDungeon;
|
||||||
|
const generateRoomLayout = RoomsMod.generateRoomLayout;
|
||||||
|
const generatePuzzles = PuzzlesMod.generatePuzzles;
|
||||||
|
const generatePlacement = PlacementMod.generatePlacement;
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { generateDungeon, generateRoomLayout, generatePuzzles, generatePlacement };
|
||||||
|
}
|
||||||
320
src/generator/placement.js
Normal file
320
src/generator/placement.js
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Browser: use window globals; Node: use require
|
||||||
|
const _C2 = typeof require !== 'undefined' ? require('../core/constants') : { TILE_TYPES, ROWS, COLS, DIR_NAMES };
|
||||||
|
const _E = typeof require !== 'undefined' ? require('../data/enemies') : { ENEMY_TYPES, getEnemyType };
|
||||||
|
const _B = typeof require !== 'undefined' ? require('../data/bosses') : { BOSS_TYPES };
|
||||||
|
const _I = typeof require !== 'undefined' ? require('../data/items') : { ITEMS, getItem };
|
||||||
|
|
||||||
|
const _DIR_NAMES = _C2.DIR_NAMES;
|
||||||
|
|
||||||
|
// Enemy pools by difficulty tier (cumulative)
|
||||||
|
const ENEMY_TIERS = {
|
||||||
|
1: ['slime', 'keese', 'peahat', 'reedeath', 'bub', 'tektite'],
|
||||||
|
2: ['slime', 'keese', 'peahat', 'reedeath', 'bub', 'tektite'],
|
||||||
|
3: ['slime', 'keese', 'peahat', 'reedeath', 'bub', 'tektite', 'octorok', 'like', 'leever', 'skullwallma'],
|
||||||
|
4: ['slime', 'keese', 'peahat', 'reedeath', 'bub', 'tektite', 'octorok', 'like', 'leever', 'skullwallma'],
|
||||||
|
5: ['slime', 'keese', 'peahat', 'reedeath', 'bub', 'tektite', 'octorok', 'like', 'leever', 'skullwallma', 'moblin', 'stalfos', 'goriya', 'armos', 'manhandla'],
|
||||||
|
6: ['slime', 'keese', 'peahat', 'reedeath', 'bub', 'tektite', 'octorok', 'like', 'leever', 'skullwallma', 'moblin', 'stalfos', 'goriya', 'armos', 'manhandla'],
|
||||||
|
7: ['slime', 'keese', 'peahat', 'reedeath', 'bub', 'tektite', 'octorok', 'like', 'leever', 'skullwallma', 'moblin', 'stalfos', 'goriya', 'armos', 'manhandla', 'wizzrobe', 'ironknuckle', 'darknut'],
|
||||||
|
8: ['slime', 'keese', 'peahat', 'reedeath', 'bub', 'tektite', 'octorok', 'like', 'leever', 'skullwallma', 'moblin', 'stalfos', 'goriya', 'armos', 'manhandla', 'wizzrobe', 'ironknuckle', 'darknut'],
|
||||||
|
9: ['slime', 'keese', 'peahat', 'reedeath', 'bub', 'tektite', 'octorok', 'like', 'leever', 'skullwallma', 'moblin', 'stalfos', 'goriya', 'armos', 'manhandla', 'wizzrobe', 'ironknuckle', 'darknut', 'lynel'],
|
||||||
|
10: ['slime', 'keese', 'peahat', 'reedeath', 'bub', 'tektite', 'octorok', 'like', 'leever', 'skullwallma', 'moblin', 'stalfos', 'goriya', 'armos', 'manhandla', 'wizzrobe', 'ironknuckle', 'darknut', 'lynel'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Room type -> max enemy count
|
||||||
|
const ENEMY_COUNT_BY_TYPE = {
|
||||||
|
normal: 3,
|
||||||
|
treasure: 1,
|
||||||
|
puzzle: 1,
|
||||||
|
trap: 2,
|
||||||
|
boss: 2,
|
||||||
|
entrance: 1,
|
||||||
|
corridor: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function findFloorPosition(room, rng) {
|
||||||
|
const candidates = [];
|
||||||
|
for (let y = 1; y < _C2.ROWS - 1; y++) {
|
||||||
|
for (let x = 1; x < _C2.COLS - 1; x++) {
|
||||||
|
if (room.tiles[y][x] === _C2.TILE_TYPES.FLOOR) {
|
||||||
|
candidates.push({ x, y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
return candidates[Math.floor(rng() * candidates.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFloorPositionExcluding(room, rng, occupied) {
|
||||||
|
const candidates = [];
|
||||||
|
for (let y = 1; y < _C2.ROWS - 1; y++) {
|
||||||
|
for (let x = 1; x < _C2.COLS - 1; x++) {
|
||||||
|
if (room.tiles[y][x] === _C2.TILE_TYPES.FLOOR) {
|
||||||
|
const taken = occupied.some(p => p.x === x && p.y === y);
|
||||||
|
if (!taken) {
|
||||||
|
candidates.push({ x, y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
return candidates[Math.floor(rng() * candidates.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEnemyPool(difficulty) {
|
||||||
|
const tier = Math.max(1, Math.min(10, difficulty));
|
||||||
|
const pool = ENEMY_TIERS[tier];
|
||||||
|
if (!pool) return ENEMY_TIERS[1];
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRupeeType(difficulty, rng) {
|
||||||
|
const r = rng();
|
||||||
|
|
||||||
|
if (difficulty <= 2) {
|
||||||
|
return r < 0.85 ? 'rupee_green' : 'rupee_blue';
|
||||||
|
} else if (difficulty <= 4) {
|
||||||
|
if (r < 0.5) return 'rupee_green';
|
||||||
|
if (r < 0.9) return 'rupee_blue';
|
||||||
|
return 'rupee_red';
|
||||||
|
} else if (difficulty <= 6) {
|
||||||
|
if (r < 0.3) return 'rupee_green';
|
||||||
|
if (r < 0.65) return 'rupee_blue';
|
||||||
|
if (r < 0.95) return 'rupee_red';
|
||||||
|
return 'rupee_yellow';
|
||||||
|
} else {
|
||||||
|
if (r < 0.15) return 'rupee_green';
|
||||||
|
if (r < 0.4) return 'rupee_blue';
|
||||||
|
if (r < 0.75) return 'rupee_red';
|
||||||
|
return 'rupee_yellow';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRandomDirection(rng) {
|
||||||
|
return _DIR_NAMES[Math.floor(rng() * 4)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEnemyCountForRoom(roomType, difficulty, rng) {
|
||||||
|
const maxCount = ENEMY_COUNT_BY_TYPE[roomType] || 0;
|
||||||
|
if (maxCount === 0) return 0;
|
||||||
|
|
||||||
|
const chance = 0.6 + (difficulty / 10) * 0.3;
|
||||||
|
if (rng() < (1 - chance)) return 0;
|
||||||
|
|
||||||
|
return Math.floor(rng() * (maxCount + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEnemy(typeId, x, y, rng) {
|
||||||
|
const def = _E.getEnemyType(typeId);
|
||||||
|
if (!def) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: def.id,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
hp: def.hp,
|
||||||
|
maxHp: def.hp,
|
||||||
|
alive: true,
|
||||||
|
behavior: def.behavior,
|
||||||
|
state: 'idle',
|
||||||
|
direction: getRandomDirection(rng),
|
||||||
|
timer: 0,
|
||||||
|
damage: def.damage,
|
||||||
|
defense: def.defense,
|
||||||
|
speed: def.speed,
|
||||||
|
attackRange: def.attackRange,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createItem(typeId, x, y, count) {
|
||||||
|
return {
|
||||||
|
id: typeId,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
collected: false,
|
||||||
|
count: count || 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeEnemies(rooms, dungeon, rng) {
|
||||||
|
const difficulty = dungeon.difficulty || 1;
|
||||||
|
const pool = getEnemyPool(difficulty);
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
if (room.type === 'corridor') continue;
|
||||||
|
|
||||||
|
const count = getEnemyCountForRoom(room.type, difficulty, rng);
|
||||||
|
|
||||||
|
const occupied = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const pos = findFloorPositionExcluding(room, rng, occupied);
|
||||||
|
if (!pos) break;
|
||||||
|
|
||||||
|
const typeId = pool[Math.floor(rng() * pool.length)];
|
||||||
|
const enemy = createEnemy(typeId, pos.x, pos.y, rng);
|
||||||
|
if (enemy) {
|
||||||
|
room.enemies.push(enemy);
|
||||||
|
occupied.push({ x: pos.x, y: pos.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeItems(rooms, dungeon, rng) {
|
||||||
|
const difficulty = dungeon.difficulty || 1;
|
||||||
|
const hasBow = dungeon.playerHasBow || false;
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
if (room.type === 'corridor') continue;
|
||||||
|
|
||||||
|
const occupied = room.enemies.map(e => ({ x: e.x, y: e.y }));
|
||||||
|
|
||||||
|
// Rupees: 30% chance per normal room, 0-3 rupees
|
||||||
|
if (room.type === 'normal' || room.type === 'entrance') {
|
||||||
|
if (rng() < 0.3) {
|
||||||
|
const count = 1 + Math.floor(rng() * 3);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const pos = findFloorPositionExcluding(room, rng, occupied);
|
||||||
|
if (!pos) break;
|
||||||
|
const rupeeType = getRupeeType(difficulty, rng);
|
||||||
|
room.items.push(createItem(rupeeType, pos.x, pos.y, 1));
|
||||||
|
occupied.push({ x: pos.x, y: pos.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Healing potions: 15% chance per room
|
||||||
|
if (rng() < 0.15) {
|
||||||
|
const pos = findFloorPositionExcluding(room, rng, occupied);
|
||||||
|
if (pos) {
|
||||||
|
room.items.push(createItem('healing_potion', pos.x, pos.y, 1));
|
||||||
|
occupied.push({ x: pos.x, y: pos.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Super potions: 5% chance (difficulty 3+)
|
||||||
|
if (difficulty >= 3 && rng() < 0.05) {
|
||||||
|
const pos = findFloorPositionExcluding(room, rng, occupied);
|
||||||
|
if (pos) {
|
||||||
|
room.items.push(createItem('super_potion', pos.x, pos.y, 1));
|
||||||
|
occupied.push({ x: pos.x, y: pos.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Magic potions: 3% chance (difficulty 5+)
|
||||||
|
if (difficulty >= 5 && rng() < 0.03) {
|
||||||
|
const pos = findFloorPositionExcluding(room, rng, occupied);
|
||||||
|
if (pos) {
|
||||||
|
room.items.push(createItem('magic_potion', pos.x, pos.y, 1));
|
||||||
|
occupied.push({ x: pos.x, y: pos.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrows: 20% chance if player has bow
|
||||||
|
if (hasBow && rng() < 0.2) {
|
||||||
|
const pos = findFloorPositionExcluding(room, rng, occupied);
|
||||||
|
if (pos) {
|
||||||
|
const arrowCount = 3 + Math.floor(rng() * 5);
|
||||||
|
room.items.push(createItem('arrow', pos.x, pos.y, arrowCount));
|
||||||
|
occupied.push({ x: pos.x, y: pos.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bombs: 15% chance per room
|
||||||
|
if (rng() < 0.15) {
|
||||||
|
const pos = findFloorPositionExcluding(room, rng, occupied);
|
||||||
|
if (pos) {
|
||||||
|
const bombCount = 1 + Math.floor(rng() * 4);
|
||||||
|
room.items.push(createItem('bomb', pos.x, pos.y, bombCount));
|
||||||
|
occupied.push({ x: pos.x, y: pos.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small keys: placed in treasure rooms or random rooms
|
||||||
|
if (room.type === 'treasure') {
|
||||||
|
const pos = findFloorPositionExcluding(room, rng, occupied);
|
||||||
|
if (pos) {
|
||||||
|
room.items.push(createItem('small_key', pos.x, pos.y, 1));
|
||||||
|
occupied.push({ x: pos.x, y: pos.y });
|
||||||
|
}
|
||||||
|
} else if (rng() < 0.08) {
|
||||||
|
const pos = findFloorPositionExcluding(room, rng, occupied);
|
||||||
|
if (pos) {
|
||||||
|
room.items.push(createItem('small_key', pos.x, pos.y, 1));
|
||||||
|
occupied.push({ x: pos.x, y: pos.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heart pieces: 2% chance per room (rare)
|
||||||
|
if (rng() < 0.02) {
|
||||||
|
const pos = findFloorPositionExcluding(room, rng, occupied);
|
||||||
|
if (pos) {
|
||||||
|
room.items.push(createItem('heart_piece', pos.x, pos.y, 1));
|
||||||
|
occupied.push({ x: pos.x, y: pos.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeBoss(bossRoom, bossType, rng) {
|
||||||
|
const def = _B.BOSS_TYPES[bossType];
|
||||||
|
if (!def) return null;
|
||||||
|
|
||||||
|
const x = Math.floor(_C2.COLS / 2);
|
||||||
|
const y = Math.floor(_C2.ROWS / 2);
|
||||||
|
|
||||||
|
const boss = {
|
||||||
|
id: def.id,
|
||||||
|
name: def.name,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
hp: def.hp,
|
||||||
|
maxHp: def.hp,
|
||||||
|
damage: def.damage,
|
||||||
|
defense: def.defense,
|
||||||
|
behavior: def.behavior,
|
||||||
|
phases: def.phases ? def.phases.map(p => ({ ...p, attacks: p.attacks.map(a => ({ ...a })) })) : [],
|
||||||
|
alive: true,
|
||||||
|
drops: def.drops ? [...def.drops] : [],
|
||||||
|
currentState: {
|
||||||
|
phase: 0,
|
||||||
|
attackTimer: {},
|
||||||
|
stunned: false,
|
||||||
|
frozen: false,
|
||||||
|
},
|
||||||
|
direction: getRandomDirection(rng),
|
||||||
|
timer: 0,
|
||||||
|
state: 'idle',
|
||||||
|
};
|
||||||
|
|
||||||
|
return boss;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePlacement(rooms, dungeon, rng) {
|
||||||
|
placeEnemies(rooms, dungeon, rng);
|
||||||
|
placeItems(rooms, dungeon, rng);
|
||||||
|
|
||||||
|
// Place boss in the boss room
|
||||||
|
const bossRoom = rooms.find(r => r.type === 'boss');
|
||||||
|
if (bossRoom && dungeon.bossId !== undefined) {
|
||||||
|
const boss = placeBoss(bossRoom, dungeon.bossId, rng);
|
||||||
|
if (boss) {
|
||||||
|
bossRoom.boss = boss;
|
||||||
|
bossRoom.hasBoss = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = {
|
||||||
|
generatePlacement,
|
||||||
|
placeEnemies,
|
||||||
|
placeItems,
|
||||||
|
placeBoss,
|
||||||
|
findFloorPosition,
|
||||||
|
getEnemyPool,
|
||||||
|
getRupeeType,
|
||||||
|
};
|
||||||
|
}
|
||||||
420
src/generator/puzzles.js
Normal file
420
src/generator/puzzles.js
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Browser: use window globals; Node: use require
|
||||||
|
const _C = typeof require !== 'undefined' ? require('../core/constants') : { TILE_TYPES, ROWS, COLS };
|
||||||
|
|
||||||
|
function findFloorPosition(room, rng, exclude) {
|
||||||
|
const candidates = [];
|
||||||
|
for (let y = 1; y < _C.ROWS - 1; y++) {
|
||||||
|
for (let x = 1; x < _C.COLS - 1; x++) {
|
||||||
|
if (room.tiles[y][x] === _C.TILE_TYPES.FLOOR) {
|
||||||
|
if (!exclude || !exclude.some(p => p.x === x && p.y === y)) {
|
||||||
|
candidates.push({ x, y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
const idx = Math.floor(rng() * candidates.length);
|
||||||
|
return candidates[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
function findWallPosition(room, rng, edge, exclude) {
|
||||||
|
const candidates = [];
|
||||||
|
if (edge === 'bottom') {
|
||||||
|
for (let x = 1; x < _C.COLS - 1; x++) {
|
||||||
|
const y = _C.ROWS - 1;
|
||||||
|
if (room.tiles[y][x] === _C.TILE_TYPES.WALL) {
|
||||||
|
if (!exclude || !exclude.some(p => p.x === x && p.y === y)) {
|
||||||
|
candidates.push({ x, y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (edge === 'top') {
|
||||||
|
for (let x = 1; x < _C.COLS - 1; x++) {
|
||||||
|
const y = 0;
|
||||||
|
if (room.tiles[y][x] === _C.TILE_TYPES.WALL) {
|
||||||
|
if (!exclude || !exclude.some(p => p.x === x && p.y === y)) {
|
||||||
|
candidates.push({ x, y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (edge === 'left') {
|
||||||
|
for (let y = 1; y < _C.ROWS - 1; y++) {
|
||||||
|
const x = 0;
|
||||||
|
if (room.tiles[y][x] === _C.TILE_TYPES.WALL) {
|
||||||
|
if (!exclude || !exclude.some(p => p.x === x && p.y === y)) {
|
||||||
|
candidates.push({ x, y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (edge === 'right') {
|
||||||
|
for (let y = 1; y < _C.ROWS - 1; y++) {
|
||||||
|
const x = _C.COLS - 1;
|
||||||
|
if (room.tiles[y][x] === _C.TILE_TYPES.WALL) {
|
||||||
|
if (!exclude || !exclude.some(p => p.x === x && p.y === y)) {
|
||||||
|
candidates.push({ x, y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
const idx = Math.floor(rng() * candidates.length);
|
||||||
|
return candidates[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRockPushPuzzle(room, rng) {
|
||||||
|
const difficulty = room.difficulty || 1;
|
||||||
|
const rockCount = Math.min(3, difficulty);
|
||||||
|
const elements = [];
|
||||||
|
const occupied = [];
|
||||||
|
let solutions = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < rockCount; i++) {
|
||||||
|
let attempts = 0;
|
||||||
|
let rockPos, targetPos;
|
||||||
|
|
||||||
|
while (attempts < 50) {
|
||||||
|
rockPos = findFloorPosition(room, rng, occupied);
|
||||||
|
if (!rockPos) break;
|
||||||
|
|
||||||
|
targetPos = findFloorPosition(room, rng, [...occupied, rockPos]);
|
||||||
|
if (!targetPos) {
|
||||||
|
attempts++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = targetPos.x - rockPos.x;
|
||||||
|
const dy = targetPos.y - rockPos.y;
|
||||||
|
const pushDirX = -Math.sign(dx);
|
||||||
|
const pushDirY = -Math.sign(dy);
|
||||||
|
|
||||||
|
const behindX = rockPos.x + pushDirX;
|
||||||
|
const behindY = rockPos.y + pushDirY;
|
||||||
|
|
||||||
|
if (behindX < 1 || behindX >= _C.COLS - 1 ||
|
||||||
|
behindY < 1 || behindY >= _C.ROWS - 1 ||
|
||||||
|
occupied.some(p => p.x === behindX && p.y === behindY)) {
|
||||||
|
attempts++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (room.tiles[behindY][behindX] !== _C.TILE_TYPES.FLOOR) {
|
||||||
|
attempts++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
room.tiles[rockPos.y][rockPos.x] = _C.TILE_TYPES.ROCK;
|
||||||
|
room.tiles[targetPos.y][targetPos.x] = _C.TILE_TYPES.TARGET;
|
||||||
|
|
||||||
|
const rock = {
|
||||||
|
type: 'rock',
|
||||||
|
x: rockPos.x,
|
||||||
|
y: rockPos.y,
|
||||||
|
pushable: true,
|
||||||
|
requiredPosition: { x: targetPos.x, y: targetPos.y },
|
||||||
|
};
|
||||||
|
const target = {
|
||||||
|
type: 'target',
|
||||||
|
x: targetPos.x,
|
||||||
|
y: targetPos.y,
|
||||||
|
active: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
elements.push(rock);
|
||||||
|
elements.push(target);
|
||||||
|
|
||||||
|
occupied.push(rockPos);
|
||||||
|
occupied.push(targetPos);
|
||||||
|
occupied.push({ x: behindX, y: behindY });
|
||||||
|
|
||||||
|
solutions.push(`Push rock at (${rockPos.x}, ${rockPos.y}) to (${targetPos.x}, ${targetPos.y})`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'rock_push',
|
||||||
|
elements,
|
||||||
|
solution: solutions.join('. '),
|
||||||
|
solved: false,
|
||||||
|
reward: elements.length >= 4 ? 'heart_piece' : 'small_key',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateLeverPuzzle(room, rng) {
|
||||||
|
const elements = [];
|
||||||
|
const occupied = [];
|
||||||
|
|
||||||
|
const leverPos = findFloorPosition(room, rng, occupied);
|
||||||
|
if (!leverPos) return null;
|
||||||
|
|
||||||
|
occupied.push(leverPos);
|
||||||
|
room.tiles[leverPos.y][leverPos.x] = _C.TILE_TYPES.LEVER;
|
||||||
|
|
||||||
|
elements.push({
|
||||||
|
type: 'lever',
|
||||||
|
x: leverPos.x,
|
||||||
|
y: leverPos.y,
|
||||||
|
activated: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetPos = findFloorPosition(room, rng, occupied);
|
||||||
|
if (!targetPos) return null;
|
||||||
|
|
||||||
|
occupied.push(targetPos);
|
||||||
|
room.tiles[targetPos.y][targetPos.x] = _C.TILE_TYPES.TARGET;
|
||||||
|
|
||||||
|
const targetEffect = rng() < 0.6 ? 'softwall' : 'door';
|
||||||
|
|
||||||
|
elements.push({
|
||||||
|
type: 'target',
|
||||||
|
x: targetPos.x,
|
||||||
|
y: targetPos.y,
|
||||||
|
active: false,
|
||||||
|
effect: targetEffect,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (targetEffect === 'softwall') {
|
||||||
|
let softwallPos = null;
|
||||||
|
for (let attempts = 0; attempts < 30; attempts++) {
|
||||||
|
softwallPos = findFloorPosition(room, rng, occupied);
|
||||||
|
if (!softwallPos) break;
|
||||||
|
if (room.tiles[softwallPos.y][softwallPos.x] === _C.TILE_TYPES.FLOOR) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (softwallPos) {
|
||||||
|
room.tiles[softwallPos.y][softwallPos.x] = _C.TILE_TYPES.SOFTWALL;
|
||||||
|
occupied.push(softwallPos);
|
||||||
|
elements.push({
|
||||||
|
type: 'softwall',
|
||||||
|
x: softwallPos.x,
|
||||||
|
y: softwallPos.y,
|
||||||
|
breakable: true,
|
||||||
|
linkedToLever: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (targetEffect === 'door') {
|
||||||
|
const edges = ['top', 'bottom', 'left', 'right'];
|
||||||
|
const edge = edges[Math.floor(rng() * edges.length)];
|
||||||
|
const doorPos = findWallPosition(room, rng, edge, occupied);
|
||||||
|
|
||||||
|
if (doorPos) {
|
||||||
|
room.tiles[doorPos.y][doorPos.x] = _C.TILE_TYPES.DOOR;
|
||||||
|
occupied.push(doorPos);
|
||||||
|
elements.push({
|
||||||
|
type: 'door',
|
||||||
|
x: doorPos.x,
|
||||||
|
y: doorPos.y,
|
||||||
|
locked: true,
|
||||||
|
keyRequired: 'small_key',
|
||||||
|
linkedToLever: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'lever',
|
||||||
|
elements,
|
||||||
|
solution: `Activate lever at (${leverPos.x}, ${leverPos.y}) to ${targetEffect === 'softwall' ? 'remove softwall' : 'unlock door'}`,
|
||||||
|
solved: false,
|
||||||
|
reward: 'small_key',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateKeyDoorPuzzle(room, rng) {
|
||||||
|
const elements = [];
|
||||||
|
const occupied = [];
|
||||||
|
|
||||||
|
const edges = ['top', 'bottom', 'left', 'right'];
|
||||||
|
const edge = edges[Math.floor(rng() * edges.length)];
|
||||||
|
const doorPos = findWallPosition(room, rng, edge, occupied);
|
||||||
|
|
||||||
|
if (!doorPos) return null;
|
||||||
|
|
||||||
|
room.tiles[doorPos.y][doorPos.x] = _C.TILE_TYPES.DOOR;
|
||||||
|
occupied.push(doorPos);
|
||||||
|
|
||||||
|
elements.push({
|
||||||
|
type: 'door',
|
||||||
|
x: doorPos.x,
|
||||||
|
y: doorPos.y,
|
||||||
|
locked: true,
|
||||||
|
keyRequired: 'small_key',
|
||||||
|
});
|
||||||
|
|
||||||
|
const keyRoom = {
|
||||||
|
type: 'key',
|
||||||
|
x: -1,
|
||||||
|
y: -1,
|
||||||
|
keyType: 'small_key',
|
||||||
|
collected: false,
|
||||||
|
roomId: -1,
|
||||||
|
};
|
||||||
|
elements.push(keyRoom);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'key_door',
|
||||||
|
elements,
|
||||||
|
solution: `Find small key in another room, then unlock door at (${doorPos.x}, ${doorPos.y})`,
|
||||||
|
solved: false,
|
||||||
|
reward: 'chest_unlock',
|
||||||
|
keyRoomId: keyRoom.roomId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSwitchPuzzle(room, rng) {
|
||||||
|
const elements = [];
|
||||||
|
const occupied = [];
|
||||||
|
|
||||||
|
const switchPos = findFloorPosition(room, rng, occupied);
|
||||||
|
if (!switchPos) return null;
|
||||||
|
|
||||||
|
occupied.push(switchPos);
|
||||||
|
room.tiles[switchPos.y][switchPos.x] = _C.TILE_TYPES.SWITCH;
|
||||||
|
|
||||||
|
elements.push({
|
||||||
|
type: 'switch',
|
||||||
|
x: switchPos.x,
|
||||||
|
y: switchPos.y,
|
||||||
|
activated: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const puzzleVariant = rng() < 0.7 ? 'softwall' : 'door';
|
||||||
|
|
||||||
|
if (puzzleVariant === 'softwall') {
|
||||||
|
let softwallPos = null;
|
||||||
|
for (let attempts = 0; attempts < 30; attempts++) {
|
||||||
|
softwallPos = findFloorPosition(room, rng, occupied);
|
||||||
|
if (!softwallPos) break;
|
||||||
|
if (room.tiles[softwallPos.y][softwallPos.x] === _C.TILE_TYPES.FLOOR) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (softwallPos) {
|
||||||
|
room.tiles[softwallPos.y][softwallPos.x] = _C.TILE_TYPES.SOFTWALL;
|
||||||
|
occupied.push(softwallPos);
|
||||||
|
elements.push({
|
||||||
|
type: 'softwall',
|
||||||
|
x: softwallPos.x,
|
||||||
|
y: softwallPos.y,
|
||||||
|
breakable: true,
|
||||||
|
linkedToSwitch: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const edges = ['top', 'bottom', 'left', 'right'];
|
||||||
|
const edge = edges[Math.floor(rng() * edges.length)];
|
||||||
|
const doorPos = findWallPosition(room, rng, edge, occupied);
|
||||||
|
|
||||||
|
if (doorPos) {
|
||||||
|
room.tiles[doorPos.y][doorPos.x] = _C.TILE_TYPES.DOOR;
|
||||||
|
occupied.push(doorPos);
|
||||||
|
elements.push({
|
||||||
|
type: 'door',
|
||||||
|
x: doorPos.x,
|
||||||
|
y: doorPos.y,
|
||||||
|
locked: true,
|
||||||
|
keyRequired: null,
|
||||||
|
linkedToSwitch: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'switch',
|
||||||
|
elements,
|
||||||
|
solution: `Step on switch at (${switchPos.x}, ${switchPos.y}) to ${puzzleVariant === 'softwall' ? 'break softwall' : 'unlock door'}`,
|
||||||
|
solved: false,
|
||||||
|
reward: 'small_key',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePuzzles(rooms, dungeon, rng) {
|
||||||
|
const difficulty = dungeon.difficulty || 1;
|
||||||
|
const puzzleRooms = rooms.filter(r => r.type === 'puzzle');
|
||||||
|
|
||||||
|
for (const room of puzzleRooms) {
|
||||||
|
room.difficulty = difficulty;
|
||||||
|
|
||||||
|
const puzzleTypes = ['rock_push', 'lever', 'switch'];
|
||||||
|
if (room.width >= 6 && room.height >= 6) {
|
||||||
|
puzzleTypes.push('key_door');
|
||||||
|
}
|
||||||
|
|
||||||
|
const chosenType = puzzleTypes[Math.floor(rng() * puzzleTypes.length)];
|
||||||
|
|
||||||
|
let puzzle;
|
||||||
|
switch (chosenType) {
|
||||||
|
case 'rock_push':
|
||||||
|
puzzle = generateRockPushPuzzle(room, rng);
|
||||||
|
break;
|
||||||
|
case 'lever':
|
||||||
|
puzzle = generateLeverPuzzle(room, rng);
|
||||||
|
break;
|
||||||
|
case 'switch':
|
||||||
|
puzzle = generateSwitchPuzzle(room, rng);
|
||||||
|
break;
|
||||||
|
case 'key_door':
|
||||||
|
puzzle = generateKeyDoorPuzzle(room, rng);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
puzzle = generateSwitchPuzzle(room, rng);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (puzzle) {
|
||||||
|
room.puzzles.push(puzzle);
|
||||||
|
|
||||||
|
if (puzzle.type === 'key_door' && puzzle.keyRoomId !== undefined) {
|
||||||
|
const keyRoom = rooms.find(r => r.id === puzzle.keyRoomId);
|
||||||
|
if (keyRoom) {
|
||||||
|
const keyPos = findFloorPosition(keyRoom, rng, null);
|
||||||
|
if (keyPos) {
|
||||||
|
const keyElement = puzzle.elements.find(e => e.type === 'key');
|
||||||
|
if (keyElement) {
|
||||||
|
keyElement.x = keyPos.x;
|
||||||
|
keyElement.y = keyPos.y;
|
||||||
|
keyElement.roomId = keyRoom.id;
|
||||||
|
room.tiles[keyPos.y][keyPos.x] = _C.TILE_TYPES.ITEM;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const room of puzzleRooms) {
|
||||||
|
for (const puzzle of room.puzzles) {
|
||||||
|
if (puzzle.type === 'key_door' && puzzle.keyRoomId === -1) {
|
||||||
|
const otherRooms = rooms.filter(r => r.id !== room.id && r.type !== 'boss');
|
||||||
|
if (otherRooms.length > 0) {
|
||||||
|
const keyRoom = otherRooms[Math.floor(rng() * otherRooms.length)];
|
||||||
|
puzzle.keyRoomId = keyRoom.id;
|
||||||
|
const keyPos = findFloorPosition(keyRoom, rng, null);
|
||||||
|
if (keyPos) {
|
||||||
|
const keyElement = puzzle.elements.find(e => e.type === 'key');
|
||||||
|
if (keyElement) {
|
||||||
|
keyElement.x = keyPos.x;
|
||||||
|
keyElement.y = keyPos.y;
|
||||||
|
keyElement.roomId = keyRoom.id;
|
||||||
|
keyRoom.tiles[keyPos.y][keyPos.x] = _C.TILE_TYPES.ITEM;
|
||||||
|
keyRoom.items.push({
|
||||||
|
type: 'key',
|
||||||
|
keyType: 'small_key',
|
||||||
|
x: keyPos.x,
|
||||||
|
y: keyPos.y,
|
||||||
|
collected: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { generatePuzzles };
|
||||||
|
}
|
||||||
399
src/generator/rooms.js
Normal file
399
src/generator/rooms.js
Normal file
@ -0,0 +1,399 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// null = FLOOR (default), explicit values for special tiles
|
||||||
|
// Outer ring = WALL perimeter
|
||||||
|
|
||||||
|
const NORMAL_TEMPLATES = [
|
||||||
|
// Empty room
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
[1, null, null, null, 1],
|
||||||
|
[1, null, null, null, 1],
|
||||||
|
[1, null, null, null, 1],
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
// With rock
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
[1, null, null, null, 1],
|
||||||
|
[1, null, null, null, 1],
|
||||||
|
[1, null, null, 11, 1],
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
// With softwall
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
[1, null, null, null, 1],
|
||||||
|
[1, null, 2, null, 1],
|
||||||
|
[1, null, null, null, 1],
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
// Rock + torch
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
[1, 5, null, null, 1],
|
||||||
|
[1, null, null, null, 1],
|
||||||
|
[1, null, null, 11, 1],
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
const ENTRANCE_TEMPLATES = [
|
||||||
|
// EXIT at bottom
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
[1, null, null, null, 1],
|
||||||
|
[1, null, 6, null, 1],
|
||||||
|
[1, null, null, null, 1],
|
||||||
|
[1, 1, 14, 1, 1],
|
||||||
|
],
|
||||||
|
// EXIT at top
|
||||||
|
[
|
||||||
|
[1, 1, 14, 1, 1],
|
||||||
|
[1, null, null, null, 1],
|
||||||
|
[1, null, 6, null, 1],
|
||||||
|
[1, null, null, null, 1],
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
// EXIT at left
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
[14, null, null, null, 1],
|
||||||
|
[1, null, null, 6, 1],
|
||||||
|
[1, null, null, null, 1],
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
// EXIT at right
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
[1, null, null, null, 14],
|
||||||
|
[1, null, 6, null, 1],
|
||||||
|
[1, null, null, null, 1],
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
const BOSS_TEMPLATES = [
|
||||||
|
// Open arena, corner torches, EXIT bottom center
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1, 1, 1],
|
||||||
|
[1, 5, null, null, null, 5, 1],
|
||||||
|
[1, null, null, null, null, null, 1],
|
||||||
|
[1, null, null, null, null, null, 1],
|
||||||
|
[1, null, null, null, null, null, 1],
|
||||||
|
[1, 5, null, null, null, 5, 1],
|
||||||
|
[1, 1, 1, 14, 1, 1, 1],
|
||||||
|
],
|
||||||
|
// Arena with center softwall
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1, 1, 1],
|
||||||
|
[1, 5, null, null, null, 5, 1],
|
||||||
|
[1, null, null, null, null, null, 1],
|
||||||
|
[1, null, null, 2, null, null, 1],
|
||||||
|
[1, null, null, null, null, null, 1],
|
||||||
|
[1, 5, null, null, null, 5, 1],
|
||||||
|
[1, 1, 1, 14, 1, 1, 1],
|
||||||
|
],
|
||||||
|
// Arena with two softwalls
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1, 1, 1],
|
||||||
|
[1, 5, null, null, null, 5, 1],
|
||||||
|
[1, null, null, 2, null, null, 1],
|
||||||
|
[1, null, null, null, null, null, 1],
|
||||||
|
[1, null, null, 2, null, null, 1],
|
||||||
|
[1, 5, null, null, null, 5, 1],
|
||||||
|
[1, 1, 1, 14, 1, 1, 1],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
const TREASURE_TEMPLATES = [
|
||||||
|
// Chest in center, clean
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
[1, null, null, null, 1],
|
||||||
|
[1, null, 12, null, 1],
|
||||||
|
[1, null, null, null, 1],
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
// Chest with traps
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
[1, 9, null, 9, 1],
|
||||||
|
[1, null, 12, null, 1],
|
||||||
|
[1, null, null, null, 1],
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
// Chest with traps + rock
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
[1, 9, null, 9, 1],
|
||||||
|
[1, null, 12, null, 1],
|
||||||
|
[1, null, 11, null, 1],
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
// Chest surrounded by traps
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
[1, 9, 9, 9, 1],
|
||||||
|
[1, 9, 12, 9, 1],
|
||||||
|
[1, 9, 9, 9, 1],
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
const PUZZLE_TEMPLATES = [
|
||||||
|
// Lever + target
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1, 1],
|
||||||
|
[1, null, null, 7, null, 1],
|
||||||
|
[1, null, null, null, null, 1],
|
||||||
|
[1, null, null, null, null, 1],
|
||||||
|
[1, null, 10, null, null, 1],
|
||||||
|
[1, 1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
// Rock + target
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1, 1],
|
||||||
|
[1, null, null, null, 11, 1],
|
||||||
|
[1, null, null, null, null, 1],
|
||||||
|
[1, null, null, null, null, 1],
|
||||||
|
[1, null, 10, null, null, 1],
|
||||||
|
[1, 1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
// Switch + target
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1, 1],
|
||||||
|
[1, null, null, 8, null, 1],
|
||||||
|
[1, null, null, null, null, 1],
|
||||||
|
[1, null, null, null, null, 1],
|
||||||
|
[1, null, 10, null, null, 1],
|
||||||
|
[1, 1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
// Two levers + target
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1, 1],
|
||||||
|
[1, null, 7, null, null, 1],
|
||||||
|
[1, null, null, null, null, 1],
|
||||||
|
[1, null, null, null, null, 1],
|
||||||
|
[1, null, 10, null, 7, 1],
|
||||||
|
[1, 1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
const TRAP_TEMPLATES = [
|
||||||
|
// 2 traps
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
[1, null, 9, null, 1],
|
||||||
|
[1, null, null, null, 1],
|
||||||
|
[1, null, null, 9, 1],
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
// 3 traps
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
[1, 9, null, null, 1],
|
||||||
|
[1, null, 9, null, 1],
|
||||||
|
[1, null, null, 9, 1],
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
// 4 traps
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
[1, 9, null, null, 1],
|
||||||
|
[1, null, null, null, 1],
|
||||||
|
[1, null, null, 9, 1],
|
||||||
|
[1, 1, 9, 9, 1],
|
||||||
|
],
|
||||||
|
// Lava traps
|
||||||
|
[
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
[1, null, 3, null, 1],
|
||||||
|
[1, null, null, null, 1],
|
||||||
|
[1, null, 3, null, 1],
|
||||||
|
[1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
function getRoomLayoutTemplate(roomType, rng) {
|
||||||
|
let templates;
|
||||||
|
|
||||||
|
switch (roomType) {
|
||||||
|
case 'entrance':
|
||||||
|
templates = ENTRANCE_TEMPLATES;
|
||||||
|
break;
|
||||||
|
case 'boss':
|
||||||
|
templates = BOSS_TEMPLATES;
|
||||||
|
break;
|
||||||
|
case 'treasure':
|
||||||
|
templates = TREASURE_TEMPLATES;
|
||||||
|
break;
|
||||||
|
case 'puzzle':
|
||||||
|
templates = PUZZLE_TEMPLATES;
|
||||||
|
break;
|
||||||
|
case 'trap':
|
||||||
|
templates = TRAP_TEMPLATES;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
templates = NORMAL_TEMPLATES;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = Math.floor(rng() * templates.length);
|
||||||
|
return templates[idx].map(row => [...row]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRoomLayout(room, theme, rng) {
|
||||||
|
const template = getRoomLayoutTemplate(room.type, rng);
|
||||||
|
const tW = template[0].length;
|
||||||
|
const tH = template.length;
|
||||||
|
const rW = room.width;
|
||||||
|
const rH = room.height;
|
||||||
|
|
||||||
|
// Initialize room grid: walls on perimeter, floor inside
|
||||||
|
const grid = [];
|
||||||
|
for (let y = 0; y < rH; y++) {
|
||||||
|
grid[y] = [];
|
||||||
|
for (let x = 0; x < rW; x++) {
|
||||||
|
if (y === 0 || y === rH - 1 || x === 0 || x === rW - 1) {
|
||||||
|
grid[y][x] = TILE_TYPES.WALL;
|
||||||
|
} else {
|
||||||
|
grid[y][x] = TILE_TYPES.FLOOR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map template tiles to room grid
|
||||||
|
for (let ty = 0; ty < tH; ty++) {
|
||||||
|
for (let tx = 0; tx < tW; tx++) {
|
||||||
|
const tile = template[ty][tx];
|
||||||
|
if (tile === null) continue;
|
||||||
|
|
||||||
|
let rx, ry;
|
||||||
|
|
||||||
|
// Interior tile
|
||||||
|
if (ty > 0 && ty < tH - 1 && tx > 0 && tx < tW - 1) {
|
||||||
|
rx = Math.round((tx - 1) * (rW - 2) / (tW - 2)) + 1;
|
||||||
|
ry = Math.round((ty - 1) * (rH - 2) / (tH - 2)) + 1;
|
||||||
|
}
|
||||||
|
// Top edge
|
||||||
|
else if (ty === 0 && tx > 0 && tx < tW - 1) {
|
||||||
|
rx = Math.round((tx / (tW - 1)) * (rW - 1));
|
||||||
|
ry = 0;
|
||||||
|
}
|
||||||
|
// Bottom edge
|
||||||
|
else if (ty === tH - 1 && tx > 0 && tx < tW - 1) {
|
||||||
|
rx = Math.round((tx / (tW - 1)) * (rW - 1));
|
||||||
|
ry = rH - 1;
|
||||||
|
}
|
||||||
|
// Left edge
|
||||||
|
else if (tx === 0 && ty > 0 && ty < tH - 1) {
|
||||||
|
rx = 0;
|
||||||
|
ry = Math.round((ty / (tH - 1)) * (rH - 1));
|
||||||
|
}
|
||||||
|
// Right edge
|
||||||
|
else if (tx === tW - 1 && ty > 0 && ty < tH - 1) {
|
||||||
|
rx = rW - 1;
|
||||||
|
ry = Math.round((ty / (tH - 1)) * (rH - 1));
|
||||||
|
}
|
||||||
|
// Corners (wall overlaps, skip unless special)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rx >= 0 && rx < rW && ry >= 0 && ry < rH) {
|
||||||
|
grid[ry][rx] = tile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add corner torches based on RNG
|
||||||
|
if (rng() < 0.6) {
|
||||||
|
grid[1][1] = TILE_TYPES.TORCH;
|
||||||
|
}
|
||||||
|
if (rng() < 0.4) {
|
||||||
|
grid[1][rW - 2] = TILE_TYPES.TORCH;
|
||||||
|
}
|
||||||
|
if (rng() < 0.4) {
|
||||||
|
grid[rH - 2][1] = TILE_TYPES.TORCH;
|
||||||
|
}
|
||||||
|
if (rng() < 0.6) {
|
||||||
|
grid[rH - 2][rW - 2] = TILE_TYPES.TORCH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add theme-appropriate floor decorations
|
||||||
|
if (theme && theme.floorHazards) {
|
||||||
|
for (const hazard of theme.floorHazards) {
|
||||||
|
if (rng() < hazard.chance) {
|
||||||
|
const hx = 1 + Math.floor(rng() * (rW - 2));
|
||||||
|
const hy = 1 + Math.floor(rng() * (rH - 2));
|
||||||
|
if (grid[hy][hx] === TILE_TYPES.FLOOR) {
|
||||||
|
grid[hy][hx] = hazard.tileType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
room.tiles = grid;
|
||||||
|
applyThemeToRoom(room, theme);
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyThemeToRoom(room, theme) {
|
||||||
|
if (!theme) return;
|
||||||
|
|
||||||
|
room.theme = theme;
|
||||||
|
room.floorColor = theme.floorColor;
|
||||||
|
room.floorDarkColor = theme.floorDarkColor;
|
||||||
|
room.wallColor = theme.wallColor;
|
||||||
|
room.wallTopColor = theme.wallTopColor;
|
||||||
|
room.wallFrontColor = theme.wallFrontColor;
|
||||||
|
room.wallBottomColor = theme.wallBottomColor;
|
||||||
|
room.wallBorderColor = theme.wallBorderColor;
|
||||||
|
|
||||||
|
for (const deco of room.decorations) {
|
||||||
|
switch (deco.type) {
|
||||||
|
case 'torch':
|
||||||
|
deco.glowColor = theme.torchGlow;
|
||||||
|
deco.stickColor = theme.torchStickColor;
|
||||||
|
deco.flameColor = theme.torchFlameColor;
|
||||||
|
deco.coreColor = theme.torchCoreColor;
|
||||||
|
break;
|
||||||
|
case 'rock':
|
||||||
|
deco.color = theme.rockColor || '#888888';
|
||||||
|
deco.darkColor = theme.rockDarkColor || '#666666';
|
||||||
|
deco.lightColor = theme.rockLightColor || '#AAAAAA';
|
||||||
|
break;
|
||||||
|
case 'chest':
|
||||||
|
deco.closedColor = theme.chestClosedColor || '#8B4513';
|
||||||
|
deco.goldColor = theme.chestGoldColor || '#DAA520';
|
||||||
|
deco.openColor = theme.chestOpenColor || '#A0522D';
|
||||||
|
break;
|
||||||
|
case 'lever':
|
||||||
|
deco.color = theme.leverColor || '#B8860B';
|
||||||
|
break;
|
||||||
|
case 'switch':
|
||||||
|
deco.color = theme.switchFloorColor || '#DAA520';
|
||||||
|
break;
|
||||||
|
case 'target':
|
||||||
|
deco.color = theme.targetColor || '#FFD700';
|
||||||
|
deco.glowColor = theme.targetGlow || 'rgba(255,215,0,0.3)';
|
||||||
|
break;
|
||||||
|
case 'trap':
|
||||||
|
deco.color = theme.trapColor || '#8B008B';
|
||||||
|
break;
|
||||||
|
case 'lava':
|
||||||
|
deco.color = theme.lavaColor || '#FF4500';
|
||||||
|
deco.lightColor = theme.lavaLightColor || '#FF6347';
|
||||||
|
break;
|
||||||
|
case 'water':
|
||||||
|
deco.color = theme.waterColor || '#4169AA';
|
||||||
|
deco.lightColor = theme.waterLightColor || '#5F9EA0';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { generateRoomLayout, getRoomLayoutTemplate, applyThemeToRoom };
|
||||||
|
}
|
||||||
29
src/input/actions.js
Normal file
29
src/input/actions.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === ACTION DEFINITIONS ===
|
||||||
|
// Maps action names to key bindings. Actual key detection is in keyboard.js.
|
||||||
|
|
||||||
|
const ACTIONS = {
|
||||||
|
move: { keys: ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'], type: 'hold' },
|
||||||
|
attack: { keys: ['Space', 'KeyZ'], type: 'press' },
|
||||||
|
interact: { keys: ['KeyE', 'Enter'], type: 'press' },
|
||||||
|
menu: { keys: ['KeyI'], type: 'press' },
|
||||||
|
back: { keys: ['Escape', 'KeyQ'], type: 'press' },
|
||||||
|
switchWeapon: { keys: ['KeyX'], type: 'press' },
|
||||||
|
useItem: { keys: ['KeyR'], type: 'press' },
|
||||||
|
bomb: { keys: ['KeyB'], type: 'press' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Menu navigation actions
|
||||||
|
const MENU_ACTIONS = {
|
||||||
|
up: { keys: ['ArrowUp', 'KeyW'] },
|
||||||
|
down: { keys: ['ArrowDown', 'KeyS'] },
|
||||||
|
left: { keys: ['ArrowLeft', 'KeyA'] },
|
||||||
|
right: { keys: ['ArrowRight', 'KeyD'] },
|
||||||
|
select: { keys: ['Enter', 'Space'] },
|
||||||
|
back: { keys: ['Escape', 'KeyQ'] },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { ACTIONS, MENU_ACTIONS, initActions: () => {}, getActionState: () => {}, getAction: () => {}, getActiveActions: () => {} };
|
||||||
|
}
|
||||||
83
src/input/keyboard.js
Normal file
83
src/input/keyboard.js
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === KEYBOARD INPUT SYSTEM ===
|
||||||
|
|
||||||
|
const inputSystem = {
|
||||||
|
keys: {},
|
||||||
|
justPressed: {},
|
||||||
|
actions: {},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'Enter', 'ShiftLeft', 'ShiftRight', 'KeyE', 'KeyI', 'KeyQ', 'Tab'].includes(e.code)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
if (!this.keys[e.code]) {
|
||||||
|
this.justPressed[e.code] = true;
|
||||||
|
}
|
||||||
|
this.keys[e.code] = true;
|
||||||
|
});
|
||||||
|
document.addEventListener('keyup', (e) => {
|
||||||
|
this.keys[e.code] = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearKeys() {
|
||||||
|
this.keys = {};
|
||||||
|
this.justPressed = {};
|
||||||
|
},
|
||||||
|
|
||||||
|
update(dt) {
|
||||||
|
// Process actions
|
||||||
|
this.actions = {
|
||||||
|
move: this.getMove(),
|
||||||
|
attack: this.justPressed['Space'] || this.justPressed['KeyZ'],
|
||||||
|
interact: this.justPressed['KeyE'] || this.justPressed['Enter'],
|
||||||
|
menu: this.justPressed['KeyI'],
|
||||||
|
back: this.justPressed['Escape'] || this.justPressed['KeyQ'],
|
||||||
|
switchWeapon: this.justPressed['KeyX'],
|
||||||
|
useItem: this.justPressed['KeyR'],
|
||||||
|
bomb: this.justPressed['KeyB'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear justPressed for next frame
|
||||||
|
this.justPressed = {};
|
||||||
|
},
|
||||||
|
|
||||||
|
getMove() {
|
||||||
|
let dx = 0, dy = 0;
|
||||||
|
if (this.keys['ArrowUp'] || this.keys['KeyW']) dy = -1;
|
||||||
|
else if (this.keys['ArrowDown'] || this.keys['KeyS']) dy = 1;
|
||||||
|
if (this.keys['ArrowLeft'] || this.keys['KeyA']) dx = -1;
|
||||||
|
else if (this.keys['ArrowRight'] || this.keys['KeyD']) dx = 1;
|
||||||
|
if (dx !== 0 || dy !== 0) return { dx, dy };
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAction(name) {
|
||||||
|
return this.actions[name] || false;
|
||||||
|
},
|
||||||
|
|
||||||
|
isKeyDown(code) {
|
||||||
|
return !!this.keys[code];
|
||||||
|
},
|
||||||
|
|
||||||
|
wasKeyPressed(code) {
|
||||||
|
return !!this.justPressed[code];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function initKeyboard() {
|
||||||
|
inputSystem.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { inputSystem, initKeyboard, isKeyDown: (code) => inputSystem.isKeyDown(code),
|
||||||
|
isKeyJustPressed: (code) => inputSystem.wasKeyPressed(code),
|
||||||
|
setKeyHandlers: () => {},
|
||||||
|
resetKeys: () => inputSystem.clearKeys(),
|
||||||
|
getMove: () => inputSystem.getMove(),
|
||||||
|
getAction: (name) => inputSystem.getAction(name),
|
||||||
|
clearKeys: () => inputSystem.clearKeys(),
|
||||||
|
update: (dt) => inputSystem.update(dt) };
|
||||||
|
}
|
||||||
195
src/menu/dungeon-select.js
Normal file
195
src/menu/dungeon-select.js
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === DUNGEON SELECT SCREEN ===
|
||||||
|
|
||||||
|
function renderDungeonSelect(container, save, onSelected) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.style.background = 'linear-gradient(180deg, #0a0a1a 0%, #1a1a2d 50%, #0a0a1a 100%)';
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'menu-title';
|
||||||
|
title.textContent = 'SELECT DUNGEON';
|
||||||
|
container.appendChild(title);
|
||||||
|
|
||||||
|
// Filter tabs
|
||||||
|
const filterContainer = document.createElement('div');
|
||||||
|
filterContainer.className = 'filter-tabs';
|
||||||
|
filterContainer.style.pointerEvents = 'auto';
|
||||||
|
|
||||||
|
const themeNames = ['All', ...Object.keys(THEMES)];
|
||||||
|
let activeFilter = 'all';
|
||||||
|
|
||||||
|
for (const theme of themeNames) {
|
||||||
|
const tab = document.createElement('button');
|
||||||
|
tab.className = 'filter-tab' + (theme === 'All' ? ' active' : '');
|
||||||
|
tab.textContent = theme === 'All' ? 'All' : theme;
|
||||||
|
tab.dataset.theme = theme === 'All' ? 'all' : theme.toLowerCase();
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
filterContainer.querySelectorAll('.filter-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
activeFilter = tab.dataset.theme;
|
||||||
|
renderGrid(activeFilter);
|
||||||
|
});
|
||||||
|
filterContainer.appendChild(tab);
|
||||||
|
}
|
||||||
|
container.appendChild(filterContainer);
|
||||||
|
|
||||||
|
// Dungeon grid
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'dungeon-grid';
|
||||||
|
grid.id = 'dungeon-grid';
|
||||||
|
|
||||||
|
function renderGrid(filter) {
|
||||||
|
grid.innerHTML = '';
|
||||||
|
const filtered = filter === 'all'
|
||||||
|
? DUNGEONS.map((d, i) => ({ ...d, idx: i }))
|
||||||
|
: DUNGEONS.map((d, i) => ({ ...d, idx: i })).filter(d => d.theme === filter);
|
||||||
|
|
||||||
|
for (const dungeon of filtered) {
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
cell.className = 'dungeon-cell';
|
||||||
|
const themeData = THEMES[dungeon.theme];
|
||||||
|
cell.style.background = themeData ? themeData.cellColor || '#333' : '#333';
|
||||||
|
cell.style.color = themeData ? themeData.cellTextColor || '#FFF' : '#FFF';
|
||||||
|
cell.textContent = dungeon.idx + 1;
|
||||||
|
cell.dataset.idx = dungeon.idx;
|
||||||
|
|
||||||
|
const unlocked = isDungeonUnlocked(dungeon.idx);
|
||||||
|
const completed = isDungeonCompleted(dungeon.idx);
|
||||||
|
|
||||||
|
if (!unlocked) cell.classList.add('locked');
|
||||||
|
if (completed) cell.classList.add('completed');
|
||||||
|
|
||||||
|
if (unlocked) {
|
||||||
|
cell.addEventListener('click', () => {
|
||||||
|
grid.querySelectorAll('.dungeon-cell').forEach(c => c.classList.remove('selected'));
|
||||||
|
cell.classList.add('selected');
|
||||||
|
showInfoPanel(dungeon);
|
||||||
|
});
|
||||||
|
cell.addEventListener('dblclick', () => {
|
||||||
|
if (unlocked) onSelected(dungeon.idx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.appendChild(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderGrid('all');
|
||||||
|
container.appendChild(grid);
|
||||||
|
|
||||||
|
// Info panel
|
||||||
|
const infoPanel = document.createElement('div');
|
||||||
|
infoPanel.className = 'info-panel';
|
||||||
|
infoPanel.id = 'info-panel';
|
||||||
|
infoPanel.style.display = 'none';
|
||||||
|
container.appendChild(infoPanel);
|
||||||
|
|
||||||
|
function showInfoPanel(dungeon) {
|
||||||
|
infoPanel.style.display = 'block';
|
||||||
|
const themeData = THEMES[dungeon.theme] || {};
|
||||||
|
infoPanel.innerHTML = `
|
||||||
|
<div class="name">${dungeon.name}</div>
|
||||||
|
<div class="theme">Theme: ${dungeon.theme}</div>
|
||||||
|
<div class="difficulty">Difficulty: ${'★'.repeat(dungeon.difficulty)}${'☆'.repeat(10 - dungeon.difficulty)}</div>
|
||||||
|
<div class="rooms">Rooms: ${dungeon.roomCount}</div>
|
||||||
|
${dungeon.requiredWeapon ? `<div style="color:#FF6347">Requires: ${dungeon.requiredWeapon}</div>` : ''}
|
||||||
|
<div style="margin-top:4px; color:#AAA">${dungeon.description || ''}</div>
|
||||||
|
<div style="margin-top:6px; color:#888">Double-click to enter</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter button
|
||||||
|
const enterBtn = document.createElement('button');
|
||||||
|
enterBtn.className = 'menu-button';
|
||||||
|
enterBtn.textContent = 'Enter Dungeon';
|
||||||
|
enterBtn.id = 'enter-btn';
|
||||||
|
enterBtn.style.marginTop = '10px';
|
||||||
|
enterBtn.style.display = 'none';
|
||||||
|
enterBtn.addEventListener('click', () => {
|
||||||
|
const selected = grid.querySelector('.dungeon-cell.selected');
|
||||||
|
if (selected && !selected.classList.contains('locked')) {
|
||||||
|
onSelected(parseInt(selected.dataset.idx));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
container.appendChild(enterBtn);
|
||||||
|
|
||||||
|
// Back button
|
||||||
|
const backBtn = document.createElement('button');
|
||||||
|
backBtn.className = 'menu-button';
|
||||||
|
backBtn.textContent = 'Back';
|
||||||
|
backBtn.style.marginTop = '6px';
|
||||||
|
backBtn.addEventListener('click', () => { switchScreen(SCREEN_TITLE); });
|
||||||
|
container.appendChild(backBtn);
|
||||||
|
|
||||||
|
// Listen for cell selection to show enter button
|
||||||
|
const origRenderGrid = renderGrid;
|
||||||
|
grid.addEventListener('click', () => {
|
||||||
|
const selected = grid.querySelector('.dungeon-cell.selected');
|
||||||
|
enterBtn.style.display = selected && !selected.classList.contains('locked') ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
let selectedCell = null;
|
||||||
|
container.addEventListener('keydown', (e) => {
|
||||||
|
const cells = [...grid.querySelectorAll('.dungeon-cell:not(.locked)')];
|
||||||
|
if (!cells.length) return;
|
||||||
|
|
||||||
|
if (!selectedCell) {
|
||||||
|
selectedCell = cells[0];
|
||||||
|
selectedCell.classList.add('selected');
|
||||||
|
const idx = parseInt(selectedCell.dataset.idx);
|
||||||
|
showInfoPanel(DUNGEONS[idx]);
|
||||||
|
enterBtn.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = cells.indexOf(selectedCell);
|
||||||
|
|
||||||
|
if (e.code === 'ArrowRight' || e.code === 'KeyD') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedCell.classList.remove('selected');
|
||||||
|
selectedCell = cells[Math.min(idx + 1, cells.length - 1)];
|
||||||
|
selectedCell.classList.add('selected');
|
||||||
|
const i = parseInt(selectedCell.dataset.idx);
|
||||||
|
showInfoPanel(DUNGEONS[i]);
|
||||||
|
}
|
||||||
|
if (e.code === 'ArrowLeft' || e.code === 'KeyA') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedCell.classList.remove('selected');
|
||||||
|
selectedCell = cells[Math.max(idx - 1, 0)];
|
||||||
|
selectedCell.classList.add('selected');
|
||||||
|
const i = parseInt(selectedCell.dataset.idx);
|
||||||
|
showInfoPanel(DUNGEONS[i]);
|
||||||
|
}
|
||||||
|
if (e.code === 'ArrowDown' || e.code === 'KeyS') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedCell.classList.remove('selected');
|
||||||
|
selectedCell = cells[Math.min(idx + 10, cells.length - 1)];
|
||||||
|
selectedCell.classList.add('selected');
|
||||||
|
const i = parseInt(selectedCell.dataset.idx);
|
||||||
|
showInfoPanel(DUNGEONS[i]);
|
||||||
|
}
|
||||||
|
if (e.code === 'ArrowUp' || e.code === 'KeyW') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedCell.classList.remove('selected');
|
||||||
|
selectedCell = cells[Math.max(idx - 10, 0)];
|
||||||
|
selectedCell.classList.add('selected');
|
||||||
|
const i = parseInt(selectedCell.dataset.idx);
|
||||||
|
showInfoPanel(DUNGEONS[i]);
|
||||||
|
}
|
||||||
|
if (e.code === 'Enter' || e.code === 'Space') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedCell && !selectedCell.classList.contains('locked')) {
|
||||||
|
onSelected(parseInt(selectedCell.dataset.idx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.code === 'Escape' || e.code === 'KeyQ') {
|
||||||
|
e.preventDefault();
|
||||||
|
switchScreen(SCREEN_TITLE);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { renderDungeonSelect };
|
||||||
|
}
|
||||||
75
src/menu/hud-overlay.js
Normal file
75
src/menu/hud-overlay.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === INVENTORY / HUD OVERLAY ===
|
||||||
|
|
||||||
|
function renderInventoryOverlay(container, state) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.style.background = 'rgba(0,0,0,0.85)';
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'menu-title';
|
||||||
|
title.textContent = 'INVENTORY';
|
||||||
|
title.style.fontSize = '24px';
|
||||||
|
container.appendChild(title);
|
||||||
|
|
||||||
|
// Weapons
|
||||||
|
const weaponsDiv = document.createElement('div');
|
||||||
|
weaponsDiv.style.cssText = 'font-family: monospace; font-size: 12px; color: #CCC; margin: 8px 0; max-width: 400px;';
|
||||||
|
weaponsDiv.innerHTML = '<div style="color:#FFD700; margin-bottom:4px;">WEAPONS</div>';
|
||||||
|
if (state.player.weapons.length === 0) {
|
||||||
|
weaponsDiv.innerHTML += '<div style="color:#666;">No weapons</div>';
|
||||||
|
} else {
|
||||||
|
for (const w of state.player.weapons) {
|
||||||
|
const isActive = w === state.player.activeWeapon;
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.style.cssText = `color: ${isActive ? '#FFD700' : '#AAA'}; padding: 2px 0;`;
|
||||||
|
line.textContent = `${isActive ? '> ' : ' '}${w.name} (DMG:${w.damage})`;
|
||||||
|
weaponsDiv.appendChild(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
container.appendChild(weaponsDiv);
|
||||||
|
|
||||||
|
// Items
|
||||||
|
const itemsDiv = document.createElement('div');
|
||||||
|
itemsDiv.style.cssText = 'font-family: monospace; font-size: 12px; color: #CCC; margin: 8px 0; max-width: 400px;';
|
||||||
|
itemsDiv.innerHTML = '<div style="color:#FFD700; margin-bottom:4px;">ITEMS</div>';
|
||||||
|
if (state.player.items.length === 0) {
|
||||||
|
itemsDiv.innerHTML += '<div style="color:#666;">No items</div>';
|
||||||
|
} else {
|
||||||
|
for (const item of state.player.items) {
|
||||||
|
const def = ITEMS.find(i => i.id === item.id);
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.style.cssText = 'color: #90EE90; padding: 2px 0;';
|
||||||
|
line.textContent = `${def ? def.name : item.id}${(item.count || 1) > 1 ? ` x${item.count}` : ''}`;
|
||||||
|
itemsDiv.appendChild(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
container.appendChild(itemsDiv);
|
||||||
|
|
||||||
|
// Resources
|
||||||
|
const resourcesDiv = document.createElement('div');
|
||||||
|
resourcesDiv.style.cssText = 'font-family: monospace; font-size: 12px; color: #CCC; margin: 8px 0; max-width: 400px;';
|
||||||
|
resourcesDiv.innerHTML = `
|
||||||
|
<div style="color:#FFD700; margin-bottom:4px;">RESOURCES</div>
|
||||||
|
<div>Arrows: ${state.player.arrows}</div>
|
||||||
|
<div>Bombs: ${state.player.bombs}</div>
|
||||||
|
<div>Keys: ${state.player.keys}</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(resourcesDiv);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const statsDiv = document.createElement('div');
|
||||||
|
statsDiv.style.cssText = 'font-family: monospace; font-size: 11px; color: #888; margin-top: 10px; max-width: 400px; text-align: center;';
|
||||||
|
statsDiv.textContent = `Dungeons: ${state.dungeonsCompleted} Rooms: ${state.roomsCleared} Enemies: ${state.enemiesDefeated}`;
|
||||||
|
container.appendChild(statsDiv);
|
||||||
|
|
||||||
|
// Controls hint
|
||||||
|
const hint = document.createElement('div');
|
||||||
|
hint.style.cssText = 'font-family: monospace; font-size: 11px; color: #666; margin-top: 10px; text-align: center;';
|
||||||
|
hint.textContent = 'X: Switch Weapon R: Use Potion I/Escape: Close';
|
||||||
|
container.appendChild(hint);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { renderInventoryOverlay };
|
||||||
|
}
|
||||||
244
src/menu/screens.js
Normal file
244
src/menu/screens.js
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === DEATH / WIN / COMPLETION SCREENS ===
|
||||||
|
|
||||||
|
function renderDeathScreen(container, state) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.style.background = 'rgba(20,0,0,0.9)';
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'menu-title';
|
||||||
|
title.textContent = 'YOU DIED';
|
||||||
|
title.style.color = '#FF0000';
|
||||||
|
container.appendChild(title);
|
||||||
|
|
||||||
|
const dungeon = DUNGEONS[state.currentDungeon];
|
||||||
|
if (dungeon) {
|
||||||
|
const subtitle = document.createElement('div');
|
||||||
|
subtitle.className = 'menu-subtitle';
|
||||||
|
subtitle.textContent = `${dungeon.name} - Room ${state.currentRoomIdx + 1}`;
|
||||||
|
container.appendChild(subtitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = document.createElement('div');
|
||||||
|
stats.style.cssText = 'font-family: monospace; font-size: 12px; color: #888; margin: 15px 0; text-align: center; line-height: 1.8;';
|
||||||
|
stats.innerHTML = `
|
||||||
|
Rooms Cleared: ${state.roomsCleared}<br>
|
||||||
|
Enemies Defeated: ${state.enemiesDefeated}<br>
|
||||||
|
Dungeons Completed: ${state.dungeonsCompleted}
|
||||||
|
`;
|
||||||
|
container.appendChild(stats);
|
||||||
|
|
||||||
|
// Retry button
|
||||||
|
const retryBtn = createMenuButton('Try Again', 0);
|
||||||
|
retryBtn.addEventListener('click', () => {
|
||||||
|
state.roomsCleared = 0;
|
||||||
|
state.enemiesDefeated = 0;
|
||||||
|
resetPlayer();
|
||||||
|
state.player.invincibleTimer = 1000;
|
||||||
|
// Restore saved weapons
|
||||||
|
for (const wName of saveData.weapons) {
|
||||||
|
const wDef = WEAPONS.find(w => w.name === wName);
|
||||||
|
if (wDef && !state.player.weapons.find(w => w.name === wName)) {
|
||||||
|
state.player.weapons.push({ ...wDef });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.player.weapons.length > 0 && !state.player.activeWeapon) {
|
||||||
|
state.player.activeWeapon = state.player.weapons[0];
|
||||||
|
}
|
||||||
|
state.currentRoomIdx = 0;
|
||||||
|
state.dungeonRooms = generateDungeon(DUNGEONS[state.currentDungeon]);
|
||||||
|
loadDungeonRoom(state.currentDungeon, 0);
|
||||||
|
switchScreen(SCREEN_PLAYING);
|
||||||
|
});
|
||||||
|
container.appendChild(retryBtn);
|
||||||
|
|
||||||
|
// Dungeon select button
|
||||||
|
const selectBtn = createMenuButton('Dungeon Select', 1);
|
||||||
|
selectBtn.addEventListener('click', () => { switchScreen(SCREEN_DUNGEON_SELECT); });
|
||||||
|
container.appendChild(selectBtn);
|
||||||
|
|
||||||
|
// Title button
|
||||||
|
const titleBtn = createMenuButton('Title Screen', 2);
|
||||||
|
titleBtn.addEventListener('click', () => { switchScreen(SCREEN_TITLE); });
|
||||||
|
container.appendChild(titleBtn);
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
const buttons = [retryBtn, selectBtn, titleBtn];
|
||||||
|
let selectedIndex = 0;
|
||||||
|
buttons[0].classList.add('selected');
|
||||||
|
|
||||||
|
container.addEventListener('keydown', (e) => {
|
||||||
|
if (e.code === 'ArrowDown' || e.code === 'KeyS') {
|
||||||
|
e.preventDefault();
|
||||||
|
buttons[selectedIndex].classList.remove('selected');
|
||||||
|
selectedIndex = (selectedIndex + 1) % buttons.length;
|
||||||
|
buttons[selectedIndex].classList.add('selected');
|
||||||
|
}
|
||||||
|
if (e.code === 'ArrowUp' || e.code === 'KeyW') {
|
||||||
|
e.preventDefault();
|
||||||
|
buttons[selectedIndex].classList.remove('selected');
|
||||||
|
selectedIndex = (selectedIndex - 1 + buttons.length) % buttons.length;
|
||||||
|
buttons[selectedIndex].classList.add('selected');
|
||||||
|
}
|
||||||
|
if (e.code === 'Enter' || e.code === 'Space') {
|
||||||
|
e.preventDefault();
|
||||||
|
buttons[selectedIndex].click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDungeonCompleteScreen(container, state) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.style.background = 'rgba(0,10,0,0.9)';
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'menu-title';
|
||||||
|
title.textContent = 'DUNGEON CLEARED!';
|
||||||
|
title.style.color = '#00FF00';
|
||||||
|
container.appendChild(title);
|
||||||
|
|
||||||
|
const dungeon = DUNGEONS[state.currentDungeon];
|
||||||
|
if (dungeon) {
|
||||||
|
const subtitle = document.createElement('div');
|
||||||
|
subtitle.className = 'menu-subtitle';
|
||||||
|
subtitle.textContent = `${dungeon.name} conquered!`;
|
||||||
|
container.appendChild(subtitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = document.createElement('div');
|
||||||
|
stats.style.cssText = 'font-family: monospace; font-size: 12px; color: #AAA; margin: 15px 0; text-align: center; line-height: 1.8;';
|
||||||
|
stats.innerHTML = `
|
||||||
|
Rooms Cleared: ${state.roomsCleared}<br>
|
||||||
|
Enemies Defeated: ${state.enemiesDefeated}<br>
|
||||||
|
HP Remaining: ${state.player.hp}/${state.player.maxHp}
|
||||||
|
`;
|
||||||
|
container.appendChild(stats);
|
||||||
|
|
||||||
|
const nextIdx = state.currentDungeon + 1;
|
||||||
|
const hasNext = nextIdx < DUNGEONS.length;
|
||||||
|
|
||||||
|
if (hasNext) {
|
||||||
|
const nextDungeon = DUNGEONS[nextIdx];
|
||||||
|
const nextInfo = document.createElement('div');
|
||||||
|
nextInfo.style.cssText = 'font-family: monospace; font-size: 12px; color: #FFD700; margin: 8px 0; text-align: center;';
|
||||||
|
nextInfo.textContent = `Next: ${nextDungeon.name} (Difficulty: ${'★'.repeat(nextDungeon.difficulty)})`;
|
||||||
|
container.appendChild(nextInfo);
|
||||||
|
|
||||||
|
const nextBtn = createMenuButton('Next Dungeon', 0);
|
||||||
|
nextBtn.addEventListener('click', () => {
|
||||||
|
state.currentDungeon = nextIdx;
|
||||||
|
state.currentRoomIdx = 0;
|
||||||
|
state.roomsCleared = 0;
|
||||||
|
state.dungeonRooms = generateDungeon(DUNGEONS[nextIdx]);
|
||||||
|
resetPlayer();
|
||||||
|
switchScreen(SCREEN_PLAYING);
|
||||||
|
});
|
||||||
|
container.appendChild(nextBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectBtn = createMenuButton('Dungeon Select', hasNext ? 1 : 0);
|
||||||
|
selectBtn.addEventListener('click', () => { switchScreen(SCREEN_DUNGEON_SELECT); });
|
||||||
|
container.appendChild(selectBtn);
|
||||||
|
|
||||||
|
const titleBtn = createMenuButton('Title Screen', hasNext ? 2 : 1);
|
||||||
|
titleBtn.addEventListener('click', () => { switchScreen(SCREEN_TITLE); });
|
||||||
|
container.appendChild(titleBtn);
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
const buttons = container.querySelectorAll('.menu-button');
|
||||||
|
let selectedIndex = 0;
|
||||||
|
if (buttons.length) buttons[0].classList.add('selected');
|
||||||
|
|
||||||
|
container.addEventListener('keydown', (e) => {
|
||||||
|
if (e.code === 'ArrowDown' || e.code === 'KeyS') {
|
||||||
|
e.preventDefault();
|
||||||
|
buttons[selectedIndex].classList.remove('selected');
|
||||||
|
selectedIndex = (selectedIndex + 1) % buttons.length;
|
||||||
|
buttons[selectedIndex].classList.add('selected');
|
||||||
|
}
|
||||||
|
if (e.code === 'ArrowUp' || e.code === 'KeyW') {
|
||||||
|
e.preventDefault();
|
||||||
|
buttons[selectedIndex].classList.remove('selected');
|
||||||
|
selectedIndex = (selectedIndex - 1 + buttons.length) % buttons.length;
|
||||||
|
buttons[selectedIndex].classList.add('selected');
|
||||||
|
}
|
||||||
|
if (e.code === 'Enter' || e.code === 'Space') {
|
||||||
|
e.preventDefault();
|
||||||
|
buttons[selectedIndex].click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGameCompleteScreen(container, state) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.style.background = 'linear-gradient(180deg, #0a0a00 0%, #1a1a00 50%, #0a0a00 100%)';
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'menu-title';
|
||||||
|
title.textContent = 'ALL DUNGEONS CLEARED!';
|
||||||
|
title.style.color = '#FFD700';
|
||||||
|
title.style.fontSize = '24px';
|
||||||
|
container.appendChild(title);
|
||||||
|
|
||||||
|
const subtitle = document.createElement('div');
|
||||||
|
subtitle.className = 'menu-subtitle';
|
||||||
|
subtitle.textContent = 'You are the true Hero of Dungeons!';
|
||||||
|
container.appendChild(subtitle);
|
||||||
|
|
||||||
|
const sword = document.createElement('div');
|
||||||
|
sword.style.cssText = 'margin: 10px 0 20px; font-size: 36px; color: #FFD700;';
|
||||||
|
sword.textContent = '\u2694 \u2694 \u2694';
|
||||||
|
container.appendChild(sword);
|
||||||
|
|
||||||
|
const stats = document.createElement('div');
|
||||||
|
stats.style.cssText = 'font-family: monospace; font-size: 13px; color: #DDD; margin: 10px 0; text-align: center; line-height: 2;';
|
||||||
|
stats.innerHTML = `
|
||||||
|
Dungeons Conquered: 100<br>
|
||||||
|
Total Rooms Cleared: ${saveData.totalRoomsCleared}<br>
|
||||||
|
Total Enemies Slain: ${saveData.totalEnemiesDefeated}<br>
|
||||||
|
Weapons Found: ${saveData.weapons.length}/${WEAPONS.length}<br>
|
||||||
|
Heart Pieces: ${saveData.heartPieces}
|
||||||
|
`;
|
||||||
|
container.appendChild(stats);
|
||||||
|
|
||||||
|
const trophy = document.createElement('div');
|
||||||
|
trophy.style.cssText = 'font-size: 48px; margin: 15px 0;';
|
||||||
|
trophy.textContent = '\uD83C\uDFC6';
|
||||||
|
container.appendChild(trophy);
|
||||||
|
|
||||||
|
const titleBtn = createMenuButton('Title Screen', 0);
|
||||||
|
titleBtn.addEventListener('click', () => { switchScreen(SCREEN_TITLE); });
|
||||||
|
container.appendChild(titleBtn);
|
||||||
|
|
||||||
|
const selectBtn = createMenuButton('Dungeon Select', 1);
|
||||||
|
selectBtn.addEventListener('click', () => { switchScreen(SCREEN_DUNGEON_SELECT); });
|
||||||
|
container.appendChild(selectBtn);
|
||||||
|
|
||||||
|
const buttons = [titleBtn, selectBtn];
|
||||||
|
let selectedIndex = 0;
|
||||||
|
buttons[0].classList.add('selected');
|
||||||
|
|
||||||
|
container.addEventListener('keydown', (e) => {
|
||||||
|
if (e.code === 'ArrowDown' || e.code === 'KeyS') {
|
||||||
|
e.preventDefault();
|
||||||
|
buttons[selectedIndex].classList.remove('selected');
|
||||||
|
selectedIndex = (selectedIndex + 1) % buttons.length;
|
||||||
|
buttons[selectedIndex].classList.add('selected');
|
||||||
|
}
|
||||||
|
if (e.code === 'ArrowUp' || e.code === 'KeyW') {
|
||||||
|
e.preventDefault();
|
||||||
|
buttons[selectedIndex].classList.remove('selected');
|
||||||
|
selectedIndex = (selectedIndex - 1 + buttons.length) % buttons.length;
|
||||||
|
buttons[selectedIndex].classList.add('selected');
|
||||||
|
}
|
||||||
|
if (e.code === 'Enter' || e.code === 'Space') {
|
||||||
|
e.preventDefault();
|
||||||
|
buttons[selectedIndex].click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { renderDeathScreen, renderDungeonCompleteScreen, renderGameCompleteScreen };
|
||||||
|
}
|
||||||
131
src/menu/title-screen.js
Normal file
131
src/menu/title-screen.js
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === TITLE SCREEN ===
|
||||||
|
|
||||||
|
function renderTitleScreen(container) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.style.background = 'linear-gradient(180deg, #1a0a00 0%, #2d1810 50%, #1a0a00 100%)';
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'menu-title';
|
||||||
|
title.textContent = 'ZELDA DUNGEON QUEST';
|
||||||
|
title.style.fontSize = '28px';
|
||||||
|
container.appendChild(title);
|
||||||
|
|
||||||
|
const subtitle = document.createElement('div');
|
||||||
|
subtitle.className = 'menu-subtitle';
|
||||||
|
subtitle.textContent = '100 Dungeons \u00B7 22 Enemies \u00B7 12 Weapons';
|
||||||
|
container.appendChild(subtitle);
|
||||||
|
|
||||||
|
// Decorative sword
|
||||||
|
const swordDiv = document.createElement('div');
|
||||||
|
swordDiv.style.cssText = 'margin: 10px 0 20px; font-size: 24px; color: #C0C0C0;';
|
||||||
|
swordDiv.textContent = '\u2694';
|
||||||
|
container.appendChild(swordDiv);
|
||||||
|
|
||||||
|
// Menu buttons
|
||||||
|
const buttonsContainer = document.createElement('div');
|
||||||
|
buttonsContainer.style.cssText = 'display: flex; flex-direction: column; align-items: center; gap: 6px;';
|
||||||
|
|
||||||
|
const startBtn = createMenuButton('Start Adventure', 0);
|
||||||
|
startBtn.addEventListener('click', () => { switchScreen(SCREEN_DUNGEON_SELECT); });
|
||||||
|
buttonsContainer.appendChild(startBtn);
|
||||||
|
|
||||||
|
const controlsBtn = createMenuButton('Controls', 1);
|
||||||
|
const controlsDiv = document.createElement('div');
|
||||||
|
controlsDiv.style.cssText = 'display: none; font-family: monospace; font-size: 11px; color: #AAA; background: rgba(0,0,0,0.7); border: 1px solid #555; padding: 10px 14px; margin: 8px 0; max-width: 360px; text-align: left; line-height: 1.6;';
|
||||||
|
controlsDiv.innerHTML = `
|
||||||
|
<span style="color:#FFD700">Movement:</span> Arrow Keys / WASD<br>
|
||||||
|
<span style="color:#FFD700">Attack:</span> Space / Z<br>
|
||||||
|
<span style="color:#FFD700">Interact:</span> E / Enter<br>
|
||||||
|
<span style="color:#FFD700">Switch Weapon:</span> X<br>
|
||||||
|
<span style="color:#FFD700">Use Potion:</span> R<br>
|
||||||
|
<span style="color:#FFD700">Place Bomb:</span> B<br>
|
||||||
|
<span style="color:#FFD700">Menu/Inventory:</span> I<br>
|
||||||
|
<span style="color:#FFD700">Back:</span> Escape / Q<br>
|
||||||
|
<span style="color:#FFD700">Debug:</span> F1 (overlay) F2 (skip) F3 (god) F4 (spawn)
|
||||||
|
`;
|
||||||
|
controlsBtn.addEventListener('click', () => {
|
||||||
|
controlsDiv.style.display = controlsDiv.style.display === 'none' ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
buttonsContainer.appendChild(controlsBtn);
|
||||||
|
buttonsContainer.appendChild(controlsDiv);
|
||||||
|
|
||||||
|
container.appendChild(buttonsContainer);
|
||||||
|
|
||||||
|
// Save info
|
||||||
|
const summary = getSaveSummary();
|
||||||
|
const lastDungeon = saveData.completedDungeons.length > 0
|
||||||
|
? saveData.completedDungeons[saveData.completedDungeons.length - 1] + 1
|
||||||
|
: 0;
|
||||||
|
if (summary.dungeonsCompleted > 0) {
|
||||||
|
const saveInfo = document.createElement('div');
|
||||||
|
saveInfo.className = 'menu-subtitle';
|
||||||
|
saveInfo.style.marginTop = '15px';
|
||||||
|
saveInfo.style.fontSize = '12px';
|
||||||
|
saveInfo.textContent = `Progress: ${summary.dungeonsCompleted}/100 dungeons \u00B7 ${summary.enemiesDefeated} enemies slain`;
|
||||||
|
container.appendChild(saveInfo);
|
||||||
|
|
||||||
|
const resumeBtn = createMenuButton('Resume', 2);
|
||||||
|
if (lastDungeon < DUNGEONS.length) {
|
||||||
|
resumeBtn.addEventListener('click', () => {
|
||||||
|
gameState.currentDungeon = lastDungeon;
|
||||||
|
gameState.currentRoomIdx = 0;
|
||||||
|
gameState.dungeonRooms = generateDungeon(DUNGEONS[lastDungeon]);
|
||||||
|
resetPlayer();
|
||||||
|
// Restore saved weapons
|
||||||
|
for (const wName of saveData.weapons) {
|
||||||
|
const wDef = WEAPONS.find(w => w.name === wName);
|
||||||
|
if (wDef && !gameState.player.weapons.find(w => w.name === wName)) {
|
||||||
|
gameState.player.weapons.push({ ...wDef });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (gameState.player.weapons.length > 0 && !gameState.player.activeWeapon) {
|
||||||
|
gameState.player.activeWeapon = gameState.player.weapons[0];
|
||||||
|
}
|
||||||
|
switchScreen(SCREEN_PLAYING);
|
||||||
|
});
|
||||||
|
container.appendChild(resumeBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard navigation for title screen
|
||||||
|
let selectedIndex = 0;
|
||||||
|
const allButtons = [startBtn];
|
||||||
|
if (summary.dungeonsCompleted > 0 && lastDungeon < DUNGEONS.length) allButtons.unshift(
|
||||||
|
document.querySelector('.menu-button[data-index="2"]')
|
||||||
|
);
|
||||||
|
|
||||||
|
container.addEventListener('keydown', (e) => {
|
||||||
|
if (e.code === 'ArrowDown' || e.code === 'KeyS') {
|
||||||
|
e.preventDefault();
|
||||||
|
allButtons[selectedIndex].classList.remove('selected');
|
||||||
|
selectedIndex = (selectedIndex + 1) % allButtons.length;
|
||||||
|
allButtons[selectedIndex].classList.add('selected');
|
||||||
|
}
|
||||||
|
if (e.code === 'ArrowUp' || e.code === 'KeyW') {
|
||||||
|
e.preventDefault();
|
||||||
|
allButtons[selectedIndex].classList.remove('selected');
|
||||||
|
selectedIndex = (selectedIndex - 1 + allButtons.length) % allButtons.length;
|
||||||
|
allButtons[selectedIndex].classList.add('selected');
|
||||||
|
}
|
||||||
|
if (e.code === 'Enter' || e.code === 'Space') {
|
||||||
|
e.preventDefault();
|
||||||
|
allButtons[selectedIndex].click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
allButtons[0].classList.add('selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMenuButton(text, index) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'menu-button';
|
||||||
|
btn.textContent = text;
|
||||||
|
btn.dataset.index = index;
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { renderTitleScreen, createMenuButton };
|
||||||
|
}
|
||||||
261
src/rendering/draw-bosses.js
Normal file
261
src/rendering/draw-bosses.js
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === BOSS DRAWING ===
|
||||||
|
|
||||||
|
function renderBoss(boss) {
|
||||||
|
if (!boss) return;
|
||||||
|
const px = boss.x * TILE;
|
||||||
|
const py = boss.y * TILE;
|
||||||
|
const isHit = boss.hitTimer > 0;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
|
||||||
|
if (isHit) {
|
||||||
|
ctx.globalAlpha = 0.5 + Math.sin(Date.now() * 0.02) * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boss is drawn larger (2x2 tiles)
|
||||||
|
const def = BOSS_TYPES.find(b => b.id === boss.id || b.name === boss.name);
|
||||||
|
if (def && def.drawFunction && typeof window[def.drawFunction] === 'function') {
|
||||||
|
window[def.drawFunction](ctx, px, py, boss, isHit);
|
||||||
|
} else {
|
||||||
|
drawGenericBoss(ctx, px, py, boss, isHit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boss HP bar (wider, at top of screen)
|
||||||
|
const barW = 200;
|
||||||
|
const barH = 8;
|
||||||
|
const barX = (CANVAS_W - barW) / 2;
|
||||||
|
const barY = 4;
|
||||||
|
const hpRatio = boss.hp / boss.maxHp;
|
||||||
|
|
||||||
|
ctx.fillStyle = '#333333';
|
||||||
|
ctx.fillRect(barX, barY, barW, barH);
|
||||||
|
ctx.fillStyle = hpRatio > 0.5 ? '#00FF00' : hpRatio > 0.25 ? '#FFAA00' : '#FF0000';
|
||||||
|
ctx.fillRect(barX, barY, barW * hpRatio, barH);
|
||||||
|
ctx.strokeStyle = '#555';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(barX, barY, barW, barH);
|
||||||
|
|
||||||
|
// Boss name
|
||||||
|
ctx.fillStyle = '#FFD700';
|
||||||
|
ctx.font = 'bold 10px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(boss.name, CANVAS_W / 2, barY + barH + 10);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
|
||||||
|
// Phase indicator
|
||||||
|
if (boss.phases && boss.phase > 0) {
|
||||||
|
ctx.fillStyle = '#FF4444';
|
||||||
|
ctx.font = '8px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(`Phase ${boss.phase + 1}`, CANVAS_W / 2, barY + barH + 20);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGenericBoss(ctx, px, py, boss, isHit) {
|
||||||
|
const size = TILE * 2;
|
||||||
|
const color = isHit ? '#FFFFFF' : (boss.color || '#8B0000');
|
||||||
|
|
||||||
|
// Large body
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(px - TILE / 2, py - TILE / 2, size, size, 6);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Details
|
||||||
|
ctx.fillStyle = boss.colorLight || '#CC2222';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(px - TILE / 2 + 4, py - TILE / 2 + 4, size - 8, size * 0.4, 4);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Eyes
|
||||||
|
ctx.fillStyle = boss.eyeColor || '#FFD700';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px - 6, py - 4, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + 6, py - 4, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px - 6, py - 4, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + 6, py - 4, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specific boss draw functions
|
||||||
|
|
||||||
|
function drawGleeok(ctx, px, py, boss, isHit) {
|
||||||
|
const size = TILE * 2;
|
||||||
|
const color = isHit ? '#FFFFFF' : (boss.color || '#2F4F4F');
|
||||||
|
const heads = boss.heads || 3;
|
||||||
|
const aliveHeads = boss.aliveHeads || heads;
|
||||||
|
|
||||||
|
// Body
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(px, py, size * 0.45, size * 0.35, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Scales pattern
|
||||||
|
ctx.fillStyle = boss.colorLight || '#3F5F5F';
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const angle = (i / 6) * Math.PI * 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(px + Math.cos(angle) * 12, py + Math.sin(angle) * 8, 4, 3, angle, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heads
|
||||||
|
for (let i = 0; i < heads; i++) {
|
||||||
|
const angle = (i / heads) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
const hx = px + Math.cos(angle) * 24;
|
||||||
|
const hy = py + Math.sin(angle) * 18 - 8;
|
||||||
|
const alive = i < aliveHeads;
|
||||||
|
|
||||||
|
if (!alive) {
|
||||||
|
ctx.fillStyle = '#333';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(hx, hy, 5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headBob = Math.sin(Date.now() * 0.005 + i) * 2;
|
||||||
|
|
||||||
|
ctx.fillStyle = boss.headColor || '#006400';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(hx, hy + headBob, 6, 5, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Eyes
|
||||||
|
ctx.fillStyle = '#FFD700';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(hx - 2, hy + headBob, 1.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(hx + 2, hy + headBob, 1.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Fire breath
|
||||||
|
if (boss.attacking && i < aliveHeads) {
|
||||||
|
ctx.fillStyle = '#FF4500';
|
||||||
|
const dirX = Math.cos(angle);
|
||||||
|
const dirY = Math.sin(angle);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(hx + dirX * 6, hy + headBob + dirY * 6);
|
||||||
|
ctx.lineTo(hx + dirX * 14, hy + headBob + dirY * 14);
|
||||||
|
ctx.lineTo(hx + dirX * 6 + dirY * 3, hy + headBob + dirY * 6 - dirX * 3);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawDarknut(ctx, px, py, boss, isHit) {
|
||||||
|
const size = TILE * 2;
|
||||||
|
const color = isHit ? '#FFFFFF' : (boss.color || '#708090');
|
||||||
|
const armorBroken = boss.armorBroken || 0;
|
||||||
|
|
||||||
|
// Armor body
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillRect(px - 10, py - 12, 20, 28);
|
||||||
|
|
||||||
|
// Helmet
|
||||||
|
ctx.fillStyle = isHit ? '#FFFFFF' : '#606060';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py - 8, 8, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Visor
|
||||||
|
ctx.fillStyle = boss.eyeColor || '#FF0000';
|
||||||
|
ctx.fillRect(px - 5, py - 10, 10, 3);
|
||||||
|
|
||||||
|
// Armor plates (break with phases)
|
||||||
|
ctx.fillStyle = isHit ? '#FFFFFF' : '#808080';
|
||||||
|
if (armorBroken < 1) {
|
||||||
|
ctx.fillRect(px - 12, py - 4, 24, 6); // Chest plate
|
||||||
|
}
|
||||||
|
if (armorBroken < 2) {
|
||||||
|
ctx.fillRect(px - 12, py + 8, 24, 6); // Waist plate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weapon (two-handed)
|
||||||
|
ctx.fillStyle = '#C0C0C0';
|
||||||
|
ctx.fillRect(px + 12, py - 16, 3, 32);
|
||||||
|
ctx.fillStyle = '#8B4513';
|
||||||
|
ctx.fillRect(px + 10, py + 6, 7, 4);
|
||||||
|
|
||||||
|
// Speed increases with armor broken
|
||||||
|
if (armorBroken > 0) {
|
||||||
|
ctx.fillStyle = 'rgba(255,0,0,0.2)';
|
||||||
|
ctx.fillRect(px - 14, py - 14, 28, 32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWizzrobe(ctx, px, py, boss, isHit) {
|
||||||
|
const robeColor = isHit ? '#FFFFFF' : (boss.color || '#4B0082');
|
||||||
|
const teleporting = boss.teleporting || false;
|
||||||
|
|
||||||
|
if (teleporting) {
|
||||||
|
ctx.globalAlpha = 0.3 + Math.sin(Date.now() * 0.05) * 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Robe
|
||||||
|
ctx.fillStyle = robeColor;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px, py - 14);
|
||||||
|
ctx.lineTo(px - 10, py + 16);
|
||||||
|
ctx.lineTo(px + 10, py + 16);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Hood
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py - 10, 8, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Face opening
|
||||||
|
ctx.fillStyle = '#FFD700';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py - 10, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Eyes
|
||||||
|
ctx.fillStyle = '#FF00FF';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px - 2, py - 11, 1.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + 2, py - 11, 1.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Magic orb
|
||||||
|
if (boss.castSpell) {
|
||||||
|
ctx.fillStyle = '#FF00FF';
|
||||||
|
ctx.globalAlpha = 0.7;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + 14, py - 4, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register boss draw functions
|
||||||
|
const BOSS_DRAW_FUNCTIONS = {
|
||||||
|
gleeok: drawGleeok,
|
||||||
|
darknut: drawDarknut,
|
||||||
|
wizzrobe: drawWizzrobe,
|
||||||
|
generic: drawGenericBoss,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { renderBoss, drawGenericBoss, drawGleeok, drawDarknut, drawWizzrobe };
|
||||||
|
}
|
||||||
362
src/rendering/draw-enemies.js
Normal file
362
src/rendering/draw-enemies.js
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === ENEMY DRAWING FUNCTIONS ===
|
||||||
|
|
||||||
|
function renderEnemies() {
|
||||||
|
for (const enemy of gameState.enemies) {
|
||||||
|
drawEnemy(enemy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawEnemy(enemy) {
|
||||||
|
const px = enemy.x * TILE;
|
||||||
|
const py = enemy.y * TILE;
|
||||||
|
const isHit = enemy.hitTimer > 0;
|
||||||
|
const isStunned = enemy.stunned;
|
||||||
|
const isFrozen = enemy.frozen;
|
||||||
|
const isOnFire = enemy.onFire;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
|
||||||
|
if (isHit) {
|
||||||
|
ctx.globalAlpha = 0.5 + Math.sin(Date.now() * 0.02) * 0.5;
|
||||||
|
}
|
||||||
|
if (isStunned) {
|
||||||
|
ctx.globalAlpha = 0.6;
|
||||||
|
}
|
||||||
|
if (isFrozen) {
|
||||||
|
ctx.filter = 'hue-rotate(180deg) brightness(1.2)';
|
||||||
|
}
|
||||||
|
if (isOnFire) {
|
||||||
|
ctx.filter = 'saturate(2) hue-rotate(-30deg)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw based on enemy type
|
||||||
|
const def = ENEMY_TYPES.find(e => e.id === enemy.id || e.name === enemy.name);
|
||||||
|
if (def && def.drawFunction && typeof window[def.drawFunction] === 'function') {
|
||||||
|
window[def.drawFunction](ctx, px, py, enemy, isHit);
|
||||||
|
} else {
|
||||||
|
// Default goblin-style drawing
|
||||||
|
const bodyColor = isHit ? '#FFFFFF' : (enemy.color || COLORS.goblin);
|
||||||
|
ctx.fillStyle = bodyColor;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(px + 3, py + 4, TILE - 6, TILE - 8, 3);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = enemy.colorLight || COLORS.goblinLight;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(px + 4, py + 5, (TILE - 6) * 0.6, (TILE - 8) * 0.4, 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = enemy.eyeColor || COLORS.goblinEye;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 - 4, py + 10, 2.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 + 4, py + 10, 2.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 - 4, py + 10, 1.2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 + 4, py + 10, 1.2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// HP bar
|
||||||
|
const barW = TILE - 4;
|
||||||
|
const hpRatio = enemy.hp / enemy.maxHp;
|
||||||
|
ctx.fillStyle = '#333333';
|
||||||
|
ctx.fillRect(px + 2, py - 4, barW, 4);
|
||||||
|
ctx.fillStyle = hpRatio > 0.5 ? '#00FF00' : hpRatio > 0.25 ? '#FFAA00' : '#FF0000';
|
||||||
|
ctx.fillRect(px + 2, py - 4, barW * hpRatio, 4);
|
||||||
|
|
||||||
|
// Stun indicator
|
||||||
|
if (isStunned) {
|
||||||
|
ctx.fillStyle = '#FFFF00';
|
||||||
|
ctx.font = '8px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('?', px + TILE / 2, py - 6);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Freeze indicator
|
||||||
|
if (isFrozen) {
|
||||||
|
ctx.fillStyle = '#00BFFF';
|
||||||
|
ctx.font = '8px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('❄', px + TILE / 2, py - 6);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual enemy draw functions
|
||||||
|
|
||||||
|
function drawSlime(ctx, px, py, enemy, isHit) {
|
||||||
|
const bounce = Math.sin(Date.now() * 0.008 + enemy.x * 2) * 2;
|
||||||
|
const color = isHit ? '#FFFFFF' : (enemy.color || '#00CC44');
|
||||||
|
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(px + TILE / 2, py + TILE / 2 + bounce, TILE * 0.35, TILE * 0.25, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = isHit ? '#FFFFFF' : '#00FF66';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(px + TILE / 2, py + TILE / 2 + bounce - 2, TILE * 0.15, TILE * 0.1, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Eyes
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 - 3, py + TILE / 2 + bounce, 1.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 + 3, py + TILE / 2 + bounce, 1.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawKeese(ctx, px, py, enemy, isHit) {
|
||||||
|
const flap = Math.sin(Date.now() * 0.015 + enemy.x * 3) * 3;
|
||||||
|
const color = isHit ? '#FFFFFF' : (enemy.color || '#4A3040');
|
||||||
|
|
||||||
|
// Body
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(px + TILE / 2, py + TILE / 2 + flap, TILE * 0.2, TILE * 0.15, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Wings
|
||||||
|
ctx.fillStyle = isHit ? '#FFFFFF' : '#3A2030';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(px + TILE / 2 - 8, py + TILE / 2 + flap, 8, 4, -0.3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(px + TILE / 2 + 8, py + TILE / 2 + flap, 8, 4, 0.3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Eyes
|
||||||
|
ctx.fillStyle = enemy.eyeColor || '#FF0000';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 - 2, py + TILE / 2 + flap, 1.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 + 2, py + TILE / 2 + flap, 1.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawOctorok(ctx, px, py, enemy, isHit) {
|
||||||
|
const color = isHit ? '#FFFFFF' : (enemy.color || '#8B008B');
|
||||||
|
|
||||||
|
// Body
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2, py + TILE / 2, TILE * 0.35, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Legs
|
||||||
|
ctx.fillStyle = isHit ? '#FFFFFF' : (enemy.colorLight || '#A020A0');
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const angle = (i / 8) * Math.PI * 2 + Date.now() * 0.001;
|
||||||
|
const lx = px + TILE / 2 + Math.cos(angle) * TILE * 0.4;
|
||||||
|
const ly = py + TILE / 2 + Math.sin(angle) * TILE * 0.4;
|
||||||
|
ctx.fillRect(lx - 1, ly - 1, 3, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eyes
|
||||||
|
ctx.fillStyle = enemy.eyeColor || '#FFFF00';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 - 4, py + TILE / 2 - 2, 2.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 + 4, py + TILE / 2 - 2, 2.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 - 4, py + TILE / 2 - 2, 1, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 + 4, py + TILE / 2 - 2, 1, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMoblin(ctx, px, py, enemy, isHit) {
|
||||||
|
const color = isHit ? '#FFFFFF' : (enemy.color || '#2F4F2F');
|
||||||
|
|
||||||
|
// Body (larger than goblin)
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(px + 2, py + 2, TILE - 4, TILE - 4, 4);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = enemy.colorLight || '#3F5F3F';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(px + 3, py + 3, (TILE - 4) * 0.5, (TILE - 4) * 0.4, 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Snout
|
||||||
|
ctx.fillStyle = isHit ? '#FFFFFF' : '#5F3F1F';
|
||||||
|
ctx.fillRect(px + TILE / 2 - 3, py + 14, 6, 6);
|
||||||
|
|
||||||
|
// Eyes
|
||||||
|
ctx.fillStyle = enemy.eyeColor || '#FF4400';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 - 5, py + 8, 2.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 + 5, py + 8, 2.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Spear
|
||||||
|
ctx.fillStyle = '#8B4513';
|
||||||
|
ctx.fillRect(px + TILE - 4, py + 6, 2, TILE - 10);
|
||||||
|
ctx.fillStyle = '#808080';
|
||||||
|
ctx.fillRect(px + TILE - 6, py + 4, 6, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawStalfos(ctx, px, py, enemy, isHit) {
|
||||||
|
const color = isHit ? '#FFFFFF' : (enemy.color || '#E8E8E8');
|
||||||
|
|
||||||
|
// Skeleton body
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillRect(px + TILE / 2 - 2, py + 4, 4, TILE - 10);
|
||||||
|
|
||||||
|
// Skull
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2, py + 6, 5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Eye sockets
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 - 2, py + 5, 1.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 + 2, py + 5, 1.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Eye glow
|
||||||
|
ctx.fillStyle = enemy.eyeColor || '#FF0000';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 - 2, py + 5, 0.8, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 + 2, py + 5, 0.8, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Arms
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillRect(px + 2, py + 10, TILE - 4, 2);
|
||||||
|
|
||||||
|
// Sword
|
||||||
|
ctx.fillStyle = '#C0C0C0';
|
||||||
|
ctx.fillRect(px + TILE - 4, py + 4, 2, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGoriya(ctx, px, py, enemy, isHit) {
|
||||||
|
const color = isHit ? '#FFFFFF' : (enemy.color || '#4169AA');
|
||||||
|
|
||||||
|
// Body
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(px + 3, py + 4, TILE - 6, TILE - 8, 3);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Mask
|
||||||
|
ctx.fillStyle = enemy.colorLight || '#5F9EA0';
|
||||||
|
ctx.fillRect(px + TILE / 2 - 5, py + 6, 10, 8);
|
||||||
|
|
||||||
|
// Eyes
|
||||||
|
ctx.fillStyle = enemy.eyeColor || '#FFFF00';
|
||||||
|
ctx.fillRect(px + TILE / 2 - 3, py + 8, 2, 2);
|
||||||
|
ctx.fillRect(px + TILE / 2 + 1, py + 8, 2, 2);
|
||||||
|
|
||||||
|
// Boomerang
|
||||||
|
ctx.fillStyle = '#DAA520';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE - 2, py + 10, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#B8860B';
|
||||||
|
ctx.fillRect(px + TILE - 4, py + 8, 2, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawArmos(ctx, px, py, enemy, isHit) {
|
||||||
|
const color = isHit ? '#FFFFFF' : (enemy.color || '#DAA520');
|
||||||
|
const activated = enemy.activated || false;
|
||||||
|
|
||||||
|
// Statue body
|
||||||
|
ctx.fillStyle = activated ? '#FFD700' : color;
|
||||||
|
ctx.fillRect(px + 4, py + 2, TILE - 8, TILE - 4);
|
||||||
|
|
||||||
|
// Triangle eyes
|
||||||
|
ctx.fillStyle = activated ? '#FF0000' : '#B8860B';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px + TILE / 2 - 4, py + 8);
|
||||||
|
ctx.lineTo(px + TILE / 2 - 2, py + 12);
|
||||||
|
ctx.lineTo(px + TILE / 2 - 6, py + 12);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px + TILE / 2 + 4, py + 8);
|
||||||
|
ctx.lineTo(px + TILE / 2 + 2, py + 12);
|
||||||
|
ctx.lineTo(px + TILE / 2 + 6, py + 12);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Sword
|
||||||
|
ctx.fillStyle = '#C0C0C0';
|
||||||
|
ctx.fillRect(px + TILE - 2, py + 4, 2, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawLynel(ctx, px, py, enemy, isHit) {
|
||||||
|
const color = isHit ? '#FFFFFF' : (enemy.color || '#8B0000');
|
||||||
|
|
||||||
|
// Large body
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(px, py + 2, TILE, TILE - 4, 4);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Mane
|
||||||
|
ctx.fillStyle = enemy.colorLight || '#CC2222';
|
||||||
|
ctx.fillRect(px + 2, py + 2, TILE - 4, 6);
|
||||||
|
|
||||||
|
// Eyes
|
||||||
|
ctx.fillStyle = enemy.eyeColor || '#FFD700';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 - 5, py + 8, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 + 5, py + 8, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 - 5, py + 8, 1.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2 + 5, py + 8, 1.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register draw functions
|
||||||
|
const ENEMY_DRAW_FUNCTIONS = {
|
||||||
|
slime: drawSlime,
|
||||||
|
keese: drawKeese,
|
||||||
|
octorok: drawOctorok,
|
||||||
|
moblin: drawMoblin,
|
||||||
|
stalfos: drawStalfos,
|
||||||
|
goriya: drawGoriya,
|
||||||
|
armos: drawArmos,
|
||||||
|
lynel: drawLynel,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { renderEnemies, drawEnemy, drawSlime, drawKeese, drawOctorok, drawMoblin, drawStalfos, drawGoriya, drawArmos, drawLynel };
|
||||||
|
}
|
||||||
199
src/rendering/draw-weapons.js
Normal file
199
src/rendering/draw-weapons.js
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === WEAPON DRAWING ===
|
||||||
|
|
||||||
|
function drawWeaponHeld(weapon, px, py, dir) {
|
||||||
|
const def = WEAPONS.find(w => w.name === weapon.name || w.id === weapon.id);
|
||||||
|
if (!def || !def.drawFunction) return;
|
||||||
|
const drawFn = window[def.drawFunction];
|
||||||
|
if (typeof drawFn !== 'function') return;
|
||||||
|
drawFn(ctx, px, py, dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWeaponInHUD(weapon, x, y) {
|
||||||
|
const def = WEAPONS.find(w => w.name === weapon.name || w.id === weapon.id);
|
||||||
|
if (!def || !drawWeaponIcon) return;
|
||||||
|
drawWeaponIcon(ctx, def, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sword
|
||||||
|
function drawSwordHeld(ctx, px, py, dir) {
|
||||||
|
ctx.fillStyle = COLORS.swordBlade;
|
||||||
|
const sx = px + TILE / 2 + DIRS[dir].x * 10;
|
||||||
|
const sy = py + TILE / 2 + DIRS[dir].y * 10;
|
||||||
|
ctx.fillRect(sx - 2, sy - 2, 4, 4);
|
||||||
|
ctx.fillStyle = COLORS.swordHandle;
|
||||||
|
const hx = px + TILE / 2 + DIRS[dir].x * 5;
|
||||||
|
const hy = py + TILE / 2 + DIRS[dir].y * 5;
|
||||||
|
ctx.fillRect(hx - 1.5, hy - 1.5, 3, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMasterSwordHeld(ctx, px, py, dir) {
|
||||||
|
ctx.fillStyle = '#E8E8FF';
|
||||||
|
const sx = px + TILE / 2 + DIRS[dir].x * 12;
|
||||||
|
const sy = py + TILE / 2 + DIRS[dir].y * 12;
|
||||||
|
ctx.fillRect(sx - 2, sy - 2, 4, 4);
|
||||||
|
// Aura
|
||||||
|
ctx.fillStyle = 'rgba(100,100,255,0.3)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, sy, 6, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#DAA520';
|
||||||
|
const hx = px + TILE / 2 + DIRS[dir].x * 5;
|
||||||
|
const hy = py + TILE / 2 + DIRS[dir].y * 5;
|
||||||
|
ctx.fillRect(hx - 2, hy - 2, 4, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bow
|
||||||
|
function drawBowHeld(ctx, px, py, dir) {
|
||||||
|
ctx.fillStyle = '#8B4513';
|
||||||
|
const bx = px + TILE / 2 + DIRS[dir].x * 6;
|
||||||
|
const by = py + TILE / 2 + DIRS[dir].y * 6;
|
||||||
|
ctx.beginPath();
|
||||||
|
if (dir === 0 || dir === 1) {
|
||||||
|
ctx.arc(bx, by, 6, dir === 0 ? -0.8 : 0.8, dir === 0 ? 0.8 : 2.4);
|
||||||
|
} else {
|
||||||
|
ctx.arc(bx, by, 6, dir === 2 ? 0.3 : 2.9, dir === 2 ? 1.1 : 3.7);
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = '#8B4513';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
// String
|
||||||
|
ctx.strokeStyle = '#FFFFFF';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(bx + DIRS[dir].x * 2, by + DIRS[dir].y * 2);
|
||||||
|
ctx.lineTo(bx - DIRS[dir].x * 2, by - DIRS[dir].y * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boomerang
|
||||||
|
function drawBoomerangHeld(ctx, px, py, dir) {
|
||||||
|
ctx.fillStyle = '#FFD700';
|
||||||
|
const bx = px + TILE / 2 + DIRS[dir].x * 8;
|
||||||
|
const by = py + TILE / 2 + DIRS[dir].y * 8;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(bx, by);
|
||||||
|
ctx.lineTo(bx + DIRS[dir].x * 4, by + DIRS[dir].y * 2);
|
||||||
|
ctx.lineTo(bx + DIRS[dir].x * 2, by + DIRS[dir].y * 4);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bombs
|
||||||
|
function drawBombHeld(ctx, px, py, dir) {
|
||||||
|
ctx.fillStyle = '#333';
|
||||||
|
const bx = px + TILE / 2 + DIRS[dir].x * 6;
|
||||||
|
const by = py + TILE / 2 + DIRS[dir].y * 6;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(bx, by, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#FF0000';
|
||||||
|
ctx.fillRect(bx - 1, by - 6, 2, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hammer
|
||||||
|
function drawHammerHeld(ctx, px, py, dir) {
|
||||||
|
ctx.fillStyle = '#8B4513';
|
||||||
|
const hx = px + TILE / 2 + DIRS[dir].x * 8;
|
||||||
|
const hy = py + TILE / 2 + DIRS[dir].y * 8;
|
||||||
|
ctx.fillRect(hx - 1, hy - 1, 3, 10);
|
||||||
|
ctx.fillStyle = '#666';
|
||||||
|
ctx.fillRect(hx - 4, hy - 5, 8, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire Rod
|
||||||
|
function drawFireRodHeld(ctx, px, py, dir) {
|
||||||
|
ctx.fillStyle = '#8B4513';
|
||||||
|
const rx = px + TILE / 2 + DIRS[dir].x * 6;
|
||||||
|
const ry = py + TILE / 2 + DIRS[dir].y * 6;
|
||||||
|
ctx.fillRect(rx - 1, ry - 1, 3, 8);
|
||||||
|
ctx.fillStyle = '#FFD700';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(rx + DIRS[dir].x * 2, ry + DIRS[dir].y * 2, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#FF4500';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(rx + DIRS[dir].x * 2, ry + DIRS[dir].y * 2, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ice Rod
|
||||||
|
function drawIceRodHeld(ctx, px, py, dir) {
|
||||||
|
ctx.fillStyle = '#8B4513';
|
||||||
|
const rx = px + TILE / 2 + DIRS[dir].x * 6;
|
||||||
|
const ry = py + TILE / 2 + DIRS[dir].y * 6;
|
||||||
|
ctx.fillRect(rx - 1, ry - 1, 3, 8);
|
||||||
|
ctx.fillStyle = '#B0E0E6';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(rx + DIRS[dir].x * 2, ry + DIRS[dir].y * 2, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#00BFFF';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(rx + DIRS[dir].x * 2, ry + DIRS[dir].y * 2, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slingshot
|
||||||
|
function drawSlingshotHeld(ctx, px, py, dir) {
|
||||||
|
ctx.fillStyle = '#8B4513';
|
||||||
|
const sx = px + TILE / 2 + DIRS[dir].x * 6;
|
||||||
|
const sy = py + TILE / 2 + DIRS[dir].y * 6;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(sx - 3, sy);
|
||||||
|
ctx.lineTo(sx, sy - 4);
|
||||||
|
ctx.lineTo(sx + 3, sy);
|
||||||
|
ctx.strokeStyle = '#8B4513';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.strokeStyle = '#DEB887';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(sx - 3, sy);
|
||||||
|
ctx.lineTo(sx + 3, sy);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hookshot
|
||||||
|
function drawHookshotHeld(ctx, px, py, dir) {
|
||||||
|
ctx.fillStyle = '#C0C0C0';
|
||||||
|
const hx = px + TILE / 2 + DIRS[dir].x * 6;
|
||||||
|
const hy = py + TILE / 2 + DIRS[dir].y * 6;
|
||||||
|
ctx.fillRect(hx - 1, hy - 1, 3, 8);
|
||||||
|
ctx.fillStyle = '#808080';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(hx + DIRS[dir].x * 2, hy + DIRS[dir].y * 2, 3, 0, Math.PI);
|
||||||
|
ctx.strokeStyle = '#808080';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register weapon draw functions
|
||||||
|
const WEAPON_DRAW = {
|
||||||
|
sword: { held: drawSwordHeld },
|
||||||
|
master_sword: { held: drawMasterSwordHeld },
|
||||||
|
bow: { held: drawBowHeld },
|
||||||
|
boomerang: { held: drawBoomerangHeld },
|
||||||
|
bomb: { held: drawBombHeld },
|
||||||
|
hammer: { held: drawHammerHeld },
|
||||||
|
fire_rod: { held: drawFireRodHeld },
|
||||||
|
ice_rod: { held: drawIceRodHeld },
|
||||||
|
slingshot: { held: drawSlingshotHeld },
|
||||||
|
hookshot: { held: drawHookshotHeld },
|
||||||
|
};
|
||||||
|
|
||||||
|
function drawWeaponIcon(ctx, weapon, x, y) {
|
||||||
|
const drawFn = WEAPON_DRAW[weapon.id];
|
||||||
|
if (drawFn && drawFn.held) {
|
||||||
|
ctx.save();
|
||||||
|
drawFn.held(ctx, x, y, 0);
|
||||||
|
ctx.restore();
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = weapon.color || '#C0C0C0';
|
||||||
|
ctx.fillRect(x - 3, y - 8, 6, 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { drawWeaponHeld, drawWeaponInHUD, drawSwordHeld, drawMasterSwordHeld, drawBowHeld, drawBoomerangHeld, drawBombHeld, drawHammerHeld, drawFireRodHeld, drawIceRodHeld, drawSlingshotHeld, drawHookshotHeld, drawWeaponIcon };
|
||||||
|
}
|
||||||
77
src/rendering/effects.js
vendored
Normal file
77
src/rendering/effects.js
vendored
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === PARTICLE EFFECTS ===
|
||||||
|
|
||||||
|
function spawnParticles(x, y, color, count) {
|
||||||
|
if (!gameState.particles) gameState.particles = [];
|
||||||
|
if (gameState.particles.length > MAX_PARTICLES) {
|
||||||
|
gameState.particles.splice(0, gameState.particles.length - MAX_PARTICLES + count);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const angle = Math.random() * Math.PI * 2;
|
||||||
|
const speed = 1 + Math.random() * 3;
|
||||||
|
gameState.particles.push({
|
||||||
|
x: x,
|
||||||
|
y: y,
|
||||||
|
vx: Math.cos(angle) * speed,
|
||||||
|
vy: Math.sin(angle) * speed - 1,
|
||||||
|
gravity: 0.05,
|
||||||
|
color: color,
|
||||||
|
size: 1 + Math.random() * 2,
|
||||||
|
life: 300 + Math.random() * 400,
|
||||||
|
maxLife: 700,
|
||||||
|
maxAlpha: 0.8 + Math.random() * 0.2,
|
||||||
|
alpha: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnHitParticles(x, y) {
|
||||||
|
spawnParticles(x, y, '#FF0000', 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnDefeatParticles(x, y) {
|
||||||
|
spawnParticles(x, y, '#FFD700', 12);
|
||||||
|
spawnParticles(x, y, '#FFFFFF', 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnExplosionParticles(x, y) {
|
||||||
|
spawnParticles(x, y, '#FF4500', 15);
|
||||||
|
spawnParticles(x, y, '#FFD700', 8);
|
||||||
|
spawnParticles(x, y, '#FFFFFF', 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderParticles() {
|
||||||
|
if (!gameState.particles) return;
|
||||||
|
for (const p of gameState.particles) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = p.alpha;
|
||||||
|
ctx.fillStyle = p.color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw projectiles
|
||||||
|
function renderProjectiles() {
|
||||||
|
if (!gameState.projectiles) return;
|
||||||
|
for (const p of gameState.projectiles) {
|
||||||
|
ctx.fillStyle = p.color || '#FFFF00';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x * TILE + TILE / 2, p.y * TILE + TILE / 2, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Trail
|
||||||
|
ctx.fillStyle = (p.color || '#FFFF00') + '40';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x * TILE + TILE / 2 - p.dir.x * 6, p.y * TILE + TILE / 2 - p.dir.y * 6, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { spawnParticles, spawnHitParticles, spawnDefeatParticles, spawnExplosionParticles, renderParticles, renderProjectiles };
|
||||||
|
}
|
||||||
107
src/rendering/hud.js
Normal file
107
src/rendering/hud.js
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === HUD RENDERING ===
|
||||||
|
|
||||||
|
function renderHUD() {
|
||||||
|
// Hearts
|
||||||
|
const totalHearts = Math.ceil(gameState.player.maxHp / 2);
|
||||||
|
const fullHearts = Math.floor(gameState.player.hp / 2);
|
||||||
|
const halfHeart = gameState.player.hp % 2 === 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < totalHearts; i++) {
|
||||||
|
const hx = 12 + i * 22;
|
||||||
|
const hy = 12;
|
||||||
|
drawHeart(ctx, hx, hy, 14, i < fullHearts);
|
||||||
|
if (i === fullHearts && halfHeart) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(hx - 7, hy - 7, 14, 14);
|
||||||
|
ctx.clip();
|
||||||
|
drawHeart(ctx, hx, hy, 14, true);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active weapon icon
|
||||||
|
if (gameState.player.activeWeapon) {
|
||||||
|
drawWeaponIcon(ctx, gameState.player.activeWeapon, CANVAS_W - 24, 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ammo counts
|
||||||
|
if (gameState.player.arrows > 0) {
|
||||||
|
ctx.fillStyle = '#8B4513';
|
||||||
|
ctx.fillRect(CANVAS_W - 50, 10, 2, 12);
|
||||||
|
ctx.fillStyle = '#808080';
|
||||||
|
ctx.fillRect(CANVAS_W - 52, 10, 6, 3);
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
ctx.font = '9px monospace';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.fillText(gameState.player.arrows, CANVAS_W - 54, 22);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameState.player.bombs > 0) {
|
||||||
|
ctx.fillStyle = '#333';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(CANVAS_W - 38, 14, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
ctx.font = '9px monospace';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.fillText(gameState.player.bombs, CANVAS_W - 42, 22);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameState.player.keys > 0) {
|
||||||
|
ctx.fillStyle = '#FFD700';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(CANVAS_W - 30, 30, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
ctx.font = '9px monospace';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.fillText(gameState.player.keys, CANVAS_W - 34, 38);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weapon inventory (bottom right)
|
||||||
|
if (gameState.player.weapons.length > 1) {
|
||||||
|
let wx = CANVAS_W - 16;
|
||||||
|
for (let i = gameState.player.weapons.length - 1; i >= 0; i--) {
|
||||||
|
const w = gameState.player.weapons[i];
|
||||||
|
const isActive = w === gameState.player.activeWeapon;
|
||||||
|
ctx.fillStyle = isActive ? '#FFD700' : '#555';
|
||||||
|
ctx.fillRect(wx - 10, CANVAS_H - 20, 12, 16);
|
||||||
|
ctx.strokeStyle = isActive ? '#FFD700' : '#333';
|
||||||
|
ctx.lineWidth = isActive ? 1.5 : 0.5;
|
||||||
|
ctx.strokeRect(wx - 10, CANVAS_H - 20, 12, 16);
|
||||||
|
drawWeaponIcon(ctx, w, wx - 4, CANVAS_H - 12);
|
||||||
|
wx -= 14;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Room name / dungeon info
|
||||||
|
const dungeon = DUNGEONS[gameState.currentDungeon];
|
||||||
|
if (dungeon) {
|
||||||
|
ctx.fillStyle = '#CCCCCC';
|
||||||
|
ctx.font = '12px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(`${dungeon.name} - Room ${gameState.currentRoomIdx + 1}/${gameState.dungeonRooms ? gameState.dungeonRooms.length : '?'}`, CANVAS_W / 2, CANVAS_H - 8);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controls hint (fades out)
|
||||||
|
if (gameState.messageTimer <= 0 && gameState.currentRoomIdx === 0) {
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||||
|
ctx.fillRect(CANVAS_W / 2 - 100, CANVAS_H - 28, 200, 18);
|
||||||
|
ctx.fillStyle = '#888';
|
||||||
|
ctx.font = '10px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('Arrows/WASD: Move Space: Attack E: Interact X: Switch', CANVAS_W / 2, CANVAS_H - 15);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { renderHUD };
|
||||||
|
}
|
||||||
559
src/rendering/renderer.js
Normal file
559
src/rendering/renderer.js
Normal file
@ -0,0 +1,559 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === MAIN RENDERER ===
|
||||||
|
// Draws the game world: floor, walls, torches, doors, etc.
|
||||||
|
|
||||||
|
function renderFloor(theme) {
|
||||||
|
for (let y = 0; y < ROWS; y++) {
|
||||||
|
for (let x = 0; x < COLS; x++) {
|
||||||
|
if (gameState.layout[y][x] === TILE_TYPES.FLOOR) {
|
||||||
|
const shade = ((x + y) % 2 === 0) ? (theme.floorColor || COLORS.floor) : (theme.floorDarkColor || COLORS.floorDark);
|
||||||
|
ctx.fillStyle = shade;
|
||||||
|
ctx.fillRect(x * TILE, y * TILE, TILE, TILE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWalls(theme) {
|
||||||
|
const wc = getWallColors(theme);
|
||||||
|
for (let y = 0; y < ROWS; y++) {
|
||||||
|
for (let x = 0; x < COLS; x++) {
|
||||||
|
if (gameState.layout[y][x] === TILE_TYPES.WALL) {
|
||||||
|
const px = x * TILE;
|
||||||
|
const py = y * TILE;
|
||||||
|
|
||||||
|
ctx.fillStyle = wc.wall;
|
||||||
|
ctx.fillRect(px, py, TILE, TILE);
|
||||||
|
|
||||||
|
ctx.fillStyle = wc.top;
|
||||||
|
ctx.fillRect(px, py, TILE, 3);
|
||||||
|
|
||||||
|
ctx.fillStyle = wc.front;
|
||||||
|
ctx.fillRect(px, py + 3, TILE, TILE - 6);
|
||||||
|
|
||||||
|
ctx.fillStyle = wc.bottom;
|
||||||
|
ctx.fillRect(px, py + TILE - 3, TILE, 3);
|
||||||
|
|
||||||
|
ctx.strokeStyle = theme.wallBorderColor || '#404040';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
ctx.strokeRect(px + 0.5, py + 0.5, TILE - 1, TILE - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTorches(theme) {
|
||||||
|
for (let y = 0; y < ROWS; y++) {
|
||||||
|
for (let x = 0; x < COLS; x++) {
|
||||||
|
if (gameState.layout[y][x] === TILE_TYPES.TORCH) {
|
||||||
|
const cx = x * TILE + TILE / 2;
|
||||||
|
const cy = y * TILE + TILE / 2;
|
||||||
|
|
||||||
|
// Glow
|
||||||
|
ctx.fillStyle = theme.torchGlow || COLORS.torchGlow;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, TILE * 1.2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Floor tile under torch
|
||||||
|
ctx.fillStyle = theme.floorColor || COLORS.floor;
|
||||||
|
ctx.fillRect(x * TILE, y * TILE, TILE, TILE);
|
||||||
|
|
||||||
|
// Stick
|
||||||
|
ctx.fillStyle = theme.torchStickColor || COLORS.torchStick;
|
||||||
|
ctx.fillRect(cx - 2, cy + 2, 4, TILE * 0.4);
|
||||||
|
|
||||||
|
// Flame
|
||||||
|
ctx.fillStyle = theme.torchFlameColor || COLORS.torchFlame;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy - 4, 4, 6, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Flame core
|
||||||
|
ctx.fillStyle = theme.torchCoreColor || '#FFFF00';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy - 3, 2, 3, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDoors(theme) {
|
||||||
|
for (const door of gameState.doors) {
|
||||||
|
const px = door.x * TILE;
|
||||||
|
const py = door.y * TILE;
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.doorFrame;
|
||||||
|
ctx.fillRect(px - 1, py - 1, TILE + 2, TILE + 2);
|
||||||
|
|
||||||
|
if (door.opened) {
|
||||||
|
ctx.fillStyle = (theme && theme.floorColor) || COLORS.floor;
|
||||||
|
ctx.fillRect(px + 2, py + 2, TILE - 4, TILE - 4);
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = COLORS.door;
|
||||||
|
ctx.fillRect(px + 2, py + 2, TILE - 4, TILE - 4);
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#8B7355';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(px + 2, py + 2, TILE - 4, TILE - 4);
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.doorKnob;
|
||||||
|
ctx.beginPath();
|
||||||
|
const knobX = door.dir === 3 ? px + TILE - 7 : px + 7;
|
||||||
|
ctx.arc(knobX, py + TILE / 2, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSoftwalls() {
|
||||||
|
for (let y = 0; y < ROWS; y++) {
|
||||||
|
for (let x = 0; x < COLS; x++) {
|
||||||
|
if (gameState.layout[y][x] === TILE_TYPES.SOFTWALL) {
|
||||||
|
const px = x * TILE;
|
||||||
|
const py = y * TILE;
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.softwall;
|
||||||
|
ctx.fillRect(px, py, TILE, TILE);
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.softwallDark;
|
||||||
|
ctx.fillRect(px + 2, py + 2, TILE - 4, TILE - 4);
|
||||||
|
|
||||||
|
// Crack pattern
|
||||||
|
ctx.strokeStyle = '#3A2818';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px + 4, py + 4);
|
||||||
|
ctx.lineTo(px + TILE / 2, py + TILE / 2);
|
||||||
|
ctx.lineTo(px + TILE - 4, py + 6);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLava() {
|
||||||
|
for (let y = 0; y < ROWS; y++) {
|
||||||
|
for (let x = 0; x < COLS; x++) {
|
||||||
|
if (gameState.layout[y][x] === TILE_TYPES.LAVA) {
|
||||||
|
const px = x * TILE;
|
||||||
|
const py = y * TILE;
|
||||||
|
const t = Date.now() * 0.003;
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.lava;
|
||||||
|
ctx.fillRect(px, py, TILE, TILE);
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.lavaLight;
|
||||||
|
const offset = Math.sin(t + x * 0.5 + y * 0.3) * 3;
|
||||||
|
ctx.fillRect(px + 4 + offset, py + 6, TILE - 8, TILE - 10);
|
||||||
|
|
||||||
|
// Bubbles
|
||||||
|
ctx.fillStyle = '#FF6347';
|
||||||
|
ctx.beginPath();
|
||||||
|
const bx = px + TILE / 2 + Math.sin(t * 1.5 + x) * 4;
|
||||||
|
const by = py + TILE / 2 + Math.cos(t * 1.2 + y) * 3;
|
||||||
|
ctx.arc(bx, by, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWater() {
|
||||||
|
for (let y = 0; y < ROWS; y++) {
|
||||||
|
for (let x = 0; x < COLS; x++) {
|
||||||
|
if (gameState.layout[y][x] === TILE_TYPES.WATER) {
|
||||||
|
const px = x * TILE;
|
||||||
|
const py = y * TILE;
|
||||||
|
const t = Date.now() * 0.002;
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.water;
|
||||||
|
ctx.fillRect(px, py, TILE, TILE);
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.waterLight;
|
||||||
|
const offset = Math.sin(t + x * 0.8 + y * 0.4) * 2;
|
||||||
|
ctx.fillRect(px + 3 + offset, py + 8, TILE - 6, 3);
|
||||||
|
ctx.fillRect(px + 6 - offset, py + 16, TILE - 10, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTraps() {
|
||||||
|
for (const trap of gameState.traps) {
|
||||||
|
if (trap.triggered) continue;
|
||||||
|
const px = trap.x * TILE;
|
||||||
|
const py = trap.y * TILE;
|
||||||
|
|
||||||
|
// Hidden trap - draw subtle indicator
|
||||||
|
ctx.fillStyle = (trap.color || COLORS.trap) + '40';
|
||||||
|
ctx.fillRect(px + 4, py + 4, TILE - 8, TILE - 8);
|
||||||
|
|
||||||
|
// Spike pattern
|
||||||
|
ctx.fillStyle = trap.color || COLORS.trap;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
for (let j = 0; j < 3; j++) {
|
||||||
|
ctx.beginPath();
|
||||||
|
const sx = px + 6 + i * 9;
|
||||||
|
const sy = py + TILE - 6 - j * 9;
|
||||||
|
ctx.moveTo(sx, sy - 4);
|
||||||
|
ctx.lineTo(sx - 2, sy);
|
||||||
|
ctx.lineTo(sx + 2, sy);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLevers() {
|
||||||
|
for (const lever of gameState.levers) {
|
||||||
|
if (lever.activated) continue;
|
||||||
|
const px = lever.x * TILE;
|
||||||
|
const py = lever.y * TILE;
|
||||||
|
|
||||||
|
// Base
|
||||||
|
ctx.fillStyle = '#666';
|
||||||
|
ctx.fillRect(px + TILE / 2 - 2, py + TILE / 2 - 2, 4, 4);
|
||||||
|
|
||||||
|
// Handle
|
||||||
|
ctx.fillStyle = COLORS.lever;
|
||||||
|
ctx.fillRect(px + TILE / 2 - 1, py + 4, 3, TILE / 2 - 4);
|
||||||
|
|
||||||
|
// Knob
|
||||||
|
ctx.fillStyle = '#DAA520';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2, py + 4, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSwitches() {
|
||||||
|
for (const sw of gameState.switches) {
|
||||||
|
const px = sw.x * TILE;
|
||||||
|
const py = sw.y * TILE;
|
||||||
|
|
||||||
|
if (sw.activated) {
|
||||||
|
ctx.fillStyle = COLORS.switchFloor;
|
||||||
|
ctx.fillRect(px + 4, py + 4, TILE - 8, TILE - 8);
|
||||||
|
ctx.fillStyle = '#00FF00';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2, py + TILE / 2, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = COLORS.switchFloor;
|
||||||
|
ctx.fillRect(px + 4, py + 4, TILE - 8, TILE - 8);
|
||||||
|
ctx.fillStyle = '#FFD700';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2, py + TILE / 2, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTargets() {
|
||||||
|
for (const target of gameState.targets) {
|
||||||
|
const cx = target.x * TILE + TILE / 2;
|
||||||
|
const cy = target.y * TILE + TILE / 2;
|
||||||
|
const occupied = gameState.rocks.some(r => r.x === target.x && r.y === target.y);
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.targetGlow;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, TILE * 0.7, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = occupied ? '#00FF00' : COLORS.target;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, TILE * 0.3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.strokeStyle = occupied ? '#00AA00' : '#CC9900';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, TILE * 0.3, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRocks() {
|
||||||
|
for (const rock of gameState.rocks) {
|
||||||
|
const px = rock.x * TILE;
|
||||||
|
const py = rock.y * TILE;
|
||||||
|
const inset = 3;
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.rock;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(px + inset, py + inset, TILE - inset * 2, TILE - inset * 2, 4);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.rockLight;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(px + inset + 1, py + inset + 1, (TILE - inset * 2) * 0.5, (TILE - inset * 2) * 0.4, 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.rockDark;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(px + inset + (TILE - inset * 2) * 0.4, py + inset + (TILE - inset * 2) * 0.5, (TILE - inset * 2) * 0.4, (TILE - inset * 2) * 0.4, 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#555555';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(px + inset, py + inset, TILE - inset * 2, TILE - inset * 2, 4);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChests() {
|
||||||
|
for (const chest of gameState.chests) {
|
||||||
|
const px = chest.x * TILE;
|
||||||
|
const py = chest.y * TILE;
|
||||||
|
|
||||||
|
if (chest.opened) {
|
||||||
|
ctx.fillStyle = COLORS.chestOpen;
|
||||||
|
ctx.fillRect(px + 2, py + 10, TILE - 4, TILE - 12);
|
||||||
|
ctx.fillStyle = COLORS.chestGold;
|
||||||
|
ctx.fillRect(px + 4, py + 12, TILE - 8, TILE - 14);
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.chestClosed;
|
||||||
|
ctx.fillRect(px + 2, py + 2, TILE - 4, 6);
|
||||||
|
ctx.fillStyle = COLORS.chestGold;
|
||||||
|
ctx.fillRect(px + 4, py + 3, TILE - 8, 4);
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = COLORS.chestClosed;
|
||||||
|
ctx.fillRect(px + 2, py + 2, TILE - 4, TILE - 4);
|
||||||
|
ctx.fillStyle = COLORS.chestGold;
|
||||||
|
ctx.fillRect(px + 4, py + 4, TILE - 8, TILE - 8);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#FFD700';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2, py + TILE / 2, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#B8860B';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(px + 2, py + 2, TILE - 4, TILE - 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItems() {
|
||||||
|
for (const item of gameState.floorItems) {
|
||||||
|
if (item.collected) continue;
|
||||||
|
const px = item.x * TILE;
|
||||||
|
const py = item.y * TILE;
|
||||||
|
const def = ITEMS.find(i => i.id === item.id);
|
||||||
|
if (!def) continue;
|
||||||
|
|
||||||
|
const bob = Math.sin(Date.now() * 0.005 + item.x + item.y) * 2;
|
||||||
|
|
||||||
|
if (item.id === 'heart_piece') {
|
||||||
|
drawHeart(ctx, px + TILE / 2, py + TILE / 2 + bob, 10, true);
|
||||||
|
} else if (item.id.startsWith('potion')) {
|
||||||
|
ctx.fillStyle = def.color || '#FF0000';
|
||||||
|
ctx.fillRect(px + 10, py + 8 + bob, 12, 16);
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
ctx.fillRect(px + 12, py + 6 + bob, 8, 4);
|
||||||
|
} else if (item.id === 'arrows') {
|
||||||
|
ctx.fillStyle = '#8B4513';
|
||||||
|
ctx.fillRect(px + 12, py + 4 + bob, 2, 24);
|
||||||
|
ctx.fillStyle = '#808080';
|
||||||
|
ctx.fillRect(px + 10, py + 4 + bob, 6, 4);
|
||||||
|
} else if (item.id === 'bombs') {
|
||||||
|
ctx.fillStyle = '#333';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2, py + TILE / 2 + bob, 6, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#FF0000';
|
||||||
|
ctx.fillRect(px + TILE / 2 - 1, py + 2 + bob, 2, 4);
|
||||||
|
} else if (item.id === 'key') {
|
||||||
|
ctx.fillStyle = '#FFD700';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + 10, py + 10 + bob, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillRect(px + 14, py + 9 + bob, 10, 3);
|
||||||
|
ctx.fillRect(px + 20, py + 9 + bob, 3, 5);
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = def.color || '#FFD700';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2, py + TILE / 2 + bob, 5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw exit door
|
||||||
|
function renderExit() {
|
||||||
|
if (!gameState.exit) return;
|
||||||
|
|
||||||
|
const px = gameState.exit.x * TILE;
|
||||||
|
const py = gameState.exit.y * TILE;
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.doorFrame;
|
||||||
|
ctx.fillRect(px - 1, py - 1, TILE + 2, TILE + 2);
|
||||||
|
|
||||||
|
if (gameState.exitOpen) {
|
||||||
|
ctx.fillStyle = '#1A1A1A';
|
||||||
|
ctx.fillRect(px + 2, py + 2, TILE - 4, TILE - 4);
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = COLORS.door;
|
||||||
|
ctx.fillRect(px + 2, py + 2, TILE - 4, TILE - 4);
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#8B7355';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(px + 2, py + 2, TILE - 4, TILE - 4);
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.doorKnob;
|
||||||
|
ctx.beginPath();
|
||||||
|
const knobX = gameState.exit.dir === 3 ? px + TILE - 7 : px + 7;
|
||||||
|
ctx.arc(knobX, py + TILE / 2, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
ctx.font = 'bold 10px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
const arrowX = gameState.exit.dir === 3 ? px + TILE + 10 : px - 10;
|
||||||
|
ctx.fillText(
|
||||||
|
gameState.exit.dir === 3 ? '\u2192' :
|
||||||
|
gameState.exit.dir === 2 ? '\u2190' :
|
||||||
|
gameState.exit.dir === 0 ? '\u2193' : '\u2191',
|
||||||
|
arrowX, py + TILE / 2 + 4
|
||||||
|
);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw player character
|
||||||
|
function renderPlayer() {
|
||||||
|
const p = gameState.player;
|
||||||
|
const px = p.x * TILE;
|
||||||
|
const py = p.y * TILE;
|
||||||
|
const isHit = p.hitTimer > 0;
|
||||||
|
const isInvincible = p.invincibleTimer > 0;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
|
||||||
|
if (isHit) {
|
||||||
|
ctx.globalAlpha = 0.5 + Math.sin(Date.now() * 0.03) * 0.5;
|
||||||
|
}
|
||||||
|
if (isInvincible) {
|
||||||
|
ctx.globalAlpha = Math.sin(Date.now() * 0.01) > 0 ? 1 : 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyColor = isHit ? '#FF6666' : COLORS.playerBody;
|
||||||
|
|
||||||
|
// Body
|
||||||
|
ctx.fillStyle = bodyColor;
|
||||||
|
ctx.fillRect(px + 6, py + 14, TILE - 12, TILE - 16);
|
||||||
|
|
||||||
|
// Tunic detail
|
||||||
|
ctx.fillStyle = COLORS.playerTunic;
|
||||||
|
ctx.fillRect(px + 8, py + 16, TILE - 16, TILE - 20);
|
||||||
|
|
||||||
|
// Head
|
||||||
|
ctx.fillStyle = COLORS.playerHead;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2, py + 10, 7, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Hair
|
||||||
|
ctx.fillStyle = COLORS.playerHair;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px + TILE / 2, py + 8, 7, Math.PI, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Eyes
|
||||||
|
ctx.fillStyle = COLORS.playerEyes;
|
||||||
|
const eyeOffX = DIRS[p.dir].x * 2;
|
||||||
|
const eyeOffY = DIRS[p.dir].y * 2;
|
||||||
|
ctx.fillRect(px + TILE / 2 - 3 + eyeOffX, py + 9 + eyeOffY, 2, 2);
|
||||||
|
ctx.fillRect(px + TILE / 2 + 1 + eyeOffX, py + 9 + eyeOffY, 2, 2);
|
||||||
|
|
||||||
|
// Active weapon indicator
|
||||||
|
if (p.activeWeapon) {
|
||||||
|
drawWeaponHeld(p.activeWeapon, px, py, p.dir);
|
||||||
|
} else if (p.weapons.length > 0) {
|
||||||
|
drawWeaponHeld(p.weapons[0], px, py, p.dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw attack animation
|
||||||
|
function renderAttackAnim() {
|
||||||
|
const p = gameState.player;
|
||||||
|
if (p.attackAnim <= 0 || !p.activeWeapon) return;
|
||||||
|
|
||||||
|
const px = p.x * TILE;
|
||||||
|
const py = p.y * TILE;
|
||||||
|
const dir = DIRS[p.dir];
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = p.attackAnim / (p.activeWeapon.animDuration || ATTACK_ANIM);
|
||||||
|
|
||||||
|
const ax = px + TILE / 2 + dir.x * TILE * 0.6;
|
||||||
|
const ay = py + TILE / 2 + dir.y * TILE * 0.6;
|
||||||
|
|
||||||
|
// Weapon-specific attack visual
|
||||||
|
if (p.activeWeapon.type === 'melee') {
|
||||||
|
ctx.fillStyle = p.activeWeapon.attackColor || COLORS.swordBlade;
|
||||||
|
if (dir.x !== 0) {
|
||||||
|
ctx.fillRect(ax - 3, ay - TILE * 0.4, 6, TILE * 0.8);
|
||||||
|
} else {
|
||||||
|
ctx.fillRect(ax - TILE * 0.4, ay - 3, TILE * 0.8, 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slash effect
|
||||||
|
ctx.strokeStyle = '#FFFFFF';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.globalAlpha = p.attackAnim / (p.activeWeapon.animDuration || ATTACK_ANIM) * 0.7;
|
||||||
|
if (dir.x !== 0) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(ax, ay - TILE * 0.5);
|
||||||
|
ctx.lineTo(ax + dir.x * 8, ay);
|
||||||
|
ctx.lineTo(ax, ay + TILE * 0.5);
|
||||||
|
ctx.stroke();
|
||||||
|
} else {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(ax - TILE * 0.5, ay);
|
||||||
|
ctx.lineTo(ax, ay + dir.y * 8);
|
||||||
|
ctx.lineTo(ax + TILE * 0.5, ay);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw messages
|
||||||
|
function renderMessages() {
|
||||||
|
if (gameState.messageTimer <= 0) return;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = Math.min(1, gameState.messageTimer / 300);
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||||
|
const textW = ctx.measureText(gameState.message).width;
|
||||||
|
const boxW = textW + 20;
|
||||||
|
const boxX = (CANVAS_W - boxW) / 2;
|
||||||
|
const boxY = CANVAS_H / 2 - 30;
|
||||||
|
ctx.fillRect(boxX, boxY, boxW, 24);
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#FFD700';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(boxX, boxY, boxW, 24);
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.msgText;
|
||||||
|
ctx.font = '14px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(gameState.message, CANVAS_W / 2, boxY + 17);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { renderFloor, renderWalls, renderTorches, renderDoors, renderSoftwalls, renderLava, renderWater, renderTraps, renderLevers, renderSwitches, renderTargets, renderRocks, renderChests, renderItems, renderExit, renderPlayer, renderAttackAnim, renderMessages };
|
||||||
|
}
|
||||||
120
src/save/save.js
Normal file
120
src/save/save.js
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === SAVE SYSTEM ===
|
||||||
|
|
||||||
|
const SAVE_KEY = 'zelda_dungeon_save_v1';
|
||||||
|
|
||||||
|
let saveData = {
|
||||||
|
completedDungeons: [],
|
||||||
|
weapons: [],
|
||||||
|
heartPieces: 0,
|
||||||
|
totalRoomsCleared: 0,
|
||||||
|
totalEnemiesDefeated: 0,
|
||||||
|
dungeonsBeaten: 0,
|
||||||
|
playTime: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadSaveData() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SAVE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
saveData = JSON.parse(raw);
|
||||||
|
console.log('Save loaded:', saveData);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load save:', e);
|
||||||
|
}
|
||||||
|
// Check for electron API (for Electron builds)
|
||||||
|
if (window.electronAPI && window.electronAPI.loadSave) {
|
||||||
|
try {
|
||||||
|
const electronSave = window.electronAPI.loadSave();
|
||||||
|
if (electronSave) saveData = electronSave;
|
||||||
|
} catch (e) { /* fallback to localStorage */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveProgress() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
|
||||||
|
console.log('Save written:', saveData);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to save:', e);
|
||||||
|
}
|
||||||
|
if (window.electronAPI && window.electronAPI.saveData) {
|
||||||
|
try {
|
||||||
|
window.electronAPI.saveData(saveData);
|
||||||
|
} catch (e) { /* fallback to localStorage */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markDungeonComplete(dungeonIdx) {
|
||||||
|
if (!saveData.completedDungeons.includes(dungeonIdx)) {
|
||||||
|
saveData.completedDungeons.push(dungeonIdx);
|
||||||
|
saveData.dungeonsBeaten++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWeapon(name) {
|
||||||
|
if (!saveData.weapons.includes(name)) {
|
||||||
|
saveData.weapons.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addHeartPiece() {
|
||||||
|
saveData.heartPieces++;
|
||||||
|
// Every 4 heart pieces = 1 new heart (2 HP)
|
||||||
|
if (saveData.heartPieces % 4 === 0) {
|
||||||
|
if (gameState && gameState.player) {
|
||||||
|
if (gameState.player.maxHp < MAX_HEARTS * 2) {
|
||||||
|
gameState.player.maxHp += 2;
|
||||||
|
gameState.player.hp = gameState.player.maxHp;
|
||||||
|
showMessage('Heart Container! Max HP increased!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addStats(rooms, enemies) {
|
||||||
|
saveData.totalRoomsCleared += rooms;
|
||||||
|
saveData.totalEnemiesDefeated += enemies;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDungeonCompleted(dungeonIdx) {
|
||||||
|
return saveData.completedDungeons.includes(dungeonIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDungeonUnlocked(dungeonIdx) {
|
||||||
|
if (dungeonIdx === 0) return true;
|
||||||
|
return saveData.completedDungeons.includes(dungeonIdx - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSaveSummary() {
|
||||||
|
return {
|
||||||
|
dungeonsCompleted: saveData.completedDungeons.length,
|
||||||
|
dungeonsBeaten: saveData.dungeonsBeaten,
|
||||||
|
weapons: saveData.weapons.length,
|
||||||
|
heartPieces: saveData.heartPieces,
|
||||||
|
roomsCleared: saveData.totalRoomsCleared,
|
||||||
|
enemiesDefeated: saveData.totalEnemiesDefeated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSave() {
|
||||||
|
saveData = {
|
||||||
|
completedDungeons: [],
|
||||||
|
weapons: [],
|
||||||
|
heartPieces: 0,
|
||||||
|
totalRoomsCleared: 0,
|
||||||
|
totalEnemiesDefeated: 0,
|
||||||
|
dungeonsBeaten: 0,
|
||||||
|
playTime: 0,
|
||||||
|
};
|
||||||
|
localStorage.removeItem(SAVE_KEY);
|
||||||
|
console.log('Save cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { saveData, loadSaveData, saveProgress, markDungeonComplete,
|
||||||
|
addWeapon, addHeartPiece, addStats, isDungeonCompleted, isDungeonUnlocked,
|
||||||
|
getSaveSummary, clearSave };
|
||||||
|
}
|
||||||
110
test-full-node.js
Normal file
110
test-full-node.js
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
// Full browser simulation test
|
||||||
|
const fs = require('fs');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
// Create browser context
|
||||||
|
const context = {
|
||||||
|
window: null,
|
||||||
|
document: {
|
||||||
|
currentScript: { src: '', getAttribute: function(a) { return null; } },
|
||||||
|
getElementsByTagName: function() { return []; },
|
||||||
|
},
|
||||||
|
location: { origin: 'http://localhost:3000' },
|
||||||
|
console: console,
|
||||||
|
Math: Math, JSON: JSON, Array: Array, Object: Object,
|
||||||
|
String: String, Number: Number, Function: Function,
|
||||||
|
Error: Error, RegExp: RegExp, Set: Set, Map: Map,
|
||||||
|
Promise: Promise, setTimeout: setTimeout,
|
||||||
|
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||||
|
};
|
||||||
|
context.window = context;
|
||||||
|
vm.createContext(context);
|
||||||
|
|
||||||
|
var results = [];
|
||||||
|
function assert(name, cond) { results.push((cond ? 'PASS' : 'FAIL') + ': ' + name); }
|
||||||
|
function setScript(src) { context.document.currentScript = { src: 'http://localhost:3000' + src, getAttribute: () => null }; }
|
||||||
|
|
||||||
|
function load(name, code) {
|
||||||
|
setScript(name);
|
||||||
|
try {
|
||||||
|
vm.runInContext(code, context);
|
||||||
|
console.log(' Loaded:', name);
|
||||||
|
} catch(e) {
|
||||||
|
console.error(' ERROR in', name + ':', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all scripts in order
|
||||||
|
load('/src/browser-polyfill.js', fs.readFileSync('src/browser-polyfill.js', 'utf8'));
|
||||||
|
load('/src/core/constants.js', fs.readFileSync('src/core/constants.js', 'utf8'));
|
||||||
|
load('/src/core/rng.js', fs.readFileSync('src/core/rng.js', 'utf8'));
|
||||||
|
load('/src/core/utils.js', fs.readFileSync('src/core/utils.js', 'utf8'));
|
||||||
|
load('/src/data/themes.js', fs.readFileSync('src/data/themes.js', 'utf8'));
|
||||||
|
load('/src/data/dungeons.js', fs.readFileSync('src/data/dungeons.js', 'utf8'));
|
||||||
|
load('/src/data/enemies.js', fs.readFileSync('src/data/enemies.js', 'utf8'));
|
||||||
|
load('/src/data/weapons.js', fs.readFileSync('src/data/weapons.js', 'utf8'));
|
||||||
|
load('/src/data/bosses.js', fs.readFileSync('src/data/bosses.js', 'utf8'));
|
||||||
|
load('/src/data/items.js', fs.readFileSync('src/data/items.js', 'utf8'));
|
||||||
|
load('/src/generator/rooms.js', fs.readFileSync('src/generator/rooms.js', 'utf8'));
|
||||||
|
load('/src/generator/puzzles.js', fs.readFileSync('src/generator/puzzles.js', 'utf8'));
|
||||||
|
load('/src/generator/placement.js', fs.readFileSync('src/generator/placement.js', 'utf8'));
|
||||||
|
load('/src/generator/dungeon.js', fs.readFileSync('src/generator/dungeon.js', 'utf8'));
|
||||||
|
|
||||||
|
// Core tests
|
||||||
|
assert('module exists', typeof context.module !== 'undefined');
|
||||||
|
assert('require is function', typeof context.require === 'function');
|
||||||
|
assert('TILE=32', context.window.TILE === 32);
|
||||||
|
assert('COLS=32', context.window.COLS === 32);
|
||||||
|
assert('ROWS=24', context.window.ROWS === 24);
|
||||||
|
assert('TILE_TYPES object', context.window.TILE_TYPES && typeof context.window.TILE_TYPES === 'object');
|
||||||
|
assert('COLORS object', context.window.COLORS && typeof context.window.COLORS === 'object');
|
||||||
|
assert('createRNG function', typeof context.createRNG === 'function');
|
||||||
|
assert('createEmptyLayout function', typeof context.createEmptyLayout === 'function');
|
||||||
|
assert('createBorderLayout function', typeof context.createBorderLayout === 'function');
|
||||||
|
assert('isPassable function', typeof context.isPassable === 'function');
|
||||||
|
|
||||||
|
// Data tests
|
||||||
|
assert('THEMES object', context.window.THEMES && typeof context.window.THEMES === 'object');
|
||||||
|
assert('DUNGEONS array', Array.isArray(context.window.DUNGEONS));
|
||||||
|
assert('DUNGEONS count=100', context.window.DUNGEONS && context.window.DUNGEONS.length === 100);
|
||||||
|
assert('ENEMY_TYPES array', Array.isArray(context.window.ENEMY_TYPES));
|
||||||
|
assert('ENEMY_TYPES count=22', context.window.ENEMY_TYPES && context.window.ENEMY_TYPES.length === 22);
|
||||||
|
assert('WEAPONS array', Array.isArray(context.window.WEAPONS));
|
||||||
|
assert('WEAPONS count=12', context.window.WEAPONS && context.window.WEAPONS.length === 12);
|
||||||
|
assert('BOSS_TYPES array', Array.isArray(context.window.BOSS_TYPES));
|
||||||
|
assert('BOSS_TYPES count=100', context.window.BOSS_TYPES && context.window.BOSS_TYPES.length === 100);
|
||||||
|
assert('ITEMS array', Array.isArray(context.window.ITEMS));
|
||||||
|
assert('getItem function', typeof context.getItem === 'function');
|
||||||
|
assert('getEnemyType function', typeof context.getEnemyType === 'function');
|
||||||
|
assert('getBossType function', typeof context.getBossType === 'function');
|
||||||
|
assert('getWeapon function', typeof context.getWeapon === 'function');
|
||||||
|
|
||||||
|
// Generator tests
|
||||||
|
assert('generateDungeon function', typeof context.generateDungeon === 'function');
|
||||||
|
assert('generateRoomLayout function', typeof context.generateRoomLayout === 'function');
|
||||||
|
assert('generatePuzzles function', typeof context.generatePuzzles === 'function');
|
||||||
|
assert('generatePlacement function', typeof context.generatePlacement === 'function');
|
||||||
|
|
||||||
|
// Require resolution tests
|
||||||
|
context.document.currentScript = { src: 'http://localhost:3000/src/generator/placement.js', getAttribute: () => null };
|
||||||
|
var roomsMod = context.require('./rooms');
|
||||||
|
assert('require ./rooms generateRoomLayout', roomsMod.generateRoomLayout !== undefined);
|
||||||
|
var enemiesMod = context.require('../data/enemies');
|
||||||
|
assert('require ../data/enemies ENEMY_TYPES', Array.isArray(enemiesMod.ENEMY_TYPES));
|
||||||
|
|
||||||
|
// Dungeon generation test
|
||||||
|
var dungeon0 = context.window.DUNGEONS[0];
|
||||||
|
var rooms = context.generateDungeon(dungeon0);
|
||||||
|
assert('generateDungeon returns rooms', Array.isArray(rooms) && rooms.length > 0);
|
||||||
|
assert('dungeon #0 rooms ~20', rooms.length >= 17 && rooms.length <= 25);
|
||||||
|
assert('entrance assigned', rooms[0].type === 'entrance');
|
||||||
|
assert('boss assigned', rooms[rooms.length - 1].type === 'boss');
|
||||||
|
assert('all rooms connected', rooms.every(function(r) { return r.connections && r.connections.length >= 1; }));
|
||||||
|
assert('rooms have tiles', rooms.every(function(r) { return Array.isArray(r.tiles); }));
|
||||||
|
|
||||||
|
console.log('\n=== Results ===');
|
||||||
|
results.forEach(r => console.log(r));
|
||||||
|
var passed = results.filter(r => r.startsWith('PASS')).length;
|
||||||
|
var failed = results.filter(r => r.startsWith('FAIL')).length;
|
||||||
|
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
117
test-polyfill-node.js
Normal file
117
test-polyfill-node.js
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
// Browser simulation test for polyfill
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
// Create a realistic browser context
|
||||||
|
const context = {
|
||||||
|
// Global browser objects
|
||||||
|
window: null, // will be set below
|
||||||
|
document: {
|
||||||
|
currentScript: { src: '', getAttribute: function(a) { return null; } },
|
||||||
|
getElementsByTagName: function() { return []; },
|
||||||
|
},
|
||||||
|
location: { origin: 'http://localhost:3000' },
|
||||||
|
console: console,
|
||||||
|
Math: Math,
|
||||||
|
JSON: JSON,
|
||||||
|
Array: Array,
|
||||||
|
Object: Object,
|
||||||
|
String: String,
|
||||||
|
Number: Number,
|
||||||
|
Function: Function,
|
||||||
|
Error: Error,
|
||||||
|
RegExp: RegExp,
|
||||||
|
Set: Set,
|
||||||
|
Map: Map,
|
||||||
|
Promise: Promise,
|
||||||
|
setTimeout: setTimeout,
|
||||||
|
setInterval: setInterval,
|
||||||
|
clearTimeout: clearTimeout,
|
||||||
|
clearInterval: clearInterval,
|
||||||
|
// Browser APIs used by the code
|
||||||
|
localStorage: {
|
||||||
|
getItem: function() { return null; },
|
||||||
|
setItem: function() {},
|
||||||
|
removeItem: function() {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// window IS the global object in browsers
|
||||||
|
context.window = context;
|
||||||
|
|
||||||
|
vm.createContext(context);
|
||||||
|
|
||||||
|
const polyfillCode = fs.readFileSync('src/browser-polyfill.js', 'utf8');
|
||||||
|
const constantsCode = fs.readFileSync('src/core/constants.js', 'utf8');
|
||||||
|
const rngCode = fs.readFileSync('src/core/rng.js', 'utf8');
|
||||||
|
const utilsCode = fs.readFileSync('src/core/utils.js', 'utf8');
|
||||||
|
|
||||||
|
var results = [];
|
||||||
|
function assert(name, condition) {
|
||||||
|
results.push((condition ? 'PASS' : 'FAIL') + ': ' + name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track which script is loading
|
||||||
|
function setScript(src) {
|
||||||
|
context.document.currentScript = { src: 'http://localhost:3000' + src, getAttribute: function(a) { return null; } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load polyfill
|
||||||
|
try {
|
||||||
|
vm.runInContext(polyfillCode, context);
|
||||||
|
assert('module exists', typeof context.module !== 'undefined');
|
||||||
|
assert('require is function', typeof context.require === 'function');
|
||||||
|
} catch(e) { console.error('Polyfill error:', e.message); }
|
||||||
|
|
||||||
|
// Load constants.js
|
||||||
|
setScript('/src/core/constants.js');
|
||||||
|
try {
|
||||||
|
vm.runInContext(constantsCode, context);
|
||||||
|
assert('TILE on window', context.window.TILE === 32);
|
||||||
|
assert('COLS on window', context.window.COLS === 32);
|
||||||
|
assert('ROWS on window', context.window.ROWS === 24);
|
||||||
|
assert('TILE_TYPES is object', context.window.TILE_TYPES && typeof context.window.TILE_TYPES === 'object');
|
||||||
|
assert('TILE_TYPES.FLOOR=0', context.window.TILE_TYPES && context.window.TILE_TYPES.FLOOR === 0);
|
||||||
|
assert('TILE_TYPES.WALL=1', context.window.TILE_TYPES && context.window.TILE_TYPES.WALL === 1);
|
||||||
|
assert('T_FLOOR', context.T_FLOOR === 0);
|
||||||
|
assert('T_WALL', context.T_WALL === 1);
|
||||||
|
assert('COLORS exists', context.COLORS && typeof context.COLORS === 'object');
|
||||||
|
assert('SCREEN_TITLE', context.SCREEN_TITLE === 'title');
|
||||||
|
} catch(e) { console.error('constants error:', e.message); }
|
||||||
|
|
||||||
|
// Load rng.js
|
||||||
|
setScript('/src/core/rng.js');
|
||||||
|
try {
|
||||||
|
vm.runInContext(rngCode, context);
|
||||||
|
assert('createRNG is function', typeof context.createRNG === 'function');
|
||||||
|
assert('mulberry32 is function', typeof context.mulberry32 === 'function');
|
||||||
|
} catch(e) { console.error('rng error:', e.message); }
|
||||||
|
|
||||||
|
// Load utils.js (uses require('./constants'))
|
||||||
|
setScript('/src/core/utils.js');
|
||||||
|
try {
|
||||||
|
vm.runInContext(utilsCode, context);
|
||||||
|
assert('createEmptyLayout is function', typeof context.createEmptyLayout === 'function');
|
||||||
|
assert('createBorderLayout is function', typeof context.createBorderLayout === 'function');
|
||||||
|
assert('isPassable is function', typeof context.isPassable === 'function');
|
||||||
|
} catch(e) { console.error('utils error:', e.message); }
|
||||||
|
|
||||||
|
// Test require resolution
|
||||||
|
try {
|
||||||
|
context.document.currentScript = { src: 'http://localhost:3000/src/test.js', getAttribute: function() { return null; } };
|
||||||
|
var consts = context.require('./core/constants');
|
||||||
|
assert('require constants TILE=32', consts.TILE === 32);
|
||||||
|
assert('require constants COLS=32', consts.COLS === 32);
|
||||||
|
assert('require constants ROWS=24', consts.ROWS === 24);
|
||||||
|
assert('require constants TILE_TYPES', consts.TILE_TYPES && consts.TILE_TYPES.FLOOR === 0);
|
||||||
|
|
||||||
|
var rng = context.require('./core/rng');
|
||||||
|
assert('require createRNG', typeof rng.createRNG === 'function');
|
||||||
|
} catch(e) { console.error('require error:', e.message); }
|
||||||
|
|
||||||
|
console.log('\n=== Results ===');
|
||||||
|
results.forEach(r => console.log(r));
|
||||||
|
var passed = results.filter(r => r.startsWith('PASS')).length;
|
||||||
|
var failed = results.filter(r => r.startsWith('FAIL')).length;
|
||||||
|
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
96
test-polyfill.html
Normal file
96
test-polyfill.html
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="UTF-8"><title>Polyfill Test</title></head>
|
||||||
|
<body>
|
||||||
|
<pre id="output"></pre>
|
||||||
|
|
||||||
|
<script src="src/browser-polyfill.js"></script>
|
||||||
|
<script src="src/core/constants.js"></script>
|
||||||
|
<script src="src/core/rng.js"></script>
|
||||||
|
<script src="src/core/utils.js"></script>
|
||||||
|
<script src="src/data/themes.js"></script>
|
||||||
|
<script src="src/data/dungeons.js"></script>
|
||||||
|
<script src="src/data/enemies.js"></script>
|
||||||
|
<script src="src/data/weapons.js"></script>
|
||||||
|
<script src="src/data/bosses.js"></script>
|
||||||
|
<script src="src/data/items.js"></script>
|
||||||
|
<script src="src/generator/dungeon.js"></script>
|
||||||
|
<script src="src/generator/rooms.js"></script>
|
||||||
|
<script src="src/generator/puzzles.js"></script>
|
||||||
|
<script src="src/generator/placement.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var output = document.getElementById('output');
|
||||||
|
var lines = [];
|
||||||
|
|
||||||
|
function assert(name, condition) {
|
||||||
|
var result = condition ? 'PASS' : 'FAIL';
|
||||||
|
lines.push(result + ': ' + name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test polyfill created globals
|
||||||
|
assert('module exists', typeof module !== 'undefined');
|
||||||
|
assert('require exists', typeof require === 'function');
|
||||||
|
|
||||||
|
// Test constants.js exposed to window
|
||||||
|
assert('TILE on window', window.TILE === 32);
|
||||||
|
assert('COLS on window', window.COLS === 32);
|
||||||
|
assert('ROWS on window', window.ROWS === 24);
|
||||||
|
assert('TILE_TYPES exists', window.TILE_TYPES && typeof window.TILE_TYPES === 'object');
|
||||||
|
assert('TILE_TYPES.FLOOR', window.TILE_TYPES && window.TILE_TYPES.FLOOR === 0);
|
||||||
|
assert('TILE_TYPES.WALL', window.TILE_TYPES && window.TILE_TYPES.WALL === 1);
|
||||||
|
|
||||||
|
// Test backward-compat constants
|
||||||
|
assert('T_FLOOR', window.T_FLOOR === 0);
|
||||||
|
assert('T_WALL', window.T_WALL === 1);
|
||||||
|
|
||||||
|
// Test rng.js exposed to window
|
||||||
|
assert('createRNG exists', typeof createRNG === 'function');
|
||||||
|
assert('mulberry32 exists', typeof mulberry32 === 'function');
|
||||||
|
|
||||||
|
// Test utils.js require worked
|
||||||
|
assert('C from utils (via require)', typeof createEmptyLayout === 'function');
|
||||||
|
assert('createBorderLayout exists', typeof createBorderLayout === 'function');
|
||||||
|
assert('isPassable exists', typeof isPassable === 'function');
|
||||||
|
|
||||||
|
// Test data files exposed to window
|
||||||
|
assert('THEMES exists', window.THEMES && typeof window.THEMES === 'object');
|
||||||
|
assert('DUNGEONS exists', Array.isArray(window.DUNGEONS));
|
||||||
|
assert('DUNGEONS.length', window.DUNGEONS && window.DUNGEONS.length === 100);
|
||||||
|
assert('ENEMY_TYPES exists', Array.isArray(window.ENEMY_TYPES));
|
||||||
|
assert('ENEMY_TYPES.length', window.ENEMY_TYPES && window.ENEMY_TYPES.length === 22);
|
||||||
|
assert('WEAPONS exists', Array.isArray(window.WEAPONS));
|
||||||
|
assert('BOSS_TYPES exists', Array.isArray(window.BOSS_TYPES));
|
||||||
|
assert('ITEMS exists', Array.isArray(window.ITEMS));
|
||||||
|
|
||||||
|
// Test generator files
|
||||||
|
assert('generateDungeon exists', typeof generateDungeon === 'function');
|
||||||
|
assert('generateRoomLayout exists', typeof generateRoomLayout === 'function');
|
||||||
|
assert('generatePuzzles exists', typeof generatePuzzles === 'function');
|
||||||
|
assert('generatePlacement exists', typeof generatePlacement === 'function');
|
||||||
|
|
||||||
|
// Test require resolution
|
||||||
|
var constantsFromRequire = require('./core/constants');
|
||||||
|
assert('require constants TILE', constantsFromRequire.TILE === 32);
|
||||||
|
assert('require constants COLS', constantsFromRequire.COLS === 32);
|
||||||
|
assert('require constants ROWS', constantsFromRequire.ROWS === 24);
|
||||||
|
|
||||||
|
var rngFromRequire = require('./core/rng');
|
||||||
|
assert('require createRNG', typeof rngFromRequire.createRNG === 'function');
|
||||||
|
|
||||||
|
// Test dungeon generation
|
||||||
|
var dungeon = window.DUNGEONS[0];
|
||||||
|
var rooms = generateDungeon(dungeon);
|
||||||
|
assert('generateDungeon returns rooms', Array.isArray(rooms) && rooms.length > 0);
|
||||||
|
assert('dungeon #0 room count ~20', rooms.length >= 18 && rooms.length <= 26);
|
||||||
|
assert('entrance room assigned', rooms[0].type === 'entrance');
|
||||||
|
assert('boss room assigned', rooms[rooms.length - 1].type === 'boss');
|
||||||
|
assert('rooms connected', rooms.every(function(r) { return r.connections && r.connections.length > 0; }));
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Total: ' + lines.filter(function(l) { return l.startsWith('PASS'); }).length + ' passed, ' + lines.filter(function(l) { return l.startsWith('FAIL'); }).length + ' failed');
|
||||||
|
|
||||||
|
output.textContent = lines.join('\n');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user