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:
2026-08-23 06:59:36 +00:00
commit fc1fa2d51e
42 changed files with 11784 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# build artifacts
diablo2d-itch.zip
*.log
node_modules/
.DS_Store
+27
View File
@@ -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.
+131
View File
@@ -0,0 +1,131 @@
# Diablo2D — Shadows of Tristram
A dark, isometric **action-RPG that runs entirely in your browser** — no build step,
no server, no assets to download. Every tile, creature, spell effect, sound and
music track is generated procedurally in code. Inspired by classic Diablo and
modern browser ARPGs like Sephiria.
![class](https://img.shields.io/badge/classes-3-gold) ![acts](https://img.shields.io/badge/acts-4-red) ![offline](https://img.shields.io/badge/offline-100%25-green)
---
## Play
**Instant:** serve the folder with any static file server and open it.
```bash
cd diablo2d
python3 -m http.server 8080 # or: node server.js 8080
# open http://localhost:8080
```
It also runs straight from `file://` (double-click `index.html`) and can be
zipped as-is for **itch.io** (see *Publishing* below).
> Progress autosaves to `localStorage` every 90 seconds, on floor change,
> and when the tab closes. Export/import your hero as a code from Settings.
## Features
- **3 classes** — Crusader, Ranger, Sorceress — 20 hand-tuned skills across
3 talent branches each (actives + passives, 6-slot action bar).
- **4 acts × 4 floors** of procedurally generated dungeons:
room-and-corridor crypts, cellular cave systems, and a boss lair per act
(The Flesh Butcher, The Bone King, Aranea the Spider Queen, Malgor the
Lord of Terror) with multi-phase scripted attacks.
- **Diablo-style loot**: 40+ item bases, 4 rarities, ~30 affixes with
item-level tiers, 14 build-defining legendaries (Cleave Echo, Corpse Boom,
Voidstep…), vendor with rerolling stock, gamble-free sell/buy economy,
and a shared stash.
- **Elite monsters** with 10 random affixes (Frozen, Jailer, Necrotic…),
champion packs, and torment levels IX 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, 13** | 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
View File
@@ -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
View File
@@ -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 &amp; 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>
+438
View File
@@ -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
View File
@@ -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 14 — 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 14 — 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);
+124
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+142
View File
@@ -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);
+410
View File
@@ -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);
+258
View File
@@ -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);
+227
View File
@@ -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
View File
@@ -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);
+841
View File
@@ -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);
+178
View File
@@ -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);
+45
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+149
View File
@@ -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
View File
@@ -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);
+327
View File
@@ -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);
+513
View File
@@ -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 spawnstairs 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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+769
View File
@@ -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
View File
@@ -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
View File
@@ -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, '&lt;'); }
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);
+360
View File
@@ -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);
+14
View File
@@ -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" }
]
}
+37
View File
@@ -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}`));
+246
View File
@@ -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>
+76
View File
@@ -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`);
+82
View File
@@ -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();
+77
View File
@@ -0,0 +1,77 @@
/* Measures clickdestination 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();
+62
View File
@@ -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);
+46
View File
@@ -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();
+477
View File
@@ -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);
});
+121
View File
@@ -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`);
}