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
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
# build artifacts
|
||||||
|
diablo2d-itch.zip
|
||||||
|
*.log
|
||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
  
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
+684
@@ -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; }
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
+128
@@ -0,0 +1,128 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||||
|
<title>Diablo2D — Shadows of Tristram</title>
|
||||||
|
<meta name="description" content="A dark isometric action-RPG. Descend procedurally generated dungeons, slay elites and bosses, collect legendary loot. Playable fully offline in your browser.">
|
||||||
|
<meta property="og:title" content="Diablo2D — Shadows of Tristram">
|
||||||
|
<meta property="og:description" content="Dark isometric action-RPG in your browser. Procedural dungeons, elite affixes, legendary loot, 3 classes.">
|
||||||
|
<meta name="theme-color" content="#0b0908">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%230b0908'/%3E%3Cpath d='M32 10 L52 26 L46 54 L18 54 L12 26 Z' fill='%23a32c22'/%3E%3Cpath d='M32 20 L42 28 L38 46 L26 46 L22 28 Z' fill='%23e9c87b'/%3E%3C/svg%3E">
|
||||||
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
|
<link rel="apple-touch-icon" href="icons/icon-180.png">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<canvas id="game-canvas"></canvas>
|
||||||
|
|
||||||
|
<!-- ======== IN-GAME HUD (DOM over canvas) ======== -->
|
||||||
|
<div id="hud" class="hidden">
|
||||||
|
<div id="quest-tracker" class="hud-corner"></div>
|
||||||
|
|
||||||
|
<div id="minimap-box" class="hud-panel">
|
||||||
|
<canvas id="minimap" width="216" height="150"></canvas>
|
||||||
|
<div id="minimap-label"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="buff-bar"></div>
|
||||||
|
|
||||||
|
<div id="bottom-hud">
|
||||||
|
<div id="orb-life" class="orb" title="">
|
||||||
|
<div class="orb-fill life" id="life-fill"></div>
|
||||||
|
<div class="orb-gloss"></div>
|
||||||
|
<div class="orb-text" id="life-text">0/0</div>
|
||||||
|
</div>
|
||||||
|
<div id="center-hud">
|
||||||
|
<div id="xp-bar-wrap"><div id="xp-bar"></div><div id="xp-text"></div></div>
|
||||||
|
<div id="hotbar">
|
||||||
|
<div class="hotbar-group" id="hb-skills"></div>
|
||||||
|
<div class="hotbar-sep"></div>
|
||||||
|
<div class="hotbar-group" id="hb-potions"></div>
|
||||||
|
<div class="hotbar-sep"></div>
|
||||||
|
<div class="hotbar-group small" id="hb-menus"></div>
|
||||||
|
</div>
|
||||||
|
<div id="level-pill"><span id="lvl-num">1</span></div>
|
||||||
|
</div>
|
||||||
|
<div id="orb-mana" class="orb">
|
||||||
|
<div class="orb-fill mana" id="mana-fill"></div>
|
||||||
|
<div class="orb-gloss"></div>
|
||||||
|
<div class="orb-text" id="mana-text">0/0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toasts"></div>
|
||||||
|
<div id="boss-bar-wrap" class="hidden">
|
||||||
|
<div id="boss-name"></div>
|
||||||
|
<div id="boss-bar"><div id="boss-fill"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======== FLOATING PANELS ======== -->
|
||||||
|
<div id="panels"></div>
|
||||||
|
<div id="tooltip" class="hidden"></div>
|
||||||
|
<div id="drag-ghost" class="hidden"></div>
|
||||||
|
|
||||||
|
<!-- ======== FULL SCREENS ======== -->
|
||||||
|
<div id="screens">
|
||||||
|
<div id="screen-loading" class="screen">
|
||||||
|
<div class="loading-inner">
|
||||||
|
<h1 class="game-logo">DIABLO<span>2D</span></h1>
|
||||||
|
<div class="loading-sub">Shadows of Tristram</div>
|
||||||
|
<div class="loading-bar"><div id="loading-progress"></div></div>
|
||||||
|
<div id="loading-tip"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="screen-title" class="screen hidden"></div>
|
||||||
|
<div id="screen-classselect" class="screen hidden"></div>
|
||||||
|
<div id="screen-settings" class="screen hidden"></div>
|
||||||
|
<div id="screen-help" class="screen hidden"></div>
|
||||||
|
<div id="screen-death" class="screen hidden"></div>
|
||||||
|
<div id="screen-victory" class="screen hidden"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======== PAUSE OVERLAY ======== -->
|
||||||
|
<div id="pause-overlay" class="hidden">
|
||||||
|
<div class="pause-menu panel-gothic">
|
||||||
|
<h2 data-i18n="menu.paused">Paused</h2>
|
||||||
|
<button class="btn btn-primary" id="btn-resume" data-i18n="menu.resume">Resume</button>
|
||||||
|
<button class="btn" id="btn-pause-settings" data-i18n="menu.settings">Settings</button>
|
||||||
|
<button class="btn" id="btn-pause-help" data-i18n="menu.help">Help</button>
|
||||||
|
<button class="btn btn-danger" id="btn-save-exit" data-i18n="menu.saveexit">Save & Exit to Title</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Core -->
|
||||||
|
<script src="js/core/util.js"></script>
|
||||||
|
<script src="js/core/i18n.js"></script>
|
||||||
|
<script src="js/core/save.js"></script>
|
||||||
|
<script src="js/core/input.js"></script>
|
||||||
|
<script src="js/core/audio.js"></script>
|
||||||
|
<!-- Data -->
|
||||||
|
<script src="js/data/balance.js"></script>
|
||||||
|
<script src="js/data/items.js"></script>
|
||||||
|
<script src="js/data/monsters.js"></script>
|
||||||
|
<script src="js/data/skills.js"></script>
|
||||||
|
<!-- Simulation -->
|
||||||
|
<script src="js/game/path.js"></script>
|
||||||
|
<script src="js/game/fov.js"></script>
|
||||||
|
<script src="js/game/world.js"></script>
|
||||||
|
<script src="js/game/entities.js"></script>
|
||||||
|
<script src="js/game/ai.js"></script>
|
||||||
|
<script src="js/game/combat.js"></script>
|
||||||
|
<script src="js/game/loot.js"></script>
|
||||||
|
<script src="js/game/player.js"></script>
|
||||||
|
<!-- Presentation -->
|
||||||
|
<script src="js/render/sprites.js"></script>
|
||||||
|
<script src="js/render/render.js"></script>
|
||||||
|
<script src="js/ui/hud.js"></script>
|
||||||
|
<script src="js/ui/panels.js"></script>
|
||||||
|
<script src="js/ui/screens.js"></script>
|
||||||
|
<!-- State machine & bootstrap -->
|
||||||
|
<script src="js/game/game.js"></script>
|
||||||
|
<script src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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);
|
||||||
+303
@@ -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);
|
||||||
@@ -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);
|
||||||
+101
@@ -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);
|
||||||
+134
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
+609
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
+1081
File diff suppressed because it is too large
Load Diff
+149
@@ -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);
|
||||||
+128
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
+184
@@ -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);
|
||||||
+1102
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
+284
@@ -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 = `<span class="keybind">${d.label}</span><canvas width="36" height="36"></canvas><div class="cd-overlay" style="display:none"></div>`;
|
||||||
|
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 = `<span class="keybind">${pd.label}</span><canvas width="36" height="36"></canvas><span class="potion-count">0</span>`;
|
||||||
|
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 = `<span style="font-size:16px">${md.glyph}</span>`;
|
||||||
|
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 = '<div class="quest-header">' + D2.i18n.t('ui.questlog') + '</div>';
|
||||||
|
for (const q of G.quests) {
|
||||||
|
html += `<div class="quest-line ${q.done ? 'quest-done' : ''}">` +
|
||||||
|
`<div>${q.text}</div></div>`;
|
||||||
|
}
|
||||||
|
el.questTracker.innerHTML = html;
|
||||||
|
};
|
||||||
|
|
||||||
|
})(window.D2);
|
||||||
+864
@@ -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(/</g, '<'); }
|
||||||
|
|
||||||
|
ui.statLabel = function (k) {
|
||||||
|
const keys = {
|
||||||
|
str: 'stat.strength', dex: 'stat.dexterity', vit: 'stat.vitality', ene: 'stat.energy',
|
||||||
|
dmgPct: 'stat.damage', critChance: 'stat.crit', critDmgPct: 'stat.critdmg',
|
||||||
|
attackSpeedPct: '+Attack Speed %', moveSpeedPct: 'stat.speed',
|
||||||
|
armor: 'stat.armor', hpFlat: '+Life', manaFlat: '+Mana',
|
||||||
|
hpRegen: '+HP Regen/s', manaRegen: '+Mana Regen/s',
|
||||||
|
lifeOnKill: 'stat.lifelench', magicFind: 'stat.magicfind', goldFind: '% Gold Find',
|
||||||
|
fireDmg: '+Fire Dmg', coldDmg: '+Cold Dmg', litDmg: '+Lightning Dmg', poisDmg: '+Poison Dmg',
|
||||||
|
resFire: 'stat.fire', resCold: 'stat.cold', resLit: 'stat.lightning', resPois: 'stat.poison',
|
||||||
|
cdr: '-Cooldowns %', thorns: 'Thorns', blockChance: '% Block', flatDmg: '+Flat Dmg',
|
||||||
|
};
|
||||||
|
return keys[k] ? D2.i18n.t(keys[k]) : k;
|
||||||
|
};
|
||||||
|
|
||||||
|
ui.itemTooltipHtml = function (item, opts = {}) {
|
||||||
|
const rarCol = D2.Items.RARITIES[item.rarity].color;
|
||||||
|
let h = `<div class="tt-title" style="color:${rarCol}">${esc(item.name)}</div>`;
|
||||||
|
h += `<div class="tt-type">${esc(D2.i18n.t('ui.equip.' + item.slot))} · ${esc(item.rarity)} · ilvl ${item.ilvl}</div>`;
|
||||||
|
if (item.dmgMax) {
|
||||||
|
h += `<div class="tt-value">${item.dmgMin}–${item.dmgMax} damage · ${item.aps.toFixed(2)}/s</div>`;
|
||||||
|
}
|
||||||
|
if (item.armor) h += `<div class="tt-value">${item.armor} armor</div>`;
|
||||||
|
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 => `<div class="tt-affix">${l}</div>`).join('');
|
||||||
|
if (item.special) {
|
||||||
|
const sp = SPECIAL_TEXT[item.special];
|
||||||
|
if (sp) h += `<div class="tt-affix" style="color:${rarCol}">★ ${sp[D2.i18n.getLang()] || sp.en}</div>`;
|
||||||
|
}
|
||||||
|
if (item.reqLevel > 1) h += `<div class="tt-type">Requires level ${item.reqLevel}</div>`;
|
||||||
|
if (item.flavor) h += `<div class="tt-flavor">“${esc(item.flavor)}”</div>`;
|
||||||
|
|
||||||
|
/* 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 += `<div class="tt-compare">Equipped: ${esc(cur.name)} ` +
|
||||||
|
`<span class="${diff >= 0 ? 'tt-up' : 'tt-down'}">(${diff >= 0 ? '+' : ''}${Math.round(diff)})</span></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h += `<div class="tt-value" style="color:#c8a35a">${item.value} gold</div>`;
|
||||||
|
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 = `
|
||||||
|
<div class="fpanel-head">
|
||||||
|
<div class="panel-title">${D2.i18n.t(titleKey)}</div>
|
||||||
|
<div class="fpanel-close">✕</div>
|
||||||
|
</div>
|
||||||
|
<div class="fpanel-body"></div>`;
|
||||||
|
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 = `
|
||||||
|
<span class="stat-name">${D2.i18n.t(label)} ${canAlloc ? '' : ''}</span>
|
||||||
|
<span><span class="stat-val">${s[key]}</span>${canAlloc ? '<span class="plus-btn" data-k="' + key + '">+</span>' : ''}</span>`;
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
const pts = document.createElement('div');
|
||||||
|
pts.className = 'stat-row';
|
||||||
|
pts.innerHTML = `<span style="color:var(--gold)">${D2.i18n.t('stat.points')}</span><span class="stat-val">${p.statPoints}</span>`;
|
||||||
|
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 = `<span>${D2.i18n.t(k)}</span><span class="stat-val">${v}</span>`;
|
||||||
|
list2.appendChild(r);
|
||||||
|
}
|
||||||
|
b.appendChild(list2);
|
||||||
|
|
||||||
|
/* resist strip */
|
||||||
|
const rs = document.createElement('div');
|
||||||
|
rs.className = 'resist-strip';
|
||||||
|
rs.innerHTML = `
|
||||||
|
<span>🔥<b>${Math.min(72, s.resFire || 0)}%</b></span>
|
||||||
|
<span>❄️<b>${Math.min(72, s.resCold || 0)}%</b></span>
|
||||||
|
<span>⚡<b>${Math.min(72, s.resLit || 0)}%</b></span>
|
||||||
|
<span>☠️<b>${Math.min(72, s.resPois || 0)}%</b></span>`;
|
||||||
|
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 = `<span>${cls.name[D2.i18n.getLang()] || cls.name.en}</span>
|
||||||
|
<span>${D2.i18n.t('stat.skillpoints')}: <b>${p.skillPoints}</b></span>`;
|
||||||
|
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 = `<div class="skill-branch-name">${br.name[D2.i18n.getLang()] || br.name.en}</div>`;
|
||||||
|
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 = `<div class="tt-title" style="color:var(--gold-hi)">${sk.name[D2.i18n.getLang()] || sk.name.en}</div>`;
|
||||||
|
h += `<div class="tt-type">${sk.type} · ${D2.i18n.t('level_short')} ${sk.unlockLevel}+</div>`;
|
||||||
|
if (rank <= 0) {
|
||||||
|
h += `<div class="tt-simple" style="color:#93876f">${d[D2.i18n.getLang()] || d.en}</div>`;
|
||||||
|
} else {
|
||||||
|
h += `<div class="tt-simple">${d[D2.i18n.getLang()] || d.en}</div>`;
|
||||||
|
}
|
||||||
|
if (sk.type === 'active') {
|
||||||
|
h += `<div class="tt-compare">Mana: ${sk.manaCost(Math.max(1, rank))} · CD: ${sk.cooldown}s</div>`;
|
||||||
|
}
|
||||||
|
if (!unlocked) h += `<div class="tt-compare" style="color:#e07a6a">Locked — requires level ${sk.unlockLevel}</div>`;
|
||||||
|
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 = `<b style="color:var(--gold-hi)">${sk.name[D2.i18n.getLang()] || sk.name.en}</b> ` +
|
||||||
|
`<span style="color:var(--gold)">(${rank}/${sk.maxRank})</span><br>` +
|
||||||
|
(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 = `<div style="color:var(--gold);font-size:12px;letter-spacing:1px;text-transform:uppercase">${q.header}</div>
|
||||||
|
<div class="${q.done ? 'quest-done' : ''}" style="font-size:14px;margin-top:3px">${q.text} ${q.done ? '✔' : ''}</div>`;
|
||||||
|
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 = `<span>${k}</span><span class="stat-val">${v}</span>`;
|
||||||
|
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 = `<div class="dialog-text">“${esc(D2.i18n.t(greetKeys[npcType] || 'npc.stash.greet'))}”</div>`;
|
||||||
|
|
||||||
|
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 = `<div class="wm-icon">${icon}</div><div>${label}</div><div class="wm-depth">${sub}</div>`;
|
||||||
|
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);
|
||||||
@@ -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 = `
|
||||||
|
<div class="screen-center-col">
|
||||||
|
<h1 class="game-logo">DIABLO<span>2D</span></h1>
|
||||||
|
<div class="loading-sub">${D2.i18n.t('title.tagline')}</div>
|
||||||
|
<div class="title-menu">
|
||||||
|
<button class="btn btn-primary" id="t-new">${D2.i18n.t('title.newgame')}</button>
|
||||||
|
<button class="btn" id="t-continue" ${meta ? '' : 'disabled'}>${D2.i18n.t('title.continue')}</button>
|
||||||
|
<button class="btn" id="t-settings">${D2.i18n.t('title.settings')}</button>
|
||||||
|
<button class="btn" id="t-help">${D2.i18n.t('title.help')}</button>
|
||||||
|
</div>
|
||||||
|
${meta ? `<div class="title-savecard">${D2.i18n.t('title.saved_hero')}: <b>${D2.i18n.t('class.' + meta.classId + '.name')}</b> · ${D2.i18n.t('level_short')} ${meta.level}${meta.torment ? ' · ' + D2.i18n.t('torment', meta.torment) : ''}${meta.hardcore ? ' · ☠ Hardcore' : ''}</div>` : ''}
|
||||||
|
<div class="title-footer">v1.0 — runs fully offline in your browser</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="screen-center-col">
|
||||||
|
<h2 style="letter-spacing:6px;color:var(--gold);text-transform:uppercase;font-weight:normal">${D2.i18n.t('class.select')}</h2>
|
||||||
|
<div class="class-grid"></div>
|
||||||
|
<div style="display:flex;gap:12px">
|
||||||
|
<button class="btn btn-primary" id="cs-play" disabled>${D2.i18n.t('class.play')}</button>
|
||||||
|
<button class="btn" id="cs-back">${D2.i18n.t('back')}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
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 = `
|
||||||
|
<canvas width="150" height="150"></canvas>
|
||||||
|
<h3>${D2.i18n.t('class.' + clsId + '.name')}</h3>
|
||||||
|
<p>${D2.i18n.t(cls.descKey)}</p>
|
||||||
|
<div class="class-stats">
|
||||||
|
<span>⚔ ${cls.baseMods.str} STR</span>
|
||||||
|
<span>🏹 ${cls.baseMods.dex} DEX</span>
|
||||||
|
<span>❤ ${cls.baseMods.vit} VIT</span>
|
||||||
|
<span>✦ ${cls.baseMods.ene} ENE</span>
|
||||||
|
</div>`;
|
||||||
|
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 = `
|
||||||
|
<div class="panel-gothic settings-panel">
|
||||||
|
<div class="panel-title">${D2.i18n.t('title.settings')}</div>
|
||||||
|
<div class="settings-body">
|
||||||
|
<div class="setting-row"><label>${D2.i18n.t('set.master')}</label><input type="range" id="s-master" min="0" max="1" step="0.05" value="${s.masterVol}"></div>
|
||||||
|
<div class="setting-row"><label>${D2.i18n.t('set.music')}</label><input type="range" id="s-music" min="0" max="1" step="0.05" value="${s.musicVol}"></div>
|
||||||
|
<div class="setting-row"><label>${D2.i18n.t('set.sfx')}</label><input type="range" id="s-sfx" min="0" max="1" step="0.05" value="${s.sfxVol}"></div>
|
||||||
|
<div class="setting-row"><label>${D2.i18n.t('set.lang')}</label>
|
||||||
|
<select class="gothiselect" id="s-lang">
|
||||||
|
<option value="en" ${s.lang === 'en' ? 'selected' : ''}>English</option>
|
||||||
|
<option value="vi" ${s.lang === 'vi' ? 'selected' : ''}>Tiếng Việt</option>
|
||||||
|
</select></div>
|
||||||
|
<div class="setting-row"><label>${D2.i18n.t('set.shake')}</label><div class="toggle-switch ${s.screenShake ? 'on' : ''}" id="s-shake"></div></div>
|
||||||
|
<div class="setting-row"><label>${D2.i18n.t('set.dmgnum')}</label><div class="toggle-switch ${s.dmgNumbers ? 'on' : ''}" id="s-dmg"></div></div>
|
||||||
|
<div class="setting-row"><label>${D2.i18n.t('set.labels')}</label>
|
||||||
|
<select class="gothiselect" id="s-labels">
|
||||||
|
<option value="alt" ${s.labels === 'alt' ? 'selected' : ''}>Alt</option>
|
||||||
|
<option value="always" ${s.labels === 'always' ? 'selected' : ''}>${D2.i18n.t('yes')}</option>
|
||||||
|
</select></div>
|
||||||
|
<div class="setting-row" style="border-top:1px solid var(--border);padding-top:10px">
|
||||||
|
<label>${D2.i18n.t('set.export')}</label><button class="btn" id="s-export" style="font-size:12px;padding:4px 12px">⧉</button></div>
|
||||||
|
<textarea class="savecode" id="s-code" placeholder="paste save code here…"></textarea>
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||||
|
<button class="btn" id="s-import" style="font-size:12px">${D2.i18n.t('set.import')}</button>
|
||||||
|
<button class="btn btn-danger" id="s-wipe" style="font-size:12px">${D2.i18n.t('set.wipe')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center;padding:6px 0 14px">
|
||||||
|
<button class="btn btn-primary" id="s-back">${D2.i18n.t('back')}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="panel-gothic help-panel">
|
||||||
|
<div class="panel-title">${D2.i18n.t('ui.help')}</div>
|
||||||
|
<div class="help-body">
|
||||||
|
<h4>${D2.i18n.t('help.keys_title')}</h4>
|
||||||
|
<div class="help-keys">
|
||||||
|
<span><span class="key-cap">LMB</span></span><span>${D2.i18n.t('help.move')}</span>
|
||||||
|
<span><span class="key-cap">RMB</span> <span class="key-cap">1</span>–<span class="key-cap">3</span></span><span>${D2.i18n.t('help.combat')}</span>
|
||||||
|
<span><span class="key-cap">Q</span> <span class="key-cap">E</span></span><span>${D2.i18n.t('hb.health_potion')} / ${D2.i18n.t('hb.mana_potion')}</span>
|
||||||
|
<span><span class="key-cap">F</span>/<span class="key-cap">Space</span></span><span>Interact — stairs, chests, shrines, NPCs</span>
|
||||||
|
<span><span class="key-cap">Alt</span></span><span>${D2.i18n.t('help.items')}</span>
|
||||||
|
<span><span class="key-cap">I</span> <span class="key-cap">C</span> <span class="key-cap">T</span> <span class="key-cap">J</span> <span class="key-cap">M</span></span><span>${D2.i18n.t('help.panels')}</span>
|
||||||
|
</div>
|
||||||
|
<h4>Tips</h4>
|
||||||
|
<div>${D2.i18n.t('help.tip1')}<br>${D2.i18n.t('help.tip2')}<br>${D2.i18n.t('help.tip3')}</div>
|
||||||
|
<h4>Credits</h4>
|
||||||
|
<div style="color:var(--text-dim)">${D2.i18n.t('credits.line1')}<br>${D2.i18n.t('credits.line2')}</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center;padding:6px 0 14px">
|
||||||
|
<button class="btn btn-primary" id="h-back">${D2.i18n.t('back')}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
scr.querySelector('#h-back').addEventListener('click', () => onBack());
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ================= death ================= */
|
||||||
|
|
||||||
|
ui.showDeath = function () {
|
||||||
|
show('screen-death');
|
||||||
|
const scr = $('screen-death');
|
||||||
|
scr.innerHTML = `
|
||||||
|
<div class="screen-center-col">
|
||||||
|
<div class="death-title">${D2.i18n.t('death.title')}</div>
|
||||||
|
<div class="screen-subtitle">${D2.i18n.t('death.subtitle')}</div>
|
||||||
|
<div style="display:flex;gap:14px;margin-top:30px">
|
||||||
|
<button class="btn btn-primary" id="d-respawn">${D2.i18n.t('death.respawn')}</button>
|
||||||
|
<button class="btn" id="d-title">${D2.i18n.t('death.title_screen')}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
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 = `
|
||||||
|
<div class="screen-center-col">
|
||||||
|
<div class="victory-title">${D2.i18n.t('victory.title')}</div>
|
||||||
|
<div class="screen-subtitle">${D2.i18n.t('victory.subtitle')}</div>
|
||||||
|
<div class="victory-stats">
|
||||||
|
${D2.i18n.t('victory.stats')}<br>
|
||||||
|
<b>${D2.i18n.t('level_short')} ${p.level}</b> · ${p.kills} kills · ${p.eliteKills} elites · ${p.bossKills} bosses · ${p.deaths} deaths
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:14px;margin-top:26px">
|
||||||
|
${G.progress.torment < D2.BAL.maxTorment ? `<button class="btn btn-primary" id="v-torment">${D2.i18n.t('victory.continue', G.progress.torment + 1)}</button>` : ''}
|
||||||
|
<button class="btn" id="v-title">${D2.i18n.t('victory.title_screen')}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
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);
|
||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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}`));
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"><title>Drive</title></head>
|
||||||
|
<body style="margin:0;background:#000">
|
||||||
|
<iframe id="g" src="/index.html" style="width:1440px;height:900px;border:0"></iframe>
|
||||||
|
<pre id="status" style="color:#0f0;font-size:18px;white-space:pre-wrap">booting…</pre>
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
/* ============================================================
|
||||||
|
* Diablo2D E2E driver.
|
||||||
|
* ?phase=full — everything incl. rendering probes (short)
|
||||||
|
* ?phase=descend — boot→travel→walk to stairs→KeyF→floor+1
|
||||||
|
* &norender=1 — pump skips draw calls (long sessions)
|
||||||
|
* ============================================================ */
|
||||||
|
const PHASE = new URLSearchParams(location.search).get('phase') || 'full';
|
||||||
|
const NORENDER = new URLSearchParams(location.search).get('norender') === '1';
|
||||||
|
const MAXFRAMES = parseInt(new URLSearchParams(location.search).get('maxframes') || '0', 10);
|
||||||
|
let drawnFrames = 0;
|
||||||
|
const MUTE = new URLSearchParams(location.search).get('mute') === '1';
|
||||||
|
|
||||||
|
const S = [];
|
||||||
|
function log(m) { S.push(m); document.getElementById('status').textContent = S.join('\n'); }
|
||||||
|
const frame = document.getElementById('g');
|
||||||
|
frame.addEventListener('load', () => setTimeout(run, 1500));
|
||||||
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
let W, D, cv, cvRect;
|
||||||
|
const TW = 64, THh = 32;
|
||||||
|
|
||||||
|
function q(sel) { return frame.contentDocument.querySelector(sel); }
|
||||||
|
function qa(sel) { return [...frame.contentDocument.querySelectorAll(sel)]; }
|
||||||
|
function click(el, btn = 0) {
|
||||||
|
if (!el) return false;
|
||||||
|
const o = { bubbles: true, button: btn };
|
||||||
|
el.dispatchEvent(new W.MouseEvent('mousedown', o));
|
||||||
|
el.dispatchEvent(new W.MouseEvent('mouseup', o));
|
||||||
|
el.dispatchEvent(new W.MouseEvent('click', o));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function canvasClick(x, y, btn = 0) {
|
||||||
|
const o = { bubbles: true, button: btn, clientX: x, clientY: y };
|
||||||
|
cv.dispatchEvent(new W.MouseEvent('mousemove', o));
|
||||||
|
cv.dispatchEvent(new W.MouseEvent('mousedown', o));
|
||||||
|
W.dispatchEvent(new W.MouseEvent('mouseup', o)); // iframe window!
|
||||||
|
}
|
||||||
|
function toScreen(wx, wy) {
|
||||||
|
const cam = D.render.cam, z = cam.zoom || 1;
|
||||||
|
const dx = wx - cam.x, dy = wy - cam.y;
|
||||||
|
return {
|
||||||
|
x: cvRect.left + cv.clientWidth / 2 + (dx - dy) * TW / 2 * z,
|
||||||
|
y: cvRect.top + cv.clientHeight / 2 + (dx + dy) * THh / 2 * z,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureLoop() {
|
||||||
|
let frames = 0;
|
||||||
|
const orig = D.render.frame.bind(D.render);
|
||||||
|
D.render.frame = (g, dt) => { frames++; return orig(g, dt); };
|
||||||
|
await sleep(400);
|
||||||
|
const rafAlive = frames > 3;
|
||||||
|
if (!rafAlive) {
|
||||||
|
setInterval(() => {
|
||||||
|
try {
|
||||||
|
const dt = 1 / 30;
|
||||||
|
D.ui.tickGlobalKeys();
|
||||||
|
if (D.game.state === 'playing') {
|
||||||
|
D.game.update(dt);
|
||||||
|
const allowDraw = !NORENDER && (!MAXFRAMES || drawnFrames < MAXFRAMES);
|
||||||
|
if (allowDraw) { D.render.frame(D.game, dt); drawnFrames++; }
|
||||||
|
else if (MAXFRAMES && drawnFrames === MAXFRAMES) { drawnFrames++; log('render capped @' + MAXFRAMES); }
|
||||||
|
D.ui.tick(dt);
|
||||||
|
}
|
||||||
|
D.input.endFrame();
|
||||||
|
} catch (e) { log('PUMP_ERROR ' + e.message); }
|
||||||
|
}, 33);
|
||||||
|
}
|
||||||
|
return rafAlive;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootThroughTown(cls = 'crusader') {
|
||||||
|
click(q('#t-new'));
|
||||||
|
await sleep(300);
|
||||||
|
log('classselect=' + !!q('.class-card'));
|
||||||
|
click(qa('.class-card')[0]);
|
||||||
|
await sleep(250);
|
||||||
|
click(q('#cs-play'));
|
||||||
|
await sleep(1200);
|
||||||
|
log('state=' + D.game.state + ' town=' + !!D.game.world.isTown);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function travelAct1Floor1() {
|
||||||
|
D.input.injectPress('KeyM');
|
||||||
|
await sleep(350);
|
||||||
|
const nodes = qa('.wm-node');
|
||||||
|
log('wmNodes=' + nodes.length);
|
||||||
|
click(nodes[1]); // cathedral node -> waypoint travel
|
||||||
|
await sleep(2200);
|
||||||
|
qa('.fpanel:not(.hidden) .fpanel-close').forEach(b => click(b));
|
||||||
|
await sleep(250);
|
||||||
|
log('travel act=' + D.game.world.act + ' f=' + D.game.world.floorIdx +
|
||||||
|
' mons=' + D.game.monsters.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDescend() {
|
||||||
|
const pl = D.game.player;
|
||||||
|
const sd = D.game.world.stairsDown;
|
||||||
|
if (!sd || D.game.world.isTown) { log('no stairs here'); return; }
|
||||||
|
pl.maxHp = 1e7; pl.hp = 1e7;
|
||||||
|
let pressed = 0;
|
||||||
|
for (let i = 0; i < 1600; i++) {
|
||||||
|
if (D.game.world.floorIdx >= 1 || D.game.world.isTown) break;
|
||||||
|
const d = Math.hypot(pl.x - sd.x - .5, pl.y - sd.y - .5);
|
||||||
|
if (d < 2.4 && i % 8 === 0) { D.input.injectPress('KeyF'); pressed++; }
|
||||||
|
if (!pl.moveTarget || i % 30 === 0) pl.moveTarget = { x: sd.x + .5, y: sd.y + .5 };
|
||||||
|
if (i % 400 === 399) log(' d=' + d.toFixed(2) + ' left=' + D.input.state.left + ' fPresses=' + pressed);
|
||||||
|
await sleep(33);
|
||||||
|
}
|
||||||
|
log('descended f=' + D.game.world.floorIdx + ' presses=' + pressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function combatAndLoot() {
|
||||||
|
/* bind cleave to RMB (learned at char creation) */
|
||||||
|
const pl0 = D.game.player;
|
||||||
|
const learned = Object.keys(pl0.skills).filter(k => pl0.skills[k] > 0);
|
||||||
|
pl0.hotbar[1] = learned[0] || null;
|
||||||
|
log('rmbSkill=' + pl0.hotbar[1]);
|
||||||
|
|
||||||
|
let casts = 0;
|
||||||
|
const origCast = D.combat.castSkill.bind(D.combat);
|
||||||
|
D.combat.castSkill = (...a) => { casts++; return origCast(...a); };
|
||||||
|
|
||||||
|
let target = null;
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
if (!target || target.dead) {
|
||||||
|
target = D.game.nearestMonster(D.game.player.x, D.game.player.y, 24);
|
||||||
|
if (!target) break;
|
||||||
|
}
|
||||||
|
const ang = Math.atan2(D.game.player.y - target.y, D.game.player.x - target.x);
|
||||||
|
D.game.player.x = target.x + Math.cos(ang) * 1.05;
|
||||||
|
D.game.player.y = target.y + Math.sin(ang) * 0.63;
|
||||||
|
D.game.player.moveTarget = null;
|
||||||
|
await sleep(50);
|
||||||
|
const mp = toScreen(target.x, target.y);
|
||||||
|
canvasClick(mp.x, mp.y, 2);
|
||||||
|
await sleep(300);
|
||||||
|
}
|
||||||
|
const p = D.game.player;
|
||||||
|
log('casts=' + casts + ' kills=' + p.kills + ' xp=' + p.xp +
|
||||||
|
' inv=' + p.inventory.length + ' gold=' + p.gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pixelProof() {
|
||||||
|
try { D.render.frame(D.game, 1 / 30); } catch (e) { log('renderErr ' + e.message); return; }
|
||||||
|
/* camera settle check */
|
||||||
|
const cam = D.render.cam;
|
||||||
|
const p = D.game.player;
|
||||||
|
log('camDelta=' + (Math.abs(cam.x - p.x) + Math.abs(cam.y - p.y)).toFixed(2));
|
||||||
|
|
||||||
|
/* authoritative: read straight from the game's backbuffer */
|
||||||
|
const cx = cv.width >> 1, cy = cv.height >> 1;
|
||||||
|
const pts = [[0, 0], [-120, -80], [120, 80], [-200, 0], [200, 0]].map(([dx, dy]) =>
|
||||||
|
D.render.debugPixel(cx + dx, cy + dy));
|
||||||
|
log('backbuffer ' + JSON.stringify(pts));
|
||||||
|
|
||||||
|
/* cross-check via canvas copy */
|
||||||
|
const off = frame.contentDocument.createElement('canvas');
|
||||||
|
off.width = 80; off.height = 80;
|
||||||
|
const oc = off.getContext('2d');
|
||||||
|
oc.drawImage(cv, cv.width / 2 - 200, cv.height / 2 - 150, 400, 300, 0, 0, 80, 80);
|
||||||
|
const data = oc.getImageData(0, 0, 80, 80).data;
|
||||||
|
let lit = 0, colors = new Set(), maxB = 0;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const b = data[i] + data[i + 1] + data[i + 2];
|
||||||
|
if (b > 60) lit++;
|
||||||
|
if (b > maxB) maxB = b;
|
||||||
|
colors.add((data[i] >> 4) + ',' + (data[i + 1] >> 4) + ',' + (data[i + 2] >> 4));
|
||||||
|
}
|
||||||
|
log('canvasCopy lit=' + Math.round(lit / (data.length / 4) * 100) + '% colors=' + colors.size + ' maxB=' + maxB);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
W = frame.contentWindow;
|
||||||
|
D = W.D2;
|
||||||
|
frame.contentWindow.onerror = m => log('PAGE_ERR ' + m);
|
||||||
|
cv = q('#game-canvas');
|
||||||
|
cvRect = cv.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (MUTE) {
|
||||||
|
try {
|
||||||
|
D.audio.sfx = () => {};
|
||||||
|
D.audio.playMusic = () => {};
|
||||||
|
D.audio.stopMusic = () => {};
|
||||||
|
D.audio.unlock = () => {};
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
const raf = await ensureLoop();
|
||||||
|
log('raf=' + raf + ' norender=' + NORENDER + ' mute=' + MUTE);
|
||||||
|
|
||||||
|
await bootThroughTown();
|
||||||
|
|
||||||
|
if (PHASE === 'full') {
|
||||||
|
/* movement */
|
||||||
|
const p0 = { x: D.game.player.x, y: D.game.player.y };
|
||||||
|
const sp = toScreen(p0.x + 3, p0.y + 1);
|
||||||
|
canvasClick(sp.x, sp.y);
|
||||||
|
await sleep(1400);
|
||||||
|
log('moved=' + Math.hypot(D.game.player.x - p0.x, D.game.player.y - p0.y).toFixed(2));
|
||||||
|
/* panels */
|
||||||
|
for (const [code, id] of [['KeyI', '#panel-inventory'], ['KeyC', '#panel-character'], ['KeyT', '#panel-skills'], ['KeyM', '#panel-worldmap']]) {
|
||||||
|
D.input.injectPress(code);
|
||||||
|
await sleep(320);
|
||||||
|
const open = !q(id).classList.contains('hidden');
|
||||||
|
if (!open) log('PANEL_FAIL ' + code);
|
||||||
|
click(q(id + ' .fpanel-close'));
|
||||||
|
await sleep(140);
|
||||||
|
}
|
||||||
|
log('panels ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
await travelAct1Floor1();
|
||||||
|
|
||||||
|
if (PHASE === 'full') {
|
||||||
|
await combatAndLoot();
|
||||||
|
await sleep(900); // camera settle before sampling
|
||||||
|
pixelProof();
|
||||||
|
/* save/load */
|
||||||
|
D.game.saveGame();
|
||||||
|
const g0 = D.game.player.gold;
|
||||||
|
D.game.player.gold += 555;
|
||||||
|
const ok = D.game.loadGame() && D.game.player.gold === g0 && D.game.player.hp === D.game.player.maxHp;
|
||||||
|
log('save/load=' + ok);
|
||||||
|
} else {
|
||||||
|
await doDescend();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fin = D.game.player;
|
||||||
|
log('DONE f=' + D.game.world.floorIdx + ' lvl=' + fin.level + ' kills=' + fin.kills +
|
||||||
|
' hp=' + Math.round(fin.hp) + '/' + fin.maxHp + ' errors=none');
|
||||||
|
} catch (e) {
|
||||||
|
log('DRIVE_ERROR: ' + (e.stack || e.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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`);
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user