commit fc1fa2d51e0d7d05bf27b7ab8eff18ff4fedb03a Author: deepseek Date: Sun Aug 23 06:59:36 2026 +0000 Diablo2D — Shadows of Tristram: complete browser ARPG - 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 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 0000000..03417c2 Binary files /dev/null and b/icons/icon-180.png differ diff --git a/icons/icon-192.png b/icons/icon-192.png new file mode 100644 index 0000000..5dae5c3 Binary files /dev/null and b/icons/icon-192.png differ diff --git a/icons/icon-512.png b/icons/icon-512.png new file mode 100644 index 0000000..5fb548d Binary files /dev/null and b/icons/icon-512.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..a92025d --- /dev/null +++ b/index.html @@ -0,0 +1,128 @@ + + + + + +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`); +}