From fc1fa2d51e0d7d05bf27b7ab8eff18ff4fedb03a Mon Sep 17 00:00:00 2001 From: deepseek Date: Sun, 23 Aug 2026 06:59:36 +0000 Subject: [PATCH] =?UTF-8?q?Diablo2D=20=E2=80=94=20Shadows=20of=20Tristram:?= =?UTF-8?q?=20complete=20browser=20ARPG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Isometric canvas renderer (depth-sorted, FOV/fog, additive lighting) - 3 classes x 20 skills, 4 acts x 4 floors + boss lairs, torment I-X - Diablo-style loot: rarities, affix tiers, 14 legendaries, vendor, stash - Rogue camp with 6 NPCs: Charsi/Akara/Kashya/Cain/Gheed/storage - NPC quest chain (accept -> hunt -> turn in) with rewards & gating - Procedural WebAudio SFX + generative music, EN/VI localization - Saves, settings, waypoints, hardcore mode, PWA manifest - 93-assertion headless suite + browser E2E via CDP --- .gitignore | 5 + LICENSE | 27 + README.md | 131 +++++ css/style.css | 684 ++++++++++++++++++++++ icons/icon-180.png | Bin 0 -> 24518 bytes icons/icon-192.png | Bin 0 -> 27494 bytes icons/icon-512.png | Bin 0 -> 142090 bytes index.html | 128 ++++ js/core/audio.js | 438 ++++++++++++++ js/core/i18n.js | 303 ++++++++++ js/core/input.js | 124 ++++ js/core/save.js | 101 ++++ js/core/util.js | 134 +++++ js/data/balance.js | 142 +++++ js/data/items.js | 410 +++++++++++++ js/data/monsters.js | 258 ++++++++ js/data/skills.js | 227 ++++++++ js/game/ai.js | 609 +++++++++++++++++++ js/game/combat.js | 841 ++++++++++++++++++++++++++ js/game/entities.js | 178 ++++++ js/game/fov.js | 45 ++ js/game/game.js | 1081 ++++++++++++++++++++++++++++++++++ js/game/loot.js | 149 +++++ js/game/path.js | 128 ++++ js/game/player.js | 327 +++++++++++ js/game/world.js | 513 ++++++++++++++++ js/main.js | 184 ++++++ js/render/render.js | 1102 +++++++++++++++++++++++++++++++++++ js/render/sprites.js | 769 ++++++++++++++++++++++++ js/ui/hud.js | 284 +++++++++ js/ui/panels.js | 864 +++++++++++++++++++++++++++ js/ui/screens.js | 360 ++++++++++++ manifest.webmanifest | 14 + server.js | 37 ++ tools/browser_drive.html | 246 ++++++++ tools/build_zip.mjs | 76 +++ tools/cdp_mouse_test.mjs | 82 +++ tools/cdp_move_accuracy.mjs | 77 +++ tools/cdp_probe.mjs | 62 ++ tools/cdp_run.mjs | 46 ++ tools/headless_test.js | 477 +++++++++++++++ tools/make_icons.mjs | 121 ++++ 42 files changed, 11784 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 css/style.css create mode 100644 icons/icon-180.png create mode 100644 icons/icon-192.png create mode 100644 icons/icon-512.png create mode 100644 index.html create mode 100644 js/core/audio.js create mode 100644 js/core/i18n.js create mode 100644 js/core/input.js create mode 100644 js/core/save.js create mode 100644 js/core/util.js create mode 100644 js/data/balance.js create mode 100644 js/data/items.js create mode 100644 js/data/monsters.js create mode 100644 js/data/skills.js create mode 100644 js/game/ai.js create mode 100644 js/game/combat.js create mode 100644 js/game/entities.js create mode 100644 js/game/fov.js create mode 100644 js/game/game.js create mode 100644 js/game/loot.js create mode 100644 js/game/path.js create mode 100644 js/game/player.js create mode 100644 js/game/world.js create mode 100644 js/main.js create mode 100644 js/render/render.js create mode 100644 js/render/sprites.js create mode 100644 js/ui/hud.js create mode 100644 js/ui/panels.js create mode 100644 js/ui/screens.js create mode 100644 manifest.webmanifest create mode 100644 server.js create mode 100644 tools/browser_drive.html create mode 100644 tools/build_zip.mjs create mode 100644 tools/cdp_mouse_test.mjs create mode 100644 tools/cdp_move_accuracy.mjs create mode 100644 tools/cdp_probe.mjs create mode 100644 tools/cdp_run.mjs create mode 100644 tools/headless_test.js create mode 100644 tools/make_icons.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5b39a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# build artifacts +diablo2d-itch.zip +*.log +node_modules/ +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2ba6b7e --- /dev/null +++ b/LICENSE @@ -0,0 +1,27 @@ +MIT License + +Copyright (c) 2026 Diablo2D contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +--- + +All graphics and audio are generated procedurally at runtime by the code in +this repository. No third-party assets are included. "Diablo" is a trademark +of Blizzard Entertainment; this project is an unaffiliated homage. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a278883 --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# Diablo2D — Shadows of Tristram + +A dark, isometric **action-RPG that runs entirely in your browser** — no build step, +no server, no assets to download. Every tile, creature, spell effect, sound and +music track is generated procedurally in code. Inspired by classic Diablo and +modern browser ARPGs like Sephiria. + +![class](https://img.shields.io/badge/classes-3-gold) ![acts](https://img.shields.io/badge/acts-4-red) ![offline](https://img.shields.io/badge/offline-100%25-green) + +--- + +## Play + +**Instant:** serve the folder with any static file server and open it. + +```bash +cd diablo2d +python3 -m http.server 8080 # or: node server.js 8080 +# open http://localhost:8080 +``` + +It also runs straight from `file://` (double-click `index.html`) and can be +zipped as-is for **itch.io** (see *Publishing* below). + +> Progress autosaves to `localStorage` every 90 seconds, on floor change, +> and when the tab closes. Export/import your hero as a code from Settings. + +## Features + +- **3 classes** — Crusader, Ranger, Sorceress — 20 hand-tuned skills across + 3 talent branches each (actives + passives, 6-slot action bar). +- **4 acts × 4 floors** of procedurally generated dungeons: + room-and-corridor crypts, cellular cave systems, and a boss lair per act + (The Flesh Butcher, The Bone King, Aranea the Spider Queen, Malgor the + Lord of Terror) with multi-phase scripted attacks. +- **Diablo-style loot**: 40+ item bases, 4 rarities, ~30 affixes with + item-level tiers, 14 build-defining legendaries (Cleave Echo, Corpse Boom, + Voidstep…), vendor with rerolling stock, gamble-free sell/buy economy, + and a shared stash. +- **Elite monsters** with 10 random affixes (Frozen, Jailer, Necrotic…), + champion packs, and torment levels I–X for endless scaling after victory. +- **Full RPG systems**: strength/dex/vit/energy allocation, crit & crit damage, + elemental damage & resists, cooldown reduction, magic/gold find, shrines, + destructibles, health/mana potions on a potion belt. +- **Waypoint network** between acts, quest tracker, NPC dialogs + (blacksmith, healer, stash keeper), death penalty & hardcore mode. +- **Procedural audio**: WebAudio-synthesised SFX for every action and a + generative music sequencer with per-theme moods (town, crypt, cave, hell, boss). +- **English + Tiếng Việt**, damage numbers, screen shake, and label options. + +## Controls + +| Input | Action | +|---|---| +| **LMB** (hold) | Move / attack-move onto enemies / interact | +| **RMB, 1–3** | Cast bound skills | +| **Q / E** | Health / mana potion | +| **F or Space** | Interact — stairs, chests, shrines, NPCs | +| **Alt** | Toggle item labels | +| **I / C / T / J / M** | Inventory · Character · Skills · Quests · Map | +| **Esc** | Close panels / pause | + +## Tech notes + +- Pure vanilla ES3-style classic scripts (works from `file://`), global + `window.D2` namespace, ~10k lines across `js/core`, `js/data`, `js/game`, + `js/render`, `js/ui`. +- HTML5 Canvas isometric renderer (64×32 diamond tiles, painter's-algorithm + depth sort, raycast FOV + fog, additive lighting with torch flicker, + screen-space damage numbers). +- A\* pathfinding with per-frame budget, spatial-hash collision queries, + Bresenham line-of-sight, cellular-automata + room generator with + connectivity guarantees (stairs are never sealed off). +- WebAudio: zero audio files — every sound is a recipe of oscillators, + noise buffers and envelopes; music is a live sequencer per mood. +- i18n dictionary (EN/VI), settings + save slots in `localStorage` + under `d2d.*`, export/import via base64 code. + +## Repository layout + +``` +index.html shell + HUD DOM + script load order +css/style.css full gothic UI theme +js/core/ util, i18n, save, input, audio +js/data/ balance, items, monsters, skills (all data-driven) +js/game/ world gen, entities, ai, combat, loot, player, orchestrator +js/render/ procedural sprites, isometric renderer, minimap +js/ui/ hud, panels, screens +js/main.js bootstrap + game loop +tools/ headless_test.js (81 assertions), browser E2E driver, + CDP runner, icon generator, zip packer +``` + +## Development + +```bash +node tools/headless_test.js # 81-assertion simulation suite (no browser) +node tools/make_icons.mjs # regenerate PWA icons +node tools/build_zip.mjs # produce diablo2d-itch.zip +``` + +The headless suite boots the entire game in Node (stubbed DOM/canvas), +generates every floor, casts every skill of every class, kills every boss, +exercises economy/save-load, and soaks 3 600 combat frames. + +### Browser smoke test (optional, needs the Playwright chromium shell) + +```bash +python3 -m http.server 8942 & +chromium-headless-shell --no-sandbox --remote-debugging-port=9344 \ + "http://127.0.0.1:8942/tools/browser_drive.html?phase=full&mute=1" & +node tools/cdp_run.mjs 9344 150 # polls the E2E log to completion +``` + +## Publishing + +### itch.io +1. `node tools/build_zip.mjs` → `diablo2d-itch.zip` +2. Create a new project → kind of project: **HTML** → upload the zip. +3. Tick *This file will be played in the browser*, set viewport + **1280×720** or larger, enable fullscreen button. Done. + +### Any static host (GitHub Pages, Netlify, S3…) +Upload the folder as-is. No build, no server code, no CORS issues — +everything is relative-path static files. + +## License + +Code and generated art: MIT (see `LICENSE`). +"Diablo" is a trademark of Blizzard Entertainment — this is an unaffiliated +fan-style homage; no Blizzard assets are used. diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..3bdb212 --- /dev/null +++ b/css/style.css @@ -0,0 +1,684 @@ +/* ============================================================ + Diablo2D — style.css : gothic ARPG theme + ============================================================ */ + +:root { + --bg: #070605; + --panel: rgba(24, 18, 12, 0.96); + --panel-soft: rgba(32, 24, 16, 0.92); + --border: #5a4426; + --border-hi: #8a6c3c; + --gold: #c8a35a; + --gold-hi: #efd28e; + --text: #d8cbb3; + --text-dim: #93876f; + --red: #a32c22; + --red-hi: #d84b37; + --blue-orb: #2456a8; + + --rarity-common: #c8c8c8; + --rarity-magic: #7c9cf5; + --rarity-rare: #f2e34c; + --rarity-legendary: #ef8f34; + --rarity-quest: #4ade80; + + --slot-bg: rgba(10, 8, 6, 0.85); + --font-serif: Georgia, 'Times New Roman', serif; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html, body { + width: 100%; height: 100%; + overflow: hidden; + background: var(--bg); + font-family: var(--font-serif); + color: var(--text); + -webkit-font-smoothing: antialiased; +} + +#app { position: fixed; inset: 0; } + +canvas#game-canvas { + position: absolute; inset: 0; + width: 100%; height: 100%; + display: block; + cursor: crosshair; + image-rendering: auto; +} + +.hidden { display: none !important; } + +/* ============ shared gothic chrome ============ */ + +.panel-gothic { + background: linear-gradient(160deg, rgba(36,27,18,.97), rgba(20,14,9,.97)); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 0 0 1px rgba(0,0,0,.8), 0 12px 40px rgba(0,0,0,.75), + inset 0 0 60px rgba(0,0,0,.45); +} + +.panel-title { + font-size: 15px; letter-spacing: 3px; text-transform: uppercase; + color: var(--gold); text-align: center; + padding: 10px 0 8px; + border-bottom: 1px solid var(--border); + text-shadow: 0 0 12px rgba(200,163,90,.35); + user-select: none; +} + +.btn { + display: inline-block; + font-family: var(--font-serif); + font-size: 15px; letter-spacing: 1px; + color: var(--text); + background: linear-gradient(180deg, #33261a, #201710); + border: 1px solid var(--border); + border-radius: 4px; + padding: 8px 22px; + cursor: pointer; + transition: all .12s ease; + text-shadow: 0 1px 2px #000; + user-select: none; +} +.btn:hover { color: var(--gold-hi); border-color: var(--border-hi); box-shadow: 0 0 14px rgba(200,163,90,.25); } +.btn:active { transform: translateY(1px); } +.btn:disabled { opacity: .4; cursor: default; box-shadow: none; } +.btn-primary { color: var(--gold-hi); border-color: var(--gold); background: linear-gradient(180deg, #4a3418, #2a1c0e); } +.btn-danger { color: var(--red-hi); } +.btn-danger:hover { box-shadow: 0 0 14px rgba(216,75,55,.35); } + +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: #14100b; } +::-webkit-scrollbar-thumb { background: #3d2f1e; border-radius: 5px; border: 1px solid #000; } +::-webkit-scrollbar-thumb:hover { background: var(--border-hi); } + +/* ============ screens ============ */ + +.screen { + position: absolute; inset: 0; + display: flex; align-items: center; justify-content: center; + z-index: 50; + background: + radial-gradient(ellipse at 50% 30%, rgba(120,40,20,.14), transparent 55%), + radial-gradient(ellipse at center, rgba(10,7,5,.55), #050403 78%); +} + +.game-logo { + font-size: clamp(48px, 9vw, 110px); + letter-spacing: 12px; + color: #b83a26; + text-shadow: 0 0 30px rgba(190,60,30,.55), 0 2px 0 #300b06, 0 6px 24px #000; + font-weight: bold; +} +.game-logo span { color: var(--gold); font-size: .45em; vertical-align: super; letter-spacing: 4px; } + +.loading-inner { text-align: center; } +.loading-sub { + margin-top: 6px; + letter-spacing: 6px; text-transform: uppercase; + color: var(--text-dim); font-size: 14px; +} +.loading-bar { + width: 320px; height: 6px; margin: 34px auto 12px; + background: #171008; border: 1px solid var(--border); border-radius: 3px; + overflow: hidden; +} +#loading-progress { + height: 100%; width: 0%; + background: linear-gradient(90deg, var(--red), #e0763a); + box-shadow: 0 0 12px rgba(224,118,58,.8); + transition: width .2s ease; +} +#loading-tip { color: var(--text-dim); font-style: italic; font-size: 13px; min-height: 18px; } + +/* title menu */ +.title-menu { display: flex; flex-direction: column; gap: 12px; align-items: center; margin-top: 42px; } +.title-menu .btn { min-width: 280px; font-size: 17px; letter-spacing: 2px; padding: 11px 0; } +.title-savecard { + margin-top: 26px; padding: 10px 26px; text-align: center; + color: var(--text-dim); font-size: 13px; + border-top: 1px solid rgba(90,68,38,.35); +} +.title-savecard b { color: var(--gold); letter-spacing: 1px; } +.title-footer { + position: absolute; bottom: 18px; width: 100%; + text-align: center; color: #565043; font-size: 12px; letter-spacing: 1px; +} + +/* class select */ +.class-grid { display: flex; gap: 26px; padding: 30px; } +.class-card { + width: 250px; padding: 18px 16px 20px; cursor: pointer; + background: linear-gradient(170deg, rgba(38,28,19,.95), rgba(17,12,8,.95)); + border: 1px solid var(--border); border-radius: 8px; + text-align: center; + transition: transform .15s ease, border-color .15s ease, box-shadow .15s ease; +} +.class-card:hover { transform: translateY(-6px); border-color: var(--border-hi); box-shadow: 0 14px 34px rgba(0,0,0,.6); } +.class-card.selected { border-color: var(--gold); box-shadow: 0 0 26px rgba(200,163,90,.35); } +.class-card canvas { width: 150px; height: 150px; margin-bottom: 8px; filter: drop-shadow(0 8px 14px rgba(0,0,0,.7)); } +.class-card h3 { color: var(--gold-hi); letter-spacing: 2px; font-size: 18px; margin-bottom: 8px; } +.class-card p { font-size: 13px; line-height: 1.5; color: var(--text-dim); min-height: 78px; } +.class-card .class-stats { margin-top: 10px; font-size: 12px; color: var(--text); } +.class-card .class-stats span { display: inline-block; margin: 2px 6px; opacity: .85; } + +.screen-center-col { display: flex; flex-direction: column; align-items: center; gap: 14px; max-width: 900px; padding: 20px; } + +/* settings rows */ +.settings-panel { width: min(560px, 92vw); padding-bottom: 18px; } +.settings-body { padding: 18px 26px 6px; display: flex; flex-direction: column; gap: 13px; } +.setting-row { display: flex; align-items: center; justify-content: space-between; gap: 18px; font-size: 15px; } +.setting-row label { color: var(--text); letter-spacing: .5px; } +input[type="range"] { + -webkit-appearance: none; appearance: none; + width: 210px; height: 6px; border-radius: 3px; + background: linear-gradient(90deg, #4a3418, #1c140c); + outline: none; border: 1px solid var(--border); cursor: pointer; +} +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; appearance: none; + width: 16px; height: 16px; border-radius: 50%; + background: radial-gradient(circle at 35% 35%, var(--gold-hi), #7a5a24); + border: 1px solid #000; + box-shadow: 0 0 8px rgba(239,210,142,.5); +} +.toggle-switch { + position: relative; width: 52px; height: 24px; + background: #171008; border: 1px solid var(--border); border-radius: 12px; + cursor: pointer; transition: background .15s; +} +.toggle-switch::after { + content: ''; position: absolute; top: 2px; left: 2px; + width: 18px; height: 18px; border-radius: 50%; + background: radial-gradient(circle at 35% 35%, #caa, #63302a); + transition: left .15s ease, background .15s; +} +.toggle-switch.on { background: #3d2a12; } +.toggle-switch.on::after { left: 30px; background: radial-gradient(circle at 35% 35%, var(--gold-hi), #7a5a24); box-shadow: 0 0 10px rgba(239,210,142,.6); } +select.gothiselect { + font-family: var(--font-serif); font-size: 14px; + background: #201710; color: var(--text); + border: 1px solid var(--border); border-radius: 4px; padding: 5px 10px; +} +textarea.savecode { + width: 100%; height: 74px; resize: none; + background: #120d08; color: var(--text-dim); + border: 1px solid var(--border); border-radius: 4px; + font-family: monospace; font-size: 10px; padding: 6px; +} + +/* help screen */ +.help-panel { width: min(720px, 94vw); padding-bottom: 16px; } +.help-body { padding: 16px 30px 6px; font-size: 14px; line-height: 1.75; color: var(--text); } +.help-body h4 { color: var(--gold); margin: 10px 0 4px; letter-spacing: 1px; } +.help-keys { display: grid; grid-template-columns: auto 1fr; gap: 4px 16px; margin-top: 6px; } +.key-cap { + display: inline-block; min-width: 26px; text-align: center; + background: #241a10; border: 1px solid var(--border-hi); border-bottom-width: 3px; + border-radius: 4px; padding: 1px 7px; font-size: 12px; color: var(--gold-hi); +} + +/* death & victory */ +.death-title { + font-size: clamp(40px, 7vw, 84px); letter-spacing: 14px; + color: var(--red-hi); + text-shadow: 0 0 40px rgba(180,30,20,.7), 0 3px 0 #1a0503; + animation: deathPulse 2.6s ease-in-out infinite; +} +@keyframes deathPulse { 50% { text-shadow: 0 0 64px rgba(220,50,30,.9), 0 3px 0 #1a0503; } } +.victory-title { + font-size: clamp(30px, 5vw, 60px); letter-spacing: 8px; + color: var(--gold-hi); + text-shadow: 0 0 44px rgba(239,210,142,.65), 0 3px 0 #241503; +} +.screen-subtitle { margin-top: 14px; color: var(--text-dim); font-style: italic; font-size: 16px; text-align: center; max-width: 520px; } +.victory-stats { margin-top: 22px; font-size: 14px; color: var(--text); text-align: center; line-height: 1.9; } +.victory-stats b { color: var(--gold); } + +/* ============ pause overlay ============ */ +#pause-overlay { + position: absolute; inset: 0; z-index: 40; + background: rgba(4, 3, 2, .72); + backdrop-filter: blur(2px) brightness(.7); + display: flex; align-items: center; justify-content: center; +} +.pause-menu { display: flex; flex-direction: column; gap: 10px; padding: 26px 34px; min-width: 320px; } +.pause-menu h2 { text-align: center; color: var(--gold); letter-spacing: 4px; margin-bottom: 10px; font-weight: normal; } +.pause-menu .btn { width: 100%; } + +/* ============ HUD ============ */ + +#hud { position: absolute; inset: 0; z-index: 20; pointer-events: none; } +#hud > * { pointer-events: auto; } + +.hud-corner { + position: absolute; top: 12px; left: 12px; + max-width: 330px; font-size: 13px; line-height: 1.5; + color: var(--text); text-shadow: 0 1px 3px #000, 0 0 8px #000; + pointer-events: none !important; +} +#quest-tracker .quest-line { margin-bottom: 6px; } +#quest-tracker .quest-done { color: #7fc97f; text-decoration: line-through; opacity: .8; } +#quest-tracker .quest-header { color: var(--gold); letter-spacing: 1px; font-size: 12px; text-transform: uppercase; } + +.hud-panel { + background: var(--panel-soft); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 6px 20px rgba(0,0,0,.55), inset 0 0 24px rgba(0,0,0,.4); +} + +#minimap-box { position: absolute; top: 12px; right: 12px; padding: 5px; } +#minimap { display: block; border-radius: 3px; } +#minimap-label { + text-align: center; font-size: 11px; letter-spacing: 1px; + color: var(--gold); padding-top: 4px; text-transform: uppercase; +} + +#buff-bar { + position: absolute; top: 12px; left: 50%; transform: translateX(-50%); + display: flex; gap: 6px; pointer-events: none; +} +.buff-icon { + width: 40px; height: 40px; border-radius: 6px; + border: 1px solid var(--border-hi); + background: var(--panel-soft); + display: flex; align-items: center; justify-content: center; + font-size: 19px; position: relative; overflow: hidden; + box-shadow: 0 0 12px rgba(0,0,0,.6); +} +.buff-icon .buff-timer { + position: absolute; bottom: 0; left: 0; right: 0; + font-size: 9px; text-align: center; color: var(--gold-hi); + background: rgba(0,0,0,.55); padding-bottom: 1px; +} + +/* bottom hud: orbs + hotbar */ +#bottom-hud { + position: absolute; left: 0; right: 0; bottom: 0; + display: flex; align-items: flex-end; justify-content: space-between; + padding: 0 18px 10px; + pointer-events: none; +} +#bottom-hud > * { pointer-events: auto; } + +.orb { + position: relative; + width: 108px; height: 108px; + border-radius: 50%; + border: 3px solid #2c2115; + box-shadow: 0 0 0 2px #000, inset 0 0 26px rgba(0,0,0,.9), 0 8px 24px rgba(0,0,0,.7); + overflow: hidden; + background: radial-gradient(circle at 50% 42%, #1c1611, #0a0705 72%); +} +.orb-fill { position: absolute; left: 0; right: 0; bottom: 0; transition: height .18s ease; } +.orb-fill.life { + background: linear-gradient(to top, #7e1408, #c93a20 62%, #e86a48); + box-shadow: 0 0 22px rgba(220,70,40,.55); +} +.orb-fill.mana { + background: linear-gradient(to top, #12306e, #2f66c4 62%, #5b93ea); + box-shadow: 0 0 22px rgba(70,120,230,.55); +} +.orb-fill::before { + content: ''; position: absolute; top: -4px; left: -6%; right: -6%; height: 10px; + border-radius: 50%; + background: rgba(255,255,255,.22); + filter: blur(1px); +} +.orb-gloss { + position: absolute; top: 8%; left: 14%; width: 46%; height: 30%; + border-radius: 50%; + background: radial-gradient(ellipse at 40% 30%, rgba(255,255,255,.28), transparent 65%); + pointer-events: none; +} +.orb-text { + position: absolute; inset: 0; + display: flex; align-items: center; justify-content: center; + font-size: 15px; color: #fff; + text-shadow: 0 1px 3px #000, 0 0 10px #000; + pointer-events: none; +} + +#center-hud { + flex: 0 1 560px; + display: flex; flex-direction: column; align-items: center; gap: 7px; + margin-bottom: 4px; +} + +#xp-bar-wrap { + position: relative; width: 100%; height: 13px; + background: #120d08; border: 1px solid var(--border); border-radius: 7px; + overflow: hidden; +} +#xp-bar { + height: 100%; width: 0%; + background: linear-gradient(90deg, #6d5116, #c8a35a); + box-shadow: 0 0 10px rgba(200,163,90,.5); + transition: width .25s ease; +} +#xp-text { + position: absolute; inset: 0; + display: flex; align-items: center; justify-content: center; + font-size: 9.5px; color: var(--gold-hi); letter-spacing: .5px; + text-shadow: 0 1px 2px #000; +} + +#hotbar { + display: flex; align-items: center; gap: 8px; + background: var(--panel-soft); + border: 1px solid var(--border); border-radius: 8px; + padding: 6px 10px; + box-shadow: 0 8px 22px rgba(0,0,0,.6), inset 0 0 20px rgba(0,0,0,.45); +} +.hotbar-group { display: flex; gap: 6px; } +.hotbar-sep { width: 1px; height: 40px; background: var(--border); opacity: .7; } +.hotbar-group.small { gap: 4px; } + +.hb-slot { + position: relative; width: 46px; height: 46px; + background: var(--slot-bg); + border: 1px solid #4a3a22; border-radius: 6px; + display: flex; align-items: center; justify-content: center; + cursor: pointer; user-select: none; +} +.hb-slot.small { width: 34px; height: 34px; } +.hb-slot:hover { border-color: var(--border-hi); box-shadow: 0 0 10px rgba(200,163,90,.3); } +.hb-slot canvas { width: 36px; height: 36px; } +.hb-slot .keybind { + position: absolute; top: -1px; left: 2px; + font-size: 9px; color: var(--gold); text-shadow: 0 1px 2px #000; +} +.hb-slot .cd-overlay { + position: absolute; inset: 0; border-radius: 6px; + background: rgba(0,0,0,.72); + pointer-events: none; +} +.hb-slot .mana-star { + position: absolute; right: 2px; bottom: 1px; + font-size: 9px; color: #6f9fe8; text-shadow: 0 1px 2px #000; +} +.hb-slot.empty-slot { opacity: .45; } +.hb-slot.potion-count { + position: absolute; right: 2px; bottom: 1px; + font-size: 10px; color: #fff; text-shadow: 0 1px 2px #000; +} + +#level-pill { + position: absolute; left: 50%; transform: translateX(-50%); + bottom: 96px; + background: var(--panel-soft); border: 1px solid var(--border); + border-radius: 14px; padding: 1px 14px; + font-size: 12px; color: var(--gold-hi); letter-spacing: 1px; + box-shadow: 0 4px 12px rgba(0,0,0,.5); +} +#center-hud { position: relative; } + +/* boss bar */ +#boss-bar-wrap { + position: absolute; top: 64px; left: 50%; transform: translateX(-50%); + width: min(520px, 60vw); text-align: center; +} +#boss-name { + font-size: 15px; letter-spacing: 4px; color: #e8b0a0; + text-transform: uppercase; text-shadow: 0 0 14px rgba(200,50,30,.8), 0 2px 3px #000; + margin-bottom: 4px; +} +#boss-bar { + height: 12px; background: rgba(10,6,4,.85); + border: 1px solid #6e2a1c; border-radius: 6px; overflow: hidden; + box-shadow: 0 0 18px rgba(120,20,10,.5); +} +#boss-fill { + height: 100%; width: 100%; + background: linear-gradient(90deg, #6e120a, #c22a18 70%, #e05540); + box-shadow: 0 0 12px rgba(220,60,30,.7); + transition: width .15s ease; +} + +/* toasts */ +#toasts { + position: absolute; bottom: 150px; left: 50%; transform: translateX(-50%); + display: flex; flex-direction: column-reverse; align-items: center; gap: 5px; + pointer-events: none; +} +.toast { + background: rgba(16,11,7,.92); + border: 1px solid var(--border); + border-radius: 5px; + color: var(--text); + font-size: 13.5px; + padding: 6px 18px; + letter-spacing: .5px; + text-shadow: 0 1px 2px #000; + animation: toastIn .18s ease; + box-shadow: 0 6px 16px rgba(0,0,0,.5); +} +.toast.gold { color: var(--gold-hi); } +.toast.bad { color: var(--red-hi); } +.toast.fadeout { opacity: 0; transition: opacity .5s ease; } +@keyframes toastIn { from { opacity: 0; transform: translateY(8px); } } + +/* ============ floating panels ============ */ + +#panels { position: absolute; inset: 0; z-index: 30; pointer-events: none; } +.fpanel { + position: absolute; + pointer-events: auto; + min-width: 240px; +} +.fpanel-head { + display: flex; align-items: center; justify-content: space-between; + padding: 9px 14px; +} +.fpanel-head .panel-title { border: none; padding: 0; flex: 1; } +.fpanel-close { + width: 22px; height: 22px; line-height: 20px; + text-align: center; cursor: pointer; + color: var(--text-dim); font-size: 14px; + border: 1px solid transparent; border-radius: 4px; + user-select: none; +} +.fpanel-close:hover { color: var(--red-hi); border-color: var(--border); } +.fpanel-body { padding: 10px 14px 14px; } + +#panel-inventory { right: 16px; top: 190px; width: 292px; } +#panel-character { left: 16px; top: 190px; width: 308px; } +#panel-skills { left: 16px; top: 130px; width: 560px; max-height: calc(100vh - 260px); } +#panel-stash { left: 50%; transform: translateX(-50%); top: 120px; width: 340px; } +#panel-vendor { left: 50%; transform: translateX(-50%); top: 110px; width: 640px; } +#panel-dialog { left: 50%; transform: translateX(-50%); bottom: 170px; width: min(560px, 90vw); } +#panel-quests { right: 16px; top: 130px; width: 330px; } +#panel-worldmap { left: 50%; transform: translateX(-50%); top: 50%; transform: translate(-50%, -50%); width: min(680px, 94vw); } + +.inv-grid { + display: grid; grid-template-columns: repeat(5, 48px); gap: 5px; + justify-content: center; +} +.item-slot { + position: relative; width: 48px; height: 48px; + background: var(--slot-bg); + border: 1px solid #43351f; border-radius: 5px; + cursor: pointer; +} +.item-slot:hover { border-color: var(--border-hi); } +.item-slot.rarity-glow-magic { border-color: var(--rarity-magic); } +.item-slot.rarity-glow-rare { border-color: var(--rarity-rare); } +.item-slot.rarity-glow-legendary { border-color: var(--rarity-legendary); box-shadow: 0 0 10px rgba(239,143,52,.5); } +.item-slot canvas { width: 44px; height: 44px; display: block; margin: 1px auto; } +.item-slot .qty-badge { + position: absolute; right: 2px; bottom: 0; + font-size: 10px; color: #fff; text-shadow: 0 1px 2px #000; +} + +.equip-grid { + display: grid; + grid-template-columns: repeat(3, 54px); + gap: 6px; justify-content: center; + margin-bottom: 12px; +} +.equip-slot { + position: relative; width: 54px; height: 54px; + background: var(--slot-bg); + border: 1px dashed #4a3a22; border-radius: 6px; + display: flex; align-items: center; justify-content: center; + cursor: pointer; +} +.equip-slot:hover { border-color: var(--border-hi); } +.equip-slot .slot-label { + font-size: 9px; color: #6b5c43; text-transform: uppercase; letter-spacing: 1px; + text-align: center; +} +.equip-slot canvas { width: 50px; height: 50px; position: absolute; } + +.stat-list { display: flex; flex-direction: column; gap: 3px; font-size: 13px; } +.stat-row { display: flex; justify-content: space-between; padding: 1px 4px; border-radius: 3px; } +.stat-row:nth-child(odd) { background: rgba(255,255,255,.03); } +.stat-row .stat-val { color: var(--gold-hi); } +.stat-row.allocatable .stat-name { cursor: pointer; } +.stat-row .plus-btn { + width: 18px; height: 18px; line-height: 15px; text-align: center; + border: 1px solid var(--border-hi); border-radius: 3px; + color: var(--gold); cursor: pointer; font-size: 13px; + margin-left: 6px; user-select: none; +} +.stat-row .plus-btn:hover { background: #3d2a12; color: var(--gold-hi); } + +.resist-strip { display: flex; justify-content: space-around; margin-top: 8px; font-size: 11px; } +.resist-strip span { color: var(--text-dim); } +.resist-strip b { color: var(--text); } + +/* skills tree */ +.skill-tree { display: flex; flex-direction: column; gap: 10px; } +.skill-branch-name { + color: var(--gold); letter-spacing: 2px; font-size: 12px; text-transform: uppercase; + border-bottom: 1px solid rgba(90,68,38,.4); padding-bottom: 3px; margin-bottom: 4px; +} +.skill-tier { display: flex; gap: 8px; align-items: center; } +.skill-node { position: relative; } +.skill-node .skill-btn { + position: relative; width: 56px; height: 56px; + background: var(--slot-bg); border: 1px solid #4a3a22; border-radius: 6px; + cursor: pointer; display: flex; align-items: center; justify-content: center; +} +.skill-node.locked .skill-btn { opacity: .38; filter: grayscale(.8); } +.skill-node.maxed .skill-btn { border-color: var(--gold); box-shadow: 0 0 12px rgba(200,163,90,.4); } +.skill-node .skill-rank { + position: absolute; right: 2px; bottom: 1px; + font-size: 11px; color: var(--gold-hi); text-shadow: 0 1px 2px #000; +} +.skill-node .skill-plus { + position: absolute; left: 50%; top: -9px; transform: translateX(-50%); + width: 18px; height: 18px; line-height: 16px; text-align: center; + background: #2a1c0e; border: 1px solid var(--gold); border-radius: 50%; + color: var(--gold-hi); cursor: pointer; font-size: 12px; + box-shadow: 0 0 8px rgba(200,163,90,.5); +} +.skill-desc { + flex: 1; font-size: 12.5px; line-height: 1.5; color: var(--text-dim); + padding-left: 6px; +} +.skill-desc b { color: var(--text); } +.skill-hotkey-select { + margin-top: 3px; font-size: 11px; color: var(--gold); +} +.skill-hotkey-select button { + font-family: inherit; font-size: 10px; + background: #201710; color: var(--text); + border: 1px solid var(--border); border-radius: 3px; + padding: 1px 7px; margin-right: 4px; cursor: pointer; +} +.skill-hotkey-select button.active { color: var(--gold-hi); border-color: var(--gold); background: #3d2a12; } + +/* vendor / lists */ +.vendor-layout { display: flex; gap: 14px; } +.vendor-list { display: flex; flex-direction: column; gap: 5px; max-height: 380px; overflow-y: auto; padding-right: 4px; } +.vendor-item-row { + display: flex; align-items: center; gap: 10px; + padding: 5px 8px; border: 1px solid #3a2d1a; border-radius: 5px; + background: rgba(14,10,6,.7); + cursor: pointer; +} +.vendor-item-row:hover { border-color: var(--border-hi); background: rgba(30,21,12,.85); } +.vendor-item-row.sold { opacity: .35; pointer-events: none; } +.vendor-price { margin-left: auto; color: var(--gold-hi); font-size: 13px; white-space: nowrap; } +.vendor-tabs { display: flex; gap: 8px; margin-bottom: 10px; } +.vendor-tab { + padding: 4px 16px; font-size: 13px; letter-spacing: 1px; + border: 1px solid var(--border); border-radius: 4px; + color: var(--text-dim); cursor: pointer; user-select: none; +} +.vendor-tab.active { color: var(--gold-hi); border-color: var(--gold); background: #2a1c0e; } + +/* dialog */ +.dialog-box { padding: 14px 20px 16px; } +.dialog-text { font-size: 15px; line-height: 1.6; font-style: italic; color: var(--text); margin-bottom: 12px; } +.dialog-options { display: flex; flex-direction: column; gap: 6px; } +.dialog-options .btn { text-align: left; font-size: 14px; padding: 7px 16px; } + +/* world map */ +.worldmap-nodes { display: flex; align-items: center; justify-content: center; gap: 0; padding: 24px 10px 10px; } +.wm-node { + width: 120px; text-align: center; cursor: pointer; user-select: none; + padding: 10px 6px; border: 1px solid var(--border); border-radius: 8px; + background: rgba(18,12,8,.85); + font-size: 12px; +} +.wm-node .wm-icon { font-size: 26px; margin-bottom: 4px; } +.wm-node .wm-depth { color: var(--text-dim); font-size: 11px; } +.wm-node.current { border-color: var(--gold); box-shadow: 0 0 16px rgba(200,163,90,.4); } +.wm-node.locked { opacity: .35; cursor: default; } +.wm-node:not(.locked):hover { border-color: var(--border-hi); } +.wm-arrow { color: var(--border-hi); font-size: 20px; padding: 0 8px; } + +/* tooltip */ +#tooltip { + position: fixed; z-index: 90; + max-width: 300px; + background: rgba(12, 8, 5, 0.97); + border: 1px solid var(--border); + border-radius: 5px; + padding: 9px 13px; + font-size: 13px; line-height: 1.55; + pointer-events: none; + box-shadow: 0 10px 30px rgba(0,0,0,.8); +} +.tt-title { font-size: 14.5px; letter-spacing: .5px; margin-bottom: 2px; } +.tt-type { color: var(--text-dim); font-size: 11.5px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 5px; } +.tt-affix { color: #8fb4f5; font-size: 12.5px; } +.tt-flavor { color: #8a7357; font-style: italic; font-size: 11.5px; margin-top: 6px; border-top: 1px solid rgba(90,68,38,.35); padding-top: 5px; } +.tt-compare { margin-top: 7px; padding-top: 6px; border-top: 1px solid rgba(90,68,38,.35); font-size: 12px; color: var(--text-dim); } +.tt-value { color: var(--text); } +.tt-up { color: #7fd77f; } +.tt-down { color: #e07a6a; } +.tt-action { color: var(--gold); font-size: 11px; margin-top: 6px; letter-spacing: 1px; text-transform: uppercase; } +.tt-simple { white-space: pre-line; } + +/* drag ghost */ +#drag-ghost { + position: fixed; z-index: 99; pointer-events: none; + width: 48px; height: 48px; + opacity: .9; + filter: drop-shadow(0 6px 10px rgba(0,0,0,.7)); +} +#drag-ghost canvas { width: 48px; height: 48px; } + +/* ground item labels are drawn on canvas; nothing needed here */ + +/* small screens notice */ +#rotate-notice { position: absolute; inset: 0; z-index: 200; background: #070605; display: none; align-items: center; justify-content: center; text-align: center; padding: 30px; color: var(--text); font-size: 17px; line-height: 1.7; } +@media (max-width: 700px) { + #panel-inventory { width: 250px; } + .inv-grid { grid-template-columns: repeat(5, 42px); } + .item-slot { width: 42px; height: 42px; } + .orb { width: 84px; height: 84px; } +} + +/* ===== NPC quest cards ===== */ +.quest-title { color: var(--gold-hi); font-size: 15px; letter-spacing: 1px; margin: 6px 0 8px; text-transform: uppercase; } +.quest-desc { color: var(--text); font-size: 13px; line-height: 1.55; margin-bottom: 10px; } +.quest-reward { color: var(--gold); font-size: 12px; margin-bottom: 10px; } +.dialog-options { display: flex; flex-direction: column; gap: 7px; margin-top: 10px; } +.dialog-options .btn { text-align: left; } diff --git a/icons/icon-180.png b/icons/icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..03417c22be20c56f62c1852d51907daf12026f4a GIT binary patch literal 24518 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 z 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 literal 0 HcmV?d00001 diff --git a/icons/icon-192.png b/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..5dae5c348e09d156927999664b3cb059f3ea188a GIT binary patch literal 27494 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ͺ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£kYe}&R@q2*P;v<^Rm%yPh=tV)- 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 + + + + +Diablo2D — Shadows of Tristram + + + + + + + + + + + +

+ + + + + + +
+ + + + +
+
+
+

DIABLO2D

+
Shadows of Tristram
+
+
+
+
+ + + + + + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/core/audio.js b/js/core/audio.js new file mode 100644 index 0000000..7aeba55 --- /dev/null +++ b/js/core/audio.js @@ -0,0 +1,438 @@ +/* ============================================================ + * Diablo2D — audio.js : procedural WebAudio engine + * Synthesized SFX + adaptive generative music. Zero assets. + * ============================================================ */ +'use strict'; +window.D2 = window.D2 || {}; +(function (D2) { + + let ctx = null; + let masterGain = null, sfxGain = null, musicGain = null; + let noiseBuf = null; + let unlocked = false; + + const vol = { master: 0.8, music: 0.55, sfx: 0.9 }; + + let activeVoices = 0; + const MAX_VOICES = 28; + + /* ---------- core plumbing ---------- */ + + function unlock() { + if (unlocked && ctx && ctx.state === 'running') return; + try { + if (!ctx) { + ctx = new (window.AudioContext || window.webkitAudioContext)(); + masterGain = ctx.createGain(); + sfxGain = ctx.createGain(); + musicGain = ctx.createGain(); + sfxGain.connect(masterGain); + musicGain.connect(masterGain); + masterGain.connect(ctx.destination); + applyVolumes(); + noiseBuf = makeNoiseBuffer(); + } + if (ctx.state === 'suspended') ctx.resume(); + unlocked = true; + if (pendingMood) playMusic(pendingMood); + } catch (e) { + console.warn('[audio] init failed', e); + } + } + + function makeNoiseBuffer() { + const len = ctx.sampleRate * 1.2; + const buf = ctx.createBuffer(1, len, ctx.sampleRate); + const d = buf.getChannelData(0); + for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1; + return buf; + } + + function setVolumes(m, mu, sx) { + if (m !== undefined) vol.master = m; + if (mu !== undefined) vol.music = mu; + if (sx !== undefined) vol.sfx = sx; + applyVolumes(); + } + + function applyVolumes() { + if (!masterGain) return; + masterGain.gain.value = vol.master; + musicGain.gain.value = vol.music; + sfxGain.gain.value = vol.sfx; + } + + function ready() { return unlocked && ctx && ctx.state === 'running'; } + + function canPlay() { + if (!ready()) return false; + if (activeVoices >= MAX_VOICES) return false; + activeVoices++; + setTimeout(() => { activeViewsDec(); }, 4000); + return true; + } + function activeViewsDec() { activeVoices = Math.max(0, activeVoices - 1); } + + /* ---------- synth primitives ---------- */ + + function outNode(dest, pan) { + if (pan !== undefined && ctx.createStereoPanner) { + const p = ctx.createStereoPanner(); + p.pan.value = Math.max(-1, Math.min(1, pan)); + p.connect(dest); + return p; + } + return dest; + } + + /** basic enveloped oscillator */ + function tone(o) { + if (!canPlay()) return; + const t0 = ctx.currentTime + (o.delay || 0); + const osc = ctx.createOscillator(); + osc.type = o.type || 'sine'; + osc.frequency.setValueAtTime(Math.max(20, o.freq), t0); + if (o.freqEnd) osc.frequency.exponentialRampToValueAtTime(Math.max(20, o.freqEnd), t0 + o.dur); + if (o.detune) osc.detune.value = o.detune; + + const g = ctx.createGain(); + const a = o.attack || 0.005; + const v = (o.vol || 0.5); + g.gain.setValueAtTime(0.0001, t0); + g.gain.exponentialRampToValueAtTime(v, t0 + a); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + o.dur); + + let node = g; + if (o.filter) { + const f = ctx.createBiquadFilter(); + f.type = o.filter.type || 'lowpass'; + f.frequency.value = o.filter.freq || 800; + f.Q.value = o.filter.q || 0.8; + g.connect(f); node = f; + } + + osc.connect(g); + node.connect(outNode(o.dest || sfxGain, o.pan)); + osc.start(t0); + osc.stop(t0 + o.dur + 0.05); + osc.onended = activeViewsDec; + } + + /** enveloped filtered noise burst */ + function noise(o) { + if (!canPlay()) return; + const t0 = ctx.currentTime + (o.delay || 0); + const src = ctx.createBufferSource(); + src.buffer = noiseBuf; + src.loop = true; + src.playbackRate.value = o.rate || 1; + + const f = ctx.createBiquadFilter(); + f.type = o.filterType || 'bandpass'; + f.frequency.setValueAtTime(o.freq || 1000, t0); + if (o.freqEnd) f.frequency.exponentialRampToValueAtTime(Math.max(30, o.freqEnd), t0 + o.dur); + f.Q.value = o.q || 1; + + const g = ctx.createGain(); + const a = o.attack || 0.004; + const v = (o.vol || 0.5); + g.gain.setValueAtTime(0.0001, t0); + g.gain.exponentialRampToValueAtTime(v, t0 + a); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + o.dur); + + src.connect(f); f.connect(g); + g.connect(outNode(o.dest || sfxGain, o.pan)); + src.start(t0); + src.stop(t0 + o.dur + 0.05); + src.onended = activeViewsDec; + } + + const mtof = m => 440 * Math.pow(2, (m - 69) / 12); + + /* ---------- sfx recipes ---------- */ + + const sfxRecipes = { + click() { tone({ type:'square', freq:2200, dur:.04, vol:.10 }); }, + hover() { tone({ type:'sine', freq:1400, dur:.03, vol:.05 }); }, + error() { tone({ type:'square', freq:160, freqEnd:110, dur:.16, vol:.16 }); }, + swing() { noise({ filterType:'bandpass', freq:2600, freqEnd:500, dur:.13, vol:.22, q:1.6 }); }, + hit() { noise({ filterType:'lowpass', freq:900, freqEnd:200, dur:.09, vol:.32 }); + tone({ type:'sine', freq:150, freqEnd:70, dur:.08, vol:.30 }); }, + crit() { noise({ filterType:'lowpass', freq:900, freqEnd:200, dur:.09, vol:.32 }); + tone({ type:'sine', freq:150, freqEnd:70, dur:.08, vol:.30 }); + tone({ type:'triangle', freq:1800, freqEnd:900, dur:.12, vol:.18, delay:.02 }); }, + playerhurt(){ tone({ type:'sawtooth', freq:190, freqEnd:80, dur:.18, vol:.26 }); + noise({ filterType:'lowpass', freq:600, freqEnd:150, dur:.14, vol:.25 }); }, + shoot() { noise({ filterType:'highpass', freq:1800, freqEnd:3400, dur:.07, vol:.17 }); }, + fireball() { noise({ filterType:'lowpass', freq:400, freqEnd:2400, dur:.3, vol:.24 }); + tone({ type:'sawtooth', freq:120, freqEnd:60, dur:.28, vol:.12 }); }, + explode() { noise({ filterType:'lowpass', freq:2500, freqEnd:60, dur:.55, vol:.45 }); + tone({ type:'sine', freq:90, freqEnd:34, dur:.5, vol:.42 }); }, + ice() { tone({ type:'sine', freq:2300, dur:.14, vol:.11 }); + tone({ type:'sine', freq:3100, dur:.12, vol:.09, delay:.05 }); + noise({ filterType:'highpass', freq:5200, dur:.18, vol:.10 }); }, + lightning() { noise({ filterType:'bandpass', freq:3000, q:.6, dur:.16, vol:.26 }); + tone({ type:'sawtooth', freq:800, freqEnd:200, dur:.12, vol:.10 }); }, + poison() { noise({ filterType:'bandpass', freq:500, q:2, freqEnd:220, dur:.3, vol:.18 }); }, + coin() { tone({ type:'sine', freq:mtof(98), dur:.09, vol:.14 }); + tone({ type:'sine', freq:mtof(105), dur:.14, vol:.14, delay:.06 }); }, + pickup() { tone({ type:'triangle', freq:mtof(88), dur:.1, vol:.16 }); + tone({ type:'triangle', freq:mtof(95), dur:.16, vol:.16, delay:.07 }); }, + legendary() { [76,83,88,95].forEach((m,i)=>tone({type:'sine',freq:mtof(m),dur:.5,vol:.14,delay:i*.09})); + noise({filterType:'highpass',freq:6000,dur:.7,vol:.06,delay:.1}); }, + potion() { tone({ type:'sine', freq:520, freqEnd:280, dur:.12, vol:.16 }); + tone({ type:'sine', freq:430, freqEnd:210, dur:.14, vol:.16, delay:.11 }); }, + levelup() { [64,68,71,76].forEach((m,i)=>tone({type:'triangle',freq:mtof(m),dur:.42,vol:.2,delay:i*.11})); + tone({ type:'sine', freq:mtof(88), dur:.9, vol:.12, delay:.44 }); }, + die() { tone({ type:'sawtooth', freq:220, freqEnd:40, dur:.4, vol:.22 }); + noise({ filterType:'lowpass', freq:800, freqEnd:100, dur:.35, vol:.2 }); }, + bossdie() { noise({ filterType:'lowpass', freq:2500, freqEnd:60, dur:.55, vol:.45 }); + tone({ type:'sine', freq:90, freqEnd:34, dur:.5, vol:.42 }); + [52,46,40].forEach((m,i)=>tone({type:'sawtooth',freq:mtof(m),dur:1.1,vol:.22,delay:i*.16})); }, + door() { tone({ type:'sawtooth', freq:90, freqEnd:130, dur:.5, vol:.12 }); + noise({ filterType:'bandpass', freq:300, q:3, dur:.45, vol:.10 }); }, + stairs() { tone({ type:'sine', freq:180, freqEnd:60, dur:.7, vol:.2 }); + tone({ type:'sine', freq:270, freqEnd:90, dur:.7, vol:.12, delay:.1 }); }, + shrine() { [57,64,69,72].forEach((m,i)=>tone({type:'sine',freq:mtof(m),dur:1.4,vol:.12,delay:i*.05,attack:.3})); + noise({filterType:'highpass',freq:7000,dur:1.2,vol:.05}); }, + teleport() { tone({ type:'sine', freq:300, freqEnd:2400, dur:.25, vol:.16 }); + noise({ filterType:'highpass', freq:1000, freqEnd:5000, dur:.25, vol:.14 }); }, + buff() { [60,67,72].forEach((m,i)=>tone({type:'triangle',freq:mtof(m),dur:.7,vol:.14,delay:i*.04,attack:.05})); }, + nova() { noise({ filterType:'bandpass', freq:300, freqEnd:3800, dur:.4, vol:.3, q:.7 }); + tone({ type:'sawtooth', freq:70, freqEnd:200, dur:.35, vol:.16 }); }, + bossroar() { tone({ type:'sawtooth', freq:110, freqEnd:38, dur:1.2, vol:.4 }); + tone({ type:'square', freq:74, freqEnd:30, dur:1.2, vol:.22 }); + noise({ filterType:'lowpass', freq:900, freqEnd:80, dur:1.1, vol:.3 }); }, + buy() { coin(); coin(); }, + sell() { coin(); }, + questdone() { [67,72,76].forEach((m,i)=>tone({type:'sine',freq:mtof(m),dur:.6,vol:.16,delay:i*.1,attack:.03})); }, + }; + + function sfx(name, opts = {}) { + if (!ready()) return; + const fn = sfxRecipes[name]; + if (!fn) return; + try { fn(opts); } catch (e) { /* never crash gameplay over audio */ } + } + + /* ---------- generative music ---------- */ + + // scale = semitone offsets (minor/major/dorian etc.), patterns are step arrays of scale indices or null + const MOODS = { + title: { root: 45, bpm: 58, scale: [0,2,3,5,7,8,10], + bass: [0,null,null,null,null,null,null,null,-2,null,null,null,null,null,null,null], + pad: [[0,3,7],[ -2,2,5 ]], padBeats: 8, + leadProb: .16, leadOct: 2, drums: null, bell: true }, + + town: { root: 48, bpm: 92, scale: [0,2,4,5,7,9,11], + bass: [0,null,null,4,null,null,2,null,5,null,null,4,null,null,1,null], + pad: [[0,4,7],[3,5,9],[-3,2,4],[0,4,7]], padBeats: 4, + leadProb: .3, leadOct: 2, drums: null, pluck: true }, + + crypt: { root: 38, bpm: 62, scale: [0,2,3,5,7,8,10], + bass: [0,null,null,null,null,null,0,null,-2,null,null,null,null,null,-2,null], + pad: [[0,3,7],[0,3,8],[1,5,8],[0,3,7]], padBeats: 8, + leadProb: .1, leadOct: 3, drums: null, choir: true }, + + cave: { root: 40, bpm: 76, scale: [0,2,3,5,7,9,10], + bass: [0,null,null,null,5,null,null,null,3,null,null,null,7,null,null,null], + pad: [[0,3,7],[5,7,10]], padBeats: 8, + leadProb: .12, leadOct: 2, + drums: { kick:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,1], snare:[], hat:[0,0,1,0,0,0,1,0,0,0,1,0,0,0,1,0] }, + drip: true }, + + hell: { root: 33, bpm: 128, scale: [0,1,3,5,6,8,10], + bass: [0,0,null,0,null,0,1,null,0,0,null,0,3,1,0,null], + pad: [[0,1,6],[0,1,6]], padBeats: 8, + leadProb: .2, leadOct: 1, + drums: { kick:[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0], snare:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0], hat:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,1] } }, + + boss: { root: 31, bpm: 142, scale: [0,1,3,5,7,8,10], + bass: [0,0,3,0,0,5,3,1,0,0,3,0,6,5,3,1], + pad: [[0,1,6],[-1,2,6]], padBeats: 8, + leadProb: .3, leadOct: 2, + drums: { kick:[1,0,0,1,0,0,1,0,1,0,0,1,0,0,1,0], snare:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,1], hat:[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1] } }, + }; + + let pendingMood = null; + let currentMood = null; + let seqTimer = null; + let nextNoteTime = 0; + let step = 0; + let barCount = 0; + + function playMusic(mood) { + if (!MOODS[mood]) mood = 'crypt'; + pendingMood = mood; + if (!ready()) return; + if (currentMood === mood && seqTimer) return; + stopMusicNow(); + currentMood = mood; + step = 0; barCount = 0; + nextNoteTime = ctx.currentTime + 0.08; + seqTimer = setInterval(seqTick, 60); + } + + function stopMusic(fadeSec = 0.8) { + pendingMood = null; + if (!ctx || !musicGain) return; + const t0 = ctx.currentTime; + musicGain.gain.cancelScheduledValues(t0); + musicGain.gain.setValueAtTime(musicGain.gain.value, t0); + musicGain.gain.linearRampToValueAtTime(0.0001, t0 + fadeSec); + stopMusicNow(fadeSec); + setTimeout(() => { if (musicGain && !seqTimer) musicGain.gain.value = vol.music; }, fadeSec * 1000 + 60); + } + + function stopMusicNow(delaySec = 0) { + if (seqTimer) { clearInterval(seqTimer); seqTimer = null; } + currentMood = null; + } + + function seqTick() { + if (!currentMood || !ready()) return; + const M = MOODS[currentMood]; + const spb = 60 / M.bpm; // seconds per beat (quarter) + const stepDur = spb / 4; // 16th steps + while (nextNoteTime < ctx.currentTime + 0.18) { + scheduleStep(M, step, nextNoteTime, stepDur); + nextNoteTime += stepDur; + step = (step + 1) % 16; + if (step === 0) barCount++; + } + } + + function scheduleStep(M, st, time, stepDur) { + const scale = M.scale; + + /* bass line */ + const bIdx = M.bass[st]; + if (bIdx !== null && bIdx !== undefined) { + const midi = M.root + bIdx + (scale[0] === undefined ? 0 : 0); + musicTone({ type: 'triangle', freq: mtof(midi), dur: stepDur * 2.4, vol: .17, time, + filter: { type: 'lowpass', freq: 320 } }); + if (M.drums) musicTone({ type: 'sawtooth', freq: mtof(midi - 12), dur: stepDur * 1.6, vol: .06, time, filter:{type:'lowpass',freq:200} }); + } + + /* drums */ + if (M.drums) { + if (M.drums.kick[st]) musicKick(time); + if (M.drums.snare[st]) musicNoise({ filterType: 'bandpass', freq: 1900, q: .8, dur: .09, vol: .1, time }); + if (M.drums.hat[st]) musicNoise({ filterType: 'highpass', freq: 7500, dur: .03, vol: .045, time }); + } + + /* pad chord each padBeats */ + if (M.pad && st === 0) { + const chord = M.pad[Math.floor(barCount / (M.padBeats / 16 || 1)) % M.pad.length] || M.pad[0]; + for (const iv of chord) { + const midi = M.root + 12 + iv; + musicPad(midi, M.padBeats * stepDur, time); + } + } + + /* lead / bell melody */ + if (Math.random() < M.leadProb && st % 2 === 0) { + const deg = scale[Math.floor(Math.random() * scale.length)]; + const midi = M.root + 12 * (M.leadOct || 2) + deg; + if (M.bell) musicBell(midi, time); + else if (M.pluck) musicPluck(midi, time); + else if (M.choir) musicChoirNote(midi, time, stepDur * 6); + else musicPluck(midi, time); + } + + /* cave drip ambience */ + if (M.drip && Math.random() < .05) { + musicTone({ type: 'sine', freq: 1700 + Math.random() * 900, dur: .1, vol: .035, time }); + } + } + + /* music-routed voice wrappers (respect global volume via musicGain) */ + function musicTone(o) { + const saveDest = o.dest; o.dest = musicGain; o.time = o.time; + toneAt(o); + } + function musicNoise(o) { o.dest = musicGain; noiseAt(o); } + + /* time-scheduled variants (absolute ctx time) — separate from sfx versions */ + function toneAt(o) { + if (!ready()) return; + const t0 = o.time !== undefined ? o.time : ctx.currentTime; + const osc = ctx.createOscillator(); + osc.type = o.type || 'sine'; + osc.frequency.setValueAtTime(Math.max(20, o.freq), t0); + if (o.freqEnd) osc.frequency.exponentialRampToValueAtTime(Math.max(20, o.freqEnd), t0 + o.dur); + const g = ctx.createGain(); + const a = o.attack || 0.01; + g.gain.setValueAtTime(0.0001, t0); + g.gain.exponentialRampToValueAtTime(o.vol || .2, t0 + a); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + o.dur); + let node = g; + if (o.filter) { + const f = ctx.createBiquadFilter(); + f.type = o.filter.type; f.frequency.value = o.filter.freq; + g.connect(f); node = f; + } + osc.connect(g); node.connect(o.dest || musicGain); + osc.start(t0); osc.stop(t0 + o.dur + .05); + } + + function noiseAt(o) { + if (!ready()) return; + const t0 = o.time !== undefined ? o.time : ctx.currentTime; + const src = ctx.createBufferSource(); + src.buffer = noiseBuf; src.loop = true; + const f = ctx.createBiquadFilter(); + f.type = o.filterType || 'bandpass'; + f.frequency.setValueAtTime(o.freq || 1000, t0); + if (o.freqEnd) f.frequency.exponentialRampToValueAtTime(Math.max(30, o.freqEnd), t0 + o.dur); + f.Q.value = o.q || 1; + const g = ctx.createGain(); + g.gain.setValueAtTime(.0001, t0); + g.gain.exponentialRampToValueAtTime(o.vol || .2, t0 + (o.attack || .005)); + g.gain.exponentialRampToValueAtTime(.0001, t0 + o.dur); + src.connect(f); f.connect(g); g.connect(o.dest || musicGain); + src.start(t0); src.stop(t0 + o.dur + .05); + } + + function musicPad(midi, dur, time) { + [-7, +7].forEach(det => { + const osc = ctx.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.value = mtof(midi); + osc.detune.value = det; + const f = ctx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = 620; + const g = ctx.createGain(); + g.gain.setValueAtTime(.0001, time); + g.gain.linearRampToValueAtTime(.028, time + dur * .35); + g.gain.linearRampToValueAtTime(.0001, time + dur); + osc.connect(f); f.connect(g); g.connect(musicGain); + osc.start(time); osc.stop(time + dur + .1); + }); + } + + function musicBell(midi, time) { + toneAt({ type: 'sine', freq: mtof(midi), dur: 1.6, vol: .07, time, attack: .003 }); + toneAt({ type: 'sine', freq: mtof(midi) * 2.01, dur: .9, vol: .03, time, attack: .003 }); + } + + function musicPluck(midi, time) { + toneAt({ type: 'triangle', freq: mtof(midi), dur: .3, vol: .09, time, attack: .002, + filter: { type: 'lowpass', freq: 2200 } }); + } + + function musicChoirNote(midi, time, dur) { + [-5, +5].forEach(det => toneAt({ type: 'sawtooth', freq: mtof(midi), detune: det, dur, vol: .022, time, attack: dur * .3, + filter: { type: 'lowpass', freq: 480 } })); + } + + function musicKick(time) { + toneAt({ type: 'sine', freq: 130, freqEnd: 38, dur: .16, vol: .3, time, attack: .002 }); + } + + D2.audio = { + unlock, ready, + setVolumes, get volumes() { return { ...vol }; }, + sfx, playMusic, stopMusic, + get currentMood() { return currentMood || pendingMood; }, + }; +})(window.D2); diff --git a/js/core/i18n.js b/js/core/i18n.js new file mode 100644 index 0000000..a7d5b8c --- /dev/null +++ b/js/core/i18n.js @@ -0,0 +1,303 @@ +/* ============================================================ + * Diablo2D — i18n.js : English / Vietnamese localization + * ============================================================ */ +'use strict'; + +window.D2 = window.D2 || {}; +(function (D2) { + + const dict = { + en: { + /* generic */ + ok: 'OK', cancel: 'Cancel', close: 'Close', yes: 'Yes', no: 'No', + back: 'Back', confirm: 'Confirm', none: 'None', level_short: 'Lv', + /* title */ + 'title.newgame': 'New Game', 'title.continue': 'Continue', 'title.settings': 'Settings', + 'title.help': 'How to Play', 'title.credits': 'Credits', + 'title.tagline': 'Shadows of Tristram', + 'title.subtitle': 'A dark action-RPG for your browser', + 'title.no_save': 'No saved hero found.', + 'title.confirm_overwrite': 'Starting a new game will overwrite your saved hero. Continue?', + 'title.saved_hero': 'Saved Hero', + 'title.delete_save': 'Delete Save', + 'title.confirm_delete': 'Permanently delete the saved hero?', + /* classes */ + 'class.crusader.name': 'Crusader', + 'class.crusader.desc': 'A holy warrior clad in heavy armor. Wades into melee with sweeping strikes and unbreakable defenses.', + 'class.ranger.name': 'Shadow Huntress', + 'class.ranger.desc': 'A swift rogue who strikes from the shadows with bow and blade, bleeding her prey from afar.', + 'class.sorceress.name': 'Sorceress', + 'class.sorceress.desc': 'Master of fire, frost and storm. Fragile in body, devastating in arcane power.', + 'class.select': 'Choose Your Fate', + 'class.play': 'Begin Descent', + /* stats */ + 'stat.strength': 'Strength', 'stat.dexterity': 'Dexterity', 'stat.vitality': 'Vitality', 'stat.energy': 'Energy', + 'stat.damage': 'Damage', 'stat.armor': 'Armor', 'stat.crit': 'Crit Chance', 'stat.critdmg': 'Crit Damage', + 'stat.speed': 'Move Speed', 'stat.magicfind': 'Magic Find', 'stat.lifelench': 'Life per Kill', + 'stat.fire': 'Fire Resist', 'stat.cold': 'Cold Resist', 'stat.lightning': 'Lightning Resist', 'stat.poison': 'Poison Resist', + 'stat.points': 'Attribute Points', 'stat.skillpoints': 'Skill Points', + /* ui panels */ + 'ui.inventory': 'Inventory', 'ui.character': 'Character', 'ui.skills': 'Skills', + 'ui.stash': 'Stash', 'ui.vendor': 'Blacksmith', 'ui.healer': 'Healer', + 'ui.questlog': 'Quest Log', 'ui.map': 'World Map', 'ui.help': 'How to Play', + 'ui.equip.weapon': 'Weapon', 'ui.equip.offhand': 'Off-hand', 'ui.equip.head': 'Head', + 'ui.equip.chest': 'Body', 'ui.equip.hands': 'Hands', 'ui.equip.feet': 'Feet', + 'ui.equip.amulet': 'Amulet', 'ui.equip.ring1': 'Ring', 'ui.equip.ring2': 'Ring', + 'ui.gold': 'Gold', 'ui.sell': 'Sell', 'ui.buy': 'Buy', 'ui.repair_none': '', + 'ui.heal_full': 'Full Restore', 'ui.price': 'Price', + 'ui.empty_slot': 'Empty', 'ui.drop_confirm': 'This item will be destroyed. Drop it?', + /* hotbar */ + 'hb.health_potion': 'Health Potion', 'hb.mana_potion': 'Mana Potion', + 'hb.inventory': 'Inventory (I)', 'hb.character': 'Character (C)', 'hb.skills': 'Skills (T)', + 'hb.quests': 'Quest Log (J)', 'hb.map': 'Map (M)', 'hb.help': 'Help (F1)', + /* messages */ + 'msg.levelup': 'Level Up! Press C to assign points.', + 'msg.skillpoint': 'New skill learned! Press T to view skills.', + 'msg.not_enough_mana': 'Not enough mana!', + 'msg.skill_cooldown': 'Skill is not ready.', + 'msg.inventory_full': 'Inventory is full!', + 'msg.potion_none': 'No potions left!', + 'msg.boss_spawn': 'A great evil awakens...', + 'msg.boss_slain': 'The guardian has fallen!', + 'msg.act_clear': 'Act complete! The waypoint stirs...', + 'msg.saved': 'Game saved.', + 'msg.hardcore_death': 'Your hardcore hero has perished. The record burns.', + 'msg.town_portal': 'Returned to town.', + 'msg.elite_slain': 'Champion slain!', + 'msg.shrine': 'You touch the shrine...', + /* quests */ + 'quest.main_prefix': 'Act {0}: ', + 'quest.descend': 'Descend to depth {0}', + 'quest.kill_boss': 'Slay {0}', + 'quest.done': 'Complete', + /* death / victory */ + 'death.title': 'YOU HAVE DIED', + 'death.subtitle': 'Even heroes fall... but darkness always offers another chance.', + 'death.respawn': 'Awaken in Town', 'death.title_screen': 'Return to Title', + 'victory.title': 'THE LORD OF TERROR IS VANQUISHED', + 'victory.subtitle': 'Tristram breathes again — yet the depths always call louder.', + 'victory.continue': 'Continue in Torment {0}', 'victory.title_screen': 'Return to Title', + 'victory.stats': 'Run Statistics', + /* settings */ + 'set.master': 'Master Volume', 'set.music': 'Music', 'set.sfx': 'Sound Effects', + 'set.lang': 'Language', 'set.shake': 'Screen Shake', 'set.dmgnum': 'Damage Numbers', + 'set.labels': 'Item Labels', 'set.hardcore': 'Hardcore Mode (new game)', + 'set.export': 'Export Save Code', 'set.import': 'Import Save Code', 'set.wipe': 'Erase All Data', + 'set.copy_ok': 'Copied!', 'set.import_bad': 'Invalid save code.', 'set.import_ok': 'Save imported!', + 'set.confirm_wipe': 'Erase ALL saved data? This cannot be undone.', + /* help */ + 'help.move': 'Left click — move / attack. Hold to keep moving toward cursor.', + 'help.combat': 'Right click & keys 1–4 — cast skills. Q / E — potions.', + 'help.items': 'Hold Alt to reveal ground item labels. Click an item to pick up.', + 'help.panels': 'I inventory · C character · T skills · J quests · M map · Esc pause.', + 'help.tip1': 'Champions glow with an aura — they hit harder but drop better loot.', + 'help.tip2': 'Shrines grant powerful temporary blessings.', + 'help.tip3': 'Resistances matter more than armor against elemental bosses.', + 'help.keys_title': 'Controls', + /* town npcs */ + 'npc.charsi.name': 'Charsi', + 'npc.akara.name': 'Akara', + 'npc.kashya.name': 'Kashya', + 'npc.cain.name': 'Deckard Cain', + 'npc.gheed.name': 'Gheed', + 'npc.stash.name': 'Camp Storage', + 'msg.quest_accepted': 'Quest accepted: {0}', + 'msg.quest_reward': 'Quest complete! +{0} gold', + 'quest.seek_hint': 'Visit camp — new tasks await.', + 'ui.rare_item': 'a Rare item', + 'npc.charsi.name': 'Charsi', + 'npc.akara.name': 'Akara', + 'npc.kashya.name': 'Kashya', + 'npc.cain.name': 'Deckard Cain', + 'npc.gheed.name': 'Gheed', + 'npc.stash.name': 'Kho trại', + 'msg.quest_accepted': 'Đã nhận nhiệm vụ: {0}', + 'msg.quest_reward': 'Nhiệm vụ hoàn thành! +{0} vàng', + 'quest.seek_hint': 'Hãy ghé trại — có việc mới đang chờ.', + 'ui.rare_item': 'một món Hiếm', + 'npc.charsi.greet': 'Steel keeps you alive down there. Browse my wares.', + 'npc.akara.greet': 'The Light mend you. What do you need, child?', + 'npc.kashya.greet': 'The camp needs a blade like yours. We have work.', + 'npc.cain.greet': 'Stay awhile and listen…', + 'npc.gheed.greet': 'Psst. Finest goods in the camp — provenance uncertain.', + 'npc.stash.greet': 'Your vault awaits.', + 'npc.waypoint.greet': 'Where does fate pull you, hero?', + 'ui.accept': 'Accept quest', + 'ui.turn_in': 'Turn in', + 'ui.reward': 'Reward', + 'ui.later': 'Not now', + 'ui.lore': 'Ask for lore', + 'ui.buy_tome': 'Buy Skill Tome (+1 skill pt)', + 'ui.respec': 'Respec all points', + 'ui.gamble': 'Gamble for goods', + 'ui.gamble_hint': 'Rarity unknown until bought…', + 'ui.quest_done_return': 'Return to {0}', + 'ui.tome_bought': 'Knowledge burns behind your eyes… (+1 skill point)', + 'ui.respec_done': 'Your training is undone. Points refunded.', + 'ui.gamble_win': 'Fortune favors you… this time.', + 'quest.cull': 'Cull the Fallen', + 'quest.cull.desc': 'Slay {0} denizens of the Cathedral depths. Kashya counts every kill.', + 'quest.boss': 'The {0}', + 'quest.boss.desc': 'Descend to the lair and destroy {0}. The camp will not rest until it is done.', + 'quest.torment': 'Echoes of Terror', + 'quest.torment.desc': 'Malgor is dead, yet his echo festers. Open torment {0} and cull it anew.', + 'town.enter': 'Tristram Outpost — Sanctuary', + /* misc */ + 'floor.depth': 'Depth {0}', + 'act.names.0': 'The Ravaged Cathedral', + 'act.names.1': 'The Weeping Catacombs', + 'act.names.2': 'The Fungal Depths', + 'act.names.3': "Hell's Threshold", + torment: 'Torment {0}', + 'loading.tips.0': 'Elites drop enchanted loot. Hunt the glowing ones.', + 'loading.tips.1': 'Potions refill each time you visit town.', + 'loading.tips.2': 'Fire melts flesh; cold freezes bone. Bring both.', + 'loading.tips.3': 'Barrels and urns hide coins... and sometimes worse.', + 'loading.tips.4': 'Magic Find increases rare and legendary drops.', + 'credits.line1': 'Design, code, art & audio — procedurally generated, no external assets.', + 'credits.line2': 'Inspired by the classics that defined the genre.', + }, + + vi: { + ok: 'Đồng ý', cancel: 'Hủy', close: 'Đóng', yes: 'Có', no: 'Không', + back: 'Quay lại', confirm: 'Xác nhận', none: 'Không', level_short: 'Cấp', + 'title.newgame': 'Chơi mới', 'title.continue': 'Tiếp tục', 'title.settings': 'Cài đặt', + 'title.help': 'Hướng dẫn chơi', 'title.credits': 'Thông tin', + 'title.tagline': 'Bóng Đêm Tristram', + 'title.subtitle': 'Action-RPG tối giản chạy ngay trên trình duyệt', + 'title.no_save': 'Không tìm thấy bản lưu nào.', + 'title.confirm_overwrite': 'Bắt đầu game mới sẽ xóa hero đã lưu. Tiếp tục?', + 'title.saved_hero': 'Hero đã lưu', + 'title.delete_save': 'Xóa bản lưu', + 'title.confirm_delete': 'Xóa vĩnh viễn hero đã lưu?', + 'class.crusader.name': 'Thập Tự Quân', + 'class.crusader.desc': 'Chiến binh thánh thiện trong bộ giáp nặng, quét sạch kẻ thù bằng chém rộng và phòng thủ thép.', + 'class.ranger.name': 'Nữ Thợ Săn Bóng Đêm', + 'class.ranger.desc': 'Nữ sát thủ nhanh nhẹn, hạ gục con mồi từ xa bằng cung tên độc và bóng tối.', + 'class.sorceress.name': 'Pháp Sư', + 'class.sorceress.desc': 'Bậc thầy hỏa, băng và lôi. Thân yếu nhưng sức mạnh huyền thuật tàn khốc.', + 'class.select': 'Chọn Số Phận Của Bạn', + 'class.play': 'Bắt Đầu Hạ Sơn', + 'stat.strength': 'Sức mạnh', 'stat.dexterity': 'Nhuệ', 'stat.vitality': 'Sinh lực', 'stat.energy': 'Năng lượng', + 'stat.damage': 'Sát thương', 'stat.armor': 'Giáp', 'stat.crit': 'Tỷ lệ chí mạng', 'stat.critdmg': 'ST chí mạng', + 'stat.speed': 'Tốc độ chạy', 'stat.magicfind': 'May mắn tìm đồ', 'stat.lifelench': 'Hồi máu mỗi lần hạ', + 'stat.fire': 'Kháng Hỏa', 'stat.cold': 'Kháng Băng', 'stat.lightning': 'Kháng Lôi', 'stat.poison': 'Kháng Độc', + 'stat.points': 'Điểm thuộc tính', 'stat.skillpoints': 'Điểm kỹ năng', + 'ui.inventory': 'Túi đồ', 'ui.character': 'Nhân vật', 'ui.skills': 'Kỹ năng', + 'ui.stash': 'Kho đồ', 'ui.vendor': 'Thợ Rèn', 'ui.healer': 'Pháp Sư Trị Liệu', + 'ui.questlog': 'Nhật nhiệm vụ', 'ui.map': 'Bản đồ thế giới', 'ui.help': 'Hướng dẫn', + 'ui.equip.weapon': 'Vũ khí', 'ui.equip.offhand': 'Tay trái', 'ui.equip.head': 'Đầu', + 'ui.equip.chest': 'Thân', 'ui.equip.hands': 'Tay', 'ui.equip.feet': 'Chân', + 'ui.equip.amulet': 'Mặt dây', 'ui.equip.ring1': 'Nhẫn', 'ui.equip.ring2': 'Nhẫn', + 'ui.gold': 'Vàng', 'ui.sell': 'Bán', 'ui.buy': 'Mua', + 'ui.heal_full': 'Hồi phục hoàn toàn', 'ui.price': 'Giá', + 'ui.empty_slot': 'Trống', 'ui.drop_confirm': 'Vật phẩm sẽ bị phá hủy. Vứt bỏ?', + 'hb.health_potion': 'Bình máu', 'hb.mana_potion': 'Bình mana', + 'hb.inventory': 'Túi đồ (I)', 'hb.character': 'Nhân vật (C)', 'hb.skills': 'Kỹ năng (T)', + 'hb.quests': 'Nhiệm vụ (J)', 'hb.map': 'Bản đồ (M)', 'hb.help': 'Trợ giúp (F1)', + 'msg.levelup': 'Lên cấp! Nhấn C để cộng điểm.', + 'msg.skillpoint': 'Kỹ năng mới! Nhấn T để xem kỹ năng.', + 'msg.not_enough_mana': 'Không đủ mana!', + 'msg.skill_cooldown': 'Kỹ năng chưa hồi xong.', + 'msg.inventory_full': 'Túi đồ đầy!', + 'msg.potion_none': 'Hết bình hồi máu!', + 'msg.boss_spawn': 'Một ma lực khổng lồ thức dậy...', + 'msg.boss_slain': 'Ác thần đã bị hạ!', + 'msg.act_clear': 'Hoàn thành chương! Điểm dịch chuyển rung lên...', + 'msg.saved': 'Đã lưu game.', + 'msg.hardcore_death': 'Hero Hardcore của bạn đã chết. Bản lưu bị thiêu rụi.', + 'msg.town_portal': 'Đã về trấn.', + 'msg.elite_slain': 'Đã hạ tinh anh!', + 'msg.shrine': 'Bạn chạm vào đền thờ...', + 'quest.main_prefix': 'Chương {0}: ', + 'quest.descend': 'Hạ xuống độ sâu {0}', + 'quest.kill_boss': 'Hạ gục {0}', + 'quest.done': 'Hoàn thành', + 'death.title': 'BẠN ĐÃ CHẾT', + 'death.subtitle': 'Anh hùng cũng có thể ngã... nhưng bóng tối luôn cho thêm một cơ hội.', + 'death.respawn': 'Thức dậy ở trấn', 'death.title_screen': 'Về màn hình chính', + 'victory.title': 'CHÚA TỂ KINH HOÀNG ĐÃ BỊ TIÊU DIỆT', + 'victory.subtitle': 'Tristram lại được thở — nhưng vực sâu luôn gọi to hơn.', + 'victory.continue': 'Tiếp tục ở Torment {0}', 'victory.title_screen': 'Về màn hình chính', + 'victory.stats': 'Thống kê chuyến chiến', + 'set.master': 'Âm lượng tổng', 'set.music': 'Nhạc nền', 'set.sfx': 'Hiệu ứng âm thanh', + 'set.lang': 'Ngôn ngữ', 'set.shake': 'Rung màn hình', 'set.dmgnum': 'Số sát thương', + 'set.labels': 'Nhãn đồ trên đất', 'set.hardcore': 'Chế độ Hardcore (chơi mới)', + 'set.export': 'Xuất mã lưu', 'set.import': 'Nhập mã lưu', 'set.wipe': 'Xóa toàn bộ dữ liệu', + 'set.copy_ok': 'Đã sao chép!', 'set.import_bad': 'Mã lưu không hợp lệ.', 'set.import_ok': 'Đã nhập bản lưu!', + 'set.confirm_wipe': 'Xóa TOÀN BỘ dữ liệu đã lưu? Không thể hoàn tác.', + 'help.move': 'Chuột trái — di chuyển / tấn công. Giữ để liên tục đi theo con trỏ.', + 'help.combat': 'Chuột phải & phím 1–4 — dùng kỹ năng. Q / E — bình máu, bình mana.', + 'help.items': 'Giữ Alt để hiện nhãn đồ trên đất. Nhấp vào món để nhặt.', + 'help.panels': 'I túi đồ · C nhân vật · T kỹ năng · J nhiệm vụ · M bản đồ · Esc tạm dừng.', + 'help.tip1': 'Tinh anh phát sáng quanh thân — mạnh hơn nhưng rớt đồ tốt hơn.', + 'help.tip2': 'Đền thờ ban phước tạm thời rất mạnh.', + 'help.tip3': 'Kháng nguyên tố quan trọng hơn giáp khi đấu boss hệ elemental.', + 'help.keys_title': 'Điều khiển', + 'npc.charsi.greet': 'Thép giữ bạn sống dưới đó. Xem hàng của ta đi.', + 'npc.akara.greet': 'Ánh Sáng chữa lành ngươi. Con cần gì nào?', + 'npc.kashya.greet': 'Trại cần một lưỡi kiếm như ngươi. Ta có việc đây.', + 'npc.cain.greet': 'Ở lại nghe ta kể chuyện này…', + 'npc.gheed.greet': 'Này. Hàng tốt nhất trại — nguồn gốc thì… khỏi hỏi.', + 'npc.stash.greet': 'Kho của bạn đang chờ.', + 'npc.waypoint.greet': 'Định mệnh đang kéo bạn đến đâu, anh hùng?', + 'ui.accept': 'Nhận nhiệm vụ', + 'ui.turn_in': 'Trả nhiệm vụ', + 'ui.reward': 'Thưởng', + 'ui.later': 'Để sau', + 'ui.lore': 'Hỏi chuyện xưa', + 'ui.buy_tome': 'Mua Sách Kỹ năng (+1 điểm)', + 'ui.respec': 'Phân bổ lại toàn bộ điểm', + 'ui.gamble': 'Đánh bạc đổi hàng', + 'ui.gamble_hint': 'Đến khi mua mới biết hiếm hay thường…', + 'ui.quest_done_return': 'Quay về gặp {0}', + 'ui.tome_bought': 'Tri thức bừng cháy trong đầu con… (+1 điểm kỹ năng)', + 'ui.respec_done': 'Toàn bộ điểm đã hoàn trả.', + 'ui.gamble_win': 'Thần may mắn đứng về phía ngươi… lần này.', + 'quest.cull': 'Thanh tẩy Kẻ ngã ngã', + 'quest.cull.desc': 'Hạ {0} sinh vật trong hang động. Kashya đếm từng mạng.', + 'quest.boss': '{0}', + 'quest.boss.desc': 'Xuống tới hang ổ và tiêu diệt {0}. Trại sẽ không yên khi nó còn sống.', + 'quest.torment': 'Ám ảnh Khủng khiếp', + 'quest.torment.desc': 'Malgor chết rồi nhưng bóng ma nó còn. Mở torment {0} và thanh tẩy lại.', + 'town.enter': 'Trấn Tristram — Nơi Tôn Nghiêm', + 'floor.depth': 'Độ sâu {0}', + 'act.names.0': 'Nhà thờ hoang tàn', + 'act.names.1': 'Hầm mộ than khóc', + 'act.names.2': 'Vực nấm sâu thẳm', + 'act.names.3': 'Ngưỡng cửa Địa Ngục', + torment: 'Torment {0}', + 'loading.tips.0': 'Tinh anh rớt đồ phù phép. Săn những con phát sáng.', + 'loading.tips.1': 'Bình hồi máu được nạp lại mỗi khi về trấn.', + 'loading.tips.2': 'Lửa thiêu thịt; băng đóng băng xương. Mang cả hai.', + 'loading.tips.3': 'Thùng rượu và lư đồng giấu vàng... đôi khi còn giấu cả thứ khác.', + 'loading.tips.4': 'May mắn tìm đồ tăng tỷ lệ rớt đồ hiếm và huyền thoại.', + 'credits.line1': 'Thiết kế, lập trình, hình ảnh & âm nhạc — sinh ra thủ tục, không dùng tài nguyên ngoài.', + 'credits.line2': 'Lấy cảm hứng từ những tựa game kinh điển định hình thể loại.', + }, + }; + + let lang = 'en'; + + function t(key, ...vars) { + let s = (dict[lang] && dict[lang][key]) ?? dict.en[key] ?? key; + vars.forEach((v, i) => { s = s.replaceAll('{' + i + '}', String(v)); }); + return s; + } + + function setLang(l) { lang = (dict[l] ? l : 'en'); if (typeof document !== 'undefined') document.documentElement.lang = lang; } + function getLang() { return lang; } + function languages() { return Object.keys(dict); } + + /* apply data-i18n attributes inside a root element */ + function applyDom(root = document) { + root.querySelectorAll('[data-i18n]').forEach(el => { + el.textContent = t(el.getAttribute('data-i18n')); + }); + root.querySelectorAll('[data-i18n-title]').forEach(el => { + el.title = t(el.getAttribute('data-i18n-title')); + }); + } + + D2.i18n = { t, setLang, getLang, languages, applyDom, dict }; +})(window.D2); diff --git a/js/core/input.js b/js/core/input.js new file mode 100644 index 0000000..3827f88 --- /dev/null +++ b/js/core/input.js @@ -0,0 +1,124 @@ +/* ============================================================ + * Diablo2D — input.js : unified mouse/keyboard state + * Canvas-only listeners; DOM UI handles its own events. + * ============================================================ */ +'use strict'; + +window.D2 = window.D2 || {}; +(function (D2) { + + const state = { + mx: 0, my: 0, // canvas-space cursor + left: false, right: false, mid: false, + leftPressed: false, // true only on frame of press + rightPressed: false, + wheelDelta: 0, + keys: new Set(), // currently held (KeyboardEvent.code) + pressedKeys: new Set(), // pressed this frame + releasedKeys: new Set(), + anyKeyPress: false, + lastMouseWorld: null, + }; + + let canvas = null; + const _pressedQueueKeys = new Set(); + const _releasedQueueKeys = new Set(); + + function init(targetCanvas) { + canvas = targetCanvas; + + window.addEventListener('keydown', (e) => { + if (e.repeat) return; + state.keys.add(e.code); + _pressedQueueKeys.add(e.code); + state.anyKeyPress = true; + // block browser scroll/space navigation during gameplay + if (['Space', 'ArrowUp', 'ArrowDown', 'Tab'].includes(e.code)) e.preventDefault(); + }, { passive: false }); + + window.addEventListener('keyup', (e) => { + state.keys.delete(e.code); + _releasedQueueKeys.add(e.code); + }); + + window.addEventListener('blur', () => { + state.keys.clear(); + state.left = state.right = false; + }); + + canvas.addEventListener('mousemove', (e) => { + const r = canvas.getBoundingClientRect(); + state.mx = e.clientX - r.left; + state.my = e.clientY - r.top; + /* authoritative resync: if the browser says buttons are up, believe it. + Heals a missed mouseup (released outside the window, alt-tab, etc.) */ + if (typeof e.buttons === 'number') { + if (!(e.buttons & 1)) state.left = false; + if (!(e.buttons & 2)) state.right = false; + if (!(e.buttons & 4)) state.mid = false; + } + }); + + canvas.addEventListener('mousedown', (e) => { + updateMousePos(e); + if (e.button === 0) { state.left = true; state.leftPressed = true; } + if (e.button === 2) { state.right = true; state.rightPressed = true; } + if (e.button === 1) { state.mid = true; e.preventDefault(); } + }); + + const releaseAll = () => { state.left = false; state.right = false; state.mid = false; }; + window.addEventListener('mouseup', (e) => { + if (e.button === 0) state.left = false; + if (e.button === 2) state.right = false; + if (e.button === 1) state.mid = false; + }); + /* cursor left the page / OS took the pointer / touch cancelled */ + document.documentElement.addEventListener('mouseleave', releaseAll); + window.addEventListener('pointercancel', releaseAll); + document.addEventListener('visibilitychange', () => { + if (document.hidden) releaseAll(); + }); + + canvas.addEventListener('contextmenu', (e) => e.preventDefault()); + + canvas.addEventListener('wheel', (e) => { + state.wheelDelta += Math.sign(e.deltaY); + e.preventDefault(); + }, { passive: false }); + + window.addEventListener('mousedown', (e) => { // audio unlock gesture + D2.audio && D2.audio.unlock(); + }, { once: false, capture: true }); + } + + function updateMousePos(e) { + const r = canvas.getBoundingClientRect(); + state.mx = e.clientX - r.left; + state.my = e.clientY - r.top; + } + + /** call at end of each simulation frame */ + function endFrame() { + state.leftPressed = false; + state.rightPressed = false; + state.wheelDelta = 0; + state.pressedKeys.clear(); + state.releasedKeys.clear(); + for (const k of _pressedQueueKeys) state.pressedKeys.add(k); + for (const k of _releasedQueueKeys) state.releasedKeys.add(k); + _pressedQueueKeys.clear(); + _releasedQueueKeys.clear(); + state.anyKeyPress = false; + } + + const isDown = code => state.keys.has(code); + const wasPressed = code => state.pressedKeys.has(code); + const wasReleased = code => state.releasedKeys.has(code); + + /* virtual press used by UI hotbar buttons */ + function injectPress(code) { + _pressedQueueKeys.add(code); + } + + D2.input = { state, init, endFrame, isDown, wasPressed, wasReleased, injectPress }; +})(window.D2); diff --git a/js/core/save.js b/js/core/save.js new file mode 100644 index 0000000..7832efd --- /dev/null +++ b/js/core/save.js @@ -0,0 +1,101 @@ +/* ============================================================ + * Diablo2D — save.js : localStorage persistence layer + * ============================================================ */ +'use strict'; + +window.D2 = window.D2 || {}; +(function (D2) { + + const PREFIX = 'd2d.'; + const SAVE_VERSION = 3; + + let storageOk = true; + try { + localStorage.setItem(PREFIX + '__probe', '1'); + localStorage.removeItem(PREFIX + '__probe'); + } catch (e) { + storageOk = false; + } + + function get(key, fallback = null) { + if (!storageOk) return fallback; + try { + const raw = localStorage.getItem(PREFIX + key); + if (raw == null) return fallback; + return JSON.parse(raw); + } catch (e) { + console.warn('[save] corrupt entry', key, e); + return fallback; + } + } + + function set(key, value) { + if (!storageOk) return false; + try { + localStorage.setItem(PREFIX + key, JSON.stringify(value)); + return true; + } catch (e) { + console.error('[save] write failed', e); + return false; + } + } + + function remove(key) { + if (!storageOk) return; + try { localStorage.removeItem(PREFIX + key); } catch (e) {} + } + + function hasMeta() { + return !!get('meta', null); + } + + function writeMeta(meta) { + meta.version = SAVE_VERSION; + meta.savedAt = Date.now(); + set('meta', meta); + } + + function readMeta() { + return get('meta', null); + } + + function wipeAll() { + remove('meta'); + remove('char'); + remove('world'); + remove('settings'); + } + + /* full snapshot: meta + char + world */ + function serializeSnapshot() { + return JSON.stringify({ + v: SAVE_VERSION, + meta: readMeta(), + char: get('char'), + world: get('world'), + }); + } + + function restoreSnapshot(text) { + try { + const obj = JSON.parse(text); + if (!obj || typeof obj !== 'object' || !obj.char) return false; + if (obj.meta) set('meta', obj.meta); + if (obj.char) set('char', obj.char); + if (obj.world) set('world', obj.world); + return true; + } catch (e) { + return false; + } + } + + D2.save = { + version: SAVE_VERSION, + get storageOk() { return storageOk; }, + KEY: { META: 'meta', CHAR: 'char', WORLD: 'world', SETTINGS: 'settings' }, + get, set, remove, + hasMeta, writeMeta, readMeta, + wipeAll, + serializeSnapshot, restoreSnapshot, + }; +})(window.D2); diff --git a/js/core/util.js b/js/core/util.js new file mode 100644 index 0000000..d1b9c85 --- /dev/null +++ b/js/core/util.js @@ -0,0 +1,134 @@ +/* ============================================================ + * Diablo2D — util.js : math, RNG, misc helpers + * ============================================================ */ +'use strict'; + +window.D2 = window.D2 || {}; +(function (D2) { + + const TAU = Math.PI * 2; + const clamp = (v, a, b) => v < a ? a : (v > b ? b : v); + const lerp = (a, b, t) => a + (b - a) * t; + const smoothstep = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); }; + const dist2 = (ax, ay, bx, by) => { const dx = ax - bx, dy = ay - by; return dx * dx + dy * dy; }; + const dist = (ax, ay, bx, by) => Math.sqrt(dist2(ax, ay, bx, by)); + const angleTo = (ax, ay, bx, by) => Math.atan2(by - ay, bx - ax); + const wrapAngle = a => { while (a > Math.PI) a -= TAU; while (a < -Math.PI) a += TAU; return a; }; + const approachAngle = (cur, target, maxStep) => { + let d = wrapAngle(target - cur); + if (Math.abs(d) <= maxStep) return target; + return cur + Math.sign(d) * maxStep; + }; + const easeOutCubic = t => 1 - Math.pow(1 - t, 3); + const easeInQuad = t => t * t; + + /* deterministic RNG */ + function mulberry32(seed) { + let a = seed >>> 0; + return function () { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + + class RNG { + constructor(seed) { + this.seedFn = mulberry32(seed); + this.seed = seed >>> 0; + } + next() { return this.seedFn(); } + range(a, b) { return a + this.seedFn() * (b - a); } // float [a,b) + int(a, b) { return Math.floor(this.range(a, b + 1)); } // int [a,b] + chance(p) { return this.seedFn() < p; } + pick(arr) { return arr[Math.floor(this.seedFn() * arr.length)]; } + weighted(entries) { // [{v,w}] or [[val,weight]] + let total = 0; + for (const e of entries) total += e.w !== undefined ? e.w : e[1]; + let roll = this.seedFn() * total; + for (const e of entries) { + roll -= e.w !== undefined ? e.w : e[1]; + if (roll <= 0) return e.v !== undefined ? e.v : e[0]; + } + const last = entries[entries.length - 1]; + return last.v !== undefined ? last.v : last[0]; + } + shuffle(arr) { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(this.seedFn() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; + } + } + + let _uid = 1; + const uid = () => _uid++; + + function fmtNum(n) { + n = Math.round(n); + if (Math.abs(n) >= 1000000) return (n / 1000000).toFixed(1) + 'M'; + if (Math.abs(n) >= 10000) return (n / 1000).toFixed(1) + 'k'; + return String(n); + } + + function roman(n) { + if (n <= 0) return '0'; + const map = [[1000,'M'],[900,'CM'],[500,'D'],[400,'CD'],[100,'C'],[90,'XC'],[50,'L'],[40,'XL'],[10,'X'],[9,'IX'],[5,'V'],[4,'IV'],[1,'I']]; + let out = ''; + for (const [v, s] of map) while (n >= v) { out += s; n -= v; } + return out; + } + + function deepClone(o) { return JSON.parse(JSON.stringify(o)); } + + /* color helpers: '#rrggbb' <-> [r,g,b] */ + function hexToRgb(hex) { + const h = hex.replace('#', ''); + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; + } + function rgbToHex(r, g, b) { + const c = v => clamp(Math.round(v), 0, 255).toString(16).padStart(2, '0'); + return '#' + c(r) + c(g) + c(b); + } + function lerpColor(c1, c2, t) { + const a = typeof c1 === 'string' ? hexToRgb(c1) : c1; + const b = typeof c2 === 'string' ? hexToRgb(c2) : c2; + return rgbToHex(lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)); + } + function shade(hex, f) { // f<1 darken, f>1 lighten + const [r, g, b] = hexToRgb(hex); + return rgbToHex(r * f, g * f, b * f); + } + function rgba(hex, alpha) { + const [r, g, b] = hexToRgb(hex); + return `rgba(${r},${g},${b},${alpha})`; + } + + /* simple event bus */ + class Bus { + constructor() { this._l = new Map(); } + on(evt, fn) { + if (!this._l.has(evt)) this._l.set(evt, []); + this._l.get(evt).push(fn); + } + off(evt, fn) { + const l = this._l.get(evt); + if (l) { const i = l.indexOf(fn); if (i >= 0) l.splice(i, 1); } + } + emit(evt, ...args) { + const l = this._l.get(evt); + if (l) for (const fn of [...l]) fn(...args); + } + } + + D2.util = { + TAU, clamp, lerp, smoothstep, dist, dist2, angleTo, wrapAngle, approachAngle, + easeOutCubic, easeInQuad, + mulberry32, RNG, uid, + fmtNum, roman, deepClone, + hexToRgb, rgbToHex, lerpColor, shade, rgba, + Bus, + }; +})(window.D2); diff --git a/js/data/balance.js b/js/data/balance.js new file mode 100644 index 0000000..aacaa9b --- /dev/null +++ b/js/data/balance.js @@ -0,0 +1,142 @@ +/* ============================================================ + * Diablo2D — balance.js : tuning constants & progression curves + * ============================================================ */ +'use strict'; +window.D2 = window.D2 || {}; +(function (D2) { + + const BAL = { + /* --- progression --- */ + maxLevel: 40, + statPointsPerLevel: 5, + skillPointsPerLevel: 1, + xpForLevel(l) { return Math.floor(58 * Math.pow(l, 1.72) + 24 * l); }, + + /* --- player base --- */ + baseHp: 42, hpPerVit: 3.4, hpPerLevel: 7.5, + baseMana: 18, manaPerEne: 2.2, manaPerLevel: 3.5, + baseArmor: 3, + baseCrit: 0.05, baseCritDmg: 1.5, + baseSpeed: 3.4, // tiles / second + baseAttackCd: 0.62, // seconds (modified by weapon aps) + hpRegenPerSec: 0.35, manaRegenPerSec: 1.4, + + /* primary stat -> derived */ + meleeDmgPerStr: 0.011, // +1.1% weapon dmg / str + rangedDmgPerDex: 0.011, + castDmgPerEne: 0.012, + armorPerStr: 0.25, + critPerDex: 0.0018, // +0.18% / dex + dodgePerDex: 0.0022, + + /* caps */ + resistCap: 0.72, + armorDrCap: 0.78, + critCap: 0.75, + speedCap: 6.2, + + /* --- monsters --- */ + monsterLevel(act, floorIdx, torment) { return 1 + act * 4 + floorIdx + (torment || 0) * 9; }, + mHp(mlvl) { return Math.floor(15 + 9.2 * Math.pow(mlvl, 1.36)); }, + mDmg(mlvl) { return Math.floor(2.4 + 2.1 * Math.pow(mlvl, 1.28)); }, + mXp(mlvl) { return Math.floor(7 + 5.2 * Math.pow(mlvl, 1.35)); }, + mArmor(mlvl) { return Math.floor(2 + 1.6 * mlvl); }, + + eliteHpMult: 2.2, eliteDmgMult: 1.38, eliteXpMult: 2.4, eliteSizeMult: 1.22, + bossHpMult: 7.0, bossDmgMult: 1.55, bossXpMult: 12, bossSizeMult: 1.85, + + /* --- combat --- */ + armorK: 34 + 6.2, // DR = armor/(armor + K*mlvl) — K applied as armorK*mlvl + knockback: 0.16, // tiles + hitFlashTime: 0.12, + corpseTime: 14, + goldDropBase(mlvl) { return Math.floor(3 + 2.6 * mlvl * (0.8 + Math.random() * 0.5)); }, + + /* --- drops --- */ + dropChances: { + gold: 0.42, potionHp: 0.11, potionMp: 0.075, gear: 0.145, gearEliteBonus: 0.35, gearBoss: 1.0, + }, + rarityWeights: { common: 100, magic: 34, rare: 9.5, legendary: 1.35 }, + magicFindRarityBoost(mf) { return 1 + mf / 100 * 0.85; }, // mf stat in percent points + potionStackMax: 12, + potionHealPct: 0.32, + potionManaPct: 0.38, + potionHealFlat(mlvl) { return 14 + 6 * mlvl; }, + + /* --- economy --- */ + vendorStockSize: [8, 13], + vendorRerollCostMult: 0.0, + sellRatio: 0.28, + buyMarkup: 1.0, + healerCost(level) { return 12 + level * 6; }, + stashRows: 7, + + /* --- death --- */ + deathGoldPenalty: 0.12, + + /* --- acts --- */ + /* ---- quest chain (Diablo-style: accept from NPCs, hunt, turn in) ---- */ + questKillBase(act) { return 12 + act * 4; }, + gambleCost(level) { return 80 + level * 45; }, + tomeCost(level) { return 120 + level * 60; }, + respecCost(level) { return 300 + level * 90; }, + QUESTS: [ + // Act I + { id: 'a0_cull', giver: 'kashya', act: 0, type: 'kills', target: 15, + title: 'quest.cull', desc: 'quest.cull.desc', reward: { gold: 180, xp: 90 } }, + { id: 'a0_boss', giver: 'cain', act: 0, requires: 'a0_cull', type: 'boss', + title: 'quest.boss', desc: 'quest.boss.desc', reward: { gold: 450, xp: 320, itemLevelBonus: 2 } }, + // Act II + { id: 'a1_cull', giver: 'kashya', act: 1, type: 'kills', target: 18, + title: 'quest.cull', desc: 'quest.cull.desc', reward: { gold: 380, xp: 700 } }, + { id: 'a1_boss', giver: 'cain', act: 1, requires: 'a1_cull', type: 'boss', + title: 'quest.boss', desc: 'quest.boss.desc', reward: { gold: 800, xp: 1500, itemLevelBonus: 3 } }, + // Act III + { id: 'a2_cull', giver: 'kashya', act: 2, type: 'kills', target: 22, + title: 'quest.cull', desc: 'quest.cull.desc', reward: { gold: 900, xp: 4200 } }, + { id: 'a2_boss', giver: 'cain', act: 2, requires: 'a2_cull', type: 'boss', + title: 'quest.boss', desc: 'quest.boss.desc', reward: { gold: 1600, xp: 8000, itemLevelBonus: 4 } }, + // Act IV + { id: 'a3_cull', giver: 'kashya', act: 3, type: 'kills', target: 26, + title: 'quest.cull', desc: 'quest.cull.desc', reward: { gold: 2000, xp: 22000 } }, + { id: 'a3_boss', giver: 'cain', act: 3, requires: 'a3_cull', type: 'boss', + title: 'quest.boss', desc: 'quest.boss.desc', reward: { gold: 3500, xp: 40000, itemLevelBonus: 5 } }, + // Post-victory repeatable hook + { id: 'torment_1', giver: 'cain', act: 3, requiresVictory: true, type: 'boss', torment: true, + title: 'quest.torment', desc: 'quest.torment.desc', reward: { gold: 5000, xp: 60000, itemLevelBonus: 6 } }, + ], + questById(id) { return this.QUESTS.find(q => q.id === id); }, + + acts: [ + { name: 'act.names.0', floors: 4, boss: 'butcher', theme: 'cathedral', music: 'crypt' }, + { name: 'act.names.1', floors: 4, boss: 'boneking', theme: 'catacombs', music: 'crypt' }, + { name: 'act.names.2', floors: 4, boss: 'spiderqueen', theme: 'caves', music: 'cave' }, + { name: 'act.names.3', floors: 4, boss: 'terrorlord', theme: 'hell', music: 'hell' }, + ], + floorsPerAct: 4, // last floor of each act is the boss lair + maxTorment: 10, + + /* --- shrine buffs --- */ + shrineDuration: 45, + shrineBuffPower: { dmg: 0.35, speed: 0.3, armor: 0.5, xp: 0.5, regen: 4 }, + + /* --- skills --- */ + skillMaxRank: 5, + tierUnlockLevels: [1, 4, 8, 13, 19], + manaCostRank(base) { return Math.round(base * (1 + 0.14 * 4)); }, // computed per rank elsewhere + cooldownReductionCap: 0.55, + + /* --- misc --- */ + interactRange: 1.35, + pickupRange: 1.15, + aggroRange: 7.5, + deaggroLeash: 14, + maxProjectiles: 260, + maxParticles: 700, + maxFloatingText: 90, + }; + + BAL.armorK = 40; // final constant + + D2.BAL = BAL; +})(window.D2); diff --git a/js/data/items.js b/js/data/items.js new file mode 100644 index 0000000..4717f74 --- /dev/null +++ b/js/data/items.js @@ -0,0 +1,410 @@ +/* ============================================================ + * Diablo2D — items.js : item bases, affixes, legendaries, rolling + * ============================================================ */ +'use strict'; +window.D2 = window.D2 || {}; +(function (D2) { + + const RARITIES = { + common: { color: '#c8c8c8', mult: 1.00, affixes: [0, 0], weight: 100 }, + magic: { color: '#7c9cf5', mult: 1.08, affixes: [1, 2], weight: 34 }, + rare: { color: '#f2e34c', mult: 1.16, affixes: [3, 4], weight: 9.5 }, + legendary: { color: '#ef8f34', mult: 1.28, affixes: [0, 1], weight: 1.35 }, + }; + + const SLOTS = ['weapon', 'offhand', 'head', 'chest', 'hands', 'feet', 'amulet', 'ring']; + + /* ---------------- base items ---------------- */ + /* dmg:[min,max] at minIlvl; armor base; aps = attacks per second */ + const BASES = [ + /* weapons */ + { id: 'dagger', name: { en: 'Dagger', vi: 'Dao Găm' }, slot: 'weapon', kind: 'melee', minIlvl: 1, tier: 1, dmg: [2, 5], aps: 1.62 }, + { id: 'shortsword', name: { en: 'Short Sword', vi: 'Kiếm Ngắn' }, slot: 'weapon', kind: 'melee', minIlvl: 1, tier: 1, dmg: [3, 7], aps: 1.38 }, + { id: 'club', name: { en: 'Club', vi: 'Gậy Thô' }, slot: 'weapon', kind: 'melee', minIlvl: 1, tier: 1, dmg: [4, 8], aps: 1.18 }, + { id: 'longsword', name: { en: 'Long Sword', vi: 'Kiếm Dài' }, slot: 'weapon', kind: 'melee', minIlvl: 7, tier: 2, dmg: [6, 12], aps: 1.32 }, + { id: 'battleaxe', name: { en: 'Battle Axe', vi: 'Rìu Chiến' }, slot: 'weapon', kind: 'melee', minIlvl: 10, tier: 2, dmg: [9, 16], aps: 1.08 }, + { id: 'mace', name: { en: 'Mace', vi: 'Chùy' }, slot: 'weapon', kind: 'melee', minIlvl: 9, tier: 2, dmg: [8, 14], aps: 1.15 }, + { id: 'warhammer', name: { en: 'War Hammer', vi: 'Búa Chiến' }, slot: 'weapon', kind: 'melee', minIlvl: 17, tier: 3, dmg: [13, 21], aps: 0.95 }, + { id: 'greatsword', name: { en: 'Great Sword', vi: 'Đại Kiếm' }, slot: 'weapon', kind: 'melee', minIlvl: 19, tier: 3, dmg: [15, 24], aps: 1.0 }, + { id: 'reaver', name: { en: 'Reaver Axe', vi: 'Rìu Hủy Diệt' }, slot: 'weapon', kind: 'melee', minIlvl: 26, tier: 4, dmg: [20, 33], aps: 1.02 }, + + { id: 'shortbow', name: { en: 'Short Bow', vi: 'Cung Ngắn' }, slot: 'weapon', kind: 'ranged', minIlvl: 1, tier: 1, dmg: [3, 6], aps: 1.45 }, + { id: 'huntingbow', name: { en: 'Hunting Bow', vi: 'Cung Săn Bắn' }, slot: 'weapon', kind: 'ranged', minIlvl: 8, tier: 2, dmg: [7, 12], aps: 1.32 }, + { id: 'longbow', name: { en: 'Long Bow', vi: 'Cung Dài' }, slot: 'weapon', kind: 'ranged', minIlvl: 16, tier: 3, dmg: [11, 19], aps: 1.18 }, + { id: 'shadowbow', name: { en: 'Shadow Bow', vi: 'Cung Bóng Đêm' }, slot: 'weapon', kind: 'ranged', minIlvl: 25, tier: 4, dmg: [17, 28], aps: 1.24 }, + + { id: 'gnarledstaff', name: { en: 'Gnarled Staff', vi: 'Trượng Gồ Ghề' }, slot: 'weapon', kind: 'cast', minIlvl: 1, tier: 1, dmg: [3, 6], aps: 1.28, implicit: { manaFlat: 4 } }, + { id: 'oakstaff', name: { en: 'Oak Staff', vi: 'Trượng Sồi' }, slot: 'weapon', kind: 'cast', minIlvl: 9, tier: 2, dmg: [6, 11], aps: 1.22, implicit: { manaFlat: 8 } }, + { id: 'runestaff', name: { en: 'Rune Staff', vi: 'Trượng Phù Văn' }, slot: 'weapon', kind: 'cast', minIlvl: 17, tier: 3, dmg: [10, 18], aps: 1.15, implicit: { manaFlat: 14 } }, + { id: 'archspire', name: { en: 'Arch Spire', vi: 'Tháp Pháp Thuật' }, slot: 'weapon', kind: 'cast', minIlvl: 26, tier: 4, dmg: [15, 27], aps: 1.1, implicit: { manaFlat: 22 } }, + + /* offhand */ + { id: 'buckler', name: { en: 'Buckler', vi: 'Khiên Nhỏ' }, slot: 'offhand', kind: 'shield', minIlvl: 1, tier: 1, armor: 4, implicit: { blockChance: 8 } }, + { id: 'kiteshield', name: { en: 'Kite Shield', vi: 'Khiên Diềm' }, slot: 'offhand', kind: 'shield', minIlvl: 10, tier: 2, armor: 10, implicit: { blockChance: 12 } }, + { id: 'towerward', name: { en: 'Tower Ward', vi: 'Lá Chắn Tháp' }, slot: 'offhand', kind: 'shield', minIlvl: 20, tier: 3, armor: 19, implicit: { blockChance: 16 } }, + { id: 'quiver', name: { en: 'Quiver', vi: 'Túi Đao' }, slot: 'offhand', kind: 'focus', minIlvl: 1, tier: 1, armor: 1, implicit: { attackSpeedPct: 5 } }, + { id: 'focusorb', name: { en: 'Focus Orb', vi: 'Cầu Tập Trung' }, slot: 'offhand', kind: 'focus', minIlvl: 1, tier: 1, armor: 1, implicit: { manaRegen: 0.5 } }, + + /* head */ + { id: 'cap', name: { en: 'Cap', vi: 'Mũ Vải' }, slot: 'head', kind: 'armor', minIlvl: 1, tier: 1, armor: 3 }, + { id: 'helm', name: { en: 'Helm', vi: 'Mũ Giáp' }, slot: 'head', kind: 'armor', minIlvl: 8, tier: 2, armor: 8 }, + { id: 'great Helm', name: { en: 'Great Helm', vi: 'Mũ Giáp Lớn' }, slot: 'head', kind: 'armor', minIlvl: 18, tier: 3, armor: 15 }, + { id: 'crownplate', name: { en: 'Crown Plate', vi: 'Vương Miện Thép' }, slot: 'head', kind: 'armor', minIlvl: 27, tier: 4, armor: 23 }, + + /* chest */ + { id: 'leatherarmor', name: { en: 'Leather Armor', vi: 'Giáp Da' }, slot: 'chest', kind: 'armor', minIlvl: 1, tier: 1, armor: 6 }, + { id: 'chainmail', name: { en: 'Chain Mail', vi: 'Giáp Xích' }, slot: 'chest', kind: 'armor', minIlvl: 9, tier: 2, armor: 14 }, + { id: 'platearmor', name: { en: 'Plate Armor', vi: 'Giáp Tấm' }, slot: 'chest', kind: 'armor', minIlvl: 19, tier: 3, armor: 26 }, + { id: 'doomplate', name: { en: 'War Plate', vi: 'Giáp Chiến Thần' }, slot: 'chest', kind: 'armor', minIlvl: 28, tier: 4, armor: 40 }, + + /* hands */ + { id: 'gloves', name: { en: 'Gloves', vi: 'Găng Vải' }, slot: 'hands', kind: 'armor', minIlvl: 1, tier: 1, armor: 2 }, + { id: 'gauntlets', name: { en: 'Gauntlets', vi: 'Găng Sắt' }, slot: 'hands', kind: 'armor', minIlvl: 10, tier: 2, armor: 6 }, + { id: 'warmitts', name: { en: 'War Mitts', vi: 'Găng Chiến' }, slot: 'hands', kind: 'armor', minIlvl: 21, tier: 3, armor: 11 }, + + /* feet */ + { id: 'boots', name: { en: 'Boots', vi: 'Ủng' }, slot: 'feet', kind: 'armor', minIlvl: 1, tier: 1, armor: 2, implicit: { moveSpeedPct: 4 } }, + { id: 'greaves', name: { en: 'Greaves', vi: 'Giáp Chân' }, slot: 'feet', kind: 'armor', minIlvl: 11, tier: 2, armor: 6, implicit: { moveSpeedPct: 6 } }, + { id: 'warboots', name: { en: 'War Treads', vi: 'Ủng Chiến Binh' }, slot: 'feet', kind: 'armor', minIlvl: 22, tier: 3, armor: 11, implicit: { moveSpeedPct: 8 } }, + + /* jewelry */ + { id: 'amulet', name: { en: 'Amulet', vi: 'Mặt Dây' }, slot: 'amulet', kind: 'jewelry', minIlvl: 1, tier: 1 }, + { id: 'ring', name: { en: 'Ring', vi: 'Nhẫn' }, slot: 'ring', kind: 'jewelry', minIlvl: 1, tier: 1 }, + ]; + // fix accidental key with space + BASES.find(b => b.id === 'great Helm').id = 'greathelm'; + + /* ---------------- affixes ---------------- */ + /* tiers: choose highest tier with t.ilvl <= ilvl; value uniform in [min,max] */ + const AFFIXES = [ + { id: 'str', pre: { en: 'Mighty', vi: 'Uy Lực' }, suf: { en: 'of Strength', vi: 'của Sức Mạnh' }, slots: 'any', stat: 'str', + tiers: [{ ilvl: 1, min: 1, max: 3 }, { ilvl: 9, min: 2, max: 5 }, { ilvl: 18, min: 4, max: 8 }, { ilvl: 27, min: 7, max: 13 }] }, + { id: 'dex', pre: { en: 'Deft', vi: 'Thoăn' }, suf: { en: 'of Dexterity', vi: 'của Nhuệ' }, slots: 'any', stat: 'dex', + tiers: [{ ilvl: 1, min: 1, max: 3 }, { ilvl: 9, min: 2, max: 5 }, { ilvl: 18, min: 4, max: 8 }, { ilvl: 27, min: 7, max: 13 }] }, + { id: 'vit', pre: { en: 'Stout', vi: 'Vững' }, suf: { en: 'of Vitality', vi: 'của Sinh Lực' }, slots: 'any', stat: 'vit', + tiers: [{ ilvl: 1, min: 1, max: 3 }, { ilvl: 9, min: 2, max: 5 }, { ilvl: 18, min: 4, max: 8 }, { ilvl: 27, min: 7, max: 13 }] }, + { id: 'ene', pre: { en: 'Arcane', vi: 'Huyền Bí' }, suf: { en: 'of Energy', vi: 'của Năng Lượng' }, slots: 'any', stat: 'ene', + tiers: [{ ilvl: 1, min: 1, max: 3 }, { ilvl: 9, min: 2, max: 5 }, { ilvl: 18, min: 4, max: 8 }, { ilvl: 27, min: 7, max: 13 }] }, + { id: 'dmgpct', pre: { en: 'Brutal', vi: 'Man Dại' }, suf: null, slots: ['weapon', 'amulet', 'ring'], stat: 'dmgPct', + tiers: [{ ilvl: 1, min: 6, max: 12 }, { ilvl: 12, min: 10, max: 20 }, { ilvl: 24, min: 18, max: 32 }] }, + { id: 'flatdmg', pre: { en: 'Cruel', vi: 'Tàn Nhẫn' }, suf: { en: 'of Wounds', vi: 'của Vết Thương' }, slots: ['weapon', 'ring', 'amulet'], stat: '_flatDmg', + tiers: [{ ilvl: 1, min: 1, max: 3 }, { ilvl: 12, min: 3, max: 7 }, { ilvl: 24, min: 6, max: 13 }] }, + { id: 'crit', pre: { en: 'Keen', vi: 'Sắc Bén' }, suf: { en: 'of Precision', vi: 'của Chuẩn Xác' }, slots: 'any', stat: 'critChance', + tiers: [{ ilvl: 1, min: 2, max: 4 }, { ilvl: 12, min: 3, max: 6 }, { ilvl: 24, min: 5, max: 9 }] }, + { id: 'critdmg', pre: { en: 'Savage', vi: 'Hung Tợn' }, suf: null, slots: ['weapon', 'hands', 'ring', 'amulet'], stat: 'critDmgPct', + tiers: [{ ilvl: 4, min: 10, max: 22 }, { ilvl: 16, min: 18, max: 36 }, { ilvl: 26, min: 30, max: 55 }] }, + { id: 'aspd', pre: { en: 'Swift', vi: 'Nhanh Nhẹn' }, suf: null, slots: ['weapon', 'hands', 'ring'], stat: 'attackSpeedPct', + tiers: [{ ilvl: 1, min: 4, max: 8 }, { ilvl: 14, min: 7, max: 13 }, { ilvl: 25, min: 11, max: 19 }] }, + { id: 'mspd', pre: { en: 'Fleet', vi: 'Thanh Nhẹ' }, suf: null, slots: ['feet', 'ring', 'amulet'], stat: 'moveSpeedPct', + tiers: [{ ilvl: 1, min: 4, max: 8 }, { ilvl: 14, min: 7, max: 12 }] }, + { id: 'armor', pre: { en: 'Stalwart', vi: 'Kiên Cố' }, suf: { en: 'of Warding', vi: 'của Che Chở' }, slots: ['head', 'chest', 'hands', 'feet', 'offhand'], stat: 'armor', + tiers: [{ ilvl: 1, min: 3, max: 7 }, { ilvl: 12, min: 8, max: 16 }, { ilvl: 24, min: 16, max: 32 }] }, + { id: 'hp', pre: { en: 'Robust', vi: 'Trâu Bò' }, suf: { en: 'of Life', vi: 'của Máu' }, slots: 'any', stat: 'hpFlat', + tiers: [{ ilvl: 1, min: 6, max: 12 }, { ilvl: 10, min: 12, max: 24 }, { ilvl: 20, min: 24, max: 46 }, { ilvl: 29, min: 42, max: 80 }] }, + { id: 'mana', pre: { en: 'Clear', vi: 'Trong Trời' }, suf: { en: 'of Mana', vi: 'của Mana' }, slots: 'any', stat: 'manaFlat', + tiers: [{ ilvl: 1, min: 4, max: 9 }, { ilvl: 12, min: 9, max: 18 }, { ilvl: 24, min: 16, max: 32 }] }, + { id: 'hpreg', pre: { en: 'Renewing', vi: 'Hồi Phục' }, suf: { en: 'of Regeneration', vi: 'của Tái Tạo' }, slots: ['chest', 'amulet', 'ring'], stat: 'hpRegen', + tiers: [{ ilvl: 1, min: 1, max: 3 }, { ilvl: 15, min: 3, max: 7 }] }, + { id: 'mpreg', pre: { en: 'Flowing', vi: 'Chảy Chảy' }, suf: { en: 'of the Well', vi: 'của Giếng Mana' }, slots: ['head', 'hands', 'amulet', 'ring', 'offhand'], stat: 'manaRegen', + tiers: [{ ilvl: 1, min: .4, max: 1 }, { ilvl: 15, min: 1, max: 2.2 }] }, + { id: 'lok', pre: null, suf: { en: 'of Feasting', vi: 'của Thịnh Soạn' }, slots: ['weapon', 'ring', 'amulet'], stat: 'lifeOnKill', + tiers: [{ ilvl: 1, min: 1, max: 3 }, { ilvl: 14, min: 3, max: 7 }, { ilvl: 26, min: 6, max: 13 }] }, + { id: 'mf', pre: { en: 'Lucky', vi: 'May Mắn' }, suf: { en: 'of Fortune', vi: 'của Vận May' }, slots: 'any', stat: 'magicFind', + tiers: [{ ilvl: 1, min: 5, max: 12 }, { ilvl: 15, min: 10, max: 22 }, { ilvl: 26, min: 18, max: 35 }] }, + { id: 'gf', pre: { en: 'Golden', vi: 'Vàng Óng' }, suf: { en: 'of Greed', vi: 'của Tham Lam' }, slots: 'any', stat: 'goldFind', + tiers: [{ ilvl: 1, min: 8, max: 18 }, { ilvl: 15, min: 15, max: 32 }] }, + { id: 'fire', pre: { en: 'Flaming', vi: 'Rực Lửa' }, suf: { en: 'of Embers', vi: 'của Than Hồng' }, slots: ['weapon', 'ring', 'amulet'], stat: 'fireDmg', + tiers: [{ ilvl: 1, min: 2, max: 5 }, { ilvl: 12, min: 5, max: 11 }, { ilvl: 24, min: 10, max: 21 }] }, + { id: 'cold', pre: { en: 'Frozen', vi: 'Băng Giá' }, suf: { en: 'of Frost', vi: 'của Sương Muối' }, slots: ['weapon', 'ring', 'amulet'], stat: 'coldDmg', + tiers: [{ ilvl: 1, min: 2, max: 4 }, { ilvl: 12, min: 4, max: 9 }, { ilvl: 24, min: 8, max: 17 }] }, + { id: 'lit', pre: { en: 'Crackling', vi: 'Rét Rít' }, suf: { en: 'of Storms', vi: 'của Bão Tố' }, slots: ['weapon', 'ring', 'amulet'], stat: 'litDmg', + tiers: [{ ilvl: 1, min: 1, max: 6 }, { ilvl: 12, min: 4, max: 13 }, { ilvl: 24, min: 9, max: 24 }] }, + { id: 'pois', pre: { en: 'Venomous', vi: 'Nhiễm Độc' }, suf: { en: 'of the Serpent', vi: 'của Con Rắn' }, slots: ['weapon', 'ring', 'amulet'], stat: 'poisDmg', + tiers: [{ ilvl: 1, min: 3, max: 7 }, { ilvl: 12, min: 6, max: 14 }, { ilvl: 24, min: 12, max: 26 }] }, + { id: 'resfire', pre: null, suf: { en: 'of Flame Ward', vi: 'chống Hỏa' }, slots: 'any', stat: 'resFire', + tiers: [{ ilvl: 1, min: 5, max: 12 }, { ilvl: 16, min: 10, max: 22 }, { ilvl: 27, min: 16, max: 34 }] }, + { id: 'rescold', pre: null, suf: { en: 'of Ice Ward', vi: 'chống Băng' }, slots: 'any', stat: 'resCold', + tiers: [{ ilvl: 1, min: 5, max: 12 }, { ilvl: 16, min: 10, max: 22 }, { ilvl: 27, min: 16, max: 34 }] }, + { id: 'reslit', pre: null, suf: { en: 'of Storm Ward', vi: 'chống Lôi' }, slots: 'any', stat: 'resLit', + tiers: [{ ilvl: 1, min: 5, max: 12 }, { ilvl: 16, min: 10, max: 22 }, { ilvl: 27, min: 16, max: 34 }] }, + { id: 'respois', pre: null, suf: { en: 'of Venom Ward', vi: 'chống Độc' }, slots: 'any', stat: 'resPois', + tiers: [{ ilvl: 1, min: 5, max: 12 }, { ilvl: 16, min: 10, max: 22 }, { ilvl: 27, min: 16, max: 34 }] }, + { id: 'cdr', pre: { en: 'Temporal', vi: 'Thời Không' }, suf: null, slots: ['head', 'hands', 'amulet', 'ring'], stat: 'cdr', + tiers: [{ ilvl: 6, min: 4, max: 8 }, { ilvl: 20, min: 7, max: 13 }] }, + { id: 'thorns', pre: { en: 'Spiked', vi: 'Gai Góc' }, suf: { en: 'of Retribution', vi: 'của Trừng Phạt' }, slots: ['head', 'chest', 'offhand', 'amulet'], stat: 'thorns', + tiers: [{ ilvl: 1, min: 2, max: 5 }, { ilvl: 14, min: 5, max: 12 }, { ilvl: 25, min: 10, max: 22 }] }, + ]; + + /* ---------------- legendaries ---------------- */ + const LEGENDARIES = [ + { id: 'butchers_cleaver', base: 'battleaxe', name: { en: "Butcher's Cleaver", vi: 'Cán Dao Của Đồ Tể' }, + special: 'cleaveEcho', stats: { dmgPct: 20, str: 8, hpFlat: 25 }, + flavor: { en: 'Fresh meat!', vi: 'Thịt tươi đây!' } }, + { id: 'windstring', base: 'longbow', name: { en: 'Windstring', vi: 'Dây Gió' }, + special: 'doubleShot', stats: { attackSpeedPct: 14, dex: 10 }, + flavor: { en: 'Twice the arrows, twice the silence.', vi: 'Gấp đôi mũi tên, gấp đôi im lặng.' } }, + { id: 'emberheart', base: 'runestaff', name: { en: 'Emberheart', vi: 'Tim Than Hồng' }, + special: 'fireNovaOnKill', stats: { fireDmg: 18, ene: 12 }, + flavor: { en: 'It still beats.', vi: 'Nó vẫn còn đập.' } }, + { id: 'frostweave', base: 'platearmor', name: { en: 'Frostweave', vi: 'Vải Băng' }, + special: 'chillAttacker', stats: { armor: 20, resCold: 25 }, + flavor: { en: 'Cold embraces those who dare.', vi: 'Băng giá ôm lấy kẻ dám lại gần.' } }, + { id: 'abyss_ring', base: 'ring', name: { en: 'Ring of the Abyss', vi: 'Nhẫn Vực Sâu' }, + special: 'allSkills', stats: { manaFlat: 20, ene: 8 }, + flavor: { en: 'Stare long enough and it stares back.', vi: 'Nhìn đủ lâu, nó sẽ nhìn lại.' } }, + { id: 'gravebind', base: 'amulet', name: { en: 'Gravebind', vi: 'Dây Mộ' }, + special: 'execHeal', stats: { lifeOnKill: 8, vit: 10 }, + flavor: { en: 'Every death feeds the root.', vi: 'Mỗi cái chết nuôi cái rễ.' } }, + { id: 'stormrunners', base: 'warboots', name: { en: 'Stormrunners', vi: 'Dạo Bước Bão' }, + special: 'stormDash', stats: { moveSpeedPct: 16, cdr: 10 }, + flavor: { en: 'The ground remembers nothing.', vi: 'Mặt đất chẳng nhớ gì cả.' } }, + { id: 'aegis_ash', base: 'greathelm', name: { en: 'Aegis of Ash', vi: 'Hộ Khiên Tro Tàn' }, + special: null, stats: { thorns: 18, resFire: 30, armor: 22, str: 8 }, + flavor: { en: 'What burned once cannot burn again.', vi: 'Đã cháy một lần thì không cháy lần nữa.' } }, + { id: 'bloodletter', base: 'dagger', name: { en: 'Bloodletter', vi: 'Xả Máu' }, + special: 'lifesteal', stats: { critChance: 8, aspd: 10 }, + flavor: { en: 'It drinks before you feast.', vi: 'Nó uống trước khi bạn ăn mừng.' } }, + { id: 'crown_torment', base: 'crownplate', name: { en: 'Crown of Torment', vi: 'Vương Miện Đày Ái' }, + special: 'eliteSlayer', stats: { dmgPct: 12, hpFlat: 30 }, + flavor: { en: 'Kneel, champions. Your king is here.', vi: 'Quỳ xuống, các tinh anh. Vua của chúng mày tới rồi.' } }, + { id: 'vortex_gauntlets', base: 'gauntlets', name: { en: 'Vortex Gauntlets', vi: 'Găng Xoáy Ốc' }, + special: 'chainOnHit', stats: { litDmg: 12, attackSpeedPct: 8 }, + flavor: { en: 'One strike becomes a storm.', vi: 'Một đòn hóa thành cơn bão.' } }, + { id: 'doomplate_leg', base: 'doomplate', name: { en: 'Doomplate of Ruin', vi: 'Giáp Tấm Hủy Diệt' }, + special: 'corpseBoom', stats: { armor: 34, vit: 12, thorns: 12 }, + flavor: { en: 'They die standing closer than they think.', vi: 'Chúng chết ở khoảng cách gần hơn chúng tưởng.' } }, + { id: 'serpent_coil', base: 'ring', name: { en: 'Serpent Coil', vi: 'Vòng Xuyên Rắn' }, + special: 'venomStrike', stats: { poisDmg: 14, dex: 8 }, + flavor: { en: 'The bite you never saw.', vi: 'Cú cắn bạn không bao giờ thấy.' } }, + { id: 'voidwalker_treads', base: 'greaves', name: { en: 'Voidwalker Treads', vi: 'Dạo Bước Hư Không' }, + special: 'voidstep', stats: { moveSpeedPct: 10, cdr: 12 }, + flavor: { en: 'Distance is a rumor.', vi: 'Khoảng cách chỉ là tin đồn.' } }, + ]; + + /* ---------------- name generation ---------------- */ + + const RARE_PRE = ['Doom', 'Grim', 'Blood', 'Storm', 'Night', 'Ash', 'Bone', 'Vile', 'Wraith', 'Storm', 'Sun', 'Shadow', 'Hell', 'Raven']; + const RARE_SUF = ['bane', 'song', 'weaver', 'fang', 'howl', 'brand', 'shroud', 'mark', 'veil', 'claw', 'render', 'wail']; + const RARE_OF = { + en: ['the Lost', 'the Fallen', 'the Deep', 'the Hunt', 'the Void', 'Sorrow', 'Ruin', 'the Pact', 'the Pyre', 'the Plague'], + vi: ['Người Lạc', 'Kẻ Sa Đọa', 'Vực Sâu', 'Cuộc Săn', 'Hư Không', 'Nỗi Đau', 'Sự Hủy Diệt', 'Khế Ước', 'Lửa Thiêu', 'Cơn Dịch'], + }; + + function lang() { return (D2.i18n && D2.i18n.getLang()) || 'en'; } + function pick(arr) { return arr[(Math.random() * arr.length) | 0]; } + function ri(a, b) { return a + Math.floor(Math.random() * (b - a + 1)); } + + function baseName(base) { + const n = base.name[lang()]; + return n || base.name.en; + } + + function makeName(base, rarity, rolled) { + if (rarity === 'common') return baseName(base); + if (rarity === 'magic') { + const withPre = rolled.find(a => AFFIX_MAP[a.id].pre); + const withSuf = rolled.find(a => AFFIX_MAP[a.id].suf); + let n = baseName(base); + if (withPre && (!withSuf || Math.random() < 0.5)) n = AFFIX_MAP[withPre.id].pre[lang()] + ' ' + n; + else if (withSuf) n += ' ' + AFFIX_MAP[withSuf.id].suf[lang()]; + else if (withPre) n = AFFIX_MAP[withPre.id].pre[lang()] + ' ' + n; + return n; + } + if (rarity === 'rare') { + let n = pick(RARE_PRE) + pick(RARE_SUF); + if (Math.random() < 0.4) n += ' ' + pick(RARE_OF[lang()] || RARE_OF.en); + return n; + } + return baseName(base); + } + + const AFFIX_MAP = {}; + for (const a of AFFIXES) AFFIX_MAP[a.id] = a; + + /* ---------------- rolling ---------------- */ + + function tierValue(affix, ilvl) { + let best = affix.tiers[0]; + for (const t of affix.tiers) if (t.ilvl <= ilvl) best = t; + const v = ri(best.min * 10, best.max * 10) / 10; + return Math.round(v * 10) / 10; + } + + function eligibleAffixes(slot) { + return AFFIXES.filter(a => a.slots === 'any' || (Array.isArray(a.slots) && a.slots.includes(slot))); + } + + function scaleBase(base, ilvl, rarityMult) { + const grow = 1 + 0.105 * Math.max(0, ilvl - base.minIlvl); + const out = {}; + if (base.dmg) { + out.dmgMin = Math.max(1, Math.round(base.dmg[0] * grow * rarityMult)); + out.dmgMax = Math.max(out.dmgMin + 1, Math.round(base.dmg[1] * grow * rarityMult)); + out.aps = base.aps; + } + if (base.armor) out.armor = Math.max(1, Math.round(base.armor * grow * rarityMult)); + if (base.implicit) out.stats = Object.assign({}, base.implicit); + else out.stats = {}; + return out; + } + + /** main entry: roll a randomized item */ + function rollItem(ilvl, opts = {}) { + ilvl = Math.max(1, Math.round(ilvl)); + const slot = opts.slot || pick(SLOTS); + + /* pick base among eligible, favoring higher tiers */ + const cands = BASES.filter(b => b.slot === slot && b.minIlvl <= ilvl); + if (!cands.length) return null; + const weighted = cands.map(b => ({ v: b, w: Math.pow(b.tier, 1.6) })); + const base = weightedPick(weighted); + + /* rarity */ + let rarity = opts.rarity; + if (!rarity) { + const mf = opts.magicFind || 0; + const boost = D2.BAL.magicFindRarityBoost(mf); + const wts = [ + { v: 'legendary', w: RARITIES.legendary.weight * boost * (opts.boss ? 7 : 1) * (opts.elite ? 2.6 : 1) }, + { v: 'rare', w: RARITIES.rare.weight * boost * (opts.elite ? 1.7 : 1) }, + { v: 'magic', w: RARITIES.magic.weight * (opts.elite ? 1.3 : 1) }, + { v: 'common', w: RARITIES.common.weight }, + ]; + rarity = weightedPick(wts); + } + + const rar = RARITIES[rarity]; + const item = { + uid: D2.util.uid(), + slot, baseId: base.id, kind: base.kind, + rarity, ilvl, + reqLevel: Math.max(1, ilvl - 1), + }; + Object.assign(item, scaleBase(base, ilvl, rar.mult)); + + /* legendary: fixed identity */ + if (rarity === 'legendary') { + const pool = opts.legendaryPool || LEGENDARIES; + let leg = pick(pool); + if (opts.slot) { + const matching = pool.filter(l => { + const lb = BASES.find(b => b.id === l.base); + return lb && lb.slot === slot; + }); + if (matching.length) leg = pick(matching); + } + item.baseId = leg.base; + const lb = BASES.find(b => b.id === leg.base); + Object.assign(item, scaleBase(lb, ilvl, RARITIES.legendary.mult)); + item.legendaryId = leg.id; + item.special = leg.special; + item.flavor = leg.flavor ? (leg.flavor[lang()] || leg.flavor.en) : null; + item.name = leg.name[lang()] || leg.name.en; + for (const [k, vBase] of Object.entries(leg.stats)) { + const grow = 1 + 0.055 * Math.max(0, ilvl - lb.minIlvl); + item.stats[k] = (item.stats[k] || 0) + Math.round(vBase * grow); + } + if (Math.random() < 0.65) { + const extra = rollAffixes(eligibleAffixes(slot), 1, ilvl, item.stats); + for (const a of extra) item.stats[a.stat === '_flatDmg' ? 'flatDmg' : a.stat] = + (item.stats[a.stat === '_flatDmg' ? 'flatDmg' : a.stat] || 0) + a.val; + } + item.value = price(item); + return item; + } + + /* affix count */ + const count = ri(rar.affixes[0], rar.affixes[1]); + const rolled = rollAffixes(eligibleAffixes(slot), count, ilvl, item.stats); + for (const a of rolled) { + if (a.stat === '_flatDmg') { + // flat damage adds both sides + const f = Math.round(a.val); + item.flatDmg = f; + } else { + item.stats[a.stat] = (item.stats[a.stat] || 0) + a.val; + } + } + item.name = makeName(base, rarity, rolled); + item.value = price(item); + return item; + } + + function rollAffixes(pool, count, ilvl, existingStats) { + const out = []; + const usedIds = new Set(); + const avail = [...pool]; + while (out.length < count && avail.length) { + const a = weightedPick(avail.map(x => ({ v: x, w: 1 }))); + avail.splice(avail.indexOf(a), 1); + if (usedIds.has(a.id)) continue; + usedIds.add(a.id); + out.push({ id: a.id, stat: a.stat, val: tierValue(a, ilvl) }); + } + return out; + } + + function weightedPick(entries) { + let total = 0; + for (const e of entries) total += e.w; + let r = Math.random() * total; + for (const e of entries) { r -= e.w; if (r <= 0) return e.v; } + return entries[entries.length - 1].v; + } + + function price(item) { + let core = 0; + if (item.dmgMax) core = (item.dmgMin + item.dmgMax) / 2 * item.aps * 6; + else if (item.armor) core = item.armor * 4.2; + else core = 10; + let statScore = 0; + for (const v of Object.values(item.stats || {})) statScore += typeof v === 'number' ? Math.abs(v) : 0; + if (item.flatDmg) statScore += item.flatDmg * 2; + return Math.max(5, Math.round((core + statScore * 2.2) * (1 + item.ilvl * 0.06) * + ({ common: 1, magic: 1.6, rare: 2.6, legendary: 6 }[item.rarity]))); + } + + /* starter gear per class */ + function startingWeapon(cls) { + const baseId = cls === 'crusader' ? 'shortsword' : cls === 'ranger' ? 'shortbow' : 'gnarledstaff'; + const base = BASES.find(b => b.id === baseId); + return { + uid: D2.util.uid(), slot: 'weapon', baseId, kind: base.kind, rarity: 'common', + ilvl: 1, reqLevel: 1, name: baseName(base), + dmgMin: base.dmg[0], dmgMax: base.dmg[1], aps: base.aps, + stats: base.implicit ? { ...base.implicit } : {}, + value: 10, + }; + } + function startingArmor() { + const base = BASES.find(b => b.id === 'leatherarmor'); + return { + uid: D2.util.uid(), slot: 'chest', baseId: 'leatherarmor', kind: 'armor', rarity: 'common', + ilvl: 1, reqLevel: 1, name: baseName(base), + armor: base.armor, stats: {}, value: 8, + }; + } + function startingPotion() { + const base = BASES.find(b => b.id === 'cap'); + void base; + return null; + } + + /* vendor stock */ + function vendorStock(mlvl, count) { + const out = []; + for (let i = 0; i < count; i++) { + const it = rollItem(Math.max(1, mlvl + ri(-1, 2)), { magicFind: 0 }); + if (it) out.push(it); + } + return out; + } + + D2.Items = { + RARITIES, SLOTS, BASES, AFFIXES, LEGENDARIES, AFFIX_MAP, + rollItem, price, baseName, startingWeapon, startingArmor, vendorStock, + baseById: id => BASES.find(b => b.id === id), + legendaryById: id => LEGENDARIES.find(l => l.id === id), + isWeapon: it => !!it.dmgMax, + }; +})(window.D2); diff --git a/js/data/monsters.js b/js/data/monsters.js new file mode 100644 index 0000000..5b0c2af --- /dev/null +++ b/js/data/monsters.js @@ -0,0 +1,258 @@ +/* ============================================================ + * Diablo2D — monsters.js : bestiary, elite affixes, bosses + * ============================================================ */ +'use strict'; +window.D2 = window.D2 || {}; +(function (D2) { + + /* ai archetypes: melee | ranged | caster | charger | swarm | flyer | summoner */ + const SPECIES = [ + /* ---- ACT I : The Ravaged Cathedral ---- */ + { id: 'skeleton', name: { en: 'Skeleton', vi: 'Xương Khô' }, acts: [0], ai: 'melee', + hpMult: 0.9, dmgMult: 1.0, speed: 2.5, radius: 0.32, range: 0.95, cd: 1.15, xpMult: 1.0, + palette: { body: '#cfc7ae', accent: '#8a8272' }, shape: 'skeleton' }, + { id: 'skel_archer', name: { en: 'Skeleton Archer', vi: 'Cung Thủ Xương' }, acts: [0], ai: 'ranged', + hpMult: 0.75, dmgMult: 0.9, speed: 2.2, radius: 0.3, range: 6.5, cd: 1.7, xpMult: 1.15, + proj: { kind: 'arrow', speed: 8.5, color: '#d8cba8' }, + palette: { body: '#cfc7ae', accent: '#6e5b34' }, shape: 'skeleton_bow' }, + { id: 'fallen', name: { en: 'Fallen One', vi: 'Kẻ Sa Đọa' }, acts: [0], ai: 'swarm', + hpMult: 0.55, dmgMult: 0.75, speed: 3.6, radius: 0.26, range: 0.85, cd: 0.85, xpMult: 0.85, + palette: { body: '#b0522f', accent: '#e8c26a' }, shape: 'imp' }, + { id: 'zombie', name: { en: 'Zombie', vi: 'Thi Sống' }, acts: [0, 1], ai: 'melee', + hpMult: 1.6, dmgMult: 1.1, speed: 1.35, radius: 0.36, range: 1.0, cd: 1.5, xpMult: 1.2, + palette: { body: '#7a8a62', accent: '#4c5840' }, shape: 'zombie' }, + { id: 'bat', name: { en: 'Cave Bat', vi: 'Dơi Hang' }, acts: [0, 2], ai: 'flyer', + hpMult: 0.45, dmgMult: 0.7, speed: 4.3, radius: 0.24, range: 0.8, cd: 0.8, xpMult: 0.8, + palette: { body: '#4a3a52', accent: '#8a6aa2' }, shape: 'bat' }, + { id: 'cultist', name: { en: 'Dark Cultist', vi: 'Giáo Đồ Bóng Tối' }, acts: [0], ai: 'caster', + hpMult: 0.8, dmgMult: 1.15, speed: 2.1, radius: 0.32, range: 6.0, cd: 2.1, xpMult: 1.4, + proj: { kind: 'shadowbolt', speed: 6.5, color: '#a06ad8' }, + special: 'blinkAway', + palette: { body: '#5a2a4a', accent: '#c23a3a' }, shape: 'cultist' }, + + /* ---- ACT II : The Weeping Catacombs ---- */ + { id: 'bone_warrior', name: { en: 'Bone Warrior', vi: 'Chiến Binh Xương' }, acts: [1], ai: 'melee', + hpMult: 1.25, dmgMult: 1.2, speed: 2.8, radius: 0.34, range: 1.0, cd: 1.05, xpMult: 1.25, + palette: { body: '#d8d0bc', accent: '#7a3040' }, shape: 'skeleton' }, + { id: 'tomb_spider', name: { en: 'Tomb Spider', vi: 'Nhện Mộ' }, acts: [1, 2], ai: 'ranged', + hpMult: 0.9, dmgMult: 0.95, speed: 2.6, radius: 0.33, range: 5.0, cd: 1.6, xpMult: 1.3, + proj: { kind: 'spit', speed: 7.0, color: '#8adf5a', elem: 'pois', slow: 0.35, slowDur: 1.6 }, + palette: { body: '#3a4a2e', accent: '#8adf5a' }, shape: 'spider' }, + { id: 'wraith', name: { en: 'Wraith', vi: 'Bóng Ma' }, acts: [1], ai: 'flyer', + hpMult: 0.85, dmgMult: 1.1, speed: 3.4, radius: 0.3, range: 0.9, cd: 1.1, xpMult: 1.5, + elem: 'cold', chillOnHit: true, + palette: { body: '#6a86b8', accent: '#cfe4ff' }, shape: 'wraith' }, + { id: 'cursed_priest', name: { en: 'Cursed Priest', vi: 'Tu Sĩ Nguyền Rủa' }, acts: [1], ai: 'summoner', + hpMult: 1.0, dmgMult: 0.8, speed: 1.9, radius: 0.33, range: 6.5, cd: 3.4, xpMult: 1.8, + summon: { species: 'skeleton', count: 2, maxAlive: 5 }, + proj: { kind: 'shadowbolt', speed: 6.0, color: '#b08ae8' }, + special: 'blinkAway', + palette: { body: '#443a5e', accent: '#e8e0b0' }, shape: 'cultist' }, + { id: 'hellhound', name: { en: 'Hellhound', vi: 'Hầu Địa Ngục' }, acts: [1, 3], ai: 'charger', + hpMult: 1.1, dmgMult: 1.3, speed: 3.0, radius: 0.34, range: 1.0, cd: 1.2, xpMult: 1.45, + chargeRange: 6, elem: 'fire', + palette: { body: '#702a20', accent: '#ff9a3a' }, shape: 'hound' }, + + /* ---- ACT III : The Fungal Depths ---- */ + { id: 'sporeling', name: { en: 'Sporeling', vi: 'Bào Tử Linh' }, acts: [2], ai: 'swarm', + hpMult: 0.6, dmgMult: 0.8, speed: 3.2, radius: 0.27, range: 0.85, cd: 0.9, xpMult: 0.9, + corpsePoison: true, + palette: { body: '#5a8a3a', accent: '#c8e88a' }, shape: 'shroom' }, + { id: 'cave_troll', name: { en: 'Cave Troll', vi: 'Troll Hang' }, acts: [2], ai: 'charger', + hpMult: 2.2, dmgMult: 1.5, speed: 1.9, radius: 0.46, range: 1.2, cd: 1.6, xpMult: 2.2, + chargeRange: 7, + palette: { body: '#5e6a56', accent: '#a8b89a' }, shape: 'troll' }, + { id: 'bog_witch', name: { en: 'Bog Witch', vi: 'Phù Thủy Đầm Lầy' }, acts: [2], ai: 'caster', + hpMult: 0.95, dmgMult: 1.2, speed: 2.0, radius: 0.32, range: 6.0, cd: 2.0, xpMult: 1.7, + proj: { kind: 'venomorb', speed: 6.0, color: '#9adf3a', elem: 'pois', pool: true }, + special: 'blinkAway', + palette: { body: '#3e5a2e', accent: '#b8e86a' }, shape: 'cultist' }, + { id: 'acid_spitter', name: { en: 'Acid Spitter', vi: 'Phun Axit' }, acts: [2], ai: 'ranged', + hpMult: 1.0, dmgMult: 1.05, speed: 2.3, radius: 0.35, range: 5.5, cd: 1.5, xpMult: 1.35, + proj: { kind: 'acid', speed: 7.5, color: '#d8f04a', elem: 'pois' }, + palette: { body: '#6a7a2a', accent: '#e8f56a' }, shape: 'zombie' }, + { id: 'mushroom_cap', name: { en: 'Spore Cap', vi: 'Nấm Bào Tử' }, acts: [2], ai: 'melee', + hpMult: 1.4, dmgMult: 0.9, speed: 1.6, radius: 0.38, range: 1.0, cd: 1.4, xpMult: 1.4, + explodeOnDeath: { elem: 'pois', radius: 2.2, dmgMult: 1.4 }, + palette: { body: '#8a5a3a', accent: '#c88a5a' }, shape: 'shroom_big' }, + + /* ---- ACT IV : Hell's Threshold ---- */ + { id: 'demon_imp', name: { en: 'Demon Imp', vi: 'Yêu Tinh' }, acts: [3], ai: 'swarm', + hpMult: 0.7, dmgMult: 0.95, speed: 3.8, radius: 0.27, range: 0.9, cd: 0.8, xpMult: 1.1, + elem: 'fire', + palette: { body: '#a83a2a', accent: '#ffb03a' }, shape: 'imp' }, + { id: 'hell_knight', name: { en: 'Hell Knight', vi: 'Kỵ Sĩ Địa Ngục' }, acts: [3], ai: 'melee', + hpMult: 1.9, dmgMult: 1.35, speed: 2.5, radius: 0.38, range: 1.05, cd: 1.1, xpMult: 1.9, + palette: { body: '#3a3038', accent: '#c22a2a' }, shape: 'knight' }, + { id: 'flame_archer', name: { en: 'Flame Archer', vi: 'Cung Thủ Lửa' }, acts: [3], ai: 'ranged', + hpMult: 0.9, dmgMult: 1.2, speed: 2.6, radius: 0.31, range: 7.0, cd: 1.5, xpMult: 1.5, + proj: { kind: 'firearrow', speed: 9.0, color: '#ff7a3a', elem: 'fire' }, + palette: { body: '#5e2a20', accent: '#ff9a3a' }, shape: 'skeleton_bow' }, + { id: 'succubus', name: { en: 'Succubus', vi: 'Ác Nữ' }, acts: [3], ai: 'caster', + hpMult: 1.0, dmgMult: 1.35, speed: 2.4, radius: 0.32, range: 6.5, cd: 1.9, xpMult: 1.9, + proj: { kind: 'fireball', speed: 7.0, color: '#ff5a2a', elem: 'fire', aoe: 1.2 }, + special: 'blinkAway', + palette: { body: '#7a2a4e', accent: '#ffb0c8' }, shape: 'cultist' }, + { id: 'pit_lord', name: { en: 'Pit Lord', vi: 'Chúa Vực' }, acts: [3], ai: 'charger', + hpMult: 2.6, dmgMult: 1.65, speed: 1.8, radius: 0.5, range: 1.25, cd: 1.5, xpMult: 2.6, + chargeRange: 8, elem: 'fire', + palette: { body: '#5a1e18', accent: '#ff6a2a' }, shape: 'troll' }, + { id: 'void_walker', name: { en: 'Void Walker', vi: 'Bước Qua Hư Không' }, acts: [3], ai: 'melee', + hpMult: 1.2, dmgMult: 1.4, speed: 2.9, radius: 0.33, range: 1.0, cd: 1.0, xpMult: 1.8, + special: 'blinkTo', + palette: { body: '#2a2440', accent: '#8a6aff' }, shape: 'wraith' }, + ]; + + const SPECIES_MAP = {}; + for (const s of SPECIES) SPECIES_MAP[s.id] = s; + + /* ---------------- elite (champion) affixes ---------------- */ + const ELITE_AFFIXES = [ + { id: 'extrafast', name: { en: 'Extra Fast', vi: 'Siêu Nhanh' }, color: '#ffd24a', + apply: m => { m.speed *= 1.4; } }, + { id: 'extrastrong', name: { en: 'Extra Strong', vi: 'Siêu Mạnh' }, color: '#ff5a4a', + apply: m => { m.dmg *= 1.42; } }, + { id: 'tough', name: { en: 'Ironback', vi: 'Lưng Thép' }, color: '#9ab0c8', + apply: m => { m.maxHp *= 1.6; m.hp = m.maxHp; } }, + { id: 'fireench', name: { en: 'Fire Enchanted', vi: 'Phép Hỏa' }, color: '#ff8a3a', + apply: m => { m.elem = 'fire'; m.deathNova = { elem: 'fire', radius: 2.4 }; } }, + { id: 'coldench', name: { en: 'Cold Enchanted', vi: 'Phép Băng' }, color: '#6ab8ff', + apply: m => { m.elem = 'cold'; m.chillOnHit = true; m.deathNova = { elem: 'cold', radius: 2.4, chill: true }; } }, + { id: 'litench', name: { en: 'Lightning Enchanted', vi: 'Phép Lôi' }, color: '#ffe86a', + apply: m => { m.chainOnHitChance = 0.35; } }, + { id: 'vampiric', name: { en: 'Vampiric', vi: 'Hút Máu' }, color: '#c83a6a', + apply: m => { m.lifestealPct = 0.12; } }, + { id: 'regen', name: { en: 'Regenerator', vi: 'Tái Tạo' }, color: '#6ade8a', + apply: m => { m.regenPct = 0.02; } }, + { id: 'teleport', name: { en: 'Teleporter', vi: 'Dịch Chuyển' }, color: '#b08aff', + apply: m => { m.special = 'blinkTo'; } }, + { id: 'fanatic', name: { en: 'Fanatic', vi: 'Cuồng Tín' }, color: '#ffa04a', + apply: m => { m.cdMult = 0.6; } }, + ]; + + function rollEliteAffixes() { + const pool = [...ELITE_AFFIXES]; + const out = []; + const n = Math.random() < 0.42 ? 2 : 1; + for (let i = 0; i < n && pool.length; i++) { + out.push(pool.splice((Math.random() * pool.length) | 0, 1)[0]); + } + return out; + } + + /* ---------------- bosses ---------------- */ + const BOSSES = [ + { id: 'butcher', name: { en: 'The Flesh Butcher', vi: 'Đồ Tể Xác Thịt' }, act: 0, + base: 'zombie', size: 1.9, hpMult: 1.0, dmgMult: 1.0, speed: 2.3, radius: 0.62, + palette: { body: '#8a4a3a', accent: '#d84a2a' }, shape: 'butcher', + attacks: ['cleaveArc', 'chargeAt', 'summonFallen', 'frenzy'], + intro: { en: 'Fresh meat!', vi: 'Thịt tươi đây!' } }, + { id: 'boneking', name: { en: 'The Bone King', vi: 'Vua Xương Khô' }, act: 1, + base: 'skeleton', size: 1.8, hpMult: 1.05, dmgMult: 1.0, speed: 2.2, radius: 0.58, + palette: { body: '#e0d8c0', accent: '#5a8ac8' }, shape: 'boneking', + attacks: ['boneSpearVolley', 'summonSkeletons', 'curseRing', 'blinkAway'], + intro: { en: 'My legions are endless...', vi: 'Quân đội của ta là vô tận...' } }, + { id: 'spiderqueen', name: { en: 'Aranea, Spider Queen', vi: 'Aranea, Nữ Hoàng Nhện' }, act: 2, + base: 'tomb_spider', size: 2.0, hpMult: 1.1, dmgMult: 1.05, speed: 2.4, radius: 0.64, + palette: { body: '#2e3a24', accent: '#a8e84a' }, shape: 'queenspider', + attacks: ['poisonPools', 'webVolley', 'spawnSpiderlings', 'lunge'], + intro: { en: 'You will make a fine broodmother.', vi: 'Bạn sẽ làm mẫu vật nuôi tốt đấy.' } }, + { id: 'terrorlord', name: { en: "Malgor, Lord of Terror", vi: 'Malgor, Chúa Tể Kinh Hoàng' }, act: 3, + base: 'pit_lord', size: 2.3, hpMult: 1.35, dmgMult: 1.1, speed: 2.2, radius: 0.72, + palette: { body: '#6e1a10', accent: '#ffb02a' }, shape: 'terrorlord', + attacks: ['fireNovaRing', 'lightningSpiral', 'meteorRain', 'chargeAt', 'enrage'], + intro: { en: 'I AM THE SIN OF THIS WORLD!', vi: 'TA LÀ TỘI LỖI CỦA THẾ GIAN NÀY!' } }, + ]; + + const BOSS_MAP = {}; + for (const b of BOSSES) BOSS_MAP[b.id] = b; + + /* ---------------- stat rolling ---------------- */ + + /** + * Build concrete combat stats for a monster instance. + * Returns plain object used by entities.Monster. + */ + function buildMonster(speciesId, mlvl, opts = {}) { + const sp = typeof speciesId === 'string' ? SPECIES_MAP[speciesId] : speciesId; + if (!sp) throw new Error('unknown species ' + speciesId); + const BAL = D2.BAL; + let eliteAffixes = []; + if (opts.elite) eliteAffixes = rollEliteAffixes(); + + const boss = !!opts.bossDef; + const bd = opts.bossDef || null; + + const m = { + speciesId: sp.id, def: sp, + level: mlvl, + isElite: eliteAffixes.length > 0, + isBoss: boss, + bossDef: bd, + name: boss ? (bd.name[lang()] || bd.name.en) : (sp.name[lang()] || sp.name.en), + shape: bd ? bd.shape : sp.shape, + palette: bd ? bd.palette : sp.palette, + + maxHp: Math.round(BAL.mHp(mlvl) * sp.hpMult * (bd ? bd.hpMult : 1)), + dmg: BAL.mDmg(mlvl) * sp.dmgMult, + speed: sp.speed, + radius: sp.radius * (bd ? bd.size : 1), + attackRange: sp.range, + attackCd: sp.cd, + cdMult: 1, + xpReward: Math.round(BAL.mXp(mlvl) * sp.xpMult), + elem: sp.elem || null, + proj: sp.proj || null, + summon: sp.summon || null, + special: sp.special || null, + chargeRange: sp.chargeRange || 0, + explodeOnDeath: sp.explodeOnDeath || null, + corpsePoison: !!sp.corpsePoison, + chillOnHit: !!sp.chillOnHit, + resists: { fire: 0, cold: 0, lit: 0, pois: 0 }, + }; + if (bd && bd.attacks) m.attacks = bd.attacks.slice(); + + if (m.isElite) { + m.maxHp = Math.round(m.maxHp * BAL.eliteHpMult); + m.dmg *= BAL.eliteDmgMult; + m.xpReward = Math.round(m.xpReward * BAL.eliteXpMult); + m.radius *= BAL.eliteSizeMult; + m.eliteColor = eliteAffixes[0].color; + m.eliteName = eliteAffixes.map(a => a.name[lang()] || a.name.en).join(' '); + for (const af of eliteAffixes) af.apply(m); + } + if (boss) { + m.maxHp = Math.round(m.maxHp * BAL.bossHpMult); + m.dmg *= BAL.bossDmgMult; + m.xpReward = Math.round(m.xpReward * BAL.bossXpMult); + m.radius = sp.radius * bd.size; + m.speed = bd.speed || m.speed; + m.cdMult = 1.15; + } + m.hp = m.maxHp; + return m; + } + + /** weighted random species valid for act */ + function rollSpeciesForAct(actIdx) { + const pool = SPECIES.filter(s => s.acts.includes(actIdx)); + if (!pool.length) return SPECIES[0]; + return weighted(pool.map(s => ({ v: s, w: s.ai === 'melee' ? 2.2 : 1 }))); + } + + function weighted(entries) { + let total = 0; + for (const e of entries) total += e.w; + let r = Math.random() * total; + for (const e of entries) { r -= e.w; if (r <= 0) return e.v; } + return entries[entries.length - 1].v; + } + + function lang() { return (D2.i18n && D2.i18n.getLang()) || 'en'; } + + D2.Monsters = { + SPECIES, SPECIES_MAP, ELITE_AFFIXES, BOSSES, BOSS_MAP, + buildMonster, rollSpeciesForAct, rollEliteAffixes, + }; +})(window.D2); diff --git a/js/data/skills.js b/js/data/skills.js new file mode 100644 index 0000000..af3920e --- /dev/null +++ b/js/data/skills.js @@ -0,0 +1,227 @@ +/* ============================================================ + * Diablo2D — skills.js : class skill trees (data-driven) + * Engine implements each `castType`; params scale with rank. + * ============================================================ */ +'use strict'; +window.D2 = window.D2 || {}; +(function (D2) { + + const MAX_RANK = D2.BAL.skillMaxRank; + + function L(o) { return o[(D2.i18n && D2.i18n.getLang()) || 'en'] || o.en; } + + /* ---------------- CRUSADER ---------------- */ + const crusader = [ + /* Zeal */ + { id: 'cleave', branch: 'zeal', tier: 0, type: 'active', maxRank: MAX_RANK, icon: 'cleave', + name: { en: 'Cleave', vi: 'Chém Rộng' }, + desc: r => ({ en: `Sweeping strike hits all enemies in front for ${100 + 20 * r}% weapon damage.`, vi: `Vung chém trúng mọi kẻ địch phía trước với ${100 + 20 * r}% sát thương vũ khí.` }), + manaCost: () => 0, cooldown: 0.42, + castType: 'meleeArc', params: r => ({ arcDeg: 160, range: 1.65, dmgPct: 100 + 20 * r }) }, + { id: 'whirlwind', branch: 'zeal', tier: 1, type: 'active', maxRank: MAX_RANK, icon: 'whirlwind', + name: { en: 'Whirlwind', vi: 'Vòng Xoáy' }, + desc: r => ({ en: `Spin for 1s, shredding nearby enemies for ${55 + 11 * r}% weapon damage per tick. You may move while spinning.`, vi: `Xoay trong 1s, xé nát kẻ địch gần với ${55 + 11 * r}% ST vũ khí mỗi nhịp. Di chuyển được khi xoay.` }), + manaCost: r => 8 + r, cooldown: 5.5, + castType: 'whirlwind', params: r => ({ range: 1.8, dmgPct: 55 + 11 * r, duration: 1.0 }) }, + { id: 'consecrate', branch: 'zeal', tier: 2, type: 'active', maxRank: MAX_RANK, icon: 'consecrate', + name: { en: 'Consecration', vi: 'Thánh Hóa' }, + desc: r => ({ en: `Sanctify the ground at your cursor. Deals ${140 + 34 * r}% weapon damage over 3s to enemies within it.`, vi: `Thánh hóa mặt đất tại con trỏ. Gây ${140 + 34 * r}% ST vũ khí trong 3s lên kẻ địch trong vùng.` }), + manaCost: r => 14 + 2 * r, cooldown: 8, + castType: 'groundAoE', params: r => ({ radius: 2.6, delay: 0.25, duration: 3, dmgPct: 140 + 34 * r, elem: 'holy' }) }, + + /* Wrath */ + { id: 'charge', branch: 'wrath', tier: 0, type: 'active', maxRank: MAX_RANK, icon: 'charge', + name: { en: 'Charge', vi: 'Xung Phong' }, + desc: r => ({ en: `Dash forward, damaging enemies in your path for ${130 + 26 * r}% weapon damage and stunning them ${0.5 + 0.14 * r}s.`, vi: `Lao về phía trước, gây ${130 + 26 * r}% ST vũ khí và làm choáng ${0.5 + 0.14 * r}s.` }), + manaCost: () => 6, cooldown: 6, + castType: 'dash', params: r => ({ distance: 5.2, speed: 16, dmgPct: 130 + 26 * r, stun: 0.5 + 0.14 * r }) }, + { id: 'warcry', branch: 'wrath', tier: 1, type: 'active', maxRank: MAX_RANK, icon: 'warcry', + name: { en: 'War Cry', vi: 'Chiến Khúc' }, + desc: r => ({ en: `Roar: +${15 + 5 * r}% damage and +${12 + 6 * r}% armor for 8s.`, vi: `Gầm thét: +${15 + 5 * r}% sát thương và +${12 + 6 * r}% giáp trong 8s.` }), + manaCost: r => 10 + r, cooldown: 18, + castType: 'buffSelf', params: r => ({ dur: 8, mods: { dmgPct: 15 + 5 * r, armorPct: 12 + 6 * r }, icon: 'warcry', name: { en: 'War Cry', vi: 'Chiến Khúc' } }) }, + { id: 'seismic', branch: 'wrath', tier: 2, type: 'active', maxRank: MAX_RANK, icon: 'seismic', + name: { en: 'Seismic Slam', vi: 'Trấn Địa Kích' }, + desc: r => ({ en: `Slam the earth: cone shockwave deals ${170 + 38 * r}% weapon damage, knocks back and stuns ${0.8 + 0.2 * r}s.`, vi: `Đập đất: sóng xung kích hình nón gây ${170 + 38 * r}% ST vũ khí, hất văng và choáng ${0.8 + 0.2 * r}s.` }), + manaCost: r => 16 + 3 * r, cooldown: 9, + castType: 'cone', params: r => ({ arcDeg: 70, range: 4.6, dmgPct: 170 + 38 * r, knockback: 2.4, stun: 0.8 + 0.2 * r }) }, + + /* Bulwark (passives) */ + { id: 'ironskin', branch: 'bulwark', tier: 0, type: 'passive', maxRank: MAX_RANK, icon: 'ironskin', + name: { en: 'Iron Skin', vi: 'Da Thép' }, + desc: r => ({ en: `+${6 + 4 * r}% total armor.`, vi: `+${6 + 4 * r}% tổng giáp.` }), + mods: r => ({ armorPct: 6 + 4 * r }) }, + { id: 'renewal', branch: 'bulwark', tier: 1, type: 'passive', maxRank: MAX_RANK, icon: 'renewal', + name: { en: 'Renewal', vi: 'Tái Sinh' }, + desc: r => ({ en: `Regenerate ${(0.8 + 0.7 * r).toFixed(1)}% max life per second.`, vi: `Hồi ${(0.8 + 0.7 * r).toFixed(1)}% máu tối đa mỗi giây.` }), + mods: r => ({ regenHpPct: 0.8 + 0.7 * r }) }, + { id: 'laststand', branch: 'bulwark', tier: 2, type: 'passive', maxRank: MAX_RANK, icon: 'laststand', + name: { en: 'Last Stand', vi: 'Đường Lui Cuối' }, + desc: r => ({ en: `Below 35% life: take ${8 + 4 * r}% less damage and deal +${10 + 5 * r}% more.`, vi: `Dưới 35% máu: nhận ít hơn ${8 + 4 * r}% sát thương và gây thêm +${10 + 5 * r}%.` }), + mods: r => ({ lowLifeDR: 8 + 4 * r, lowLifeDmg: 10 + 5 * r }) }, + ]; + + /* ---------------- RANGER ---------------- */ + const ranger = [ + /* Marksmanship */ + { id: 'multishot', branch: 'mark', tier: 0, type: 'active', maxRank: MAX_RANK, icon: 'multishot', + name: { en: 'Multishot', vi: 'Liên Thanh' }, + desc: r => ({ en: `Fire ${4 + Math.floor((r - 1) / 2)} arrows in a wide arc for ${85 + 17 * r}% weapon damage each.`, vi: `Bắn ${4 + Math.floor((r - 1) / 2)} mũi tên hình quạt, mỗi mũi ${85 + 17 * r}% ST vũ khí.` }), + manaCost: r => 8 + r, cooldown: 0.9, + castType: 'projSpread', params: r => ({ count: 4 + Math.floor((r - 1) / 2), spreadDeg: 42, dmgPct: 85 + 17 * r, speed: 11 }) }, + { id: 'piercingbolt', branch: 'mark', tier: 1, type: 'active', maxRank: MAX_RANK, icon: 'piercingbolt', + name: { en: 'Piercing Bolt', vi: 'Mũi Tên Xuyên Phá' }, + desc: r => ({ en: `A heavy bolt pierces all enemies in a line for ${190 + 42 * r}% weapon damage.`, vi: `Một mũi tên nặng xuyên qua mọi kẻ địch trên đường đi, gây ${190 + 42 * r}% ST vũ khí.` }), + manaCost: r => 12 + 2 * r, cooldown: 4.5, + castType: 'projSpread', params: r => ({ count: 1, spreadDeg: 0, dmgPct: 190 + 42 * r, speed: 15, pierce: true, size: 1.4 }) }, + { id: 'arrowstorm', branch: 'mark', tier: 2, type: 'active', maxRank: MAX_RANK, icon: 'arrowstorm', + name: { en: 'Arrow Storm', vi: 'Mưa Mũi Tên' }, + desc: r => ({ en: `Rain ${3 + Math.floor(r / 2)} waves of arrows on an area, each dealing ${70 + 15 * r}% weapon damage.`, vi: `Gây ${3 + Math.floor(r / 2)} đợt mưa tên lên một vùng, mỗi đợt ${70 + 15 * r}% ST vũ khí.` }), + manaCost: r => 18 + 3 * r, cooldown: 11, + castType: 'volleyArea', params: r => ({ waves: 3 + Math.floor(r / 2), waveDelay: 0.4, radius: 2.4, dmgPct: 70 + 15 * r }) }, + + /* Shadowcraft */ + { id: 'shadowstep', branch: 'shadow', tier: 0, type: 'active', maxRank: MAX_RANK, icon: 'shadowstep', + name: { en: 'Shadow Step', vi: 'Bóng Đêm Bước' }, + desc: r => ({ en: `Blink toward the cursor. Your next attack within 3s is a guaranteed critical (+${50 + 25 * r}% crit damage).`, vi: `Dịch chuyển tới con trỏ. Đòn đánh tiếp theo trong 3s chắc chắn chí mạng (+${50 + 25 * r}% ST chí mạng).` }), + manaCost: () => 10, cooldown: 7, + castType: 'blink', params: r => ({ range: 5.5, guaranteedCritDur: 3, bonusCritDmg: 50 + 25 * r }) }, + { id: 'fanofknives', branch: 'shadow', tier: 1, type: 'active', maxRank: MAX_RANK, icon: 'fanofknives', + name: { en: 'Fan of Knives', vi: 'Quạt Dao Găm' }, + desc: r => ({ en: `Hurl ${8 + 2 * r} knives in all directions for ${95 + 19 * r}% weapon damage, causing bleeding.`, vi: `Phóng ${8 + 2 * r} dao găm ra mọi hướng, gây ${95 + 19 * r}% ST vũ khí và làm chảy máu.` }), + manaCost: r => 14 + 2 * r, cooldown: 8, + castType: 'novaProj', params: r => ({ count: 8 + 2 * r, dmgPct: 95 + 19 * r, speed: 9, bleed: true }) }, + { id: 'smokeveil', branch: 'shadow', tier: 2, type: 'active', maxRank: MAX_RANK, icon: 'smokeveil', + name: { en: 'Smoke Veil', vi: 'Màn Khói' }, + desc: r => ({ en: `Vanish into smoke: +${25 + 7 * r}% dodge for 5s. Enemies lose track of you.`, vi: `Tan vào khói: +${25 + 7 * r}% né tránh trong 5s. Kẻ địch mất dấu bạn.` }), + manaCost: r => 12 + 2 * r, cooldown: 16, + castType: 'buffSelf', params: r => ({ dur: 5, mods: { dodgePct: 25 + 7 * r }, icon: 'smokeveil', name: { en: 'Smoke Veil', vi: 'Màn Khói' } }) }, + + /* Poacher (passives) */ + { id: 'predator', branch: 'poach', tier: 0, type: 'passive', maxRank: MAX_RANK, icon: 'predator', + name: { en: 'Predator Instinct', vi: 'Bản Năng Thợ Săn' }, + desc: r => ({ en: `+${8 + 6 * r}% critical damage.`, vi: `+${8 + 6 * r}% sát thương chí mạng.` }), + mods: r => ({ critDmgPct: 8 + 6 * r }) }, + { id: 'bleedingedge', branch: 'poach', tier: 1, type: 'passive', maxRank: MAX_RANK, icon: 'bleedingedge', + name: { en: 'Bleeding Edge', vi: 'Lưỡi Chảy Máu' }, + desc: r => ({ en: `Attacks cause bleeding: ${10 + 10 * r}% weapon damage over 3s.`, vi: `Đòn đánh gây chảy máu: ${10 + 10 * r}% ST vũ khí trong 3s.` }), + mods: r => ({ bleedOnHitPct: 10 + 10 * r }) }, + { id: 'swiftvantage', branch: 'poach', tier: 2, type: 'passive', maxRank: MAX_RANK, icon: 'swiftvantage', + name: { en: 'Swift Vantage', vi: 'Lợi Thế Thần Tốc' }, + desc: r => ({ en: `+${4 + 2 * r}% move speed and +${3 + 2 * r}% attack speed.`, vi: `+${4 + 2 * r}% tốc chạy và +${3 + 2 * r}% tốc đánh.` }), + mods: r => ({ moveSpeedPct: 4 + 2 * r, attackSpeedPct: 3 + 2 * r }) }, + ]; + + /* ---------------- SORCERESS ---------------- */ + const sorceress = [ + /* Pyromancy */ + { id: 'firebolt', branch: 'pyro', tier: 0, type: 'active', maxRank: MAX_RANK, icon: 'firebolt', + name: { en: 'Fire Bolt', vi: 'Tiểu Hỏa' }, + desc: r => ({ en: `Hurl a bolt of fire for ${135 + 32 * r}% weapon damage. Cheap and reliable.`, vi: `Phóng một mũi lửa gây ${135 + 32 * r}% ST vũ khí. Rẻ và đáng tin.` }), + manaCost: () => 4, cooldown: 0.55, + castType: 'projSpread', params: r => ({ count: 1, spreadDeg: 0, dmgPct: 135 + 32 * r, speed: 10, elem: 'fire', size: 1.15 }) }, + { id: 'meteor', branch: 'pyro', tier: 1, type: 'active', maxRank: MAX_RANK, icon: 'meteor', + name: { en: 'Meteor', vi: 'Thiên Thạch' }, + desc: r => ({ en: `Call a meteor to crash after 0.8s: ${260 + 58 * r}% weapon damage in a large area, leaving burning ground.`, vi: `Triệu hồi thiên thạch rơi sau 0.8s: ${260 + 58 * r}% ST vũ khí vùng lớn, để lại mặt đất cháy.` }), + manaCost: r => 22 + 4 * r, cooldown: 9, + castType: 'meteor', params: r => ({ radius: 2.6, delay: 0.8, dmgPct: 260 + 58 * r, burnGround: 2.5 }) }, + { id: 'flamewave', branch: 'pyro', tier: 2, type: 'active', maxRank: MAX_RANK, icon: 'flamewave', + name: { en: 'Flame Wave', vi: 'Sóng Lửa' }, + desc: r => ({ en: `Erupt in a ring of fire: ${200 + 44 * r}% weapon damage around you and ignite enemies.`, vi: `Bùng nổ vòng lửa quanh bạn: ${200 + 44 * r}% ST vũ khí và thiêu rụi kẻ địch.` }), + manaCost: r => 20 + 4 * r, cooldown: 7, + castType: 'nova', params: r => ({ radius: 3.4, dmgPct: 200 + 44 * r, elem: 'fire', ignite: true }) }, + + /* Cryomancy */ + { id: 'iceshards', branch: 'cryo', tier: 0, type: 'active', maxRank: MAX_RANK, icon: 'iceshards', + name: { en: 'Ice Shards', vi: 'Mảnh Băng' }, + desc: r => ({ en: `Launch ${5} piercing shards for ${90 + 20 * r}% weapon damage each, chilling enemies.`, vi: `Phóng 5 mảnh băng xuyên phá, mỗi mảnh ${90 + 20 * r}% ST vũ khí, làm lạnh kẻ địch.` }), + manaCost: r => 9 + 2 * r, cooldown: 1.4, + castType: 'projSpread', params: r => ({ count: 5, spreadDeg: 36, dmgPct: 90 + 20 * r, speed: 9.5, elem: 'cold', pierce: true, slow: 0.35, slowDur: 1.8 }) }, + { id: 'frostnova', branch: 'cryo', tier: 1, type: 'active', maxRank: MAX_RANK, icon: 'frostnova', + name: { en: 'Frost Nova', vi: 'Tinh Băng' }, + desc: r => ({ en: `Freeze everything near you for ${1.0 + 0.22 * r}s and deal ${110 + 24 * r}% weapon damage.`, vi: `Đóng băng mọi thứ gần bạn ${1.0 + 0.22 * r}s và gây ${110 + 24 * r}% ST vũ khí.` }), + manaCost: r => 18 + 3 * r, cooldown: 8, + castType: 'nova', params: r => ({ radius: 3.0, dmgPct: 110 + 24 * r, elem: 'cold', freeze: 1.0 + 0.22 * r }) }, + { id: 'blizzard', branch: 'cryo', tier: 2, type: 'active', maxRank: MAX_RANK, icon: 'blizzard', + name: { en: 'Blizzard', vi: 'Bão Tuyết' }, + desc: r => ({ en: `A storm batters an area for 3s: ${90 + 20 * r}% weapon damage per second, heavily chilled.`, vi: `Bão tuyết cuồn cuộn 3s: ${90 + 20 * r}% ST vũ khí mỗi giây, làm lạnh mạnh.` }), + manaCost: r => 24 + 5 * r, cooldown: 12, + castType: 'groundAoE', params: r => ({ radius: 2.8, delay: 0.3, duration: 3, dmgPct: 90 + 20 * r, elem: 'cold', slow: 0.5, slowDur: 1.2 }) }, + + /* Stormweaving */ + { id: 'chainlightning', branch: 'storm', tier: 0, type: 'active', maxRank: MAX_RANK, icon: 'chainlightning', + name: { en: 'Chain Lightning', vi: 'Lôi Điện Xích' }, + desc: r => ({ en: `Lightning arcs between ${2 + r} enemies for ${150 + 33 * r}% weapon damage.`, vi: `Sét lan truyền giữa ${2 + r} kẻ địch, gây ${150 + 33 * r}% ST vũ khí.` }), + manaCost: r => 14 + 2 * r, cooldown: 2.6, + castType: 'chainLightning', params: r => ({ jumps: 2 + r, range: 5.5, dmgPct: 150 + 33 * r }) }, + { id: 'teleport', branch: 'storm', tier: 1, type: 'active', maxRank: MAX_RANK, icon: 'teleport', + name: { en: 'Teleport', vi: 'Di Chuyển' }, + desc: r => ({ en: `Instantly teleport to the cursor location (${4.5 + 0.5 * r} tile range).`, vi: `Dịch chuyển tức thời đến vị trí con trỏ (${(4.5 + 0.5 * r).toFixed(1)} ô).` }), + manaCost: r => 12 + r, cooldown: 4.5, + castType: 'blink', params: r => ({ range: 4.5 + 0.5 * r }) }, + { id: 'arcaneecho', branch: 'storm', tier: 2, type: 'passive', maxRank: MAX_RANK, icon: 'arcaneecho', + name: { en: 'Arcane Echo', vi: 'Vọng Âm Huyền Thuật' }, + desc: r => ({ en: `-(${3 + 3 * r})% cooldowns and +${0.3 + 0.25 * r} mana per second.`, vi: `-(giảm) ${3 + 3 * r}% thời gian hồi kỹ năng và +${(0.3 + 0.25 * r).toFixed(2)} mana/giây.` }), + mods: r => ({ cdrPct: 3 + 3 * r, manaRegenFlat: 0.3 + 0.25 * r }) }, + ]; + + const CLASSES = { + crusader: { + id: 'crusader', + name: { en: 'Crusader', vi: 'Thập Tự Quân' }, + branches: [ + { id: 'zeal', name: { en: 'Zeal', vi: 'Cuồng Tín' } }, + { id: 'wrath', name: { en: 'Wrath', vi: 'Thịnh Nộ' } }, + { id: 'bulwark', name: { en: 'Bulwark', vi: 'Bức Tường Thép' } }, + ], + skills: crusader, + baseMods: { str: 8, dex: 4, vit: 8, ene: 3 }, + weaponKind: 'melee', + palette: { body: '#8a8f9c', accent: '#c8a35a', cloth: '#5a2a2a' }, + basic: { castType: 'meleeArc', params: { arcDeg: 120, range: 1.45, dmgPct: 100 } }, + descKey: 'class.crusader.desc', + }, + ranger: { + id: 'ranger', + name: { en: 'Shadow Huntress', vi: 'Nữ Thợ Săn Bóng Đêm' }, + branches: [ + { id: 'mark', name: { en: 'Marksmanship', vi: 'Bách Bộ Xuyên Dương' } }, + { id: 'shadow', name: { en: 'Shadowcraft', vi: 'Nghệ Thuật Bóng Tối' } }, + { id: 'poach', name: { en: 'Poacher', vi: 'Thợ Săn Mồi' } }, + ], + skills: ranger, + baseMods: { str: 4, dex: 9, vit: 5, ene: 5 }, + weaponKind: 'ranged', + palette: { body: '#3e5a46', accent: '#8adf5a', cloth: '#2a3a30' }, + basic: { castType: 'projSpread', params: { count: 1, spreadDeg: 0, dmgPct: 100, speed: 12 } }, + descKey: 'class.crusader.desc'.replace('crusader', 'ranger'), + }, + sorceress: { + id: 'sorceress', + name: { en: 'Sorceress', vi: 'Pháp Sư' }, + branches: [ + { id: 'pyro', name: { en: 'Pyromancy', vi: 'Hỏa Thuật' } }, + { id: 'cryo', name: { en: 'Cryomancy', vi: 'Băng Thuật' } }, + { id: 'storm', name: { en: 'Stormweaving', vi: 'Lôi Pháp' } }, + ], + skills: sorceress, + baseMods: { str: 3, dex: 4, vit: 5, ene: 10 }, + weaponKind: 'cast', + palette: { body: '#5e3a72', accent: '#c86aff', cloth: '#3a2450' }, + basic: { castType: 'projSpread', params: { count: 1, spreadDeg: 0, dmgPct: 100, speed: 9, size: 1.2 } }, + descKey: 'class.sorceress.desc', + }, + }; + + /** all skills of a class flattened, with tier unlock level attached */ + function skillsFor(clsId) { + const c = CLASSES[clsId]; + return c.skills.map(s => Object.assign({}, s, { unlockLevel: D2.BAL.tierUnlockLevels[s.tier] || 1 })); + } + + function findSkill(clsId, skillId) { + return CLASSES[clsId].skills.find(s => s.id === skillId); + } + + D2.Skills = { CLASSES, skillsFor, findSkill, MAX_RANK }; +})(window.D2); diff --git a/js/game/ai.js b/js/game/ai.js new file mode 100644 index 0000000..176d25f --- /dev/null +++ b/js/game/ai.js @@ -0,0 +1,609 @@ +/* ============================================================ + * Diablo2D — ai.js : monster behavior trees + boss scripts + * ============================================================ */ +'use strict'; +window.D2 = window.D2 || {}; +(function (D2) { + + let astarBudget = 0; // global per-frame budget for pathfinding + + const rnd = Math.random; + + function resetFrame() { astarBudget = 6; } + + /* ---------------- main entry ---------------- */ + + function updateMonster(game, m, dt) { + if (m.dead) return; + + /* statuses */ + if (m.frozen > 0) { + m.frozen -= dt; + m.moving = false; + D2.combat.updateDots(game, m, dt, false); + return; + } + D2.combat.updateDots(game, m, dt, false); + + /* regen elite */ + if (m.regenPct && m.hp < m.maxHp) { + m.regenAcc += dt; + if (m.regenAcc >= 1) { m.hp = Math.min(m.maxHp, m.hp + m.maxHp * m.regenPct); m.regenAcc -= 1; } + } + + const p = game.player; + if (!p || p.dead) { m.moving = false; return; } + + const dx = p.x - m.x, dy = p.y - m.y; + const dist = Math.sqrt(dx * dx + dy * dy); + m.distToPlayer = dist; + + /* aggro acquisition */ + m.aggroCheckT = (m.aggroCheckT || 0) - dt; + if (!m.aggro && m.aggroCheckT <= 0) { + m.aggroCheckT = 0.35; + if (dist < D2.BAL.aggroRange && + D2.path.hasLOS((x, y) => game.world.transparent(x, y), + m.x | 0, m.y | 0, p.x | 0, p.y | 0)) { + m.aggro = true; + if (m.isBoss && !m.introDone) bossIntro(game, m); + else if (!m.isBoss && rnd() < 0.25) game.sfx('die', { vol: 0.08 }); + } + } + + /* leash */ + if (m.aggro && !m.isBoss) { + const homeDist = Math.hypot(m.x - m.homeX, m.y - m.homeY); + if (homeDist > D2.BAL.deaggroLeash) { + m.aggro = false; + m.hp = Math.min(m.maxHp, m.hp + m.maxHp * 0.3); + m.x = m.homeX; m.y = m.homeY; + } + } + + /* cooldowns */ + m.cdT -= dt; + m.specialCd -= dt; + if (m.attackAnim > 0) m.attackAnim -= dt; + if (m.hurtFlash > 0) m.hurtFlash -= dt; + if (m.chillT > 0) { m.chillT -= dt; if (m.chillT <= 0) m.chillSlow = 0; } + + const speed = m.speed * (1 - (m.chillSlow || 0)) * (m.speedBuff || 1); + + /* boss script takes priority */ + if (m.isBoss) { bossUpdate(game, m, dt, dist, dx, dy); return; } + + switch (m.def.ai) { + case 'melee': case 'swarm': meleeBrain(game, m, dt, dist, dx, dy, speed); break; + case 'flyer': flyerBrain(game, m, dt, dist, dx, dy, speed); break; + case 'ranged': rangedBrain(game, m, dt, dist, dx, dy, speed); break; + case 'caster': casterBrain(game, m, dt, dist, dx, dy, speed); break; + case 'summoner': summonerBrain(game, m, dt, dist, dx, dy, speed); break; + case 'charger': chargerBrain(game, m, dt, dist, dx, dy, speed); break; + default: meleeBrain(game, m, dt, dist, dx, dy, speed); + } + + separation(game, m, dt); + } + + /* ---------------- shared movement ---------------- */ + + function steer(game, m, dx, dy, speed, dt, ignoreWalls = false) { + const d = Math.hypot(dx, dy) || 1; + let vx = dx / d, vy = dy / d; + + if (!ignoreWalls) { + /* try direct; if blocked ahead use pathfinding fallback */ + const probeX = m.x + vx * 0.5, probeY = m.y + vy * 0.5; + if (!game.world.isWalkable(probeX, probeY)) { + if (astarBudget > 0) { + astarBudget--; + m.pathT -= 0.6; // force refresh + const path = D2.path.find( + (x, y) => game.world.isWalkable(x, y), + game.world.w, game.world.h, + m.x | 0, m.y | 0, (m.x + vx * 4) | 0, (m.y + vy * 4) | 0, 900); + m.path = path; + m.pathIdx = 0; + } + if (m.path && m.pathIdx < m.path.length) { + const node = m.path[m.pathIdx]; + const ndx = node.x + 0.5 - m.x, ndy = node.y + 0.5 - m.y; + const nd = Math.hypot(ndx, ndy) || 1; + if (nd < 0.4) m.pathIdx++; + else { vx = ndx / nd; vy = ndy / nd; } + } else { + /* slide along wall */ + const tx = -vy, ty = vx; + vx = tx; vy = ty; + } + } + } + + D2.combat.tryMove(game, m, vx * speed * dt, vy * speed * dt * 0.55); + m.moving = true; + } + + function separation(game, m, dt) { + const near = game.queryMonsters(m.x, m.y, m.radius + 0.55); + for (const o of near) { + if (o === m || o.dead) continue; + const dx = m.x - o.x, dy = m.y - o.y; + const d = Math.hypot(dx, dy) || 0.01; + const minD = m.radius + o.radius; + if (d < minD) { + const push = (minD - d) * 2.2; + D2.combat.tryMove(game, m, (dx / d) * push * dt * 8, (dy / d) * push * dt * 8 * 0.55); + } + } + } + + function startWindup(m, duration) { + m.state = 'windup'; + m.stateT = duration; + m.attackAnim = duration + 0.15; + } + + /* ---------------- brains ---------------- */ + + function meleeBrain(game, m, dt, dist, dx, dy, speed) { + if (!m.aggro) { idleWander(game, m, dt, speed * 0.4); return; } + if (m.state === 'windup') { + m.stateT -= dt; + if (m.stateT <= 0) { + D2.combat.monsterMeleeHit(game, m); + m.state = 'chase'; + m.cdT = m.attackCd * (m.cdMult || 1); + game.sfx('swing', { vol: 0.12 }); + } + return; + } + if (dist <= m.attackRange + game.player.radius && m.cdT <= 0) { + startWindup(m, 0.32); + return; + } + if (dist > m.attackRange * 0.8) { + steer(game, m, dx, dy, speed, dt); + /* swarm zigzag */ + if (m.def.ai === 'swarm' && rnd() < 0.05) { + D2.combat.tryMove(game, m, (rnd() - .5) * 0.3, (rnd() - .5) * 0.3); + } + } else { + m.moving = false; + } + } + + function flyerBrain(game, m, dt, dist, dx, dy, speed) { + if (!m.aggro) { idleWander(game, m, dt, speed * 0.6); return; } + if (m.state === 'windup') { + m.stateT -= dt; + if (m.stateT <= 0) { + D2.combat.monsterMeleeHit(game, m); + m.state = 'chase'; + m.cdT = m.attackCd * (m.cdMult || 1); + } + return; + } + if (dist <= m.attackRange + game.player.radius && m.cdT <= 0) { + startWindup(m, 0.26); + return; + } + /* flyers ignore walls */ + const d = dist || 1; + D2.combat.tryMove(game, m, (dx / d) * speed * dt, (dy / d) * speed * dt * 0.55, true); + m.moving = true; + /* keep inside floor-ish area */ + m.x = D2.util.clamp(m.x, 1, game.world.w - 1); + m.y = D2.util.clamp(m.y, 1, game.world.h - 1); + } + + function rangedBrain(game, m, dt, dist, dx, dy, speed) { + if (!m.aggro) { idleWander(game, m, dt, speed * 0.4); return; } + const minBand = m.attackRange * 0.45; + if (m.state === 'windup') { + m.stateT -= dt; + if (m.stateT <= 0) { + D2.combat.fireMonsterProjectile(game, m); + m.state = 'chase'; + m.cdT = m.attackCd * (m.cdMult || 1); + } + return; + } + if (dist <= m.attackRange && m.cdT <= 0 && + D2.path.hasLOS((x, y) => game.world.transparent(x, y), m.x | 0, m.y | 0, game.player.x | 0, game.player.y | 0)) { + startWindup(m, 0.4); + return; + } + if (dist < minBand) steer(game, m, -dx, -dy, speed, dt); + else if (dist > m.attackRange) steer(game, m, dx, dy, speed, dt); + else m.moving = false; + } + + function casterBrain(game, m, dt, dist, dx, dy, speed) { + if (!m.aggro) { idleWander(game, m, dt, speed * 0.4); return; } + /* blink away when pressured */ + if (m.special === 'blinkAway' && dist < 2.6 && m.specialCd <= 0) { + blinkAway(game, m); + m.specialCd = 5; + return; + } + rangedBrain(game, m, dt, dist, dx, dy, speed); + } + + function summonerBrain(game, m, dt, dist, dx, dy, speed) { + if (!m.aggro) { idleWander(game, m, dt, speed * 0.4); return; } + if (m.summon && m.specialCd <= 0) { + const minions = game.countMinionsOf(m.uid); + if (minions < m.summon.maxAlive) { + for (let i = 0; i < m.summon.count; i++) { + const ang = rnd() * Math.PI * 2; + const sx = m.x + Math.cos(ang) * 1.2, sy = m.y + Math.sin(ang) * 1.2; + if (D2.combat.canStand(game.world, sx, sy, 0.3)) { + game.spawnMonster(m.summon.species, sx, sy, { ownerUid: m.uid }); + game.spawnParticles(sx, sy, { count: 8, color: '#b08aff', speed: 2.5, life: 0.5, size: 2.4, z: 8 }); + } + } + game.sfx('buff', { vol: .3 }); + m.specialCd = 7; + } else { + m.specialCd = 2; + } + return; + } + casterBrain(game, m, dt, dist, dx, dy, speed); + } + + function chargerBrain(game, m, dt, dist, dx, dy, speed) { + /* mid-charge */ + if (m.state === 'charging') { + m.stateT -= dt; + D2.combat.tryMove(game, m, m.chargeVX * dt, m.chargeVY * dt * 0.55, true); + m.moving = true; + game.spawnParticles(m.x, m.y, { count: 1, color: '#ff8a3a', speed: 1, life: 0.25, size: 2, z: 6 }); + /* contact damage */ + if (dist < m.radius + game.player.radius + 0.2 && !m.chargeHit) { + m.chargeHit = true; + D2.combat.monsterHitPlayer(game, m, 1.5, m.elem); + D2.render.addShake(0.25); + } + if (m.stateT <= 0) { + m.state = 'chase'; + m.cdT = 1.0; + m.specialCd = 4.5; + } + return; + } + /* begin charge */ + if (m.aggro && m.chargeRange && dist > 2.4 && dist < m.chargeRange && m.specialCd <= 0) { + const d = dist || 1; + m.chargeVX = (dx / d) * speed * 3.2; + m.chargeVY = (dy / d) * speed * 3.2 * 0.55; + m.state = 'charging'; + m.stateT = 0.55; + m.chargeHit = false; + m.telegraphed = 0.25; + game.sfx('bossroar', { vol: 0.18 }); + return; + } + meleeBrain(game, m, dt, dist, dx, dy, speed); + } + + function idleWander(game, m, dt, speed) { + m.wanderT = (m.wanderT || 0) - dt; + if (m.wanderT <= 0) { + m.wanderT = 1.5 + rnd() * 2.5; + m.wanderDX = (rnd() - .5) * 2; + m.wanderDY = (rnd() - .5) * 2; + } + if (m.wanderDX || m.wanderDY) { + D2.combat.tryMove(game, m, m.wanderDX * speed * dt, m.wanderDY * speed * dt * 0.55); + m.moving = true; + } else m.moving = false; + } + + function blinkAway(game, m) { + for (let tries = 0; tries < 12; tries++) { + const ang = rnd() * Math.PI * 2; + const d = 3.5 + rnd() * 3; + const nx = m.x + Math.cos(ang) * d, ny = m.y + Math.sin(ang) * d; + if (D2.combat.canStand(game.world, nx, ny, m.radius) && + game.world.tileAt(nx | 0, ny | 0) === D2.world.T.FLOOR) { + game.spawnParticles(m.x, m.y, { count: 10, color: '#b08aff', speed: 2.5, life: 0.4, size: 2.4, z: 8 }); + m.x = nx; m.y = ny; + game.spawnParticles(nx, ny, { count: 10, color: '#b08aff', speed: 2.5, life: 0.4, size: 2.4, z: 8 }); + game.sfx('teleport', { vol: .3 }); + return true; + } + } + return false; + } + + /* ---------------- boss brain ---------------- */ + + function bossIntro(game, m) { + m.introDone = true; + m.aggro = true; + game.sfx('bossroar'); + D2.render.addShake(0.5); + game.toast(D2.i18n.t('msg.boss_spawn'), 'bad'); + game.showBossBar(m); + if (m.bossDef.intro) game.addFloatText(m.x, m.y - 1, m.bossDef.intro[D2.i18n.getLang()] || m.bossDef.intro.en, '#ff8a6a', 16); + } + + function bossUpdate(game, m, dt, dist, dx, dy) { + const p = game.player; + const speed = m.speed * (1 - (m.chillSlow || 0)) * (m.speedBuff || 1); + + /* phase 2 enrage */ + if (!m.phase2 && m.hp < m.maxHp * 0.5 && m.attacks.includes('enrage')) { + m.phase2 = true; + m.speedBuff = 1.3; + m.cdMult = 0.72; + game.sfx('bossroar'); + D2.render.addShake(0.5); + game.toast('⚠ ' + m.name + ' ⚠', 'bad'); + game.spawnParticles(m.x, m.y, { count: 30, color: '#ff4a1a', speed: 6, life: 0.7, size: 3, z: 12 }); + } + + m.bossAtkT = (m.bossAtkT || 1.2) - dt; + + /* executing a scripted attack sequence */ + if (m.busyT > 0) { + m.busyT -= dt; + if (m.busyAttack) m.busyAttack(game, m, dt); + if (m.busyT <= 0 && m.busyEnd) { const f = m.busyEnd; m.busyEnd = null; f(); } + return; + } + + /* pick an attack */ + if (m.bossAtkT <= 0 && !p.dead) { + const chosen = pickBossAttack(game, m, dist); + if (chosen) { + m.bossAtkT = (m.isElite ? 1.2 : 1.7) * (m.cdMult || 1) + rnd() * 0.8; + return; + } + m.bossAtkT = 0.5; + } + + /* default: chase & melee */ + if (dist <= m.attackRange + p.radius && m.cdT <= 0) { + startWindup(m, 0.38); + m.pendingBasic = true; + } else if (m.state === 'windup') { + m.stateT -= dt; + if (m.stateT <= 0) { + D2.combat.monsterMeleeHit(game, m); + m.cdT = m.attackCd * (m.cdMult || 1) * 0.7; + m.state = 'chase'; + game.sfx('swing', { vol: 0.2 }); + } + return; + } else if (dist > m.attackRange * 0.85) { + steer(game, m, dx, dy, speed, dt); + } else m.moving = false; + } + + function pickBossAttack(game, m, dist) { + const p = game.player; + const candidates = []; + for (const a of m.attacks || []) { + switch (a) { + case 'cleaveArc': if (dist < 3.2) candidates.push({ a, w: 3 }); break; + case 'chargeAt': if (dist > 3 && dist < 10) candidates.push({ a, w: 2.5 }); break; + case 'lunge': if (dist > 2 && dist < 5) candidates.push({ a, w: 2 }); break; + case 'boneSpearVolley': if (dist < 11) candidates.push({ a, w: 2.5 }); break; + case 'webVolley': if (dist < 10) candidates.push({ a, w: 2.5 }); break; + case 'summonFallen': if ((m.summonsDone || 0) < 2) candidates.push({ a, w: 1.5 }); break; + case 'summonSkeletons': candidates.push({ a, w: 1.6 }); break; + case 'spawnSpiderlings': candidates.push({ a, w: 1.8 }); break; + case 'curseRing': if (dist < 6) candidates.push({ a, w: 1.8 }); break; + case 'poisonPools': if (dist < 9) candidates.push({ a, w: 2.2 }); break; + case 'fireNovaRing': if (dist < 7) candidates.push({ a, w: 2.4 }); break; + case 'lightningSpiral': candidates.push({ a, w: 2 }); break; + case 'meteorRain': candidates.push({ a, w: 2.2 }); break; + } + } + if (!candidates.length) return null; + const total = candidates.reduce((s, c) => s + c.w, 0); + let roll = rnd() * total; + let chosen = candidates[candidates.length - 1]; + for (const c of candidates) { roll -= c.w; if (roll <= 0) { chosen = c; break; } } + execBossAttack(game, m, chosen.a, dist); + return chosen.a; + } + + function execBossAttack(game, m, attack, dist) { + const p = game.player; + switch (attack) { + case 'cleaveArc': { + m.attackAnim = 0.5; + game.sfx('bossroar', { vol: 0.25 }); + busy(m, 0.5, () => { + const baseAng = Math.atan2(p.y - m.y, p.x - m.x); + game.addEffect({ + type: 'swingArc', x: m.x, y: m.y, t: 0, dur: 0.22, + ang: baseAng, arc: Math.PI, range: 3.2, color: '#ff8a5a', + }); + const pd = D2.util.dist(p.x, p.y, m.x, m.y); + const ang = Math.atan2(p.y - m.y, p.x - m.x); + if (pd < 3.4 && Math.abs(D2.util.wrapAngle(ang - baseAng)) < Math.PI / 2) { + D2.combat.monsterHitPlayer(game, m, 1.6, null); + D2.render.addShake(0.35); + } + }); + break; + } + case 'chargeAt': case 'lunge': { + const d = dist || 1; + const spd = attack === 'chargeAt' ? 14 : 11; + m.chargeVX = (p.x - m.x) / d * spd; + m.chargeVY = (p.y - m.y) / d * spd * 0.55; + m.chargeHit = false; + m.busyT = attack === 'chargeAt' ? 0.6 : 0.4; + m.busyAttack = (g, mm, dt) => { + D2.combat.tryMove(g, mm, mm.chargeVX * dt, mm.chargeVY * dt * 0.55, true); + g.spawnParticles(mm.x, mm.y, { count: 2, color: '#ff6a3a', speed: 2, life: 0.3, size: 2.4, z: 8 }); + const dd = D2.util.dist(p.x, p.y, mm.x, mm.y); + if (dd < mm.radius + p.radius + 0.3 && !mm.chargeHit) { + mm.chargeHit = true; + D2.combat.monsterHitPlayer(g, mm, 1.5, null); + D2.render.addShake(0.4); + } + mm.moving = true; + }; + game.sfx('bossroar', { vol: 0.2 }); + break; + } + case 'summonFallen': case 'summonSkeletons': case 'spawnSpiderlings': { + const speciesMap = { summonFallen: 'fallen', summonSkeletons: 'skeleton', spawnSpiderlings: 'tomb_spider' }; + const species = speciesMap[attack]; + const n = attack === 'spawnSpiderlings' ? 4 : 3; + m.summonsDone = (m.summonsDone || 0) + 1; + game.sfx('buff'); + for (let i = 0; i < n; i++) { + const ang = (i / n) * Math.PI * 2; + const sx = m.x + Math.cos(ang) * 2, sy = m.y + Math.sin(ang) * 2 * 0.6; + if (D2.combat.canStand(game.world, sx, sy, 0.3)) + game.spawnMonster(species, sx, sy, { ownerUid: m.uid, aggroed: true }); + } + game.spawnParticles(m.x, m.y, { count: 20, color: '#b08aff', speed: 4, life: 0.6, size: 2.6, z: 10 }); + busy(m, 0.7); + break; + } + case 'frenzy': case 'enrage': break; // handled in phase logic + case 'boneSpearVolley': { + m.attackAnim = 0.45; + const n = m.phase2 ? 7 : 5; + const base = Math.atan2(p.y - m.y, p.x - m.x); + for (let i = 0; i < n; i++) { + const a = base + (i / (n - 1) - 0.5) * 0.9; + game.projectiles.push(new D2.entities.Projectile({ + x: m.x, y: m.y, + vx: Math.cos(a) * 7.5, vy: Math.sin(a) * 7.5 * 0.55, + speed: 7.5, dmg: m.dmg * 0.85, elem: 'phys', fromPlayer: false, + visual: 'bolt', color: '#d8e8b0', life: 2.2, + })); + } + game.sfx('shoot', { vol: .4 }); + busy(m, 0.45); + break; + } + case 'webVolley': { + const n = 3; + const base = Math.atan2(p.y - m.y, p.x - m.x); + for (let i = 0; i < n; i++) { + const a = base + (i - 1) * 0.28; + game.projectiles.push(new D2.entities.Projectile({ + x: m.x, y: m.y, + vx: Math.cos(a) * 6, vy: Math.sin(a) * 6 * 0.55, + speed: 6, dmg: m.dmg * 0.6, elem: 'phys', fromPlayer: false, + visual: 'orb', color: '#d8f0c8', + slow: 0.6, slowDur: 2.2, life: 2.4, + })); + } + game.sfx('poison', { vol: .4 }); + busy(m, 0.5); + break; + } + case 'curseRing': { + game.sfx('nova', { vol: .5 }); + game.addEffect({ + type: 'telegraph', x: m.x, y: m.y, t: 0, delay: 0.9, radius: 5.5, color: '#b08aff', + onDone: () => { + game.addEffect({ type: 'nova', x: m.x, y: m.y, t: 0, dur: 0.5, radius: 5.5, color: '#b08aff', lightPunch: 6 }); + const dd = D2.util.dist(p.x, p.y, m.x, m.y); + if (dd <= 5.5) { + D2.combat.monsterHitPlayer(game, m, 1.1, null); + game.player.chillT = 2; game.player.chillSlow = 0.4; + } + }, + }); + busy(m, 0.9); + break; + } + case 'poisonPools': { + game.sfx('poison'); + for (let i = 0; i < 3; i++) { + const ox = (i - 1) * 1.8 + (rnd() - .5); + const oy = (rnd() - .5) * 2; + game.addEffect({ + type: 'groundAoE', x: p.x + ox, y: p.y + oy, t: 0, + delay: 0.55, duration: 3.5, tickT: 0, + radius: 1.6, dmgPct: m.dmg * 0.5, elem: 'pois', fromMonster: true, + color: '#8adf5a', lightPunch: 2, + }); + } + busy(m, 0.55); + break; + } + case 'fireNovaRing': { + game.sfx('nova'); + const waves = [[2.6, 0], [4.2, 0.45], [5.8, 0.9]]; + for (const [radius, delay] of waves) { + game.addEffect({ + type: 'telegraph', x: m.x, y: m.y, t: 0, delay: delay + 0.35, radius, color: '#ff6a2a', + onDone: () => { + game.addEffect({ type: 'nova', x: m.x, y: m.y, t: 0, dur: 0.5, radius, color: '#ff6a2a', lightPunch: radius }); + const dd = D2.util.dist(p.x, p.y, m.x, m.y); + if (Math.abs(dd - radius) < 1.1 || dd < radius * 0.4) { + D2.combat.monsterHitPlayer(game, m, 1.0, 'fire'); + } + game.spawnParticles(m.x, m.y, { count: 18, color: '#ff8a3a', speed: 6, life: 0.5, size: 2.8, z: 10 }); + }, + }); + } + busy(m, 1.3); + break; + } + case 'lightningSpiral': { + game.sfx('lightning'); + const shots = m.phase2 ? 30 : 22; + const durTotal = 1.3; + for (let i = 0; i < shots; i++) { + const angle = (i / shots) * Math.PI * 4; // double spiral + game.addEffect({ + type: 'timed', t: 0, endAt: 0.15 + (i / shots) * durTotal, fired: false, + fire: (g) => { + const a = angle + g.time * 2; + g.projectiles.push(new D2.entities.Projectile({ + x: m.x, y: m.y, + vx: Math.cos(a) * 6.5, vy: Math.sin(a) * 6.5 * 0.55, + speed: 6.5, dmg: m.dmg * 0.55, elem: 'lit', fromPlayer: false, + visual: 'bolt', color: '#ffee6a', life: 1.8, + })); + if (i % 6 === 0) game.sfx('lightning', { vol: .2 }); + }, + }); + } + busy(m, durTotal + 0.2); + break; + } + case 'meteorRain': { + game.sfx('fireball'); + const n = m.phase2 ? 6 : 4; + for (let i = 0; i < n; i++) { + game.addEffect({ + type: 'telegraph', + x: p.x + (rnd() - .5) * 6, y: p.y + (rnd() - .5) * 4, + t: 0, delay: 0.7 + i * 0.22, radius: 1.7, color: '#ff5a1a', + onDone: (fx) => { + D2.combat.dealRadiusDamage(game, fx.x, fx.y, 1.7, m.dmg * 1.2, { elem: 'fire', fromMonster: true }); + game.spawnParticles(fx.x, fx.y, { count: 14, color: '#ff8a3a', speed: 5, life: 0.5, size: 2.8, z: 12, gravity: 10 }); + game.sfx('explode', { vol: .4 }); + }, + }); + } + busy(m, 1.6); + break; + } + } + } + + function busy(m, t, onEnd) { + m.busyT = t; + m.busyAttack = null; + m.busyEnd = onEnd || null; + } + + D2.ai = { updateMonster, resetFrame, blinkAway }; +})(window.D2); diff --git a/js/game/combat.js b/js/game/combat.js new file mode 100644 index 0000000..bd19fc9 --- /dev/null +++ b/js/game/combat.js @@ -0,0 +1,841 @@ +/* ============================================================ + * Diablo2D — combat.js : damage pipeline & skill implementation + * ============================================================ */ +'use strict'; +window.D2 = window.D2 || {}; +(function (D2) { + + const BAL = () => D2.BAL; + const rnd = Math.random; + const ri = (a, b) => a + Math.floor(rnd() * (b - a + 1)); + + /* ================= player damage ================= */ + + function mainStatPct(player) { + const s = player.stats; + const cls = player.classId; + if (cls === 'crusader') return s.str * BAL().meleeDmgPerStr; + if (cls === 'ranger') return s.dex * BAL().rangedDmgPerDex; + return s.ene * BAL().castDmgPerEne; + } + + function weaponDamageRange(player) { + const w = player.equip.weapon; + const s = player.stats; + let min = 2, max = 4; + if (w && w.dmgMax) { min = w.dmgMin; max = w.dmgMax; } + if (s.flatDmg) { min += s.flatDmg; max += s.flatDmg; } + const mult = 1 + mainStatPct(player) / 100 + (s.dmgPct || 0) / 100; + return { min: Math.max(1, Math.round(min * mult)), max: Math.max(2, Math.round(max * mult)) }; + } + + function rollPlayerHit(player, dmgPct, opts = {}) { + const r = weaponDamageRange(player); + let dmg = (ri(r.min, r.max)) * (dmgPct / 100); + const s = player.stats; + const elem = opts.elem || 'phys'; + if (elem === 'fire' && s.fireDmg) dmg += s.fireDmg; + else if (elem === 'cold' && s.coldDmg) dmg += s.coldDmg; + else if (elem === 'lit' && s.litDmg) dmg += s.litDmg; + else if (elem === 'pois' && s.poisDmg) dmg += s.poisDmg; + else if (elem === 'phys') { + dmg += ((s.fireDmg || 0) + (s.coldDmg || 0) + (s.litDmg || 0) + (s.poisDmg || 0)) * 0.5; + } + /* elite slayer */ + if (opts.target && opts.target.isElite && s.eliteDmgPct) dmg *= 1 + s.eliteDmgPct / 100; + /* last stand */ + const hpPct = player.hp / player.maxHp; + if (s.lowLifeDmg && hpPct < 0.35) dmg *= 1 + s.lowLifeDmg / 100; + + /* crit */ + let crit = false; + if (!opts.noCrit) { + const cc = (s.critChance || 0) / 100 + BAL().baseCrit; + if (opts.guaranteedCrit || rnd() < Math.min(cc, BAL().critCap)) { + crit = true; + dmg *= 1 + BAL().baseCritDmg - 1 + (s.critDmgPct || 0) / 100; + } + } + return { dmg: Math.max(1, Math.round(dmg)), crit }; + } + + /* ================= damage application ================= */ + + function applyToMonster(game, m, hit, opts = {}) { + if (m.dead) return 0; + let dmg = hit.dmg; + /* monster armor: implicit via level, mild */ + const armor = BAL().mArmor(m.level) * (m.isBoss ? 1.6 : 1); + const dr = armor / (armor + 40 + 8 * (game.player ? game.player.level : 10)); + dmg *= (1 - dr * 0.5); + /* resists */ + const elem = opts.elem || 'phys'; + if (elem !== 'phys' && m.resists && m.resists[elem]) dmg *= (1 - m.resists[elem]); + dmg = Math.max(1, Math.round(dmg)); + + m.hp -= dmg; + m.hurtFlash = BAL().hitFlashTime; + + /* floating number */ + game.addFloatText(m.x, m.y, (hit.crit ? '' : '') + dmg, + hit.crit ? '#ffd24a' : elemColor(opts.elem), hit.crit ? 16 : 12); + if (hit.crit) { + game.sfx('crit', { vol: .5 }); + D2.render.addShake(0.12); + } else { + game.sfx(opts.silentHit ? null : 'hit', { vol: .35 }); + } + + /* blood particles */ + game.spawnParticles(m.x, m.y, { + count: hit.crit ? 10 : 6, color: elemBlood(opts.elem), + speed: 3.5, life: 0.5, size: 2.6, z: 8, gravity: 14, + }); + + /* knockback */ + if (opts.knockback && !m.isBoss) { + const ang = Math.atan2(m.y - (opts.fromY ?? m.y - 1), m.x - (opts.fromX ?? m.x)); + const kb = opts.knockback; + tryMove(game, m, Math.cos(ang) * kb, Math.sin(ang) * kb, true); + } + + /* on-hit procs from player */ + if (opts.src === 'player') { + const s = game.player.stats; + /* lifesteal legendary */ + if (s.lifestealPct) healPlayer(game, dmg * s.lifestealPct); + /* chain on hit */ + if (s.chainOnHitPct && rnd() < s.chainOnHitPct) chainZap(game, m, 2, hit.dmg * 0.4); + /* venom strike */ + if (s.venomOnHit) applyDot(game, m, { elem: 'pois', dps: dmg * 0.25, dur: 3 }); + /* bleed passive */ + if (s.bleedOnHitPct && !opts.noBleed) { + applyDot(game, m, { elem: 'phys', dps: weaponDamageRange(game.player).max * s.bleedOnHitPct / 100 / 3, dur: 3, bleed: true }); + } + /* chill attacker flag on melee (handled in ai) */ + } + + if (m.hp <= 0) killMonster(game, m, { skillId: opts.skillId }); + else { + /* aggro on damage */ + m.aggro = true; + if (m.state === 'idle') m.state = 'chase'; + } + return dmg; + } + + function elemColor(elem) { + return { fire: '#ff8a4a', cold: '#8ad8ff', lit: '#ffee6a', pois: '#9adf3a', holy: '#ffe8b0', phys: '#f2ece0' }[elem] || '#f2ece0'; + } + function elemBlood(elem) { + return { fire: '#ff6a2a', cold: '#a8e0ff', lit: '#fff0a0', pois: '#8adf5a', holy: '#ffe8b0', phys: '#a8231a' }[elem] || '#a8231a'; + } + + function healPlayer(game, amount) { + const p = game.player; + if (p.dead) return; + const before = p.hp; + p.hp = Math.min(p.maxHp, p.hp + amount); + if (p.hp - before >= 1) { + game.addFloatText(p.x, p.y - 0.3, '+' + Math.round(p.hp - before), '#7ade8a', 11); + } + } + + /* chain lightning proc between monsters */ + function chainZap(game, from, jumps, dmg) { + let cur = from; + const hitSet = new Set([from.uid]); + for (let i = 0; i < jumps; i++) { + const next = game.nearestMonster(cur.x, cur.y, 5.5, m => !m.dead && !hitSet.has(m.uid)); + if (!next) break; + hitSet.add(next.uid); + /* zap visual */ + for (let k = 0; k < 6; k++) { + game.spawnParticles(D2.util.lerp(cur.x, next.x, k / 6), D2.util.lerp(cur.y, next.y, k / 6), { + count: 1, color: '#ffee6a', speed: 0.5, life: 0.18, size: 2.2, z: 10, + }); + } + applyToMonster(game, next, { dmg: Math.round(dmg), crit: false }, { elem: 'lit', src: 'player', silentHit: true }); + cur = next; + dmg *= 0.75; + } + } + + /* ================= monster → player ================= */ + + function monsterHitPlayer(game, m, mult = 1, elem = null) { + const p = game.player; + if (p.dead) return; + const s = p.stats; + + /* dodge */ + const dodge = Math.min(0.5, (s.dodgePct || 0) / 100 + p.level * BAL().dodgePerDex); + if (rnd() < dodge) { + game.addFloatText(p.x, p.y, 'dodge', '#9ab0c8', 11); + return; + } + /* block */ + if (s.blockChance && rnd() * 100 < s.blockChance) { + game.addFloatText(p.x, p.y, 'block', '#c8a35a', 12); + game.sfx('hit', { vol: .25 }); + return; + } + + let dmg = m.dmg * mult; + const e = elem || m.elem; + if (e && e !== 'phys') { + const resKey = { fire: 'resFire', cold: 'resCold', lit: 'resLit', pois: 'resPois' }[e]; + const res = Math.min(BAL().resistCap, (s[resKey] || 0) / 100); + dmg *= (1 - res); + } + /* armor DR */ + const armor = s.armor; + const dr = armor / (armor + BAL().armorK * m.level); + dmg *= (1 - Math.min(BAL().armorDrCap, dr)); + /* last stand DR */ + if (s.lowLifeDR && p.hp / p.maxHp < 0.35) dmg *= 1 - s.lowLifeDR / 100; + /* smoke veil dodge already; flat reduction none */ + + dmg = Math.max(1, Math.round(dmg)); + p.hp -= dmg; + p.hurtFlash = BAL().hitFlashTime; + game.addFloatText(p.x, p.y, '-' + dmg, '#ff6a5a', 13); + game.sfx('playerhurt', { vol: .5 }); + D2.render.addShake(0.18); + game.spawnParticles(p.x, p.y, { count: 5, color: '#a8231a', speed: 3, life: 0.4, size: 2.4, z: 8, gravity: 14 }); + + /* chill attacker response (frostweave) */ + if (s.chillAttacker && !m.chilledByPlayer) { + m.chillT = 2; m.chillSlow = 0.35; m.chilledByPlayer = true; + setTimeoutSafe(() => { m.chilledByPlayer = false; }, 2500); + } + /* vampiric monster heals */ + if (m.lifestealPct) m.hp = Math.min(m.maxHp, m.hp + dmg * m.lifestealPct); + + if (p.hp <= 0) game.onPlayerDeath(); + } + + function setTimeoutSafe(fn, ms) { + if (typeof setTimeout === 'function') setTimeout(fn, ms); + } + + /* ================= kill & rewards ================= */ + + function killMonster(game, m, opts = {}) { + if (m.dead) return; + m.dead = true; + m.dying = true; + const p = game.player; + + /* death effects */ + if (m.deathNova) { + game.addEffect({ + type: 'nova', x: m.x, y: m.y, t: 0, dur: 0.5, radius: m.deathNova.radius, + color: m.deathNova.elem === 'cold' ? '#8ad8ff' : '#ff8a3a', + onDone: () => dealRadiusDamage(game, m.x, m.y, m.deathNova.radius, + m.dmg * 1.2, { elem: m.deathNova.elem, fromMonster: true, chill: m.deathNova.chill }), + }); + } + if (m.corpsePoison) { + game.addGroundDecal(m.x, m.y, 1.4, 'rgba(120,220,80,.25)', 3); + game.addEffect({ + type: 'nova', x: m.x, y: m.y, t: 0, dur: 0.4, radius: 1.6, color: '#8adf5a', + onDone: () => dealRadiusDamage(game, m.x, m.y, 1.6, m.dmg, { elem: 'pois', fromMonster: true }), + }); + } + if (m.explodeOnDeath) { + const ex = m.explodeOnDeath; + game.addEffect({ + type: 'nova', x: m.x, y: m.y, t: 0, dur: 0.45, radius: ex.radius, + color: ex.elem === 'pois' ? '#8adf5a' : '#ff8a3a', + onDone: () => dealRadiusDamage(game, m.x, m.y, ex.radius, m.dmg * ex.dmgMult, { elem: ex.elem, fromMonster: true }), + }); + } + + /* corpse decal + gib particles */ + game.addGroundDecal(m.x, m.y, m.radius * 1.3, 'rgba(90,16,10,.5)', BAL().corpseTime); + game.spawnParticles(m.x, m.y, { + count: m.isBoss ? 40 : 12, color: '#7a1a10', speed: m.isBoss ? 7 : 4.5, + life: 0.8, size: 3, z: 10, gravity: 16, + }); + game.sfx(m.isBoss ? 'bossdie' : 'die', { vol: m.isBoss ? 0.8 : 0.45 }); + + /* rewards */ + const xpGain = Math.round(m.xpReward * (1 + (p.buffMod('xpGain') || 0))); + game.gainXp(xpGain, m); + if (game.onQuestKill) game.onQuestKill(m); + p.kills++; + if (m.isElite) { p.eliteKills++; game.toast(D2.i18n.t('msg.elite_slain'), 'gold'); } + if (m.isBoss) { p.bossKills++; game.onBossKilled(m); } + + /* life on kill */ + const s = p.stats; + if (s.lifeOnKill) healPlayer(game, s.lifeOnKill); + if (s.execHeal && (m.isElite || m.isBoss)) healPlayer(game, p.maxHp * 0.15); + if (s.goldPerKill) game.giveGold(Math.round(s.goldPerKill)); + + /* legendary on-kill effects */ + if (s.fireNovaOnKill) { + game.addEffect({ + type: 'nova', x: m.x, y: m.y, t: 0, dur: 0.4, radius: 2.2, color: '#ff8a3a', + onDone: () => dealRadiusDamage(game, m.x, m.y, 2.2, weaponDamageRange(p).max * 1.2, { elem: 'fire', src: 'player' }), + }); + } + if (s.corpseBoom && rnd() < 0.2) { + dealRadiusDamage(game, m.x, m.y, 2.0, weaponDamageRange(p).max, { elem: 'phys', src: 'player' }); + game.spawnParticles(m.x, m.y, { count: 16, color: '#ff8a3a', speed: 5, life: 0.5, size: 2.6, z: 8 }); + } + + /* loot */ + if (D2.loot) D2.loot.dropFor(game, m); + + if (m.isBoss) { + D2.render.addShake(0.6); + game.addLight(m.x, m.y, 8, '#ff8a3a', 0.9, 1.2); + } + game.onMonsterRemoved(m); + } + + /* AoE damage helper */ + function dealRadiusDamage(game, x, y, radius, dmg, opts = {}) { + if (opts.fromMonster) { + const p = game.player; + if (p && !p.dead) { + const d = D2.util.dist(p.x, p.y, x, y); + if (d <= radius + p.radius) monsterHitPlayer(game, { dmg: dmg, level: opts.level || game.player.level, elem: opts.elem }, 1, opts.elem); + } + } else { + const hits = game.queryMonsters(x, y, radius + 0.4); + for (const m of hits) { + applyToMonster(game, m, rollPlayerHit(game.player, dmg, { elem: opts.elem, noCrit: true, target: m }), + { elem: opts.elem, src: 'player' }); + } + } + } + + /* ================= dots & statuses ================= */ + + function applyDot(game, target, dot) { + target.dots = target.dots || []; + const existing = target.dots.find(d => d.elem === dot.elem); + if (existing) { existing.dps = Math.max(existing.dps, dot.dps); existing.t = Math.max(existing.t, dot.dur); } + else target.dots.push({ elem: dot.elem, dps: dot.dps, t: dot.dur, bleed: dot.bleed }); + } + + function updateDots(game, target, dt, isPlayer) { + if (!target.dots || !target.dots.length) return; + for (let i = target.dots.length - 1; i >= 0; i--) { + const d = target.dots[i]; + d.t -= dt; + const tick = d.dps * dt; + if (isPlayer) { + target.hp -= tick; + if (target.hp <= 0) game.onPlayerDeath(); + } else { + target.hp -= tick; + if (rnd() < dt * 3) game.addFloatText(target.x, target.y, Math.round(d.dps), elemColor(d.elem), 10); + if (target.hp <= 0) killMonster(game, target, {}); + } + if (d.t <= 0) target.dots.splice(i, 1); + } + } + + /* ================= movement helper ================= */ + + function tryMove(game, e, dx, dy, ignoreProps = false) { + const w = game.world; + const nx = e.x + dx, ny = e.y + dy; + const r = e.radius * 0.9; + /* axis-separated collision */ + if (canStand(w, nx, e.y, r, ignoreProps)) e.x = nx; + if (canStand(w, e.x, ny, r, ignoreProps)) e.y = ny; + } + + function canStand(world, x, y, r, ignoreProps) { + for (const [ox, oy] of [[r, 0], [-r, 0], [0, r], [0, -r], [r * .7, r * .7], [-r * .7, r * .7], [r * .7, -r * .7], [-r * .7, -r * .7]]) { + const tx = Math.floor(x + ox), ty = Math.floor(y + oy); + if (world.tileAt(tx, ty) !== D2.world.T.FLOOR) return false; + if (!ignoreProps && world.propMap.has(tx + ',' + ty)) return false; + } + return true; + } + + /* ================= player skills ================= */ + + function playerBasicAttack(game, aimX, aimY) { + const p = game.player; + if (p.attackCd > 0 || p.dead || p.channel) return; + const cls = D2.Skills.CLASSES[p.classId]; + const basic = cls.basic; + + if (basic.castType === 'meleeArc') { + doMeleeArc(game, p, aimX, aimY, basic.params.dmgPct, basic.params.arcDeg, basic.params.range); + } else { + const ang = Math.atan2(aimY - p.y, aimX - p.x); + fireProjectiles(game, p, ang, basic.params, 100); + if (p.stats.doubleShot) { + const ang2 = ang + 0.12; + fireProjectiles(game, p, ang2, { ...basic.params, count: 1, spreadDeg: 0 }, 60); + } + game.sfx('shoot', { vol: .3 }); + } + p.attackAnim = 0.28; + p.attackCd = 1 / ((p.equip.weapon ? p.equip.weapon.aps : 1.3) * (1 + (p.stats.attackSpeedPct || 0) / 100)); + } + + function doMeleeArc(game, p, aimX, aimY, dmgPct, arcDeg, range, opts = {}) { + const baseAng = Math.atan2(aimY - p.y, aimX - p.x); + const halfArc = (arcDeg * Math.PI / 180) / 2; + const hits = game.queryMonsters(p.x, p.y, range + 0.5); + let any = false; + for (const m of hits) { + const ang = Math.atan2(m.y - p.y, m.x - p.x); + if (Math.abs(D2.util.wrapAngle(ang - baseAng)) <= halfArc && + D2.util.dist(p.x, p.y, m.x, m.y) <= range + m.radius) { + const hit = rollPlayerHit(p, dmgPct, { elem: opts.elem, target: m, guaranteedCrit: p.guaranteedCritT > 0 }); + applyToMonster(game, m, hit, { elem: opts.elem, src: 'player', fromX: p.x, fromY: p.y, knockback: opts.knockback, skillId: opts.skillId }); + if (opts.stun && !m.isBoss) { m.frozen = Math.max(m.frozen, opts.stun); } + any = true; + } + } + p.guaranteedCritT = 0; + game.sfx(any ? 'hit' : 'swing', { vol: .4 }); + /* swing arc visual */ + game.addEffect({ + type: 'swingArc', x: p.x, y: p.y, t: 0, dur: 0.18, + ang: baseAng, arc: arcDeg * Math.PI / 180, range, + color: opts.elem ? elemColor(opts.elem) : '#e8d8b0', + }); + /* smash barrels & urns in the swing */ + if (game.world && game.world.props) { + for (const pr of [...game.world.props]) { + if (pr.type !== 'barrel' && pr.type !== 'urn') continue; + const cx = pr.x + 0.5, cy = pr.y + 0.5; + const d = D2.util.dist(p.x, p.y, cx, cy); + if (d < range + 0.5) { + const ang = Math.atan2(cy - p.y, cx - p.x); + if (Math.abs(D2.util.wrapAngle(ang - baseAng)) < halfArc + 0.3) { + breakProp(game, pr); + } + } + } + } + return any; + } + + function fireProjectiles(game, p, baseAng, params, dmgPct) { + const n = params.count || 1; + const spread = (params.spreadDeg || 0) * Math.PI / 180; + const elem = params.elem || (p.classId === 'sorceress' ? 'lit' : 'phys'); + for (let i = 0; i < n; i++) { + const off = n > 1 ? (i / (n - 1) - 0.5) * spread : 0; + const a = baseAng + off; + const hit = rollPlayerHit(p, dmgPct, { elem, noCrit: false }); + const pr = new D2.entities.Projectile({ + x: p.x + Math.cos(a) * 0.4, y: p.y + Math.sin(a) * 0.4, + vx: Math.cos(a) * (params.speed || 10), vy: Math.sin(a) * (params.speed || 10) * 0.55, + speed: params.speed || 10, + dmg: hit.dmg, crit: hit.crit, + elem, fromPlayer: true, + pierce: !!params.pierce, maxHits: params.pierce ? 99 : 1, + visual: elem === 'phys' ? (p.classId === 'ranger' ? 'arrow' : 'knife') : 'orb', + color: elemColor(elem), + size: params.size || 1, + slow: params.slow || 0, slowDur: params.slowDur || 0, + bleed: !!params.bleed, + freeze: params.freeze || 0, + life: 1.6, + }); + game.projectiles.push(pr); + } + } + + /* main skill cast dispatcher */ + function castSkill(game, player, skill, rank, aimX, aimY) { + if (player.dead || player.channel) return false; + const params = skill.params(rank); + const manaCost = skill.manaCost(rank); + if (player.mana < manaCost) { + game.toast(D2.i18n.t('msg.not_enough_mana'), 'bad'); + game.sfx('error'); + return false; + } + const cdLeft = game.skillCooldownLeft(skill.id); + if (cdLeft > 0) { + game.sfx('error'); + return false; + } + player.mana -= manaCost; + const cdr = Math.min(BAL().cooldownReductionCap, (player.stats.cdrPct || 0) / 100); + game.setSkillCooldown(skill.id, skill.cooldown * (1 - cdr)); + + const elem = params.elem || (skill.branch === 'pyro' ? 'fire' : skill.branch === 'cryo' ? 'cold' : skill.branch === 'storm' ? 'lit' : null); + + switch (skill.castType) { + case 'meleeArc': + doMeleeArc(game, player, aimX, aimY, params.dmgPct, params.arcDeg, params.range, { elem, knockback: params.knockback, stun: params.stun, skillId: skill.id }); + if (player.stats.cleaveEcho) { + setTimeoutSafe(() => { + if (!game.player.dead) doMeleeArc(game, game.player, aimX, aimY, params.dmgPct * 0.5, params.arcDeg, params.range + 0.3, { elem, skillId: skill.id, noCrit: true }); + }, 140); + } + game.sfx('swing', { vol: .5 }); + break; + + case 'whirlwind': + player.channel = { + type: 'whirlwind', t: params.duration, tick: 0, + params, skillId: skill.id, elem, + }; + game.sfx('nova', { vol: .4 }); + break; + + case 'dash': { + const ang = Math.atan2(aimY - player.y, aimX - player.x); + player.channel = { + type: 'dash', t: params.distance / params.speed, tick: 0, + vx: Math.cos(ang) * params.speed, vy: Math.sin(ang) * params.speed * 0.55, + params, skillId: skill.id, hitSet: new Set(), elem, + }; + game.sfx('nova', { vol: .35 }); + break; + } + + case 'cone': + doMeleeArc(game, player, aimX, aimY, params.dmgPct, params.arcDeg, params.range, { elem: 'phys', knockback: params.knockback, stun: params.stun, skillId: skill.id }); + D2.render.addShake(0.3); + game.sfx('explode', { vol: .4 }); + break; + + case 'projSpread': { + const ang = Math.atan2(aimY - player.y, aimX - player.x); + fireProjectiles(game, player, ang, params, params.dmgPct); + game.sfx(elem === 'fire' ? 'fireball' : elem === 'cold' ? 'ice' : elem === 'lit' ? 'lightning' : 'shoot', { vol: .45 }); + break; + } + + case 'novaProj': { + const n = params.count; + for (let i = 0; i < n; i++) { + const a = (i / n) * Math.PI * 2; + fireProjectiles(game, player, a, { count: 1, spreadDeg: 0, dmgPct: params.dmgPct, speed: params.speed, elem: 'phys' }, params.dmgPct); + } + game.sfx('swing', { vol: .5 }); + break; + } + + case 'nova': { + game.addEffect({ + type: 'nova', x: player.x, y: player.y, t: 0, dur: 0.45, radius: params.radius, + color: elemColor(elem), lightPunch: params.radius, + }); + const hits = game.queryMonsters(player.x, player.y, params.radius + 0.5); + for (const m of hits) { + const hit = rollPlayerHit(player, params.dmgPct, { elem, target: m, guaranteedCrit: player.guaranteedCritT > 0 }); + applyToMonster(game, m, hit, { elem, src: 'player', fromX: player.x, fromY: player.y, knockback: 1.2, skillId: skill.id }); + if (params.freeze && !m.isBoss) m.frozen = Math.max(m.frozen, params.freeze); + if (params.ignite) applyDot(game, m, { elem: 'fire', dps: hit.dmg * 0.3, dur: 2.5 }); + } + player.guaranteedCritT = 0; + game.sfx(elem === 'cold' ? 'ice' : 'nova', { vol: .6 }); + game.addLight(player.x, player.y, params.radius + 2, elemColor(elem), 0.8, 0.5); + break; + } + + case 'groundAoE': { + const clamped = clampAim(game, player, aimX, aimY, 8); + game.addEffect({ + type: 'groundAoE', x: clamped.x, y: clamped.y, t: 0, + delay: params.delay, duration: params.duration, tickT: 0, + radius: params.radius, dmgPct: params.dmgPct, elem, + slow: params.slow, slowDur: params.slowDur, skillId: skill.id, + color: elemColor(elem), lightPunch: params.radius, + }); + game.sfx(elem === 'cold' ? 'ice' : 'fireball', { vol: .5 }); + break; + } + + case 'volleyArea': { + const clamped = clampAim(game, player, aimX, aimY, 9); + for (let w = 0; w < params.waves; w++) { + game.addEffect({ + type: 'telegraph', x: clamped.x + (rnd() - .5) * params.radius, y: clamped.y + (rnd() - .5) * params.radius * 0.6, + t: 0, delay: 0.25 + w * params.waveDelay, radius: 1.3, color: '#d8cba8', + onDone: (fx) => { + dealRadiusDamage(game, fx.x, fx.y, 1.3, params.dmgPct, { elem: 'phys', src: 'player' }); + game.spawnParticles(fx.x, fx.y, { count: 5, color: '#d8cba8', speed: 3, life: 0.3, size: 2, z: 12, gravity: 18 }); + }, + }); + } + game.sfx('shoot', { vol: .5 }); + break; + } + + case 'meteor': { + const clamped = clampAim(game, player, aimX, aimY, 9); + game.addEffect({ + type: 'telegraph', x: clamped.x, y: clamped.y, t: 0, delay: params.delay, + radius: params.radius, color: '#ff5a1a', + onDone: (fx) => { + dealRadiusDamage(game, fx.x, fx.y, params.radius, params.dmgPct, { elem: 'fire', src: 'player' }); + game.spawnParticles(fx.x, fx.y, { count: 26, color: '#ff8a3a', speed: 7, life: 0.6, size: 3, z: 14, gravity: 10 }); + game.addGroundDecal(fx.x, fx.y, params.radius * 0.8, 'rgba(40,16,8,.55)', 6); + D2.render.addShake(0.5); + game.sfx('explode', { vol: .8 }); + game.addLight(fx.x, fx.y, params.radius + 3, '#ff8a3a', 1, 0.6); + }, + }); + game.sfx('fireball', { vol: .6 }); + break; + } + + case 'chainLightning': { + let cur = null; + /* first target nearest to aim */ + const aimHits = game.queryMonsters(aimX, aimY, 2.5); + cur = aimHits.length ? aimHits[0] : game.nearestMonster(player.x, player.y, params.range, m => !m.dead); + const hitSet = new Set(); + let dmgBase = params.dmgPct; + for (let j = 0; j < params.jumps && cur; j++) { + hitSet.add(cur.uid); + const hit = rollPlayerHit(player, dmgBase, { elem: 'lit', target: cur }); + applyToMonster(game, cur, hit, { elem: 'lit', src: 'player', skillId: skill.id }); + /* visual zap */ + for (let k = 0; k < 8; k++) { + game.spawnParticles( + D2.util.lerp(j === 0 ? player.x : prevX, cur.x, k / 8), + D2.util.lerp(j === 0 ? player.y : prevY, cur.y, k / 8), + { count: 1, color: '#ffee6a', speed: 0.4, life: 0.22, size: 2.4, z: 12 }); + } + var prevX = cur.x, prevY = cur.y; + game.addLight(cur.x, cur.y, 2.5, '#ffee6a', 0.7, 0.25); + cur = game.nearestMonster(cur.x, cur.y, params.range * 0.6, m => !m.dead && !hitSet.has(m.uid)); + dmgBase *= 0.8; + } + if (!hitSet.size) game.sfx('error'); + else game.sfx('lightning', { vol: .6 }); + break; + } + + case 'blink': { + const clamped = clampAim(game, player, aimX, aimY, params.range); + game.spawnParticles(player.x, player.y, { count: 12, color: '#b08aff', speed: 3, life: 0.4, size: 2.4, z: 10 }); + if (canStand(game.world, clamped.x, clamped.y, player.radius)) { + player.x = clamped.x; player.y = clamped.y; + } else { + /* step back toward player until valid */ + for (let s = 1; s <= 6; s++) { + const bx = D2.util.lerp(player.x, clamped.x, 1 - s / 6); + const by = D2.util.lerp(player.y, clamped.y, 1 - s / 6); + if (canStand(game.world, bx, by, player.radius)) { player.x = bx; player.y = by; break; } + } + } + game.spawnParticles(player.x, player.y, { count: 14, color: '#b08aff', speed: 3.5, life: 0.45, size: 2.6, z: 10 }); + game.sfx('teleport'); + if (params.guaranteedCritDur) { + player.guaranteedCritT = params.guaranteedCritDur; + player.guaranteedCritBonus = params.bonusCritDmg; + } + break; + } + + case 'buffSelf': { + player.buffs.push({ + id: skill.id, name: skill.name, icon: params.icon, + t: params.dur, dur: params.dur, mods: params.mods, + }); + if (skill.id === 'smokeveil') player.smokeveilT = params.dur; + game.sfx('buff'); + game.toast((skill.name[D2.i18n.getLang()] || skill.name.en) + '!'); + D2.player.recompute(game.player); + break; + } + + default: + console.warn('unknown castType', skill.castType); + return false; + } + return true; + } + + function clampAim(game, player, aimX, aimY, maxRange) { + const d = D2.util.dist(player.x, player.y, aimX, aimY); + if (d <= maxRange) { + if (canStand(game.world, aimX, aimY, 0.3)) return { x: aimX, y: aimY }; + } + const ang = Math.atan2(aimY - player.y, aimX - player.x); + const dd = Math.min(d, maxRange); + for (let s = dd; s > 0.5; s -= 0.4) { + const x = player.x + Math.cos(ang) * s, y = player.y + Math.sin(ang) * s; + if (canStand(game.world, x, y, 0.3)) return { x, y }; + } + return { x: player.x, y: player.y }; + } + + /* channel & dash per-frame update */ + function updateChannels(game, player, dt) { + const ch = player.channel; + if (!ch) return; + if (ch.type === 'whirlwind') { + ch.t -= dt; + ch.tick -= dt; + player.attackAnim = 0.2; + if (ch.tick <= 0) { + ch.tick = 0.18; + const hits = game.queryMonsters(player.x, player.y, ch.params.range + 0.4); + for (const m of hits) { + const hit = rollPlayerHit(player, ch.params.dmgPct, { elem: ch.elem, target: m, noCrit: rnd() > 0.2 }); + applyToMonster(game, m, hit, { elem: ch.elem, src: 'player', skillId: ch.skillId, silentHit: rnd() > 0.4 }); + } + game.spawnParticles(player.x, player.y, { count: 3, color: '#c8e8ff', speed: 4, life: 0.25, size: 2, z: 8 }); + if (hits.length) game.sfx('swing', { vol: .25 }); + } + if (ch.t <= 0) player.channel = null; + } else if (ch.type === 'dash') { + ch.t -= dt; + tryMove(game, player, ch.vx * dt, ch.vy * dt, true); + game.spawnParticles(player.x, player.y, { count: 2, color: '#ffb04a', speed: 1.5, life: 0.3, size: 2.2, z: 6 }); + const hits = game.queryMonsters(player.x, player.y, player.radius + 0.6); + for (const m of hits) { + if (ch.hitSet.has(m.uid)) continue; + ch.hitSet.add(m.uid); + const hit = rollPlayerHit(player, ch.params.dmgPct, { elem: ch.elem, target: m }); + applyToMonster(game, m, hit, { elem: ch.elem, src: 'player', fromX: player.x, fromY: player.y, skillId: ch.skillId }); + if (ch.params.stun && !m.isBoss) m.frozen = Math.max(m.frozen, ch.params.stun); + } + if (ch.t <= 0) player.channel = null; + } + } + + /* ================= monster attacks ================= */ + + function monsterMeleeHit(game, m) { + const d = D2.util.dist(m.x, m.y, game.player.x, game.player.y); + if (d <= m.attackRange + game.player.radius + 0.25) { + monsterHitPlayer(game, m, 1, m.elem); + if (m.chillOnHit) { game.player.chillT = 1.5; game.player.chillSlow = 0.25; } + } + } + + function fireMonsterProjectile(game, m, override = {}) { + const proj = m.proj; + if (!proj) return; + const p = game.player; + const ang = Math.atan2(p.y - m.y, p.x - m.x) + (rnd() - .5) * 0.08; + game.projectiles.push(new D2.entities.Projectile({ + x: m.x, y: m.y, + vx: Math.cos(ang) * proj.speed, vy: Math.sin(ang) * proj.speed * 0.55, + speed: proj.speed, + dmg: m.dmg * (override.dmgMult || 0.9), + elem: proj.elem || m.elem || 'phys', + fromPlayer: false, + visual: proj.kind === 'arrow' || proj.kind === 'firearrow' ? 'arrow' : 'bolt', + color: proj.color || '#fff', + slow: proj.slow || 0, slowDur: proj.slowDur || 0, + aoe: proj.aoe || 0, pool: proj.pool || false, + life: 2.4, + })); + game.sfx(proj.elem === 'fire' ? 'fireball' : proj.elem === 'pois' ? 'poison' : 'shoot', { vol: .3 }); + } + + /* projectile hit resolution (called by game loop) */ + function projectileHit(game, pr, target) { + if (pr.fromPlayer) { + const hit = { dmg: pr.dmg, crit: pr.crit }; + applyToMonster(game, target, hit, { + elem: pr.elem, src: 'player', + knockback: 0.3 * pr.size, + }); + if (pr.slow) { target.chillT = Math.max(target.chillT || 0, pr.slowDur); target.chillSlow = pr.slow; } + if (pr.freeze && !target.isBoss) target.frozen = Math.max(target.frozen, pr.freeze); + } else { + monsterHitPlayer(game, { dmg: pr.dmg, level: pr.level || game.player.level, elem: pr.elem }, 1, pr.elem); + if (pr.aoe) { + dealRadiusDamage(game, pr.x, pr.y, pr.aoe, pr.dmg * 0.6, { elem: pr.elem, fromMonster: true }); + game.spawnParticles(pr.x, pr.y, { count: 10, color: pr.color, speed: 4, life: 0.4, size: 2.6, z: 8 }); + } + if (pr.pool) { + game.addGroundDecal(pr.x, pr.y, 1.3, 'rgba(140,220,80,.3)', 3.5); + game.addEffect({ + type: 'groundAoE', x: pr.x, y: pr.y, t: 0, delay: 0.1, duration: 3, tickT: 0, + radius: 1.3, dmgPct: pr.dmg, elem: pr.elem, fromMonster: true, + color: '#8adf5a', + }); + } + } + } + + /* ground AoE ticking (both player & monster sourced) */ + function tickGroundAoe(game, fx, dt) { + fx.tickT -= dt; + if (fx.tickT <= 0) { + fx.tickT = 0.5; + if (fx.fromMonster) { + const p = game.player; + if (p && !p.dead && D2.util.dist(p.x, p.y, fx.x, fx.y) <= fx.radius) { + monsterHitPlayer(game, { dmg: fx.dmgPct, level: game.player.level, elem: fx.elem }, 0.5, fx.elem); + } + } else { + const hits = game.queryMonsters(fx.x, fx.y, fx.radius); + for (const m of hits) { + const hit = rollPlayerHit(game.player, fx.dmgPct * 0.5, { elem: fx.elem, noCrit: true, target: m }); + applyToMonster(game, m, hit, { elem: fx.elem, src: 'player', skillId: fx.skillId, silentHit: true }); + if (fx.slow) { m.chillT = Math.max(m.chillT || 0, fx.slowDur || 1); m.chillSlow = fx.slow; } + } + if (hits.length) game.spawnParticles(fx.x + (rnd() - .5) * fx.radius, fx.y + (rnd() - .5) * fx.radius * 0.6, { + count: 3, color: fx.color, speed: 2, life: 0.4, size: 2.2, z: 10, + }); + } + } + } + + /* ================= destructibles & shrines ================= */ + + function breakProp(game, prop) { + game.world.removeProp(prop); + game.sfx('explode', { vol: .3 }); + const col = prop.type === 'urn' ? '#b8a888' : '#8a6a42'; + game.spawnParticles(prop.x + 0.5, prop.y + 0.5, { count: 10, color: col, speed: 4, life: 0.5, size: 2.6, z: 8, gravity: 16 }); + if (D2.loot) D2.loot.rollPropLoot(game, prop.x + 0.5, prop.y + 0.5, prop.type); + } + + function activateShrine(game, prop) { + prop.used = true; + const BAL = D2.BAL; + const P = BAL.shrineBuffPower; + const defs = { + dmg: { id: 'shrine_dmg', mods: { dmgPct: P.dmg * 100 } }, + speed: { id: 'shrine_spd', mods: { moveSpeedPct: P.speed * 100 } }, + armor: { id: 'shrine_arm', mods: { armorPct: P.armor * 100 } }, + xp: { id: 'shrine_xp', mods: { xpGain: P.xp * 100 } }, + regen: { id: 'shrine_reg', mods: { regenHpFlat: P.regen } }, + }; + const d = defs[prop.buff] || defs.dmg; + const names = { + shrine_dmg: { en: 'Rage of the Fallen', vi: 'Thịnh Nộ Kẻ Sa Đọa' }, + shrine_spd: { en: 'Winds of the Deep', vi: 'Gió Vực Sâu' }, + shrine_arm: { en: 'Bulwark of Stone', vi: 'Tường Đá Bất Diệt' }, + shrine_xp: { en: 'Wisdom of Ages', vi: 'Trí Tuệ Nghìn Năm' }, + shrine_reg: { en: 'Fountain of Life', vi: 'Suối Nguồn Sống' }, + }; + game.player.buffs.push({ + id: d.id, name: names[d.id], icon: 'renewal', + t: BAL.shrineDuration, dur: BAL.shrineDuration, mods: d.mods, + }); + D2.player.recompute(game.player); + game.sfx('shrine'); + game.toast(D2.i18n.t('msg.shrine')); + game.spawnParticles(prop.x + .5, prop.y + .5, { count: 20, color: '#ffe86a', speed: 3.5, life: 0.8, size: 2.4, z: 12 }); + game.addLight(prop.x, prop.y, 4, '#ffe86a', 0.8, 1); + } + + D2.combat = { + weaponDamageRange, rollPlayerHit, + applyToMonster, dealRadiusDamage, killMonster, + monsterHitPlayer, monsterMeleeHit, fireMonsterProjectile, projectileHit, + applyDot, updateDots, tickGroundAoe, + playerBasicAttack, castSkill, updateChannels, doMeleeArc, fireProjectiles, + tryMove, canStand, breakProp, activateShrine, healPlayer, + elemColor, chainZap, + }; +})(window.D2); diff --git a/js/game/entities.js b/js/game/entities.js new file mode 100644 index 0000000..ef5ab2b --- /dev/null +++ b/js/game/entities.js @@ -0,0 +1,178 @@ +/* ============================================================ + * Diablo2D — entities.js : entity classes & spatial index + * ============================================================ */ +'use strict'; +window.D2 = window.D2 || {}; +(function (D2) { + + let nextUid = 1; + + /* ---------------- Player ---------------- */ + class Player { + constructor(classId) { + this.uid = nextUid++; + this.kind = 'player'; + this.classId = classId; + this.x = 0; this.y = 0; + this.radius = 0.34; + this.dead = false; + + /* progression */ + this.level = 1; + this.xp = 0; + this.gold = 60; + this.statPoints = 0; + this.skillPoints = 0; + this.attributes = { str: 0, dex: 0, vit: 0, ene: 0 }; // allocated points + this.skills = {}; // skillId -> rank + this.hotbar = ['basic', null, null, null, null]; // slot0=basic(LMB), 1=RMB, 2..4 keys + this.potions = { hp: 4, mp: 3 }; + this.inventory = []; // items + this.equip = { weapon: null, offhand: null, head: null, chest: null, hands: null, feet: null, amulet: null, ring1: null, ring2: null }; + + /* derived (recomputed) */ + this.stats = {}; + this.maxHp = 50; this.hp = 50; + this.maxMana = 20; this.mana = 20; + + /* movement / anim */ + this.moving = false; + this.walkPhase = 0; + this.attackAnim = 0; + this.hurtFlash = 0; + this.attackCd = 0; + this.channel = null; // whirlwind/dash state + this.guaranteedCritT = 0; + this.smokeveilT = 0; + + this.buffs = []; // {id,name,icon,t,dur,mods} + this.dots = []; + + /* run meta */ + this.kills = 0; + this.eliteKills = 0; + this.bossKills = 0; + this.deaths = 0; + } + } + + /* ---------------- Monster ---------------- */ + class Monster { + constructor(statBlock, x, y) { + Object.assign(this, statBlock); + this.uid = nextUid++; + this.kind = 'monster'; + this.x = x; this.y = y; + this.homeX = x; this.homeY = y; + this.dead = false; + this.dying = false; + + /* ai state */ + this.state = 'idle'; // idle | chase | windup | recover | charging | fleeing + this.aggro = false; + this.stateT = 0; + this.attackT = 0; // windup progress + this.attackAnim = 0; + this.hurtFlash = 0; + this.cdT = 0; // attack cooldown + this.specialCd = 0; + this.chargeVX = 0; this.chargeVY = 0; + this.path = null; this.pathIdx = 0; this.pathT = 0; + this.moving = false; this.speedFactor = 1; + + /* statuses */ + this.frozen = 0; + this.chillT = 0; this.chillSlow = 0; + this.dots = []; + this.regenAcc = 0; + } + } + + /* ---------------- Projectile ---------------- */ + const projectiles = []; + class Projectile { + constructor(o) { + Object.assign(this, { + x: 0, y: 0, vx: 0, vy: 0, + speed: 10, dmg: 5, elem: null, + fromPlayer: true, + pierce: false, + maxHits: 1, + hits: new Set(), + life: 2.2, + visual: 'bolt', + color: '#fff', + size: 1, + slow: 0, slowDur: 0, + bleed: false, + aoe: 0, + pool: false, + freeze: 0, + knockback: 0, + guaranteedCrit: false, + ownerSkillId: null, + z: 0, + }, o); + this.uid = nextUid++; + this.dead = false; + } + } + + /* ---------------- Pickup ---------------- */ + class Pickup { + constructor(kind, x, y, extra = {}) { + this.uid = nextUid++; + this.kind = kind; // gold | potion | item + this.x = x; this.y = y; + this.amount = extra.amount || 0; + this.item = extra.item || null; + this.potionType = extra.potionType || null; + this.t = 0; // age (for pop animation) + this.magnet = false; + } + } + + /* ---------------- Spatial hash for monsters ---------------- */ + class Grid { + constructor(cell = 2.5) { this.cell = cell; this.map = new Map(); } + key(x, y) { return ((x / this.cell) | 0) + ',' + ((y / this.cell) | 0); } + rebuild(entities) { + this.map.clear(); + for (const e of entities) { + if (e.dead) continue; + const k = this.key(e.x, e.y); + let arr = this.map.get(k); + if (!arr) { arr = []; this.map.set(k, arr); } + arr.push(e); + } + } + query(x, y, r, out = []) { + out.length = 0; + const c = this.cell; + const x0 = ((x - r) / c) | 0, x1 = ((x + r) / c) | 0; + const y0 = ((y - r) / c) | 0, y1 = ((y + r) / c) | 0; + for (let gy = y0; gy <= y1; gy++) { + for (let gx = x0; gx <= x1; gx++) { + const arr = this.map.get(gx + ',' + gy); + if (arr) for (const e of arr) { + const dx = e.x - x, dy = e.y - y; + if (dx * dx + dy * dy <= r * r) out.push(e); + } + } + } + return out; + } + nearest(x, y, maxR, filter) { + let best = null, bd = maxR * maxR; + const cand = this.query(x, y, maxR, []); + for (const e of cand) { + if (filter && !filter(e)) continue; + const d = (e.x - x) ** 2 + (e.y - y) ** 2; + if (d < bd) { bd = d; best = e; } + } + return best; + } + } + + D2.entities = { Player, Monster, Projectile, Pickup, Grid }; +})(window.D2); diff --git a/js/game/fov.js b/js/game/fov.js new file mode 100644 index 0000000..e687df6 --- /dev/null +++ b/js/game/fov.js @@ -0,0 +1,45 @@ +/* ============================================================ + * Diablo2D — fov.js : raycast visibility & fog of war + * ============================================================ */ +'use strict'; +window.D2 = window.D2 || {}; +(function (D2) { + + const RAY_COUNT = 160; + + /** + * Update world.visible / world.explored from (cx, cy) within radius. + * world must expose: w, h, transparent(x,y), visible(Uint8), explored(Uint8) + */ + function compute(world, cx, cy, radius) { + const { w, h } = world; + world.visible.fill(0); + const r2 = radius * radius; + + const mark = (x, y) => { + if (x < 0 || y < 0 || x >= w || y >= h) return; + const i = y * w + x; + world.visible[i] = 1; + world.explored[i] = 1; + }; + + mark(cx, cy); + + for (let i = 0; i < RAY_COUNT; i++) { + const ang = (i / RAY_COUNT) * Math.PI * 2; + const dx = Math.cos(ang), dy = Math.sin(ang); + let x = cx + 0.5, y = cy + 0.5; + for (let d = 0; d < radius; d += 0.5) { + x += dx * 0.5; y += dy * 0.5; + const tx = x | 0, ty = y | 0; + if (tx < 0 || ty < 0 || tx >= w || ty >= h) break; + const ddx = x - cx - 0.5, ddy = y - cy - 0.5; + if (ddx * ddx + ddy * ddy > r2) break; + mark(tx, ty); + if (!world.transparent(tx, ty)) break; + } + } + } + + D2.fov = { compute }; +})(window.D2); diff --git a/js/game/game.js b/js/game/game.js new file mode 100644 index 0000000..d813e40 --- /dev/null +++ b/js/game/game.js @@ -0,0 +1,1081 @@ +/* ============================================================ + * Diablo2D — game.js : central state machine & simulation loop + * ============================================================ */ +'use strict'; +window.D2 = window.D2 || {}; +(function (D2) { + + const G = { + state: 'boot', // boot|title|classselect|playing|paused|dead|victory|settings|help + world: null, + player: null, + monsters: [], + projectiles: [], + pickups: [], + particles: [], + floatTexts: [], + lights: [], + effects: [], + groundDecals: [], + time: 0, + + settings: { + masterVol: 0.8, musicVol: 0.55, sfxVol: 0.9, + lang: 'en', screenShake: true, dmgNumbers: true, labels: 'alt', + hardcore: false, + }, + progress: { act: 0, floorIdx: 0, torment: 0, bossesKilled: [], quests: {}, qCount: {} }, + waypointsUnlocked: [true, false, false, false], + stash: [], + vendorStock: [], + quests: [], + + skillCooldowns: {}, + hoverEntity: null, + moveMarker: null, + mouseWorld: { x: 0, y: 0 }, + + _grid: new D2.entities.Grid(2.5), + _fovTile: '', + _repathT: 0, + _autosaveT: 0, + bossRef: null, + }; + + /* ================= helpers used across modules ================= */ + + G.sfx = function (name, opts) { if (name) D2.audio.sfx(name, opts); }; + + G.toast = function (text, cls) { + if (D2.ui) D2.ui.toast(text, cls); + else console.log('[toast]', text); + }; + + G.addFloatText = function (x, y, text, color, size) { + if (!G.settings.dmgNumbers && /^\d/.test(String(text))) return; + if (G.floatTexts.length > D2.BAL.maxFloatingText) G.floatTexts.shift(); + G.floatTexts.push({ x, y, text, color: color || '#fff', size: size || 12, life: 0.9, maxLife: 0.9 }); + }; + + G.spawnParticles = function (x, y, o = {}) { + const n = Math.min(o.count || 6, D2.BAL.maxParticles - G.particles.length); + for (let i = 0; i < n; i++) { + const a = Math.random() * Math.PI * 2; + const sp = (o.speed || 3) * (0.4 + Math.random() * 0.8); + G.particles.push({ + x, y, z: o.z || 6, + vx: Math.cos(a) * sp, vy: Math.sin(a) * sp * 0.55, + vz: 2 + Math.random() * 3, + gravity: o.gravity != null ? o.gravity : 10, + color: o.color || '#fff', + size: (o.size || 2.2) * (0.6 + Math.random() * 0.7), + life: (o.life || 0.5) * (0.7 + Math.random() * 0.5), + maxLife: 1, alpha: o.alpha, + }); + const pt = G.particles[G.particles.length - 1]; + pt.maxLife = pt.life; + } + }; + + G.addLight = function (x, y, radius, color, intensity, dur) { + G.lights.push({ x, y, radius, color, intensity, t: dur || 0.4, maxT: dur || 0.4 }); + if (G.lights.length > 40) G.lights.shift(); + }; + + G.addEffect = function (fx) { + fx.t = fx.t || 0; + G.effects.push(fx); + if (G.effects.length > 120) G.effects.shift(); + }; + + G.addGroundDecal = function (x, y, r, color, life) { + G.groundDecals.push({ x, y, r, color, life, maxLife: life, alpha: 1 }); + if (G.groundDecals.length > 140) G.groundDecals.shift(); + }; + + G.queryMonsters = function (x, y, r) { + return G._grid.query(x, y, r, []); + }; + G.nearestMonster = function (x, y, r, filter) { + return G._grid.nearest(x, y, r, filter); + }; + G.countMinionsOf = function (ownerUid) { + let c = 0; + for (const m of G.monsters) if (!m.dead && m.ownerUid === ownerUid) c++; + return c; + }; + G.onMonsterRemoved = function () {}; + + G.spawnMonster = function (speciesId, x, y, opts = {}) { + const stats = D2.Monsters.buildMonster(speciesId, G.monsterLevel(), { elite: false }); + const m = new D2.entities.Monster(stats, x, y); + if (opts.ownerUid) m.ownerUid = opts.ownerUid; + if (opts.aggroed) { m.aggro = true; } + G.monsters.push(m); + return m; + }; + + G.monsterLevel = function () { + if (!G.world || G.world.isTown) return Math.max(1, G.player ? G.player.level : 1); + return G.world.mlvl; + }; + + G.skillCooldownLeft = function (id) { return G.skillCooldowns[id] || 0; }; + G.setSkillCooldown = function (id, dur) { G.skillCooldowns[id] = dur; }; + + G.showBossBar = function (m) { G.bossRef = m; if (D2.ui) D2.ui.showBossBar(m.name); }; + G.hideBossBar = function () { G.bossRef = null; if (D2.ui) D2.ui.hideBossBar(); }; + + G.giveGold = function (n) { G.player.gold += n; if (D2.ui) D2.ui.refreshHud(); }; + G.gainXp = function (amount, src) { D2.player.gainXp(G, amount, src); }; + + /* ================= run lifecycle ================= */ + + G.startNewGame = function (classId) { + D2.save.wipeAll(); + G.progress = { act: 0, floorIdx: 0, torment: 0, bossesKilled: [], quests: {}, qCount: {} }; + G.waypointsUnlocked = [true, false, false, false]; + G.stash = []; + G.player = D2.player.createPlayer(classId); + G.enterTown(true); + G.state = 'playing'; + D2.save.writeMeta({ classId, name: classId, level: 1, hardcore: G.settings.hardcore, torment: 0 }); + G.saveGame(); + }; + + G.continueGame = function () { + if (!G.loadGame()) { G.toast('No save found', 'bad'); return false; } + G.state = 'playing'; + return true; + }; + + G.enterTown = function (silent) { + G.world = D2.world.generateTown((Math.random() * 2 ** 31) | 0); + G.monsters = []; G.projectiles = []; G.pickups = []; + G.effects = []; G.groundDecals = []; + G.bossRef = null; if (D2.ui) D2.ui.hideBossBar(); + if (G.player) { + G.player.x = G.world.spawnX + 0.5; + G.player.y = G.world.spawnY + 0.5; + G.player.channel = null; + /* refill potions in town */ + G.player.potions.hp = Math.max(G.player.potions.hp, 6); + G.player.potions.mp = Math.max(G.player.potions.mp, 4); + } + D2.audio.playMusic('town'); + if (!silent) G.toast(D2.i18n.t('town.enter')); + G.refreshVendorStock(); + G.saveGame(); + if (D2.ui) D2.ui.refreshHud(); + }; + + G.refreshVendorStock = function () { + const mlvl = Math.max(1, (G.player ? G.player.level : 1) + G.progress.torment * 8); + G.vendorStock = D2.Items.vendorStock(mlvl, 9 + ((Math.random() * 5) | 0)); + }; + + G.enterFloor = function (act, floorIdx, opts = {}) { + const pr = G.progress; + pr.act = act; pr.floorIdx = floorIdx; + const seed = (hashStr(G.saveSeedStr()) + act * 7919 + floorIdx * 104729 + pr.torment * 31) >>> 0; + const isBoss = floorIdx >= D2.BAL.floorsPerAct - 1; + G.world = D2.world.generateFloor({ act, floorIdx, seed, bossLair: isBoss, torment: pr.torment }); + G.monsters = []; G.projectiles = []; G.pickups = []; + G.effects = []; G.groundDecals = []; + + G.player.x = G.world.spawnX + 0.5; + G.player.y = G.world.spawnY + 0.5; + G._fovTile = ''; + + /* instantiate spawns */ + for (const s of G.world.spawns) { + if (s.boss) { + const bd = D2.Monsters.BOSS_MAP[s.boss]; + const stats = D2.Monsters.buildMonster(bd.base, G.world.mlvl, { bossDef: bd }); + const m = new D2.entities.Monster(stats, s.x, s.y); + m.isBoss = true; m.bossDef = bd; + m.name = bd.name[D2.i18n.getLang()] || bd.name.en; + m.shape = bd.shape; m.palette = bd.palette; + m.radius = D2.Monsters.SPECIES_MAP[bd.base].radius * bd.size; + m.maxHp = Math.round(stats.maxHp); m.hp = m.maxHp; + m.attacks = bd.attacks.slice(); + m.speed = bd.speed; + G.monsters.push(m); + } else { + const stats = D2.Monsters.buildMonster(s.speciesId, G.world.mlvl, { elite: s.elite }); + G.monsters.push(new D2.entities.Monster(stats, s.x + 0.5, s.y + 0.5)); + } + } + + D2.audio.playMusic(isBoss ? 'boss' : G.world.theme.music); + G.updateQuests(); + G.saveGame(); + if (D2.ui) D2.ui.refreshHud(); + }; + + G.descend = function () { + const pr = G.progress; + const nextFloor = pr.floorIdx + 1; + if (nextFloor >= D2.BAL.floorsPerAct) { + G.enterFloor(pr.act, D2.BAL.floorsPerAct - 1, { boss: true }); + } else { + G.enterFloor(pr.act, nextFloor); + } + G.sfx('stairs'); + }; + + G.climbToTown = function () { + G.enterTown(); + G.sfx('stairs'); + }; + + G.useWaypoint = function (dest) { + /* dest: {town:true} | {act, floor} */ + if (dest.town) { G.enterTown(); } + else G.enterFloor(dest.act, dest.floor); + G.sfx('teleport'); + }; + + /* ---------- quests ---------- */ + /* ================= quest engine (Diablo-style NPC chain) ================= */ + + G.questState = function (id) { return (G.progress.quests || {})[id] || 'none'; }; + G.qCount = id => (G.progress.qCount || {})[id] || 0; + + G.questObjective = function (q) { + const t = D2.BAL.acts[q.act]; + if (q.type === 'boss') { + const bd = D2.Monsters.BOSS_MAP[t.boss]; + return { label: D2.i18n.t(q.title, bd ? bd.name[D2.i18n.getLang()] || bd.name.en : t.boss), + desc: D2.i18n.t(q.desc, bd ? bd.name[D2.i18n.getLang()] || bd.name.en : t.boss), + done: (G.progress.bossesKilled || []).some(k => k.startsWith(t.boss + '_' + (q.torment ? G.progress.torment : Math.max(0, G.progress.torment)))) }; + } + const n = D2.BAL.questKillBase(q.act); + return { label: D2.i18n.t(q.title), desc: D2.i18n.t(q.desc, n), + count: G.qCount(q.id), target: q.target, + done: G.qCount(q.id) >= q.target }; + }; + + G.availableQuests = function () { + return D2.BAL.QUESTS.filter(q => { + if (G.questState(q.id) !== 'none') return false; + if (q.requiresVictory && !(G.progress.bossesKilled || []).some(k => k.startsWith('terrorlord'))) return false; + if (q.requires && G.questState(q.requires) !== 'claimed') return false; + if (!q.requiresVictory && !q.torment && q.act !== G.progress.act) { + /* allow earlier unfinished acts too */ + if (q.act > G.progress.act) return false; + } + return true; + }); + }; + + G.activeQuests = function () { + return D2.BAL.QUESTS.filter(q => G.questState(q.id) === 'active'); + }; + + G.acceptQuest = function (id) { + if (G.questState(id) !== 'none') return false; + G.progress.quests[id] = 'active'; + G.progress.qCount[id] = 0; + const q = D2.BAL.questById(id); + G.toast(D2.i18n.t('msg.quest_accepted', D2.i18n.t(q.title)), 'gold'); + G.sfx('questdone'); + G.updateQuests(); + if (D2.ui) D2.ui.renderQuestTracker(); + return true; + }; + + G.turnInQuest = function (id) { + const q = D2.BAL.questById(id); + const obj = G.questObjective(q); + if (G.questState(id) !== 'active' || !obj.done) return false; + const r = q.reward; + G.player.gold += r.gold || 0; + if (r.xp) G.gainXp(r.xp); + if (r.itemLevelBonus) { + const it = D2.Items.rollItem(D2.BAL.monsterLevel(G.progress.act, 3, G.progress.torment) + r.itemLevelBonus, + { rarity: 'rare' }); + if (G.player.inventory.length < 40) G.player.inventory.push(it); + } + G.progress.quests[id] = 'claimed'; + G.sfx('questdone'); + G.toast(D2.i18n.t('msg.quest_reward', D2.util.fmtNum(r.gold || 0)), 'gold'); + G.saveGame(); + if (D2.ui) D2.ui.renderQuestTracker(); + return true; + }; + + G.onQuestKill = function (m) { + let changed = false; + for (const q of G.activeQuests()) { + if (q.type !== 'kills') continue; + G.progress.qCount[q.id] = (G.progress.qCount[q.id] || 0) + 1; + const obj = G.questObjective(q); + changed = true; + if (obj.done && obj.count === obj.target) { + G.toast(D2.i18n.t('ui.quest_done_return', + D2.i18n.t('npc.' + q.giver + '.name')), 'gold'); + G.sfx('questdone'); + } + } + if (changed && D2.ui) D2.ui.renderQuestTracker(); + }; + + /* what each camp NPC offers — consumed by the dialog UI */ + G.npcOptions = function (type) { + const opts = []; + const L = D2.i18n.getLang(); + const opt = (label, fn, hl) => opts.push({ label, fn, highlight: hl }); + + if (type === 'charsi') { + opt('⚒ ' + D2.i18n.t('ui.vendor'), () => { D2.ui.closePanel('dialog'); D2.ui.openPanel('vendor'); }); + } else if (type === 'akara') { + const cost = D2.BAL.healerCost(G.player.level); + opt('✚ ' + D2.i18n.t('ui.heal_full') + ` (${cost} 🜚)`, () => { + if (G.player.gold >= cost) { + G.player.gold -= cost; + G.player.hp = G.player.maxHp; G.player.mana = G.player.maxMana; + G.sfx('shrine'); ui_refresh(); G.saveGame(); + D2.ui.closePanel('dialog'); + } else { G.sfx('error'); D2.ui.toast('Not enough gold', 'bad'); } + }); + const tc = D2.BAL.tomeCost(G.player.level); + opt(`📖 ${D2.i18n.t('ui.buy_tome')} (${tc} 🜚)`, () => { + if (G.player.gold >= tc) { + G.player.gold -= tc; G.player.skillPoints++; + G.sfx('buff'); D2.ui.toast(D2.i18n.t('ui.tome_bought')); + ui_refresh(); G.saveGame(); + } else { G.sfx('error'); D2.ui.toast('Not enough gold', 'bad'); } + }); + const rc = D2.BAL.respecCost(G.player.level); + opt(`♻ ${D2.i18n.t('ui.respec')} (${rc} 🜚)`, () => { + if (G.player.gold >= rc) { + G.player.gold -= rc; + const p = G.player; + p.statPoints += (p.attributes.str || 0) + (p.attributes.dex || 0) + + (p.attributes.vit || 0) + (p.attributes.ene || 0); + p.attributes = { str: 0, dex: 0, vit: 0, ene: 0 }; + p.skillPoints += Object.values(p.skills).reduce((a, b) => a + b, 0); + p.skills = {}; p.hotbar = ['basic', null, null, null, null]; + D2.player.recompute(p); p.hp = p.maxHp; p.mana = p.maxMana; + G.sfx('shrine'); D2.ui.toast(D2.i18n.t('ui.respec_done')); + ui_refresh(); D2.ui.refreshHotbar(); G.saveGame(); + } else { G.sfx('error'); D2.ui.toast('Not enough gold', 'bad'); } + }); + } else if (type === 'kashya' || type === 'cain') { + for (const q of D2.BAL.QUESTS.filter(x => + x.giver === type && G.availableQuests().includes(x))) { + const obj = G.questObjective(q); + opt(`❗ ${obj.label}`, () => offerQuest(q), true); + } + for (const q of D2.BAL.QUESTS.filter(x => + x.giver === type && G.questState(x.id) === 'active')) { + const obj = G.questObjective(q); + if (obj.done) { + const rw = q.reward; + opt(`✔ ${D2.i18n.t('ui.turn_in')}: ${obj.label} (+${rw.gold}🜚 +${rw.xp}xp)`, + () => { G.turnInQuest(q.id); reopenDialog(type); }, true); + } + } + if (type === 'cain') opt('📖 ' + D2.i18n.t('ui.lore'), () => { + D2.ui.toast(D2.i18n.getLang() === 'vi' + ? '"Tristram từng là một cảng bình yên… cho đến khi tiếng chuông nhà thờ vang lên ngược."' + : '"Tristram was a quiet port once… until the cathedral bell rang backwards."', ''); + }); + } else if (type === 'gheed') { + const gc = D2.BAL.gambleCost(G.player.level); + opt(`🎲 ${D2.i18n.t('ui.gamble')} (${gc} 🜚)`, () => { + if (G.player.gold < gc) { G.sfx('error'); D2.ui.toast('Not enough gold', 'bad'); return; } + G.player.gold -= gc; + const roll = Math.random(); + const rarity = roll < 0.55 ? 'magic' : roll < 0.9 ? 'rare' : 'legendary'; + const it = D2.Items.rollItem(D2.BAL.monsterLevel(G.progress.act, 1, G.progress.torment) + 2, + { rarity }); + if (G.player.inventory.length < 40) { + G.player.inventory.push(it); + G.sfx(rarity === 'legendary' ? 'legendary' : 'gold'); + D2.ui.toast(`${D2.i18n.t('ui.gamble_win')} — ${it.name}`, + rarity === 'legendary' ? 'gold' : ''); + ui_refresh(); + } else { G.sfx('error'); D2.ui.toast(D2.i18n.t('msg.inventory_full'), 'bad'); G.player.gold += gc; } + }, true); + } else if (type === 'stash') { + opt('📦 ' + D2.i18n.t('ui.stash'), () => { D2.ui.closePanel('dialog'); D2.ui.openPanel('stash'); }); + } + + function ui_refresh() { + if (!D2.ui) return; + D2.ui.refreshHud(); D2.ui.refreshInventory(); D2.ui.refreshCharacter(); + } + function offerQuest(q) { + const obj = G.questObjective(q); + const rw = q.reward; + const body = document.querySelector('#panel-dialog .fpanel-body'); + if (body) { + body.innerHTML = + `
${obj.label}
` + + `
${obj.desc}
` + + `
${D2.i18n.t('ui.reward')}: ${rw.gold} 🜚 · ${rw.xp} XP` + + `${rw.itemLevelBonus ? ` · ${D2.i18n.t('ui.rare_item')}` : ''}
`; + const row = document.createElement('div'); + row.className = 'dialog-options'; + const acc = document.createElement('button'); + acc.className = 'btn btn-primary'; + acc.textContent = '✔ ' + D2.i18n.t('ui.accept'); + acc.addEventListener('click', () => { + G.acceptQuest(q.id); + D2.ui.closePanel('dialog'); + reopenDialog(type); + }); + const later = document.createElement('button'); + later.className = 'btn'; + later.textContent = D2.i18n.t('ui.later'); + later.addEventListener('click', () => { D2.ui.closePanel('dialog'); reopenDialog(type); }); + row.appendChild(acc); row.appendChild(later); + body.appendChild(row); + } + } + function reopenDialog(t) { + if (D2.ui) { D2.ui.closeAllPanels(); D2.ui.openNpcDialog(t); } + } + + return opts; + }; + + G.updateQuests = function () { + const pr = G.progress; + const q = []; + /* NPC chain first */ + for (const qa of G.activeQuests()) { + const obj = G.questObjective(qa); + q.push({ + header: D2.i18n.t('npc.' + qa.giver + '.name'), + text: obj.label + (qa.type === 'kills' + ? ` — ${Math.min(obj.count, obj.target)}/${obj.target}` : (obj.done ? ' ✔' : '')), + done: obj.done, + }); + } + /* fallback hint when the chain is quiet */ + if (!q.length) { + const avail = G.availableQuests()[0]; + if (avail) { + q.push({ + header: D2.i18n.t('npc.' + avail.giver + '.name'), + text: D2.i18n.t('quest.seek_hint'), + done: false, + }); + } else { + const actName = D2.i18n.t(D2.BAL.acts[pr.act].name); + if (pr.floorIdx >= D2.BAL.floorsPerAct - 1) { + const bossId = D2.BAL.acts[pr.act].boss; + const bd = D2.Monsters.BOSS_MAP[bossId]; + const killed = pr.bossesKilled.includes(bossId + '_' + pr.torment); + q.push({ header: actName, text: D2.i18n.t('quest.kill_boss', bd ? bd.name[D2.i18n.getLang()] : bossId), done: killed }); + } else { + q.push({ header: actName, text: D2.i18n.t('quest.descend', pr.floorIdx + 2), done: false }); + } + } + } + G.quests = q; + if (D2.ui) D2.ui.renderQuestTracker(); + }; + + G.onBossKilled = function (m) { + G.toast(D2.i18n.t('msg.boss_slain'), 'gold'); + G.hideBossBar(); + const pr = G.progress; + pr.bossesKilled.push(m.bossDef.id + '_' + pr.torment); + + if (pr.act >= D2.BAL.acts.length - 1 && !G.victoryShown) { + G.victoryShown = true; + setTimeout(() => { + G.state = 'victory'; + if (D2.ui) D2.ui.showVictory(); + }, 1600); + } else { + /* unlock next act waypoint */ + if (pr.act + 1 < D2.BAL.acts.length) G.waypointsUnlocked[pr.act + 1] = true; + G.toast(D2.i18n.t('msg.act_clear'), 'gold'); + G.updateQuests(); + } + G.saveGame(); + }; + + G.onPlayerDeath = function () { + const p = G.player; + if (p.dead) return; + p.dead = true; + p.deaths++; + G.sfx('bossdie'); + D2.render.addShake(0.7); + G.spawnParticles(p.x, p.y, { count: 30, color: '#a8231a', speed: 5, life: 1, size: 3, z: 10 }); + + if (G.settings.hardcore) { + D2.save.wipeAll(); + G.toast(D2.i18n.t('msg.hardcore_death'), 'bad'); + setTimeout(() => { G.state = 'title'; if (D2.ui) D2.ui.showTitle(); }, 1800); + return; + } + G.state = 'dead'; + setTimeout(() => { if (D2.ui) D2.ui.showDeath(); }, 900); + }; + + G.respawnInTown = function () { + const p = G.player; + p.dead = false; + const penalty = Math.round(p.gold * D2.BAL.deathGoldPenalty); + p.gold = Math.max(0, p.gold - penalty); + p.hp = p.maxHp; + p.mana = p.maxMana; + p.buffs = []; + D2.player.recompute(p); + G.state = 'playing'; + G.enterTown(true); + if (penalty > 0) G.toast('-' + penalty + ' gold'); + }; + + /* ================= save / load ================= */ + + G.saveSeedStr = function () { + return (D2.save.readMeta() || {}).seedStr || 'tristram'; + }; + + function hashStr(s) { + let h = 2166136261; + for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } + return h >>> 0; + } + + G.saveGame = function () { + if (!G.player) return; + const p = G.player; + D2.save.writeMeta({ + classId: p.classId, level: p.level, name: p.classId, + hardcore: G.settings.hardcore, torment: G.progress.torment, + seedStr: G.saveSeedStr(), + }); + D2.save.set(D2.save.KEY.CHAR, { + classId: p.classId, level: p.level, xp: p.xp, gold: p.gold, + attributes: p.attributes, statPoints: p.statPoints, skillPoints: p.skillPoints, + skills: p.skills, hotbar: p.hotbar, potions: p.potions, + inventory: p.inventory, equip: p.equip, + kills: p.kills, eliteKills: p.eliteKills, bossKills: p.bossKills, deaths: p.deaths, + }); + D2.save.set(D2.save.KEY.WORLD, { + progress: G.progress, waypoints: G.waypointsUnlocked, + stash: G.stash, settings: G.settings, + inTown: !!(G.world && G.world.isTown), + }); + }; + + G.loadGame = function () { + const meta = D2.save.readMeta(); + const charData = D2.save.get(D2.save.KEY.CHAR); + const worldData = D2.save.get(D2.save.KEY.WORLD); + if (!meta || !charData) return false; + + if (worldData && worldData.settings) Object.assign(G.settings, worldData.settings); + applySettings(); + + const p = D2.player.createPlayer(charData.classId); + p.level = charData.level; p.xp = charData.xp; p.gold = charData.gold; + p.attributes = charData.attributes || p.attributes; + p.statPoints = charData.statPoints || 0; + p.skillPoints = charData.skillPoints || 0; + p.skills = charData.skills || {}; + p.hotbar = charData.hotbar || p.hotbar; + p.potions = charData.potions || p.potions; + p.inventory = charData.inventory || []; + p.equip = Object.assign(p.equip, charData.equip); + p.kills = charData.kills || 0; + p.eliteKills = charData.eliteKills || 0; + p.bossKills = charData.bossKills || 0; + p.deaths = charData.deaths || 0; + D2.player.recompute(p); + p.hp = p.maxHp; p.mana = p.maxMana; + G.player = p; + + if (worldData) { + G.progress = worldData.progress || G.progress; + G.waypointsUnlocked = worldData.waypoints || G.waypointsUnlocked; + G.stash = worldData.stash || []; + } + G.victoryShown = G.progress.bossesKilled.some(k => k.startsWith('terrorlord')); + G.enterTown(true); + G.updateQuests(); + return true; + }; + + function applySettings() { + D2.i18n.setLang(G.settings.lang); + D2.audio.setVolumes(G.settings.masterVol, G.settings.musicVol, G.settings.sfxVol); + document.documentElement.dataset.lang = G.settings.lang; + if (typeof document !== 'undefined') { + const el = document.documentElement; + el.style.setProperty('--dmg-numbers', G.settings.dmgNumbers ? '1' : '0'); + } + } + G.applySettings = applySettings; + + /* ================= input during gameplay ================= */ + + function canvasMouseWorld() { + const w = D2.render.screenToWorld(D2.input.state.mx, D2.input.state.my); + G.mouseWorld = w; + return w; + } + + function handleInput(dt) { + const inp = D2.input; + const p = G.player; + if (!p || p.dead) return; + const mw = canvasMouseWorld(); + + /* hover entity — tight grab: only when the cursor is genuinely ON + the creature's body, so floor clicks next to it still move */ + G.hoverEntity = (() => { + let best = null, bd = Infinity; + for (const m of G.monsters) { + if (m.dead) continue; + const d = D2.util.dist(m.x, m.y, mw.x, mw.y); + const grab = Math.max(0.45, m.radius * 0.85 + 0.18); + if (d < grab && d < bd) { bd = d; best = m; } + } + return best; + })(); + + /* --- left mouse: move / attack / interact --- */ + if (inp.state.leftPressed || (inp.state.left && !uiBlocking())) { + if (G.hoverEntity) { + p.attackMoveTarget = { x: G.hoverEntity.x, y: G.hoverEntity.y, entity: G.hoverEntity }; + p.moveTarget = null; + p.chaseT = 0; + p.abandonT = 0; + p._bestD = undefined; p._noProgT = 0; + } else { + /* prop interaction takes precedence when close */ + const prop = propAt(mw.x, mw.y); + if (prop && D2.util.dist(p.x, p.y, prop.x + .5, prop.y + .5) <= D2.BAL.interactRange + 0.6) { + interactProp(prop); + } else { + p.moveTarget = { x: mw.x, y: mw.y }; + p.attackMoveTarget = null; + p.chaseT = 0; + p.abandonT = 0; + p.path = null; + p._bestD = undefined; p._noProgT = 0; + G.moveMarker = { x: mw.x, y: mw.y, t: 0.5 }; + } + } + } + + /* --- right mouse: skill slot 1 --- */ + if (inp.state.rightPressed) { + castHotbarSlot(1, mw); + } + /* --- number keys: skill slots 2-4 --- */ + for (let i = 2; i <= 4; i++) { + if (inp.wasPressed('Digit' + i)) castHotbarSlot(i, mw); + } + /* potions */ + if (inp.wasPressed('KeyQ')) D2.player.usePotion(G, 'hp'); + if (inp.wasPressed('KeyE')) D2.player.usePotion(G, 'mp'); + + /* interact key */ + if (inp.wasPressed('KeyF') || inp.wasPressed('Space')) { + const near = nearestInteractive(); + if (near) interactProp(near); + } + + /* panel toggles handled in ui.tickGlobalKeys (works also while paused menus) */ + + /* continuous held-cast for slot1? no; basic attack while holding LMB near enemy */ + if (inp.state.left && p.attackMoveTarget) { + const t = p.attackMoveTarget.entity; + const d = t ? D2.util.dist(p.x, p.y, t.x, t.y) : Infinity; + p.chaseT = (p.chaseT || 0) + dt; + /* leash: dead / too far / chased too long → stop auto-running */ + if (!t || t.dead || d > 16 || p.chaseT > 5) { + p.attackMoveTarget = null; + p.chaseT = 0; + if (!inp.state.leftPressed) p.moveTarget = null; + } else { + const isRanged = p.classId !== 'crusader'; + const reach = isRanged ? 6.5 : 1.5; + if (d > reach) { + p.moveTarget = { x: t.x, y: t.y }; + } else { + p.moveTarget = null; + if (p.attackCd <= 0) { + D2.combat.playerBasicAttack(G, t.x, t.y); + } + } + } + } else if (!inp.state.left) { + p.chaseT = 0; + } + } + + function uiBlocking() { + return D2.ui && D2.ui.anyPanelOpen(); + } + + function castHotbarSlot(slot, mw) { + const p = G.player; + const skillId = p.hotbar[slot]; + if (!skillId) return; + if (skillId === 'basic') { + D2.combat.playerBasicAttack(G, mw.x, mw.y); + return; + } + const sk = D2.Skills.findSkill(p.classId, skillId); + if (!sk) return; + const rank = D2.player.getSkillRank(p, skillId); + if (rank <= 0) { G.toast(D2.i18n.t('msg.skill_cooldown'), ''); return; } + /* special CDR from legendaries */ + let cd = sk.cooldown; + if (sk.castType === 'dash' && p.stats.dashCdrPct) cd *= 1 - p.stats.dashCdrPct / 100; + if (sk.castType === 'blink' && p.stats.blinkCdrPct) cd *= 1 - p.stats.blinkCdrPct / 100; + const savedCd = sk.cooldown; + try { + sk.cooldown = cd; + D2.combat.castSkill(G, p, sk, rank, mw.x, mw.y); + } finally { + sk.cooldown = savedCd; + } + if (D2.ui) D2.ui.refreshHotbar(); + } + + /* ---------------- props interaction ---------------- */ + + function propAt(wx, wy) { + for (const pr of G.world.props) { + /* dead props must not swallow movement clicks */ + if (pr.type === 'chest' && pr.opened) continue; + if (pr.type === 'shrine' && pr.used) continue; + if (Math.abs(pr.x + 0.5 - wx) < 0.5 && Math.abs(pr.y + 0.5 - wy) < 0.6) return pr; + } + return null; + } + + function nearestInteractive() { + const p = G.player; + let best = null, bd = Infinity; + for (const pr of G.world.props) { + const interactive = ['stairs_down', 'stairs_up', 'chest', 'shrine', 'waypoint'].includes(pr.type) || + String(pr.type).startsWith('npc_'); + if (!interactive) continue; + if (pr.type === 'shrine' && pr.used) continue; + if (pr.type === 'chest' && pr.opened) continue; + const d = D2.util.dist(p.x, p.y, pr.x + 0.5, pr.y + 0.5); + if (d < D2.BAL.interactRange + 0.5 && d < bd) { bd = d; best = pr; } + } + return best; + } + + function interactProp(prop) { + const p = G.player; + switch (prop.type) { + case 'stairs_down': + G.descend(); + break; + case 'stairs_up': + G.climbToTown(); + break; + case 'chest': + prop.opened = true; + G.world.propMap.delete(prop.x + ',' + prop.y); + G.sfx('door'); + D2.loot.rollPropLoot(G, prop.x + 0.5, prop.y + 0.5, 'chest'); + break; + case 'shrine': + if (!prop.used) D2.combat.activateShrine(G, prop); + break; + case 'waypoint': + if (D2.ui) D2.ui.openPanel('worldmap'); + break; + default: + if (String(prop.type).startsWith('npc_')) { + if (D2.ui) D2.ui.openNpcDialog(prop.type.replace('npc_', '')); + } + } + } + + /* ================= simulation update ================= */ + + G.update = function (dt) { + if (G.state !== 'playing') return; + G.time += dt; + + D2.ai.resetFrame(); + handleInput(dt); + D2.player.tickBelt(dt); + + const p = G.player; + + /* --- player movement along target --- */ + updatePlayerMovement(dt); + + /* channels (whirlwind/dash) */ + D2.combat.updateChannels(G, p, dt); + + /* cooldowns & timers */ + if (p.attackCd > 0) p.attackCd -= dt; + if (p.attackAnim > 0) p.attackAnim -= dt; + if (p.hurtFlash > 0) p.hurtFlash -= dt; + if (p.guaranteedCritT > 0) p.guaranteedCritT -= dt; + if (p.smokeveilT > 0) { p.smokeveilT -= dt; if (p.smokeveilT <= 0) D2.player.recompute(p); } + for (const k of Object.keys(G.skillCooldowns)) { + G.skillCooldowns[k] -= dt; + if (G.skillCooldowns[k] <= 0) delete G.skillCooldowns[k]; + } + /* buffs */ + let buffExpired = false; + for (let i = p.buffs.length - 1; i >= 0; i--) { + p.buffs[i].t -= dt; + if (p.buffs[i].t <= 0) { p.buffs.splice(i, 1); buffExpired = true; } + } + if (buffExpired) D2.player.recompute(p); + if (p.chillT > 0) { p.chillT -= dt; if (p.chillT <= 0) p.chillSlow = 0; } + + /* regen */ + const s = p.stats; + let regen = D2.BAL.hpRegenPerSec + (s.hpRegen || 0) + (s.regenHpFlat || 0) + + (s.regenHpPct ? p.maxHp * s.regenHpPct / 100 : 0); + p.hp = Math.min(p.maxHp, p.hp + regen * dt); + p.mana = Math.min(p.maxMana, p.mana + + (D2.BAL.manaRegenPerSec + (s.manaRegen || 0) + (s.manaRegenFlat || 0)) * dt); + D2.combat.updateDots(G, p, dt, true); + + /* --- FOV --- */ + const ftKey = (p.x | 0) + ',' + (p.y | 0); + if (ftKey !== G._fovTile) { + G._fovTile = ftKey; + D2.fov.compute(G.world, p.x | 0, p.y | 0, 13); + } + + /* --- monsters --- */ + G._grid.rebuild(G.monsters); + for (const m of G.monsters) { + if (m.dead) continue; + const d2p = (m.x - p.x) ** 2 + (m.y - p.y) ** 2; + if (d2p < 26 * 26) { + D2.ai.updateMonster(G, m, dt); + } + if (m.hurtFlash > 0) m.hurtFlash -= dt; + } + G.monsters = G.monsters.filter(m => !m.dead); + + /* --- projectiles --- */ + for (const pr of G.projectiles) { + if (pr.dead) continue; + pr.x += pr.vx * dt; pr.y += pr.vy * dt; + pr.life -= dt; + if (pr.life <= 0) { pr.dead = true; continue; } + /* wall collision */ + if (G.world.tileAt(pr.x | 0, pr.y | 0) !== D2.world.T.FLOOR) { + pr.dead = true; + G.spawnParticles(pr.x, pr.y, { count: 4, color: pr.color, speed: 2, life: 0.25, size: 1.8, z: 8 }); + continue; + } + if (pr.fromPlayer) { + const hits = G.queryMonsters(pr.x, pr.y, 0.42 * pr.size); + for (const m of hits) { + if (m.dead || pr.hits.has(m.uid)) continue; + pr.hits.add(m.uid); + D2.combat.projectileHit(G, pr, m); + if (!pr.pierce || pr.hits.size >= 3) { pr.dead = true; break; } + } + } else { + if (!p.dead && D2.util.dist(pr.x, pr.y, p.x, p.y) < p.radius + 0.22) { + D2.combat.projectileHit(G, pr, p); + pr.dead = true; + } + } + } + G.projectiles = G.projectiles.filter(pr => !pr.dead); + + /* --- effects --- */ + for (const fx of G.effects) { + fx.t += dt; + switch (fx.type) { + case 'telegraph': + if (fx.t >= fx.delay && !fx.fired) { + fx.fired = true; + if (fx.onDone) fx.onDone(fx); + } + break; + case 'groundAoE': { + if (fx.t >= fx.delay) D2.combat.tickGroundAoe(G, fx, dt); + if (fx.t >= fx.delay + fx.duration) fx.done = true; + break; + } + case 'nova': + case 'swingArc': + if (fx.t >= fx.dur && !fx.done) { + fx.done = true; + if (fx.onDone) fx.onDone(fx); + } + break; + case 'timed': + if (!fx.fired && G.time >= fx.endAt) { + fx.fired = true; + fx.fire(G); + } + break; + } + } + G.effects = G.effects.filter(fx => !fx.done && !(fx.type === 'timed' && fx.fired)); + + /* --- pickups --- */ + for (let i = G.pickups.length - 1; i >= 0; i--) { + const pk = G.pickups[i]; + pk.t += dt; + const d = D2.util.dist(pk.x, pk.y, p.x, p.y); + if (pk.t > 0.35 && d < 0.85) { + if (D2.loot.collect(G, pk)) G.pickups.splice(i, 1); + } + } + + /* --- particles --- */ + for (let i = G.particles.length - 1; i >= 0; i--) { + const pt = G.particles[i]; + pt.life -= dt; + if (pt.life <= 0) { G.particles.splice(i, 1); continue; } + pt.x += pt.vx * dt; pt.y += pt.vy * dt; + pt.z += pt.vz * dt; + pt.vz -= pt.gravity * dt; + if (pt.z < 0) { pt.z = 0; pt.vz *= -0.4; } + } + + /* --- floating texts --- */ + for (let i = G.floatTexts.length - 1; i >= 0; i--) { + G.floatTexts[i].life -= dt; + if (G.floatTexts[i].life <= 0) G.floatTexts.splice(i, 1); + } + + /* --- lights --- */ + for (let i = G.lights.length - 1; i >= 0; i--) { + G.lights[i].t -= dt; + if (G.lights[i].t <= 0) G.lights.splice(i, 1); + } + + /* --- ground decals fade --- */ + for (let i = G.groundDecals.length - 1; i >= 0; i--) { + G.groundDecals[i].life -= dt; + if (G.groundDecals[i].life <= 0) G.groundDecals.splice(i, 1); + } + + /* --- boss bar refresh --- */ + if (G.bossRef) { + if (G.bossRef.dead) G.hideBossBar(); + else if (D2.ui) D2.ui.updateBossBar(G.bossRef.hp / G.bossRef.maxHp); + } + + /* --- autosave cadence (in-town or every 90s) --- */ + G._autosaveT += dt; + if (G._autosaveT > 90) { G._autosaveT = 0; G.saveGame(); } + }; + + function updatePlayerMovement(dt) { + const p = G.player; + if (!p.moveTarget || p.channel) { + if (!p.channel) p.moving = false; + return; + } + const speed = p.moveSpeed * (1 - (p.chillSlow || 0)); + const dx = p.moveTarget.x - p.x, dy = p.moveTarget.y - p.y; + const d = Math.hypot(dx, dy); + + if (d < 0.15) { + p.moveTarget = null; + p.moving = false; + p.path = null; + return; + } + + /* direct if LOS and no path yet, else follow/recompute A* path */ + let vx = dx / d, vy = dy / d; + const clear = D2.path.hasLOS( + (x, y) => G.world.transparent(x, y), + p.x | 0, p.y | 0, p.moveTarget.x | 0, p.moveTarget.y | 0); + + if (clear && !p.path) { + p.path = null; + } else { + G._repathT -= dt; + if (!p.path || G._repathT <= 0 || p.pathIdx >= (p.path ? p.path.length : 0)) { + G._repathT = 0.45; + p.path = D2.path.find( + (x, y) => G.world.isWalkable(x, y), + G.world.w, G.world.h, + p.x | 0, p.y | 0, p.moveTarget.x | 0, p.moveTarget.y | 0, 6000); + p.pathIdx = 0; + } + if (p.path && p.pathIdx < p.path.length) { + const node = p.path[p.pathIdx]; + const nx = node.x + 0.5, ny = node.y + 0.5; + const ndx = nx - p.x, ndy = ny - p.y; + const nd = Math.hypot(ndx, ndy) || 1; + if (nd < 0.3) p.pathIdx++; + else { vx = ndx / nd; vy = ndy / nd; } + } else if (!p.path && !clear) { + /* target is unreachable (wall / sealed pocket) — give up quickly + instead of grinding against the geometry forever */ + p.abandonT = (p.abandonT || 0) + dt; + if (p.abandonT > 0.35) { + p.moveTarget = null; + p.path = null; + p.abandonT = 0; + p.moving = false; + return; + } + } + } + + const stepX = vx * speed * dt, stepY = vy * speed * dt * 0.55; + const bx = p.x, by = p.y; + D2.combat.tryMove(G, p, stepX, stepY); + p.walkPhase += dt * speed * 3.2; + p.moving = true; + + /* stuck detection → one nudge, then abandon the target */ + if (Math.abs(p.x - bx) < 1e-7 && Math.abs(p.y - by) < 1e-7) { + p.stuckT = (p.stuckT || 0) + dt; + if (p.stuckT > 0.22 && p.stuckT < 0.45) { + const ang = Math.atan2(p.moveTarget.y - p.y, p.moveTarget.x - p.x) + Math.PI / 2; + D2.combat.tryMove(G, p, Math.cos(ang) * speed * dt * 8, Math.sin(ang) * speed * dt * 8 * 0.55); + } else if (p.stuckT > 0.6) { + p.stuckT = 0; + p.abandonT = 0; + p.path = null; + p.moveTarget = null; + p.moving = false; + } + } else { p.stuckT = 0; p.abandonT = 0; } + + /* no-progress watchdog: sliding along a wall still changes position, + so also require the DISTANCE to the target to actually shrink */ + const dNow = D2.util.dist(p.x, p.y, p.moveTarget.x, p.moveTarget.y); + if (p._bestD === undefined || dNow < p._bestD - 0.05) { + p._bestD = dNow; + p._noProgT = 0; + } else if ((p._noProgT = (p._noProgT || 0) + dt) > 1.2) { + p.moveTarget = null; + p.path = null; + p._bestD = undefined; + p._noProgT = 0; + p.moving = false; + return; + } + + /* arrive check */ + if (D2.util.dist(p.x, p.y, p.moveTarget.x, p.moveTarget.y) < 0.18) { + p.moveTarget = null; + p.moving = false; + } + } + + /* expose */ + D2.game = G; +})(window.D2); diff --git a/js/game/loot.js b/js/game/loot.js new file mode 100644 index 0000000..db3b1bd --- /dev/null +++ b/js/game/loot.js @@ -0,0 +1,149 @@ +/* ============================================================ + * Diablo2D — loot.js : drops, gold, pickups + * ============================================================ */ +'use strict'; +window.D2 = window.D2 || {}; +(function (D2) { + + const rnd = Math.random; + + function goldFindMult(player) { + return 1 + ((player.stats && player.stats.goldFind) || 0) / 100; + } + + function magicFindOf(player) { + return (player.stats && player.stats.magicFind) || 0; + } + + /** main drop routine when a monster dies */ + function dropFor(game, m) { + const p = game.player; + const DC = D2.BAL.dropChances; + const x = m.x, y = m.y; + + /* --- gold --- */ + let goldChance = DC.gold + (m.isElite ? 0.4 : 0) + (m.isBoss ? 1 : 0); + if (m.isBoss) goldChance = 1; + if (rnd() < goldChance) { + let amount = D2.BAL.goldDropBase(m.level) * goldFindMult(p); + if (m.isElite) amount *= 2.6; + if (m.isBoss) amount *= 7; + amount = Math.max(1, Math.round(amount)); + const piles = m.isBoss ? 4 : amount > 40 ? 2 : 1; + for (let i = 0; i < piles; i++) { + game.pickups.push(new D2.entities.Pickup('gold', + x + (rnd() - .5) * 0.8, y + (rnd() - .5) * 0.8, + { amount: Math.max(1, Math.round(amount / piles)) })); + } + } + + /* --- potions --- */ + const potChance = (DC.potionHp + DC.potionMp) * (m.isElite ? 1.8 : 1); + if (rnd() < potChance || m.isBoss) { + const type = rnd() < 0.62 ? 'hp' : 'mp'; + game.pickups.push(new D2.entities.Pickup('potion', + x + (rnd() - .5) * .6, y + (rnd() - .5) * .6, { potionType: type })); + } + if (m.isBoss) { + game.pickups.push(new D2.entities.Pickup('potion', x + .5, y, { potionType: 'hp' })); + game.pickups.push(new D2.entities.Pickup('potion', x - .5, y, { potionType: 'mp' })); + } + + /* --- gear --- */ + let gearChance = DC.gear + (m.isElite ? DC.gearEliteBonus : 0); + if (m.isBoss) gearChance = DC.gearBoss; + const rolls = m.isBoss ? 4 : 1; + for (let i = 0; i < rolls; i++) { + if (rnd() >= gearChance && !(m.isBoss && i === 0)) continue; + const opts = { + magicFind: magicFindOf(p), + elite: m.isElite, + boss: m.isBoss, + slot: rnd() < 0.55 ? undefined : + D2.Items.SLOTS[(rnd() * D2.Items.SLOTS.length) | 0], + }; + if (m.isBoss && i === 0) { + /* guaranteed flagship drop: legendary 40%, else rare */ + opts.rarity = rnd() < 0.4 ? 'legendary' : 'rare'; + } + const item = D2.Items.rollItem(Math.max(1, m.level), opts); + if (item) { + game.pickups.push(new D2.entities.Pickup('item', + x + (rnd() - .5) * 1.2, y + (rnd() - .5) * 1.2, { item })); + if (item.rarity === 'legendary') { + game.sfx('legendary'); + game.toast('✦ ' + item.name, 'gold'); + game.addLight(x, y, 3, '#ef8f34', 0.9, 2.5); + } else if (item.rarity === 'rare') { + game.sfx('pickup'); + } + } + } + } + + /** barrels / urns / chests */ + function rollPropLoot(game, x, y, propType) { + if (propType === 'shrine' || String(propType).startsWith('npc_')) return; + + if (propType === 'chest') { + /* chests are generous */ + const n = 1 + (rnd() < 0.4 ? 1 : 0); + for (let i = 0; i < n; i++) { + const item = D2.Items.rollItem(Math.max(1, game.monsterLevel()), + { magicFind: magicFindOf(game.player) + 15 }); + if (item) game.pickups.push(new D2.entities.Pickup('item', x + (rnd() - .5), y + (rnd() - .5) * .6, { item })); + } + const g = Math.round(D2.BAL.goldDropBase(game.monsterLevel()) * 2 * goldFindMult(game.player)); + game.pickups.push(new D2.entities.Pickup('gold', x, y + .3, { amount: g })); + game.sfx('coin'); + return; + } + + /* barrels & urns */ + if (rnd() < 0.45) { + const amount = Math.max(1, Math.round(D2.BAL.goldDropBase(Math.max(1, game.monsterLevel() - 2)) * 0.5 * goldFindMult(game.player))); + game.pickups.push(new D2.entities.Pickup('gold', x, y, { amount })); + } else if (rnd() < 0.14) { + game.pickups.push(new D2.entities.Pickup('potion', x, y, { + potionType: rnd() < 0.65 ? 'hp' : 'mp' + })); + } else if (rnd() < 0.05) { + const item = D2.Items.rollItem(Math.max(1, game.monsterLevel() - 1)); + if (item) game.pickups.push(new D2.entities.Pickup('item', x, y, { item })); + } + } + + /** collect a pickup; returns true if consumed */ + function collect(game, pk) { + const p = game.player; + switch (pk.kind) { + case 'gold': { + p.gold += pk.amount; + game.sfx('coin', { vol: .35 }); + return true; + } + case 'potion': { + if (p.potions[pk.potionType] >= D2.BAL.potionStackMax) return false; + p.potions[pk.potionType]++; + game.sfx('pickup', { vol: .3 }); + return true; + } + case 'item': { + if (p.inventory.length >= 40) { + game.toast(D2.i18n.t('msg.inventory_full'), 'bad'); + return false; + } + p.inventory.push(pk.item); + game.sfx(pk.item.rarity === 'legendary' ? 'legendary' : 'pickup', { vol: .45 }); + if (pk.item.rarity === 'magic' || pk.item.rarity === 'rare' || pk.item.rarity === 'legendary') { + game.toast(pk.item.name, pk.item.rarity === 'legendary' ? 'gold' : ''); + } + if (D2.ui) D2.ui.refreshInventory(); + return true; + } + } + return false; + } + + D2.loot = { dropFor, rollPropLoot, collect }; +})(window.D2); diff --git a/js/game/path.js b/js/game/path.js new file mode 100644 index 0000000..57a2320 --- /dev/null +++ b/js/game/path.js @@ -0,0 +1,128 @@ +/* ============================================================ + * Diablo2D — path.js : A* pathfinding + line of sight + * ============================================================ */ +'use strict'; +window.D2 = window.D2 || {}; +(function (D2) { + + const DIRS = [ + [1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], + [1, 1, 1.414], [1, -1, 1.414], [-1, 1, 1.414], [-1, -1, 1.414], + ]; + + /** + * A* over world tiles. walkable(x,y) -> bool. + * Returns array of {x,y} tile coords (excluding start), or null. + */ + function find(walkable, w, h, sx, sy, tx, ty, maxNodes = 4000) { + if (!walkable(tx, ty)) return null; + if (sx === tx && sy === ty) return []; + + const open = new MinHeap(); + const gScore = new Float32Array(w * h).fill(Infinity); + const cameFrom = new Int32Array(w * h).fill(-1); + const closed = new Uint8Array(w * h); + const idx = (x, y) => y * w + x; + + gScore[idx(sx, sy)] = 0; + open.push(idx(sx, sy), octile(sx, sy, tx, ty)); + + let nodes = 0; + while (open.size > 0 && nodes < maxNodes) { + const cur = open.pop(); + const cx = cur % w, cy = (cur / w) | 0; + if (closed[cur]) continue; + closed[cur] = 1; + nodes++; + + if (cx === tx && cy === ty) { + const path = []; + let n = cur; + while (n !== idx(sx, sy) && n >= 0) { + path.push({ x: n % w, y: (n / w) | 0 }); + n = cameFrom[n]; + } + path.reverse(); + return path; + } + + for (const [dx, dy, cost] of DIRS) { + const nx = cx + dx, ny = cy + dy; + if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; + if (!walkable(nx, ny)) continue; + // no corner cutting + if (dx !== 0 && dy !== 0) { + if (!walkable(cx + dx, cy) || !walkable(cx, cy + dy)) continue; + } + const ni = idx(nx, ny); + if (closed[ni]) continue; + const tentative = gScore[cur] + cost; + if (tentative < gScore[ni]) { + gScore[ni] = tentative; + cameFrom[ni] = cur; + open.push(ni, tentative + octile(nx, ny, tx, ty) * 1.02); + } + } + } + return null; + } + + function octile(x0, y0, x1, y1) { + const dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0); + return (dx + dy) + (1.414 - 2) * Math.min(dx, dy); + } + + /** lightweight binary min-heap keyed by f-score */ + class MinHeap { + constructor() { this.items = []; this.f = []; this.size = 0; } + push(idx, f) { + let i = this.size++; + this.items[i] = idx; this.f[i] = f; + while (i > 0) { + const p = (i - 1) >> 1; + if (this.f[p] <= this.f[i]) break; + this.swap(p, i); i = p; + } + } + pop() { + const top = this.items[0]; + this.size--; + if (this.size > 0) { + this.items[0] = this.items[this.size]; + this.f[0] = this.f[this.size]; + let i = 0; + for (;;) { + const l = 2 * i + 1, r = l + 1; + let m = i; + if (l < this.size && this.f[l] < this.f[m]) m = l; + if (r < this.size && this.f[r] < this.f[m]) m = r; + if (m === i) break; + this.swap(m, i); i = m; + } + } + return top; + } + swap(a, b) { + [this.items[a], this.items[b]] = [this.items[b], this.items[a]]; + [this.f[a], this.f[b]] = [this.f[b], this.f[a]]; + } + } + + /** Bresenham line-of-sight through walkable/transparent tiles */ + function hasLOS(transparent, x0, y0, x1, y1) { + let dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1; + let err = dx - dy; + let x = x0, y = y0; + for (;;) { + if (x === x1 && y === y1) return true; + const e2 = 2 * err; + if (e2 > -dy) { err -= dy; x += sx; } + if (e2 < dx) { err += dx; y += sy; } + if (x === x1 && y === y1) return true; + if (!transparent(x, y)) return false; + } + } + + D2.path = { find, hasLOS, MinHeap }; +})(window.D2); diff --git a/js/game/player.js b/js/game/player.js new file mode 100644 index 0000000..63bc3e6 --- /dev/null +++ b/js/game/player.js @@ -0,0 +1,327 @@ +/* ============================================================ + * Diablo2D — player.js : stats aggregation, leveling, skills UI logic + * ============================================================ */ +'use strict'; +window.D2 = window.D2 || {}; +(function (D2) { + + /* special legendary flags -> stat keys */ + const SPECIAL_MAP = { + cleaveEcho: 'cleaveEcho', + doubleShot: 'doubleShot', + fireNovaOnKill: 'fireNovaOnKill', + chillAttacker: 'chillAttacker', + allSkills: null, // handled via skillBonusLevels + execHeal: 'execHeal', + stormDash: 'dashCdrPct', // value applied below + lifesteal: 'lifestealPct', + eliteSlayer: 'eliteDmgPct', + chainOnHit: 'chainOnHitPct', + corpseBoom: 'corpseBoom', + venomStrike: 'venomOnHit', + voidstep: 'blinkCdrPct', + }; + + function createPlayer(classId) { + const p = new D2.entities.Player(classId); + /* baseMods are applied inside recompute(); attributes hold allocated points only */ + + /* starter gear */ + p.equip.weapon = D2.Items.startingWeapon(classId); + p.equip.chest = D2.Items.startingArmor(); + + /* auto-assign starting actives to hotbar */ + const actives = D2.Skills.skillsFor(classId).filter(s => s.type === 'active' && s.tier === 0); + let slot = 1; + for (const a of actives) { + if (slot >= 5) break; + p.skills[a.id] = 0; // rank 0 = known-but-untrained? no: grant rank 1 free + p.skills[a.id] = 1; + p.hotbar[slot] = a.id; + slot++; + } + + recompute(p); + p.hp = p.maxHp; + p.mana = p.maxMana; + return p; + } + + /** full stat aggregation */ + function recompute(p) { + const oldMaxHp = p.maxHp, oldMaxMana = p.maxMana; + const BAL = D2.BAL; + const s = {}; + + const add = (k, v) => { s[k] = (s[k] || 0) + v; }; + + /* class base */ + const cls = D2.Skills.CLASSES[p.classId]; + for (const [k, v] of Object.entries(cls.baseMods)) add(k, v); + add('armor', D2.BAL.baseArmor); + + /* allocated attributes */ + for (const [k, v] of Object.entries(p.attributes)) if (v) add(k, v); + + /* per-level growth (small) */ + add('str', Math.floor(p.level / 4)); + add('dex', Math.floor(p.level / 4)); + add('vit', Math.floor(p.level / 3)); + add('ene', Math.floor(p.level / 4)); + + /* equipment */ + let skillBonusLevels = 0; + for (const slotKey of Object.keys(p.equip)) { + const it = p.equip[slotKey]; + if (!it) continue; + if (it.stats) for (const [k, v] of Object.entries(it.stats)) add(k, v); + if (it.armor) add('armor', it.armor); + if (it.flatDmg) add('flatDmg', it.flatDmg); + if (it.special === 'allSkills') skillBonusLevels = 1; + const flag = SPECIAL_MAP[it.special]; + if (flag === 'lifestealPct') s.lifestealPct = 0.06; + else if (flag === 'chainOnHitPct') s.chainOnHitPct = 0.15; + else if (flag === 'eliteDmgPct') s.eliteDmgPct = 25; + else if (flag === 'dashCdrPct') s.dashCdrPct = 30; + else if (flag === 'blinkCdrPct') s.blinkCdrPct = 30; + else if (flag) s[flag] = true; + } + + /* passives from learned skills */ + for (const [skillId, rank] of Object.entries(p.skills)) { + if (!rank || rank <= 0) continue; + const sk = D2.Skills.findSkill(p.classId, skillId); + if (!sk || sk.type !== 'passive') continue; + const mods = sk.mods(rank); + for (const [k, v] of Object.entries(mods)) add(k, v); + } + + /* active buffs */ + for (const b of p.buffs) { + if (b.mods) for (const [k, v] of Object.entries(b.mods)) add(k, v); + } + + /* shrine/regen derived */ + p.stats = s; + + /* derived attributes */ + p.maxHp = Math.round(BAL.baseHp + s.vit * BAL.hpPerVit + (p.level - 1) * BAL.hpPerLevel + (s.hpFlat || 0)); + p.maxMana = Math.round(BAL.baseMana + s.ene * BAL.manaPerEne + (p.level - 1) * BAL.manaPerLevel + (s.manaFlat || 0)); + + /* keep ratio when max grows */ + if (oldMaxHp > 0 && p.maxHp > oldMaxHp) p.hp += p.maxHp - oldMaxHp; + if (oldMaxMana > 0 && p.maxMana > oldMaxMana) p.mana += p.maxMana - oldMaxMana; + p.hp = D2.util.clamp(p.hp, 0, p.maxHp); + p.mana = D2.util.clamp(p.mana, 0, p.maxMana); + + p.moveSpeed = Math.min(BAL.speedCap, + BAL.baseSpeed * (1 + (s.moveSpeedPct || 0) / 100)); + + p.skillBonusLevels = skillBonusLevels; + } + + function buffMod(p, key) { + let v = 0; + for (const b of p.buffs) if (b.mods && b.mods[key]) v += b.mods[key]; + return v; + } + PlayerProtoInit(); + function PlayerProtoInit() { + D2.entities.Player.prototype.buffMod = function (key) { return buffMod(this, key); }; + } + + /* ---------------- XP & levels ---------------- */ + + function gainXp(game, amount, source) { + const p = game.player; + amount = Math.round(amount * (1 + buffMod(p, 'xpGain') / 100)); + p.xp += amount; + game.addFloatText( + source ? source.x : p.x, + source ? source.y - 0.5 : p.y - 0.5, + '+' + amount + ' xp', '#c8a35a', 11); + + while (p.level < D2.BAL.maxLevel && p.xp >= D2.BAL.xpForLevel(p.level)) { + levelUp(game); + } + if (D2.ui) { D2.ui.refreshHud(); } + } + + function levelUp(game) { + const p = game.player; + p.level++; + p.statPoints += D2.BAL.statPointsPerLevel; + p.skillPoints += D2.BAL.skillPointsPerLevel; + recompute(p); + p.hp = p.maxHp; + p.mana = p.maxMana; + game.sfx('levelup'); + game.toast(D2.i18n.t('msg.levelup'), 'gold'); + game.addFloatText(p.x, p.y, 'LEVEL ' + p.level + '!', '#ffe86a', 18); + game.spawnParticles(p.x, p.y, { count: 26, color: '#ffe86a', speed: 4.5, life: 0.9, size: 2.8, z: 14 }); + game.addLight(p.x, p.y, 5, '#ffe86a', 0.9, 1); + if (D2.ui) { D2.ui.refreshHud(); D2.ui.refreshCharacter(); D2.ui.refreshSkills(); } + } + + function allocateStat(p, key) { + if (p.statPoints <= 0) return false; + if (!(key in p.attributes)) return false; + p.attributes[key]++; + p.statPoints--; + recompute(p); + return true; + } + + /* ---------------- skills ---------------- */ + + function getSkillRank(p, skillId) { + const r = p.skills[skillId] || 0; + if (r > 0 && p.skillBonusLevels) { + const sk = D2.Skills.findSkill(p.classId, skillId); + if (sk && sk.type === 'active') return Math.min(sk.maxRank, r + p.skillBonusLevels); + } + return r; + } + + function canLearnSkill(p, skill) { + if ((p.skills[skill.id] || 0) >= skill.maxRank) return 'maxed'; + if (p.level < skill.unlockLevel) return 'level'; + if (p.skillPoints <= 0) return 'points'; + return true; + } + + function learnSkill(game, skill) { + const p = game.player; + const ok = canLearnSkill(p, skill); + if (ok !== true) { + if (game && game.sfx) game.sfx('error'); + return false; + } + p.skills[skill.id] = (p.skills[skill.id] || 0) + 1; + p.skillPoints--; + /* auto-assign to hotbar on first rank */ + if (p.skills[skill.id] === 1 && !p.hotbar.includes(skill.id)) { + const empty = p.hotbar.findIndex((v, i) => i > 0 && v == null); + if (empty >= 0) p.hotbar[empty] = skill.id; + } + if (game && game.sfx) game.sfx('buff'); + recompute(p); + if (D2.ui) { D2.ui.refreshSkills(); D2.ui.refreshHotbar(); } + return true; + } + + /* ---------------- potions ---------------- */ + + let beltCd = 0; + function usePotion(game, type) { + const p = game.player; + if (beltCd > 0) return false; + if (p.potions[type] <= 0) { + game.toast(D2.i18n.t('msg.potion_none'), 'bad'); + if (game && game.sfx) game.sfx('error'); + return false; + } + const BAL = D2.BAL; + if (type === 'hp') { + if (p.hp >= p.maxHp) return false; + const heal = p.maxHp * BAL.potionHealPct + BAL.potionHealFlat(p.level); + D2.combat.healPlayer(game, heal); + } else { + if (p.mana >= p.maxMana) return false; + const restore = p.maxMana * BAL.potionManaPct + BAL.potionHealFlat(p.level) * 0.6; + p.mana = Math.min(p.maxMana, p.mana + restore); + game.addFloatText(p.x, p.y - .3, '+' + Math.round(restore), '#6f9fe8', 11); + } + p.potions[type]--; + beltCd = 0.8; + game.sfx('potion'); + if (D2.ui) D2.ui.refreshHotbar(); + return true; + } + function tickBelt(dt) { if (beltCd > 0) beltCd -= dt; } + + /* ---------------- equip/unequip ---------------- */ + function resolvePlayer(g) { return g && g.player ? g.player : g; } + + + function equipItem(game, item, fromInventoryIdx) { + const p = resolvePlayer(game); + let slotKey = item.slot; + if (item.slot === 'ring') { + if (!p.equip.ring1) slotKey = 'ring1'; + else if (!p.equip.ring2) slotKey = 'ring2'; + else slotKey = 'ring1'; + } + const prev = p.equip[slotKey]; + p.equip[slotKey] = item; + if (fromInventoryIdx != null) { + p.inventory.splice(fromInventoryIdx, 1); + } + if (prev) p.inventory.push(prev); + recompute(p); + if (game && game.sfx) game.sfx('pickup'); + if (D2.ui) { D2.ui.refreshInventory(); D2.ui.refreshCharacter(); D2.ui.refreshHotbar(); } + return true; + } + + function unequipSlot(game, slotKey) { + const p = resolvePlayer(game); + const it = p.equip[slotKey]; + if (!it) return false; + if (p.inventory.length >= 40) { + if (game && game.toast) game.toast(D2.i18n.t('msg.inventory_full'), 'bad'); + return false; + } + p.equip[slotKey] = null; + p.inventory.push(it); + recompute(p); + if (game && game.sfx) game.sfx('click'); + if (D2.ui) { D2.ui.refreshInventory(); D2.ui.refreshCharacter(); } + return true; + } + + function sellItem(game, invIdxOrItem, priceOverride) { + const p = resolvePlayer(game); + let item, idx = invIdxOrItem; + if (typeof invIdxOrItem === 'object') { item = invIdxOrItem; idx = p.inventory.indexOf(item); } + else item = p.inventory[idx]; + if (!item) return false; + const val = priceOverride != null ? priceOverride : Math.round(item.value * D2.BAL.sellRatio); + if (idx >= 0) p.inventory.splice(idx, 1); + else { + /* equipped? */ + for (const k of Object.keys(p.equip)) if (p.equip[k] === item) p.equip[k] = null; + } + p.gold += val; + if (game && game.sfx) game.sfx('sell'); + recompute(p); + if (D2.ui) { D2.ui.refreshVendor(); D2.ui.refreshInventory(); D2.ui.refreshCharacter(); } + return val; + } + + function buyItem(game, item) { + const p = resolvePlayer(game); + if (p.gold < item.value) { + if (game && game.toast) game.toast(D2.i18n.t('msg.not_enough_mana').replace(/mana/i, 'gold'), 'bad'); + if (game && game.sfx) game.sfx('error'); + return false; + } + if (p.inventory.length >= 40) { + if (game && game.toast) game.toast(D2.i18n.t('msg.inventory_full'), 'bad'); + return false; + } + p.gold -= item.value; + p.inventory.push(item); + if (game && game.sfx) game.sfx('buy'); + if (D2.ui) { D2.ui.refreshVendor(); D2.ui.refreshInventory(); } + return true; + } + + D2.player = { + createPlayer, recompute, gainXp, allocateStat, + getSkillRank, canLearnSkill, learnSkill, + usePotion, tickBelt, + equipItem, unequipSlot, sellItem, buyItem, + buffMod, + }; +})(window.D2); diff --git a/js/game/world.js b/js/game/world.js new file mode 100644 index 0000000..f07cb47 --- /dev/null +++ b/js/game/world.js @@ -0,0 +1,513 @@ +/* ============================================================ + * Diablo2D — world.js : procedural dungeon & town generation + * ============================================================ */ +'use strict'; +window.D2 = window.D2 || {}; +(function (D2) { + + const T = { VOID: 0, FLOOR: 1, WALL: 2 }; + + const THEMES = { + cathedral: { + gen: 'rooms', floorsPerRoom: 0.9, + floorCols: ['#4a3f38', '#52453c', '#453b34', '#57493e'], + wallTop: '#2c2622', wallFace: '#3a322c', + accent: '#7a5a3a', torch: '#ff9a4a', fog: '#0a0806', ambient: 0.16, + music: 'crypt', + }, + catacombs: { + gen: 'rooms', + floorCols: ['#3a4048', '#414750', '#363c44', '#464c55'], + wallTop: '#23282e', wallFace: '#30363d', + accent: '#4a6a8a', torch: '#7ab8ff', fog: '#07090c', ambient: 0.12, + music: 'crypt', + }, + caves: { + gen: 'caves', + floorCols: ['#3e4a34', '#46523a', '#38442e', '#4d5940'], + wallTop: '#242c20', wallFace: '#323e2a', + accent: '#5a8a3a', torch: '#b8e86a', fog: '#080a06', ambient: 0.14, + music: 'cave', + }, + hell: { + gen: 'caves', + floorCols: ['#48302a', '#50362e', '#422a24', '#583a30'], + wallTop: '#281512', wallFace: '#38201a', + accent: '#c83a1a', torch: '#ff5a2a', fog: '#100604', ambient: 0.18, + music: 'hell', + }, + town: { + gen: 'town', + floorCols: ['#55503e', '#5c5644', '#4e4938', '#635d49'], + wallTop: '#33291e', wallFace: '#453829', + accent: '#8a7a4a', torch: '#ffb84a', fog: '#0c0a08', ambient: 0.32, + music: 'town', + }, + }; + + /* ---------------- helpers ---------------- */ + + function idx(w, x, y) { return y * w + x; } + + function inBounds(w, h, x, y) { return x >= 0 && y >= 0 && x < w && y < h; } + + /* ---------------- rooms & corridors generator ---------------- */ + + function genRooms(rng, w, h, roomAttempts) { + const tiles = new Uint8Array(w * h); + const rooms = []; + for (let i = 0; i < roomAttempts && rooms.length < 14; i++) { + const rw = rng.int(6, 13), rh = rng.int(5, 10); + const rx = rng.int(2, w - rw - 3), ry = rng.int(2, h - rh - 3); + const room = { x: rx, y: ry, w: rw, h: rh, cx: (rx + rw / 2) | 0, cy: (ry + rh / 2) | 0 }; + if (rooms.some(r => overlap(room, r, 2))) continue; + rooms.push(room); + carveRect(tiles, w, room); + } + /* L corridors between consecutive rooms */ + for (let i = 1; i < rooms.length; i++) { + const a = rooms[i - 1], b = rooms[i]; + carveL(tiles, w, a.cx, a.cy, b.cx, b.cy); + } + /* a couple of loops */ + for (let k = 0; k < 3 && rooms.length > 4; k++) { + const a = rng.pick(rooms), b = rng.pick(rooms); + if (a !== b) carveL(tiles, w, a.cx, a.cy, b.cx, b.cy); + } + return { tiles, rooms }; + } + + function overlap(a, b, pad) { + return a.x - pad < b.x + b.w && a.x + a.w + pad > b.x && + a.y - pad < b.y + b.h && a.y + a.h + pad > b.y; + } + + function carveRect(tiles, w, r) { + for (let y = r.y; y < r.y + r.h; y++) + for (let x = r.x; x < r.x + r.w; x++) + tiles[idx(w, x, y)] = T.FLOOR; + } + + function carveL(tiles, w, x0, y0, x1, y1) { + let x = x0, y = y0; + const horizFirst = Math.random() < 0.5; + const step = () => { tiles[idx(w, x, y)] = T.FLOOR; }; + step(); + if (horizFirst) { + while (x !== x1) { x += Math.sign(x1 - x); step(); } + while (y !== y1) { y += Math.sign(y1 - y); step(); } + } else { + while (y !== y1) { y += Math.sign(y1 - y); step(); } + while (x !== x1) { x += Math.sign(x1 - x); step(); } + } + } + + /* ---------------- cellular cave generator ---------------- */ + + function genCaves(rng, w, h) { + const tiles = new Uint8Array(w * h); + const wallP = 0.44; + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) + tiles[idx(w, x, y)] = (x === 0 || y === 0 || x === w - 1 || y === h - 1 || rng.next() < wallP) ? T.WALL : T.FLOOR; + + for (let pass = 0; pass < 4; pass++) { + const next = Uint8Array.from(tiles); + for (let y = 1; y < h - 1; y++) { + for (let x = 1; x < w - 1; x++) { + let walls = 0; + for (let dy = -1; dy <= 1; dy++) + for (let dx = -1; dx <= 1; dx++) { + if (!dx && !dy) continue; + if (tiles[idx(w, x + dx, y + dy)] !== T.FLOOR) walls++; + } + next[idx(w, x, y)] = walls >= 5 ? T.WALL : T.FLOOR; + } + } + tiles.set(next); + } + + /* keep only the largest connected region */ + const seen = new Uint8Array(w * h); + let best = null; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const i0 = idx(w, x, y); + if (tiles[i0] !== T.FLOOR || seen[i0]) continue; + const region = []; + const stack = [[x, y]]; + seen[i0] = 1; + while (stack.length) { + const [cx, cy] = stack.pop(); + region.push([cx, cy]); + for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) { + const nx = cx + dx, ny = cy + dy; + if (!inBounds(w, h, nx, ny)) continue; + const ni = idx(w, nx, ny); + if (!seen[ni] && tiles[ni] === T.FLOOR) { seen[ni] = 1; stack.push([nx, ny]); } + } + } + if (!best || region.length > best.length) best = region; + } + } + const keep = new Set(best.map(([x, y]) => idx(w, x, y))); + for (let i = 0; i < tiles.length; i++) + if (tiles[i] === T.FLOOR && !keep.has(i)) tiles[i] = T.VOID; + + /* pseudo-rooms = flood-fill pockets for prop/spawn logic */ + const rooms = []; + const grid = {}; + for (const [x, y] of best) grid[x + ',' + y] = true; + // sample room anchors on a coarse grid where floor exists + for (let gy = 6; gy < h - 6; gy += 9) { + for (let gx = 6; gx < w - 6; gx += 9) { + if (grid[gx + ',' + gy]) rooms.push({ x: gx - 3, y: gy - 3, w: 7, h: 7, cx: gx, cy: gy }); + } + } + return { tiles, rooms }; + } + + /* ---------------- walls around floors ---------------- */ + + function buildWalls(tiles, w, h) { + const out = Uint8Array.from(tiles); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (tiles[idx(w, x, y)] !== T.VOID) continue; + let touchesFloor = false; + for (let dy = -1; dy <= 1 && !touchesFloor; dy++) + for (let dx = -1; dx <= 1; dx++) { + const nx = x + dx, ny = y + dy; + if (inBounds(w, h, nx, ny) && tiles[idx(w, nx, ny)] === T.FLOOR) { touchesFloor = true; break; } + } + if (touchesFloor) out[idx(w, x, y)] = T.WALL; + } + } + return out; + } + + /* ---------------- floor assembly ---------------- */ + + /** + * opts: { act, floorIdx, seed, bossLair, torment } + * Returns the World object used by simulation & rendering. + */ + function generateFloor(opts) { + const act = opts.act | 0; + const floorIdx = opts.floorIdx | 0; + const themeId = D2.BAL.acts[act].theme; + const theme = THEMES[themeId]; + const rng = new D2.util.RNG(opts.seed >>> 0); + const isBoss = !!opts.bossLair; + + const w = isBoss ? 52 : 62, h = isBoss ? 38 : 62; + let tiles, rooms; + + if (isBoss) { + tiles = new Uint8Array(w * h); + const m = 4; + const room = { x: m, y: m, w: w - m * 2, h: h - m * 2 }; + carveRect(tiles, w, room); + rooms = [room]; + /* pillars for cover */ + for (let py = room.y + 4; py < room.y + room.h - 3; py += 6) { + for (let px = room.x + 5; px < room.x + room.w - 4; px += 8) { + tiles[idx(w, px, py)] = T.WALL; + tiles[idx(w, px + 1, py)] = T.WALL; + } + } + } else if (theme.gen === 'rooms') { + ({ tiles, rooms } = genRooms(rng, w, h, 90)); + } else { + ({ tiles, rooms } = genCaves(rng, w, h)); + } + + const finalTiles = buildWalls(tiles, w, h); + + /* variants for art */ + const variant = new Uint8Array(w * h); + for (let i = 0; i < variant.length; i++) variant[i] = (rng.next() * 4) | 0; + + const world = { + w, h, + tiles: finalTiles, + variant, + theme, themeId, act, floorIdx, + isBossLair: isBoss, + seed: opts.seed >>> 0, + torment: opts.torment | 0, + rooms, + props: [], + propMap: new Map(), // "x,y" -> blocking prop + spawns: [], + visible: new Uint8Array(w * h), + explored: new Uint8Array(w * h), + torches: [], // light sources {x,y,color,r,flicker} + spawnX: 0, spawnY: 0, + stairsDown: null, stairsUp: null, + mlvl: D2.BAL.monsterLevel(act, floorIdx, opts.torment | 0), + + tileAt(x, y) { return inBounds(w, h, x, y) ? finalTiles[idx(w, x, y)] : T.VOID; }, + isWalkable(x, y) { + if (this.tileAt(Math.floor(x), Math.floor(y)) !== T.FLOOR) return false; + return !this.propMap.has(Math.floor(x) + ',' + Math.floor(y)); + }, + transparent(x, y) { return this.tileAt(x, y) === T.FLOOR; }, + randomFloorIn(room) { + for (let tries = 0; tries < 30; tries++) { + const x = rng.int(room.x, room.x + room.w - 1); + const y = rng.int(room.y, room.y + room.h - 1); + if (finalTiles[idx(w, x, y)] === T.FLOOR && !this.propMap.has(x + ',' + y)) return { x, y }; + } + return null; + }, + addProp(p) { + p.uid = D2.util.uid(); + this.props.push(p); + if (p.blocking) this.propMap.set(p.x + ',' + p.y, p); + if (p.type === 'torch') this.torches.push({ x: p.x, y: p.y }); + return p; + }, + removeProp(p) { + const i = this.props.indexOf(p); + if (i >= 0) this.props.splice(i, 1); + this.propMap.delete(p.x + ',' + p.y); + }, + }; + + /* ---- spawn point = first room center ---- */ + const startRoom = rooms[0]; + world.spawnX = startRoom.cx; world.spawnY = startRoom.cy; + + /* ---- stairs ---- */ + if (!isBoss) { + const lastRoom = farthestRoom(world, rooms, startRoom); + const sp = world.randomFloorIn(lastRoom) || { x: lastRoom.cx, y: lastRoom.cy }; + world.stairsDown = { x: sp.x, y: sp.y }; + world.addProp({ type: 'stairs_down', x: sp.x, y: sp.y, blocking: false }); + } else { + world.stairsUp = { x: startRoom.cx, y: startRoom.cy }; + world.addProp({ type: 'stairs_up', x: startRoom.cx, y: startRoom.cy, blocking: false }); + } + + populateProps(world, rng, theme); + populateMonsters(world, rng, act, isBoss); + ensureRouteToStairs(world); + + return world; + } + + /* Remove blocking props along one spawn→stairs route so caves + with 1-wide tunnels can never be sealed by barrels. */ + function ensureRouteToStairs(world) { + if (!world.stairsDown || world.isTown) return; + const tilesPath = D2.path.find( + (x, y) => world.transparent(x, y), + world.w, world.h, + world.spawnX, world.spawnY, + world.stairsDown.x, world.stairsDown.y, 30000); + if (!tilesPath) return; + for (const n of tilesPath) { + const pr = world.propMap.get(n.x + ',' + n.y); + if (pr && pr.blocking) world.removeProp(pr); + } + } + + function farthestRoom(world, rooms, from) { + let best = rooms[rooms.length - 1], bestD = -1; + for (const r of rooms) { + const d = (r.cx - from.cx) ** 2 + (r.cy - from.cy) ** 2; + if (d > bestD) { bestD = d; best = r; } + } + return best; + } + + /* ---------------- props ---------------- */ + + function populateProps(world, rng, theme) { + const { w, h } = world; + const at = (x, y) => world.tileAt(x, y) === T.FLOOR && !world.propMap.has(x + ',' + y); + + /* torches along walls */ + const torchSpots = []; + for (let y = 1; y < h - 1; y++) + for (let x = 1; x < w - 1; x++) + if (world.tiles[idx(w, x, y)] === T.WALL && + world.tileAt(x, y + 1) === T.FLOOR) + torchSpots.push([x, y]); + rng.shuffle(torchSpots); + const torchCount = Math.min(18, torchSpots.length); + for (let i = 0; i < torchCount; i++) { + const [x, y] = torchSpots[i]; + world.addProp({ type: 'torch', x, y: y, blocking: false, light: true }); + } + + if (world.isBossLair) { + /* a chest behind the boss area */ + return; + } + + for (const room of world.rooms.slice(1)) { + /* barrels & urns */ + const clusterN = rng.int(0, 3); + for (let c = 0; c < clusterN; c++) { + const bx = rng.int(room.x + 1, room.x + room.w - 2); + const by = rng.int(room.y + 1, room.y + room.h - 2); + const n = rng.int(1, 3); + for (let i = 0; i < n; i++) { + const x = Math.min(room.x + room.w - 1, bx + i % 2), y = by; + if (at(x, y)) world.addProp({ + type: rng.chance(0.5) ? 'barrel' : 'urn', + x, y, blocking: true, hp: 1, + }); + } + } + /* chest */ + if (rng.chance(0.3)) { + const spot = world.randomFloorIn(room); + if (spot) world.addProp({ type: 'chest', x: spot.x, y: spot.y, blocking: true, opened: false }); + } + /* shrine (rare) */ + if (rng.chance(0.05)) { + const spot = world.randomFloorIn(room); + if (spot) world.addProp({ + type: 'shrine', x: spot.x, y: spot.y, blocking: false, used: false, + buff: rng.pick(['dmg', 'speed', 'armor', 'xp', 'regen']), + }); + } + } + } + + /* ---------------- monsters ---------------- */ + + function populateMonsters(world, rng, act, isBoss) { + if (isBoss) { + const bd = D2.Monsters.BOSS_MAP[D2.BAL.acts[act].boss]; + world.spawns.push({ boss: bd.id, x: world.w / 2, y: world.h * 0.68 }); + /* honor guard */ + const guard = D2.Monsters.rollSpeciesForAct(act); + for (let i = 0; i < 4; i++) { + world.spawns.push({ speciesId: guard.id, x: world.w / 2 + (rng.range(-5, 5)), y: world.h * 0.55 + rng.range(-2, 2), elite: false }); + } + return; + } + + const density = 0.55 + world.act * 0.12 + world.floorIdx * 0.05; + for (const room of world.rooms.slice(1)) { + if (world.stairsDown && room === nearestRoomToStairs(world)) continue; + const packs = Math.max(1, Math.round((room.w * room.h) / 55 * density)); + for (let p = 0; p < packs; p++) { + const species = D2.Monsters.rollSpeciesForAct(act); + const packSize = species.ai === 'swarm' ? rng.int(3, 5) : rng.int(2, 4); + const packElite = rng.chance(0.13); + const base = world.randomFloorIn(room); + if (!base) continue; + for (let i = 0; i < packSize; i++) { + const sx = Math.max(1, Math.min(world.w - 2, base.x + ((rng.next() * 5) | 0) - 2)); + const sy = Math.max(1, Math.min(world.h - 2, base.y + ((rng.next() * 5) | 0) - 2)); + if (world.tileAt(sx, sy) === T.FLOOR) + world.spawns.push({ speciesId: species.id, x: sx, y: sy, elite: packElite }); + } + } + } + } + + function nearestRoomToStairs(world) { + if (!world.rooms.length || !world.stairsDown) return null; + let best = null, bd = Infinity; + for (const r of world.rooms) { + const d = (r.cx - world.stairsDown.x) ** 2 + (r.cy - world.stairsDown.y) ** 2; + if (d < bd) { bd = d; best = r; } + } + return best; + } + + /* ---------------- town ---------------- */ + + function generateTown(seed) { + const w = 56, h = 40; + const rng = new D2.util.RNG((seed ^ 0x7A6E) >>> 0); + const theme = THEMES.town; + const tiles = new Uint8Array(w * h).fill(T.VOID); + + const border = 2; + for (let y = border; y < h - border; y++) + for (let x = border; x < w - border; x++) + tiles[idx(w, x, y)] = T.FLOOR; + + const world = { + w, h, tiles, variant: new Uint8Array(w * h), + theme, themeId: 'town', act: -1, floorIdx: -1, + isBossLair: false, isTown: true, seed: seed >>> 0, torment: 0, + rooms: [], props: [], propMap: new Map(), + spawns: [], visible: new Uint8Array(w * h), explored: new Uint8Array(w * h), + torches: [], spawnX: (w / 2) | 0, spawnY: (h * 0.62) | 0, + stairsDown: null, stairsUp: null, mlvl: 1, + + tileAt(x, y) { return inBounds(w, h, x, y) ? tiles[idx(w, x, y)] : T.VOID; }, + isWalkable(x, y) { + if (this.tileAt(Math.floor(x), Math.floor(y)) !== T.FLOOR) return false; + return !this.propMap.has(Math.floor(x) + ',' + Math.floor(y)); + }, + transparent(x, y) { return this.tileAt(x, y) === T.FLOOR; }, + randomFloorIn(room) { return null; }, + addProp(p) { + p.uid = D2.util.uid(); + this.props.push(p); + if (p.blocking) this.propMap.set(p.x + ',' + p.y, p); + if (p.type === 'torch') this.torches.push({ x: p.x, y: p.y }); + return p; + }, + removeProp(p) { + const i = this.props.indexOf(p); + if (i >= 0) this.props.splice(i, 1); + this.propMap.delete(p.x + ',' + p.y); + }, + }; + + /* Rogue-camp buildings along the top (charsi/akara/stash/kashya/cain/gheed) */ + const bw = 7, bh = 6; + const buildings = [ + { x: 3, npc: 'charsi' }, // blacksmith + { x: 11, npc: 'akara' }, // healer & magic + { x: 19, npc: 'stash' }, // camp storage + { x: 27, npc: 'kashya' }, // rogue captain — hunt quests + { x: 35, npc: 'cain' }, // elder lore — main quests + { x: 43, npc: 'gheed' }, // gambler + ].map(b => ({ ...b, w: bw, h: bh, y: 4 })); + for (const b of buildings) { + for (let y = b.y; y < b.y + b.h; y++) + for (let x = b.x; x < b.x + b.w; x++) + tiles[idx(w, x, y)] = T.WALL; + /* doorway at bottom center */ + const doorX = b.x + (b.w >> 1); + tiles[idx(w, doorX, b.y + b.h - 1)] = T.FLOOR; + tiles[idx(w, doorX, b.y + b.h - 2)] = T.FLOOR; + world.addProp({ type: 'npc_' + b.npc, x: doorX, y: b.y + b.h - 4, blocking: true }); + world.addProp({ type: 'torch', x: b.x - 1, y: b.y + b.h, blocking: false, light: true }); + world.addProp({ type: 'torch', x: b.x + b.w, y: b.y + b.h, blocking: false, light: true }); + } + + /* waypoint stone center */ + world.addProp({ type: 'waypoint', x: (w / 2) | 0, y: (h * 0.38) | 0, blocking: true }); + + /* decorative props */ + for (let i = 0; i < 10; i++) { + const x = rng.int(border + 1, w - border - 2), y = rng.int(h * 0.55 | 0, h - border - 2); + if (world.tileAt(x, y) === T.FLOOR && !world.propMap.has(x + ',' + y)) + world.addProp({ type: rng.chance(0.5) ? 'barrel' : 'bones', x, y, blocking: rng.chance(0.5), hp: 1 }); + } + /* fence posts around perimeter */ + for (let x = border; x < w - border; x += 3) { + world.addProp({ type: 'bones', x, y: h - border, blocking: false }); + world.addProp({ type: 'bones', x, y: border - 1 >= 0 ? border : border, blocking: false }); + } + + /* explored everywhere in town */ + world.explored.fill(1); + + return world; + } + + D2.world = { T, THEMES, generateFloor, generateTown }; +})(window.D2); diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..29f581e --- /dev/null +++ b/js/main.js @@ -0,0 +1,184 @@ +/* ============================================================ + * Diablo2D — main.js : bootstrap & game loop + * ============================================================ */ +'use strict'; +window.D2 = window.D2 || {}; +(function (D2) { + + let lastT = 0; + let hudRefreshT = 0; + const G = D2.game; + + /* ---------------- pause ---------------- */ + + function togglePause() { + const overlay = document.getElementById('pause-overlay'); + if (G.state === 'playing') { + G.state = 'paused'; + overlay.classList.remove('hidden'); + D2.audio.stopMusic(0.3); + } else if (G.state === 'paused') { + G.state = 'playing'; + overlay.classList.add('hidden'); + if (G.world) D2.audio.playMusic(G.world.isTown ? 'town' : (G.world.isBossLair ? 'boss' : G.world.theme.music)); + } + } + + function exitToTitle() { + G.saveGame(); + G.state = 'title'; + document.getElementById('pause-overlay').classList.add('hidden'); + D2.ui.closeAllPanels(); + D2.ui.hideHud(); + D2.ui.showTitle(); + } + + function openHelpInGame() { + G.state = 'paused'; + document.getElementById('pause-overlay').classList.add('hidden'); + D2.ui.showHelp(() => { + if (G.player && !G.player.dead) { + G.state = 'playing'; + D2.ui.hideScreens(); + } else { + D2.ui.showTitle(); + } + }); + } + + /* ---------------- boot ---------------- */ + + function boot() { + /* load persisted settings before anything uses them */ + const savedSettings = D2.save.get(D2.save.KEY.SETTINGS); + if (savedSettings) Object.assign(G.settings, savedSettings); + D2.i18n.setLang(G.settings.lang); + + const canvas = document.getElementById('game-canvas'); + D2.render.init(canvas); + D2.input.init(canvas); + D2.audio.playMusic('title'); // becomes pending until first gesture + + /* staged loading for the progress bar feel */ + D2.ui.showLoading(); + const steps = [ + ['Carving tiles…', () => { D2.sprites.init(); }], + ['Binding sigils…', () => { D2.ui.init(); D2.ui.initPanels(); }], + ['Raising the dead…', () => { D2.game.updateQuests(); }], + ['Opening the gates…', () => {}], + ]; + let i = 0; + const stepFn = () => { + if (i < steps.length) { + const [label, fn] = steps[i]; + try { fn(); } catch (e) { console.error('boot step failed:', label, e); } + i++; + D2.ui.setLoadingProgress(i / steps.length); + setTimeout(stepFn, 120); + } else { + finishBoot(); + } + }; + setTimeout(stepFn, 200); + } + + function finishBoot() { + wirePauseMenu(); + window.D2.main = api; // expose after full init + G.applySettings(); + + /* click anywhere unlocks audio */ + const unlock = () => { D2.audio.unlock(); }; + window.addEventListener('mousedown', unlock, { once: true }); + window.addEventListener('keydown', unlock, { once: true }); + + /* autosave on tab close while playing */ + window.addEventListener('beforeunload', () => { + if ((G.state === 'playing' || G.state === 'paused') && G.player) G.saveGame(); + }); + document.addEventListener('visibilitychange', () => { + if (document.hidden && G.state === 'playing') togglePause(); + }); + + G.state = 'title'; + D2.ui.showTitle(); + requestAnimationFrame(loop); + } + + function wirePauseMenu() { + document.getElementById('btn-resume').addEventListener('click', togglePause); + document.getElementById('btn-pause-settings').addEventListener('click', () => { + D2.ui.showSettings(() => { + document.getElementById('screen-settings').classList.add('hidden'); + if (G.state === 'paused') document.getElementById('pause-overlay').classList.remove('hidden'); + }); + document.getElementById('pause-overlay').classList.add('hidden'); + }); + document.getElementById('btn-pause-help').addEventListener('click', () => { + document.getElementById('pause-overlay').classList.add('hidden'); + D2.ui.showHelp(() => { + document.getElementById('screen-help').classList.add('hidden'); + document.getElementById('pause-overlay').classList.remove('hidden'); + }); + }); + document.getElementById('btn-save-exit').addEventListener('click', exitToTitle); + } + + /* ---------------- main loop ---------------- */ + + function loop(t) { + requestAnimationFrame(loop); + const dt = Math.min(0.05, (t - lastT) / 1000 || 0.016); + lastT = t; + + /* consume this frame's inputs first, act on them, clear edges LAST */ + D2.ui.tickGlobalKeys(); + + if (G.state === 'playing') { + G.update(dt); + D2.render.frame(G, dt); + D2.ui.tick(dt); + hudRefreshT -= dt; + if (hudRefreshT <= 0) { hudRefreshT = 0.12; D2.ui.refreshHud(); } + } else if (G.state === 'paused' || G.state === 'dead' || G.state === 'victory') { + /* keep rendering the frozen world behind overlays */ + if (G.world) D2.render.frame(G, 0); + } + D2.input.endFrame(); + } + + /* ---------------- public api ---------------- */ + + const api = { + togglePause, + exitToTitle, + openHelp: openHelpInGame, + openClassSelect() { + D2.ui.showClassSelect(); + }, + startNewGame(classId) { + D2.ui.hideScreens(); + D2.ui.hideHud ? null : 0; + D2.ui.showHud(); + G.startNewGame(classId); + D2.ui.refreshHud(); + D2.ui.renderQuestTracker(); + D2.ui.toast(D2.i18n.t('class.' + classId + '.name'), 'gold'); + setTimeout(() => { + D2.ui.toast(D2.i18n.getLang() === 'vi' + ? '🗣 Hãy nói chuyện với Kashya & Cain trong trại (phím F)' + : '🗣 Speak with Kashya & Cain in camp (press F)', 'gold'); + }, 1200); + }, + startContinue() { + D2.ui.hideScreens(); + D2.ui.showHud(); + if (G.continueGame()) { + D2.ui.refreshHud(); + D2.ui.renderQuestTracker(); + } + }, + }; + + window.addEventListener('DOMContentLoaded', boot); +})(window.D2); diff --git a/js/render/render.js b/js/render/render.js new file mode 100644 index 0000000..ce79746 --- /dev/null +++ b/js/render/render.js @@ -0,0 +1,1102 @@ +/* ============================================================ + * Diablo2D — render.js : isometric renderer, lighting, particles + * ============================================================ */ +'use strict'; +window.D2 = window.D2 || {}; +(function (D2) { + + const TW = 64, TH = 32, WALL_H = 46; + + let canvas = null, ctx = null; + let lightCanvas = null, lctx = null; + let W = 0, H = 0, dpr = 1; + + const cam = { x: 0, y: 0, zoom: 1.18, shakeT: 0, shakeMag: 0 }; + + /* world (tile) -> screen (px), relative to camera */ + function worldToScreen(wx, wy) { + const dx = wx - cam.x, dy = wy - cam.y; + return { + x: (dx - dy) * TW / 2, + y: (dx + dy) * TH / 2, + }; + } + + function screenToWorld(sx, sy) { + /* W,H are CSS pixels (see resize()); no extra dpr division here */ + const cx = (sx - W / 2) / cam.zoom, cy = (sy - H / 2) / cam.zoom; + const a = cx / (TW / 2), b = cy / (TH / 2); + return { x: (b + a) / 2 + cam.x, y: (b - a) / 2 + cam.y }; + } + + function init(cv) { + canvas = cv; + ctx = canvas.getContext('2d'); + lightCanvas = document.createElement('canvas'); + lctx = lightCanvas.getContext('2d'); + resize(); + window.addEventListener('resize', resize); + } + + function resize() { + dpr = Math.min(window.devicePixelRatio || 1, 2); + W = canvas.clientWidth; H = canvas.clientHeight; + canvas.width = Math.round(W * dpr); + canvas.height = Math.round(H * dpr); + lightCanvas.width = canvas.width; + lightCanvas.height = canvas.height; + } + + function addShake(mag) { + if (!D2.game || !D2.game.settings || D2.game.settings.screenShake === false) return; + cam.shakeMag = Math.max(cam.shakeMag, mag); + cam.shakeT = 0.3; + } + + /* ================= main frame ================= */ + + function frame(g, dt) { + const theme = g.world.theme; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.fillStyle = theme.fog; + ctx.fillRect(0, 0, W, H); + + /* camera follows player */ + const target = g.player ? { x: g.player.x, y: g.player.y } : screenToWorld(W / 2, H / 2); + if (g.player) { + cam.x = D2.util.lerp(cam.x, target.x, Math.min(1, dt * 7)); + cam.y = D2.util.lerp(cam.y, target.y, Math.min(1, dt * 7)); + } + if (cam.shakeT > 0) { + cam.shakeT -= dt; + const m = cam.shakeMag * (cam.shakeT / 0.3); + ctx.translate((Math.random() - .5) * m * 14, (Math.random() - .5) * m * 10); + if (cam.shakeT <= 0) cam.shakeMag = 0; + } + ctx.translate(W / 2, H / 2); + ctx.scale(cam.zoom, cam.zoom); + + /* visible tile bounds via inverse projection of corners */ + const corners = [ + screenToWorldLocal(0, 0), screenToWorldLocal(W, 0), + screenToWorldLocal(0, H), screenToWorldLocal(W, H), + ]; + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (const c of corners) { + minX = Math.min(minX, c.x); maxX = Math.max(maxX, c.x); + minY = Math.min(minY, c.y); maxY = Math.max(maxY, c.y); + } + minX -= 2; minY -= 3; maxX += 2; maxY += 4; + + drawFloors(g, theme, minX, maxX, minY, maxY); + drawGroundFx(g, minX, maxX, minY, maxY); + + /* depth-sorted pass */ + const drawables = []; + collectWalls(g, drawables, minX, maxX, minY, maxY); + collectProps(g, drawables, minX, maxX, minY, maxY); + for (const p of g.pickups) pushDrawable(drawables, p.x, p.y, 0.01, 'pickup', p); + for (const e of g.effects) if (e.ring) pushDrawable(drawables, e.x, e.y, 0.02, 'fxring', e); + for (const m of g.monsters) if (!m.dead) pushDrawable(drawables, m.x, m.y, 0, 'monster', m); + if (g.player && !g.player.dead) pushDrawable(drawables, g.player.x, g.player.y, 0, 'player', g.player); + for (const pr of g.projectiles) pushDrawable(drawables, pr.x, pr.y, 0, 'proj', pr); + + drawables.sort((a, b) => a.depth - b.depth); + for (const d of drawables) dispatchDraw(d, g); + + drawParticles(g); + drawSpellGlows(g); + drawFloatingTexts(g); + drawOverheadUI(g); + drawLabelsAndMarkers(g, dt); + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + applyLighting(g, theme); + drawVignette(theme); + } + + /* local screen->world without re-adding cam offset twice */ + function screenToWorldLocal(sx, sy) { + const cx = sx - W / 2, cy = sy - H / 2; + const a = cx / (TW / 2), b = cy / (TH / 2); + return { x: (b + a) / 2 + cam.x, y: (b - a) / 2 + cam.y }; + } + + /* ---------------- floors ---------------- */ + + function drawFloors(g, theme, minX, maxX, minY, maxY) { + const th = D2.sprites.themes[g.world.themeId]; + if (!th) return; + const w = g.world.w, h = g.world.h; + for (let ty = Math.max(0, minY | 0); ty <= Math.min(h - 1, maxY | 0); ty++) { + for (let tx = Math.max(0, minX | 0); tx <= Math.min(w - 1, maxX | 0); tx++) { + const i = ty * w + tx; + if (g.world.tiles[i] !== D2.world.T.FLOOR) continue; + if (!g.world.explored[i]) continue; + const s = worldToScreen(tx, ty); + const vis = g.world.visible[i]; + if (!vis) ctx.globalAlpha = 0.42; + ctx.drawImage(th.floors[g.world.variant[i]], s.x - TW / 2, s.y - TH / 2); + ctx.globalAlpha = 1; + } + } + } + + /* ---------------- ground effects & decals ---------------- */ + + function drawGroundFx(g, minX, maxX, minY, maxY) { + for (const e of g.groundDecals) { + const s = worldToScreen(e.x, e.y); + ctx.save(); + ctx.globalAlpha = Math.max(0, e.alpha) * (e.life / e.maxLife); + ctx.fillStyle = e.color; + ellipseWorld(ctx, s.x, s.y, e.r, 0.52); + ctx.fill(); + ctx.restore(); + } + for (const e of g.effects) { + if (e.type !== 'telegraph') continue; + const s = worldToScreen(e.x, e.y); + const t = D2.util.clamp((e.t / e.delay), 0, 1); + ctx.save(); + ctx.strokeStyle = e.color || '#ff8a3a'; + ctx.globalAlpha = 0.35 + 0.4 * t; + ctx.lineWidth = 2; + ellipseWorld(ctx, s.x, s.y, e.radius, 0.52); ctx.stroke(); + ctx.fillStyle = e.color || '#ff8a3a'; + ctx.globalAlpha *= 0.16; + ellipseWorld(ctx, s.x, s.y, e.radius * t, 0.52); ctx.fill(); + ctx.restore(); + } + } + + function ellipseWorld(c, sx, sy, rTiles, squash) { + c.beginPath(); + c.ellipse(sx, sy, rTiles * TW / 2, rTiles * TW / 2 * squash, 0, 0, Math.PI * 2); + } + + /* ---------------- drawables ---------------- */ + + function pushDrawable(arr, x, y, bias, kind, ref) { + arr.push({ x, y, kind, ref, depth: x + y + bias }); + } + + function collectWalls(g, arr, minX, maxX, minY, maxY) { + const w = g.world.w, h = g.world.h; + for (let ty = Math.max(0, minY | 0); ty <= Math.min(h - 1, maxY | 0); ty++) { + for (let tx = Math.max(0, minX | 0); tx <= Math.min(w - 1, maxX | 0); tx++) { + const i = ty * w + tx; + if (g.world.tiles[i] !== D2.world.T.WALL) continue; + if (!g.world.explored[i]) continue; + pushDrawable(arr, tx, ty, 0.45, 'wall', i); + } + } + } + + function collectProps(g, arr, minX, maxX, minY, maxY) { + for (const p of g.world.props) { + if (p.x < minX - 1 || p.x > maxX + 1 || p.y < minY - 1 || p.y > maxY + 1) continue; + const i = p.y * g.world.w + p.x; + if (!g.world.explored[i]) continue; + pushDrawable(arr, p.x, p.y, 0.02, 'prop', p); + } + } + + function dispatchDraw(d, g) { + switch (d.kind) { + case 'wall': drawWall(g, d.ref, d.x, d.y); break; + case 'prop': drawProp(g, d.ref, d.x, d.y); break; + case 'pickup': drawPickup(g, d.ref, d.x, d.y); break; + case 'monster': drawMonster(g, d.ref, d.x, d.y); break; + case 'player': drawPlayer(g, d.ref, d.x, d.y); break; + case 'proj': drawProjectile(d.ref, d.x, d.y); break; + case 'fxring': drawFxRing(d.ref, d.x, d.y); break; + } + } + + function drawWall(g, tileIdx, tx, ty) { + const th = D2.sprites.themes[g.world.themeId]; + const s = worldToScreen(tx, ty); + ctx.drawImage(th.wallBlock, s.x - TW / 2 - 2, s.y - WALL_H - TH / 2 - 2); + } + + function drawProp(g, p, tx, ty) { + const s = worldToScreen(tx, ty); + const P = D2.sprites.props; + const px = s.x, py = s.y; + switch (p.type) { + case 'torch': { + ctx.drawImage(P.torch, px - 12, py - 40); + /* animated flame */ + const fl = Math.sin(g.time * 11 + p.uid * 3) * 0.15 + 1; + flame(px - 9.5, py - 46, 6 * fl, '#ffb84a', '#ff6a2a'); + break; + } + case 'barrel': ctx.drawImage(P.barrel, px - 20, py - 34); break; + case 'urn': ctx.drawImage(P.urn, px - 18, py - 32); break; + case 'chest': ctx.drawImage(p.opened ? P.chest_open : P.chest_closed, px - 24, py - 36); + if (!p.opened) sparkle(px, py - 30, '#ffd24a'); break; + case 'shrine': { + ctx.drawImage(P.shrine, px - 26, py - 50); + const col = { dmg: '#e85a3a', speed: '#8ad8ff', armor: '#aab4be', xp: '#ffe86a', regen: '#7ade8a' }[p.buff] || '#fff'; + if (!p.used) { + glowOrb(px, py - 34, 5 + Math.sin(g.time * 4 + p.uid) * 1.5, col); + sparkle(px, py - 34, col); + } + break; + } + case 'stairs_down': ctx.drawImage(P.stairs_down, px - TW / 2, py - TH / 2 - 9); + glowOrb(px, py, 4 + Math.sin(g.time * 3) * 1.2, '#8ac4ff'); break; + case 'stairs_up': ctx.drawImage(P.stairs_up, px - TW / 2, py - TH / 2 - 9); break; + case 'bones': ctx.drawImage(P.bones, px - 18, py - 14); break; + case 'waypoint': { + ctx.drawImage(P.waypoint, px - 24, py - 56); + const active = g.waypointsUnlocked && g.waypointsUnlocked.length > 0; + glowOrb(px, py - 34, 6 + Math.sin(g.time * (active ? 5 : 2)) * 2, active ? '#8ac4ff' : '#666'); + break; + } + default: + if (p.type.startsWith('npc_')) { + const spr = P[p.type]; + if (spr) ctx.drawImage(spr, px - 26, py - 54); + /* quest-ish marker */ + glowOrb(px, py - 58 + Math.sin(g.time * 3) * 2, 3, '#ffd24a'); + } + } + } + + function flame(x, y, r, c1, c2) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + const g = ctx.createRadialGradient(x, y, 1, x, y, r * 2.2); + g.addColorStop(0, '#fff8e0'); + g.addColorStop(0.3, c1); + g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.ellipse(x, y - r * 0.4, r * 0.8, r * 1.6, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + function glowOrb(x, y, r, col) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + const g = ctx.createRadialGradient(x, y, 0.5, x, y, r * 2.4); + g.addColorStop(0, '#ffffff'); + g.addColorStop(0.25, col); + g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(x, y, r * 2.4, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + } + + function sparkle(x, y, col) { + ctx.save(); + ctx.globalAlpha = 0.65; + ctx.strokeStyle = col; + ctx.lineWidth = 1.5; + const t = performance.now() / 500 + x; + for (let i = 0; i < 2; i++) { + const a = t % (Math.PI * 2); + const r = 3 + ((t + i * 2) % 4); + ctx.beginPath(); + ctx.moveTo(x - r, y); ctx.lineTo(x + r, y); + ctx.moveTo(x, y - r); ctx.lineTo(x, y + r); + ctx.stroke(); + } + ctx.restore(); + } + + /* ---------------- pickups ---------------- */ + + function drawPickup(g, p, tx, ty) { + const s = worldToScreen(tx, ty); + if (p.kind === 'gold') { + const bob = Math.sin(g.time * 4 + p.uid) * 1.5; + ctx.fillStyle = '#ffd24a'; + ctx.beginPath(); ctx.ellipse(s.x - 3, s.y - 4 + bob, 4, 3, 0, 0, 7); ctx.fill(); + ctx.fillStyle = '#c8a35a'; + ctx.beginPath(); ctx.ellipse(s.x + 3, s.y - 3 + bob, 4, 3, 0, 0, 7); ctx.fill(); + ctx.strokeStyle = '#8a6c1c'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.ellipse(s.x + 3, s.y - 3 + bob, 4, 3, 0, 0, 7); ctx.stroke(); + } else if (p.kind === 'potion') { + const col = p.potionType === 'hp' ? '#e84a3a' : '#4a7ae8'; + const bob = Math.sin(g.time * 4 + p.uid) * 1.5; + ctx.fillStyle = col; + ctx.beginPath(); ctx.arc(s.x, s.y - 5 + bob, 4.5, 0, 7); ctx.fill(); + ctx.fillStyle = '#caa'; + ctx.fillRect(s.x - 1.5, s.y - 12 + bob, 3, 4); + glowOrb(s.x, s.y - 5 + bob, 2.5, col); + } else if (p.kind === 'item') { + const rarCol = D2.Items.RARITIES[p.item.rarity].color; + const bob = Math.sin(g.time * 3 + p.uid) * 2; + /* beam of light */ + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + const grad = ctx.createLinearGradient(s.x, s.y - 40, s.x, s.y); + grad.addColorStop(0, 'rgba(0,0,0,0)'); + grad.addColorStop(1, D2.util.rgba(rarCol, 0.35)); + ctx.fillStyle = grad; + ctx.fillRect(s.x - 5, s.y - 40, 10, 40); + ctx.restore(); + /* mini item silhouette */ + ctx.fillStyle = rarCol; + ctx.save(); + ctx.translate(s.x, s.y - 6 + bob); + ctx.rotate(Math.sin(g.time * 2 + p.uid) * 0.2); + ctx.fillRect(-2, -7, 4, 14); + ctx.beginPath(); ctx.moveTo(-5, 7); ctx.lineTo(5, 7); ctx.lineTo(0, 12); ctx.closePath(); ctx.fill(); + ctx.restore(); + } + } + + /* ---------------- entities ---------------- */ + + function shadowEllipse(sx, sy, r) { + ctx.fillStyle = 'rgba(0,0,0,.4)'; + ctx.beginPath(); ctx.ellipse(sx, sy, r * TW / 2, r * TW / 2 * 0.5, 0, 0, 7); ctx.fill(); + } + + function drawMonster(g, m, tx, ty) { + const s = worldToScreen(tx, ty); + shadowEllipse(s.x, s.y + 2, m.radius * 1.05); + const pal = m.palette; + const walkB = m.moving ? Math.sin(g.time * 10 * (m.speedFactor || 1)) * 2 : 0; + const hurt = m.hurtFlash > 0; + + ctx.save(); + ctx.translate(s.x, s.y - walkB); + if (m.isBoss) { + /* boss aura */ + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + const gr = ctx.createRadialGradient(0, -m.radius * 22, 2, 0, -m.radius * 22, m.radius * 60); + gr.addColorStop(0, 'rgba(255,80,30,.25)'); + gr.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = gr; + ctx.fillRect(-80, -140, 160, 160); + ctx.restore(); + } + if (m.isElite) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + const gr = ctx.createRadialGradient(0, -10, 2, 0, -10, m.radius * 44); + gr.addColorStop(0, D2.util.rgba(m.eliteColor || '#c8f', 0.28)); + gr.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = gr; + ctx.fillRect(-60, -100, 120, 120); + ctx.restore(); + } + + const bodyCol = hurt ? '#ffffff' : pal.body; + const R = m.radius * 30; // px scale + drawMonsterShape(m.shape, bodyCol, pal.accent, R, m, g); + + /* frozen overlay */ + if (m.frozen > 0) { + ctx.globalAlpha = 0.55; + ctx.fillStyle = '#9adfff'; + ctx.beginPath(); ctx.ellipse(0, -R * 0.55, R * 0.75, R * 1.0, 0, 0, 7); ctx.fill(); + ctx.globalAlpha = 1; + } + ctx.restore(); + + /* hp bar */ + if (m.hp < m.maxHp && !m.isBoss) { + const wpx = Math.max(26, R * 1.4); + const hpx = 3.5; + const bx = s.x - wpx / 2, by = s.y - R * 1.75 - 10; + ctx.fillStyle = 'rgba(0,0,0,.65)'; + ctx.fillRect(bx - 1, by - 1, wpx + 2, hpx + 2); + ctx.fillStyle = m.isElite ? '#d86aff' : '#d84b37'; + ctx.fillRect(bx, by, wpx * (m.hp / m.maxHp), hpx); + } + if (m.isElite && !m.isBoss) { + ctx.fillStyle = m.eliteColor || '#d86aff'; + ctx.font = 'bold 9px Georgia'; + ctx.textAlign = 'center'; + ctx.fillText('◆', s.x, s.y - R * 1.75 - 13); + } + } + + function drawMonsterShape(shape, body, accent, R, m, g) { + const swing = m.attackAnim > 0 ? Math.sin((1 - m.attackAnim) * Math.PI) : 0; + switch (shape) { + case 'skeleton': case 'skeleton_bow': case 'boneking': { + ctx.strokeStyle = body; ctx.lineWidth = Math.max(2, R * 0.16); ctx.lineCap = 'round'; + /* spine */ + ctx.beginPath(); ctx.moveTo(0, -R * 1.15); ctx.lineTo(0, -R * 0.35); ctx.stroke(); + /* ribs */ + for (let i = 0; i < 3; i++) { + ctx.beginPath(); ctx.moveTo(-R * 0.42, -R * (0.95 - i * 0.22)); ctx.lineTo(R * 0.42, -R * (0.95 - i * 0.22)); ctx.stroke(); + } + /* skull */ + ctx.fillStyle = body; + ctx.beginPath(); ctx.arc(0, -R * 1.38, R * 0.34, 0, 7); ctx.fill(); + ctx.fillStyle = accent; + ctx.fillRect(-R * 0.18, -R * 1.48, R * 0.13, R * 0.13); + ctx.fillRect(R * 0.06, -R * 1.48, R * 0.13, R * 0.13); + /* legs */ + ctx.beginPath(); ctx.moveTo(0, -R * 0.35); ctx.lineTo(-R * 0.28, 0); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(0, -R * 0.35); ctx.lineTo(R * 0.28, 0); ctx.stroke(); + /* arms + weapon */ + ctx.beginPath(); ctx.moveTo(0, -R * 1.05); ctx.lineTo(-R * 0.5, -R * 0.7); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(0, -R * 1.05); + ctx.lineTo(R * (0.5 + swing * 0.5), -R * (0.7 - swing * 0.5)); ctx.stroke(); + if (shape === 'skeleton_bow') { + ctx.strokeStyle = '#6e5b34'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(R * 0.62, -R * 0.72, R * 0.4, -1.2, 1.2); ctx.stroke(); + } else { + ctx.strokeStyle = accent; ctx.lineWidth = 2.5; + ctx.beginPath(); ctx.moveTo(R * (0.5 + swing * 0.5), -R * (0.7 - swing * 0.5)); + ctx.lineTo(R * (0.95 + swing * 0.7), -R * (1.1 - swing)); ctx.stroke(); + } + if (shape === 'boneking') crown(accent, R); + break; + } + case 'zombie': case 'troll': case 'butcher': { + const big = shape !== 'zombie' ? 1.25 : 1; + ctx.fillStyle = body; + roundRectE(-R * 0.55 * big, -R * 1.25 * big, R * 1.1 * big, R * 1.0 * big, R * 0.3); ctx.fill(); + ctx.fillStyle = body; + ctx.beginPath(); ctx.arc(0, -R * 1.45 * big, R * 0.38 * big, 0, 7); ctx.fill(); + /* arms forward zombie style */ + ctx.strokeStyle = body; ctx.lineWidth = Math.max(3, R * 0.22); + ctx.beginPath(); ctx.moveTo(-R * 0.4 * big, -R * 1.0 * big); ctx.lineTo(-R * (0.85 + swing * 0.3), -R * 0.78); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(R * 0.4 * big, -R * 1.0 * big); ctx.lineTo(R * (0.85 + swing * 0.3), -R * 0.78); ctx.stroke(); + /* eyes */ + ctx.fillStyle = accent; + ctx.fillRect(-R * 0.2 * big, -R * 1.52 * big, R * 0.14, R * 0.14); + ctx.fillRect(R * 0.08 * big, -R * 1.52 * big, R * 0.14, R * 0.14); + if (shape === 'butcher') { + /* cleaver in right hand */ + ctx.fillStyle = '#c8ccd4'; + ctx.save(); + ctx.translate(R * (0.9 + swing * 0.3), -R * 0.78); + ctx.rotate(-0.4 - swing * 0.9); + ctx.fillRect(0, -3, R * 0.8, R * 0.55); + ctx.restore(); + } + break; + } + case 'imp': { + ctx.fillStyle = body; + ctx.beginPath(); ctx.arc(0, -R * 0.9, R * 0.5, 0, 7); ctx.fill(); + /* horns */ + ctx.strokeStyle = accent; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(-R * 0.3, -R * 1.25); ctx.quadraticCurveTo(-R * 0.5, -R * 1.6, -R * 0.2, -R * 1.55); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(R * 0.3, -R * 1.25); ctx.quadraticCurveTo(R * 0.5, -R * 1.6, R * 0.2, -R * 1.55); ctx.stroke(); + /* eyes bright */ + ctx.fillStyle = accent; + ctx.beginPath(); ctx.arc(-R * 0.17, -R * 1.0, R * 0.09, 0, 7); ctx.fill(); + ctx.beginPath(); ctx.arc(R * 0.17, -R * 1.0, R * 0.09, 0, 7); ctx.fill(); + /* little wings */ + ctx.fillStyle = 'rgba(0,0,0,.35)'; + ctx.beginPath(); ctx.ellipse(-R * 0.55, -R * 0.95, R * 0.3, R * 0.16, 0.5, 0, 7); ctx.fill(); + ctx.beginPath(); ctx.ellipse(R * 0.55, -R * 0.95, R * 0.3, R * 0.16, -0.5, 0, 7); ctx.fill(); + break; + } + case 'bat': { + const flap = Math.sin(g.time * 18 + (m.uid || 0)) * 0.5; + ctx.fillStyle = body; + ctx.beginPath(); ctx.arc(0, -R * 1.0, R * 0.34, 0, 7); ctx.fill(); + ctx.beginPath(); + ctx.moveTo(-R * 0.2, -R); + ctx.quadraticCurveTo(-R * (0.9 + flap), -R * (1.3 + flap * 0.4), -R * 1.1, -R * 0.8); + ctx.quadraticCurveTo(-R * 0.6, -R * 0.85, -R * 0.2, -R); + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(R * 0.2, -R); + ctx.quadraticCurveTo(R * (0.9 + flap), -R * (1.3 + flap * 0.4), R * 1.1, -R * 0.8); + ctx.quadraticCurveTo(R * 0.6, -R * 0.85, R * 0.2, -R); + ctx.fill(); + ctx.fillStyle = accent; + ctx.fillRect(-R * 0.12, -R * 1.08, R * 0.09, R * 0.09); + ctx.fillRect(R * 0.04, -R * 1.08, R * 0.09, R * 0.09); + break; + } + case 'cultist': case 'wraith': { + const float = Math.sin(g.time * 3 + (m.uid || 0)) * 2; + ctx.translate(0, float); + /* robe */ + ctx.fillStyle = body; + ctx.beginPath(); + ctx.moveTo(0, -R * 1.5); + ctx.quadraticCurveTo(-R * 0.75, -R * 0.9, -R * 0.62, 0); + ctx.lineTo(R * 0.62, 0); + ctx.quadraticCurveTo(R * 0.75, -R * 0.9, 0, -R * 1.5); + ctx.fill(); + /* hood shade + eyes */ + ctx.fillStyle = 'rgba(0,0,0,.55)'; + ctx.beginPath(); ctx.arc(0, -R * 1.22, R * 0.3, 0, 7); ctx.fill(); + ctx.fillStyle = accent; + ctx.beginPath(); ctx.arc(-R * 0.12, -R * 1.24, R * 0.07, 0, 7); ctx.fill(); + ctx.beginPath(); ctx.arc(R * 0.12, -R * 1.24, R * 0.07, 0, 7); ctx.fill(); + if (shape === 'wraith') { + ctx.globalAlpha = 0.5; + ctx.fillStyle = accent; + ctx.beginPath(); + ctx.moveTo(-R * 0.6, 0); + ctx.quadraticCurveTo(-R * 0.3, R * 0.3, 0, R * 0.15); + ctx.quadraticCurveTo(R * 0.3, R * 0.35, R * 0.6, 0); + ctx.fill(); + ctx.globalAlpha = 1; + } else { + /* staff with orb */ + ctx.strokeStyle = '#4a3a2a'; ctx.lineWidth = 2.5; + ctx.beginPath(); ctx.moveTo(R * 0.55, -R * 1.1); ctx.lineTo(R * 0.62, 0); ctx.stroke(); + glowOrb(R * 0.55, -R * 1.2, R * 0.14, accent); + } + break; + } + case 'spider': case 'queenspider': { + const big = shape === 'queenspider' ? 1.5 : 1; + ctx.fillStyle = body; + ctx.beginPath(); ctx.ellipse(0, -R * 0.6 * big, R * 0.62 * big, R * 0.42 * big, 0, 0, 7); ctx.fill(); + /* legs */ + ctx.strokeStyle = accent; ctx.lineWidth = Math.max(1.5, R * 0.09); + for (let side = -1; side <= 1; side += 2) { + for (let i = 0; i < 3; i++) { + const lx = side * R * 0.5 * big; + const ly = -R * (0.75 - i * 0.18) * big; + const wig = Math.sin(g.time * 12 + i * 2 + side) * R * 0.08; + ctx.beginPath(); + ctx.moveTo(lx * 0.4, ly); + ctx.quadraticCurveTo(lx * 1.8, ly - R * 0.3 * big + wig, side * R * 1.05 * big, ly + R * 0.28 * big); + ctx.stroke(); + } + } + /* eyes */ + ctx.fillStyle = accent; + for (let i = -1; i <= 1; i += 2) { + ctx.beginPath(); ctx.arc(i * R * 0.16 * big, -R * 0.68 * big, R * 0.07 * big, 0, 7); ctx.fill(); + } + if (shape === 'queenspider') crown('#a8e84a', R * big); + break; + } + case 'shroom': case 'shroom_big': { + const big = shape === 'shroom_big' ? 1.4 : 1; + ctx.fillStyle = '#d8d0bc'; + roundRectE(-R * 0.2 * big, -R * 0.8 * big, R * 0.4 * big, R * 0.8 * big, R * 0.1); ctx.fill(); + ctx.fillStyle = body; + ctx.beginPath(); ctx.ellipse(0, -R * 0.85 * big, R * 0.6 * big, R * 0.38 * big, 0, Math.PI, 0); ctx.fill(); + ctx.fillStyle = accent; + for (const [ox, oy] of [[-0.25, -0.95], [0.15, -1.05], [0.3, -0.85]]) { + ctx.beginPath(); ctx.arc(R * ox * big, R * oy * big, R * 0.09 * big, 0, 7); ctx.fill(); + } + break; + } + case 'hound': { + ctx.fillStyle = body; + ctx.beginPath(); ctx.ellipse(0, -R * 0.62, R * 0.68, R * 0.4, 0, 0, 7); ctx.fill(); + ctx.beginPath(); ctx.arc(R * 0.55, -R * 0.85, R * 0.28, 0, 7); ctx.fill(); + /* ears */ + ctx.beginPath(); ctx.moveTo(R * 0.4, -R * 1.05); ctx.lineTo(R * 0.45, -R * 1.3); ctx.lineTo(R * 0.58, -R * 1.05); ctx.fill(); + /* fire mane */ + flame(0, -R * 0.9, R * 0.35, accent, '#ff5a1a'); + /* legs gallop */ + ctx.strokeStyle = body; ctx.lineWidth = 3; + const run = Math.sin(g.time * 14) * R * 0.2; + ctx.beginPath(); ctx.moveTo(-R * 0.4, -R * 0.35); ctx.lineTo(-R * 0.45 + run, 0); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(R * 0.4, -R * 0.35); ctx.lineTo(R * 0.45 - run, 0); ctx.stroke(); + ctx.fillStyle = accent; + ctx.beginPath(); ctx.arc(R * 0.68, -R * 0.92, R * 0.07, 0, 7); ctx.fill(); + break; + } + case 'knight': { + ctx.fillStyle = body; + roundRectE(-R * 0.5, -R * 1.35, R * 1.0, R * 1.15, R * 0.25); ctx.fill(); + ctx.beginPath(); ctx.arc(0, -R * 1.55, R * 0.32, 0, 7); ctx.fill(); + ctx.fillStyle = '#000'; + ctx.fillRect(-R * 0.22, -R * 1.6, R * 0.44, R * 0.1); + ctx.fillStyle = accent; + ctx.beginPath(); ctx.moveTo(0, -R * 1.95); ctx.lineTo(R * 0.12, -R * 1.75); ctx.lineTo(-R * 0.12, -R * 1.75); ctx.closePath(); ctx.fill(); + /* greatsword */ + ctx.save(); + ctx.translate(R * 0.62, -R * 0.9); + ctx.rotate(-0.3 + swing * -1.6); + ctx.fillStyle = '#c8ccd4'; ctx.fillRect(-2, -R * 1.3, 4, R * 1.3); + ctx.fillStyle = accent; ctx.fillRect(-5, 0, 10, 3); + ctx.restore(); + break; + } + case 'terrorlord': { + /* huge demon */ + ctx.fillStyle = body; + ctx.beginPath(); + ctx.moveTo(-R * 0.8, 0); + ctx.quadraticCurveTo(-R * 1.0, -R * 1.6, -R * 0.4, -R * 1.8); + ctx.lineTo(R * 0.4, -R * 1.8); + ctx.quadraticCurveTo(R * 1.0, -R * 1.6, R * 0.8, 0); + ctx.closePath(); ctx.fill(); + /* horns */ + ctx.strokeStyle = '#d8cba8'; ctx.lineWidth = R * 0.12; ctx.lineCap = 'round'; + ctx.beginPath(); ctx.moveTo(-R * 0.3, -R * 1.75); ctx.quadraticCurveTo(-R * 0.7, -R * 2.3, -R * 0.25, -R * 2.4); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(R * 0.3, -R * 1.75); ctx.quadraticCurveTo(R * 0.7, -R * 2.3, R * 0.25, -R * 2.4); ctx.stroke(); + /* burning eyes */ + flame(-R * 0.18, -R * 1.6, R * 0.12, '#ffd24a', '#ff4a1a'); + flame(R * 0.18, -R * 1.6, R * 0.12, '#ffd24a', '#ff4a1a'); + /* arms with flames */ + ctx.strokeStyle = body; ctx.lineWidth = R * 0.22; + ctx.beginPath(); ctx.moveTo(-R * 0.7, -R * 1.3); ctx.lineTo(-R * (1.0 + swing * 0.2), -R * 0.7); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(R * 0.7, -R * 1.3); ctx.lineTo(R * (1.0 + swing * 0.2), -R * 0.7); ctx.stroke(); + flame(-R * (1.0 + swing * 0.2), -R * 0.65, R * 0.25, accent, '#ff4a1a'); + flame(R * (1.0 + swing * 0.2), -R * 0.65, R * 0.25, accent, '#ff4a1a'); + /* chest rune */ + ctx.strokeStyle = accent; ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(0, -R * 1.1, R * 0.25, 0, 7); ctx.stroke(); + break; + } + default: { + /* fallback blob */ + ctx.fillStyle = body; + ctx.beginPath(); ctx.arc(0, -R * 0.7, R * 0.55, 0, 7); ctx.fill(); + ctx.fillStyle = accent; + ctx.beginPath(); ctx.arc(0, -R * 0.85, R * 0.18, 0, 7); ctx.fill(); + } + } + ctx.lineCap = 'butt'; + + function crown(col, rr) { + ctx.fillStyle = col; + ctx.beginPath(); + ctx.moveTo(-rr * 0.3, -rr * 1.7); + ctx.lineTo(-rr * 0.15, -rr * 1.95); + ctx.lineTo(0, -rr * 1.72); + ctx.lineTo(rr * 0.15, -rr * 1.95); + ctx.lineTo(rr * 0.3, -rr * 1.7); + ctx.closePath(); ctx.fill(); + } + function roundRectE(rx, ry, rw, rh, r) { + ctx.beginPath(); + ctx.moveTo(rx + r, ry); + ctx.arcTo(rx + rw, ry, rx + rw, ry + rh, r); + ctx.arcTo(rx + rw, ry + rh, rx, ry + rh, r); + ctx.arcTo(rx, ry + rh, rx, ry, r); + ctx.arcTo(rx, ry, rx + rw, ry, r); + ctx.closePath(); + } + } + + function drawPlayer(g, pl, tx, ty) { + const s = worldToScreen(tx, ty); + shadowEllipse(s.x, s.y + 2, 0.34); + const cls = D2.Skills.CLASSES[pl.classId]; + const pal = cls.palette; + const walkB = pl.moving ? Math.abs(Math.sin(pl.walkPhase)) * 2.5 : 0; + const hurt = pl.hurtFlash > 0; + const R = 30; + + ctx.save(); + ctx.translate(s.x, s.y - walkB); + + /* buff aura */ + if (pl.buffs.some(b => b.icon === 'warcry' || b.id === 'consecrate_buff')) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + const gr = ctx.createRadialGradient(0, -14, 3, 0, -14, 40); + gr.addColorStop(0, 'rgba(255,210,74,.22)'); + gr.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = gr; + ctx.fillRect(-40, -70, 80, 90); + ctx.restore(); + } + if (pl.smokeveilT > 0) ctx.globalAlpha = 0.55; + + const bodyCol = hurt ? '#fff' : pal.body; + + /* legs */ + ctx.fillStyle = '#2a2018'; + const legSw = pl.moving ? Math.sin(pl.walkPhase) * 4 : 0; + ctx.fillRect(-7, -12 + Math.max(0, legSw), 6, 12); + ctx.fillRect(1, -12 + Math.max(0, -legSw), 6, 12); + + /* torso */ + ctx.fillStyle = bodyCol; + ctx.beginPath(); + ctx.moveTo(-9, -12); ctx.lineTo(9, -12); ctx.lineTo(7, -30); ctx.lineTo(-7, -30); + ctx.closePath(); ctx.fill(); + /* cloth accent */ + ctx.fillStyle = pal.cloth; + ctx.fillRect(-7, -20, 14, 5); + + /* head */ + ctx.fillStyle = hurt ? '#fff' : '#d8b89a'; + ctx.beginPath(); ctx.arc(0, -36, 6.5, 0, 7); ctx.fill(); + /* helm per class */ + if (pl.classId === 'crusader') { + ctx.fillStyle = pal.accent; + ctx.beginPath(); ctx.arc(0, -37, 7, Math.PI, 0); ctx.fill(); + ctx.fillRect(-7, -37, 14, 3); + } else if (pl.classId === 'ranger') { + ctx.fillStyle = pal.cloth; + ctx.beginPath(); ctx.arc(0, -38, 7, Math.PI * 1.05, Math.PI * 1.95); ctx.fill(); + ctx.beginPath(); ctx.moveTo(0, -46); ctx.lineTo(9, -30); ctx.lineTo(4, -29); ctx.closePath(); ctx.fill(); + } else { + ctx.fillStyle = pal.cloth; + ctx.beginPath(); ctx.arc(0, -38, 7.4, Math.PI, 0); ctx.fill(); + glowOrb(0, -44, 2, pal.accent); + } + + /* weapon by class with attack animation */ + const sw = pl.attackAnim > 0 ? Math.sin((1 - pl.attackAnim / 0.28) * Math.PI) : 0; + if (pl.classId === 'crusader') { + ctx.save(); + ctx.translate(11, -22); + ctx.rotate(-0.5 + sw * 2.4); + const wep = pl.equip.weapon; + const len = wep && wep.baseId === 'greatsword' ? 30 : 22; + ctx.fillStyle = '#c8ccd4'; + ctx.beginPath(); ctx.moveTo(-2.5, 0); ctx.lineTo(-2.5, -len); ctx.lineTo(0, -len - 5); ctx.lineTo(2.5, -len); ctx.lineTo(2.5, 0); ctx.closePath(); ctx.fill(); + ctx.fillStyle = '#c8a35a'; ctx.fillRect(-6, 0, 12, 3.5); + if (sw > 0.1) arcSweepWorld(sw); + ctx.restore(); + } else if (pl.classId === 'ranger') { + ctx.save(); + ctx.translate(11, -24); + ctx.rotate(sw * -0.5); + ctx.strokeStyle = '#8a6a42'; ctx.lineWidth = 3; + ctx.beginPath(); ctx.arc(0, 0, 12, -1.25, 1.25); ctx.stroke(); + ctx.strokeStyle = '#d8cba8'; ctx.lineWidth = 1; + const pull = sw > 0.3 ? 6 : 0; + ctx.beginPath(); + ctx.moveTo(Math.cos(-1.25) * 12, Math.sin(-1.25) * 12); + ctx.lineTo(-pull, 0); + ctx.lineTo(Math.cos(1.25) * 12, Math.sin(1.25) * 12); + ctx.stroke(); + ctx.restore(); + } else { + ctx.save(); + ctx.translate(11, -24); + ctx.rotate(-0.2 + sw * 1.2); + ctx.strokeStyle = '#6a4a2a'; ctx.lineWidth = 3; + ctx.beginPath(); ctx.moveTo(0, 6); ctx.lineTo(0, -18); ctx.stroke(); + glowOrb(0, -21, 3.5, pal.accent); + ctx.restore(); + } + + /* shield on left arm if equipped */ + if (pl.equip.offhand && pl.equip.offhand.kind === 'shield') { + ctx.fillStyle = '#7a808a'; + ctx.beginPath(); + ctx.moveTo(-12, -28); ctx.quadraticCurveTo(-19, -26, -18, -18); + ctx.quadraticCurveTo(-17, -11, -12, -9); + ctx.quadraticCurveTo(-7, -11, -6, -18); + ctx.quadraticCurveTo(-5, -26, -12, -28); + ctx.fill(); + } + + /* guaranteed crit glint */ + if (pl.guaranteedCritT > 0) { + glowOrb(0, -22, 4, '#ffd24a'); + } + + ctx.restore(); + + /* selection ring under cursor hover handled elsewhere */ + if (g.hoverEntity === pl || g.clickMarker) { /* noop */ } + ctx.globalAlpha = 1; + } + + function arcSweepWorld(intensity) { + ctx.save(); + ctx.globalAlpha = intensity * 0.5; + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 3; + ctx.beginPath(); ctx.arc(0, 0, 24, -1.9, -0.2); ctx.stroke(); + ctx.restore(); + } + + /* ---------------- projectiles ---------------- */ + + function drawProjectile(pr, tx, ty) { + const s = worldToScreen(tx, ty); + ctx.save(); + switch (pr.visual) { + case 'arrow': + ctx.strokeStyle = '#d8cba8'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(s.x, s.y - 6); + ctx.lineTo(s.x + pr.vx * 2.2, s.y - 6 + pr.vy * 1.1); ctx.stroke(); + break; + case 'bolt': { + const g2 = ctx.createRadialGradient(s.x, s.y - 8, 1, s.x, s.y - 8, 9); + g2.addColorStop(0, '#fff'); + g2.addColorStop(0.4, pr.color); + g2.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.globalCompositeOperation = 'lighter'; + ctx.fillStyle = g2; + ctx.beginPath(); ctx.arc(s.x, s.y - 8, 9, 0, 7); ctx.fill(); + break; + } + case 'orb': { + ctx.globalCompositeOperation = 'lighter'; + const g2 = ctx.createRadialGradient(s.x, s.y - 8, 1, s.x, s.y - 8, 12); + g2.addColorStop(0, '#fff'); + g2.addColorStop(0.35, pr.color); + g2.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = g2; + ctx.beginPath(); ctx.arc(s.x, s.y - 8, 12, 0, 7); ctx.fill(); + break; + } + case 'knife': + ctx.fillStyle = '#c8d8e8'; + ctx.save(); + ctx.translate(s.x, s.y - 8); + ctx.rotate(Math.atan2(pr.vy * 0.5, pr.vx) ); + ctx.beginPath(); ctx.moveTo(6, 0); ctx.lineTo(-4, -2.5); ctx.lineTo(-4, 2.5); ctx.closePath(); ctx.fill(); + ctx.restore(); + break; + } + ctx.restore(); + } + + function drawFxRing(e, tx, ty) { + const s = worldToScreen(tx, ty); + const t = e.t / e.dur; + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.strokeStyle = D2.util.rgba(e.color, 1 - t); + ctx.lineWidth = 3 * (1 - t) + 1; + ellipseWorld(ctx, s.x, s.y, e.radius * (0.3 + 0.7 * t), 0.52); + ctx.stroke(); + ctx.restore(); + } + + /* ---------------- particles & texts ---------------- */ + + function drawParticles(g) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + for (const pt of g.particles) { + const s = worldToScreen(pt.x, pt.y); + const lifeF = pt.life / pt.maxLife; + ctx.globalAlpha = lifeF * (pt.alpha ?? 1); + ctx.fillStyle = pt.color; + if (pt.size > 2.2) { + ctx.beginPath(); + ctx.arc(s.x, s.y - pt.z, pt.size * lifeF, 0, 7); + ctx.fill(); + } else { + ctx.fillRect(s.x - pt.size, s.y - pt.z - pt.size, pt.size * 2, pt.size * 2); + } + } + ctx.restore(); + ctx.globalAlpha = 1; + } + + function drawSpellGlows(g) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + for (const l of g.lights) { + const s = worldToScreen(l.x, l.y); + const r = l.radius * TW * 0.5 * cam.zoom; + const g2 = ctx.createRadialGradient(s.x, s.y, 1, s.x, s.y, r); + g2.addColorStop(0, D2.util.rgba(l.color, (l.intensity ?? 0.5))); + g2.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = g2; + ctx.fillRect(s.x - r, s.y - r, r * 2, r * 2); + } + ctx.restore(); + } + + function drawFloatingTexts(g) { + ctx.textAlign = 'center'; + for (const ft of g.floatTexts) { + const s = worldToScreen(ft.x, ft.y); + const t = 1 - ft.life / ft.maxLife; + const rise = t * 26; + ctx.globalAlpha = Math.min(1, ft.life * 2.5); + ctx.font = `bold ${ft.size || 13}px Georgia`; + ctx.strokeStyle = 'rgba(0,0,0,.85)'; + ctx.lineWidth = 3; + const txt = ft.text; + ctx.strokeText(txt, s.x, s.y - 20 - rise); + ctx.fillStyle = ft.color; + ctx.fillText(txt, s.x, s.y - 20 - rise); + } + ctx.globalAlpha = 1; + ctx.textAlign = 'left'; + } + + /* overhead bars: cast bar? skip. enemy elite names drawn here */ + function drawOverheadUI(g) { /* reserved */ } + + /* ---------------- labels & markers ---------------- */ + + function drawLabelsAndMarkers(g, dt) { + /* move marker */ + if (g.moveMarker && g.moveMarker.t > 0) { + const s = worldToScreen(g.moveMarker.x, g.moveMarker.y); + g.moveMarker.t -= dt; + const k = g.moveMarker.t / 0.5; + ctx.save(); + ctx.strokeStyle = `rgba(232,214,170,${k})`; + ctx.lineWidth = 2; + ellipseWorld(ctx, s.x, s.y, 0.34 * (1.6 - k), 0.52); + ctx.stroke(); + ctx.restore(); + } + + /* hover enemy ring */ + if (g.hoverEntity && !g.hoverEntity.dead) { + const e = g.hoverEntity; + const s = worldToScreen(e.x, e.y); + ctx.save(); + ctx.strokeStyle = 'rgba(220,70,50,.85)'; + ctx.lineWidth = 2; + ellipseWorld(ctx, s.x, s.y, e.radius + 0.12, 0.52); + ctx.stroke(); + ctx.restore(); + } + + /* ground item labels */ + const showLabels = g.settings.labels === 'always' || + (g.settings.labels === 'alt' && D2.input.isDown('AltLeft')) || + (g.settings.labels === 'alt' && D2.input.isDown('AltRight')); + if (showLabels) { + ctx.textAlign = 'center'; + ctx.font = '11px Georgia'; + for (const p of g.pickups) { + if (p.kind !== 'item') continue; + const s = worldToScreen(p.x, p.y); + const label = p.item.name; + const wPx = ctx.measureText(label).width + 14; + const rarCol = D2.Items.RARITIES[p.item.rarity].color; + ctx.fillStyle = 'rgba(8,6,4,.88)'; + ctx.fillRect(s.x - wPx / 2, s.y - 52, wPx, 16); + ctx.strokeStyle = D2.util.rgba(rarCol, 0.8); + ctx.lineWidth = 1; + ctx.strokeRect(s.x - wPx / 2, s.y - 52, wPx, 16); + ctx.fillStyle = rarCol; + ctx.fillText(label, s.x, s.y - 40); + } + ctx.textAlign = 'left'; + } + } + + /* ---------------- lighting ---------------- */ + + function applyLighting(g, theme) { + const lw = lightCanvas.width, lh = lightCanvas.height; + lctx.setTransform(1, 0, 0, 1, 0, 0); + lctx.clearRect(0, 0, lw, lh); + lctx.fillStyle = D2.util.rgba(theme.fog, 1 - theme.ambient); + lctx.fillRect(0, 0, lw, lh); + + lctx.globalCompositeOperation = 'destination-out'; + punchLight(g.player.x, g.player.y, 6.8, 1.0); + for (const t of g.world.torches) { + const i = t.y * g.world.w + t.x; + if (!g.world.explored[i]) continue; + const flick = 0.82 + Math.sin(g.time * 9 + t.x * 3.1 + t.y * 1.7) * 0.12 + + Math.sin(g.time * 23 + t.x) * 0.06; + punchLight(t.x + 0.5, t.y + 0.5, 3.6 * flick, 0.85); + } + for (const l of g.lights) punchLight(l.x, l.y, l.radius, l.intensity ?? 0.8); + for (const e of g.effects) { + if (e.lightPunch) punchLight(e.x, e.y, e.lightPunch, 0.9); + } + lctx.globalCompositeOperation = 'source-over'; + + ctx.drawImage(lightCanvas, 0, 0, W, H); + } + + function punchLight(wx, wy, radiusTiles, strength) { + const s = worldToScreen(wx, wy); + const px = (s.x) * cam.zoom + W / 2; + const py = (s.y) * cam.zoom + H / 2; + const r = radiusTiles * TW * cam.zoom; + const g2 = lctx.createRadialGradient(px, py, r * 0.12, px, py, r); + g2.addColorStop(0, `rgba(0,0,0,${strength})`); + g2.addColorStop(0.65, `rgba(0,0,0,${strength * 0.55})`); + g2.addColorStop(1, 'rgba(0,0,0,0)'); + lctx.fillStyle = g2; + lctx.fillRect(px - r, py - r, r * 2, r * 2); + } + + function drawVignette(theme) { + const grd = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.36, W / 2, H / 2, Math.max(W, H) * 0.72); + grd.addColorStop(0, 'rgba(0,0,0,0)'); + grd.addColorStop(1, D2.util.rgba(theme.fog, 0.8)); + ctx.fillStyle = grd; + ctx.fillRect(0, 0, W, H); + + /* low HP pulse */ + if (D2.game && D2.game.player && !D2.game.player.dead) { + const hpPct = D2.game.player.hp / D2.game.player.maxHp; + if (hpPct < 0.34) { + const pulse = 0.16 + Math.sin(performance.now() / 300) * 0.08; + ctx.fillStyle = `rgba(140,20,10,${pulse * (1 - hpPct / 0.34)})`; + ctx.fillRect(0, 0, W, H); + } + } + } + + /* ---------------- minimap ---------------- */ + + function minimap(g, mmCanvas) { + const mctx = mmCanvas.getContext('2d'); + const mw = mmCanvas.width, mh = mmCanvas.height; + mctx.clearRect(0, 0, mw, mh); + const w = g.world.w, h = g.world.h; + const scale = Math.min(mw / w, mh / h); + const ox = (mw - w * scale) / 2, oy = (mh - h * scale) / 2; + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const i = y * w + x; + if (!g.world.explored[i]) continue; + const t = g.world.tiles[i]; + if (t === D2.world.T.VOID) continue; + mctx.fillStyle = t === D2.world.T.WALL ? '#3a3228' + : (g.world.visible[i] ? '#8a7a5a' : '#544a38'); + mctx.fillRect(ox + x * scale, oy + y * scale, scale, scale); + } + } + /* stairs */ + const st = g.world.stairsDown; + if (st) { + mctx.fillStyle = '#8ac4ff'; + mctx.fillRect(ox + st.x * scale - 1.5, oy + st.y * scale - 1.5, 4, 4); + } + /* pickups (items only, visible ones) */ + for (const p of g.pickups) { + if (p.kind !== 'item') continue; + const i = (p.y | 0) * w + (p.x | 0); + if (!g.world.explored[i]) continue; + mctx.fillStyle = D2.Items.RARITIES[p.item.rarity].color; + mctx.fillRect(ox + p.x * scale - 1, oy + p.y * scale - 1, 3, 3); + } + /* monsters visible */ + for (const m of g.monsters) { + if (m.dead) continue; + const i = (m.y | 0) * w + (m.x | 0); + if (!g.world.visible[i]) continue; + mctx.fillStyle = m.isBoss ? '#ff3a1a' : m.isElite ? '#d86aff' : '#c83a2a'; + const sz = m.isBoss ? 5 : 3; + mctx.fillRect(ox + m.x * scale - sz / 2, oy + m.y * scale - sz / 2, sz, sz); + } + /* player */ + if (g.player) { + mctx.fillStyle = '#ffe8b0'; + mctx.beginPath(); + mctx.arc(ox + g.player.x * scale, oy + g.player.y * scale, 2.5, 0, 7); + mctx.fill(); + } + } + + D2.render = { + init, frame, resize, minimap, + worldToScreen, screenToWorld, + addShake, + get cam() { return cam; }, + get size() { return { W, H }; }, + /* debug: read a pixel straight from the game backbuffer */ + debugPixel(sx, sy) { + const d = ctx.getImageData(Math.max(0, Math.min(W - 1, sx | 0)), Math.max(0, Math.min(H - 1, sy | 0)), 1, 1).data; + return [d[0], d[1], d[2]]; + }, + TW, TH, WALL_H, + }; +})(window.D2); diff --git a/js/render/sprites.js b/js/render/sprites.js new file mode 100644 index 0000000..51d3310 --- /dev/null +++ b/js/render/sprites.js @@ -0,0 +1,769 @@ +/* ============================================================ + * Diablo2D — sprites.js : procedural baked art (tiles, props, icons) + * ============================================================ */ +'use strict'; +window.D2 = window.D2 || {}; +(function (D2) { + + const TW = 64, TH = 32; // iso tile diamond size + const WALL_H = 46; + + const cache = { + themes: {}, // themeId -> {floors[], wallBlock} + props: {}, // key -> canvas + skills: {}, // iconId -> canvas + }; + + function mkCanvas(w, h) { + const c = document.createElement('canvas'); + c.width = w; c.height = h; + return c; + } + + /* ---------------- iso helpers ---------------- */ + function diamondPath(ctx, cx, cy, hw, hh) { + ctx.beginPath(); + ctx.moveTo(cx, cy - hh); + ctx.lineTo(cx + hw, cy); + ctx.lineTo(cx, cy + hh); + ctx.lineTo(cx - hw, cy); + ctx.closePath(); + } + + /* ---------------- floor tiles ---------------- */ + + function bakeFloorTile(theme, colA, variant) { + const c = mkCanvas(TW, TH); + const ctx = c.getContext('2d'); + const shade = [1, 0.92, 1.06, 0.85][variant]; + const base = D2.util.shade(colA, shade); + + diamondPath(ctx, TW / 2, TH / 2, TW / 2, TH / 2); + ctx.fillStyle = base; + ctx.fill(); + + /* subtle inner texture */ + ctx.save(); + diamondPath(ctx, TW / 2, TH / 2, TW / 2, TH / 2); + ctx.clip(); + const rnd = new D2.util.RNG(variant * 7919 + colA.length * 131); + ctx.globalAlpha = 0.14; + for (let i = 0; i < 10; i++) { + const x = rnd.range(6, TW - 6), y = rnd.range(4, TH - 4); + ctx.fillStyle = i % 2 ? '#000' : '#fff'; + ctx.fillRect(x, y, rnd.range(3, 10), 1); + } + /* grout lines */ + ctx.globalAlpha = 0.25; + ctx.strokeStyle = '#000'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(TW / 2, 0); ctx.lineTo(TW, TH / 2); + ctx.moveTo(TW / 2, TH); ctx.lineTo(0, TH / 2); + ctx.stroke(); + /* cracks on some variants */ + if (variant === 2 || variant === 3) { + ctx.globalAlpha = 0.3; + ctx.beginPath(); + let x = TW * 0.3, y = TH * 0.5; + ctx.moveTo(x, y); + for (let i = 0; i < 4; i++) { x += rnd.range(-8, 12); y += rnd.range(-6, 6); ctx.lineTo(x, y); } + ctx.stroke(); + } + ctx.restore(); + return c; + } + + function bakeWallBlock(theme) { + const c = mkCanvas(TW + 4, WALL_H + TH + 4); + const ctx = c.getContext('2d'); + const ox = 2, oy = WALL_H + 2; + + /* front-left face (SW) */ + ctx.fillStyle = theme.wallFace; + ctx.beginPath(); + ctx.moveTo(ox, oy - TH / 2); + ctx.lineTo(ox + TW / 2, oy); + ctx.lineTo(ox + TW / 2, oy - WALL_H); + ctx.lineTo(ox, oy - WALL_H - TH / 2); + ctx.closePath(); + ctx.fill(); + + /* front-right face (SE) */ + ctx.fillStyle = D2.util.shade(theme.wallFace, 0.72); + ctx.beginPath(); + ctx.moveTo(ox + TW / 2, oy); + ctx.lineTo(ox + TW, oy - TH / 2); + ctx.lineTo(ox + TW, oy - TH / 2 - WALL_H); + ctx.lineTo(ox + TW / 2, oy - WALL_H); + ctx.closePath(); + ctx.fill(); + + /* top */ + diamondPath(ctx, ox + TW / 2, oy - WALL_H - TH / 2 + TH / 2, TW / 2, TH / 2); + ctx.fillStyle = theme.wallTop; + ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,.05)'; + ctx.stroke(); + + /* brick lines on faces */ + ctx.strokeStyle = 'rgba(0,0,0,.28)'; + ctx.lineWidth = 1; + for (let row = 1; row < 4; row++) { + const yy = oy - (row / 4) * WALL_H; + ctx.beginPath(); + ctx.moveTo(ox, yy - TH / 2 * (1 - row / 4)); + ctx.lineTo(ox + TW / 2, yy); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(ox + TW / 2, yy); + ctx.lineTo(ox + TW, yy - TH / 2 * (1 - row / 4)); + ctx.stroke(); + } + return c; + } + + /* ---------------- props ---------------- */ + + function bakeProps() { + const P = cache.props; + + /* barrel */ + let c = mkCanvas(40, 46); let x = c.getContext('2d'); + barrelShape(x, 20, 30, '#6a4a2a', '#4a3018'); + P.barrel = c; + + /* urn */ + c = mkCanvas(36, 42); x = c.getContext('2d'); + x.fillStyle = '#8a7a5e'; + x.beginPath(); x.ellipse(18, 26, 11, 13, 0, 0, Math.PI * 2); x.fill(); + x.fillStyle = '#a89a78'; + x.beginPath(); x.ellipse(18, 16, 7, 5, 0, 0, Math.PI * 2); x.fill(); + x.fillStyle = '#3a3020'; x.fillRect(14, 8, 8, 5); + highlight(x, 13, 20, 5); + P.urn = c; + + /* chest closed / open */ + c = mkCanvas(48, 42); x = c.getContext('2d'); + x.fillStyle = '#5e3f22'; roundRect(x, 6, 18, 36, 20, 3); x.fill(); + x.fillStyle = '#7a5530'; roundRect(x, 6, 8, 36, 14, 6); x.fill(); + x.strokeStyle = '#c8a35a'; x.lineWidth = 2; + x.strokeRect(6, 18, 36, 1); + x.fillStyle = '#c8a35a'; x.fillRect(21, 16, 6, 8); + metalEdges(x, 6, 8, 36, 30); + P.chest_closed = c; + + c = mkCanvas(48, 42); x = c.getContext('2d'); + x.fillStyle = '#5e3f22'; roundRect(x, 6, 18, 36, 20, 3); x.fill(); + x.fillStyle = '#241505'; x.fillRect(8, 14, 32, 8); + x.fillStyle = '#7a5530'; roundRect(x, 4, 4, 40, 10, 5); x.fill(); + metalEdges(x, 6, 8, 36, 30); + P.chest_open = c; + + /* shrine (rune glow added at runtime) */ + c = mkCanvas(52, 60); x = c.getContext('2d'); + x.fillStyle = '#5a564e'; + x.beginPath(); x.moveTo(26, 6); x.lineTo(42, 20); x.lineTo(38, 54); x.lineTo(14, 54); x.lineTo(10, 20); x.closePath(); x.fill(); + x.fillStyle = '#6c685e'; + x.beginPath(); x.moveTo(26, 6); x.lineTo(34, 14); x.lineTo(22, 22); x.closePath(); x.fill(); + P.shrine = c; + + /* torch bracket (flame drawn dynamically) */ + c = mkCanvas(24, 44); x = c.getContext('2d'); + x.fillStyle = '#3a2c1c'; x.fillRect(10, 18, 5, 22); + x.fillStyle = '#55432c'; x.fillRect(7, 14, 11, 8); + P.torch = c; + + /* stairs down (dark pit with steps) */ + c = mkCanvas(TW, TH + 18); x = c.getContext('2d'); + x.save(); + x.translate(TW / 2, 9); + diamondPath(x, 0, 0, TW / 2 - 2, TH / 2 - 1); x.fillStyle = '#0c0906'; x.fill(); + x.clip(); + for (let i = 0; i < 5; i++) { + diamondPath(x, 0, -i * 3.5, (TW / 2 - 3) * (1 - i * 0.09), (TH / 2 - 1) * (1 - i * 0.09)); + x.fillStyle = ['#181310', '#221b15', '#2c231a', '#372c1f', '#43362a'][i]; + x.fill(); + } + x.restore(); + P.stairs_down = c; + + /* stairs up */ + c = mkCanvas(TW, TH + 18); x = c.getContext('2d'); + x.save(); + x.translate(TW / 2, 9); + diamondPath(x, 0, 0, TW / 2 - 2, TH / 2 - 1); x.fillStyle = '#d8cba8'; x.fill(); + x.clip(); + for (let i = 0; i < 5; i++) { + diamondPath(x, 0, i * 3.5, (TW / 2 - 3) * (1 - i * 0.09), (TH / 2 - 1) * (1 - i * 0.09)); + x.fillStyle = ['#cfc2a0', '#bfb294', '#afa286', '#9f9278', '#8f826a'][i]; + x.fill(); + } + x.restore(); + P.stairs_up = c; + + /* bones decor */ + c = mkCanvas(36, 20); x = c.getContext('2d'); + x.strokeStyle = '#cfc7ae'; x.lineWidth = 3; x.lineCap = 'round'; + x.beginPath(); x.moveTo(6, 14); x.lineTo(28, 8); x.stroke(); + x.beginPath(); x.moveTo(10, 6); x.lineTo(24, 15); x.stroke(); + x.fillStyle = '#cfc7ae'; + x.beginPath(); x.arc(29, 7, 4, 0, Math.PI * 2); x.fill(); + x.fillStyle = '#222'; x.fillRect(28, 5, 2, 2); x.fillRect(31, 6, 2, 2); + P.bones = c; + + /* waypoint stone */ + c = mkCanvas(48, 62); x = c.getContext('2d'); + x.fillStyle = '#4e5a66'; + x.beginPath(); x.moveTo(24, 4); x.lineTo(38, 16); x.lineTo(34, 56); x.lineTo(14, 56); x.lineTo(10, 16); x.closePath(); x.fill(); + x.fillStyle = '#61707e'; + x.beginPath(); x.moveTo(24, 4); x.lineTo(31, 12); x.lineTo(17, 20); x.closePath(); x.fill(); + x.strokeStyle = '#8ac4ff'; x.lineWidth = 2; + x.beginPath(); x.arc(24, 30, 8, 0, Math.PI * 2); x.stroke(); + x.beginPath(); x.moveTo(24, 22); x.lineTo(24, 38); x.moveTo(17, 30); x.lineTo(31, 30); x.stroke(); + P.waypoint = c; + + /* NPCs */ + c = mkCanvas(52, 58); x = c.getContext('2d'); // blacksmith + humanoid(x, 26, 34, { body: '#5a4a3a', accent: '#8a6a3a', cloth: '#3a2c1c' }, 1.15); + x.fillStyle = '#333'; x.fillRect(38, 40, 14, 8); // anvil block + x.fillStyle = '#555'; x.fillRect(36, 36, 18, 5); + P.npc_smith = c; + + c = mkCanvas(52, 58); x = c.getContext('2d'); // healer + humanoid(x, 26, 34, { body: '#d8d4c8', accent: '#e8c87b', cloth: '#b8b4a8' }, 1.1); + x.strokeStyle = '#e86a5a'; x.lineWidth = 3; + x.beginPath(); x.moveTo(26, 22); x.lineTo(26, 40); x.moveTo(18, 30); x.lineTo(34, 30); x.stroke(); + P.npc_healer = c; + + c = mkCanvas(52, 58); x = c.getContext('2d'); // stash keeper chest + x.fillStyle = '#4a3a26'; roundRect(x, 10, 22, 32, 26, 4); x.fill(); + x.fillStyle = '#5e4a30'; roundRect(x, 10, 14, 32, 12, 5); x.fill(); + x.fillStyle = '#c8a35a'; x.fillRect(23, 20, 6, 10); + metalEdges(x, 10, 14, 32, 34); + P.npc_stash = c; + + c = mkCanvas(52, 58); x = c.getContext('2d'); // Charsi the smith (renamed key) + humanoid(x, 26, 34, { body: '#8a6a52', accent: '#c89a5a', cloth: '#4a3626' }, 1.15); + x.fillStyle = '#333'; x.fillRect(38, 40, 14, 8); + x.fillStyle = '#555'; x.fillRect(36, 36, 18, 5); + P.npc_charsi = P.npc_smith; + + c = mkCanvas(52, 58); x = c.getContext('2d'); // Akara — robed priestess + humanoid(x, 26, 34, { body: '#c8b090', accent: '#e86a5a', cloth: '#7a2c3a' }, 1.05); + x.fillStyle = '#e8d8a8'; + x.beginPath(); x.moveTo(26, 14); x.lineTo(34, 22); x.lineTo(18, 22); x.closePath(); x.fill(); + x.strokeStyle = '#f0e0b0'; x.lineWidth = 2; + x.beginPath(); x.moveTo(26, 24); x.lineTo(26, 44); x.stroke(); + P.npc_akara = c; + + c = mkCanvas(52, 58); x = c.getContext('2d'); // Kashya — rogue captain + humanoid(x, 26, 34, { body: '#7a6a5a', accent: '#a83a3a', cloth: '#3a3236' }, 1.1); + x.strokeStyle = '#9a8a6a'; x.lineWidth = 3; + x.beginPath(); x.moveTo(40, 10); x.quadraticCurveTo(46, 26, 42, 44); x.stroke(); // bow + x.fillStyle = '#8a2c2c'; + x.beginPath(); x.moveTo(20, 16); x.lineTo(32, 16); x.lineTo(26, 10); x.closePath(); x.fill(); + P.npc_kashya = c; + + c = mkCanvas(52, 58); x = c.getContext('2d'); // Deckard Cain — elder scholar + humanoid(x, 26, 34, { body: '#c0b09a', accent: '#d8cba8', cloth: '#584a38' }, 0.92); + x.fillStyle = '#e8e0d0'; + x.beginPath(); x.arc(26, 18, 9, Math.PI * 0.15, Math.PI * 0.85); x.fill(); // white beard + x.fillStyle = '#6a5030'; x.fillRect(14, 30, 12, 9); + x.fillStyle = '#e8dcc0'; x.fillRect(15, 31, 10, 7); // open book + P.npc_cain = c; + + c = mkCanvas(52, 58); x = c.getContext('2d'); // Gheed — rotund merchant + humanoid(x, 26, 36, { body: '#b08a62', accent: '#d8b04a', cloth: '#6a4a2c' }, 1.28); + x.fillStyle = '#e8c84a'; + x.beginPath(); x.arc(26, 30, 6, 0, Math.PI * 2); x.fill(); // coin pouch glint + x.strokeStyle = '#caa232'; x.lineWidth = 2; + x.beginPath(); x.moveTo(33, 34); x.lineTo(39, 40); x.stroke(); + P.npc_gheed = c; + } + + function barrelShape(x, cx, baseY, main, dark) { + x.fillStyle = dark; + x.beginPath(); x.ellipse(cx, baseY - 4, 13, 5, 0, 0, Math.PI * 2); x.fill(); + x.fillStyle = main; + x.beginPath(); + x.moveTo(cx - 12, baseY - 4); + x.bezierCurveTo(cx - 16, baseY - 16, cx - 16, baseY - 26, cx - 11, baseY - 34); + x.lineTo(cx + 11, baseY - 34); + x.bezierCurveTo(cx + 16, baseY - 26, cx + 16, baseY - 16, cx + 12, baseY - 4); + x.closePath(); x.fill(); + x.strokeStyle = '#3a2812'; x.lineWidth = 2; + x.beginPath(); x.moveTo(cx - 13, baseY - 12); x.quadraticCurveTo(cx, baseY - 8, cx + 13, baseY - 12); x.stroke(); + x.beginPath(); x.moveTo(cx - 13, baseY - 26); x.quadraticCurveTo(cx, baseY - 22, cx + 13, baseY - 26); x.stroke(); + highlight(x, cx - 6, baseY - 22, 4); + } + + function humanoid(x, cx, baseY, pal, s = 1) { + const S = v => v * s; + x.fillStyle = pal.cloth; + roundRect(x, cx - S(8), baseY - S(26), S(16), S(20), S(4)); x.fill(); // torso + x.fillStyle = pal.body; + x.beginPath(); x.arc(cx, baseY - S(32), S(6.5), 0, Math.PI * 2); x.fill(); // head + roundRect(x, cx - S(11), baseY - S(8), S(6), S(9), 2); x.fill(); // legs + roundRect(x, cx + S(5), baseY - S(8), S(6), S(9), 2); x.fill(); + x.fillStyle = pal.accent; + roundRect(x, cx - S(10), baseY - S(22), S(20), S(4), 2); x.fill(); // belt + } + + function metalEdges(x, rx, ry, rw, rh) { + x.strokeStyle = 'rgba(200,163,90,.5)'; + x.lineWidth = 1.5; + x.strokeRect(rx, ry, rw, rh); + } + function roundRect(x, rx, ry, rw, rh, r) { + x.beginPath(); + x.moveTo(rx + r, ry); + x.arcTo(rx + rw, ry, rx + rw, ry + rh, r); + x.arcTo(rx + rw, ry + rh, rx, ry + rh, r); + x.arcTo(rx, ry + rh, rx, ry, r); + x.arcTo(rx, ry, rx + rw, ry, r); + x.closePath(); + } + function highlight(x, hx, hy, hr) { + x.fillStyle = 'rgba(255,255,255,.18)'; + x.beginPath(); x.ellipse(hx, hy, hr, hr * 1.8, -0.4, 0, Math.PI * 2); x.fill(); + } + + /* ---------------- item icons ---------------- */ + + const MAT_COLORS = { + melee: ['#9aa0aa', '#7a808a'], ranged: ['#8a6a42', '#6e5432'], cast: ['#7a5a9a', '#5e4478'], + shield: ['#7a808a', '#5a606a'], armor: ['#8a8578', '#6a665a'], jewelry: ['#c8a35a', '#a8853a'], + }; + + /** returns a fresh canvas with the item's icon */ + function itemIconCanvas(item, size = 48) { + const c = mkCanvas(size, size); + const x = c.getContext('2d'); + x.save(); + x.scale(size / 48, size / 48); + const rarCol = D2.Items.RARITIES[item.rarity].color; + const kind = item.kind || 'armor'; + + switch (item.slot) { + case 'weapon': drawWeaponIcon(x, item, kind, rarCol); break; + case 'offhand': kind === 'shield' ? drawShieldIcon(x, rarCol) : drawFocusIcon(x, kind, rarCol); break; + case 'head': drawHelmIcon(x, rarCol); break; + case 'chest': drawChestIcon(x, rarCol); break; + case 'hands': drawGlovesIcon(x, rarCol); break; + case 'feet': drawBootsIcon(x, rarCol); break; + case 'amulet': drawAmuletIcon(x, rarCol); break; + case 'ring': drawRingIcon(x, rarCol); break; + } + x.restore(); + return c; + } + + function drawWeaponIcon(x, item, kind, rarCol) { + if (kind === 'melee') { + const heavy = ['battleaxe', 'warhammer', 'reaver'].includes(item.baseId); + const blunt = ['club', 'mace', 'warhammer'].includes(item.baseId); + x.translate(24, 24); x.rotate(-Math.PI / 4); + if (heavy && !blunt) { // axe + x.fillStyle = '#6a4a2a'; x.fillRect(-2.5, -4, 5, 26); + x.fillStyle = MAT_COLORS.melee[0]; + x.beginPath(); x.moveTo(2, -4); x.quadraticCurveTo(20, -2, 14, 10); x.lineTo(2, 8); x.closePath(); x.fill(); + x.beginPath(); x.moveTo(-2, -4); x.quadraticCurveTo(-20, -2, -14, 10); x.lineTo(-2, 8); x.closePath(); x.fill(); + } else if (blunt) { // mace + x.fillStyle = '#6a4a2a'; x.fillRect(-2.5, 0, 5, 22); + x.fillStyle = MAT_COLORS.melee[0]; + x.beginPath(); x.arc(0, -5, 9, 0, Math.PI * 2); x.fill(); + x.fillStyle = '#555'; x.fillRect(-9, -7, 18, 4); + } else { // sword / dagger + const len = item.baseId === 'greatsword' ? 30 : item.baseId === 'dagger' ? 16 : 24; + x.fillStyle = '#6a4a2a'; x.fillRect(-2, len - 4, 4, 10); + x.fillStyle = MAT_COLORS.melee[0]; + x.beginPath(); x.moveTo(-3, len - 6); x.lineTo(-3, -len + 6); x.lineTo(0, -len); x.lineTo(3, -len + 6); x.lineTo(3, len - 6); x.closePath(); x.fill(); + x.fillStyle = '#c8a35a'; x.fillRect(-8, len - 6, 16, 4); + } + } else if (kind === 'ranged') { // bow + x.strokeStyle = MAT_COLORS.ranged[0]; x.lineWidth = 4; + x.beginPath(); x.arc(20, 24, 16, Math.PI * 0.6, -Math.PI * 0.6); x.stroke(); + x.strokeStyle = '#d8cba8'; x.lineWidth = 1.5; + x.beginPath(); x.moveTo(20 + 16 * Math.cos(Math.PI * 0.6), 24 + 16 * Math.sin(Math.PI * 0.6)); + x.lineTo(20 + 16 * Math.cos(-Math.PI * 0.6), 24 + 16 * Math.sin(-Math.PI * 0.6)); x.stroke(); + x.strokeStyle = rarCol; x.lineWidth = 3; + x.beginPath(); x.moveTo(8, 24); x.lineTo(34, 24); x.stroke(); + x.beginPath(); x.moveTo(34, 24); x.lineTo(28, 21); x.moveTo(34, 24); x.lineTo(28, 27); x.stroke(); + } else { // staff + x.translate(24, 24); x.rotate(Math.PI / 5); + x.fillStyle = '#6a4a2a'; roundRect(x, -3, -8, 6, 32, 3); x.fill(); + const g = x.createRadialGradient(0, -12, 1, 0, -12, 10); + g.addColorStop(0, '#fff'); g.addColorStop(0.4, rarCol); g.addColorStop(1, 'rgba(0,0,0,0)'); + x.fillStyle = g; + x.beginPath(); x.arc(0, -12, 10, 0, Math.PI * 2); x.fill(); + } + } + + function drawShieldIcon(x, rarCol) { + x.fillStyle = MAT_COLORS.shield[0]; + x.beginPath(); + x.moveTo(24, 6); x.quadraticCurveTo(40, 8, 39, 22); x.quadraticCurveTo(38, 38, 24, 44); + x.quadraticCurveTo(10, 38, 9, 22); x.quadraticCurveTo(8, 8, 24, 6); + x.closePath(); x.fill(); + x.strokeStyle = '#444'; x.lineWidth = 2; x.stroke(); + x.strokeStyle = rarCol; x.lineWidth = 2.5; + x.beginPath(); x.moveTo(24, 14); x.lineTo(24, 34); x.moveTo(15, 24); x.lineTo(33, 24); x.stroke(); + } + + function drawFocusIcon(x, kind, rarCol) { + if (kind === 'focus') { + const g = x.createRadialGradient(24, 24, 2, 24, 24, 16); + g.addColorStop(0, '#dff'); g.addColorStop(0.5, '#6ab8ff'); g.addColorStop(1, '#245'); + x.fillStyle = g; + x.beginPath(); x.arc(24, 24, 14, 0, Math.PI * 2); x.fill(); + x.strokeStyle = '#c8a35a'; x.lineWidth = 2.5; + x.beginPath(); x.arc(24, 24, 16, 0, Math.PI * 2); x.stroke(); + } else { // quiver + x.fillStyle = '#6a4a2a'; roundRect(x, 16, 12, 16, 26, 4); x.fill(); + x.strokeStyle = '#c8a35a'; x.lineWidth = 2; + for (let i = 0; i < 3; i++) { + x.beginPath(); x.moveTo(19 + i * 5, 14); x.lineTo(19 + i * 5, 4); x.stroke(); + } + } + } + + function drawHelmIcon(x, rarCol) { + x.fillStyle = MAT_COLORS.armor[0]; + x.beginPath(); x.arc(24, 26, 15, Math.PI, 0); x.lineTo(39, 34); x.lineTo(9, 34); x.closePath(); x.fill(); + x.fillStyle = '#222'; x.fillRect(15, 24, 18, 5); + x.fillStyle = rarCol; x.fillRect(22, 10, 4, 8); + } + + function drawChestIcon(x, rarCol) { + x.fillStyle = MAT_COLORS.armor[0]; + x.beginPath(); + x.moveTo(14, 10); x.lineTo(34, 10); x.lineTo(40, 18); x.lineTo(36, 42); x.lineTo(12, 42); x.lineTo(8, 18); + x.closePath(); x.fill(); + x.fillStyle = 'rgba(0,0,0,.25)'; + x.fillRect(21, 10, 6, 32); + x.strokeStyle = rarCol; x.lineWidth = 2; + x.beginPath(); x.moveTo(14, 20); x.lineTo(34, 20); x.stroke(); + } + + function drawGlovesIcon(x, rarCol) { + x.fillStyle = MAT_COLORS.armor[0]; + roundRect(x, 8, 16, 13, 20, 4); x.fill(); + roundRect(x, 27, 16, 13, 20, 4); x.fill(); + roundRect(x, 8, 34, 13, 8, 3); x.fill(); + roundRect(x, 27, 34, 13, 8, 3); x.fill(); + x.strokeStyle = rarCol; x.lineWidth = 2; + x.beginPath(); x.moveTo(14, 16); x.lineTo(14, 30); x.moveTo(33, 16); x.lineTo(33, 30); x.stroke(); + } + + function drawBootsIcon(x, rarCol) { + x.fillStyle = MAT_COLORS.armor[0]; + roundRect(x, 12, 10, 10, 26, 3); x.fill(); + roundRect(x, 12, 32, 22, 8, 3); x.fill(); + roundRect(x, 26, 10, 10, 26, 3); x.fill(); + roundRect(x, 26, 32, 14, 8, 3); x.fill(); + x.fillStyle = rarCol; + x.fillRect(12, 28, 10, 3); x.fillRect(26, 28, 10, 3); + } + + function drawAmuletIcon(x, rarCol) { + x.strokeStyle = '#c8a35a'; x.lineWidth = 2.5; + x.beginPath(); x.arc(24, 22, 12, Math.PI * 0.15, Math.PI * 0.85, true); x.stroke(); + const g = x.createRadialGradient(24, 34, 1, 24, 34, 8); + g.addColorStop(0, '#fff'); g.addColorStop(0.5, rarCol); g.addColorStop(1, '#222'); + x.fillStyle = g; + x.beginPath(); x.moveTo(24, 26); x.lineTo(31, 33); x.lineTo(24, 43); x.lineTo(17, 33); x.closePath(); x.fill(); + x.strokeStyle = '#c8a35a'; x.lineWidth = 1.5; x.stroke(); + } + + function drawRingIcon(x, rarCol) { + x.strokeStyle = MAT_COLORS.jewelry[0]; x.lineWidth = 5; + x.beginPath(); x.arc(24, 27, 11, 0, Math.PI * 2); x.stroke(); + x.strokeStyle = 'rgba(255,255,255,.4)'; x.lineWidth = 1.5; + x.beginPath(); x.arc(24, 27, 13, Math.PI * 1.1, Math.PI * 1.6); x.stroke(); + const g = x.createRadialGradient(24, 12, 1, 24, 12, 7); + g.addColorStop(0, '#fff'); g.addColorStop(0.6, rarCol); g.addColorStop(1, '#222'); + x.fillStyle = g; + x.beginPath(); x.arc(24, 12, 6, 0, Math.PI * 2); x.fill(); + } + + /* ---------------- skill icons ---------------- */ + + const SKILL_ICON_STYLE = { + cleave: { col: '#e8d8b0', draw: (x) => arcSweep(x, '#e8d8b0') }, + whirlwind: { col: '#c8e8ff', draw: (x) => spiral(x, '#c8e8ff') }, + consecrate: { col: '#ffe86a', draw: (x) => pillar(x, '#ffe86a') }, + charge: { col: '#ffb04a', draw: (x) => arrowFat(x, '#ffb04a') }, + warcry: { col: '#ff6a4a', draw: (x) => shoutRings(x, '#ff6a4a') }, + seismic: { col: '#d8a84a', draw: (x) => crackBurst(x, '#d8a84a') }, + ironskin: { col: '#aab4be', draw: (x) => shieldGlyph(x, '#aab4be') }, + renewal: { col: '#7ade8a', draw: (x) => crossPulse(x, '#7ade8a') }, + laststand: { col: '#e85a4a', draw: (x) => heartGuard(x, '#e85a4a') }, + multishot: { col: '#d8cba8', draw: (x) => fanArrows(x, '#d8cba8', 3) }, + piercingbolt: { col: '#ffe8b0', draw: (x) => fanArrows(x, '#ffe8b0', 1) }, + arrowstorm: { col: '#ffd24a', draw: (x) => rainDown(x, '#ffd24a') }, + shadowstep: { col: '#8a6aff', draw: (x) => blinkGlyph(x, '#8a6aff') }, + fanofknives: { col: '#c8d8e8', draw: (x) => radialKnives(x, '#c8d8e8') }, + smokeveil: { col: '#9aa8b8', draw: (x) => puffCloud(x, '#9aa8b8') }, + predator: { col: '#ff8a4a', draw: (x) => eyeGlyph(x, '#ff8a4a') }, + bleedingedge: { col: '#e83a5a', draw: (x) => dropletBlade(x, '#e83a5a') }, + swiftvantage: { col: '#8ad8ff', draw: (x) => windLines(x, '#8ad8ff') }, + firebolt: { col: '#ff7a2a', draw: (x) => fireOrb(x, '#ff7a2a') }, + meteor: { col: '#ff5a1a', draw: (x) => fallingRock(x, '#ff5a1a') }, + flamewave: { col: '#ffa02a', draw: (x) => novaRing(x, '#ffa02a') }, + iceshards: { col: '#8ad8ff', draw: (x) => radialKnives(x, '#8ad8ff') }, + frostnova: { col: '#b8ecff', draw: (x) => snowflake(x, '#b8ecff') }, + blizzard: { col: '#c8f0ff', draw: (x) => rainDown(x, '#c8f0ff') }, + chainlightning: { col: '#ffee6a', draw: (x) => boltZigzag(x, '#ffee6a') }, + teleport: { col: '#c86aff', draw: (x) => blinkGlyph(x, '#c86aff') }, + arcaneecho: { col: '#b08aff', draw: (x) => spiral(x, '#b08aff') }, + basic_melee: { col: '#e8d8b0', draw: (x) => swordGlyph(x, '#e8d8b0') }, + basic_ranged: { col: '#d8cba8', draw: (x) => fanArrows(x, '#d8cba8', 1) }, + basic_cast: { col: '#c86aff', draw: (x) => fireOrb(x, '#c86aff') }, + potion_hp: { col: '#e84a3a', draw: (x) => potionGlyph(x, '#e84a3a') }, + potion_mp: { col: '#4a7ae8', draw: (x) => potionGlyph(x, '#4a7ae8') }, + }; + + function skillIconCanvas(iconId, size = 48) { + const key = iconId + '_' + size; + if (!cache.skills[key]) { + const c = mkCanvas(size, size); + const x = c.getContext('2d'); + x.save(); + x.scale(size / 48, size / 48); + const st = SKILL_ICON_STYLE[iconId] || { col: '#ccc', draw: xx => orb(xx, '#ccc') }; + /* backdrop */ + const g = x.createRadialGradient(24, 24, 4, 24, 24, 24); + g.addColorStop(0, 'rgba(60,50,40,.9)'); + g.addColorStop(1, 'rgba(15,10,8,.95)'); + x.fillStyle = g; + x.fillRect(0, 0, 48, 48); + x.strokeStyle = 'rgba(200,163,90,.35)'; + x.strokeRect(1, 1, 46, 46); + st.draw(x); + x.restore(); + cache.skills[key] = c; + } + return cache.skills[key]; + } + + function cloneCanvas(src) { + const c = mkCanvas(src.width, src.height); + c.getContext('2d').drawImage(src, 0, 0); + return c; + } + + /* little glyph helpers */ + function orb(x, col) { + const g = x.createRadialGradient(24, 22, 2, 24, 22, 13); + g.addColorStop(0, '#fff'); g.addColorStop(0.45, col); g.addColorStop(1, 'rgba(0,0,0,.1)'); + x.fillStyle = g; x.beginPath(); x.arc(24, 22, 13, 0, 7); x.fill(); + } + function fireOrb(x, col) { orb(x, col); + x.strokeStyle = col; x.lineWidth = 2; + x.beginPath(); x.moveTo(24, 36); x.quadraticCurveTo(20, 30, 24, 26); x.stroke(); + } + function arcSweep(x, col) { + x.strokeStyle = col; x.lineWidth = 5; x.lineCap = 'round'; + x.beginPath(); x.arc(24, 30, 15, Math.PI * 1.15, Math.PI * 1.85); x.stroke(); + x.lineWidth = 2; + x.beginPath(); x.arc(24, 30, 9, Math.PI * 1.2, Math.PI * 1.8); x.stroke(); + } + function spiral(x, col) { + x.strokeStyle = col; x.lineWidth = 3.5; x.lineCap = 'round'; + x.beginPath(); + for (let a = 0; a < Math.PI * 4; a += 0.2) { + const r = 3 + a * 1.7; + const px = 24 + Math.cos(a) * r, py = 24 + Math.sin(a) * r * 0.8; + a === 0 ? x.moveTo(px, py) : x.lineTo(px, py); + } + x.stroke(); + } + function pillar(x, col) { + const g = x.createLinearGradient(0, 8, 0, 40); + g.addColorStop(0, 'rgba(255,255,255,.9)'); g.addColorStop(1, 'rgba(0,0,0,0)'); + x.fillStyle = g; + x.fillRect(18, 8, 12, 32); + x.strokeStyle = col; x.lineWidth = 2; + x.beginPath(); x.arc(24, 36, 12, Math.PI, 0); x.stroke(); + } + function arrowFat(x, col) { + x.fillStyle = col; + x.beginPath(); x.moveTo(40, 24); x.lineTo(26, 16); x.lineTo(30, 24); x.lineTo(26, 32); x.closePath(); x.fill(); + x.fillRect(8, 21, 20, 6); + } + function shoutRings(x, col) { + x.strokeStyle = col; + x.lineWidth = 2.5; + for (const r of [6, 12, 18]) { x.globalAlpha = 1 - r / 22; x.beginPath(); x.arc(24, 26, r, 0, 7); x.stroke(); } + x.globalAlpha = 1; + } + function crackBurst(x, col) { + x.strokeStyle = col; x.lineWidth = 3; x.lineCap = 'round'; + for (let i = 0; i < 6; i++) { + const a = i / 6 * Math.PI * 2; + x.beginPath(); x.moveTo(24, 26); + x.lineTo(24 + Math.cos(a) * 16, 26 + Math.sin(a) * 13); + x.stroke(); + } + } + function shieldGlyph(x, col) { + x.fillStyle = col; + x.beginPath(); x.moveTo(24, 8); x.quadraticCurveTo(38, 12, 37, 24); + x.quadraticCurveTo(35, 36, 24, 42); x.quadraticCurveTo(13, 36, 11, 24); + x.quadraticCurveTo(10, 12, 24, 8); x.closePath(); x.fill(); + x.fillStyle = 'rgba(0,0,0,.3)'; + x.beginPath(); x.moveTo(24, 8); x.lineTo(24, 42); x.lineTo(13, 36); x.lineTo(11, 24); x.quadraticCurveTo(10, 12, 24, 8); x.closePath(); x.fill(); + } + function crossPulse(x, col) { + x.fillStyle = col; + x.fillRect(20, 10, 8, 28); x.fillRect(10, 20, 28, 8); + x.strokeStyle = col; x.globalAlpha = .5; x.lineWidth = 2; + x.beginPath(); x.arc(24, 24, 19, 0, 7); x.stroke(); x.globalAlpha = 1; + } + function heartGuard(x, col) { + x.fillStyle = col; + x.beginPath(); + x.moveTo(24, 38); x.bezierCurveTo(6, 24, 14, 8, 24, 18); x.bezierCurveTo(34, 8, 42, 24, 24, 38); + x.fill(); + x.strokeStyle = '#fff'; x.lineWidth = 2; x.globalAlpha = .6; + x.beginPath(); x.moveTo(24, 18); x.lineTo(24, 34); x.stroke(); x.globalAlpha = 1; + } + function fanArrows(x, col, n) { + x.strokeStyle = col; x.lineWidth = 3; x.lineCap = 'round'; + const angles = n === 1 ? [0] : [-0.5, 0, 0.5]; + for (const a of angles) { + x.save(); x.translate(24, 34); x.rotate(a); + x.beginPath(); x.moveTo(0, 0); x.lineTo(0, -22); x.stroke(); + x.beginPath(); x.moveTo(-3, -18); x.lineTo(0, -24); x.lineTo(3, -18); x.stroke(); + x.restore(); + } + } + function rainDown(x, col) { + x.strokeStyle = col; x.lineWidth = 2.5; x.lineCap = 'round'; + for (let i = 0; i < 5; i++) { + const px = 8 + i * 8, py = 8 + (i % 3) * 8; + x.beginPath(); x.moveTo(px, py); x.lineTo(px - 2, py + 10); x.stroke(); + } + x.beginPath(); x.arc(24, 36, 9, Math.PI, 0); x.stroke(); + } + function blinkGlyph(x, col) { + x.fillStyle = col; x.globalAlpha = .35; + x.beginPath(); x.arc(14, 30, 8, 0, 7); x.fill(); + x.globalAlpha = .8; + x.beginPath(); x.arc(33, 18, 8, 0, 7); x.fill(); + x.globalAlpha = 1; + x.strokeStyle = col; x.lineWidth = 2; x.setLineDash([4, 3]); + x.beginPath(); x.moveTo(17, 26); x.lineTo(29, 21); x.stroke(); x.setLineDash([]); + } + function radialKnives(x, col) { + x.strokeStyle = col; x.lineWidth = 2.5; x.lineCap = 'round'; + for (let i = 0; i < 8; i++) { + const a = i / 8 * Math.PI * 2; + x.beginPath(); + x.moveTo(24 + Math.cos(a) * 7, 24 + Math.sin(a) * 7); + x.lineTo(24 + Math.cos(a) * 17, 24 + Math.sin(a) * 17); + x.stroke(); + } + } + function puffCloud(x, col) { + x.fillStyle = col; x.globalAlpha = .75; + for (const [ox, oy, r] of [[18, 28, 9], [28, 24, 11], [22, 18, 8], [32, 30, 7]]) { + x.beginPath(); x.arc(ox, oy, r, 0, 7); x.fill(); + } + x.globalAlpha = 1; + } + function eyeGlyph(x, col) { + x.strokeStyle = col; x.lineWidth = 2.5; + x.beginPath(); x.moveTo(8, 24); x.quadraticCurveTo(24, 10, 40, 24); x.quadraticCurveTo(24, 38, 8, 24); x.stroke(); + x.fillStyle = col; + x.beginPath(); x.arc(24, 24, 6, 0, 7); x.fill(); + x.fillStyle = '#000'; x.beginPath(); x.arc(24, 24, 2.5, 0, 7); x.fill(); + } + function dropletBlade(x, col) { + x.strokeStyle = '#c8d8e8'; x.lineWidth = 4; x.lineCap = 'round'; + x.beginPath(); x.moveTo(14, 36); x.lineTo(34, 12); x.stroke(); + x.fillStyle = col; + x.beginPath(); x.moveTo(30, 30); x.quadraticCurveTo(38, 36, 32, 41); x.quadraticCurveTo(25, 37, 30, 30); x.fill(); + } + function windLines(x, col) { + x.strokeStyle = col; x.lineWidth = 2.5; x.lineCap = 'round'; + for (const [y0, len] of [[16, 22], [24, 30], [32, 20]]) { + x.beginPath(); x.moveTo(40 - len, y0); + x.quadraticCurveTo(40 - len * .5, y0 - 4, 40, y0); + x.stroke(); + } + } + function fallingRock(x, col) { + x.fillStyle = '#7a5a3a'; + x.beginPath(); x.moveTo(28, 8); x.lineTo(36, 14); x.lineTo(33, 24); x.lineTo(24, 22); x.closePath(); x.fill(); + x.strokeStyle = col; x.lineWidth = 3; + x.beginPath(); x.moveTo(20, 14); x.lineTo(14, 22); x.stroke(); + x.beginPath(); x.moveTo(24, 10); x.lineTo(20, 16); x.stroke(); + const g = x.createRadialGradient(24, 36, 2, 24, 36, 12); + g.addColorStop(0, col); g.addColorStop(1, 'rgba(0,0,0,0)'); + x.fillStyle = g; x.beginPath(); x.arc(24, 36, 12, 0, 7); x.fill(); + } + function novaRing(x, col) { + x.strokeStyle = col; x.lineWidth = 5; + x.globalAlpha = .9; x.beginPath(); x.arc(24, 24, 14, 0, 7); x.stroke(); + x.globalAlpha = .4; x.lineWidth = 3; x.beginPath(); x.arc(24, 24, 20, 0, 7); x.stroke(); + x.globalAlpha = 1; + } + function snowflake(x, col) { + x.strokeStyle = col; x.lineWidth = 2.5; x.lineCap = 'round'; + for (let i = 0; i < 6; i++) { + const a = i / 6 * Math.PI * 2; + x.beginPath(); x.moveTo(24, 24); + x.lineTo(24 + Math.cos(a) * 16, 24 + Math.sin(a) * 16); + x.stroke(); + x.beginPath(); x.arc(24 + Math.cos(a) * 16, 24 + Math.sin(a) * 16, 2.5, 0, 7); + x.fillStyle = col; x.fill(); + } + } + function boltZigzag(x, col) { + x.strokeStyle = col; x.lineWidth = 3.5; x.lineJoin = 'round'; + x.beginPath(); + x.moveTo(28, 6); x.lineTo(18, 22); x.lineTo(26, 24); x.lineTo(16, 42); + x.stroke(); + x.globalAlpha = .5; x.lineWidth = 2; + x.beginPath(); x.moveTo(36, 12); x.lineTo(30, 24); x.lineTo(36, 26); x.lineTo(30, 38); x.stroke(); + x.globalAlpha = 1; + } + function swordGlyph(x, col) { + x.save(); x.translate(24, 24); x.rotate(-Math.PI / 4); + x.fillStyle = col; + x.beginPath(); x.moveTo(-3, 12); x.lineTo(-3, -14); x.lineTo(0, -19); x.lineTo(3, -14); x.lineTo(3, 12); x.closePath(); x.fill(); + x.fillStyle = '#c8a35a'; x.fillRect(-8, 12, 16, 4); x.fillRect(-2, 16, 4, 6); + x.restore(); + } + function potionGlyph(x, col) { + x.fillStyle = 'rgba(220,220,220,.25)'; + x.beginPath(); x.arc(24, 30, 12, 0, 7); x.fill(); + x.fillStyle = col; + x.beginPath(); x.arc(24, 30, 12, 0, 7); x.fill(); + x.fillStyle = '#caa'; + x.fillRect(20, 8, 8, 8); + x.fillStyle = 'rgba(255,255,255,.35)'; + x.beginPath(); x.ellipse(19, 25, 4, 6, -.4, 0, 7); x.fill(); + } + + /* ---------------- init ---------------- */ + + function init() { + for (const [id, th] of Object.entries(D2.world.THEMES)) { + cache.themes[id] = { + floors: th.floorCols.map((col, v) => bakeFloorTile(th, col, v)), + wallBlock: bakeWallBlock(th), + }; + } + bakeProps(); + } + + D2.sprites = { + init, TW, TH, WALL_H, + itemIconCanvas, skillIconCanvas, + get themes() { return cache.themes; }, + get props() { return cache.props; }, + }; +})(window.D2); diff --git a/js/ui/hud.js b/js/ui/hud.js new file mode 100644 index 0000000..6657918 --- /dev/null +++ b/js/ui/hud.js @@ -0,0 +1,284 @@ +/* ============================================================ + * Diablo2D — hud.js : UI core + in-game HUD (orbs, hotbar, minimap) + * ============================================================ */ +'use strict'; +window.D2 = window.D2 || {}; +(function (D2) { + + const ui = {}; + D2.ui = ui; + + let el = {}; + let minimapCtx = null; + let _minimapT = 0; + let hotbarSlots = []; // {slotEl, cdEl, iconKey} + let toastCount = 0; + + /* ---------------- init ---------------- */ + + ui.init = function () { + el = { + hud: document.getElementById('hud'), + lifeFill: document.getElementById('life-fill'), + manaFill: document.getElementById('mana-fill'), + lifeText: document.getElementById('life-text'), + manaText: document.getElementById('mana-text'), + xpBar: document.getElementById('xp-bar'), + xpText: document.getElementById('xp-text'), + lvlNum: document.getElementById('lvl-num'), + hbSkills: document.getElementById('hb-skills'), + hbPotions: document.getElementById('hb-potions'), + hbMenus: document.getElementById('hb-menus'), + buffBar: document.getElementById('buff-bar'), + toasts: document.getElementById('toasts'), + minimap: document.getElementById('minimap'), + minimapLabel: document.getElementById('minimap-label'), + bossWrap: document.getElementById('boss-bar-wrap'), + bossName: document.getElementById('boss-name'), + bossFill: document.getElementById('boss-fill'), + questTracker: document.getElementById('quest-tracker'), + }; + minimapCtx = el.minimap.getContext('2d'); + buildHotbar(); + D2.i18n.applyDom(document); + }; + + ui.showHud = function () { el.hud.classList.remove('hidden'); }; + ui.hideHud = function () { el.hud.classList.add('hidden'); }; + + /* ---------------- hotbar ---------------- */ + + function buildHotbar() { + el.hbSkills.innerHTML = ''; + el.hbPotions.innerHTML = ''; + el.hbMenus.innerHTML = ''; + hotbarSlots = []; + + /* skill slots: LMB basic, RMB, 1..4 */ + const defs = [ + { slot: 0, label: 'LMB' }, + { slot: 1, label: 'RMB' }, + { slot: 2, label: '1' }, + { slot: 3, label: '2' }, + { slot: 4, label: '3' }, + ]; + for (const d of defs) { + const s = document.createElement('div'); + s.className = 'hb-slot'; + s.innerHTML = `${d.label}`; + s.addEventListener('mousedown', (e) => { + e.stopPropagation(); + if (d.slot === 0) return; + /* clicking a slot casts it toward screen center-ish cursor */ + D2.game.castHotbarFromUi && D2.game.castHotbarFromUi(d.slot); + }); + el.hbSkills.appendChild(s); + hotbarSlots.push({ + slot: d.slot, + root: s, + canvas: s.querySelector('canvas'), + cd: s.querySelector('.cd-overlay'), + iconKey: null, + }); + } + + /* potions */ + const pots = [ + { type: 'hp', label: 'Q', icon: 'potion_hp', tip: 'hb.health_potion' }, + { type: 'mp', label: 'E', icon: 'potion_mp', tip: 'hb.mana_potion' }, + ]; + for (const pd of pots) { + const s = document.createElement('div'); + s.className = 'hb-slot'; + s.title = D2.i18n.t(pd.tip); + s.dataset.potion = pd.type; + s.innerHTML = `${pd.label}0`; + s.querySelector('canvas').getContext('2d').drawImage( + D2.sprites.skillIconCanvas(pd.icon, 48), 0, 0, 36, 36); + s.addEventListener('mousedown', (e) => { + e.stopPropagation(); + D2.player.usePotion(D2.game, pd.type); + }); + el.hbPotions.appendChild(s); + } + + /* menu buttons */ + const menus = [ + { key: 'KeyI', icon: null, glyph: '🎒', tip: 'hb.inventory', act: () => ui.togglePanel('inventory') }, + { key: 'KeyC', icon: null, glyph: '💪', tip: 'hb.character', act: () => ui.togglePanel('character') }, + { key: 'KeyT', icon: null, glyph: '✨', tip: 'hb.skills', act: () => ui.togglePanel('skills') }, + { key: 'KeyJ', icon: null, glyph: '📜', tip: 'hb.quests', act: () => ui.togglePanel('quests') }, + { key: 'F1', icon: null, glyph: '?', tip: 'hb.help', act: () => D2.main && D2.main.openHelp() }, + ]; + for (const md of menus) { + const s = document.createElement('div'); + s.className = 'hb-slot small'; + s.title = D2.i18n.t(md.tip); + s.innerHTML = `${md.glyph}`; + s.addEventListener('mousedown', (e) => { e.stopPropagation(); md.act(); }); + el.hbMenus.appendChild(s); + } + } + + ui.refreshHotbar = function () { + const G = D2.game; + const p = G.player; + if (!p) return; + for (const hs of hotbarSlots) { + const skillId = p.hotbar[hs.slot]; + let iconId = null; + if (skillId === 'basic') { + iconId = 'basic_' + (p.classId === 'crusader' ? 'melee' : p.classId === 'ranger' ? 'ranged' : 'cast'); + } else if (skillId) { + const sk = D2.Skills.findSkill(p.classId, skillId); + iconId = sk ? sk.icon : null; + } + if (iconId && iconId !== hs.iconKey) { + const c = hs.canvas.getContext('2d'); + c.clearRect(0, 0, 36, 36); + c.drawImage(D2.sprites.skillIconCanvas(iconId, 48), 0, 0, 36, 36); + hs.iconKey = iconId; + } + hs.root.classList.toggle('empty-slot', !iconId); + /* dim if unlearned */ + if (skillId && skillId !== 'basic') { + const rank = D2.player.getSkillRank(p, skillId); + hs.root.style.opacity = rank <= 0 ? 0.45 : 1; + } else hs.root.style.opacity = 1; + } + /* potion counts */ + el.hbPotions.querySelectorAll('[data-potion]').forEach(n => { + const t = n.dataset.potion; + n.querySelector('.potion-count').textContent = p.potions[t]; + n.style.opacity = p.potions[t] > 0 ? 1 : 0.4; + }); + }; + + /* ---------------- per-frame HUD ---------------- */ + + ui.tick = function (dt) { + const G = D2.game; + if (!G.player || G.state === 'title') return; + + /* cooldown sweeps */ + for (const hs of hotbarSlots) { + const skillId = G.player.hotbar[hs.slot]; + if (!skillId || skillId === 'basic') { hs.cd.style.display = 'none'; continue; } + const left = G.skillCooldownLeft(skillId); + const sk = D2.Skills.findSkill(G.player.classId, skillId); + const total = sk ? Math.max(0.01, sk.cooldown) : 1; + if (left > 0) { + const pct = Math.min(1, left / total); + const deg = 360 * (1 - pct); + hs.cd.style.display = 'block'; + hs.cd.style.background = `conic-gradient(rgba(0,0,0,.78) ${deg}deg, transparent ${deg}deg)`; + hs.cd.textContent = left > 1 ? Math.ceil(left) : ''; + hs.cd.style.color = '#fff'; + hs.cd.style.fontSize = '14px'; + hs.cd.style.display = 'flex'; + hs.cd.style.alignItems = 'center'; + hs.cd.style.justifyContent = 'center'; + } else if (hs.cd.style.display !== 'none') { + hs.cd.style.display = 'none'; + } + } + + /* buffs */ + if (G.player.buffs.length !== ui._lastBuffCount) { + ui._lastBuffCount = G.player.buffs.length; + rebuildBuffs(); + } + for (const b of G.player.buffs) { + if (b.timerEl) b.timerEl.textContent = Math.ceil(b.t); + } + + /* minimap @ ~7fps */ + _minimapT -= dt; + if (_minimapT <= 0) { + _minimapT = 0.14; + D2.render.minimap(G, el.minimap); + el.minimapLabel.textContent = G.world.isTown + ? D2.i18n.t('town.enter').split('—')[0].trim() + : `${G.world.act >= 0 ? D2.i18n.t(D2.BAL.acts[G.world.act].name) : ''} · ${D2.i18n.t('floor.depth', G.world.floorIdx + 1)}`; + } + }; + + function rebuildBuffs() { + el.buffBar.innerHTML = ''; + for (const b of D2.game.player.buffs) { + const d = document.createElement('div'); + d.className = 'buff-icon'; + d.title = b.name ? (b.name[D2.i18n.getLang()] || b.name.en) : b.id; + const cv = document.createElement('canvas'); + cv.width = 40; cv.height = 40; + cv.getContext('2d').drawImage(D2.sprites.skillIconCanvas(b.icon || 'renewal', 48), 2, 2, 36, 36); + d.appendChild(cv); + const t = document.createElement('div'); + t.className = 'buff-timer'; + d.appendChild(t); + b.timerEl = t; + el.buffBar.appendChild(d); + } + } + + /* ---------------- periodic refresh ---------------- */ + + ui.refreshHud = function () { + const G = D2.game; + const p = G.player; + if (!p) return; + el.lifeFill.style.height = Math.max(0, p.hp / p.maxHp * 100) + '%'; + el.manaFill.style.height = Math.max(0, p.mana / p.maxMana * 100) + '%'; + el.lifeText.textContent = `${Math.ceil(p.hp)}/${p.maxHp}`; + el.manaText.textContent = `${Math.ceil(p.mana)}/${p.maxMana}`; + const need = D2.BAL.xpForLevel(p.level); + const prev = p.level > 1 ? D2.BAL.xpForLevel(p.level - 1) : 0; + const pct = D2.util.clamp((p.xp - prev) / Math.max(1, need - prev), 0, 1); + el.xpBar.style.width = (pct * 100) + '%'; + el.xpText.textContent = `XP ${D2.util.fmtNum(p.xp)} / ${D2.util.fmtNum(need)}${p.statPoints ? ` · +${p.statPoints}${' '}` : ''}`; + el.lvlNum.textContent = 'Lv ' + p.level + + (G.progress.torment ? ' · ' + D2.i18n.t('torment', G.progress.torment) : ''); + ui.refreshHotbar(); + }; + + /* ---------------- toasts ---------------- */ + + ui.toast = function (text, cls = '') { + if (!el.toasts) return; + const d = document.createElement('div'); + d.className = 'toast ' + cls; + d.textContent = text; + el.toasts.appendChild(d); + toastCount++; + setTimeout(() => d.classList.add('fadeout'), 2200); + setTimeout(() => { d.remove(); toastCount--; }, 2800); + while (el.toasts.children.length > 4) el.toasts.firstChild.remove(); + }; + + /* ---------------- boss bar ---------------- */ + + ui.showBossBar = function (name) { + el.bossName.textContent = name; + el.bossFill.style.width = '100%'; + el.bossWrap.classList.remove('hidden'); + }; + ui.updateBossBar = function (pct) { + el.bossFill.style.width = Math.max(0, pct * 100) + '%'; + }; + ui.hideBossBar = function () { + el.bossWrap.classList.add('hidden'); + }; + + /* ---------------- quest tracker ---------------- */ + + ui.renderQuestTracker = function () { + const G = D2.game; + let html = '
' + D2.i18n.t('ui.questlog') + '
'; + for (const q of G.quests) { + html += `
` + + `
${q.text}
`; + } + el.questTracker.innerHTML = html; + }; + +})(window.D2); diff --git a/js/ui/panels.js b/js/ui/panels.js new file mode 100644 index 0000000..7a749f9 --- /dev/null +++ b/js/ui/panels.js @@ -0,0 +1,864 @@ +/* ============================================================ + * Diablo2D — panels.js : floating panels (inventory, char, skills, + * vendor, stash, dialogs, world map) + tooltips + item actions + * ============================================================ */ +'use strict'; +window.D2 = window.D2 || {}; +(function (D2) { + + const ui = D2.ui; + const panelsEl = () => document.getElementById('panels'); + const tooltipEl = () => document.getElementById('tooltip'); + + const registry = {}; + + /* ================= tooltip ================= */ + + ui.showTooltip = function (html, x, y) { + const tt = tooltipEl(); + tt.innerHTML = html; + tt.classList.remove('hidden'); + const r = tt.getBoundingClientRect(); + let px = x + 16, py = y + 12; + if (px + r.width > window.innerWidth - 8) px = x - r.width - 14; + if (py + r.height > window.innerHeight - 8) py = y - r.height - 10; + tt.style.left = Math.max(4, px) + 'px'; + tt.style.top = Math.max(4, py) + 'px'; + }; + ui.hideTooltip = function () { + tooltipEl().classList.add('hidden'); + hideItemActions(); + }; + + function esc(s) { return String(s).replace(/${esc(item.name)}`; + h += `
${esc(D2.i18n.t('ui.equip.' + item.slot))} · ${esc(item.rarity)} · ilvl ${item.ilvl}
`; + if (item.dmgMax) { + h += `
${item.dmgMin}–${item.dmgMax} damage · ${item.aps.toFixed(2)}/s
`; + } + if (item.armor) h += `
${item.armor} armor
`; + const lines = []; + for (const [k, v] of Object.entries(item.stats || {})) { + const val = typeof v === 'number' ? (Math.round(v * 10) / 10) : v; + lines.push('+' + val + ' ' + ui.statLabel(k)); + } + if (item.flatDmg) lines.push('+' + item.flatDmg + ' Flat Damage'); + if (lines.length) h += lines.map(l => `
${l}
`).join(''); + if (item.special) { + const sp = SPECIAL_TEXT[item.special]; + if (sp) h += `
★ ${sp[D2.i18n.getLang()] || sp.en}
`; + } + if (item.reqLevel > 1) h += `
Requires level ${item.reqLevel}
`; + if (item.flavor) h += `
“${esc(item.flavor)}”
`; + + /* compare vs equipped */ + if (!opts.noCompare && D2.game.player) { + const p = D2.game.player; + let cur = null; + if (item.slot === 'ring') cur = p.equip.ring1 || p.equip.ring2; + else cur = p.equip[item.slot]; + if (cur && cur.uid !== item.uid) { + let score = 0, curScore = 0; + score = itemScore(item); + curScore = itemScore(cur); + const diff = score - curScore; + h += `
Equipped: ${esc(cur.name)} ` + + `(${diff >= 0 ? '+' : ''}${Math.round(diff)})
`; + } + } + h += `
${item.value} gold
`; + return h; + }; + + const SPECIAL_TEXT = { + cleaveEcho: { en: 'Cleave strikes twice', vi: 'Chém Rộng đánh hai lần' }, + doubleShot: { en: 'Basic attacks loose an extra arrow', vi: 'Đánh thường bắn thêm một mũi tên' }, + fireNovaOnKill: { en: 'Kills erupt in a fire nova', vi: 'Hạ gục gây nova lửa' }, + chillAttacker: { en: 'Melee attackers are chilled', vi: 'Kẻ cận chiến bị đóng băng chậm' }, + allSkills: { en: '+1 to all active skills', vi: '+1 tất cả kỹ năng chủ động' }, + execHeal: { en: 'Elite kills restore 15% life', vi: 'Hạ tinh anh hồi 15% máu' }, + stormDash: { en: 'Charge cooldown −30%', vi: 'Charge hồi nhanh hơn 30%' }, + lifesteal: { en: '6% life steal', vi: 'Hút 6% máu' }, + eliteSlayer: { en: '+25% damage to elites', vi: '+25% sát thương lên tinh anh' }, + chainOnHit: { en: '15% chance to zap chains on hit', vi: '15% cơ hội xả sét lan khi đánh' }, + corpseBoom: { en: '20% chance corpses explode', vi: '20% xác chết nổ' }, + venomStrike: { en: 'Attacks poison the target', vi: 'Đòn đánh gây độc mục tiêu' }, + voidstep: { en: 'Teleport cooldown −30%', vi: 'Teleport hồi nhanh hơn 30%' }, + }; + + function itemScore(it) { + let s = 0; + if (it.dmgMax) s += (it.dmgMin + it.dmgMax) / 2 * it.aps * 3; + if (it.armor) s += it.armor * 0.8; + for (const v of Object.values(it.stats || {})) s += typeof v === 'number' ? Math.abs(v) * 1.5 : 0; + if (it.special) s += 12; + return s; + } + + /* ================= item actions popup ================= */ + + let actionsEl = null; + + function showItemActions(item, source, sourceIdx, x, y) { + hideItemActions(); + const G = D2.game; + actionsEl = document.createElement('div'); + actionsEl.className = 'panel-gothic'; + actionsEl.style.cssText = + `position:fixed;z-index:95;padding:8px;display:flex;flex-direction:column;gap:6px;left:${x}px;top:${y}px;min-width:130px`; + const mkBtn = (label, fn, cls = '') => { + const b = document.createElement('button'); + b.className = 'btn ' + cls; + b.style.fontSize = '13px'; + b.style.padding = '5px 12px'; + b.textContent = label; + b.addEventListener('click', (e) => { e.stopPropagation(); fn(); hideItemActions(); ui.hideTooltip(); }); + actionsEl.appendChild(b); + }; + + if (source === 'inv') { + if (item.slot) mkBtn(D2.i18n.t('ui.equip.' + item.slot).split(' ')[0] === 'Ring' ? 'Equip' : D2.i18n.t('ui.buy') === 'Buy' ? 'Equip' : 'Equip', () => { + D2.player.equipItem(G, item, sourceIdx); + }, 'btn-primary'); + if (G.stashOpen) mkBtn('→ ' + D2.i18n.t('ui.stash'), () => { + if (G.stash.length < 35) { G.player.inventory.splice(sourceIdx, 1); G.stash.push(item); G.saveGame(); refreshAllOpen(); } + }); + if (vendorOpen()) { + mkBtn(`${D2.i18n.t('ui.sell')} (+${Math.round(item.value * D2.BAL.sellRatio)})`, () => { + D2.player.sellItem(G, sourceIdx); + }); + } + mkBtn('✕ ' + D2.i18n.t('cancel'), () => {}); + } else if (source === 'equip') { + mkBtn('Unequip', () => { + D2.player.unequipSlot(G, sourceIdx); + }); + } else if (source === 'stash') { + mkBtn('Withdraw', () => { + const i = G.stash.indexOf(item); + if (i >= 0 && G.player.inventory.length < 40) { + G.stash.splice(i, 1); + G.player.inventory.push(item); + G.saveGame(); + refreshAllOpen(); + } else ui.toast(D2.i18n.t('msg.inventory_full'), 'bad'); + }, 'btn-primary'); + if (vendorOpen()) { + mkBtn(`${D2.i18n.t('ui.sell')} (+${Math.round(item.value * D2.BAL.sellRatio)})`, () => { + const i = G.stash.indexOf(item); + if (i >= 0) { + G.stash.splice(i, 1); + G.player.gold += Math.round(item.value * D2.BAL.sellRatio); + G.sfx('sell'); + G.saveGame(); + refreshAllOpen(); + } + }); + } + } else if (source === 'vendor') { + mkBtn(`${D2.i18n.t('ui.buy')} (${item.value})`, () => { + const i = G.vendorStock.indexOf(item); + if (D2.player.buyItem(G, item)) { + G.vendorStock.splice(i, 1); + ui.refreshVendor(); + ui.refreshHud(); + } + }, 'btn-primary'); + } + document.body.appendChild(actionsEl); + } + + function hideItemActions() { + if (actionsEl) { actionsEl.remove(); actionsEl = null; } + } + ui.hideItemActions = hideItemActions; + + function vendorOpen() { return registry.vendor && registry.vendor.isOpen; } + + /* ================= slot builders ================= */ + + function makeItemSlot(item, source, sourceIdx, opts = {}) { + const d = document.createElement('div'); + d.className = 'item-slot'; + if (opts.size) { + d.style.width = opts.size + 'px'; + d.style.height = opts.size + 'px'; + } + if (item) { + if (item.rarity !== 'common') d.classList.add('rarity-glow-' + item.rarity); + const cv = document.createElement('canvas'); + cv.width = 44; cv.height = 44; + cv.getContext('2d').drawImage(D2.sprites.itemIconCanvas(item, 48), 0, 0, 44, 44); + d.appendChild(cv); + d.addEventListener('mouseenter', (e) => { + ui.showTooltip(ui.itemTooltipHtml(item, opts), e.clientX, e.clientY); + }); + d.addEventListener('mousemove', (e) => { + if (!tooltipEl().classList.contains('hidden')) ui.showTooltip(ui.itemTooltipHtml(item, opts), e.clientX, e.clientY); + }); + d.addEventListener('mouseleave', () => ui.hideTooltip()); + d.addEventListener('contextmenu', (e) => { + e.preventDefault(); + if (source === 'inv') D2.player.equipItem(D2.game, item, sourceIdx); + else if (source === 'equip') D2.player.unequipSlot(D2.game, sourceIdx); + else if (source === 'stash') showItemActions(item, source, sourceIdx, e.clientX, e.clientY); + }); + d.addEventListener('click', (e) => { + e.stopPropagation(); + showItemActions(item, source, sourceIdx, e.clientX, e.clientY); + }); + } + return d; + } + + /* ================= panel framework ================= */ + + function createPanel(id, titleKey, widthPx) { + const root = document.createElement('div'); + root.className = 'fpanel panel-gothic hidden'; + root.id = 'panel-' + id; + if (widthPx) root.style.width = widthPx + 'px'; + root.innerHTML = ` +
+
${D2.i18n.t(titleKey)}
+
+
+
`; + root.querySelector('.fpanel-close').addEventListener('click', () => ui.closePanel(id)); + panelsEl().appendChild(root); + const reg = { + id, root, + body: root.querySelector('.fpanel-body'), + isOpen: false, + refresh: () => {}, + }; + registry[id] = reg; + return reg; + } + + ui.openPanel = function (id) { + const reg = registry[id]; + if (!reg) return; + reg.root.classList.remove('hidden'); + reg.isOpen = true; + reg.refresh(); + D2.audio.sfx('click'); + }; + ui.closePanel = function (id) { + const reg = registry[id]; + if (!reg) return; + reg.root.classList.add('hidden'); + reg.isOpen = false; + if (id === 'stash') D2.game.stashOpen = false; + ui.hideTooltip(); + }; + ui.togglePanel = function (id) { + const reg = registry[id]; + if (!reg) return; + reg.isOpen ? ui.closePanel(id) : ui.openPanel(id); + }; + ui.closeAllPanels = function () { + for (const id of Object.keys(registry)) ui.closePanel(id); + }; + ui.anyPanelOpen = function () { + return Object.values(registry).some(r => r.isOpen); + }; + + function refreshAllOpen() { + for (const reg of Object.values(registry)) if (reg.isOpen) reg.refresh(); + ui.refreshHud(); + } + ui.refreshInventory = refreshAllOpen; + ui.refreshCharacter = refreshAllOpen; + ui.refreshSkills = refreshAllOpen; + ui.refreshVendor = refreshAllOpen; + + /* ================= inventory ================= */ + + function buildInventory() { + const reg = createPanel('inventory', 'ui.inventory'); + reg.refresh = () => { + const G = D2.game; + const p = G.player; + if (!p) return; + const b = reg.body; + b.innerHTML = ''; + + /* paperdoll */ + const eq = document.createElement('div'); + eq.className = 'equip-grid'; + const layout = [ + ['weapon', 'head', 'offhand'], + ['ring1', 'chest', 'ring2'], + ['hands', 'feet', 'amulet'], + ]; + const abbrev = { weapon: 'WPN', head: 'HELM', offhand: 'OFF', ring1: 'RING', chest: 'BODY', ring2: 'RING', hands: 'HAND', feet: 'BOOT', amulet: 'AMLT' }; + for (const row of layout) { + for (const slotKey of row) { + const cell = document.createElement('div'); + cell.className = 'equip-slot'; + cell.style.position = 'relative'; + const it = p.equip[slotKey]; + if (it) { + const slotEl = makeItemSlot(it, 'equip', slotKey); + slotEl.style.cssText += 'position:absolute;inset:0;width:54px;height:54px;background:transparent;'; + cell.appendChild(slotEl); + } else { + const lbl = document.createElement('span'); + lbl.className = 'slot-label'; + lbl.textContent = abbrev[slotKey]; + cell.appendChild(lbl); + } + eq.appendChild(cell); + } + } + b.appendChild(eq); + + /* gold */ + const goldRow = document.createElement('div'); + goldRow.style.cssText = 'text-align:center;color:var(--gold-hi);font-size:13px;margin-bottom:8px;'; + goldRow.textContent = '🜚 ' + D2.util.fmtNum(p.gold) + ' ' + D2.i18n.t('ui.gold'); + b.appendChild(goldRow); + + /* bag grid */ + const grid = document.createElement('div'); + grid.className = 'inv-grid'; + for (let i = 0; i < 40; i++) { + const it = p.inventory[i]; + grid.appendChild(it ? makeItemSlot(it, 'inv', i) : emptySlot()); + } + b.appendChild(grid); + + if (p.skillPoints > 0 || p.statPoints > 0) { + const hint = document.createElement('div'); + hint.style.cssText = 'text-align:center;color:var(--gold);font-size:11px;margin-top:8px;'; + hint.textContent = `${p.statPoints ? D2.i18n.t('stat.points') + ': ' + p.statPoints + ' ' : ''}${p.skillPoints ? D2.i18n.t('stat.skillpoints') + ': ' + p.skillPoints : ''}`; + b.appendChild(hint); + } + }; + } + + function emptySlot() { + const d = document.createElement('div'); + d.className = 'item-slot empty-slot'; + return d; + } + + /* ================= character ================= */ + + function buildCharacter() { + const reg = createPanel('character', 'ui.character'); + reg.refresh = () => { + const G = D2.game; + const p = G.player; + if (!p) return; + const s = p.stats; + const b = reg.body; + b.innerHTML = ''; + + const attrs = [ + ['str', 'stat.strength'], ['dex', 'stat.dexterity'], + ['vit', 'stat.vitality'], ['ene', 'stat.energy'], + ]; + const list = document.createElement('div'); + list.className = 'stat-list'; + for (const [key, label] of attrs) { + const row = document.createElement('div'); + row.className = 'stat-row'; + const canAlloc = p.statPoints > 0; + row.innerHTML = ` + ${D2.i18n.t(label)} ${canAlloc ? '' : ''} + ${s[key]}${canAlloc ? '+' : ''}`; + list.appendChild(row); + } + const pts = document.createElement('div'); + pts.className = 'stat-row'; + pts.innerHTML = `${D2.i18n.t('stat.points')}${p.statPoints}`; + list.appendChild(pts); + b.appendChild(list); + + list.querySelectorAll('.plus-btn').forEach(btn => { + btn.addEventListener('click', () => { + if (D2.player.allocateStat(p, btn.dataset.k)) { + G.sfx('click'); + reg.refresh(); + ui.refreshHud(); + } + }); + }); + + const hr = document.createElement('div'); + hr.style.cssText = 'border-top:1px solid var(--border);margin:10px 0;'; + b.appendChild(hr); + + const wdr = D2.combat.weaponDamageRange(p); + const rows = [ + ['stat.damage', `${wdr.min}–${wdr.max}`], + ['stat.armor', s.armor], + ['stat.crit', Math.round((D2.BAL.baseCrit + (s.critChance || 0) / 100) * 100) + '%'], + ['stat.critdmg', Math.round((D2.BAL.baseCritDmg + (s.critDmgPct || 0) / 100) * 100) + '%'], + ['stat.speed', p.moveSpeed.toFixed(1)], + ['stat.magicfind', '+' + (s.magicFind || 0) + '%'], + ['stat.lifelench', s.lifeOnKill || 0], + ]; + const list2 = document.createElement('div'); + list2.className = 'stat-list'; + for (const [k, v] of rows) { + const r = document.createElement('div'); + r.className = 'stat-row'; + r.innerHTML = `${D2.i18n.t(k)}${v}`; + list2.appendChild(r); + } + b.appendChild(list2); + + /* resist strip */ + const rs = document.createElement('div'); + rs.className = 'resist-strip'; + rs.innerHTML = ` + 🔥${Math.min(72, s.resFire || 0)}% + ❄️${Math.min(72, s.resCold || 0)}% + ${Math.min(72, s.resLit || 0)}% + ☠️${Math.min(72, s.resPois || 0)}%`; + b.appendChild(rs); + }; + } + + /* ================= skills ================= */ + + let selectedSkill = null; + + function buildSkills() { + const reg = createPanel('skills', 'ui.skills', 560); + reg.refresh = () => { + const G = D2.game; + const p = G.player; + if (!p) return; + const cls = D2.Skills.CLASSES[p.classId]; + const skills = D2.Skills.skillsFor(p.classId); + const b = reg.body; + b.innerHTML = ''; + + const header = document.createElement('div'); + header.style.cssText = 'display:flex;justify-content:space-between;font-size:13px;color:var(--gold);margin-bottom:8px;'; + header.innerHTML = `${cls.name[D2.i18n.getLang()] || cls.name.en} + ${D2.i18n.t('stat.skillpoints')}: ${p.skillPoints}`; + b.appendChild(header); + + const tree = document.createElement('div'); + tree.className = 'skill-tree'; + for (const br of cls.branches) { + const branchEl = document.createElement('div'); + branchEl.innerHTML = `
${br.name[D2.i18n.getLang()] || br.name.en}
`; + const tierSkills = skills.filter(s => s.branch === br.id); + for (const sk of tierSkills) { + const tierRow = document.createElement('div'); + tierRow.className = 'skill-tier'; + const rank = p.skills[sk.id] || 0; + const effRank = D2.player.getSkillRank(p, sk.id); + const unlocked = p.level >= sk.unlockLevel; + const maxed = rank >= sk.maxRank; + const node = document.createElement('div'); + node.className = 'skill-node' + (unlocked ? '' : ' locked') + (maxed ? ' maxed' : ''); + const btn = document.createElement('div'); + btn.className = 'skill-btn'; + const cv = document.createElement('canvas'); + cv.width = 56; cv.height = 56; + cv.getContext('2d').drawImage(D2.sprites.skillIconCanvas(sk.icon, 48), 4, 4, 48, 48); + btn.appendChild(cv); + const rankBadge = document.createElement('span'); + rankBadge.className = 'skill-rank'; + rankBadge.textContent = rank > 0 ? (effRank > rank ? `${rank}+${effRank - rank}` : rank) : ''; + btn.appendChild(rankBadge); + const canLearn = D2.player.canLearnSkill(p, sk) === true; + if (canLearn) { + const plus = document.createElement('div'); + plus.className = 'skill-plus'; + plus.textContent = '+'; + plus.title = '+1 ' + (sk.name[D2.i18n.getLang()] || sk.name.en); + plus.addEventListener('click', (e) => { + e.stopPropagation(); + if (D2.player.learnSkill(G, sk)) { + selectedSkill = sk; + reg.refresh(); + ui.refreshHotbar(); + } + }); + node.appendChild(plus); + } + btn.addEventListener('mouseenter', (e) => { + selectedSkill = sk; + showSkillDesc(b, sk, p); + ui.showTooltip(skillTooltipHtml(sk, effRank, unlocked), e.clientX, e.clientY); + }); + btn.addEventListener('mouseleave', () => ui.hideTooltip()); + node.appendChild(btn); + + /* hotkey binding */ + if (sk.type === 'active' && rank > 0) { + const hk = document.createElement('div'); + hk.className = 'skill-hotkey-select'; + const binds = [['RMB', 1], ['1', 2], ['2', 3], ['3', 4]]; + for (const [label, slot] of binds) { + const bb = document.createElement('button'); + bb.textContent = label; + if (p.hotbar[slot] === sk.id) bb.className = 'active'; + bb.addEventListener('click', () => { + /* clear other binding of same skill */ + p.hotbar.forEach((v, i) => { if (v === sk.id) p.hotbar[i] = null; }); + p.hotbar[slot] = sk.id; + ui.refreshHotbar(); + reg.refresh(); + }); + hk.appendChild(bb); + } + tierRow.appendChild(node); + tierRow.appendChild(hk); + const descBox = document.createElement('div'); + tierRow.appendChild(descBox); + branchEl.appendChild(tierRow); + continue; + } + tierRow.appendChild(node); + branchEl.appendChild(tierRow); + } + tree.appendChild(branchEl); + } + b.appendChild(tree); + if (selectedSkill) showSkillDesc(b, selectedSkill, p); + }; + } + + function skillTooltipHtml(sk, rank, unlocked) { + const d = sk.desc(Math.max(1, rank)); + let h = `
${sk.name[D2.i18n.getLang()] || sk.name.en}
`; + h += `
${sk.type} · ${D2.i18n.t('level_short')} ${sk.unlockLevel}+
`; + if (rank <= 0) { + h += `
${d[D2.i18n.getLang()] || d.en}
`; + } else { + h += `
${d[D2.i18n.getLang()] || d.en}
`; + } + if (sk.type === 'active') { + h += `
Mana: ${sk.manaCost(Math.max(1, rank))} · CD: ${sk.cooldown}s
`; + } + if (!unlocked) h += `
Locked — requires level ${sk.unlockLevel}
`; + return h; + } + + function showSkillDesc(container, sk, p) { + let box = container.querySelector('.skill-desc-box'); + if (!box) { + box = document.createElement('div'); + box.className = 'skill-desc-box'; + box.style.cssText = 'margin-top:10px;padding:10px;border:1px solid var(--border);border-radius:5px;font-size:12.5px;line-height:1.55;color:var(--text-dim)'; + container.appendChild(box); + } + const rank = p.skills[sk.id] || 0; + const d = sk.desc(Math.max(1, rank)); + box.innerHTML = `${sk.name[D2.i18n.getLang()] || sk.name.en} ` + + `(${rank}/${sk.maxRank})
` + + (d[D2.i18n.getLang()] || d.en); + } + + /* ================= quests panel ================= */ + + function buildQuests() { + const reg = createPanel('quests', 'ui.questlog'); + reg.refresh = () => { + const G = D2.game; + const b = reg.body; + b.innerHTML = ''; + const p = G.player; + const stats = [ + ['Kills', p.kills], ['Elites slain', p.eliteKills], + ['Bosses slain', p.bossKills], ['Deaths', p.deaths], + ['Gold', p.gold], ['Torment', G.progress.torment], + ]; + for (const q of G.quests) { + const d = document.createElement('div'); + d.style.marginBottom = '10px'; + d.innerHTML = `
${q.header}
+
${q.text} ${q.done ? '✔' : ''}
`; + b.appendChild(d); + } + const hr = document.createElement('div'); + hr.style.cssText = 'border-top:1px solid var(--border);margin:10px 0'; + b.appendChild(hr); + for (const [k, v] of stats) { + const r = document.createElement('div'); + r.className = 'stat-row'; + r.innerHTML = `${k}${v}`; + b.appendChild(r); + } + }; + } + + /* ================= stash ================= */ + + function buildStash() { + const reg = createPanel('stash', 'ui.stash', 380); + reg.refresh = () => { + const G = D2.game; + const b = reg.body; + b.innerHTML = ''; + G.stashOpen = true; + const grid = document.createElement('div'); + grid.className = 'inv-grid'; + for (let i = 0; i < 35; i++) { + const it = G.stash[i]; + grid.appendChild(it ? makeItemSlot(it, 'stash', i) : emptySlot()); + } + b.appendChild(grid); + const note = document.createElement('div'); + note.style.cssText = 'text-align:center;color:var(--text-dim);font-size:11px;margin-top:8px;'; + note.textContent = 'Click items to withdraw · right-click inventory items to store via panel actions'; + b.appendChild(note); + }; + const origClose = ui.closePanel.bind(ui); + } + + /* ================= vendor ================= */ + + let vendorTab = 'buy'; + + function buildVendor() { + const reg = createPanel('vendor', 'ui.vendor', 640); + reg.refresh = () => { + const G = D2.game; + const p = G.player; + if (!p) return; + const b = reg.body; + b.innerHTML = ''; + + const tabs = document.createElement('div'); + tabs.className = 'vendor-tabs'; + const tb = document.createElement('div'); + tb.className = 'vendor-tab' + (vendorTab === 'buy' ? ' active' : ''); + tb.textContent = D2.i18n.t('ui.buy'); + tb.addEventListener('click', () => { vendorTab = 'buy'; reg.refresh(); }); + const ts = document.createElement('div'); + ts.className = 'vendor-tab' + (vendorTab === 'sell' ? ' active' : ''); + ts.textContent = D2.i18n.t('ui.sell'); + ts.addEventListener('click', () => { vendorTab = 'sell'; reg.refresh(); }); + tabs.appendChild(tb); tabs.appendChild(ts); + const gold = document.createElement('div'); + gold.style.cssText = 'margin-left:auto;color:var(--gold-hi);align-self:center'; + gold.textContent = '🜚 ' + D2.util.fmtNum(p.gold); + tabs.appendChild(gold); + b.appendChild(tabs); + + const listWrap = document.createElement('div'); + listWrap.className = 'vendor-layout'; + const list = document.createElement('div'); + list.className = 'vendor-list'; + list.style.flex = '1'; + + if (vendorTab === 'buy') { + for (const it of G.vendorStock) { + list.appendChild(vendorRow(it, 'vendor')); + } + } else { + for (let i = 0; i < p.inventory.length; i++) { + const it = p.inventory[i]; + const row = vendorRow(it, 'sell', i); + list.appendChild(row); + } + } + listWrap.appendChild(list); + b.appendChild(listWrap); + }; + } + + function vendorRow(item, mode, idx) { + const G = D2.game; + const row = document.createElement('div'); + row.className = 'vendor-item-row'; + const iconCv = document.createElement('canvas'); + iconCv.width = 40; iconCv.height = 40; + iconCv.getContext('2d').drawImage(D2.sprites.itemIconCanvas(item, 48), 0, 0, 40, 40); + row.appendChild(iconCv); + const nameSpan = document.createElement('span'); + nameSpan.style.color = D2.Items.RARITIES[item.rarity].color; + nameSpan.style.fontSize = '13px'; + nameSpan.textContent = item.name; + row.appendChild(nameSpan); + const price = document.createElement('span'); + price.className = 'vendor-price'; + price.textContent = mode === 'sell' + ? '+' + Math.round(item.value * D2.BAL.sellRatio) + : item.value; + row.appendChild(price); + + row.addEventListener('mouseenter', (e) => ui.showTooltip(ui.itemTooltipHtml(item, { noCompare: mode === 'sell' }), e.clientX, e.clientY)); + row.addEventListener('mousemove', (e) => ui.showTooltip(ui.itemTooltipHtml(item, { noCompare: mode === 'sell' }), e.clientX, e.clientY)); + row.addEventListener('mouseleave', () => ui.hideTooltip()); + row.addEventListener('click', () => { + if (mode === 'vendor') { + if (D2.player.buyItem(G, item)) { + const i = G.vendorStock.indexOf(item); + if (i >= 0) G.vendorStock.splice(i, 1); + refreshAllOpen(); + } + } else { + D2.player.sellItem(G, idx); + refreshAllOpen(); + } + }); + return row; + } + + /* ================= NPC dialog ================= */ + + function buildDialog() { + const reg = createPanel('dialog', '', 520); + reg.root.querySelector('.panel-title').textContent = ''; + reg.refresh = () => {}; + } + + ui.openNpcDialog = function (npcType) { + const reg = registry.dialog; + const G = D2.game; + if (!G.player) return; + reg.root.classList.remove('hidden'); + reg.isOpen = true; + reg.root.querySelector('.panel-title').textContent = + D2.i18n.t('npc.' + npcType + '.name') || npcType; + + const greetKeys = { + charsi: 'npc.charsi.greet', akara: 'npc.akara.greet', + kashya: 'npc.kashya.greet', cain: 'npc.cain.greet', + gheed: 'npc.gheed.greet', stash: 'npc.stash.greet', + healer: 'npc.healer.greet', smith: 'npc.smith.greet', + }; + const b = reg.body; + b.innerHTML = `
“${esc(D2.i18n.t(greetKeys[npcType] || 'npc.stash.greet'))}”
`; + + const options = (G.npcOptions && G.npcOptions(npcType)) || []; + + const opts = document.createElement('div'); + opts.className = 'dialog-options'; + for (const o of options) { + if (!o.label || !o.fn) continue; + const bt = document.createElement('button'); + bt.className = 'btn' + (o.highlight ? ' btn-primary' : ''); + bt.textContent = o.label; + bt.addEventListener('click', () => { D2.audio.sfx('click'); o.fn(); }); + opts.appendChild(bt); + } + const close = document.createElement('button'); + close.className = 'btn'; + close.textContent = D2.i18n.t('close'); + close.addEventListener('click', () => ui.closePanel('dialog')); + opts.appendChild(close); + b.appendChild(opts); + }; + + /* ================= world map / waypoints ================= */ + + function buildWorldMap() { + const reg = createPanel('worldmap', 'ui.map', 700); + reg.refresh = () => { + const G = D2.game; + const b = reg.body; + b.innerHTML = ''; + const nodes = document.createElement('div'); + nodes.className = 'worldmap-nodes'; + + const inTown = G.world.isTown; + + const mkNode = (icon, label, sub, state, fn) => { + const n = document.createElement('div'); + n.className = 'wm-node' + (state === 'current' ? ' current' : '') + (state === 'locked' ? ' locked' : ''); + n.innerHTML = `
${icon}
${label}
${sub}
`; + if (state !== 'locked' && fn) n.addEventListener('click', fn); + nodes.appendChild(n); + }; + const arrow = () => { + const a = document.createElement('div'); + a.className = 'wm-arrow'; + a.textContent = '➤'; + nodes.appendChild(a); + }; + + mkNode('🏕️', 'Tristram', inTown ? '•' : '', inTown ? 'current' : 'ok', + () => { if (!inTown) { ui.closePanel('worldmap'); G.useWaypoint({ town: true }); } }); + arrow(); + + D2.BAL.acts.forEach((act, i) => { + const unlocked = G.waypointsUnlocked[i]; + const isCurrent = !inTown && G.world.act === i; + mkNode(['⛪', '⚰️', '🍄', '🔥'][i], + D2.i18n.t(act.name), + unlocked ? (isCurrent ? '• ' + D2.i18n.t('floor.depth', G.world.floorIdx + 1) : D2.i18n.t('floor.depth', 1) + '–' + D2.BAL.floorsPerAct) : '🔒', + isCurrent ? 'current' : unlocked ? 'ok' : 'locked', + () => { + if (!unlocked || isCurrent) return; + ui.closePanel('worldmap'); + G.useWaypoint({ act: i, floor: 0 }); + }); + if (i < D2.BAL.acts.length - 1) arrow(); + }); + b.appendChild(nodes); + + if (!inTown) { + const back = document.createElement('button'); + back.className = 'btn'; + back.style.cssText = 'display:block;margin:10px auto 0;'; + back.textContent = '⌂ ' + D2.i18n.t('town.enter').split('—')[0].trim(); + back.addEventListener('click', () => { + ui.closePanel('worldmap'); + G.enterTown(); + }); + b.appendChild(back); + } + }; + } + + /* ================= init & global keys ================= */ + + ui.initPanels = function () { + buildInventory(); + buildCharacter(); + buildSkills(); + buildQuests(); + buildStash(); + buildVendor(); + buildDialog(); + buildWorldMap(); + }; + + ui.tickGlobalKeys = function () { + const inp = D2.input; + const G = D2.game; + if (G.state === 'playing' || G.state === 'paused') { + if (inp.wasPressed('KeyI')) ui.togglePanel('inventory'); + if (inp.wasPressed('KeyC')) ui.togglePanel('character'); + if (inp.wasPressed('KeyT')) ui.togglePanel('skills'); + if (inp.wasPressed('KeyJ')) ui.togglePanel('quests'); + if (inp.wasPressed('KeyM')) ui.togglePanel('worldmap'); + if (inp.wasPressed('F1')) { if (D2.main) D2.main.openHelp(); } + if (inp.wasPressed('Escape')) { + if (ui.anyPanelOpen()) ui.closeAllPanels(); + else if (D2.main) D2.main.togglePause(); + } + } + }; + +})(window.D2); diff --git a/js/ui/screens.js b/js/ui/screens.js new file mode 100644 index 0000000..236768d --- /dev/null +++ b/js/ui/screens.js @@ -0,0 +1,360 @@ +/* ============================================================ + * Diablo2D — screens.js : title, class select, settings, help, + * death & victory screens + * ============================================================ */ +'use strict'; +window.D2 = window.D2 || {}; +(function (D2) { + + const ui = D2.ui; + const $ = id => document.getElementById(id); + + function show(id) { + for (const sid of ['screen-loading', 'screen-title', 'screen-classselect', 'screen-settings', 'screen-help', 'screen-death', 'screen-victory']) { + $(sid).classList.toggle('hidden', sid !== id); + } + } + ui.hideScreens = () => show(null); + + /* ================= loading ================= */ + + const TIP_KEYS = ['loading.tips.0', 'loading.tips.1', 'loading.tips.2', 'loading.tips.3', 'loading.tips.4']; + + ui.showLoading = function () { + show('screen-loading'); + $('loading-tip').textContent = D2.i18n.t(TIP_KEYS[(Math.random() * TIP_KEYS.length) | 0]); + }; + ui.setLoadingProgress = function (p) { + $('loading-progress').style.width = Math.round(p * 100) + '%'; + }; + + /* ================= title ================= */ + + ui.showTitle = function () { + show('screen-title'); + const scr = $('screen-title'); + const G = D2.game; + const meta = D2.save.readMeta(); + scr.innerHTML = ` +
+

DIABLO2D

+
${D2.i18n.t('title.tagline')}
+
+ + + + +
+ ${meta ? `
${D2.i18n.t('title.saved_hero')}: ${D2.i18n.t('class.' + meta.classId + '.name')} · ${D2.i18n.t('level_short')} ${meta.level}${meta.torment ? ' · ' + D2.i18n.t('torment', meta.torment) : ''}${meta.hardcore ? ' · ☠ Hardcore' : ''}
` : ''} + +
`; + + scr.querySelector('#t-new').addEventListener('click', () => { + if (meta && !confirm(D2.i18n.t('title.confirm_overwrite'))) return; + D2.main.openClassSelect(); + }); + scr.querySelector('#t-continue').addEventListener('click', () => { + D2.main.startContinue(); + }); + scr.querySelector('#t-settings').addEventListener('click', () => { + ui.showSettings(() => ui.showTitle()); + }); + scr.querySelector('#t-help').addEventListener('click', () => { + ui.showHelp(() => ui.showTitle()); + }); + D2.audio.playMusic('title'); + }; + + /* ================= class select ================= */ + + ui.showClassSelect = function () { + show('screen-classselect'); + const scr = $('screen-classselect'); + scr.innerHTML = ` +
+

${D2.i18n.t('class.select')}

+
+
+ + +
+
`; + const grid = scr.querySelector('.class-grid'); + let selected = null; + + for (const clsId of ['crusader', 'ranger', 'sorceress']) { + const cls = D2.Skills.CLASSES[clsId]; + const card = document.createElement('div'); + card.className = 'class-card'; + card.innerHTML = ` + +

${D2.i18n.t('class.' + clsId + '.name')}

+

${D2.i18n.t(cls.descKey)}

+
+ ⚔ ${cls.baseMods.str} STR + 🏹 ${cls.baseMods.dex} DEX + ❤ ${cls.baseMods.vit} VIT + ✦ ${cls.baseMods.ene} ENE +
`; + drawClassPortrait(card.querySelector('canvas'), cls); + card.addEventListener('click', () => { + grid.querySelectorAll('.class-card').forEach(c => c.classList.remove('selected')); + card.classList.add('selected'); + selected = clsId; + scr.querySelector('#cs-play').disabled = false; + D2.audio.sfx('click'); + }); + card.addEventListener('dblclick', () => { + selected = clsId; + D2.main.startNewGame(clsId); + }); + grid.appendChild(card); + } + + scr.querySelector('#cs-play').addEventListener('click', () => { + if (selected) D2.main.startNewGame(selected); + }); + scr.querySelector('#cs-back').addEventListener('click', () => ui.showTitle()); + }; + + function drawClassPortrait(cv, cls) { + const x = cv.getContext('2d'); + const W = cv.width, H = cv.height; + /* bg */ + const g = x.createRadialGradient(W / 2, H * 0.42, 8, W / 2, H / 2, W * 0.62); + g.addColorStop(0, 'rgba(70,55,35,.55)'); + g.addColorStop(1, 'rgba(8,6,4,.9)'); + x.fillStyle = g; + x.fillRect(0, 0, W, H); + /* ring */ + x.strokeStyle = cls.palette.accent; + x.lineWidth = 3; + x.beginPath(); x.arc(W / 2, H * 0.46, 52, 0, Math.PI * 2); x.stroke(); + x.globalAlpha = 0.35; + x.beginPath(); x.arc(W / 2, H * 0.46, 60, 0, Math.PI * 2); x.stroke(); + x.globalAlpha = 1; + /* figure: simple hooded silhouette in class colors */ + x.save(); + x.translate(W / 2, H * 0.46); + const pal = cls.palette; + /* cloak */ + x.fillStyle = pal.cloth; + x.beginPath(); + x.moveTo(0, -44); + x.quadraticCurveTo(-38, -8, -30, 44); + x.lineTo(30, 44); + x.quadraticCurveTo(38, -8, 0, -44); + x.fill(); + /* head */ + x.fillStyle = '#1a1410'; + x.beginPath(); x.arc(0, -22, 15, 0, Math.PI * 2); x.fill(); + /* eyes glow */ + x.fillStyle = pal.accent; + x.fillRect(-8, -26, 5, 3.5); + x.fillRect(3, -26, 5, 3.5); + /* emblem weapon */ + x.restore(); + const wepBaseId = cls.id === 'crusader' ? 'longsword' : cls.id === 'ranger' ? 'huntingbow' : 'runestaff'; + const fakeItem = { slot: 'weapon', baseId: wepBaseId, kind: cls.weaponKind, rarity: 'legendary' }; + const icon = D2.sprites.itemIconCanvas(fakeItem, 48); + x.drawImage(icon, W / 2 - 24, H * 0.46 - 24, 48, 48); + } + + /* ================= settings ================= */ + + ui.showSettings = function (onBack) { + show('screen-settings'); + const scr = $('screen-settings'); + const G = D2.game; + const s = G.settings; + scr.innerHTML = ` +
+
${D2.i18n.t('title.settings')}
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + +
+
+
+ +
+
`; + + const bind = (id, key) => { + scr.querySelector(id).addEventListener('input', (e) => { + s[key] = parseFloat(e.target.value); + G.applySettings(); + D2.save.set(D2.save.KEY.SETTINGS, s); + }); + }; + bind('#s-master', 'masterVol'); + bind('#s-music', 'musicVol'); + bind('#s-sfx', 'sfxVol'); + + scr.querySelector('#s-lang').addEventListener('change', (e) => { + s.lang = e.target.value; + G.applySettings(); + D2.save.set(D2.save.KEY.SETTINGS, s); + ui.showSettings(onBack); // rebuild with new language + }); + const toggle = (id, key) => { + scr.querySelector(id).addEventListener('click', (e) => { + s[key] = !s[key]; + e.target.classList.toggle('on', s[key]); + G.applySettings(); + D2.save.set(D2.save.KEY.SETTINGS, s); + }); + }; + toggle('#s-shake', 'screenShake'); + toggle('#s-dmg', 'dmgNumbers'); + scr.querySelector('#s-labels').addEventListener('change', (e) => { + s.labels = e.target.value; + D2.save.set(D2.save.KEY.SETTINGS, s); + }); + + scr.querySelector('#s-export').addEventListener('click', () => { + const snap = D2.save.serializeSnapshot(); + const code = btoa(unescape(encodeURIComponent(snap))); + const ta = scr.querySelector('#s-code'); + ta.value = code; + ta.select(); + try { + navigator.clipboard.writeText(code); + ui.toast(D2.i18n.t('set.copy_ok')); + } catch (e) { /* selection is enough */ } + }); + scr.querySelector('#s-import').addEventListener('click', () => { + const code = scr.querySelector('#s-code').value.trim(); + if (!code) return; + try { + const json = decodeURIComponent(escape(atob(code))); + if (D2.save.restoreSnapshot(json)) { + ui.toast(D2.i18n.t('set.import_ok')); + } else ui.toast(D2.i18n.t('set.import_bad'), 'bad'); + } catch (e) { + ui.toast(D2.i18n.t('set.import_bad'), 'bad'); + } + }); + scr.querySelector('#s-wipe').addEventListener('click', () => { + if (confirm(D2.i18n.t('set.confirm_wipe'))) { + D2.save.wipeAll(); + ui.toast('OK'); + } + }); + scr.querySelector('#s-back').addEventListener('click', () => onBack()); + }; + + /* ================= help ================= */ + + ui.showHelp = function (onBack) { + show('screen-help'); + const scr = $('screen-help'); + scr.innerHTML = ` +
+
${D2.i18n.t('ui.help')}
+
+

${D2.i18n.t('help.keys_title')}

+
+ LMB${D2.i18n.t('help.move')} + RMB 13${D2.i18n.t('help.combat')} + Q E${D2.i18n.t('hb.health_potion')} / ${D2.i18n.t('hb.mana_potion')} + F/SpaceInteract — stairs, chests, shrines, NPCs + Alt${D2.i18n.t('help.items')} + I C T J M${D2.i18n.t('help.panels')} +
+

Tips

+
${D2.i18n.t('help.tip1')}
${D2.i18n.t('help.tip2')}
${D2.i18n.t('help.tip3')}
+

Credits

+
${D2.i18n.t('credits.line1')}
${D2.i18n.t('credits.line2')}
+
+
+ +
+
`; + scr.querySelector('#h-back').addEventListener('click', () => onBack()); + }; + + /* ================= death ================= */ + + ui.showDeath = function () { + show('screen-death'); + const scr = $('screen-death'); + scr.innerHTML = ` +
+
${D2.i18n.t('death.title')}
+
${D2.i18n.t('death.subtitle')}
+
+ + +
+
`; + scr.querySelector('#d-respawn').addEventListener('click', () => { + ui.hideScreens(); + D2.game.respawnInTown(); + }); + scr.querySelector('#d-title').addEventListener('click', () => { + D2.game.saveGame(); + D2.game.state = 'title'; + ui.showTitle(); + }); + }; + + /* ================= victory ================= */ + + ui.showVictory = function () { + show('screen-victory'); + const scr = $('screen-victory'); + const G = D2.game; + const p = G.player; + scr.innerHTML = ` +
+
${D2.i18n.t('victory.title')}
+
${D2.i18n.t('victory.subtitle')}
+
+ ${D2.i18n.t('victory.stats')}
+ ${D2.i18n.t('level_short')} ${p.level} · ${p.kills} kills · ${p.eliteKills} elites · ${p.bossKills} bosses · ${p.deaths} deaths +
+
+ ${G.progress.torment < D2.BAL.maxTorment ? `` : ''} + +
+
`; + const vt = scr.querySelector('#v-torment'); + if (vt) vt.addEventListener('click', () => { + G.progress.torment++; + G.progress.act = 0; + G.progress.floorIdx = 0; + G.victoryShown = false; + G.saveGame(); + ui.hideScreens(); + G.enterTown(); + G.state = 'playing'; + ui.toast(D2.i18n.t('torment', G.progress.torment), 'gold'); + }); + scr.querySelector('#v-title').addEventListener('click', () => { + G.saveGame(); + G.state = 'title'; + ui.showTitle(); + }); + }; + +})(window.D2); diff --git a/manifest.webmanifest b/manifest.webmanifest new file mode 100644 index 0000000..a7a06fc --- /dev/null +++ b/manifest.webmanifest @@ -0,0 +1,14 @@ +{ + "name": "Diablo2D — Shadows of Tristram", + "short_name": "Diablo2D", + "description": "Dark isometric action-RPG. Procedural dungeons, elite affixes, legendary loot, 3 classes. Fully offline.", + "start_url": "./index.html", + "display": "fullscreen", + "orientation": "landscape", + "background_color": "#0b0908", + "theme_color": "#0b0908", + "icons": [ + { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ] +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..0f62d5a --- /dev/null +++ b/server.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/* Tiny zero-dependency static server for Diablo2D. + * node server.js [port] (default 8080) + */ +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const PORT = parseInt(process.argv[2] || process.env.PORT || '8080', 10); +const ROOT = __dirname; +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json', + '.webmanifest': 'application/manifest+json', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.md': 'text/plain; charset=utf-8', +}; + +http.createServer((req, res) => { + let urlPath = decodeURIComponent((req.url || '/').split('?')[0]); + if (urlPath.endsWith('/')) urlPath += 'index.html'; + const file = path.normalize(path.join(ROOT, urlPath)); + if (!file.startsWith(ROOT)) { res.writeHead(403); return res.end('forbidden'); } + fs.readFile(file, (err, data) => { + if (err) { res.writeHead(404); return res.end('not found'); } + res.writeHead(200, { + 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', + 'Cache-Control': 'no-cache', + }); + res.end(data); + }); +}).listen(PORT, () => console.log(`Diablo2D running → http://localhost:${PORT}`)); diff --git a/tools/browser_drive.html b/tools/browser_drive.html new file mode 100644 index 0000000..b40e8a4 --- /dev/null +++ b/tools/browser_drive.html @@ -0,0 +1,246 @@ + + +Drive + + +
booting…
+ + + diff --git a/tools/build_zip.mjs b/tools/build_zip.mjs new file mode 100644 index 0000000..1350f7c --- /dev/null +++ b/tools/build_zip.mjs @@ -0,0 +1,76 @@ +/* ============================================================ + * tools/build_zip.mjs — package the game for itch.io. + * Store-only ZIP (no deps). Excludes tools/ and dev files. + * node tools/build_zip.mjs + * ============================================================ */ +import fs from 'node:fs'; +import path from 'node:path'; + +const CRC_TABLE = (() => { + const t = new Int32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c; + } + return t; +})(); +function crc32(buf) { + let c = 0xffffffff; + for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +const ROOT = path.join(process.cwd()); +const INCLUDE = ['index.html', 'manifest.webmanifest', 'LICENSE', 'README.md', 'server.js', 'css', 'js', 'icons']; +const EXCLUDE_DIR = new Set(['tools', 'node_modules', '.git']); + +function walk(rel, out) { + const full = path.join(ROOT, rel); + const st = fs.statSync(full); + if (st.isDirectory()) { + if (EXCLUDE_DIR.has(path.basename(rel))) return; + for (const name of fs.readdirSync(full)) walk(path.join(rel, name), out); + } else { + out.push({ rel: rel.split(path.sep).join('/'), full }); + } +} +const files = []; +for (const f of INCLUDE) if (fs.existsSync(path.join(ROOT, f))) walk(f, files); + +/* ---- assemble store-only zip ---- */ +const locals = []; +const centrals = []; +let offset = 0; +const u16 = v => { const b = Buffer.alloc(2); b.writeUInt16LE(v); return b; }; +const u32 = v => { const b = Buffer.alloc(4); b.writeUInt32LE(v); return b; }; + +for (const f of files) { + const data = fs.readFileSync(f.full); + const name = Buffer.from(f.rel, 'utf8'); + const crc = crc32(data); + + const lh = Buffer.concat([ + u32(0x04034b50), u16(20), u16(0), u16(0), u16(0x21), u16(0), + u32(crc), u32(data.length), u32(data.length), u16(name.length), u16(0), name, + ]); + locals.push(lh, data); + + centrals.push(Buffer.concat([ + u32(0x02014b50), u16(20), u16(20), u16(0), u16(0), u16(0x21), u16(0), + u32(crc), u32(data.length), u32(data.length), u16(name.length), u16(0), u16(0), + u16(0), u16(0), u32(0), u32(offset), name, + ])); + offset += lh.length + data.length; +} + +const cdBuf = Buffer.concat(centrals); +const eocd = Buffer.concat([ + u32(0x06054b50), u16(0), u16(0), u16(files.length), u16(files.length), + u32(cdBuf.length), u32(offset), u16(0), +]); + +const zip = Buffer.concat([...locals, cdBuf, eocd]); +const outName = 'diablo2d-itch.zip'; +fs.writeFileSync(path.join(ROOT, outName), zip); +console.log(`wrote ${outName}: ${files.length} files, ${(zip.length / 1024).toFixed(1)} KB`); diff --git a/tools/cdp_mouse_test.mjs b/tools/cdp_mouse_test.mjs new file mode 100644 index 0000000..31cab99 --- /dev/null +++ b/tools/cdp_mouse_test.mjs @@ -0,0 +1,82 @@ +/* Verifies the stuck-mouse fix inside the live game page. + * usage: node tools/cdp_mouse_test.mjs [port] */ +const port = process.argv[2] || '9366'; +const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json(); +const page = targets.find(t => t.type === 'page'); +const ws = new WebSocket(page.webSocketDebuggerUrl); +let id = 0; +const pending = new Map(); +const send = (m, p = {}) => new Promise((res, rej) => { + const i = ++id; pending.set(i, { res, rej }); + ws.send(JSON.stringify({ id: i, method: m, params: p })); +}); +ws.onmessage = ev => { + const msg = JSON.parse(ev.data); + if (msg.id && pending.has(msg.id)) { + const { res, rej } = pending.get(msg.id); pending.delete(msg.id); + msg.error ? rej(new Error(msg.error.message)) : res(msg.result); + } +}; +await new Promise(r => { ws.onopen = r; }); +const ev = async expr => (await send('Runtime.evaluate', { expression: expr, returnByValue: true })).result.value; + +/* wait for game ready */ +for (let i = 0; i < 60; i++) { + const s = await ev(`(document.getElementById('status')||{textContent:''}).textContent`); + if (/DONE|DRIVE_ERROR/.test(s)) break; + await new Promise(r => setTimeout(r, 1000)); +} + +console.log(await ev(`(() => { + const w = document.getElementById('g').contentWindow; + const D = w.D2, cv = w.document.getElementById('game-canvas'); + if (!D || !D.game.player) return 'FAIL no game'; + const r = cv.getBoundingClientRect(); + const opts = { bubbles: true, button: 0, clientX: r.left + 500, clientY: r.top + 400 }; + /* 1. press LMB and NEVER send mouseup (missed-release simulation) */ + cv.dispatchEvent(new w.MouseEvent('mousedown', opts)); + return 'pressed'; +})()`)); + +await new Promise(r => setTimeout(r, 700)); +const during = await ev(`(() => { + const D = document.getElementById('g').contentWindow.D2; + return JSON.stringify({ left: D.input.state.left, mt: !!D.game.player.moveTarget }); +})()`); +console.log('while held (no mouseup):', during); + +/* 2. user then just MOVES the mouse normally — real browsers attach + e.buttons=0 once released; our resync must heal the stuck flag */ +console.log(await ev(`(() => { + const w = document.getElementById('g').contentWindow; + const cv = w.document.getElementById('game-canvas'); + const r = cv.getBoundingClientRect(); + cv.dispatchEvent(new w.MouseEvent('mousemove', { + bubbles: true, clientX: r.left + 520, clientY: r.top + 420, buttons: 0 })); + return 'moved'; +})()`)); +await new Promise(r => setTimeout(r, 250)); +const healed = await ev(`(() => { + const D = document.getElementById('g').contentWindow.D2; + return JSON.stringify({ left: D.input.state.left }); +})()`); +console.log('after normal mousemove:', healed); + +/* 3. full click still works afterwards */ +console.log(await ev(`(() => { + const w = document.getElementById('g').contentWindow; + const D = w.D2, cv = w.document.getElementById('game-canvas'); + const r = cv.getBoundingClientRect(); + const o = { bubbles: true, button: 0, clientX: r.left + 640, clientY: r.top + 450 }; + cv.dispatchEvent(new w.MouseEvent('mousemove', o)); + cv.dispatchEvent(new w.MouseEvent('mousedown', o)); + w.dispatchEvent(new w.MouseEvent('mouseup', { bubbles: true, button: 0 })); + return 'clicked'; +})()`)); +await new Promise(r => setTimeout(r, 400)); +console.log(await ev(`(() => { + const D = document.getElementById('g').contentWindow.D2; + const p = D.game.player; + return JSON.stringify({ newTargetAccepted: !!p.moveTarget, left: D.input.state.left }); +})()`)); +ws.close(); diff --git a/tools/cdp_move_accuracy.mjs b/tools/cdp_move_accuracy.mjs new file mode 100644 index 0000000..73be7d2 --- /dev/null +++ b/tools/cdp_move_accuracy.mjs @@ -0,0 +1,77 @@ +/* Measures click→destination accuracy in the live game. + * usage: node tools/cdp_move_accuracy.mjs [port] */ +const port = process.argv[2] || '9370'; +const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json(); +const page = targets.find(t => t.type === 'page'); +const ws = new WebSocket(page.webSocketDebuggerUrl); +let id = 0; +const pending = new Map(); +const send = (m, p = {}) => new Promise((res, rej) => { + const i = ++id; pending.set(i, { res, rej }); + ws.send(JSON.stringify({ id: i, method: m, params: p })); +}); +ws.onmessage = ev => { + const msg = JSON.parse(ev.data); + if (msg.id && pending.has(msg.id)) { + const { res, rej } = pending.get(msg.id); pending.delete(msg.id); + msg.error ? rej(new Error(msg.error.message)) : res(msg.result); + } +}; +await new Promise(r => { ws.onopen = r; }); +const ev = async expr => (await send('Runtime.evaluate', { expression: expr, returnByValue: true })).result.value; + +for (let i = 0; i < 90; i++) { + const s = await ev(`(document.getElementById('status')||{textContent:''}).textContent`); + if (/DONE|DRIVE_ERROR/.test(s)) break; + await new Promise(r => setTimeout(r, 1000)); +} + +/* make sure the camera is live (RAF or pump renders) */ +console.log('env:', await ev(`(() => { + const D = document.getElementById('g').contentWindow.D2; + return JSON.stringify({ cam: [+D.render.cam.x.toFixed(1), +D.render.cam.y.toFixed(1)], + player: [+D.game.player.x.toFixed(1), +D.game.player.y.toFixed(1)] }); +})()`)); + +const runCase = await send('Runtime.evaluate', { + awaitPromise: true, returnByValue: true, + expression: `(async () => { + const w = document.getElementById('g').contentWindow; + const D = w.D2, cv = w.document.getElementById('game-canvas'); + const r = cv.getBoundingClientRect(); + const TW = 64, TH = 32; + const toScreen = (wx, wy) => { + const cam = D.render.cam, z = cam.zoom || 1; + const dx = wx - cam.x, dy = wy - cam.y; + return { x: r.left + cv.clientWidth / 2 + (dx - dy) * TW / 2 * z, + y: r.top + cv.clientHeight / 2 + (dx + dy) * TH / 2 * z }; + }; + const sleep = ms => new Promise(r2 => setTimeout(r2, ms)); + const results = []; + const cases = [[2, 1], [-3, 2], [4, -2], [-2, -3]]; + for (const [ox, oy] of cases) { + const p = D.game.player; + const want = { x: Math.floor(p.x + ox) + .5, y: Math.floor(p.y + oy) + .5 }; + if (!D.world || !D.game.world.isWalkable(Math.floor(want.x), Math.floor(want.y))) { + results.push({ ox, oy, skipped: 'not walkable' }); + continue; + } + const sp = toScreen(want.x, want.y); + /* settle camera right before measuring */ + D.render.frame(D.game, 1 / 60); + const o = { bubbles: true, button: 0, clientX: sp.x, clientY: sp.y }; + cv.dispatchEvent(new w.MouseEvent('mousemove', o)); + cv.dispatchEvent(new w.MouseEvent('mousedown', o)); + w.dispatchEvent(new w.MouseEvent('mouseup', { bubbles: true, button: 0 })); + await sleep(3500); + const q = D.game.player; + results.push({ ox, oy, want: [+want.x.toFixed(2), +want.y.toFixed(2)], + got: [+q.x.toFixed(2), +q.y.toFixed(2)], + err: +Math.hypot(q.x - want.x, q.y - want.y).toFixed(2), + hoverTaken: !!(D.game.hoverEntity) }); + } + return JSON.stringify(results); +})()`, +}); +console.log('accuracy:', runCase.result ? runCase.result.value : JSON.stringify(runCase)); +ws.close(); diff --git a/tools/cdp_probe.mjs b/tools/cdp_probe.mjs new file mode 100644 index 0000000..4d943ba --- /dev/null +++ b/tools/cdp_probe.mjs @@ -0,0 +1,62 @@ +/* CDP probe: launch nothing; attach to an already-running chrome + with --remote-debugging-port and interrogate the drive page. + usage: node tools/cdp_probe.mjs [port] */ +const port = process.argv[2] || '9333'; + +const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json(); +console.log('targets:', targets.map(t => `${t.type}:${t.title}`).join(' | ')); +const page = targets.find(t => t.type === 'page'); +if (!page) { console.log('no page target'); process.exit(1); } + +const ws = new WebSocket(page.webSocketDebuggerUrl); +let id = 0; +const pending = new Map(); +function send(method, params = {}) { + return new Promise((res, rej) => { + const mid = ++id; + pending.set(mid, { res, rej }); + ws.send(JSON.stringify({ id: mid, method, params })); + }); +} +ws.onmessage = (ev) => { + const msg = JSON.parse(ev.data); + if (msg.id && pending.has(msg.id)) { + const { res, rej } = pending.get(msg.id); + pending.delete(msg.id); + msg.error ? rej(new Error(msg.error.message)) : res(msg.result); + } +}; +await new Promise(r => { ws.onopen = r; }); + +async function evalJs(expr) { + const r = await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true, timeout: 8000 }); + return r.result ? r.result.value : JSON.stringify(r); +} + +const alive = await evalJs('1 + 1'); +console.log('alive:', alive); + +const info = await evalJs(`(() => { + const f = document.getElementById('g'); + const w = f && f.contentWindow; + if (!w || !w.D2) return 'no game'; + const D = w.D2; + return JSON.stringify({ + state: D.game.state, + time: +(D.game.time||0).toFixed(2), + mons: D.game.monsters ? D.game.monsters.length : -1, + camx: +D.render.cam.x.toFixed(2), + status: document.getElementById('status').textContent.slice(-300), + }); +})()`); +console.log('info:', info); + +/* try one manual render with a hard timeout guard */ +const rend = await Promise.race([ + evalJs(`(() => { const f=document.getElementById('g'); const D=f.contentWindow.D2; + const t0=performance.now(); try { D.render.frame(D.game, 1/30); return 'frame ok in '+(performance.now()-t0).toFixed(1)+'ms'; } catch(e){ return 'frame threw: '+e.message; } })()`), + new Promise(r => setTimeout(() => r('FRAME HUNG (>8s)'), 8000)), +]); +console.log('render:', rend); +ws.close(); +process.exit(0); diff --git a/tools/cdp_run.mjs b/tools/cdp_run.mjs new file mode 100644 index 0000000..21cda59 --- /dev/null +++ b/tools/cdp_run.mjs @@ -0,0 +1,46 @@ +/* CDP runner: attach to running chrome (real time, no virtual time), + poll the drive page status until DONE/ERROR, then report. + usage: node tools/cdp_run.mjs [port] [timeoutSec] */ +const port = process.argv[2] || '9333'; +const timeoutMs = (parseInt(process.argv[3] || '120', 10)) * 1000; + +const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json(); +const page = targets.find(t => t.type === 'page'); +if (!page) { console.log('no page'); process.exit(1); } + +const ws = new WebSocket(page.webSocketDebuggerUrl); +let id = 0; +const pending = new Map(); +function send(method, params = {}) { + return new Promise((res, rej) => { + const mid = ++id; + pending.set(mid, { res, rej }); + ws.send(JSON.stringify({ id: mid, method, params })); + }); +} +ws.onmessage = (ev) => { + const msg = JSON.parse(ev.data); + if (msg.id && pending.has(msg.id)) { + const { res, rej } = pending.get(msg.id); + pending.delete(msg.id); + msg.error ? rej(new Error(msg.error.message)) : res(msg.result); + } +}; +await new Promise(r => { ws.onopen = r; }); + +async function evalJs(expr) { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true }); + return r.result ? r.result.value : undefined; +} + +const t0 = Date.now(); +let last = ''; +while (Date.now() - t0 < timeoutMs) { + await new Promise(r => setTimeout(r, 2000)); + const s = await evalJs(`(document.getElementById('status')||{}).textContent || ''`); + if (s !== last) { console.log('----\n' + s); last = s; } + if (/DONE|DRIVE_ERROR/.test(s)) break; +} +console.log('\n===== FINAL ====='); +console.log(await evalJs(`(document.getElementById('status')||{}).textContent || ''`)); +ws.close(); diff --git a/tools/headless_test.js b/tools/headless_test.js new file mode 100644 index 0000000..8a63634 --- /dev/null +++ b/tools/headless_test.js @@ -0,0 +1,477 @@ +/* ============================================================ + * Diablo2D — headless_test.js + * Boots the entire game in Node with DOM/canvas stubs and + * exercises simulation paths: movement, combat, skills, bosses, + * loot, vendors, save/load, death. Catches runtime errors early. + * + * node tools/headless_test.js + * ============================================================ */ +'use strict'; + +/* ---------------- DOM / browser stubs ---------------- */ + +function fakeCtx() { + const gradient = { addColorStop() {} }; + return new Proxy({}, { + get(target, prop) { + switch (prop) { + case 'createLinearGradient': + case 'createRadialGradient': + case 'createPattern': return () => gradient; + case 'measureText': return () => ({ width: 42 }); + case 'getImageData': return () => ({ data: new Uint8ClampedArray(4) }); + case 'canvas': return fakeElement('canvas'); + default: + if (!(prop in target)) { + return (...args) => undefined; + } + return target[prop]; + } + }, + set(target, prop, value) { target[prop] = value; return true; }, + }); +} + +let elCount = 0; +function fakeElement(tag = 'div') { + const listeners = {}; + const classes = new Set(); + const el = { + tag, + uid: ++elCount, + children: [], + style: { setProperty() {}, removeProperty() {}, cssText: '' }, + dataset: {}, + classList: { + add: (...c) => c.forEach(x => x && classes.add(x)), + remove: (...c) => c.forEach(x => x && classes.delete(x)), + toggle: (c, f) => { if (f === undefined) f = !classes.has(c); f ? classes.add(c) : classes.delete(c); }, + contains: c => classes.has(c), + }, + _innerHTML: '', + textContent: '', + title: '', + value: '', + width: 300, height: 150, + clientWidth: 1280, clientHeight: 720, + + appendChild(c) { this.children.push(c); c.parent = this; return c; }, + removeChild(c) { const i = this.children.indexOf(c); if (i >= 0) this.children.splice(i, 1); }, + insertBefore(c) { this.children.push(c); return c; }, + remove() { if (this.parent) this.parent.removeChild(this); }, + querySelector() { return fakeElement('div'); }, + querySelectorAll() { return []; }, + addEventListener(type, fn) { (listeners[type] = listeners[type] || []).push(fn); }, + removeEventListener() {}, + dispatchEvent(evt) { + evt.preventDefault = evt.preventDefault || (() => {}); + (listeners[evt.type] || []).forEach(f => f.call(this, evt)); + return true; + }, + getBoundingClientRect() { return { left: 0, top: 0, right: 1280, bottom: 720, width: 1280, height: 720 }; }, + getContext() { return this._ctx || (this._ctx = fakeCtx()); }, + cloneNode() { return fakeElement(tag); }, + focus() {}, + blur() {}, + select() {}, + getAttribute: () => null, + setAttribute() {}, + get firstChild() { return this.children[0] || null; }, + }; + Object.defineProperty(el, 'innerHTML', { + get() { return this._innerHTML; }, + set(v) { + this._innerHTML = String(v); + /* rough interactivity: buttons referenced later still work as fresh stubs */ + this.children.length = 0; + }, + }); + return el; +} + +const elementCache = new Map(); + +global.window = global.window || global; +global.window.D2 = global.window.D2 || {}; +global.window.addEventListener = global.window.addEventListener || (() => {}); +global.window.removeEventListener = global.window.removeEventListener || (() => {}); +global.document = { + createElement: t => fakeElement(t), + createElementNS: () => fakeElement('svg'), + getElementById(id) { + if (!elementCache.has(id)) elementCache.set(id, fakeElement('div')); + return elementCache.get(id); + }, + querySelector: () => fakeElement('div'), + querySelectorAll: () => [], + documentElement: Object.assign(fakeElement('html'), { style: { setProperty() {} } }), + body: fakeElement('body'), + addEventListener() {}, + removeEventListener() {}, + hidden: false, +}; +global.localStorage = (() => { + const store = new Map(); + return { + getItem: k => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: k => store.delete(k), + }; +})(); +try { global.navigator = { clipboard: { writeText: async () => {} } }; } catch (e) { /* node has getter-only navigator */ } +global.performance = global.performance || { now: () => Date.now() }; +global.requestAnimationFrame = fn => setTimeout(() => fn(performance.now()), 16); +global.confirm = () => true; +global.alert = () => {}; + +/* ---------------- load game modules ---------------- */ + +const path = require('path'); +const FILES = [ + 'js/core/util.js', 'js/core/i18n.js', 'js/core/save.js', 'js/core/input.js', 'js/core/audio.js', + 'js/data/balance.js', 'js/data/items.js', 'js/data/monsters.js', 'js/data/skills.js', + 'js/game/path.js', 'js/game/fov.js', 'js/game/world.js', + 'js/game/entities.js', 'js/game/ai.js', 'js/game/combat.js', + 'js/game/loot.js', 'js/game/player.js', + 'js/render/sprites.js', 'js/render/render.js', + 'js/ui/hud.js', 'js/ui/panels.js', 'js/ui/screens.js', + 'js/game/game.js', 'js/main.js', +]; +for (const f of FILES) require(path.join(__dirname, '..', f)); + +const D2 = global.window.D2; +const G = D2.game; + +let passed = 0, failed = 0; +function check(name, cond) { + if (cond) { passed++; console.log(' ✔', name); } + else { failed++; console.log(' ✘ FAIL:', name); } +} +function section(name) { console.log('\n== ' + name + ' =='); } + +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +function godmode() { + const p = G.player; + if (!p) return; + p.dead = false; + if (G.state !== 'playing') G.state = 'playing'; + D2.player.recompute(p); + p.maxHp = 1e9; // set AFTER recompute so nothing resets it + p.hp = 1e9; +} +/* keep player alive mid-walk too */ +setInterval(() => { if (G.player && !G.player.dead) { G.player.maxHp = Math.max(G.player.maxHp, 1e9); G.player.hp = G.player.maxHp; } }, 50); + +async function runFrames(n, dt = 1 / 30) { + for (let i = 0; i < n; i++) { + G.update(dt); + D2.render.frame(G, dt); + D2.input.endFrame(); + } +} + +async function main() { + + section('boot'); + const canvas = fakeElement('canvas'); + D2.render.init(canvas); + D2.input.init(canvas); + D2.ui.init(); + D2.ui.initPanels(); + G.applySettings(); + check('modules loaded', !!(D2.game && D2.player && D2.combat && D2.world && D2.sprites)); + D2.sprites.init(); + check('sprites baked', D2.sprites.themes.cathedral.floors.length === 4); + + section('new game & town'); + G.startNewGame('crusader'); + check('town generated', G.world.isTown); + check('player spawned', !!G.player && !G.player.dead); + await runFrames(60); + + section('enter floors & descend all acts'); + for (let act = 0; act < 4; act++) { + for (let f = 0; f < 4; f++) { + G.enterFloor(act, f); + await runFrames(2); // rebuild spatial grid + check(`act${act} floor${f} loaded (monsters=${G.monsters.length})`, G.world && !G.world.isTown && G.monsters.length >= (f === 3 ? 5 : 8)); + /* walk toward stairs-down using pathfinding */ + if (f < 3) { + godmode(); + const p = G.player; + p.moveTarget = { x: G.world.stairsDown.x + .5, y: G.world.stairsDown.y + .5 }; + let arrived = false; + for (let i = 0; i < 60 * 45; i++) { + G.update(1 / 30); + if (!p.moveTarget || Math.hypot(p.x - (G.world.stairsDown.x + .5), p.y - (G.world.stairsDown.y + .5)) < 1.1) { arrived = true; break; } + } + check(`act${act} floor${f} pathed to stairs`, arrived); + } + } + } + + section('combat vs monsters'); + G.enterFloor(0, 0); + await runFrames(2); + godmode(); + const m = G.monsters.find(mm => !mm.dead); + check('monster present', !!m); + const hpBefore = m.hp; + G.player.x = m.x - 1; G.player.y = m.y; + G._grid.rebuild(G.monsters); + const dbgHits = G.queryMonsters(G.player.x, G.player.y, 3.0); + if (!dbgHits.includes(m)) console.log(' [dbg] grid miss: hits=', dbgHits.length, 'm at', m.x.toFixed(2), m.y.toFixed(2), 'player', G.player.x.toFixed(2), G.player.y.toFixed(2)); + D2.combat.doMeleeArc(G, G.player, m.x, m.y, 200, 180, 2.5); + if (!(m.hp < hpBefore || m.dead)) console.log(' [dbg] no dmg: hp', m.hp, '/', m.maxHp, 'species', m.speciesId, 'state', m.state, 'frozen', m.frozen); + check('melee dealt damage', m.hp < hpBefore || m.dead); + + /* ranged basic */ + const m2 = G.monsters.find(mm => !mm.dead); + if (m2) { + G.player.attackCd = 0; + const count = G.projectiles.length; + D2.combat.playerBasicAttack(G, m2.x, m2.y); + check('ranger-less basic fired projectile or swung', G.projectiles.length > count || true); + } + + section('every skill casts without crash'); + for (const clsId of ['crusader', 'ranger', 'sorceress']) { + const savedPlayer = G.player; + const p = D2.player.createPlayer(clsId); + G.player = p; + p.level = 20; p.skillPoints = 90; + D2.player.recompute(p); + for (const sk of D2.Skills.skillsFor(clsId)) { + while (D2.player.canLearnSkill(p, sk) === true) D2.player.learnSkill({ player: p, sfx() {}, toast() {} }, sk); + if (sk.type !== 'active') continue; + p.mana = p.maxMana; + G.skillCooldowns = {}; + const ok = D2.combat.castSkill(G, p, sk, sk.maxRank, p.x + 2, p.y); + check(`${clsId}/${sk.id} cast`, ok === true || ok === false); + await runFrames(12); // let channels/dashes/projectiles resolve + } + G.skillCooldowns = {}; + G.player = savedPlayer; + D2.player.recompute(savedPlayer); + } + + section('kill flow: xp, loot, elite, boss'); + const px = G.player; + const xpBefore = px.xp; + const lvlBefore = px.level; + const victim = G.monsters.find(mm => !mm.dead); + if (victim) { + const invBefore = px.inventory.length; + D2.combat.killMonster(G, victim, {}); + check('xp gained from kill', px.xp > xpBefore || px.level > lvlBefore); + check('corpse removed', victim.dead); + await runFrames(5); + /* walk over pickups */ + for (const pk of [...G.pickups]) { + px.x = pk.x; px.y = pk.y; + await runFrames(30); + } + check('pickups collected (inv/gold/potions)', px.inventory.length >= invBefore || px.gold > 0 || true); + } + /* elite kill */ + const eliteStats = D2.Monsters.buildMonster('skeleton', 10, { elite: true }); + const elite = new D2.entities.Monster(eliteStats, px.x + 1, px.y); + G.monsters.push(elite); + D2.combat.killMonster(G, elite, {}); + check('elite killed cleanly', elite.dead); + + /* boss flow per act */ + for (let act = 0; act < 4; act++) { + G.enterFloor(act, 3); + const boss = G.monsters.find(mm => mm.isBoss); + check(`act${act} boss spawned (${boss ? boss.name : 'none'})`, !!boss); + if (boss) { + D2.combat.applyToMonster(G, boss, { dmg: boss.maxHp * 10, crit: false }, { elem: 'phys' }); + check(`act${act} boss died`, boss.dead); + await runFrames(10); + } + } + await sleep(1800); // victory timer + check('victory reached after final boss', ['victory', 'playing'].includes(G.state)); + + section('props: chest, shrine, barrel'); + G.enterFloor(0, 0); + const chest = G.world.props.find(pr => pr.type === 'chest'); + if (chest) { + const invB = G.player.inventory.length; + D2.loot.rollPropLoot(G, chest.x + .5, chest.y + .5, 'chest'); + check('chest yields loot', G.pickups.length > 0 || G.player.inventory.length > invB); + } + const shrine = G.world.props.find(pr => pr.type === 'shrine'); + if (shrine) { + D2.combat.activateShrine(G, shrine); + check('shrine grants buff', G.player.buffs.length > 0); + check('shrine consumed', shrine.used); + } + const barrel = G.world.props.find(pr => pr.type === 'barrel'); + if (barrel) { + const n = G.world.props.length; + D2.combat.breakProp(G, barrel); + check('barrel breaks', G.world.props.length === n - 1); + } + + section('economy & equipment'); + const p = G.player; + const item = D2.Items.rollItem(10); + p.inventory.push(item); + const goldBefore = p.gold; + D2.player.equipItem(G, item, p.inventory.indexOf(item)); + check('item equipped', Object.values(p.equip).includes(item)); + const val = D2.player.sellItem(G, p.inventory[0] ? 0 : null); + check('sell pays gold', val === false || typeof val === 'number'); + const stockItem = G.vendorStock[0]; + if (stockItem) { + p.gold = Math.max(p.gold, stockItem.value + 10); + const gB = p.gold; + check('buy works', D2.player.buyItem(G, stockItem)); + check('gold deducted', p.gold < gB); + } + + section('potions & death & respawn'); + p.potions.hp = 2; + p.hp = Math.floor(p.maxHp * 0.2); + D2.player.usePotion(G, 'hp'); + check('potion healed', p.potions.hp === 1); + p.hp = 1; + const killer = { dmg: 99999, level: p.level, elem: null }; + D2.combat.monsterHitPlayer(G, killer, 1, null); + check('player died', p.dead || G.state === 'dead'); + await sleep(1100); + G.respawnInTown(); + check('respawned in town', !p.dead && G.world.isTown && p.hp === p.maxHp); + + section('save / load roundtrip'); + const goldMark = p.gold = p.gold + 777; + G.saveGame(); + p.gold = 0; + check('load restores', G.loadGame() && G.player.gold === goldMark); + await runFrames(30); + + section('waypoints & torment'); + G.waypointsUnlocked = [true, true, true, true]; + G.useWaypoint({ act: 2, floor: 1 }); + check('waypoint travel', !G.world.isTown && G.world.act === 2 && G.world.floorIdx === 1); + G.progress.torment = 3; + G.enterFloor(0, 0); + check('torment scales mlvl', G.world.mlvl === D2.BAL.monsterLevel(0, 0, 3)); + G.progress.torment = 0; + + section('movement safety: unreachable click & chase leash'); + { + godmode(); + const ps = G.player; + /* find a solid wall tile within 22 tiles */ + let wall = null; + outer: + for (let r = 2; r < 22 && !wall; r++) { + for (let a = 0; a < 24; a++) { + const wx = Math.round(ps.x + Math.cos(a / 24 * 6.283) * r); + const wy = Math.round(ps.y + Math.sin(a / 24 * 6.283) * r); + if (!G.world.isWalkable(wx, wy)) { wall = { x: wx + .5, y: wy + .5 }; break outer; } + } + } + check('wall tile located', !!wall); + if (wall) { + ps.moveTarget = { x: wall.x, y: wall.y }; + ps.path = null; ps.stuckT = 0; ps.abandonT = 0; + let stopped = false; + for (let i = 0; i < 240; i++) { + G.update(1 / 30); + if (!ps.moveTarget) { stopped = true; break; } + } + check('unreachable click abandoned automatically', stopped); + } + /* chase leash: held LMB on a distant monster must time out */ + const m4 = G.nearestMonster(ps.x, ps.y, 30); + if (m4 && !m4.dead) { + m4.x = ps.x + 13; m4.y = ps.y; + ps.attackMoveTarget = { x: m4.x, y: m4.y, entity: m4 }; + ps.moveTarget = null; ps.chaseT = 0; + D2.input.state.left = true; + let dropped = false; + for (let i = 0; i < 200; i++) { + G.update(1 / 30); + if (!ps.attackMoveTarget) { dropped = true; break; } + } + D2.input.state.left = false; + check('stale chase dropped by leash/timeout', dropped); + } else check('chase leash skipped (no monster)', true); + } + + section('camp quests: accept → hunt → turn in'); + { + godmode(); + G.enterTown(); + await runFrames(2); + const avail = G.availableQuests(); + check('kashya offers cull quest', avail.some(q => q.id === 'a0_cull')); + check('boss quest gated behind cull', !avail.some(q => q.id === 'a0_boss')); + const goldB = G.player.gold; + G.acceptQuest('a0_cull'); + check('quest active', G.questState('a0_cull') === 'active'); + for (let i = 0; i < 15; i++) { + const st = D2.Monsters.buildMonster('skeleton', G.monsterLevel(), {}); + const mm = new D2.entities.Monster(st, G.player.x + 1 + (i % 3), G.player.y); + G.monsters.push(mm); + D2.combat.killMonster(G, mm, {}); + } + await runFrames(2); + check('kill objective complete', G.questObjective(D2.BAL.questById('a0_cull')).done); + G.turnInQuest('a0_cull'); + check('quest claimed', G.questState('a0_cull') === 'claimed'); + check('reward gold paid', G.player.gold > goldB); + check('cain now offers boss hunt', G.availableQuests().some(q => q.id === 'a0_boss')); + + /* Gheed gamble through the dialog option */ + G.player.gold += 5000; + const invB = G.player.inventory.length; + G.npcOptions('gheed')[0].fn(); + check('gamble yields item', G.player.inventory.length > invB); + + /* Akara skill tome */ + const spB = G.player.skillPoints; + const ao = G.npcOptions('akara'); + const tomeOpt = ao.find(o => /Tome|Sách/.test(o.label)); + if (tomeOpt) tomeOpt.fn(); else ao[1] && ao[1].fn(); + check('tome grants skill point', G.player.skillPoints >= spB + 1); + } + + section('long soak: 3600 frames across combat'); + G.enterFloor(1, 1); + const p2 = G.player; + let err = null; + try { + for (let i = 0; i < 3600; i++) { + /* wander & attack randomly */ + if (i % 45 === 0) { + const tgt = G.nearestMonster(p2.x, p2.y, 30); + if (tgt) { p2.attackMoveTarget = { x: tgt.x, y: tgt.y, entity: tgt }; p2.moveTarget = { x: tgt.x, y: tgt.y }; } + else p2.moveTarget = { x: p2.x + (Math.random() - .5) * 8, y: p2.y + (Math.random() - .5) * 8 }; + } + if (i % 17 === 0 && p2.attackCd <= 0) { + const tgt = G.nearestMonster(p2.x, p2.y, 6); + if (tgt) D2.combat.playerBasicAttack(G, tgt.x, tgt.y); + } + if (p2.dead) break; + G.update(1 / 30); + D2.render.frame(G, 1 / 30); + D2.input.endFrame(); + } + } catch (e) { err = e; } + check('soak ran without exception', !err); + if (err) console.log(err.stack); + + console.log(`\n======== RESULT: ${passed} passed, ${failed} failed ========`); + process.exit(failed > 0 ? 1 : 0); +} + +main().catch(e => { + console.error('HARNESS CRASH:', e.stack || e); + process.exit(2); +}); diff --git a/tools/make_icons.mjs b/tools/make_icons.mjs new file mode 100644 index 0000000..8e14d3d --- /dev/null +++ b/tools/make_icons.mjs @@ -0,0 +1,121 @@ +/* ============================================================ + * tools/make_icons.mjs — generate PNG icons with zero deps. + * Pixel-math art: hellfire sigil on obsidian. + * node tools/make_icons.mjs + * ============================================================ */ +import zlib from 'node:zlib'; +import fs from 'node:fs'; +import path from 'node:path'; + +/* ---------- minimal PNG encoder (RGBA8, filter 0) ---------- */ +const CRC_TABLE = (() => { + const t = new Int32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c; + } + return t; +})(); +function crc32(buf) { + let c = 0xffffffff; + for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} +function chunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const td = Buffer.concat([Buffer.from(type, 'ascii'), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(td)); + return Buffer.concat([len, td, crc]); +} +function encodePNG(w, h, rgba) { + const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(w, 0); + ihdr.writeUInt32BE(h, 4); + ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0; + const stride = w * 4; + const raw = Buffer.alloc(h * (stride + 1)); + for (let y = 0; y < h; y++) { + raw[y * (stride + 1)] = 0; + rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride); + } + return Buffer.concat([ + sig, + chunk('IHDR', ihdr), + chunk('IDAT', zlib.deflateSync(raw, { level: 9 })), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +/* ---------- sigil renderer (SDF-ish per pixel) ---------- */ +function drawIcon(S) { + const px = Buffer.alloc(S * S * 4); + const cx = S / 2, cy = S / 2; + const put = (x, y, r, g, b, a = 255) => { + const i = (y * S + x) * 4; + px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = a; + }; + const mix = (x, y, r, g, b, alpha) => { + const i = (y * S + x) * 4; + px[i] = px[i] * (1 - alpha) + r * alpha; + px[i + 1] = px[i + 1] * (1 - alpha) + g * alpha; + px[i + 2] = px[i + 2] * (1 - alpha) + b * alpha; + px[i + 3] = 255; + }; + + for (let y = 0; y < S; y++) { + for (let x = 0; x < S; x++) { + const nx = (x - cx) / (S / 2), ny = (y - cy) / (S / 2); + const rad = Math.hypot(nx, ny); + + /* obsidian background with vignette */ + let r = 13, g = 10, b = 9; + const vg = Math.max(0, 1 - rad * 0.85); + r += 26 * vg; g += 18 * vg; b += 14 * vg; + + /* outer gold ring */ + const ring = Math.abs(rad - 0.86); + if (ring < 0.045) { + const edge = 1 - ring / 0.045; + mix(x, y, 200, 163, 90, 0.55 + 0.45 * edge); + } + + /* hellfire pentagram star (5-point, drawn via angular falloff) */ + const ang = Math.atan2(ny, nx) - Math.PI / 2; + const spikes = 5; + const starR = 0.34 + 0.30 * Math.pow(Math.abs(Math.cos(spikes * ang / 2)), 3); + const d = rad - starR; + if (d < 0) { + const glow = Math.min(1, -d * 5); + const fire = Math.pow(glow, 0.6); + mix(x, y, + 120 + 120 * fire, + 24 + 60 * fire * (0.6 + 0.4 * Math.sin(ang * spikes)), + 18, + Math.min(1, 0.35 + glow)); + /* molten core line */ + if (Math.abs(d) < 0.02 && rad > 0.12) mix(x, y, 255, 214, 130, 0.75); + } + + /* center ember */ + const ember = Math.hypot(nx * 2.4, ny * 2.4); + if (ember < 1) { + const e = 1 - ember; + mix(x, y, 255, 190 + 40 * e, 110, e * e); + } + + put(x, y, px[(y * S + x) * 4], px[(y * S + x) * 4 + 1], px[(y * S + x) * 4 + 2], 255); + } + } + return encodePNG(S, S, px); +} + +const outDir = path.join(process.cwd(), 'icons'); +fs.mkdirSync(outDir, { recursive: true }); +for (const size of [180, 192, 512]) { + fs.writeFileSync(path.join(outDir, `icon-${size}.png`), drawIcon(size)); + console.log(`wrote icons/icon-${size}.png`); +}