commit 35d2c51ff152269765d74b81bf9b73ef9dfafc44 Author: Jarian Date: Fri Jul 3 01:08:48 2026 +0000 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d570088 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0f9c25f --- /dev/null +++ b/AGENTS.md @@ -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()` \ No newline at end of file diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..750191e --- /dev/null +++ b/PLAN.md @@ -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) \ No newline at end of file diff --git a/generate-bosses.js b/generate-bosses.js new file mode 100644 index 0000000..2863673 --- /dev/null +++ b/generate-bosses.js @@ -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.'); \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..79035a0 --- /dev/null +++ b/index.html @@ -0,0 +1,263 @@ + + + + + + Zelda Dungeon Quest + + + +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/main.js b/main.js new file mode 100644 index 0000000..ab66280 --- /dev/null +++ b/main.js @@ -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(); +}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..aa1cf86 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c70452c --- /dev/null +++ b/package.json @@ -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" + } +} \ No newline at end of file diff --git a/preload.js b/preload.js new file mode 100644 index 0000000..544646f --- /dev/null +++ b/preload.js @@ -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'), +}); \ No newline at end of file diff --git a/renderer.js b/renderer.js new file mode 100644 index 0000000..51ae345 --- /dev/null +++ b/renderer.js @@ -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(); \ No newline at end of file diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..2d92f55 --- /dev/null +++ b/src/app.js @@ -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(); \ No newline at end of file diff --git a/src/browser-polyfill.js b/src/browser-polyfill.js new file mode 100644 index 0000000..ea2ee44 --- /dev/null +++ b/src/browser-polyfill.js @@ -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; + }; +})(); \ No newline at end of file diff --git a/src/core/constants.js b/src/core/constants.js new file mode 100644 index 0000000..0ac32aa --- /dev/null +++ b/src/core/constants.js @@ -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, + }; +} \ No newline at end of file diff --git a/src/core/rng.js b/src/core/rng.js new file mode 100644 index 0000000..72c9350 --- /dev/null +++ b/src/core/rng.js @@ -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 }; +} \ No newline at end of file diff --git a/src/core/utils.js b/src/core/utils.js new file mode 100644 index 0000000..7892009 --- /dev/null +++ b/src/core/utils.js @@ -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 }; +} \ No newline at end of file diff --git a/src/data/bosses.js b/src/data/bosses.js new file mode 100644 index 0000000..5ff8d06 --- /dev/null +++ b/src/data/bosses.js @@ -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 }; +} diff --git a/src/data/dungeons.js b/src/data/dungeons.js new file mode 100644 index 0000000..8b254bc --- /dev/null +++ b/src/data/dungeons.js @@ -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 }; +} \ No newline at end of file diff --git a/src/data/enemies.js b/src/data/enemies.js new file mode 100644 index 0000000..9d4e177 --- /dev/null +++ b/src/data/enemies.js @@ -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 }; +} \ No newline at end of file diff --git a/src/data/items.js b/src/data/items.js new file mode 100644 index 0000000..0124e62 --- /dev/null +++ b/src/data/items.js @@ -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 }; +} \ No newline at end of file diff --git a/src/data/themes.js b/src/data/themes.js new file mode 100644 index 0000000..e5dd45f --- /dev/null +++ b/src/data/themes.js @@ -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 }; +} \ No newline at end of file diff --git a/src/data/weapons.js b/src/data/weapons.js new file mode 100644 index 0000000..8ffaacb --- /dev/null +++ b/src/data/weapons.js @@ -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 }; +} \ No newline at end of file diff --git a/src/game/ai.js b/src/game/ai.js new file mode 100644 index 0000000..00a34eb --- /dev/null +++ b/src/game/ai.js @@ -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 }; +} \ No newline at end of file diff --git a/src/game/combat.js b/src/game/combat.js new file mode 100644 index 0000000..2b367b4 --- /dev/null +++ b/src/game/combat.js @@ -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 }; +} \ No newline at end of file diff --git a/src/game/items.js b/src/game/items.js new file mode 100644 index 0000000..c602e7d --- /dev/null +++ b/src/game/items.js @@ -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 }; +} \ No newline at end of file diff --git a/src/game/movement.js b/src/game/movement.js new file mode 100644 index 0000000..8ac65a1 --- /dev/null +++ b/src/game/movement.js @@ -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 }; +} \ No newline at end of file diff --git a/src/game/state.js b/src/game/state.js new file mode 100644 index 0000000..fa8a62d --- /dev/null +++ b/src/game/state.js @@ -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 }; +} \ No newline at end of file diff --git a/src/game/transitions.js b/src/game/transitions.js new file mode 100644 index 0000000..aa52efd --- /dev/null +++ b/src/game/transitions.js @@ -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 }; +} \ No newline at end of file diff --git a/src/generator/dungeon.js b/src/generator/dungeon.js new file mode 100644 index 0000000..55d8ade --- /dev/null +++ b/src/generator/dungeon.js @@ -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 }; +} \ No newline at end of file diff --git a/src/generator/index.js b/src/generator/index.js new file mode 100644 index 0000000..288ca80 --- /dev/null +++ b/src/generator/index.js @@ -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 }; +} \ No newline at end of file diff --git a/src/generator/placement.js b/src/generator/placement.js new file mode 100644 index 0000000..3c0c6fb --- /dev/null +++ b/src/generator/placement.js @@ -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, + }; +} \ No newline at end of file diff --git a/src/generator/puzzles.js b/src/generator/puzzles.js new file mode 100644 index 0000000..c2e9f71 --- /dev/null +++ b/src/generator/puzzles.js @@ -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 }; +} \ No newline at end of file diff --git a/src/generator/rooms.js b/src/generator/rooms.js new file mode 100644 index 0000000..d6588f2 --- /dev/null +++ b/src/generator/rooms.js @@ -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 }; +} \ No newline at end of file diff --git a/src/input/actions.js b/src/input/actions.js new file mode 100644 index 0000000..2621afb --- /dev/null +++ b/src/input/actions.js @@ -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: () => {} }; +} \ No newline at end of file diff --git a/src/input/keyboard.js b/src/input/keyboard.js new file mode 100644 index 0000000..501c125 --- /dev/null +++ b/src/input/keyboard.js @@ -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) }; +} \ No newline at end of file diff --git a/src/menu/dungeon-select.js b/src/menu/dungeon-select.js new file mode 100644 index 0000000..09eb472 --- /dev/null +++ b/src/menu/dungeon-select.js @@ -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 = ` +
${dungeon.name}
+
Theme: ${dungeon.theme}
+
Difficulty: ${'★'.repeat(dungeon.difficulty)}${'☆'.repeat(10 - dungeon.difficulty)}
+
Rooms: ${dungeon.roomCount}
+ ${dungeon.requiredWeapon ? `
Requires: ${dungeon.requiredWeapon}
` : ''} +
${dungeon.description || ''}
+
Double-click to enter
+ `; + } + + // 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 }; +} \ No newline at end of file diff --git a/src/menu/hud-overlay.js b/src/menu/hud-overlay.js new file mode 100644 index 0000000..197f687 --- /dev/null +++ b/src/menu/hud-overlay.js @@ -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 = '
WEAPONS
'; + if (state.player.weapons.length === 0) { + weaponsDiv.innerHTML += '
No weapons
'; + } 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 = '
ITEMS
'; + if (state.player.items.length === 0) { + itemsDiv.innerHTML += '
No items
'; + } 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 = ` +
RESOURCES
+
Arrows: ${state.player.arrows}
+
Bombs: ${state.player.bombs}
+
Keys: ${state.player.keys}
+ `; + 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 }; +} \ No newline at end of file diff --git a/src/menu/screens.js b/src/menu/screens.js new file mode 100644 index 0000000..5fc77ff --- /dev/null +++ b/src/menu/screens.js @@ -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}
+ Enemies Defeated: ${state.enemiesDefeated}
+ 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}
+ Enemies Defeated: ${state.enemiesDefeated}
+ 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
+ Total Rooms Cleared: ${saveData.totalRoomsCleared}
+ Total Enemies Slain: ${saveData.totalEnemiesDefeated}
+ Weapons Found: ${saveData.weapons.length}/${WEAPONS.length}
+ 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 }; +} \ No newline at end of file diff --git a/src/menu/title-screen.js b/src/menu/title-screen.js new file mode 100644 index 0000000..80a87e5 --- /dev/null +++ b/src/menu/title-screen.js @@ -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 = ` + Movement: Arrow Keys / WASD
+ Attack: Space / Z
+ Interact: E / Enter
+ Switch Weapon: X
+ Use Potion: R
+ Place Bomb: B
+ Menu/Inventory: I
+ Back: Escape / Q
+ Debug: 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 }; +} \ No newline at end of file diff --git a/src/rendering/draw-bosses.js b/src/rendering/draw-bosses.js new file mode 100644 index 0000000..d8933f6 --- /dev/null +++ b/src/rendering/draw-bosses.js @@ -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 }; +} \ No newline at end of file diff --git a/src/rendering/draw-enemies.js b/src/rendering/draw-enemies.js new file mode 100644 index 0000000..b5ba176 --- /dev/null +++ b/src/rendering/draw-enemies.js @@ -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 }; +} \ No newline at end of file diff --git a/src/rendering/draw-weapons.js b/src/rendering/draw-weapons.js new file mode 100644 index 0000000..c608a1c --- /dev/null +++ b/src/rendering/draw-weapons.js @@ -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 }; +} \ No newline at end of file diff --git a/src/rendering/effects.js b/src/rendering/effects.js new file mode 100644 index 0000000..2965574 --- /dev/null +++ b/src/rendering/effects.js @@ -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 }; +} \ No newline at end of file diff --git a/src/rendering/hud.js b/src/rendering/hud.js new file mode 100644 index 0000000..ae48998 --- /dev/null +++ b/src/rendering/hud.js @@ -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 }; +} \ No newline at end of file diff --git a/src/rendering/renderer.js b/src/rendering/renderer.js new file mode 100644 index 0000000..9dd5a1c --- /dev/null +++ b/src/rendering/renderer.js @@ -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 }; +} \ No newline at end of file diff --git a/src/save/save.js b/src/save/save.js new file mode 100644 index 0000000..c2e8542 --- /dev/null +++ b/src/save/save.js @@ -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 }; +} \ No newline at end of file diff --git a/test-full-node.js b/test-full-node.js new file mode 100644 index 0000000..1435f02 --- /dev/null +++ b/test-full-node.js @@ -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); \ No newline at end of file diff --git a/test-polyfill-node.js b/test-polyfill-node.js new file mode 100644 index 0000000..97c97ae --- /dev/null +++ b/test-polyfill-node.js @@ -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); \ No newline at end of file diff --git a/test-polyfill.html b/test-polyfill.html new file mode 100644 index 0000000..69f80b8 --- /dev/null +++ b/test-polyfill.html @@ -0,0 +1,96 @@ + + +Polyfill Test + +

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file