refactor(scene): separate pure backend simulation from frontend rendering and modularize act-scene & net-scene

- Decompose src/scene/act-scene.ts (10,757 -> 824 lines) and src/scene/net-scene.ts (800 -> 599 lines) into 18 cohesive modules.
- Extract 6 zero-DOM backend simulation modules under src/scene/backend/:
  - scene-links-nav.ts, scene-population.ts, weapon-combat.ts, skill-caster.ts, chat-commands.ts, net-simulation.ts
- Extract 12 client presentation/rendering/DOM/HUD modules under src/scene/frontend/:
  - scene-state.ts, lighting-presets.ts, object-animation.ts, pack-loader.ts, entity-art-loader.ts, missile-overlay-renderer.ts, ground-item-renderer.ts, world-renderer.ts, overhead-labels.ts, mouse-controller.ts, toolbar-chat-ui.ts, arena-overlay.ts
- Preserve 100% backward-compatible barrel exports and source-text test sentinels.
This commit is contained in:
troytt 2026-09-29 04:24:09 +00:00
parent 9077b5889d
commit e475c2e6d8
20 changed files with 11592 additions and 10485 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,77 @@
/**
* Processes chat and console commands for ActScene (e.g. "/players <1-8>").
*
* Ground truth rules (1.13c parity):
* - Accepts "/players N" or "players N" where N is an integer between 1 and 8.
* - Calling without a number queries the current setting.
* - Out-of-range numbers display an error notification.
*/
export interface ChatCommandEngine {
setPlayers: (players: number, partyPlayers?: number) => void
gamePlayers: number
}
export interface SceneChatCommandResult {
readonly handled: boolean
readonly message: string | null
}
export type ChatCommandOutcome = SceneChatCommandResult
/**
* Pure simulation helper for scene chat commands (`/players <1-8>`).
*/
export function executeSceneChatCommand(
rawCommand: string,
engine: ChatCommandEngine,
): SceneChatCommandResult {
const trimmed = rawCommand.trim()
const match = trimmed.match(/^\/?players(?:\s+(\d+))?$/i)
if (match) {
if (match[1] !== undefined) {
const n = parseInt(match[1], 10)
if (n >= 1 && n <= 8) {
engine.setPlayers(n)
return {
handled: true,
message: `Players set to ${engine.gamePlayers}`,
}
}
return {
handled: false,
message: `Invalid players count: ${n}. Range is 1-8.`,
}
}
return {
handled: true,
message: `Players currently set to ${engine.gamePlayers}`,
}
}
return { handled: false, message: null }
}
/**
* Processes chat and console commands for ActScene (e.g. "/players <1-8>").
*
* Ground truth rules (1.13c parity):
* - Accepts "/players N" or "players N" where N is an integer between 1 and 8.
* - Calling without a number queries the current setting.
* - Out-of-range numbers display an error notification.
*
* @param rawCommand - The input command string.
* @param engine - GameEngine instance.
* @param statusEl - Optional status notification sink.
* @returns True if command was recognized and handled; false otherwise.
*/
export function handleActSceneChatCommand(
rawCommand: string,
engine: { setPlayers: (players: number, partyPlayers?: number) => void; gamePlayers: number },
statusEl?: { textContent: string | null } | null,
): boolean {
const res = executeSceneChatCommand(rawCommand, engine)
if (res.message !== null && statusEl) {
statusEl.textContent = res.message
}
return res.handled
}

View File

@ -0,0 +1,212 @@
import type { SpriteSheet } from '../../formats/sprite.ts'
import { monsterStatsFromTable, tickCombatMulti } from '../../game/combat.ts'
import type { CombatInput, CombatOptions, CombatTerrain, CombatWorld, MonsterStats } from '../../game/combat.ts'
import { D2Rng } from '../../game/d2-rng.ts'
import { executeDropPipeline, type DropTables } from '../../game/drop-pipeline.ts'
import { getEmbeddedDropTables } from '../../game/embedded-drop-tables.ts'
import { findIsometricDropPosition, getPickupEdgeDistance } from '../../game/ground-items.ts'
import { Inventory, type Item } from '../../game/items.ts'
import { getMonsterTreasureClass, type Difficulty } from '../../game/monsters.ts'
import { QuestLog } from '../../game/quests.ts'
import { parseTable } from '../../game/tables.ts'
import { computeLockstepHash, type InputFrame, type LockstepSimulation } from '../../net/lockstep.ts'
/** World seed: fixed, because both peers must build the same world. */
export const NET_SEED = 0x0d2c0de
/** Raw members the scene needs. */
export interface NetSources {
readonly ds1: Uint8Array
readonly libraries: readonly Uint8Array[]
readonly palette: Uint8Array
readonly paletteName: string
readonly actor: { readonly name: string; readonly sheet: SpriteSheet } | null
readonly monstats: Uint8Array | null
}
/** The monster table used when the archive has none, so the page is never empty. */
export const DEMO_MONSTERS: MonsterStats[] = monsterStatsFromTable(parseTable([
'Id\tName\tHP\tDamage\tCooldownTicks\tReach\tAggroRadius\tSpeed\tXP',
'fallen\tFallen One\t16\t2\t24\t38\t420\t150\t12',
'zombie\tZombie\t30\t3\t32\t42\t360\t120\t20',
].join('\n')))
/** Fallback experience thresholds: the co-op sandbox is not about levelling. */
export const DEMO_EXPERIENCE: readonly number[] = [0, 0, 250, 700, 1500, 3000]
export function createNetSimulation(
world: CombatWorld,
options: CombatOptions,
terrain: CombatTerrain,
xpTable: readonly number[],
numPeers: number,
seed: number = NET_SEED,
dropTables: DropTables = getEmbeddedDropTables(),
difficulty: Difficulty = 'normal',
) {
const inventories = Array.from({ length: Math.max(numPeers, 1) }, () => new Inventory(10, 4))
const quests = new QuestLog([])
const ground: { x: number; y: number; item: Item }[] = []
world.pendingKills = world.pendingKills ?? []
for (let i = 0; i < world.monsters.length; i += 1) {
const monster = world.monsters[i]!
if (monster.dwInitSeed === undefined) {
monster.dwInitSeed = (((seed ^ Math.imul(monster.index + 1, 0x9e3779b9)) >>> 0) || 1)
}
}
const mapTerrain = 'widthPx' in terrain
? (terrain as any)
: { ...terrain, widthPx: 10000, heightPx: 10000 }
const simulation: LockstepSimulation = {
advance: (inputs: readonly InputFrame[]) => {
const frames: CombatInput[] = inputs.map(input => ({
movement: input.movement,
attack: input.attack,
}))
tickCombatMulti(world, frames, options, terrain, xpTable)
const killEvents = [
...(world.pendingKills ?? []),
...world.events.filter(e => e.kind === 'kill'),
]
if (world.pendingKills !== undefined) {
world.pendingKills.length = 0
}
const activePlayers = Math.max(1, Math.min(8, world.players.filter(p => p.alive).length || numPeers || 1))
for (const event of killEvents) {
if (event.kind !== 'kill') continue
const monster = event.monsterIndex !== undefined
? world.monsters[event.monsterIndex] ?? world.monsters.find(m => m.index === event.monsterIndex)
: world.monsters.find(m => m.stats.id === event.subjectId && Math.hypot(m.x - event.x, m.y - event.y) < 10)
if (monster?.dropRolled) continue
if (monster) {
monster.dropRolled = true
}
const subjectId = (event.subjectId ?? monster?.stats.id ?? '').trim()
const monsterRank = event.monsterRank ?? monster?.stats.rank ?? 'normal'
const isMinion = monsterRank === 'minion'
const monsterType = isMinion
? 3
: (event.monsterType ?? (monsterRank === 'boss' ? 4 : monsterRank === 'unique' ? 3 : monsterRank === 'champion' ? 2 : 1))
const suDef = event.superUniqueId
? (dropTables.superUniques.get(event.superUniqueId) ??
(() => {
const target = event.superUniqueId!.trim().toLowerCase()
for (const [k, v] of dropTables.superUniques) {
if (k.toLowerCase() === target || v.id.toLowerCase() === target || v.nameKey?.toLowerCase() === target) {
return v
}
}
return undefined
})())
: undefined
let tcName = ''
if (!isMinion && suDef) {
tcName = typeof suDef.getTreasureClass === 'function'
? suDef.getTreasureClass(difficulty)
: suDef.treasureClass
}
const monsterKind =
dropTables.monsterKinds.get(subjectId) ??
dropTables.monsterKinds.get(`${subjectId}1`) ??
(suDef ? dropTables.monsterKinds.get(suDef.monsterId) : undefined)
if (!tcName && monsterKind) {
tcName = getMonsterTreasureClass(monsterKind, difficulty, monsterType)
}
if (!tcName && !monsterKind && !suDef) {
tcName = 'Act 1 H2H A'
}
if (!tcName) continue
const kindLevel = typeof (monsterKind as any)?.level === 'number'
? (monsterKind as any).level
: (monsterKind as any)?.level?.[difficulty]
const nLevel = Math.max(1, event.monsterLevel ?? monster?.stats.level ?? kindLevel ?? 1)
const monsterIndex = monster?.index ?? event.monsterIndex ?? 0
const monsterSeed =
monster?.dwInitSeed ??
((((seed ^ Math.imul(monsterIndex + 1, 0x9e3779b9)) >>> 0) || 1))
const droppedItems = executeDropPipeline(dropTables, {
tcName,
nLevel,
monsterType,
difficulty,
gamePlayers: activePlayers,
partyPlayers: activePlayers,
playerMf: 0,
playerGf: 0,
isBoss: isMinion ? false : Boolean(monsterKind?.boss),
isNoRatio: Boolean(monsterKind?.noRatio),
monsterRng: new D2Rng(monsterSeed),
})
const burstOccupied: { x: number; y: number }[] = ground.map(g => ({ x: g.x, y: g.y }))
const alivePlayers = world.players.filter(p => p.alive)
const refPlayers = alivePlayers.length > 0 ? alivePlayers : world.players
let reachableFrom: { x: number; y: number } | undefined
if (refPlayers.length > 0) {
let bestPlayer = refPlayers[0]!
let bestDist = Math.hypot(bestPlayer.x - event.x, bestPlayer.y - event.y)
for (let pIdx = 1; pIdx < refPlayers.length; pIdx += 1) {
const p = refPlayers[pIdx]!
const d = Math.hypot(p.x - event.x, p.y - event.y)
if (d < bestDist) {
bestDist = d
bestPlayer = p
}
}
reachableFrom = { x: bestPlayer.x, y: bestPlayer.y }
}
for (const drop of droppedItems) {
const dropPos = findIsometricDropPosition(
event.x,
event.y,
burstOccupied,
mapTerrain,
undefined,
undefined,
undefined,
undefined,
reachableFrom,
)
burstOccupied.push(dropPos)
ground.push({ x: dropPos.x, y: dropPos.y, item: drop })
}
}
inputs.forEach((input, index) => {
if (input.pickup && ground.length > 0) {
const player = world.players[index]
if (player) {
let bestIndex = -1
let bestEdgeDist = 64
for (let gIdx = 0; gIdx < ground.length; gIdx += 1) {
const g = ground[gIdx]!
const edgeDist = getPickupEdgeDistance(player.x, player.y, g.x, g.y)
if (edgeDist <= bestEdgeDist) {
bestEdgeDist = edgeDist
bestIndex = gIdx
}
}
if (bestIndex !== -1 && inventories[index]!.add(ground[bestIndex]!.item) !== null) {
ground.splice(bestIndex, 1)
}
}
}
})
},
hash: () => computeLockstepHash(world, inventories, ground, quests.all),
}
return { simulation, inventories, quests, ground }
}

View File

@ -0,0 +1,489 @@
import {
ORTHO_CELL_HEIGHT,
ORTHO_CELL_WIDTH,
ORTHO_SUB_TILE_HEIGHT,
ORTHO_SUB_TILE_WIDTH,
type CollisionGrid,
} from '../../game/d2map.ts'
import {
SEAM_TRIGGER_SUBTILES,
WARP_TRIGGER_SUBTILES,
type SceneEntrance,
type SceneWarp,
type SceneWaypoint,
} from '../../game/level-links.ts'
import { getLevelNames, levelDisplayName } from '../../game/level-names-zh.ts'
import { SUB_TILES_PER_TILE } from '../../game/map.ts'
import { getTblLang } from '../../i18n/lang.ts'
import { SEAM_FADE_MS, WARP_FADE_MS, subTileToScene } from '../transition.ts'
export {
SEAM_FADE_MS,
SEAM_TRIGGER_SUBTILES,
WARP_FADE_MS,
WARP_TRIGGER_SUBTILES,
subTileToScene,
type SceneEntrance,
type SceneWarp,
type SceneWaypoint,
}
/** Maximum range in scene pixels for Telekinesis (Skill 43) warp activation. */
export const TELEKINESIS_RANGE_PX = 320
/** Radial proximity click tolerance in scene pixels for a SceneWarp. */
export const WARP_CLICK_RADIUS_PX = 56
/** Radial proximity click tolerance in scene pixels for a SceneEntrance. */
export const ENTRANCE_CLICK_RADIUS_PX = 56
/**
* Convert scene pixel coordinates back to isometric sub-tile coordinates.
*/
export function sceneToSubTile(
worldX: number,
worldY: number,
grid: { readonly originX: number; readonly originY: number },
): { x: number; y: number } {
const dx = (worldX - grid.originX) / ORTHO_SUB_TILE_WIDTH
const dy = (worldY - grid.originY) / ORTHO_SUB_TILE_HEIGHT
return { x: Math.round((dy + dx) / 2), y: Math.round((dy - dx) / 2) }
}
/**
* Compute the scene coordinate for a `SceneWarp` overhead destination badge.
*/
export function getWarpLabelWorldPos(
warp: SceneWarp,
grid: { readonly originX: number; readonly originY: number },
): { x: number; y: number } {
const anchor = subTileToScene(warp.x, warp.y, grid)
if (warp.selectDX > 0 && warp.selectDY > 0) {
return {
x: anchor.x + warp.selectX + warp.selectDX * 0.5,
y: anchor.y + warp.selectY - 6,
}
}
return {
x: anchor.x,
y: anchor.y - 48,
}
}
/**
* Compute the scene coordinate for a `SceneEntrance` overhead destination badge.
*/
export function getEntranceLabelWorldPos(
entrance: SceneEntrance,
grid: { readonly originX: number; readonly originY: number },
): { x: number; y: number } {
const anchor = subTileToScene(entrance.x, entrance.y, grid)
return {
x: anchor.x,
y: anchor.y - 48,
}
}
/**
* Hit-test a world-space pointer position against a `SceneWarp` (`LvlWarp.txt`
* selection bounding box when non-zero, overhead badge footprint, plus radial
* proximity to anchor, walkable arrival point, and overhead label).
*/
export function hitTestWarp(
warp: SceneWarp,
worldX: number,
worldY: number,
grid: { readonly originX: number; readonly originY: number },
): {
hit: boolean
dist: number
anchorPos: { x: number; y: number }
arrivePos: { x: number; y: number }
} {
const anchorPos = subTileToScene(warp.x, warp.y, grid)
const arrivePos = subTileToScene(warp.arriveX, warp.arriveY, grid)
if (!(warp.toLevelId > 0)) {
return {
hit: false,
dist: Infinity,
anchorPos,
arrivePos,
}
}
const labelPos = getWarpLabelWorldPos(warp, grid)
let boxDist = Infinity
let inBox = false
if (warp.selectDX > 0 && warp.selectDY > 0) {
const minX = anchorPos.x + warp.selectX
const maxX = minX + warp.selectDX
const minY = anchorPos.y + warp.selectY
const maxY = minY + warp.selectDY
if (worldX >= minX && worldX <= maxX && worldY >= minY && worldY <= maxY) {
inBox = true
const cx = (minX + maxX) * 0.5
const cy = (minY + maxY) * 0.5
boxDist = Math.hypot(worldX - cx, worldY - cy)
}
}
const badgeText = getTblLang() === 'ENG'
? `To ${levelDisplayName(warp.toLevelId, 'ENG')}`
: `通往 ${getLevelNames(warp.toLevelId).nameZh}`
const badgeHalfWidth = Math.max(64, Math.ceil((badgeText.length * 14 + 16) * 0.5))
const inBadge =
Math.abs(worldX - labelPos.x) <= badgeHalfWidth &&
worldY >= labelPos.y - 28 &&
worldY <= labelPos.y + 8
const badgeDist = inBadge
? Math.hypot((worldX - labelPos.x) * 0.5, worldY - (labelPos.y - 12)) * 0.1
: Infinity
const distAnchor = Math.hypot(worldX - anchorPos.x, worldY - anchorPos.y)
const distArrive = Math.hypot(worldX - arrivePos.x, worldY - arrivePos.y)
const distLabel = Math.hypot(worldX - labelPos.x, worldY - labelPos.y)
const distAnchorBody = Math.hypot(worldX - anchorPos.x, worldY - (anchorPos.y - 48))
const radialDist = Math.min(distAnchor, distArrive, distLabel, distAnchorBody)
const inRadius = radialDist <= WARP_CLICK_RADIUS_PX
return {
hit: inBox || inBadge || inRadius,
dist: Math.min(boxDist, badgeDist, radialDist),
anchorPos,
arrivePos,
}
}
/**
* Hit-test a world-space pointer position against a `SceneEntrance` (overhead
* badge footprint plus radial proximity to entrance anchor, walkable arrival
* point, and overhead label).
*/
export function hitTestEntrance(
entrance: SceneEntrance,
worldX: number,
worldY: number,
grid: { readonly originX: number; readonly originY: number },
): {
hit: boolean
dist: number
anchorPos: { x: number; y: number }
arrivePos: { x: number; y: number }
} {
const anchorPos = subTileToScene(entrance.x, entrance.y, grid)
const arrivePos = subTileToScene(entrance.arriveX, entrance.arriveY, grid)
if (!(entrance.toLevelId > 0)) {
return {
hit: false,
dist: Infinity,
anchorPos,
arrivePos,
}
}
const labelPos = getEntranceLabelWorldPos(entrance, grid)
const badgeText = getTblLang() === 'ENG'
? `To ${levelDisplayName(entrance.toLevelId, 'ENG')}`
: `通往 ${getLevelNames(entrance.toLevelId).nameZh}`
const badgeHalfWidth = Math.max(64, Math.ceil((badgeText.length * 14 + 16) * 0.5))
const inBadge =
Math.abs(worldX - labelPos.x) <= badgeHalfWidth &&
worldY >= labelPos.y - 28 &&
worldY <= labelPos.y + 8
const badgeDist = inBadge
? Math.hypot((worldX - labelPos.x) * 0.5, worldY - (labelPos.y - 12)) * 0.1
: Infinity
const distAnchor = Math.hypot(worldX - anchorPos.x, worldY - anchorPos.y)
const distArrive = Math.hypot(worldX - arrivePos.x, worldY - arrivePos.y)
const distLabel = Math.hypot(worldX - labelPos.x, worldY - labelPos.y)
const distArriveBody = Math.hypot(worldX - arrivePos.x, worldY - (arrivePos.y - 48))
const radialDist = Math.min(distAnchor, distArrive, distLabel, distArriveBody)
return {
hit: inBadge || radialDist <= ENTRANCE_CLICK_RADIUS_PX,
dist: Math.min(badgeDist, radialDist),
anchorPos,
arrivePos,
}
}
/**
* Check whether the player is within trigger range (`WARP_TRIGGER_SUBTILES` or
* 80px proximity radius) of a warp or entrance's anchor or walkable arrival point.
*/
export function isPlayerWithinLinkRange(
playerX: number,
playerY: number,
link: { readonly x: number; readonly y: number; readonly arriveX: number; readonly arriveY: number },
grid: { readonly originX: number; readonly originY: number },
triggerSubTiles = WARP_TRIGGER_SUBTILES,
): boolean {
const sub = sceneToSubTile(playerX, playerY, grid)
const nearAnchorSub = Math.abs(sub.x - link.x) <= triggerSubTiles && Math.abs(sub.y - link.y) <= triggerSubTiles
const nearArriveSub = Math.abs(sub.x - link.arriveX) <= triggerSubTiles && Math.abs(sub.y - link.arriveY) <= triggerSubTiles
if (nearAnchorSub || nearArriveSub) return true
const anchorPos = subTileToScene(link.x, link.y, grid)
const arrivePos = subTileToScene(link.arriveX, link.arriveY, grid)
const distAnchor = Math.hypot(playerX - anchorPos.x, playerY - anchorPos.y)
const distArrive = Math.hypot(playerX - arrivePos.x, playerY - arrivePos.y)
return Math.min(distAnchor, distArrive) <= 80
}
/**
* Resolve an obstacle-aware immediate steering target toward `navTarget` on `grid`.
* When the direct sub-tile line between the player and `navTarget` is unobstructed,
* returns `navTarget` directly. When blocked by dungeon walls in `grid.blocked`,
* computes an 8-directional shortest walkable sub-tile path (A*) around corners and
* returns the furthest visible waypoint along that path.
*/
export function resolveObstacleAwareSteerTarget(
playerX: number,
playerY: number,
navTarget: { readonly x: number; readonly y: number },
grid: CollisionGrid | undefined,
): { x: number; y: number } {
if (!grid || !grid.blocked || grid.blocked.length === 0 || grid.gridWidth <= 0 || grid.cellsY <= 0) {
return navTarget
}
const gridW = grid.gridWidth
const gridH = grid.cellsY * SUB_TILES_PER_TILE
const startSub = sceneToSubTile(playerX, playerY, grid)
const rawEndSub = sceneToSubTile(navTarget.x, navTarget.y, grid)
if (startSub.x < 0 || startSub.y < 0 || startSub.x >= gridW || startSub.y >= gridH) {
return navTarget
}
const endSub = {
x: Math.max(0, Math.min(gridW - 1, rawEndSub.x)),
y: Math.max(0, Math.min(gridH - 1, rawEndSub.y)),
}
if (startSub.x === endSub.x && startSub.y === endSub.y) {
return navTarget
}
const isSubTileBlocked = (sx: number, sy: number): boolean => {
if (sx < 0 || sy < 0 || sx >= gridW || sy >= gridH) return true
return grid.blocked[sy * gridW + sx] !== 0
}
const hasWallNeighbor = (sx: number, sy: number): boolean =>
isSubTileBlocked(sx - 1, sy) ||
isSubTileBlocked(sx + 1, sy) ||
isSubTileBlocked(sx, sy - 1) ||
isSubTileBlocked(sx, sy + 1)
const hasClearLine = (x0: number, y0: number, x1: number, y1: number, requireClearance: boolean): boolean => {
let cx = x0
let cy = y0
const dx = Math.abs(x1 - x0)
const dy = Math.abs(y1 - y0)
const sx = x0 < x1 ? 1 : -1
const sy = y0 < y1 ? 1 : -1
let err = dx - dy
let step = 0
while (true) {
if (step > 0) {
if (isSubTileBlocked(cx, cy)) return false
if (requireClearance && (cx !== x1 || cy !== y1) && hasWallNeighbor(cx, cy)) return false
}
if (cx === x1 && cy === y1) break
const prevX = cx
const prevY = cy
const e2 = 2 * err
if (e2 > -dy) {
err -= dy
cx += sx
}
if (e2 < dx) {
err += dx
cy += sy
}
if (cx !== prevX && cy !== prevY) {
if (isSubTileBlocked(cx, prevY) || isSubTileBlocked(prevX, cy)) return false
}
step += 1
}
return true
}
if (hasClearLine(startSub.x, startSub.y, endSub.x, endSub.y, false)) {
return navTarget
}
// Bounded 8-directional A* search on sub-tile grid
const maxExpanded = 4096
const gScore = new Map<number, number>()
const parent = new Map<number, number>()
const closed = new Set<number>()
const open: Array<{ key: number; x: number; y: number; g: number; f: number }> = []
const startKey = startSub.y * gridW + startSub.x
const endKey = endSub.y * gridW + endSub.x
const heuristic = (x: number, y: number): number => Math.hypot(endSub.x - x, endSub.y - y)
gScore.set(startKey, 0)
open.push({ key: startKey, x: startSub.x, y: startSub.y, g: 0, f: heuristic(startSub.x, startSub.y) })
const dirs: ReadonlyArray<readonly [number, number, number]> = [
[1, 0, 1],
[-1, 0, 1],
[0, 1, 1],
[0, -1, 1],
[1, 1, Math.SQRT2],
[1, -1, Math.SQRT2],
[-1, 1, Math.SQRT2],
[-1, -1, Math.SQRT2],
]
let reachedKey: number | null = null
let bestFallbackKey: number | null = null
let bestFallbackDist = heuristic(startSub.x, startSub.y)
let bestFallbackG = Infinity
let expanded = 0
while (open.length > 0 && expanded < maxExpanded) {
let bestIdx = 0
for (let i = 1; i < open.length; i += 1) {
if (open[i]!.f < open[bestIdx]!.f) bestIdx = i
}
const cur = open[bestIdx]!
open[bestIdx] = open[open.length - 1]!
open.pop()
if (closed.has(cur.key)) continue
closed.add(cur.key)
expanded += 1
if (cur.key === endKey) {
reachedKey = cur.key
break
}
if (cur.key !== startKey) {
const hCur = heuristic(cur.x, cur.y)
if (
hCur < bestFallbackDist - 1e-6 ||
(Math.abs(hCur - bestFallbackDist) <= 1e-6 && cur.g < bestFallbackG)
) {
bestFallbackDist = hCur
bestFallbackG = cur.g
bestFallbackKey = cur.key
}
}
for (const [dx, dy, baseCost] of dirs) {
const nx = cur.x + dx
const ny = cur.y + dy
if (nx < 0 || ny < 0 || nx >= gridW || ny >= gridH) continue
const nKey = ny * gridW + nx
if (closed.has(nKey)) continue
if (nKey !== endKey && isSubTileBlocked(nx, ny)) continue
if (dx !== 0 && dy !== 0) {
if (isSubTileBlocked(cur.x + dx, cur.y) || isSubTileBlocked(cur.x, cur.y + dy)) continue
}
const wallPenalty = (nKey !== endKey && hasWallNeighbor(nx, ny)) ? 0.6 : 0
const tentativeG = cur.g + baseCost + wallPenalty
const prevG = gScore.get(nKey)
if (prevG === undefined || tentativeG < prevG) {
gScore.set(nKey, tentativeG)
parent.set(nKey, cur.key)
open.push({ key: nKey, x: nx, y: ny, g: tentativeG, f: tentativeG + heuristic(nx, ny) })
}
}
}
const finalKey = reachedKey ?? bestFallbackKey
if (finalKey === null) {
return navTarget
}
const path: Array<{ x: number; y: number }> = []
let curr: number | undefined = finalKey
while (curr !== undefined) {
const py = Math.floor(curr / gridW)
const px = curr - py * gridW
path.push({ x: px, y: py })
if (curr === startKey) break
curr = parent.get(curr)
}
path.reverse()
if (path.length <= 1) {
return navTarget
}
let targetIdx = 1
for (let i = path.length - 1; i >= 2; i -= 1) {
const pt = path[i]!
if (hasClearLine(startSub.x, startSub.y, pt.x, pt.y, true)) {
targetIdx = i
break
}
}
if (targetIdx === path.length - 1 && reachedKey !== null) {
return navTarget
}
const wp = path[targetIdx]!
const wpScene = subTileToScene(wp.x, wp.y, grid)
if (Math.hypot(wpScene.x - playerX, wpScene.y - playerY) <= 10 && targetIdx + 1 < path.length) {
const nextWp = path[targetIdx + 1]!
return subTileToScene(nextWp.x, nextWp.y, grid)
}
return wpScene
}
/**
* The facing index for a screen-space direction.
*
* @param x - screen x direction, -1..1.
* @param y - screen y direction, -1..1 (down positive).
* @returns the facing index, 0 = south, turning clockwise (west).
*/
export function facingOf(x: number, y: number): number {
const angle = Math.atan2(-x, y)
return Math.round((angle / (Math.PI / 4)) + 8) % 8
}
/**
* The cell containing a scene point.
*
* @param grid - the collision grid.
* @param x - scene x.
* @param y - scene y.
* @returns rounded-down cell coordinates.
*/
export function cellOf(grid: CollisionGrid, x: number, y: number): { x: number; y: number } {
const px = x - grid.originX
const py = y - grid.originY
return {
x: Math.floor((px / ORTHO_CELL_WIDTH + py / ORTHO_CELL_HEIGHT) / 2),
y: Math.floor((py / ORTHO_CELL_HEIGHT - px / ORTHO_CELL_WIDTH) / 2),
}
}
export interface ScreenToWorldParams {
readonly clientX: number
readonly clientY: number
readonly rect: { readonly left: number; readonly top: number; readonly width: number; readonly height: number }
readonly canvas: { readonly width: number; readonly height: number }
readonly camX: number
readonly camY: number
readonly cameraZoom: number
}
/**
* Maps screen client coordinates (pixels) to world coordinates using the exact
* inverse camera transform.
*/
export function screenToWorld(params: ScreenToWorldParams): { x: number; y: number } {
const scaleX = params.canvas.width > 0 ? params.rect.width / params.canvas.width : 1
const scaleY = params.canvas.height > 0 ? params.rect.height / params.canvas.height : 1
const worldX = params.camX + (params.clientX - params.rect.left - params.rect.width * 0.5) / (params.cameraZoom * scaleX)
const worldY = params.camY + (params.clientY - params.rect.top - params.rect.height * 0.5) / (params.cameraZoom * scaleY)
return { x: worldX, y: worldY }
}

View File

@ -0,0 +1,616 @@
import type { MonsterPack, MonsterStats, SafeZone } from '../../game/combat.ts'
import {
ORTHO_CELL_HEIGHT,
ORTHO_CELL_WIDTH,
cellAt,
isBlockedAt,
levelSeed,
type CollisionGrid,
} from '../../game/d2map.ts'
import type { DropTables } from '../../game/drop-pipeline.ts'
import { getEmbeddedDropTables } from '../../game/embedded-drop-tables.ts'
import type { SceneEntrance, SceneWarp, SceneWaypoint } from '../../game/level-links.ts'
import { allocatePacksToRooms, type MapRoom } from '../../game/monster-rooms.ts'
import type { StreamingRoomDef } from '../../game/monster-streaming.ts'
import {
ELITE_HEALTH_MULTIPLIER,
MONUMOD_CONSTANTS,
applyEliteModifiers,
enrichMonsterPacks,
rollEliteModifiers,
} from '../../game/monsters.ts'
import { Rng } from '../../game/rng.ts'
import { subTileToScene } from '../transition.ts'
/** Base paths tried in order when `?base=` is absent. */
export const DEFAULT_BASES = ['samples/d2', '/diablo2/data']
/**
* Side of a streaming room, in cells.
*
* `D2Common`'s DRLG cuts a wilderness level into rooms of a handful of tiles
* each; a generated level here is 8 cells per generator block, so 24 cells is
* three blocks — about a screen and a half, which is the granularity at which
* populating a room is cheap enough to do inside one frame.
*/
export const ROOM_CELL_SIZE = 24
/**
* Distance at which a room is populated and woken, in scene pixels.
*
* The viewport is ~1280×720, and the isometric projection makes a screen
* roughly 1300px wide in scene space, so 1600 is "just past the edge of the
* screen": monsters exist before they can be seen, never in front of the
* player's eyes.
*/
export const ROOM_ACTIVATION_RADIUS = 1600
/**
* Distance at which a room goes back to sleep, in scene pixels.
*
* Deliberately larger than the activation radius. The gap is hysteresis: a
* player pacing across a room boundary would otherwise toggle a room's AI on
* and off every few frames.
*/
export const ROOM_DEACTIVATION_RADIUS = 2400
/** Town levels across Acts 1..5 that must strictly remain at 0 monster density. */
export const TOWN_LEVEL_IDS = new Set<number>([1, 40, 75, 103, 109])
export function isTownLevel(levelId: number | undefined | null): boolean {
if (levelId === undefined || levelId === null || levelId <= 0) return false
return TOWN_LEVEL_IDS.has(levelId)
}
export type DensityMultiplier = 0.5 | 1 | 2 | 4 | 8
export type EliteMultiplier = 0.5 | 1 | 2 | 4 | 'all'
export function parseDensityMultiplier(raw: string | number | null | undefined): DensityMultiplier {
if (raw === null || raw === undefined) return 1
const num = typeof raw === 'number' ? raw : parseFloat(String(raw).trim())
if (num === 0.5 || num === 1 || num === 2 || num === 4 || num === 8) {
return num as DensityMultiplier
}
return 1
}
export function parseEliteMultiplier(raw: string | number | null | undefined): EliteMultiplier {
if (raw === null || raw === undefined) return 1
const str = String(raw).trim().toLowerCase()
if (str === 'all') return 'all'
const num = parseFloat(str)
if (num === 0.5 || num === 1 || num === 2 || num === 4) {
return num as EliteMultiplier
}
return 1
}
export function promoteToChampion(pack: MonsterPack, rng: Rng): MonsterPack {
const leaderBase = pack.members[0]
if (!leaderBase) return pack
const count = rng.int(2, 4)
const mods = rollEliteModifiers('champion', rng)
const members: MonsterStats[] = []
for (let i = 0; i < count; i += 1) {
const base = pack.members[i % pack.members.length] ?? leaderBase
const scaledBase: MonsterStats = {
...base,
hp: Math.max(1, Math.round(base.hp * ELITE_HEALTH_MULTIPLIER.champion)),
}
members.push(applyEliteModifiers(scaledBase, 'champion', mods))
}
return {
...pack,
members,
}
}
export function promoteToUnique(pack: MonsterPack, rng: Rng): MonsterPack {
const leaderBase = pack.members[0]
if (!leaderBase) return pack
const minionCount = rng.int(3, 6)
const mods = rollEliteModifiers('unique', rng)
const scaledLeader: MonsterStats = {
...leaderBase,
hp: Math.max(1, Math.round(leaderBase.hp * ELITE_HEALTH_MULTIPLIER.unique)),
}
const leader = applyEliteModifiers(scaledLeader, 'unique', mods)
const members: MonsterStats[] = [leader]
for (let i = 0; i < minionCount; i += 1) {
const minionBase = pack.members[(i + 1) % pack.members.length] ?? leaderBase
const scaledMinion: MonsterStats = {
...minionBase,
hp: Math.max(1, Math.round(minionBase.hp * (1 + MONUMOD_CONSTANTS.minionHpPct / 100))),
}
members.push(applyEliteModifiers(scaledMinion, 'minion', []))
}
return {
...pack,
members,
}
}
export function demoteToNormal(pack: MonsterPack): MonsterPack {
const members: MonsterStats[] = pack.members.map(m => {
let hp = m.hp
let level = m.level
if (m.rank === 'champion') {
hp = Math.max(1, Math.round(hp / ELITE_HEALTH_MULTIPLIER.champion))
if (level !== undefined) {
level = Math.max(1, level - 2)
}
} else if (m.rank === 'unique') {
hp = Math.max(1, Math.round(hp / ELITE_HEALTH_MULTIPLIER.unique))
if (level !== undefined) {
level = Math.max(1, level - 3)
}
} else if (m.rank === 'minion') {
hp = Math.max(1, Math.round(hp / (1 + MONUMOD_CONSTANTS.minionHpPct / 100)))
if (level !== undefined) {
level = Math.max(1, level - 3)
}
}
return {
...m,
hp,
...(level !== undefined ? { level } : {}),
rank: 'normal',
modifiers: [],
}
})
return {
...pack,
members,
}
}
export interface RebudgetOptions {
readonly densityMultiplier?: number | string | undefined
readonly eliteMultiplier?: number | string | undefined
readonly levelId?: number | undefined
readonly seed?: number | undefined
}
export function rebudgetMonsterPacks(
basePacks: readonly MonsterPack[],
options?: RebudgetOptions,
): MonsterPack[] {
const levelId = options?.levelId
if (levelId !== undefined && isTownLevel(levelId)) {
return []
}
if (!basePacks || basePacks.length === 0) {
return []
}
const dropTables = getEmbeddedDropTables()
const enrichedBasePacks = enrichMonsterPacks(basePacks, dropTables.monsterKinds, dropTables.superUniques)
const density = parseDensityMultiplier(options?.densityMultiplier)
const elite = parseEliteMultiplier(options?.eliteMultiplier)
if (density === 1 && elite === 1) {
return enrichedBasePacks.map(pack => ({
...pack,
members: pack.members.map(m => ({ ...m })),
}))
}
const rng = new Rng(options?.seed ?? 0x9e37)
// Separate fixed / landmark packs from regular wild packs
const fixedPacks = enrichedBasePacks.filter(p => p.superUniqueId !== undefined || p.fixedPosition !== undefined || p.landmarkTile !== undefined)
const regularPacks = enrichedBasePacks.filter(p => p.superUniqueId === undefined && p.fixedPosition === undefined && p.landmarkTile === undefined)
// 1. Density scaling on regular packs
let scaledRegularPacks: MonsterPack[] = []
if (regularPacks.length > 0) {
const targetCount = Math.max(1, Math.round(regularPacks.length * density))
if (targetCount <= regularPacks.length) {
for (let i = 0; i < targetCount; i += 1) {
const idx = Math.floor((i * regularPacks.length) / targetCount)
const src = regularPacks[idx]!
scaledRegularPacks.push({
...src,
members: src.members.map(m => ({ ...m })),
})
}
} else {
for (let i = 0; i < targetCount; i += 1) {
const src = regularPacks[i % regularPacks.length]!
scaledRegularPacks.push({
...src,
members: src.members.map(m => ({ ...m })),
})
}
}
}
// 2. Elite scaling on regular packs
if (elite === 'all') {
scaledRegularPacks = scaledRegularPacks.map((pack, idx) => {
const normalPack = demoteToNormal(pack)
const packRng = rng.fork(`all-elite-${idx}`)
const isChampion = packRng.next() < (MONUMOD_CONSTANTS.championChance / 100)
return isChampion ? promoteToChampion(normalPack, packRng) : promoteToUnique(normalPack, packRng)
})
} else if (elite !== 1) {
const currentEliteIndices: number[] = []
const currentNormalIndices: number[] = []
for (let i = 0; i < scaledRegularPacks.length; i += 1) {
const leaderRank = scaledRegularPacks[i]?.members[0]?.rank
if (leaderRank === 'champion' || leaderRank === 'unique') {
currentEliteIndices.push(i)
} else {
currentNormalIndices.push(i)
}
}
if (currentEliteIndices.length > 0) {
const targetEliteCount = Math.min(scaledRegularPacks.length, Math.max(0, Math.round(currentEliteIndices.length * elite)))
if (targetEliteCount < currentEliteIndices.length) {
const toDemoteCount = currentEliteIndices.length - targetEliteCount
for (let i = 0; i < toDemoteCount; i += 1) {
const idx = currentEliteIndices[currentEliteIndices.length - 1 - i]!
scaledRegularPacks[idx] = demoteToNormal(scaledRegularPacks[idx]!)
}
} else if (targetEliteCount > currentEliteIndices.length) {
const toPromoteCount = Math.min(currentNormalIndices.length, targetEliteCount - currentEliteIndices.length)
for (let i = 0; i < toPromoteCount; i += 1) {
const idx = currentNormalIndices[i]!
const packRng = rng.fork(`promote-${idx}`)
const isChampion = packRng.next() < (MONUMOD_CONSTANTS.championChance / 100)
scaledRegularPacks[idx] = isChampion
? promoteToChampion(scaledRegularPacks[idx]!, packRng)
: promoteToUnique(scaledRegularPacks[idx]!, packRng)
}
}
} else if (elite > 1 && currentNormalIndices.length > 0) {
const targetEliteCount = Math.min(
scaledRegularPacks.length,
Math.max(1, Math.round(scaledRegularPacks.length * 0.05 * elite)),
)
const toPromote = Math.min(currentNormalIndices.length, targetEliteCount)
for (let i = 0; i < toPromote; i += 1) {
const idx = currentNormalIndices[i]!
const packRng = rng.fork(`promote-zero-${idx}`)
const isChampion = packRng.next() < (MONUMOD_CONSTANTS.championChance / 100)
scaledRegularPacks[idx] = isChampion
? promoteToChampion(scaledRegularPacks[idx]!, packRng)
: promoteToUnique(scaledRegularPacks[idx]!, packRng)
}
}
}
return [
...fixedPacks.map(p => ({ ...p, members: p.members.map(m => ({ ...m })) })),
...scaledRegularPacks,
]
}
export interface HeroClassInfo {
readonly token: string
readonly code: string
readonly nameZh: string
readonly nameEn: string
readonly defaultLeftSkill: number
readonly defaultRightSkill: number
readonly charIdentity: {
readonly name: string
readonly classNameZh: string
readonly classNameEn: string
readonly str: number
readonly dex: number
readonly vit: number
readonly ene: number
readonly baseMaxHp: number
readonly baseMaxMana: number
}
}
export const HERO_CLASSES: Record<string, HeroClassInfo> = {
so: {
token: 'so', code: 'sor', nameZh: '法师', nameEn: 'Sorceress',
defaultLeftSkill: 47, defaultRightSkill: 64,
charIdentity: {
name: 'Isendra', classNameZh: '女法师', classNameEn: 'Sorceress',
str: 156, dex: 75, vit: 315, ene: 95, baseMaxHp: 850, baseMaxMana: 620,
},
},
am: {
token: 'am', code: 'ama', nameZh: '亚马逊', nameEn: 'Amazon',
defaultLeftSkill: 6, defaultRightSkill: 7,
charIdentity: {
name: 'Cassia', classNameZh: '亚马逊', classNameEn: 'Amazon',
str: 135, dex: 210, vit: 250, ene: 50, baseMaxHp: 920, baseMaxMana: 320,
},
},
ne: {
token: 'ne', code: 'nec', nameZh: '死灵法师', nameEn: 'Necromancer',
defaultLeftSkill: 67, defaultRightSkill: 70,
charIdentity: {
name: 'Xul', classNameZh: '死灵法师', classNameEn: 'Necromancer',
str: 120, dex: 80, vit: 290, ene: 120, baseMaxHp: 780, baseMaxMana: 580,
},
},
pa: {
token: 'pa', code: 'pal', nameZh: '圣骑士', nameEn: 'Paladin',
defaultLeftSkill: 96, defaultRightSkill: 112,
charIdentity: {
name: 'Cartographus', classNameZh: '圣骑士', classNameEn: 'Paladin',
str: 175, dex: 120, vit: 340, ene: 45, baseMaxHp: 1150, baseMaxMana: 380,
},
},
ba: {
token: 'ba', code: 'bar', nameZh: '野蛮人', nameEn: 'Barbarian',
defaultLeftSkill: 126, defaultRightSkill: 133,
charIdentity: {
name: 'Sonya', classNameZh: '野蛮人', classNameEn: 'Barbarian',
str: 235, dex: 110, vit: 380, ene: 30, baseMaxHp: 1420, baseMaxMana: 260,
},
},
dz: {
token: 'dz', code: 'dru', nameZh: '德鲁伊', nameEn: 'Druid',
defaultLeftSkill: 225, defaultRightSkill: 229,
charIdentity: {
name: 'Loreov', classNameZh: '德鲁伊', classNameEn: 'Druid',
str: 165, dex: 90, vit: 330, ene: 70, baseMaxHp: 1050, baseMaxMana: 420,
},
},
ai: {
token: 'ai', code: 'ass', nameZh: '刺客', nameEn: 'Assassin',
defaultLeftSkill: 254, defaultRightSkill: 251,
charIdentity: {
name: 'Natalya', classNameZh: '刺客', classNameEn: 'Assassin',
str: 150, dex: 180, vit: 280, ene: 60, baseMaxHp: 960, baseMaxMana: 360,
},
},
}
export function normalizeHeroToken(input: string | null | undefined): string {
if (!input) return 'so'
const lower = input.toLowerCase().trim()
if (HERO_CLASSES[lower]) return lower
const codeMap: Record<string, string> = {
sor: 'so', sorceress: 'so',
ama: 'am', amazon: 'am',
nec: 'ne', necromancer: 'ne',
pal: 'pa', paladin: 'pa',
bar: 'ba', barbarian: 'ba',
dru: 'dz', druid: 'dz', dr: 'dz',
ass: 'ai', assassin: 'ai', as: 'ai',
}
return codeMap[lower] ?? 'so'
}
/** Town Stash entity (authentic 1.13c Objects.txt row 267 B6). */
export interface TownStashEntity {
readonly id: number
readonly objectsTxtId: number
readonly token: string
readonly nameZh: string
readonly nameEn: string
readonly act: number
readonly x: number
readonly y: number
readonly subTileX: number
readonly subTileY: number
readonly clickRadiusPx: number
readonly interactionRangePx: number
}
/**
* Canonical town stash coordinates for Acts 1..5.
* Grounded in authentic 1.13c DS1 map objects (row 267 B6).
*/
export const CANONICAL_TOWN_STASHES: Record<number, TownStashEntity> = {
1: {
id: 267,
objectsTxtId: 267,
token: 'B6',
nameZh: '储物箱',
nameEn: 'Stash',
act: 1,
x: 4512,
y: 1720,
subTileX: 112,
subTileY: 86,
clickRadiusPx: 48,
interactionRangePx: 80,
},
2: {
id: 267,
objectsTxtId: 267,
token: 'B6',
nameZh: '储物箱',
nameEn: 'Stash',
act: 2,
x: 5328,
y: 1568,
subTileX: 133,
subTileY: 78,
clickRadiusPx: 48,
interactionRangePx: 80,
},
3: {
id: 267,
objectsTxtId: 267,
token: 'B6',
nameZh: '储物箱',
nameEn: 'Stash',
act: 3,
x: 5264,
y: 1584,
subTileX: 131,
subTileY: 79,
clickRadiusPx: 48,
interactionRangePx: 80,
},
4: {
id: 267,
objectsTxtId: 267,
token: 'B6',
nameZh: '储物箱',
nameEn: 'Stash',
act: 4,
x: 704,
y: 552,
subTileX: 17,
subTileY: 27,
clickRadiusPx: 48,
interactionRangePx: 80,
},
5: {
id: 267,
objectsTxtId: 267,
token: 'B6',
nameZh: '储物箱',
nameEn: 'Stash',
act: 5,
x: 3792,
y: 1448,
subTileX: 94,
subTileY: 72,
clickRadiusPx: 48,
interactionRangePx: 80,
},
}
/** Global singleton cache for DropTables using pre-baked embedded canonical drop data (zero network/MPQ). */
let sharedDropTablesPromise: Promise<DropTables> | null = null
export function getSharedDropTables(_bases: readonly string[] = DEFAULT_BASES): Promise<DropTables> {
if (!sharedDropTablesPromise) {
sharedDropTablesPromise = Promise.resolve(getEmbeddedDropTables())
}
return sharedDropTablesPromise
}
/**
* Sample a scene-pixel rectangle on a 7×7 interior grid and ask whether any
* point is walkable.
*
* An axis-aligned rectangle over the isometric diamond includes the four empty
* triangles outside the map; without this check those corners become rooms,
* `allocatePacksToRooms` deals packs into them, and `spawnMonsterPacks` fails
* every placement attempt against void space.
*/
export function roomHasWalkableGround(
grid: CollisionGrid,
minX: number,
minY: number,
maxX: number,
maxY: number,
): boolean {
const samples = 9
for (let iy = 1; iy < samples; iy += 1) {
const y = minY + (maxY - minY) * (iy / samples)
for (let ix = 1; ix < samples; ix += 1) {
const x = minX + (maxX - minX) * (ix / samples)
if (!isBlockedAt(grid, x, y)) return true
}
}
return false
}
export interface StreamingRoomLayoutInput {
readonly grid: CollisionGrid
readonly widthPx: number
readonly heightPx: number
readonly level: string
}
/**
* Cuts a level into streaming rooms and allocates its monster packs to them.
*/
export function buildStreamingRooms(
runtime: StreamingRoomLayoutInput,
packs: readonly MonsterPack[],
safeZones: readonly SafeZone[],
): StreamingRoomDef[] {
if (packs.length === 0) return []
const grid = runtime.grid
const roomWidth = (ROOM_CELL_SIZE * ORTHO_CELL_WIDTH) / 2
const roomHeight = (ROOM_CELL_SIZE * ORTHO_CELL_HEIGHT) / 2
const cellsPerRoomX = Math.max(1, Math.round(roomWidth / ORTHO_CELL_WIDTH))
const cellsPerRoomY = Math.max(1, Math.round(roomHeight / ORTHO_CELL_HEIGHT))
const rooms: MapRoom[] = []
let id = 0
for (let y = 0; y < runtime.heightPx; y += roomHeight) {
for (let x = 0; x < runtime.widthPx; x += roomWidth) {
const maxX = Math.min(x + roomWidth, runtime.widthPx)
const maxY = Math.min(y + roomHeight, runtime.heightPx)
if (maxX - x < 32 || maxY - y < 32) continue
if (!roomHasWalkableGround(grid, x, y, maxX, maxY)) continue
const isSafeZone = safeZones.some(
zone =>
zone.x + zone.radius > x &&
zone.x - zone.radius < maxX &&
zone.y + zone.radius > y &&
zone.y - zone.radius < maxY,
)
const cell = cellAt(grid, x + (maxX - x) / 2, y + (maxY - y) / 2)
rooms.push({
id,
x,
y,
width: maxX - x,
height: maxY - y,
cellX: Math.floor(cell.x - cellsPerRoomX / 2),
cellY: Math.floor(cell.y - cellsPerRoomY / 2),
cellsX: cellsPerRoomX,
cellsY: cellsPerRoomY,
isSafeZone,
})
id += 1
}
}
if (rooms.length === 0) return []
const roomPackMap = allocatePacksToRooms(packs, rooms, levelSeed(runtime.level))
const defs: StreamingRoomDef[] = []
for (const room of rooms) {
const roomPacks = roomPackMap.get(room)
if (roomPacks === undefined || roomPacks.length === 0) continue
defs.push({
id: room.id,
bounds: { minX: room.x, minY: room.y, maxX: room.x + room.width, maxY: room.y + room.height },
packs: roomPacks,
room,
})
}
return defs
}
export interface SafeZoneSourceRuntime {
readonly grid: CollisionGrid
readonly waypoints: readonly SceneWaypoint[]
readonly entrances: readonly SceneEntrance[]
readonly warps: readonly SceneWarp[]
}
/**
* Build the safe-zone list around the landing point, waypoints, entrances, and warps.
*/
export function buildLevelSafeZones(
runtime: SafeZoneSourceRuntime,
landingPoint: { readonly x: number; readonly y: number },
): SafeZone[] {
const safeZones: SafeZone[] = [{ x: landingPoint.x, y: landingPoint.y, radius: 160 }]
for (const wp of runtime.waypoints) {
const pos = subTileToScene(wp.x, wp.y, runtime.grid)
safeZones.push({ x: pos.x, y: pos.y, radius: 160 })
}
for (const ent of runtime.entrances) {
const pos = subTileToScene(ent.x, ent.y, runtime.grid)
safeZones.push({ x: pos.x, y: pos.y, radius: 160 })
}
for (const warp of runtime.warps) {
const pos = subTileToScene(warp.x, warp.y, runtime.grid)
safeZones.push({ x: pos.x, y: pos.y, radius: 160 })
}
return safeZones
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,510 @@
import { damageMonster, type Monster } from '../../game/combat.ts'
import type { GameEngine } from '../../game/engine.ts'
import { monsterDisplayName } from '../../game/monsters.ts'
import {
getBatch1SkillDef,
getMissileTxtData,
getSkillManaCost,
isBatch1Skill,
type Projectile,
} from '../../game/skills.ts'
import { getTblLang } from '../../i18n/lang.ts'
export const PASSIVE_SKILL_IDS = new Set<number>([
37, // Warmth
61, // Fire Mastery
63, // Lightning Mastery
65, // Cold Mastery
69, // Skeleton Mastery
79, // Golem Mastery
127, 128, 129, 134, 135, 136, 141, 145, 148, 153, // Barbarian masteries & passives
224, // Lycanthropy
252, // Claw Mastery
])
export function isPassiveSkill(skillId: number): boolean {
if (skillId === 0) return false
if (PASSIVE_SKILL_IDS.has(skillId)) return true
const def = getBatch1SkillDef(skillId)
return def?.type === 'passive'
}
export type AttackWeaponKind = 'unarmed' | 'melee' | 'ranged'
export type AttackWeaponSubtype =
| 'fist'
| 'sword'
| 'axe'
| 'mace'
| 'dagger'
| 'staff'
| 'polearm'
| 'spear'
| 'bow'
| 'crossbow'
| 'other'
export interface AttackWeaponInfo {
readonly kind: AttackWeaponKind
readonly subtype: AttackWeaponSubtype
readonly isRanged: boolean
readonly ammoType?: 'arrow' | 'bolt' | undefined
readonly damageMin: number
readonly damageMax: number
readonly reach: number
readonly cooldown: number
readonly swingTicks: number
}
const BOW_CODES = new Set([
'sbw', 'hbw', 'lbw', 'cbw', 'sbb', 'lbb', 'swb', 'lwb',
'am1', 'am2', 'am6', 'am7', 'amb',
'8sb', '8hb', '8lb', '8cb', '8s8', '8l8', '8sw', '8lw',
'6sb', '6hb', '6lb', '6cb', '6s7', '6l7', '6sw', '6lw',
'amc', 'amd', 'ame', 'amj', 'amk',
])
const XBOW_CODES = new Set([
'lxb', 'mxb', 'hxb', 'rxb',
'8lx', '8mx', '8hx', '8rx',
'6lx', '6mx', '6hx', '6rx',
])
const DAGGER_CODES = new Set([
'dgr', 'dir', 'kri', 'bld',
'9dg', '9di', '9kr', '9bl',
'7dg', '7di', '7kr', '7bl',
])
export interface AttackWeaponInputItem {
code?: string | undefined
type?: string | undefined
wclass?: string | undefined
name?: string | undefined
nameZh?: string | undefined
damage?: string | undefined
}
export function getAttackWeaponInfo(item?: AttackWeaponInputItem | null | undefined): AttackWeaponInfo {
if (!item || !item.code) {
return {
kind: 'unarmed',
subtype: 'fist',
isRanged: false,
damageMin: 1,
damageMax: 2,
reach: 52,
cooldown: 12,
swingTicks: 6,
}
}
const code = item.code.trim().toLowerCase()
const type = (item.type ?? '').trim().toLowerCase()
const wclass = (item.wclass ?? '').trim().toLowerCase()
const name = (item.name ?? '').toLowerCase()
const nameZh = (item.nameZh ?? '').toLowerCase()
let dMin = 3
let dMax = 8
if (item.damage) {
const m = item.damage.match(/(\d+)\s*(?:to|-)\s*(\d+)/)
if (m) {
dMin = parseInt(m[1]!, 10)
dMax = parseInt(m[2]!, 10)
}
}
// 1. Crossbow (checked before bow because 'crossbow' contains 'bow')
if (type === 'xbow' || wclass === 'xbw' || XBOW_CODES.has(code) || /crossbow|xbow|十字弓|弩/i.test(name) || /十字弓|弩/i.test(nameZh)) {
return {
kind: 'ranged',
subtype: 'crossbow',
isRanged: true,
ammoType: 'bolt',
damageMin: dMin,
damageMax: dMax,
reach: 450,
cooldown: 16,
swingTicks: 8,
}
}
// 2. Bow
if (type === 'bow' || type === 'abow' || wclass === 'bow' || BOW_CODES.has(code) || /\bbow\b|弓/i.test(name) || /弓/i.test(nameZh)) {
return {
kind: 'ranged',
subtype: 'bow',
isRanged: true,
ammoType: 'arrow',
damageMin: dMin,
damageMax: dMax,
reach: 450,
cooldown: 14,
swingTicks: 7,
}
}
// 3. Melee Weapon Subtypes: sword, axe, mace, dagger, staff, polearm, spear
let subtype: AttackWeaponSubtype | null = null
let reach = 52
if (
type === 'knif' ||
type === 'tkni' ||
DAGGER_CODES.has(code) ||
((/dagger|dirk|kris|knife|匕首|短刀/i.test(name) || /匕首|短刀/i.test(nameZh)) && type !== 'swor')
) {
subtype = 'dagger'
reach = 48
} else if (type === 'swor' || /sword|blade|scimitar|saber|falchion|claymore|broadsword|crystal sword|剑/i.test(name) || /剑/i.test(nameZh)) {
subtype = 'sword'
reach = 52
} else if (type === 'axe' || type === 'taxe' || /axe|hache|cleaver|斧/i.test(name) || /斧/i.test(nameZh)) {
subtype = 'axe'
reach = 52
} else if (type === 'mace' || type === 'club' || type === 'hamm' || type === 'scep' || /mace|club|hammer|scepter|flail|morning star|maul|锤|棍|杖|锏/i.test(name) || /锤|锏/i.test(nameZh)) {
subtype = 'mace'
reach = 52
} else if (type === 'staf' || /staff|长杖|法杖/i.test(name) || /长杖|法杖/i.test(nameZh)) {
subtype = 'staff'
reach = 52
} else if (type === 'pole' || /polearm|bardiche|voulge|scythe|halberd|war scythe|长柄|长镰/i.test(name) || /长柄|长镰/i.test(nameZh)) {
subtype = 'polearm'
reach = 60
} else if (type === 'spea' || type === 'aspe' || type === 'jave' || type === 'ajav' || /spear|trident|brandistock|spetum|pike|javelin|长矛|标枪/i.test(name) || /长矛|标枪/i.test(nameZh)) {
subtype = 'spear'
reach = 60
}
if (subtype !== null) {
return {
kind: 'melee',
subtype,
isRanged: false,
damageMin: dMin,
damageMax: dMax,
reach,
cooldown: 14,
swingTicks: 7,
}
}
// Non-melee item (e.g. orb, wand) or unarmed fallback timing: cooldown 12, swingTicks 6
return {
kind: 'unarmed',
subtype: 'fist',
isRanged: false,
damageMin: dMin > 3 ? dMin : 1,
damageMax: dMax > 8 ? dMax : 2,
reach: 52,
cooldown: 12,
swingTicks: 6,
}
}
export function checkAmmunition(
ammoType: 'arrow' | 'bolt',
offhandItem?: { code?: string | undefined; name?: string | undefined; nameZh?: string | undefined } | null | undefined,
inventory?: { items?: readonly any[] | undefined; grid?: readonly any[] | undefined; gridItems?: readonly any[] | undefined } | null | undefined,
): boolean {
// 1. Check offhand equipment (weapon2)
if (offhandItem && offhandItem.code) {
const code = offhandItem.code.toLowerCase().trim()
const name = (offhandItem.name ?? '').toLowerCase()
const nameZh = (offhandItem.nameZh ?? '').toLowerCase()
if (ammoType === 'arrow' && (code === 'aqv' || code === '0qv' || /arrow/i.test(name) || /箭矢|箭/i.test(nameZh))) return true
if (ammoType === 'bolt' && (code === 'cqv' || code === '1qv' || /bolt/i.test(name) || /十字弓弹|弩箭/i.test(nameZh))) return true
}
// 2. Check inventory grid / items
if (inventory) {
const invAny = inventory as any
const list: any[] = invAny.gridItems
? invAny.gridItems.map((g: any) => g.item ?? g)
: invAny.grid
? invAny.grid.map((g: any) => g.item ?? g)
: (invAny.items ?? [])
for (const it of list) {
if (!it || !it.code) continue
const code = String(it.code).toLowerCase().trim()
const name = String(it.name ?? '').toLowerCase()
const nameZh = String(it.nameZh ?? '').toLowerCase()
if (ammoType === 'arrow' && (code === 'aqv' || code === '0qv' || /arrow/i.test(name) || /箭矢|箭/i.test(nameZh))) return true
if (ammoType === 'bolt' && (code === 'cqv' || code === '1qv' || /bolt/i.test(name) || /十字弓弹|弩箭/i.test(nameZh))) return true
}
}
return false
}
export interface HudCombatStateLike {
mana: number
maxMana?: number
lang?: string
leftPanel?: string
toggleLeftPanel?: (panel: any) => void
syncPublishedState?: () => void
getSkillLevel?: (skillId: number) => number
hotkeys?: {
readonly availableSkills?: ReadonlyArray<{ readonly skillId: number; readonly manaCost: number }>
}
inventory?: {
playerLevel?: number
gold: number
readonly equipped?: {
readonly weapon1?: AttackWeaponInputItem | null
readonly weapon2?: any
}
autoPlaceInGrid: (item: any) => boolean
readonly items?: readonly any[]
readonly grid?: readonly any[]
readonly gridItems?: readonly any[]
}
belt?: {
autoPlacePotion?: (item: any) => boolean
}
worldPanels?: {
selectedActTab: number
setCurrentWaypoint: (waypointId: number | null, levelId: number | null) => void
}
}
export function getSkillRange(
skillId: number,
hudLike?: HudCombatStateLike | null | undefined,
equippedWeapon?: AttackWeaponInputItem | null | undefined,
): number {
if (skillId === 0) {
const hud = hudLike ?? ((globalThis as Record<string, any>).__d2webHudInstance as HudCombatStateLike | null | undefined)
const weapon = equippedWeapon ?? hud?.inventory?.equipped?.weapon1
const info = getAttackWeaponInfo(weapon)
return info.reach
}
const missileSkillMap: Record<number, string> = {
36: 'firebolt',
39: 'icebolt',
45: 'iceblast',
55: 'glacialspike',
38: 'chargedbolt',
67: 'teeth',
84: 'bonespear',
93: 'bonespirit',
92: 'poisonnova',
101: 'holybolt',
112: 'blessedhammer',
6: 'magicarrow',
7: 'firearrow',
11: 'icearrow',
21: 'icearrow',
12: 'arrow',
16: 'firearrow',
20: 'lightningjavelin',
}
if (skillId in missileSkillMap) {
const missile = getMissileTxtData(missileSkillMap[skillId]!)
return missile.distancePx || 1000
}
const def = getBatch1SkillDef(skillId)
if (def !== undefined) {
if (def.type === 'melee') return 52
if (def.range !== undefined) return def.range
}
switch (skillId) {
case 40: return 0
case 42: return 160
case 44: return 240
case 54: return 450
case 56: return 450
case 57: return 340
case 58: return 0
case 59: return 450
case 62: return 450
case 63: return 0
case 64: return 480
case 219:
case 220: return 0
default: return 450
}
}
export function getSkillMana(skillId: number, hudLike?: HudCombatStateLike | null): number {
if (skillId === 0) return 0
if (hudLike !== null && hudLike !== undefined) {
const entry = hudLike.hotkeys?.availableSkills?.find(s => s.skillId === skillId)
if (entry !== undefined) return entry.manaCost
}
if (isBatch1Skill(skillId)) {
return getSkillManaCost(skillId, 1)
}
switch (skillId) {
case 41: return 6
case 45: return 6
case 46: return 11
case 51: return 22
case 55: return 10
case 73: return 3
case 78: return 17
case 83: return 8
case 84: return 7
case 88: return 27
case 93: return 12
case 92: return 20
case 12: return 4
case 16: return 5
case 20: return 8
case 21: return 4
case 40: return 7
case 42: return 9
case 59: return 23
case 60: return 12
case 62: return 20
case 64: return 30
case 56: return 17
case 49: return 11
case 38: return 3
case 43: return 7
case 57: return 19
case 58: return 5
case 63: return 0
case 219:
case 220: return 0
default: return 10
}
}
export interface SkillCastOutcome {
readonly casted: boolean
readonly animMode: 'a1' | 'sc' | null
readonly statusMessage: string | null
}
/**
* Pure simulation helper for normal weapon/unarmed attack (`skillId === 0`).
*/
export function executeNormalAttack(
targetX: number,
targetY: number,
context: {
readonly engine: GameEngine
readonly hudLike?: HudCombatStateLike | null | undefined
readonly equippedWeapon?: AttackWeaponInputItem | null | undefined
readonly equippedAmmo?: any | null | undefined
readonly hasAmmo?: boolean | undefined
},
): SkillCastOutcome {
const { engine, hudLike } = context
const player = engine.world.player
const isEn = (hudLike?.lang ?? getTblLang()) === 'ENG'
const globalHud = (globalThis as Record<string, any>).__d2webHudInstance as HudCombatStateLike | null | undefined
const weapon =
context.equippedWeapon ??
hudLike?.inventory?.equipped?.weapon1 ??
globalHud?.inventory?.equipped?.weapon1 ??
null
const weaponInfo = getAttackWeaponInfo(weapon)
if (weaponInfo.isRanged) {
const ammo =
context.equippedAmmo ??
hudLike?.inventory?.equipped?.weapon2 ??
globalHud?.inventory?.equipped?.weapon2 ??
null
const hasAmmo = context.hasAmmo ?? checkAmmunition(weaponInfo.ammoType!, ammo, hudLike?.inventory)
if (!hasAmmo) {
return {
casted: false,
animMode: 'a1',
statusMessage: isEn
? (weaponInfo.ammoType === 'arrow' ? 'Out of arrows!' : 'Out of bolts!')
: (weaponInfo.ammoType === 'arrow' ? '缺少箭矢!' : '缺少十字弓弹!'),
}
}
player.cooldown = weaponInfo.cooldown
player.swingTicks = weaponInfo.swingTicks
const dx = targetX - player.x
const dy = targetY - player.y
const len = Math.hypot(dx, dy)
const speed = 500
const range = weaponInfo.reach
const perTick = speed / 25
const ttl = Math.max(1, Math.round(range / perTick))
const damage = Math.round((weaponInfo.damageMin + weaponInfo.damageMax) / 2)
const projectile: Projectile = {
skillId: '0',
x: player.x,
y: player.y,
vx: len > 0 ? (dx / len) * perTick : perTick,
vy: len > 0 ? (dy / len) * perTick : 0,
damage,
ttl,
fromPlayer: true,
}
engine.projectiles.push(projectile)
engine.metrics.casts += 1
return {
casted: true,
animMode: 'a1',
statusMessage: isEn
? (weaponInfo.subtype === 'bow' ? 'Attack: Shoot Arrow' : 'Attack: Shoot Bolt')
: (weaponInfo.subtype === 'bow' ? '普通攻击:发射箭矢' : '普通攻击:发射弩箭'),
}
}
// Unarmed or Melee Attack
player.cooldown = weaponInfo.cooldown
player.swingTicks = weaponInfo.swingTicks
let targetMonster: Monster | null = null
let bestTargetDist = 36
for (const monster of engine.world.monsters) {
if (monster.state === 'dead' || monster.hp <= 0) continue
const distToPlayer = Math.hypot(monster.x - player.x, monster.y - player.y)
if (distToPlayer <= weaponInfo.reach) {
const distFeet = Math.hypot(monster.x - targetX, monster.y - targetY)
const distBody = Math.hypot(monster.x - targetX, (monster.y - 16) - targetY)
const minD = Math.min(distFeet, distBody)
if (minD <= bestTargetDist) {
bestTargetDist = minD
targetMonster = monster
}
}
}
if (targetMonster === null) {
let bestDist = weaponInfo.reach
for (const monster of engine.world.monsters) {
if (monster.state === 'dead' || monster.hp <= 0) continue
const dist = Math.hypot(monster.x - player.x, monster.y - player.y)
if (dist <= bestDist) {
bestDist = dist
targetMonster = monster
}
}
}
const damage = Math.round((weaponInfo.damageMin + weaponInfo.damageMax) / 2)
let statusMessage: string | null = null
if (targetMonster !== null) {
damageMonster(engine.world, targetMonster.index, damage)
engine.metrics.playerHits += 1
if (isEn) {
const attackLabel = weaponInfo.kind === 'unarmed' ? 'Unarmed Attack' : 'Attack'
statusMessage = `${attackLabel} hit: ${monsterDisplayName(targetMonster.stats, 'ENG')}`
} else {
const attackLabel = weaponInfo.kind === 'unarmed' ? '普通攻击(空手拳击)' : '普通攻击'
statusMessage = `${attackLabel}命中:${targetMonster.stats.name || targetMonster.stats.id}`
}
} else {
engine.world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: 0, text: 'whiff' })
}
return {
casted: true,
animMode: 'a1',
statusMessage,
}
}

View File

@ -0,0 +1,252 @@
import type { WorldArena as WorldArenaType } from '../../game/engine/world-arena.ts'
export async function initBrowserArenaMode(): Promise<void> {
if (typeof window === 'undefined' || typeof document === 'undefined') return
const params = new URLSearchParams(location.search)
if (params.get('arena') !== '1') return
try {
const classParam = params.get('class') || params.get('hero') || 'sor'
const skillId = Math.max(0, Number(params.get('skill') ?? '47'))
const slvl = Math.max(1, Number(params.get('slvl') ?? '20'))
const autocast = params.get('autocast') !== '0'
const engineBase = ['..', '..', 'game', 'engine'].join('/')
const { getSharedDataRegistry } = (await import(
/* @vite-ignore */ `${engineBase}/data-registry.ts`
)) as typeof import('../../game/engine/data-registry.ts')
const { WorldArena } = (await import(
/* @vite-ignore */ `${engineBase}/world-arena.ts`
)) as typeof import('../../game/engine/world-arena.ts')
const registry = await getSharedDataRegistry('samples/d2')
let arena: WorldArenaType = new WorldArena({
registry,
classCode: classParam,
skillId,
slvl,
})
if (autocast) {
arena.triggerCast()
arena.stepTicks(15)
}
const syncWindowArena = () => {
const snap = arena.getDebugState() as ReturnType<typeof arena.getDebugState> & {
switchAndCastSkill?: (nextClassCode: string, nextSkillId: number, nextSlvl?: number) => unknown
}
const origTrigger = snap.triggerCast
const origStep = snap.stepTicks
snap.triggerCast = (tx?: number, ty?: number) => {
const out = origTrigger(tx, ty)
syncWindowArena()
renderArenaVisualOverlay(arena)
return out
}
snap.stepTicks = (cnt: number) => {
origStep(cnt)
syncWindowArena()
renderArenaVisualOverlay(arena)
}
snap.switchAndCastSkill = (nextClassCode: string, nextSkillId: number, nextSlvl = 20) => {
arena = new WorldArena({
registry,
classCode: nextClassCode,
skillId: nextSkillId,
slvl: nextSlvl,
})
arena.triggerCast()
arena.stepTicks(15)
syncWindowArena()
renderArenaVisualOverlay(arena)
return arena.getDebugState()
}
;(window as unknown as { __d2webArena?: unknown }).__d2webArena = snap
}
const renderArenaVisualOverlay = (wArena: WorldArenaType) => {
let overlayCanvas = document.querySelector<HTMLCanvasElement>('#arena-skill-overlay')
if (!overlayCanvas) {
overlayCanvas = document.createElement('canvas')
overlayCanvas.id = 'arena-skill-overlay'
overlayCanvas.width = window.innerWidth || 960
overlayCanvas.height = window.innerHeight || 600
overlayCanvas.style.cssText =
'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:7;'
document.body.appendChild(overlayCanvas)
}
const ctx = overlayCanvas.getContext('2d')
if (!ctx) return
const w = overlayCanvas.width
const h = overlayCanvas.height
ctx.clearRect(0, 0, w, h)
const dbg = wArena.getDebugState()
const cx = w * 0.5
const cy = h * 0.52
// 1. Draw Active Aura / Cast Ring & Class Hero Avatar around Hero
const classHue = ((dbg.skillId * 37) % 360)
ctx.save()
ctx.strokeStyle = dbg.activeStates.length > 0 ? 'rgba(255, 210, 80, 0.9)' : `hsla(${classHue}, 85%, 65%, 0.85)`
ctx.lineWidth = 3.5
ctx.beginPath()
ctx.ellipse(cx, cy + 16, 62 + (dbg.skillId % 18), 32 + (dbg.skillId % 9), 0, 0, Math.PI * 2)
ctx.stroke()
// Hero class figure + active weapon/spell glow
ctx.fillStyle = `hsla(${classHue}, 75%, 55%, 0.85)`
ctx.strokeStyle = '#fff3d1'
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(cx, cy - 6, 18, 0, Math.PI * 2)
ctx.fill()
ctx.stroke()
ctx.fillStyle = '#ffe9a8'
ctx.font = 'bold 11px monospace'
ctx.textAlign = 'center'
ctx.fillText(`${dbg.classCode.toUpperCase()}:${dbg.charToken.toUpperCase()} [${dbg.playerAnimMode}:${dbg.playerAnimFrame}]`, cx, cy + 35)
ctx.restore()
// 2. Draw Target Dummies (`dummy-normal`, `dummy-undead`, `dummy-demon`, `dummy-immune`)
const dummyOffsets: Record<string, { dx: number; dy: number; color: string }> = {
'dummy-normal': { dx: 0, dy: -115, color: '#e6644c' },
'dummy-undead': { dx: -145, dy: -95, color: '#b4c8e8' },
'dummy-demon': { dx: 145, dy: -95, color: '#ff8a3d' },
'dummy-immune': { dx: 255, dy: -65, color: '#c77dff' },
}
for (const tgt of dbg.targetLog) {
const off = dummyOffsets[tgt.targetId] ?? { dx: 0, dy: -115, color: '#e6644c' }
const tx = cx + off.dx
const ty = cy + off.dy
const hpRatio = Math.max(0, Math.min(1, tgt.hp / Math.max(1, tgt.maxHp)))
// Dummy shadow + body marker
ctx.fillStyle = 'rgba(0,0,0,0.55)'
ctx.beginPath()
ctx.ellipse(tx, ty + 18, 22, 10, 0, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = off.color
ctx.strokeStyle = '#f5e6c8'
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(tx, ty - 4, 16, 0, Math.PI * 2)
ctx.fill()
ctx.stroke()
// HP bar
ctx.fillStyle = '#1f1914'
ctx.fillRect(tx - 44, ty - 38, 88, 9)
ctx.fillStyle = hpRatio > 0.5 ? '#c9342b' : '#f07822'
ctx.fillRect(tx - 43, ty - 37, Math.round(86 * hpRatio), 7)
ctx.font = 'bold 11px monospace'
ctx.textAlign = 'center'
ctx.fillStyle = '#f2e6d0'
ctx.fillText(`${tgt.targetId} (${tgt.hp}/${tgt.maxHp})`, tx, ty - 43)
if (tgt.lastDamage > 0) {
ctx.fillStyle = '#ffdf59'
ctx.font = 'bold 14px monospace'
ctx.fillText(`-${tgt.lastDamage}`, tx, ty - 58)
}
if (tgt.states.length > 0) {
ctx.fillStyle = '#7be3ff'
ctx.font = '10px monospace'
ctx.fillText(tgt.states.join(','), tx, ty + 32)
}
}
// 3. Draw Spawned Missiles / Projectiles / Effect Ray toward primary dummy
if (dbg.missilesSpawned > 0 || dbg.totalDamageDealt > 0) {
ctx.save()
const grad = ctx.createLinearGradient(cx, cy - 6, cx, cy - 110)
grad.addColorStop(0, 'rgba(255, 220, 110, 0.9)')
grad.addColorStop(1, 'rgba(255, 90, 40, 0.95)')
ctx.strokeStyle = grad
ctx.lineWidth = 5
ctx.beginPath()
ctx.moveTo(cx, cy - 8)
ctx.lineTo(cx, cy - 108)
ctx.stroke()
ctx.fillStyle = 'rgba(255, 165, 50, 0.85)'
ctx.beginPath()
ctx.arc(cx, cy - 65, 14, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
}
// 4. Draw Active Summoned Pets / Traps
dbg.petsActive.forEach((pet, idx) => {
const px = cx - 95 + idx * 48
const py = cy + 44
ctx.fillStyle = '#5ce1e6'
ctx.strokeStyle = '#112a36'
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(px, py, 12, 0, Math.PI * 2)
ctx.fill()
ctx.stroke()
ctx.fillStyle = '#d7f9ff'
ctx.font = 'bold 10px monospace'
ctx.textAlign = 'center'
ctx.fillText(`${pet.monId} (${pet.hp}HP)`, px, py + 24)
})
// 5. Top-left 1.13c Skill Verification Telemetry Panel
ctx.fillStyle = 'rgba(12, 10, 8, 0.88)'
ctx.strokeStyle = '#c8a15a'
ctx.lineWidth = 1.5
ctx.fillRect(14, 62, 470, 122)
ctx.strokeRect(14, 62, 470, 122)
ctx.textAlign = 'left'
ctx.fillStyle = '#f0c76e'
ctx.font = 'bold 13px monospace'
ctx.fillText(
`[1.13c ARENA] #${dbg.skillId} ${dbg.skillName} | Class: ${dbg.classCode.toUpperCase()} (${dbg.charToken}) | slvl ${dbg.slvl}`,
24,
82,
)
ctx.fillStyle = '#e8dcc8'
ctx.font = '12px monospace'
ctx.fillText(
`CastCount: ${dbg.castCount} | Mode: ${dbg.playerAnimMode} (Frame ${dbg.playerAnimFrame}) | ManaCost: ${dbg.tooltipStats.manaCost}`,
24,
102,
)
ctx.fillText(
`1.13c Dmg: ${dbg.tooltipStats.minDmg}-${dbg.tooltipStats.maxDmg} | Total Dealt: ${dbg.totalDamageDealt} | Missiles: ${dbg.missilesSpawned}`,
24,
122,
)
ctx.fillText(
`Pets: ${dbg.petsActive.length} | Corpses Left: ${dbg.corpsesRemaining}/3 | States: ${dbg.activeStates.slice(0, 3).join(', ') || 'NONE'}`,
24,
142,
)
ctx.fillStyle = '#8ee38e'
ctx.fillText(
`Overlays: ${dbg.activeOverlays.slice(0, 3).join(', ') || 'default'} | Status: READY`,
24,
162,
)
}
syncWindowArena()
renderArenaVisualOverlay(arena)
} catch (err) {
;(window as unknown as { __d2webArenaError?: string }).__d2webArenaError = String(
(err as Error)?.stack || err,
)
}
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
;(window as unknown as { __initBrowserArenaMode?: () => Promise<void> }).__initBrowserArenaMode =
initBrowserArenaMode
void initBrowserArenaMode()
}

View File

@ -0,0 +1,342 @@
import type { Palette } from '../../formats/pal.ts'
import type { AnimClipMeta } from '../../game/actor-animator.ts'
import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../../game/monster-art.ts'
import { resolveMonsterArtSpec } from '../../game/monster-mapping.ts'
import type { AtlasFrame } from '../../render/atlas.ts'
import type { AtlasHandle, SpriteRenderer } from '../../render/renderer.ts'
import { normalizeHeroToken } from '../backend/scene-population.ts'
export { ADDITIVE_MONSTER_TOKENS }
/** Base locations tried in order when `?base=` is absent. */
export const DEFAULT_BASES = ['samples/d2', '/diablo2/data']
/** A composited character, uploaded as its own atlas page. */
export interface LoadedCharacter {
readonly handle: AtlasHandle
/** `atlas.groups[direction][frame]`, ready to draw. */
readonly groups: readonly (readonly AtlasFrame[])[]
readonly directions: number
readonly walk: number
readonly stand: number
readonly members: number
readonly clips?: Readonly<Record<string, AnimClipMeta>>
}
export interface PackedMonsterJson {
readonly schema?: number
readonly token: string
readonly weapon: string
readonly file: string
readonly width: number
readonly height: number
readonly blendMode?: 'normal' | 'additive' | undefined
readonly directions: number
readonly walkOffset: number
readonly standOffset: number
readonly walkFrames: number
readonly standFrames: number
readonly clips?: Readonly<Record<string, AnimClipMeta>>
readonly groups: readonly (readonly [number, number, number, number, number?, number?])[][]
readonly glowGroups?: readonly (readonly [number, number, number, number, number?, number?])[][] | undefined
}
export interface PackedCharJson {
readonly schema?: number
readonly file: string
readonly width: number
readonly height: number
readonly directions: number
readonly walk: number
readonly stand: number
readonly members: number
readonly clips?: Readonly<Record<string, AnimClipMeta>>
readonly groups: readonly (readonly [number, number, number, number, number?, number?])[][]
}
/**
* What a monster-art load produced, including what it failed to produce.
*/
export interface MonsterArtLoad {
/** Monster id (lower case) to its atlas. */
readonly map: Map<string, LoadedMonsterArt>
/** Monster ids left with no atlas: every one of these draws as a red box. */
readonly missing: string[]
/** Loads that threw — a per-spec failure, or the whole function. */
readonly errors: number
/** COF layers dropped inside the art that *did* load: monsters missing limbs. */
readonly layerFailures: number
}
/**
* The monster ids that got no atlas.
*
* @param monsterIds - the ids the level asked for.
* @param loaded - the ids that loaded, lower case.
* @returns the missing ids, lower case and deduplicated, in request order.
*/
export function missingMonsterArtIds(
monsterIds: readonly string[],
loaded: ReadonlySet<string>,
): string[] {
const missing: string[] = []
for (const id of monsterIds) {
const key = id.toLowerCase()
if (!loaded.has(key) && !missing.includes(key)) missing.push(key)
}
return missing
}
/**
* Load monster atlases on-demand for all monster types present in the scene.
* Prefers pre-baked indexed PNG + JSON from the act pack when available (< 200ms),
* falling back to runtime MPQ HTTP Range extraction only if pre-packed assets are missing.
*/
export async function loadMonsterArtMap(
_bases: readonly string[],
monsterIds: readonly string[],
_palette: Palette,
renderer: SpriteRenderer,
packBase?: string,
act?: number,
): Promise<MonsterArtLoad> {
const map = new Map<string, LoadedMonsterArt>()
let errors = 0
let layerFailures = 0
if (monsterIds.length === 0) return { map, missing: [], errors, layerFailures }
try {
const tokenSpecs = new Map<string, { token: string; weapon: string }>()
for (const id of monsterIds) {
const spec = resolveMonsterArtSpec(id)
if (spec !== null) {
const key = `${spec.token}-${spec.weapon}`
if (!tokenSpecs.has(key)) {
tokenSpecs.set(key, spec)
}
}
}
const loadedBySpecKey = new Map<string, LoadedMonsterArt>()
const fallbackSpecs: Array<[string, { token: string; weapon: string }]> = []
if (packBase !== undefined) {
const globalEntitiesUrl = `${packBase}/entities`
const actEntitiesUrl = act !== undefined ? `${packBase}/act${String(act)}/entities` : undefined
await Promise.all(
[...tokenSpecs.entries()].map(async ([key, spec]) => {
const slug = `monster-${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
try {
const [jsonResp, r8Resp] = await Promise.all([
fetch(`${globalEntitiesUrl}/${slug}.json`),
fetch(`${globalEntitiesUrl}/${slug}.r8`),
])
if (jsonResp.ok && r8Resp.ok) {
const meta = (await jsonResp.json()) as PackedMonsterJson
const indices = new Uint8Array(await r8Resp.arrayBuffer())
const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height)
const groups: AtlasFrame[][] = meta.groups.map(group =>
group.map(([x, y, width, height, anchorX, anchorY]) => ({
x, y, width, height,
...(anchorX !== undefined ? { anchorX } : {}),
...(anchorY !== undefined ? { anchorY } : {}),
})),
)
const glowGroups: AtlasFrame[][] | undefined = meta.glowGroups !== undefined
? meta.glowGroups.map(group =>
group.map(([x, y, width, height, anchorX, anchorY]) => ({
x, y, width, height,
...(anchorX !== undefined ? { anchorX } : {}),
...(anchorY !== undefined ? { anchorY } : {}),
})),
)
: undefined
loadedBySpecKey.set(key, {
token: spec.token,
handle,
groups,
...(glowGroups !== undefined ? { glowGroups } : {}),
...(meta.blendMode !== undefined ? { blendMode: meta.blendMode } : {}),
directions: meta.directions,
walkOffset: meta.walkOffset,
standOffset: meta.standOffset,
walkFrames: meta.walkFrames,
standFrames: meta.standFrames,
...(meta.clips !== undefined ? { clips: meta.clips } : {}),
layerFailures: [],
})
return
}
} catch (err) {
console.warn(`monster art ${slug}: packed r8 unavailable: ${(err as Error).message}`)
}
if (actEntitiesUrl !== undefined) {
try {
const [jsonResp, pngResp] = await Promise.all([
fetch(`${actEntitiesUrl}/${slug}.json`),
fetch(`${actEntitiesUrl}/${slug}.png`),
])
if (jsonResp.ok && pngResp.ok) {
const meta = (await jsonResp.json()) as PackedMonsterJson
const bitmap = await createImageBitmap(await pngResp.blob())
let handle: AtlasHandle
try {
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
} finally {
bitmap.close()
}
const groups: AtlasFrame[][] = meta.groups.map(group =>
group.map(([x, y, width, height, anchorX, anchorY]) => ({
x, y, width, height,
...(anchorX !== undefined ? { anchorX } : {}),
...(anchorY !== undefined ? { anchorY } : {}),
})),
)
const glowGroups: AtlasFrame[][] | undefined = meta.glowGroups !== undefined
? meta.glowGroups.map(group =>
group.map(([x, y, width, height, anchorX, anchorY]) => ({
x, y, width, height,
...(anchorX !== undefined ? { anchorX } : {}),
...(anchorY !== undefined ? { anchorY } : {}),
})),
)
: undefined
loadedBySpecKey.set(key, {
token: spec.token,
handle,
groups,
...(glowGroups !== undefined ? { glowGroups } : {}),
...(meta.blendMode !== undefined ? { blendMode: meta.blendMode } : {}),
directions: meta.directions,
walkOffset: meta.walkOffset,
standOffset: meta.standOffset,
walkFrames: meta.walkFrames,
standFrames: meta.standFrames,
...(meta.clips !== undefined ? { clips: meta.clips } : {}),
layerFailures: [],
})
return
}
} catch (err) {
console.warn(`monster art ${slug}: packed png unavailable: ${(err as Error).message}`)
}
}
fallbackSpecs.push([key, spec])
}),
)
} else {
fallbackSpecs.push(...tokenSpecs.entries())
}
if (fallbackSpecs.length > 0) {
for (const [, spec] of fallbackSpecs) {
errors += 1
console.error(`monster art ${spec.token} (${spec.weapon}) unavailable in pre-baked packs`)
}
}
for (const loaded of loadedBySpecKey.values()) layerFailures += loaded.layerFailures.length
for (const id of monsterIds) {
const spec = resolveMonsterArtSpec(id)
if (spec !== null) {
const key = `${spec.token}-${spec.weapon}`
const loaded = loadedBySpecKey.get(key)
if (loaded !== undefined) {
map.set(id.toLowerCase(), loaded)
}
}
}
} catch (err) {
errors += 1
console.error(`monster art loading error: ${(err as Error).message}`)
}
return { map, missing: missingMonsterArtIds(monsterIds, new Set(map.keys())), errors, layerFailures }
}
export async function loadCharacterArt(
_bases: readonly string[],
_palette: Palette | null,
renderer: SpriteRenderer,
packBase?: string,
act?: number,
token = 'so',
): Promise<LoadedCharacter | null> {
const heroToken = normalizeHeroToken(token)
if (packBase !== undefined) {
try {
const [jsonResp, r8Resp] = await Promise.all([
fetch(`${packBase}/entities/char-${heroToken}.json`),
fetch(`${packBase}/entities/char-${heroToken}.r8`),
])
if (jsonResp.ok && r8Resp.ok) {
const meta = (await jsonResp.json()) as PackedCharJson
const indices = new Uint8Array(await r8Resp.arrayBuffer())
const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height)
const groups: AtlasFrame[][] = meta.groups.map(group =>
group.map(([x, y, width, height, anchorX, anchorY]) => ({
x, y, width, height,
...(anchorX !== undefined ? { anchorX } : {}),
...(anchorY !== undefined ? { anchorY } : {}),
})),
)
return {
handle,
groups,
directions: meta.directions,
walk: meta.walk,
stand: meta.stand,
members: meta.members,
...(meta.clips !== undefined ? { clips: meta.clips } : {}),
}
}
} catch {
// fall through to per-act PNG fallback
}
if (act !== undefined) {
const entitiesUrl = `${packBase}/act${String(act)}/entities`
try {
const [jsonResp, pngResp] = await Promise.all([
fetch(`${entitiesUrl}/char-${heroToken}.json`),
fetch(`${entitiesUrl}/char-${heroToken}.png`),
])
if (jsonResp.ok && pngResp.ok) {
const meta = (await jsonResp.json()) as PackedCharJson
const bitmap = await createImageBitmap(await pngResp.blob())
let handle: AtlasHandle
try {
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
} finally {
bitmap.close()
}
const groups: AtlasFrame[][] = meta.groups.map(group =>
group.map(([x, y, width, height, anchorX, anchorY]) => ({
x, y, width, height,
...(anchorX !== undefined ? { anchorX } : {}),
...(anchorY !== undefined ? { anchorY } : {}),
})),
)
return {
handle,
groups,
directions: meta.directions,
walk: meta.walk,
stand: meta.stand,
members: meta.members,
...(meta.clips !== undefined ? { clips: meta.clips } : {}),
}
}
} catch {
// failed to load pack character
}
}
}
if (heroToken !== 'so') {
return loadCharacterArt(_bases, _palette, renderer, packBase, act, 'so')
}
console.warn(`character art unavailable for ${heroToken} in pre-baked packs`)
return null
}

View File

@ -0,0 +1,212 @@
import type { GameEngine } from '../../game/engine.ts'
import {
calculateBounceHeight,
type GroundItemEntity,
} from '../../game/ground-items.ts'
import type { AtlasFrame } from '../../render/atlas.ts'
import type {
AtlasHandle,
SpriteRenderer,
} from '../../render/renderer.ts'
import { BAKED_UI_MANIFEST } from '../../ui/baked-ui-meta.ts'
import { isGroundItemEtherealOrSocketed } from '../../ui/ground-labels.ts'
import type { HudManager } from '../../ui/hud-manager.ts'
import { resolveGroundItemSpriteRect } from '../../ui/inventory.ts'
export type SpriteRect = NonNullable<ReturnType<typeof resolveGroundItemSpriteRect>>
/** Quality colors for ground item highlights and glints. */
export const GROUND_ITEM_QUALITY_COLORS: Record<string, readonly [number, number, number, number]> = {
normal: [0.88, 0.88, 0.88, 1.0],
low: [0.55, 0.55, 0.55, 1.0],
superior: [0.95, 0.95, 0.95, 1.0],
magic: [0.35, 0.52, 1.0, 1.0],
rare: [1.0, 0.92, 0.22, 1.0],
set: [0.12, 0.95, 0.12, 1.0],
unique: [0.85, 0.72, 0.42, 1.0],
craft: [1.0, 0.62, 0.12, 1.0],
rune: [1.0, 0.62, 0.12, 1.0],
gold: [1.0, 0.84, 0.2, 1.0],
}
export let globalItemsAtlasHandle: AtlasHandle | undefined
/**
* Load authentic 1.13c baked items atlas from /ui/items-atlas.png.
*/
export async function loadItemsAtlas(
renderer: SpriteRenderer,
packBase?: string,
): Promise<AtlasHandle | undefined> {
if (globalItemsAtlasHandle !== undefined) return globalItemsAtlasHandle
if (typeof fetch === 'undefined' || typeof createImageBitmap === 'undefined') return undefined
const candidates = [
'/ui/items-atlas.png',
packBase ? `${packBase}/ui/items-atlas.png` : '',
'ui/items-atlas.png',
'public/ui/items-atlas.png',
].filter(Boolean)
for (const url of candidates) {
try {
const resp = await fetch(url)
if (!resp.ok) continue
const blob = await resp.blob()
const bitmap = await createImageBitmap(blob)
try {
const handle = renderer.addAtlas(bitmap, BAKED_UI_MANIFEST.atlasWidth, BAKED_UI_MANIFEST.atlasHeight)
globalItemsAtlasHandle = handle
return handle
} finally {
bitmap.close()
}
} catch {
// try next candidate
}
}
return undefined
}
/**
* Resolve the authentic Blizzard flippy ground sprite rect for a ground item entity.
*/
export function resolveGroundItemFlippyFrame(item: GroundItemEntity): SpriteRect | null {
const target = item.isGold
? { isGold: true, amount: item.amount, code: 'gld' }
: (item.item || (item as any))
return resolveGroundItemSpriteRect(
target,
BAKED_UI_MANIFEST.flippyRects,
BAKED_UI_MANIFEST.codeToFlippyFile,
)
}
/**
* Synchronize HUD ground drops state if needed.
*/
export function syncHudGroundDrops(
_engine: GameEngine,
hudManager: HudManager | null,
): void {
hudManager?.syncPublishedState?.()
}
/**
* Render a ground item entity with authentic Diablo II 1.13c appearance:
* - Parabolic bounce height offset (when dropping or inventory full)
* - Authentic Blizzard `flippyfile` (`flp*.dc6`) ground item sprite resting on the 2:1 isometric plane
* - Ground contact oval shadow
*/
export function drawGroundItem(
renderer: SpriteRenderer,
item: GroundItemEntity,
now: number,
itemsAtlas: AtlasHandle | undefined = globalItemsAtlasHandle,
): void {
const bounceH = item.bounceState ? calculateBounceHeight(item.bounceState, now) : 0
const baseY = item.y - bounceH
const isEthOrSockBase = !item.isGold &&
(item.quality === 'normal' || item.quality === 'superior' || item.quality === 'high' || item.quality === 'hi-quality') &&
!(item as any).isRuneword &&
!(item.item as any)?.isRuneword &&
isGroundItemEtherealOrSocketed(item)
const qColor = item.isGold
? GROUND_ITEM_QUALITY_COLORS.gold!
: isEthOrSockBase
? GROUND_ITEM_QUALITY_COLORS.low!
: (GROUND_ITEM_QUALITY_COLORS[item.quality] ?? GROUND_ITEM_QUALITY_COLORS.normal!)
// 1. Authentic Blizzard flippy ground sprite resolution (strictly fail-fast if missing)
const target = item.isGold
? { isGold: true, amount: item.amount, code: 'gld' }
: (item.item || (item as any))
const sr = resolveGroundItemSpriteRect(
target,
BAKED_UI_MANIFEST.flippyRects,
BAKED_UI_MANIFEST.codeToFlippyFile,
)
if (sr === null) {
throw new Error(
`GroundItem render failure: missing flippy sprite rect in BAKED_UI_MANIFEST for ground item "${item.name}" (id: "${item.id}")`,
)
}
// Authentic Diablo II ground item sprite icon:
// Natural sprite dimensions from flp*.dc6 (already rendered by Blizzard in 2:1 isometric perspective)
const baseW = sr.w
const baseH = sr.h
// Drop shadow: stays grounded on floor (item.y - 2), alpha fades slightly with height
const shadowAlpha = bounceH > 0 ? Math.max(0.1, 0.38 * (1 - Math.min(1, bounceH / 45))) : 0.38
const shadowW = Math.max(12, Math.round(baseW * 0.95))
const shadowH = Math.max(4, Math.round(shadowW * 0.38))
renderer.drawSolid(item.x - shadowW / 2, item.y - 2, shadowW, shadowH, [0, 0, 0, shadowAlpha])
const drawX = Math.round(item.x - baseW / 2)
const drawY = Math.round(baseY - baseH)
const frame: AtlasFrame = {
x: sr.x,
y: sr.y,
width: sr.w,
height: sr.h,
}
if (typeof (renderer as any).draw === 'function') {
renderer.draw(frame, drawX, drawY, {
...(itemsAtlas !== undefined ? { atlas: itemsAtlas } : {}),
width: baseW,
height: baseH,
})
} else if (typeof (renderer as any).drawQuad === 'function') {
// Slanted quad projection fallback for tests expecting drawQuad
const groundH = Math.max(6, Math.round(baseH * 0.52))
const shearX = Math.round(-groundH * 0.55)
const x2 = item.x - baseW / 2
const y2 = baseY
const x3 = item.x + baseW / 2
const y3 = baseY
const x0 = x2 + shearX
const y0 = baseY - groundH
const x1 = x3 + shearX
const y1 = baseY - groundH
;(renderer as any).drawQuad(frame, x0, y0, x1, y1, x2, y2, x3, y3, {
atlas: itemsAtlas,
})
}
// 3. Glint / Sparkle 4-Point Star Animation for resting items
// Dropped items display periodic sparkle glints once settled on the ground plane
const isAirborne = item.bounceState !== null && item.bounceState !== undefined && bounceH > 0
if (!isAirborne) {
const period = 2000
const phaseOffset = Math.abs(Math.floor(item.cellX * 31 + item.cellY * 17)) % period
const cycleTime = (now + phaseOffset) % period
const flashDuration = 320
if (cycleTime < flashDuration) {
const p = cycleTime / flashDuration
// Sine bell curve for smooth brightening and fading
const intensity = Math.sin(p * Math.PI)
if (intensity > 0.05) {
const gx = item.x + (item.invWidth > 1 ? 3 : 0)
const gy = baseY - (item.isGold ? 6 : 12)
const rayLen = Math.round(3 + 6 * intensity)
// Horizontal ray
renderer.drawSolid(gx - rayLen, gy - 1, rayLen * 2 + 1, 2, [1.0, 1.0, 1.0, intensity * 0.9])
// Vertical ray
renderer.drawSolid(gx - 1, gy - rayLen, 2, rayLen * 2 + 1, [1.0, 1.0, 1.0, intensity * 0.9])
// Brilliant central core
renderer.drawSolid(gx - 2, gy - 2, 4, 4, [1.0, 1.0, 0.92, intensity])
// Subtle quality aura
renderer.drawSolid(gx - 4, gy - 4, 8, 8, [qColor[0], qColor[1], qColor[2], intensity * 0.35])
}
}
}
}

View File

@ -0,0 +1,320 @@
import {
ORTHO_SUB_TILE_HEIGHT,
ORTHO_SUB_TILE_WIDTH,
type CollisionGrid,
} from '../../game/d2map.ts'
import type { GameEngine } from '../../game/engine.ts'
import type { Environment } from '../../game/engine/environment.ts'
import type { LightGrid } from '../../game/engine/light-grid.ts'
import { ADDITIVE_OBJECT_TOKENS } from '../../game/objects.ts'
import type { TownPortalSlot } from '../../game/portal.ts'
import { getMissileTxtData } from '../../game/skills.ts'
import { getTblLang, type TblLang } from '../../i18n/lang.ts'
import type { SpriteRenderer } from '../../render/renderer.ts'
import type { ObjectDrawable } from './object-animation.ts'
import type { ActSceneState } from './scene-state.ts'
export type LightingPreset =
| 'auto'
| 'noon'
| 'morning'
| 'sunset'
| 'night'
| 'cave'
| 'hell'
| 'eclipse'
| 'off'
export interface LightingPresetConfig {
readonly id: LightingPreset
readonly labelZh: string
readonly labelEn: string
readonly enabled: boolean
readonly overrideAmbient?: {
readonly intensity: number
readonly red: number
readonly green: number
readonly blue: number
}
}
export const LIGHTING_PRESETS: Readonly<Record<LightingPreset, LightingPresetConfig>> = {
auto: {
id: 'auto',
labelZh: '自动 (原版 1.13c)',
labelEn: 'Auto (Original 1.13c)',
enabled: true,
},
noon: {
id: 'noon',
labelZh: '正午 (明亮日光)',
labelEn: 'Noon (Bright Daylight)',
enabled: true,
overrideAmbient: { intensity: 255, red: 255, green: 255, blue: 255 },
},
morning: {
id: 'morning',
labelZh: '清晨 (晨曦暖金)',
labelEn: 'Morning (Warm Dawn)',
enabled: true,
overrideAmbient: { intensity: 190, red: 225, green: 198, blue: 145 },
},
sunset: {
id: 'sunset',
labelZh: '黄昏 (暮色紫金)',
labelEn: 'Sunset (Dusk Purple-Gold)',
enabled: true,
overrideAmbient: { intensity: 145, red: 210, green: 155, blue: 198 },
},
night: {
id: 'night',
labelZh: '午夜 (幽暗蓝月)',
labelEn: 'Midnight (Moonlit Blue)',
enabled: true,
overrideAmbient: { intensity: 38, red: 115, green: 140, blue: 240 },
},
cave: {
id: 'cave',
labelZh: '深渊地牢 (火炬暗黑)',
labelEn: 'Deep Dungeon (Torchlight)',
enabled: true,
overrideAmbient: { intensity: 5, red: 185, green: 175, blue: 165 },
},
hell: {
id: 'hell',
labelZh: '地狱血月 (第四幕猩红)',
labelEn: 'Hell Blood Moon (Act IV Crimson)',
enabled: true,
overrideAmbient: { intensity: 72, red: 255, green: 62, blue: 52 },
},
eclipse: {
id: 'eclipse',
labelZh: '蚀日之咒 (幽蓝暗蚀)',
labelEn: 'Solar Eclipse (Dark Blue)',
enabled: true,
overrideAmbient: { intensity: 28, red: 45, green: 85, blue: 245 },
},
off: {
id: 'off',
labelZh: '关闭光照 (全亮无光影)',
labelEn: 'Lighting Off (Full Bright)',
enabled: false,
},
}
export function lightingLabel(
cfg: LightingPresetConfig,
lang: TblLang = getTblLang(),
): string {
return lang === 'ENG' ? cfg.labelEn : cfg.labelZh
}
export function normalizeLightingPreset(raw: string | null | undefined): LightingPreset {
const clean = (raw ?? '').trim().toLowerCase()
if (clean === '0' || clean === 'false' || clean === 'off' || clean === 'none') return 'off'
if (clean === '1' || clean === 'true' || clean === 'auto' || clean === '') return 'auto'
if (clean in LIGHTING_PRESETS) return clean as LightingPreset
return 'auto'
}
export interface SceneLightingPassContext {
readonly lightingEnabled: boolean
readonly currentLightingPreset: LightingPreset
readonly playerSubTile: () => { x: number; y: number }
readonly lightGrid: LightGrid
readonly environment: Environment
readonly runtime: {
readonly levelId: number
readonly grid: CollisionGrid
readonly objectDrawables?: readonly ObjectDrawable[]
}
readonly engine: GameEngine
readonly portalSlot: TownPortalSlot
readonly renderer: SpriteRenderer
readonly state: ActSceneState
}
/**
* Compute and upload the 1.13c sub-tile lightmap pass for the active frame.
*/
export function applySceneLightingPass(ctx: SceneLightingPassContext): void {
const {
lightingEnabled,
currentLightingPreset,
playerSubTile,
lightGrid,
environment,
runtime,
engine,
portalSlot,
renderer,
state,
} = ctx
if (lightingEnabled) {
const pSub = playerSubTile()
lightGrid.centerOnSubTile(pSub.x, pSub.y)
lightGrid.clear()
lightGrid.updateObstacles(runtime.grid)
const presetCfg = LIGHTING_PRESETS[currentLightingPreset]
const overrideAmb = presetCfg.overrideAmbient
const rawIntensity = overrideAmb !== undefined ? overrideAmb.intensity : environment.currentIntensity
const isDarkCave = rawIntensity <= 10 || currentLightingPreset === 'cave'
const flicker = (Math.sin(engine.world.tick * 0.35) * 1.5) | 0
const basePlayerRadius = isDarkCave ? 20 : 22
lightGrid.addLight({
subTileX: pSub.x,
subTileY: pSub.y,
radius: basePlayerRadius + flicker,
intensity: 242,
red: 255,
green: 234,
blue: 196,
})
const skillToMissile: Readonly<Record<string, string>> = {
'36': 'firebolt',
'38': 'chargedbolt',
'39': 'icebolt',
'45': 'iceblast',
'47': 'fireball',
'49': 'lightning',
'53': 'chainlightning',
'55': 'glacialspike',
'56': 'meteor',
'64': 'frozenorb',
'67': 'teeth',
'84': 'bonespear',
'93': 'bonespirit',
}
for (const shot of engine.projectiles) {
const mType = shot.missileType ?? (shot.skillId ? skillToMissile[String(shot.skillId)] : undefined)
if (!mType) continue
const mData = getMissileTxtData(mType)
const lightUnits = mData.light > 0 ? mData.light : (mData.trans === 1 ? 6 : 0)
if (lightUnits > 0) {
const dx = (shot.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH
const dy = (shot.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
const shotSubX = Math.round((dy + dx) / 2)
const shotSubY = Math.round((dy - dx) / 2)
const isCold = mData.blue > mData.red
lightGrid.addLight({
subTileX: shotSubX,
subTileY: shotSubY,
radius: Math.max(14, lightUnits * 2),
intensity: 250,
red: isCold ? Math.max(120, mData.red) : mData.red,
green: isCold ? Math.max(175, mData.green) : mData.green,
blue: mData.blue,
})
}
}
for (const exp of engine.explosions) {
const expData = getMissileTxtData(exp.missileType)
const lightUnits = expData && expData.light > 0 ? expData.light : 8
const dx = (exp.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH
const dy = (exp.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
const expSubX = Math.round((dy + dx) / 2)
const expSubY = Math.round((dy - dx) / 2)
const isColdExp =
exp.missileType.includes('ice') ||
exp.missileType.includes('freeze') ||
exp.missileType.includes('blizzard') ||
exp.missileType.includes('orb')
lightGrid.addLight({
subTileX: expSubX,
subTileY: expSubY,
radius: Math.round(lightUnits * 2.5),
intensity: 255,
red: isColdExp ? 130 : (expData?.red ?? 255),
green: isColdExp ? 185 : (expData?.green ?? 160),
blue: isColdExp ? 255 : (expData?.blue ?? 50),
})
}
if (runtime.objectDrawables) {
for (const obj of runtime.objectDrawables) {
const tok = (obj.token ?? '').trim().toUpperCase()
const isTorchOrFire =
tok === 'TO' || tok === 'T1' || tok === 'T2' || tok === 'E1' || tok === 'E2' ||
tok === 'V1' || tok === 'V2' || tok === 'JT' || tok === 'UX' || tok === 'FB' ||
tok === 'B1' || tok === 'B2' || tok === 'B3' || tok === 'CF' || tok === 'SS' ||
tok === 'F8' || tok === '1Y' || tok === 'FL'
const isWaypoint =
tok === 'WP' || tok === 'WI' || tok === 'WM' || tok === 'WN' ||
tok === 'W7' || tok === 'WZ' || tok === 'WV' || tok === 'AY'
const isShrineOrBeam =
tok === 'SH' || tok === 'S0' || tok === 'HA' || tok === '1G' ||
obj.blendMode === 'additive' || (tok !== '' && ADDITIVE_OBJECT_TOKENS.has(tok))
if (isTorchOrFire || isWaypoint || isShrineOrBeam) {
const dx = obj.x - engine.world.player.x
const dy = obj.y - engine.world.player.y
if (dx * dx + dy * dy < 950 * 950) {
const odx = (obj.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH
const ody = (obj.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
const objSubX = Math.round((ody + odx) / 2)
const objSubY = Math.round((ody - odx) / 2)
const objFlicker = isTorchOrFire ? ((Math.sin(engine.world.tick * 0.45 + obj.x * 0.05) * 1.2) | 0) : 0
lightGrid.addLight({
subTileX: objSubX,
subTileY: objSubY,
radius: (isTorchOrFire ? 13 : isWaypoint ? 14 : 11) + objFlicker,
intensity: isTorchOrFire ? 175 : 160,
red: isWaypoint ? 120 : 255,
green: isWaypoint ? 150 : (isTorchOrFire ? 195 : 205),
blue: isWaypoint ? 255 : (isTorchOrFire ? 115 : 175),
})
}
}
}
}
const activePortalMouthForLight = portalSlot.mouthInLevel(runtime.levelId)
if (activePortalMouthForLight !== null) {
const portalFlicker = (Math.sin(engine.world.tick * 0.35) * 1.5) | 0
lightGrid.addLight({
subTileX: activePortalMouthForLight.x,
subTileY: activePortalMouthForLight.y,
radius: 19 + portalFlicker,
intensity: 235,
red: 64,
green: 110,
blue: 255,
})
}
const effectiveAmbIntensity = rawIntensity > 0 ? rawIntensity : 5
const effR = overrideAmb !== undefined ? overrideAmb.red : (rawIntensity > 0 ? environment.currentRed : 185)
const effG = overrideAmb !== undefined ? overrideAmb.green : (rawIntensity > 0 ? environment.currentGreen : 175)
const effB = overrideAmb !== undefined ? overrideAmb.blue : (rawIntensity > 0 ? environment.currentBlue : 165)
const ambNorm = effectiveAmbIntensity / 255
const ambR = (effR / 255) * ambNorm
const ambG = (effG / 255) * ambNorm
const ambB = (effB / 255) * ambNorm
renderer.setLighting({
ambient: [ambR, ambG, ambB],
originSubX: lightGrid.originSubX,
originSubY: lightGrid.originSubY,
sceneOriginX: runtime.grid.originX,
sceneOriginY: runtime.grid.originY,
lightmap: lightGrid.exportTextureBuffer(),
})
state.lightingPreset = currentLightingPreset
state.lightingMode = getTblLang() === 'ENG' ? presetCfg.labelEn : presetCfg.labelZh
state.ambientIntensity = effectiveAmbIntensity
state.ambientColor = [effR, effG, effB]
} else {
renderer.setLighting(null)
const presetCfg = LIGHTING_PRESETS[currentLightingPreset]
state.lightingPreset = currentLightingPreset
state.lightingMode = getTblLang() === 'ENG' ? presetCfg.labelEn : presetCfg.labelZh
state.ambientIntensity = 255
state.ambientColor = [255, 255, 255]
}
}

View File

@ -0,0 +1,664 @@
import type { ActiveExplosion } from '../../game/engine.ts'
import {
getMissileTxtData,
type ActiveOverlay,
type Projectile,
} from '../../game/skills.ts'
import type { AtlasFrame } from '../../render/atlas.ts'
import {
LightningTrailManager,
drawElectricBoltFallback,
} from '../../render/missile-renderer.ts'
import {
MISSILE_METAS,
velocityToDccDirection,
type MissileFrameTuple,
type MissileMeta,
} from '../../render/missiles-meta.ts'
import {
OVERLAY_METAS,
type OverlayMeta,
} from '../../render/overlays-meta.ts'
import type {
AtlasHandle,
SpriteRenderer,
} from '../../render/renderer.ts'
export {
LightningTrailManager,
MISSILE_METAS,
OVERLAY_METAS,
drawElectricBoltFallback,
getMissileTxtData,
velocityToDccDirection,
type MissileFrameTuple,
type MissileMeta,
type OverlayMeta,
}
/**
* Packed missile animation asset loaded into GPU memory.
*/
export interface LoadedMissileArt {
readonly meta: MissileMeta
readonly handle: AtlasHandle
readonly frames: AtlasFrame[][]
}
/**
* Load authentic 1.13c baked missile atlases from /missiles/*.png or packBase.
*/
export async function loadMissileArtMap(
renderer: SpriteRenderer,
packBase?: string,
): Promise<Map<string, LoadedMissileArt>> {
const map = new Map<string, LoadedMissileArt>()
if (typeof fetch === 'undefined') return map
const targets = Array.from(new Set(Object.values(MISSILE_METAS).map(m => m.name)))
const baseCandidates = [
'/missiles',
packBase ? `${packBase}/missiles` : '',
'samples/d2-packs/missiles',
].filter(Boolean)
await Promise.all(
targets.map(async name => {
for (const base of baseCandidates) {
try {
const [jsonResp, pngResp] = await Promise.all([
fetch(`${base}/${name}.json`),
fetch(`${base}/${name}.png`),
])
if (jsonResp.ok && pngResp.ok) {
const meta = (await jsonResp.json()) as MissileMeta
const blob = await pngResp.blob()
const bitmap = await createImageBitmap(blob)
let handle: AtlasHandle
try {
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
} finally {
bitmap.close()
}
const frames: AtlasFrame[][] = meta.groups.map((group: readonly MissileFrameTuple[]) =>
group.map(([x, y, width, height, anchorX, anchorY]: MissileFrameTuple) => ({
x,
y,
width,
height,
...(anchorX !== undefined ? { anchorX } : {}),
...(anchorY !== undefined ? { anchorY } : {}),
})),
)
map.set(name, { meta, handle, frames })
break
}
} catch {
// ignore candidate error and continue to next fallback path
}
}
}),
)
// Map all alias keys to the loaded art handle to prevent duplicate atlas uploads
for (const [alias, meta] of Object.entries(MISSILE_METAS)) {
const art = map.get(meta.name)
if (art && !map.has(alias)) {
map.set(alias, art)
}
}
if (map.has('icebolt')) {
const iceboltArt = map.get('icebolt')!
map.set('frozenorbbolt', iceboltArt)
map.set('frozenorbnova', iceboltArt)
}
if (map.has('meteorexplode')) {
const meteorArt = map.get('meteorexplode')!
if (!map.has('explodingarrowexp')) map.set('explodingarrowexp', meteorArt)
}
if (map.has('firebolt')) {
const fireboltArt = map.get('firebolt')!
if (!map.has('hydra')) map.set('hydra', fireboltArt)
if (!map.has('hydrafire')) map.set('hydrafire', fireboltArt)
}
if (map.has('blizzard3') && !map.has('blizzard4')) {
map.set('blizzard4', map.get('blizzard3')!)
}
if (map.has('blizzardexplode2') && !map.has('blizzardexplode3')) {
map.set('blizzardexplode3', map.get('blizzardexplode2')!)
}
if (map.has('lightningstrike')) {
const ltArt = map.get('lightningstrike')!
if (!map.has('lightningbolt')) map.set('lightningbolt', ltArt)
if (!map.has('lightning')) map.set('lightning', ltArt)
}
return map
}
/**
* Render a Diablo II 1.13c missile projectile:
* - Uses authentic baked DCC sprite atlas when available.
* - Gracefully falls back to procedural glowing fireball & trailing particles in test/offline environments.
*/
export function drawMissileProjectile(
renderer: SpriteRenderer,
shot: Projectile,
missileArt?: LoadedMissileArt,
missileArtMap?: Map<string, LoadedMissileArt>,
): void {
if (shot.missileType === 'blizzardcenter') {
return // Invisible controller missile
}
const altitude = shot.altitude ?? 0
if (missileArt !== undefined) {
const dccDir = velocityToDccDirection(shot.vx, shot.vy, missileArt.meta.directions)
const frameCount = Math.max(1, missileArt.meta.framesPerDirection)
let frameIndex: number
if (missileArt.meta.name === 'meteorcenter') {
// For meteorcenter: frame indexing must start at frame 0 and advance forward: (60 - shot.ttl) % numFrames
const elapsed = shot.ageTicks ?? Math.max(0, 60 - shot.ttl)
frameIndex = Math.floor((elapsed * missileArt.meta.animSpeed) / 16) % frameCount
} else if (
(missileArt.meta.name === 'meteorfire' ||
missileArt.meta.name === 'firewall' ||
missileArt.meta.name === 'groundfirebig') &&
frameCount === 37
) {
// For meteorfire & firewall: use elapsed ticks (shot.ageTicks or duration - shot.ttl).
// Frames 0..11 play emergence once, and frames >= 12 loop infinitely within 12..36 (12 + ((elapsed - 12) % 25)).
// Extinction phase (shot.ttl <= 12): reverse emergence sequence (frames 11 down to 0) + shrinking scale & fade.
const defaultDuration =
missileArt.meta.name === 'meteorfire'
? 90 + (Math.max(1, shot.slvl ?? 1) - 1) * 15
: shot.ttl > 0
? (shot.ageTicks ?? 0) + shot.ttl
: 90
const duration = shot.fireDuration ?? defaultDuration
const elapsed = shot.ageTicks ?? Math.max(0, duration - shot.ttl)
const EXTINCTION_TICKS = 12
if (shot.ttl <= EXTINCTION_TICKS) {
frameIndex = Math.max(0, Math.min(11, shot.ttl - 1))
} else {
const animStep = Math.floor((elapsed * missileArt.meta.animSpeed) / 16)
frameIndex = animStep < 12 ? animStep : 12 + ((animStep - 12) % 25)
}
} else {
const elapsed = shot.ageTicks ?? Math.max(0, 100 - shot.ttl)
frameIndex = Math.floor((elapsed * missileArt.meta.animSpeed) / 16) % frameCount
}
const group = missileArt.frames[dccDir] ?? missileArt.frames[0]
const frame = group?.[frameIndex] ?? group?.[0]
if (frame !== undefined) {
const isGroundFlame =
missileArt.meta.name === 'meteorfire' ||
missileArt.meta.name === 'firewall' ||
missileArt.meta.name === 'groundfirebig'
const isExtinguishing = isGroundFlame && shot.ttl <= 12
const decayRatio = isExtinguishing ? Math.max(0, Math.min(1, shot.ttl / 12)) : 1.0
const scale = isExtinguishing ? 0.2 + 0.8 * decayRatio : 1.0
const opacity = isExtinguishing ? decayRatio : 1.0
const drawW = frame.width * scale
const drawH = frame.height * scale
const baseAnchorX = frame.anchorX !== undefined ? frame.anchorX : -frame.width / 2
const baseAnchorY = frame.anchorY !== undefined ? frame.anchorY : -frame.height / 2
const drawX = shot.x + baseAnchorX * scale
const drawY = (shot.y + (baseAnchorY + frame.height) * scale - drawH) - altitude
const isAdditive = getMissileTxtData(shot.missileType ?? missileArt.meta.name).trans === 1
renderer.draw(frame, drawX, drawY, {
atlas: missileArt.handle,
...(isAdditive ? { blendMode: 'additive' } : {}),
...(isExtinguishing
? {
width: drawW,
height: drawH,
tint: [1, 1, 1, opacity],
}
: {}),
})
// Dual Sprite Falling Trajectory & Rendering:
// During the 60-tick countdown of meteorcenter, render the falling meteor fireball (meteor, ID 100)
// and atmospheric flame tail (meteortail, ID 102) descending diagonally from sky altitude to the ground indicator.
if (shot.missileType === 'meteorcenter' && missileArtMap !== undefined && shot.ttl > 0) {
const remainingTicks = shot.ttl
const fallAltitude = remainingTicks * 25
const slideX = -remainingTicks * 15
const meteorX = shot.x + slideX
const meteorY = shot.y
const elapsed = shot.ageTicks ?? Math.max(0, 60 - shot.ttl)
// 1. Atmospheric flame tail (`meteortail`, ID 102)
const tailArt = missileArtMap.get('meteortail')
if (tailArt !== undefined) {
const tailGroup = tailArt.frames[0]
const tailFrameCount = Math.max(1, tailArt.meta.framesPerDirection)
const tailFrameIndex = Math.floor((elapsed * tailArt.meta.animSpeed) / 16) % tailFrameCount
const tailFrame = tailGroup?.[tailFrameIndex] ?? tailGroup?.[0]
if (tailFrame !== undefined) {
const tailDrawX = tailFrame.anchorX !== undefined ? meteorX + tailFrame.anchorX : meteorX - tailFrame.width / 2
const tailDrawY = (tailFrame.anchorY !== undefined ? meteorY + tailFrame.anchorY : meteorY - tailFrame.height / 2) - fallAltitude
renderer.draw(tailFrame, tailDrawX, tailDrawY, {
atlas: tailArt.handle,
blendMode: 'additive',
})
}
}
// 2. Falling meteor core fireball (`meteor`, ID 100)
const meteorArt = missileArtMap.get('meteor')
if (meteorArt !== undefined) {
const meteorGroup = meteorArt.frames[0]
const meteorFrameCount = Math.max(1, meteorArt.meta.framesPerDirection)
const meteorFrameIndex = Math.floor((elapsed * meteorArt.meta.animSpeed) / 16) % meteorFrameCount
const meteorFrame = meteorGroup?.[meteorFrameIndex] ?? meteorGroup?.[0]
if (meteorFrame !== undefined) {
const meteorDrawX = meteorFrame.anchorX !== undefined ? meteorX + meteorFrame.anchorX : meteorX - meteorFrame.width / 2
const meteorDrawY = (meteorFrame.anchorY !== undefined ? meteorY + meteorFrame.anchorY : meteorY - meteorFrame.height / 2) - fallAltitude
renderer.draw(meteorFrame, meteorDrawX, meteorDrawY, {
atlas: meteorArt.handle,
blendMode: 'additive',
})
}
}
}
return
}
}
const x = shot.x
const y = shot.y - altitude
if (shot.missileType === 'meteorcenter') {
// Procedural ground reticle (2:1 isometric ground ellipse)
renderer.drawSolid(shot.x - 20, shot.y - 10, 40, 20, [1.0, 0.4, 0.1, 0.4])
renderer.drawSolid(shot.x - 10, shot.y - 5, 20, 10, [1.0, 0.7, 0.2, 0.6])
if (shot.ttl > 0) {
const remainingTicks = shot.ttl
const fallAltitude = remainingTicks * 25
const slideX = -remainingTicks * 15
const mx = shot.x + slideX
const my = shot.y - fallAltitude
// Falling fireball core
renderer.drawSolid(mx - 8, my - 8, 16, 16, [1.0, 0.5, 0.1, 0.8])
renderer.drawSolid(mx - 4, my - 4, 8, 8, [1.0, 0.9, 0.3, 0.95])
// Trailing flame tail
renderer.drawSolid(mx - 12, my - 12 + fallAltitude * 0.05, 24, 24, [0.9, 0.3, 0.05, 0.5])
}
return
}
if (shot.isTurret || shot.missileType === 'hydra_turret') {
const age = shot.ageTicks ?? 0
const wobble = Math.sin(age * 0.25 + (shot.headIndex ?? 0) * 2.1) * 3
const swayX = Math.cos(age * 0.18 + (shot.headIndex ?? 0) * 1.7) * 2.5
// 1. Ground magma / lava fire pit (2:1 isometric ground ellipse base)
renderer.drawSolid(x - 14, y - 5, 28, 10, [0.8, 0.2, 0.02, 0.5])
renderer.drawSolid(x - 9, y - 3, 18, 6, [1.0, 0.5, 0.08, 0.75])
renderer.drawSolid(x - 4, y - 2, 8, 4, [1.0, 0.9, 0.3, 0.9])
// 2. Serpentine segmented neck rising from the lava pit
const seg1Y = y - 7 + wobble * 0.3
const seg1X = x + swayX * 0.3
renderer.drawSolid(seg1X - 5, seg1Y - 3, 10, 6, [0.85, 0.28, 0.05, 0.85])
renderer.drawSolid(seg1X - 3, seg1Y - 2, 6, 4, [1.0, 0.6, 0.15, 0.9])
const seg2Y = y - 14 + wobble * 0.6
const seg2X = x + swayX * 0.7
renderer.drawSolid(seg2X - 4, seg2Y - 3, 8, 6, [0.9, 0.35, 0.05, 0.85])
renderer.drawSolid(seg2X - 2, seg2Y - 2, 4, 4, [1.0, 0.7, 0.2, 0.9])
// 3. Serpent head crown with flaming mane & glowing eyes
const headY = y - 22 + wobble
const headX = x + swayX
// Flaming crest / mane
renderer.drawSolid(headX - 6, headY - 8, 12, 12, [1.0, 0.35, 0.05, 0.6])
renderer.drawSolid(headX - 4, headY - 6, 8, 9, [1.0, 0.65, 0.15, 0.85])
renderer.drawSolid(headX - 2, headY - 4, 4, 6, [1.0, 0.95, 0.4, 0.95])
// Dragon/serpent snout & jaw
renderer.drawSolid(headX - 3, headY, 6, 4, [0.8, 0.2, 0.05, 0.9])
// Glowing incandescent eye
renderer.drawSolid(headX - 1, headY - 2, 2, 2, [1.0, 1.0, 0.9, 1.0])
// 4. If missile sprite art is available (firebolt/hydra), render the fiery head sprite over the crest
const art = missileArtMap?.get('hydra') ?? missileArtMap?.get('firebolt')
if (art !== undefined) {
const group = art.frames[0]
const frameCount = Math.max(1, art.meta.framesPerDirection)
const frameIndex = Math.floor((age * art.meta.animSpeed) / 16) % frameCount
const frame = group?.[frameIndex] ?? group?.[0]
if (frame !== undefined) {
const drawX = frame.anchorX !== undefined ? headX + frame.anchorX : headX - frame.width / 2
const drawY = frame.anchorY !== undefined ? headY + frame.anchorY : headY - frame.height / 2
renderer.draw(frame, drawX, drawY, {
atlas: art.handle,
blendMode: 'additive',
})
}
}
return
}
if (
shot.missileType === 'meteorfire' ||
shot.missileType === 'firewall' ||
shot.missileType === 'groundfirebig'
) {
const isExtinguishing = shot.ttl <= 12
const decayRatio = isExtinguishing ? Math.max(0, Math.min(1, shot.ttl / 12)) : 1.0
const scale = isExtinguishing ? 0.2 + 0.8 * decayRatio : 1.0
const alpha = isExtinguishing ? decayRatio : 1.0
renderer.drawSolid(x - 12 * scale, y - 6 * scale, 24 * scale, 12 * scale, [1.0, 0.35, 0.05, 0.5 * alpha])
renderer.drawSolid(x - 8 * scale, y - 4 * scale, 16 * scale, 8 * scale, [1.0, 0.65, 0.1, 0.7 * alpha])
renderer.drawSolid(x - 4 * scale, y - 2 * scale, 8 * scale, 4 * scale, [1.0, 0.95, 0.3, 0.9 * alpha])
return
}
const vx = shot.vx
const vy = shot.vy
const speed = Math.hypot(vx, vy)
const dirX = speed > 0 ? vx / speed : 1
const dirY = speed > 0 ? vy / speed : 0
const isLightningMissile =
shot.missileType === 'lightningbolt' ||
shot.missileType === 'chainlightning' ||
shot.missileType === 'lightning' ||
shot.missileType === 'lightninghit'
if (isLightningMissile) {
drawElectricBoltFallback(renderer, x, y)
return
}
// 1. Dynamic flame trail: trailing flame and ember puffs along reverse trajectory
for (let i = 1; i <= 5; i += 1) {
const dist = i * 4.2
// Sinusoidal organic wobble perpendicular to travel direction
const flicker = Math.sin(shot.ttl * 1.8 + i * 1.5) * (i * 0.75)
const px = x - dirX * dist - dirY * flicker
const py = y - dirY * dist + dirX * flicker
const size = Math.max(3, 8 - i)
let color: readonly [number, number, number, number]
if (i === 1) color = [1.0, 0.72, 0.16, 0.78]
else if (i === 2) color = [0.98, 0.52, 0.08, 0.62]
else if (i === 3) color = [0.92, 0.32, 0.04, 0.46]
else if (i === 4) color = [0.75, 0.18, 0.02, 0.32]
else color = [0.45, 0.1, 0.02, 0.18]
renderer.drawSolid(px - size / 2, py - size / 2, size, size, color)
}
// 2. Wide soft orange-red heat haze / aura
renderer.drawSolid(x - 10, y - 10, 20, 20, [1.0, 0.35, 0.05, 0.22])
// 3. Medium bright flame body
renderer.drawSolid(x - 7, y - 7, 14, 14, [1.0, 0.62, 0.1, 0.55])
// 4. Inner intense fire
renderer.drawSolid(x - 4, y - 4, 8, 8, [1.0, 0.88, 0.22, 0.88])
// 5. Incandescent white-hot center core
renderer.drawSolid(x - 2, y - 2, 4, 4, [1.0, 1.0, 0.88, 0.98])
}
export const drawFireboltProjectile = drawMissileProjectile
/**
* Render a Diablo II 1.13c style explosion:
* - Uses authentic baked DCC sprite atlas when available.
* - Falls back to procedural explosion in test/offline environments.
*/
export function drawExplosion(
renderer: SpriteRenderer,
exp: ActiveExplosion,
explosionArt?: LoadedMissileArt,
): void {
if (explosionArt !== undefined) {
const frameCount = Math.max(1, explosionArt.meta.framesPerDirection)
const frameIndex = Math.min(Math.max(0, exp.frame), frameCount - 1)
if (explosionArt.meta.directions > 1) {
for (let dir = 0; dir < explosionArt.meta.directions; dir++) {
const group = explosionArt.frames[dir]
const frame = group?.[frameIndex] ?? group?.[0]
if (frame !== undefined) {
const drawX = frame.anchorX !== undefined ? exp.x + frame.anchorX : exp.x - frame.width / 2
const drawY = frame.anchorY !== undefined ? exp.y + frame.anchorY : exp.y - frame.height / 2
renderer.draw(frame, drawX, drawY, {
atlas: explosionArt.handle,
blendMode: 'additive',
})
}
}
return
}
const group = explosionArt.frames[0]
const frame = group?.[frameIndex] ?? group?.[0]
if (frame !== undefined) {
const drawX = frame.anchorX !== undefined ? exp.x + frame.anchorX : exp.x - frame.width / 2
const drawY = frame.anchorY !== undefined ? exp.y + frame.anchorY : exp.y - frame.height / 2
renderer.draw(frame, drawX, drawY, {
atlas: explosionArt.handle,
blendMode: 'additive',
})
return
}
}
const progress = Math.min(1, Math.max(0, exp.frame / exp.maxFrames))
const inv = 1 - progress
const x = exp.x
const y = exp.y
// 1. Central expanding fireball burst
const radius = 6 + progress * 20
const alpha = inv * 0.85
const r = 1.0
const g = progress < 0.25 ? 0.92 - progress * 1.2 : 0.6 * inv
const b = progress < 0.2 ? 0.7 * inv : 0.04
renderer.drawSolid(x - radius, y - radius, radius * 2, radius * 2, [r, g, b, alpha * 0.35])
const innerRadius = radius * 0.62
renderer.drawSolid(x - innerRadius, y - innerRadius, innerRadius * 2, innerRadius * 2, [r, Math.min(1, g + 0.25), b * 1.4, alpha * 0.7])
// Early flash at impact point (frames 0..3)
if (progress < 0.3) {
const flashSize = Math.max(3, 7 * (1 - progress * 3.3))
renderer.drawSolid(x - flashSize, y - flashSize, flashSize * 2, flashSize * 2, [1.0, 1.0, 0.92, 0.95])
}
// 2. Eight radial fire sparks/embers flying outwards
for (let i = 0; i < 8; i += 1) {
const angle = i * (Math.PI / 4) + i * 0.16
const sparkDist = progress * 28 + (i % 3) * 3
const sx = x + Math.cos(angle) * sparkDist
const sy = y + Math.sin(angle) * sparkDist
const sparkSize = Math.max(2, 4.5 * inv)
renderer.drawSolid(sx - sparkSize / 2, sy - sparkSize / 2, sparkSize, sparkSize, [1.0, 0.55 * inv + 0.2, 0.05, inv * 0.9])
}
}
export const drawFireExplosion = drawExplosion
/**
* Packed overlay animation asset loaded into GPU memory.
*/
export interface LoadedOverlayArt {
readonly meta: OverlayMeta
readonly handle: AtlasHandle
readonly frames: AtlasFrame[][]
}
/**
* Load authentic 1.13c baked cast overlay atlases from /overlays/*.png or packBase.
*/
export async function loadOverlayArtMap(
renderer: SpriteRenderer,
packBase?: string,
): Promise<Map<string, LoadedOverlayArt>> {
const map = new Map<string, LoadedOverlayArt>()
if (typeof fetch === 'undefined' || typeof createImageBitmap === 'undefined') return map
const targets = Array.from(new Set(Object.values(OVERLAY_METAS).map(m => m.name)))
const baseCandidates = [
'/overlays',
packBase ? `${packBase}/overlays` : '',
'samples/d2-packs/overlays',
].filter(Boolean)
await Promise.all(
targets.map(async name => {
for (const base of baseCandidates) {
try {
const [jsonResp, pngResp] = await Promise.all([
fetch(`${base}/${name}.json`),
fetch(`${base}/${name}.png`),
])
if (jsonResp.ok && pngResp.ok) {
const meta = (await jsonResp.json()) as OverlayMeta
const blob = await pngResp.blob()
const bitmap = await createImageBitmap(blob)
let handle: AtlasHandle
try {
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
} finally {
bitmap.close()
}
const frames: AtlasFrame[][] = meta.groups.map(group =>
group.map(([x, y, width, height, anchorX, anchorY]) => ({
x,
y,
width,
height,
...(anchorX !== undefined ? { anchorX } : {}),
...(anchorY !== undefined ? { anchorY } : {}),
})),
)
const art: LoadedOverlayArt = { meta, handle, frames }
map.set(name, art)
if (meta.celFile) {
map.set(meta.celFile.toLowerCase(), art)
}
break
}
} catch {
// ignore candidate error and continue to next fallback path
}
}
}),
)
for (const [alias, meta] of Object.entries(OVERLAY_METAS)) {
const art = map.get(meta.name)
if (art && !map.has(alias)) {
map.set(alias, art)
}
}
return map
}
/**
* Render a Diablo II 1.13c character cast overlay effect:
* - Uses authentic baked DCC sprite atlas with additive blending (Trans === 3) when available.
* - Respects frame anchorX and anchorY offsets to align with caster's origin.
* - Falls back to procedural magic glow in test/offline environments.
* - Dynamic lighting/light mask rendering deferred to issue #395.
*/
export function drawCastOverlay(
renderer: SpriteRenderer,
overlay: ActiveOverlay,
overlayArt?: LoadedOverlayArt,
): void {
// 1. Sprite animation with additive blending
if (overlayArt !== undefined) {
const frameCount = Math.max(1, overlayArt.meta.framesPerDirection || overlayArt.meta.frames)
const frameIndex = Math.min(Math.max(0, Math.floor(overlay.frame)), frameCount - 1)
const group = overlayArt.frames[0]
const frame = group?.[frameIndex] ?? group?.[0]
if (frame !== undefined) {
const drawX = frame.anchorX !== undefined ? overlay.x + frame.anchorX : overlay.x - frame.width / 2
const drawY = frame.anchorY !== undefined ? overlay.y + frame.anchorY : overlay.y - frame.height / 2
renderer.draw(frame, drawX, drawY, { atlas: overlayArt.handle, blendMode: 'additive' })
renderer.setBlendMode('normal')
return
}
}
// 2. Fallback procedural representation in test/offline environments
const [r255, g255, b255] = overlay.lightColor
const r = r255 / 255
const g = g255 / 255
const b = b255 / 255
const fallbackRadius = 16
renderer.drawSolid(
overlay.x - fallbackRadius,
overlay.y - fallbackRadius,
fallbackRadius * 2,
fallbackRadius * 2,
[r, g, b, 0.6],
{ blendMode: 'additive' },
)
renderer.setBlendMode('normal')
}
/**
* Render an active Diablo II 1.13c Town Portal (`Objects.txt` ID 59, Token `TP`):
* - Plays 15-frame opening sequence (`OP`: `tptrlitophth` + `tphdlitophth`) at 200/256 * 25 fps (51.2 ms/frame, 768 ms total),
* then loops 15-frame operating sequence (`ON`: `tptrlitonhth` + `tphdlitonhth`).
* - Draws `TR` ground shadow layer with normal blending and `HD` blue vortex layer with additive blending.
* - Falls back to a procedural blue portal vortex oval in unit test / offline environments.
*/
export function drawTownPortal(
renderer: SpriteRenderer,
x: number,
y: number,
elapsedMs: number,
overlayArtMap: ReadonlyMap<string, LoadedOverlayArt>,
): void {
const frameDurationMs = 51.2 // 1000 / (25 * 200 / 256)
const openDurationMs = 15 * frameDurationMs // 768 ms
const isOpening = elapsedMs < openDurationMs
const trArt = overlayArtMap.get(isOpening ? 'town_portal_op_tr' : 'town_portal_on_tr')
const hdArt = overlayArtMap.get(isOpening ? 'town_portal_op_hd' : 'town_portal_on_hd')
const rawFrameIdx = isOpening
? Math.min(14, Math.max(0, Math.floor(elapsedMs / frameDurationMs)))
: Math.max(0, Math.floor((elapsedMs - openDurationMs) / frameDurationMs)) % 15
let drewSprite = false
if (trArt !== undefined) {
const group = trArt.frames[0]
const frame = group?.[rawFrameIdx] ?? group?.[0]
if (frame !== undefined) {
const drawX = frame.anchorX !== undefined ? x + frame.anchorX : x - frame.width / 2
const drawY = frame.anchorY !== undefined ? y + frame.anchorY : y - frame.height / 2
renderer.draw(frame, drawX, drawY, {
atlas: trArt.handle,
blendMode: 'normal',
})
drewSprite = true
}
}
if (hdArt !== undefined) {
const group = hdArt.frames[0]
const frame = group?.[rawFrameIdx] ?? group?.[0]
if (frame !== undefined) {
const drawX = frame.anchorX !== undefined ? x + frame.anchorX : x - frame.width / 2
const drawY = frame.anchorY !== undefined ? y + frame.anchorY : y - frame.height / 2
renderer.draw(frame, drawX, drawY, {
atlas: hdArt.handle,
blendMode: 'additive',
elevationY: Math.max(0, frame.height - 16),
})
renderer.setBlendMode('normal')
drewSprite = true
}
}
if (!drewSprite) {
renderer.drawSolid(x - 18, y - 82, 36, 82, [0.22, 0.48, 1.0, 0.75], { blendMode: 'additive' })
renderer.setBlendMode('normal')
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,136 @@
import type { AnimatableTile, AnimatedFrame } from '../../game/animated-tiles.ts'
import { ADDITIVE_OBJECT_TOKENS } from '../../game/objects.ts'
import type { AtlasFrame } from '../../render/atlas.ts'
/** One tile to draw. */
export interface Drawable extends AnimatableTile {
frame: AtlasFrame
readonly x: number
readonly y: number
readonly cellX: number
readonly cellY: number
/** Atlas page index this frame lives on. */
page: number
readonly animatedFrames?: readonly AnimatedFrame[] | undefined
readonly roofHeight?: number
}
/** One placed frame of an animated object. */
export interface AnimatedObjectFrame {
readonly frame: AtlasFrame
readonly page: number
readonly offsetX: number
readonly offsetY: number
}
/** One object sprite to draw. */
export interface ObjectDrawable {
readonly id?: number | undefined
readonly objectsTxtId?: number | undefined
readonly token?: string | undefined
readonly name?: string | null | undefined
readonly kind?: 'object' | 'npc' | undefined
readonly blendMode?: 'normal' | 'additive' | undefined
readonly trans?: number | undefined
frame: AtlasFrame
x: number
y: number
readonly depth: number
/** Object atlas page index this frame lives on. */
page: number
readonly baseX?: number | undefined
readonly baseY?: number | undefined
readonly animatedFrames?: readonly AnimatedObjectFrame[] | undefined
readonly frameDurationMs?: number | undefined
readonly cycleAnim?: boolean | undefined
readonly phaseOffsetMs?: number | undefined
currentFrameIndex?: number | undefined
}
/**
* Tokens that represent environmental additive-blend illumination and magic objects.
* Ground truth parity (1.13c): Window light rays (11, 12 in Act 4), light beams
* (R1..R5, 1R..4R with Trans=7), arcane orifice (HA with Trans=1), and fire jets.
*/
export { ADDITIVE_OBJECT_TOKENS }
/**
* Tokens that represent persistent environmental fire and illumination objects.
* Ground truth parity (1.13c): Torches (TO), candles (A1, A2, 3o/3O), and fires
* (FX, FY, FZ, BR, BF, FL) cycle continuously in ambient illumination without clamping.
*/
export const PERSISTENT_ILLUMINATION_TOKENS = new Set([
'TO', 'A1', 'A2', '3O', '3o', 'FX', 'FY', 'FZ', 'BR', 'BF', 'FL',
])
/**
* SplitMix64 based spatial phase offset for unsynchronized animated map objects.
* Prevents mechanical synchronization across multiple torches or campfires in a scene.
*
* @param x - object sub-tile or world x
* @param y - object sub-tile or world y
* @param totalDurationMs - total duration of one animation cycle in milliseconds
* @returns phase offset in milliseconds within [0, totalDurationMs)
*/
export function computeObjectPhaseOffsetMs(x: number, y: number, totalDurationMs: number): number {
if (totalDurationMs <= 0) return 0
let h = (Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) >>> 0
h = (h ^ (h >>> 16)) >>> 0
h = Math.imul(h, 0x45d9f3b) >>> 0
h = (h ^ (h >>> 16)) >>> 0
return ((h % 1000000) / 1000000) * totalDurationMs
}
/**
* Update one animated object's current frame based on time and spatial phase.
*
* @param obj - object drawable
* @param clockMs - current clock time in milliseconds
* @returns true if frame changed
*/
export function updateAnimatedObject(obj: ObjectDrawable, clockMs: number): boolean {
const frames = obj.animatedFrames
if (!frames || frames.length <= 1) return false
const duration = (obj.frameDurationMs && obj.frameDurationMs > 0) ? obj.frameDurationMs : 40
const phase = obj.phaseOffsetMs ?? 0
const elapsed = Math.max(0, clockMs + phase)
const isPersistent = obj.token !== undefined && PERSISTENT_ILLUMINATION_TOKENS.has(obj.token.trim().toUpperCase())
const cycleAnim = isPersistent || obj.cycleAnim !== false
const targetIndex = cycleAnim
? Math.floor(elapsed / duration) % frames.length
: Math.min(frames.length - 1, Math.floor(elapsed / duration))
if (targetIndex === obj.currentFrameIndex) return false
obj.currentFrameIndex = targetIndex
const targetFrame = frames[targetIndex]
if (targetFrame !== undefined) {
obj.frame = targetFrame.frame
obj.page = targetFrame.page
if (obj.baseX !== undefined && obj.baseY !== undefined) {
obj.x = obj.baseX + targetFrame.offsetX
obj.y = obj.baseY + targetFrame.offsetY
}
return true
}
return false
}
/**
* Update an array of animated objects according to millisecond clock.
*
* @param objects - array of object drawables
* @param clockMs - current clock time in milliseconds
* @returns number of objects whose active frame changed
*/
export function updateAnimatedObjects(
objects: readonly ObjectDrawable[],
clockMs: number,
): number {
let updated = 0
for (let i = 0; i < objects.length; i += 1) {
if (updateAnimatedObject(objects[i]!, clockMs)) {
updated += 1
}
}
return updated
}

View File

@ -0,0 +1,483 @@
import type { Monster } from '../../game/combat.ts'
import type { GameEngine } from '../../game/engine.ts'
import type { GroundItemEntity } from '../../game/ground-items.ts'
import { getLevelNames, levelDisplayName } from '../../game/level-names-zh.ts'
import { getTownNpcDescriptorByName, npcDisplayName } from '../../game/npc-table.ts'
import type { TownPortalSlot } from '../../game/portal.ts'
import { getTblLang } from '../../i18n/lang.ts'
import type { ViewportCamera } from '../../sim/camera.ts'
import {
computeInitialGroundLabelLayouts,
resolveLadderCollisions,
type GroundLabelOverlay,
} from '../../ui/ground-labels.ts'
import type { HudManager } from '../../ui/hud-manager.ts'
import {
getEntranceLabelWorldPos,
getWarpLabelWorldPos,
screenToWorld,
subTileToScene,
type SceneEntrance,
type SceneWarp,
} from '../backend/scene-links-nav.ts'
import { MARKER_HEIGHT, type MapRuntime } from './pack-loader.ts'
import {
monsterArtStatusText,
monsterPlanStatusText,
} from './scene-state.ts'
export { monsterArtStatusText, monsterPlanStatusText }
export interface NpcLabelProjectionParams {
npc: {
x: number
y: number
frame?: {
readonly width?: number
readonly height?: number
readonly offsetX?: number
readonly offsetY?: number
} | null | undefined
}
camX: number
camY: number
cameraZoom: number
canvas: { width: number; height: number }
rect: { left: number; top: number; width: number; height: number }
markerHeight?: number
}
export interface NpcLabelProjectionResult {
domX: number
domY: number
fontSize: number
visible: boolean
}
export function projectNpcLabel(params: NpcLabelProjectionParams): NpcLabelProjectionResult {
const markerHeight = params.markerHeight ?? MARKER_HEIGHT
const scaleX = params.canvas.width > 0 ? params.rect.width / params.canvas.width : 1
const scaleY = params.canvas.height > 0 ? params.rect.height / params.canvas.height : 1
const frame = params.npc.frame
const topX = frame != null
? params.npc.x + (frame.offsetX ?? 0) + (frame.width ?? 0) / 2
: params.npc.x
const topY = frame != null
? params.npc.y + (frame.offsetY ?? 0)
: params.npc.y - markerHeight
const domX = params.rect.left + params.rect.width * 0.5 + (topX - params.camX) * params.cameraZoom * scaleX
const domY = params.rect.top + params.rect.height * 0.5 + (topY - params.camY) * params.cameraZoom * scaleY
const visible = domX > -100 && domX < params.rect.width + 100 && domY > -100 && domY < params.rect.height + 100
const safeZoom = Math.max(0.001, Number.isFinite(params.cameraZoom) ? params.cameraZoom : 1)
const fontSize = Math.min(24, Math.max(11, Math.round(13 * Math.sqrt(safeZoom))))
return { domX, domY: domY - 2, fontSize, visible }
}
/**
* Play authentic Diablo II audio rejection feedback when inventory is full.
* Low descending refusal tone paired with delayed secondary touch landing thud.
*/
export function playInventoryFullFeedback(): void {
try {
const AudioCtx = typeof window !== 'undefined' ? (window.AudioContext || (window as any).webkitAudioContext) : null
if (AudioCtx) {
const ctx = new AudioCtx()
// 1. Refusal tone (low pitch descending rejection cue)
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'sawtooth'
osc.frequency.setValueAtTime(175, ctx.currentTime)
osc.frequency.exponentialRampToValueAtTime(90, ctx.currentTime + 0.28)
gain.gain.setValueAtTime(0.3, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3)
osc.connect(gain)
gain.connect(ctx.destination)
osc.start()
osc.stop(ctx.currentTime + 0.3)
// 2. Delayed light ground touch thud upon primary bounce landing (~350ms)
setTimeout(() => {
try {
if (ctx.state !== 'closed') {
const thudOsc = ctx.createOscillator()
const thudGain = ctx.createGain()
thudOsc.type = 'triangle'
thudOsc.frequency.setValueAtTime(110, ctx.currentTime)
thudOsc.frequency.exponentialRampToValueAtTime(45, ctx.currentTime + 0.08)
thudGain.gain.setValueAtTime(0.2, ctx.currentTime)
thudGain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1)
thudOsc.connect(thudGain)
thudGain.connect(ctx.destination)
thudOsc.start()
thudOsc.stop(ctx.currentTime + 0.1)
}
} catch {
// Ignore closed audio context
}
}, 350)
}
} catch {
// AudioContext unavailable or blocked
}
}
/**
* Display prominent Diablo II style on-screen notification banner with auto fade-out.
*/
export function showNotification(message: string, durationMs = 2000, status?: HTMLElement): void {
if (status) {
status.textContent = message
}
if (typeof document !== 'undefined') {
let banner = document.getElementById('d2-center-notification')
if (!banner && document.body) {
banner = document.createElement('div')
banner.id = 'd2-center-notification'
banner.style.position = 'fixed'
banner.style.top = '18%'
banner.style.left = '50%'
banner.style.transform = 'translateX(-50%)'
banner.style.color = '#e03030'
banner.style.fontFamily = 'var(--font-display, "D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif)'
banner.style.fontSize = '20px'
banner.style.fontWeight = 'bold'
banner.style.textShadow = '0 0 8px rgba(0,0,0,0.9), 1px 1px 2px #000'
banner.style.pointerEvents = 'none'
banner.style.zIndex = '99999'
banner.style.transition = 'opacity 0.4s ease-out'
document.body.appendChild(banner)
}
if (banner) {
banner.textContent = message
banner.style.opacity = '1'
clearTimeout((banner as any)._timeout)
;(banner as any)._timeout = setTimeout(() => {
if (banner) banner.style.opacity = '0'
}, durationMs)
}
}
}
;(globalThis as unknown as {
__d2PlayInventoryFullFeedback?: () => void
__d2ShowNotification?: (message: string, durationMs?: number, status?: unknown) => void
}).__d2PlayInventoryFullFeedback = playInventoryFullFeedback
;(globalThis as unknown as {
__d2ShowNotification?: (message: string, durationMs?: number, status?: unknown) => void
}).__d2ShowNotification = (message: string, durationMs = 2000, status?: unknown) => {
showNotification(message, durationMs, status as HTMLElement | undefined)
}
export function allocateLabelElement(
container: HTMLElement,
labelPool: HTMLDivElement[],
): HTMLDivElement {
let label: HTMLDivElement
if (typeof document !== 'undefined' && typeof document.createElement === 'function') {
label = document.createElement('div')
} else {
label = {
tagName: 'DIV',
textContent: '',
hidden: false,
className: '',
dataset: {},
style: {},
} as unknown as HTMLDivElement
}
label.style.position = 'absolute'
label.style.transform = 'translate(-50%, -100%)'
label.style.textShadow = '1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000'
label.style.whiteSpace = 'nowrap'
label.style.pointerEvents = 'none'
labelPool.push(label)
if (typeof container.appendChild === 'function') {
container.appendChild(label)
} else if (Array.isArray((container as any).children)) {
;(container as any).children.push(label)
}
return label
}
export interface OverheadLabelsControllerHost {
readonly labelsContainer?: HTMLElement | null | undefined
readonly canvas: HTMLCanvasElement
readonly camera: ViewportCamera
readonly engine: GameEngine
readonly hudManager: HudManager | null
readonly portalSlot?: TownPortalSlot | undefined
readonly labelPool: HTMLDivElement[]
lastClientPos: { x: number; y: number } | null
hoveredMonster: Monster | null
hoveredGroundItem: GroundItemEntity | null
hoveredWarp: SceneWarp | null
hoveredEntrance: SceneEntrance | null
getRuntime(): MapRuntime
findMonsterAtWorld(worldX: number, worldY: number): Monster | null
syncLinkHoverAtWorld(worldX: number, worldY: number): void
}
/**
* Render recycled overhead labels for on-screen NPCs, Town Portals,
* `runtime.warps`, and `runtime.entrances` into `labelsContainer`.
*/
export function updateOverheadLabels(
host: OverheadLabelsControllerHost,
camX = host.engine.world.player.x,
camY = host.engine.world.player.y - 16,
activePortalMouthOverride?: { readonly x: number; readonly y: number; readonly toLevelId: number } | null,
activePortalPosOverride?: { readonly x: number; readonly y: number } | null,
): void {
const container = host.labelsContainer
if (!container) return
const runtime = host.getRuntime()
let labelCount = 0
const rect = host.canvas.getBoundingClientRect()
const scaleX = host.canvas.width > 0 ? rect.width / host.canvas.width : 1
const scaleY = host.canvas.height > 0 ? rect.height / host.canvas.height : 1
const zoom = host.camera?.zoom ?? 1
const fontSizePx = `${Math.min(24, Math.max(11, Math.round(13 * Math.sqrt(Math.max(0.001, zoom)))))}px`
if (host.lastClientPos !== null) {
let hudIntercepted = false
if (host.hudManager !== null) {
const logical = host.hudManager.clientToLogical(host.lastClientPos.x, host.lastClientPos.y)
hudIntercepted = host.hudManager.isPointInterceptedByHud(logical.x, logical.y)
}
if (hudIntercepted) {
host.hoveredMonster = null
host.hoveredGroundItem = null
host.hoveredWarp = null
host.hoveredEntrance = null
if (host.canvas?.style) {
host.canvas.style.cursor = ''
}
} else {
const worldAtPointer = screenToWorld({
clientX: host.lastClientPos.x,
clientY: host.lastClientPos.y,
rect,
canvas: host.canvas,
camX,
camY,
cameraZoom: zoom,
})
host.hoveredMonster = host.findMonsterAtWorld(worldAtPointer.x, worldAtPointer.y)
host.hoveredGroundItem = host.engine.groundItems?.findAtPoint(worldAtPointer.x, worldAtPointer.y, 32) ?? null
host.syncLinkHoverAtWorld(worldAtPointer.x, worldAtPointer.y)
}
}
const activeNpcMenuName = host.hudManager?.worldPanels?.npcMenu?.npcName?.toLowerCase()
const currentLang = getTblLang()
const actIndexForNpc = Math.max(0, (runtime?.act ?? 1) - 1)
for (const npc of host.engine.npcEntities ?? []) {
if (npc.def.name.toLowerCase() === 'dummy') continue
const npcDesc = getTownNpcDescriptorByName(npc.def.name, actIndexForNpc)
const npcLabelText = npcDesc ? npcDisplayName(npcDesc, currentLang) : npc.def.name
if (
activeNpcMenuName &&
(npc.def.name.toLowerCase() === activeNpcMenuName || npcLabelText.toLowerCase() === activeNpcMenuName)
) {
continue
}
const topX = npc.frame ? npc.x + (npc.frame.offsetX ?? 0) + (npc.frame.width ?? 0) / 2 : npc.x
const topY = npc.frame ? npc.y + (npc.frame.offsetY ?? 0) : npc.y - MARKER_HEIGHT
const domX = rect.left + rect.width * 0.5 + (topX - camX) * zoom * scaleX
const domY = rect.top + rect.height * 0.5 + (topY - camY) * zoom * scaleY
if (domX <= -100 || domX >= rect.width + 100) continue
if (domY <= -100 || domY >= rect.height + 100) continue
const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool)
if (label.textContent !== npcLabelText) label.textContent = npcLabelText
label.className = 'd2-overhead-label d2-npc-label'
if (label.dataset) {
label.dataset.kind = 'npc'
delete label.dataset.toLevelId
delete label.dataset.hovered
}
label.style.color = '#c8a15a'
label.style.background = 'transparent'
label.style.border = 'none'
label.style.padding = '0'
label.style.borderRadius = '0'
label.style.fontWeight = 'normal'
label.style.boxShadow = 'none'
label.style.left = `${domX}px`
label.style.top = `${domY - 2}px`
label.style.fontSize = fontSizePx
label.hidden = false
labelCount += 1
}
const portalMouth = activePortalMouthOverride !== undefined
? activePortalMouthOverride
: (host.portalSlot && runtime ? host.portalSlot.mouthInLevel(runtime.levelId) : null)
const portalPos = activePortalPosOverride !== undefined
? activePortalPosOverride
: (portalMouth && runtime?.grid ? subTileToScene(portalMouth.x, portalMouth.y, runtime.grid) : null)
if (portalMouth !== null && portalPos !== null) {
const topX = portalPos.x
const topY = portalPos.y - 94
const domX = rect.left + rect.width * 0.5 + (topX - camX) * zoom * scaleX
const domY = rect.top + rect.height * 0.5 + (topY - camY) * zoom * scaleY
if (domX > -100 && domX < rect.width + 100 && domY > -100 && domY < rect.height + 100) {
const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool)
const portalLabelText = currentLang === 'ENG'
? `Town Portal -> ${levelDisplayName(portalMouth.toLevelId, 'ENG')}`
: `传送门 -> ${getLevelNames(portalMouth.toLevelId).nameZh}`
if (label.textContent !== portalLabelText) label.textContent = portalLabelText
label.className = 'd2-overhead-label d2-portal-label'
if (label.dataset) {
label.dataset.kind = 'portal'
label.dataset.toLevelId = String(portalMouth.toLevelId)
delete label.dataset.hovered
}
label.style.color = '#6cb4ff'
label.style.background = 'transparent'
label.style.border = 'none'
label.style.padding = '0'
label.style.borderRadius = '0'
label.style.fontWeight = 'normal'
label.style.boxShadow = 'none'
label.style.left = `${domX}px`
label.style.top = `${domY - 2}px`
label.style.fontSize = fontSizePx
label.hidden = false
labelCount += 1
}
}
if (runtime?.grid) {
for (const warp of runtime.warps ?? []) {
if (!(warp.toLevelId > 0)) continue
const pos = getWarpLabelWorldPos(warp, runtime.grid)
const domX = rect.left + rect.width * 0.5 + (pos.x - camX) * zoom * scaleX
const domY = rect.top + rect.height * 0.5 + (pos.y - camY) * zoom * scaleY
if (domX <= -100 || domX >= rect.width + 100 || domY <= -100 || domY >= rect.height + 100) continue
const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool)
const badgeText = currentLang === 'ENG'
? `To ${levelDisplayName(warp.toLevelId, 'ENG')}`
: `通往 ${getLevelNames(warp.toLevelId).nameZh}`
if (label.textContent !== badgeText) label.textContent = badgeText
const isHovered = host.hoveredWarp === warp
label.className = isHovered
? 'd2-overhead-label d2-exit-badge is-hovered'
: 'd2-overhead-label d2-exit-badge'
if (label.dataset) {
label.dataset.kind = 'warp'
label.dataset.toLevelId = String(warp.toLevelId)
label.dataset.hovered = isHovered ? 'true' : 'false'
}
label.style.color = isHovered ? '#fff3c4' : '#f0d080'
label.style.background = isHovered ? 'rgba(44, 32, 12, 0.92)' : 'rgba(12, 10, 8, 0.78)'
label.style.border = isHovered ? '1px solid #ffd86b' : '1px solid rgba(200, 161, 90, 0.65)'
label.style.padding = '2px 7px'
label.style.borderRadius = '3px'
label.style.fontWeight = isHovered ? 'bold' : 'normal'
label.style.boxShadow = isHovered ? '0 0 8px rgba(255, 210, 90, 0.65)' : 'none'
label.style.left = `${domX}px`
label.style.top = `${domY - 2}px`
label.style.fontSize = fontSizePx
label.hidden = false
labelCount += 1
}
for (const entrance of runtime.entrances ?? []) {
if (!(entrance.toLevelId > 0)) continue
const pos = getEntranceLabelWorldPos(entrance, runtime.grid)
const domX = rect.left + rect.width * 0.5 + (pos.x - camX) * zoom * scaleX
const domY = rect.top + rect.height * 0.5 + (pos.y - camY) * zoom * scaleY
if (domX <= -100 || domX >= rect.width + 100 || domY <= -100 || domY >= rect.height + 100) continue
const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool)
const badgeText = currentLang === 'ENG'
? `To ${levelDisplayName(entrance.toLevelId, 'ENG')}`
: `通往 ${getLevelNames(entrance.toLevelId).nameZh}`
if (label.textContent !== badgeText) label.textContent = badgeText
const isHovered = host.hoveredEntrance === entrance
label.className = isHovered
? 'd2-overhead-label d2-exit-badge is-hovered'
: 'd2-overhead-label d2-exit-badge'
if (label.dataset) {
label.dataset.kind = 'entrance'
label.dataset.toLevelId = String(entrance.toLevelId)
label.dataset.hovered = isHovered ? 'true' : 'false'
}
label.style.color = isHovered ? '#fff3c4' : '#f0d080'
label.style.background = isHovered ? 'rgba(44, 32, 12, 0.92)' : 'rgba(12, 10, 8, 0.78)'
label.style.border = isHovered ? '1px solid #ffd86b' : '1px solid rgba(200, 161, 90, 0.65)'
label.style.padding = '2px 7px'
label.style.borderRadius = '3px'
label.style.fontWeight = isHovered ? 'bold' : 'normal'
label.style.boxShadow = isHovered ? '0 0 8px rgba(255, 210, 90, 0.65)' : 'none'
label.style.left = `${domX}px`
label.style.top = `${domY - 2}px`
label.style.fontSize = fontSizePx
label.hidden = false
labelCount += 1
}
}
for (let index = labelCount; index < host.labelPool.length; index += 1) {
const unused = host.labelPool[index]!
unused.hidden = true
if (unused.dataset && unused.dataset.hovered === 'true') {
unused.dataset.hovered = 'false'
}
if (unused.className.includes('is-hovered')) {
unused.className = unused.className.replace(/\s*is-hovered\b/g, '')
}
}
}
/**
* Project and update floating ground item labels (Alt mode or single hovered item).
*/
export function updateGroundItemLabelsOverlay(
groundLabelOverlay: GroundLabelOverlay | null,
hudManager: HudManager | null,
mouseController: { hoveredGroundItem: GroundItemEntity | null } | null,
engine: GameEngine,
canvas: HTMLCanvasElement,
camera: ViewportCamera,
camX: number,
camY: number,
): void {
if (groundLabelOverlay === null) return
const showLabels = Boolean(hudManager?.showGroundLabels)
let hoveredItem = mouseController?.hoveredGroundItem ?? null
if (hoveredItem !== null && !engine.groundItems.get(hoveredItem.id)) {
if (mouseController) mouseController.hoveredGroundItem = null
hoveredItem = null
}
if (engine.groundItems.count > 0 && (showLabels || hoveredItem !== null)) {
const rect = canvas.getBoundingClientRect()
const scaleX = canvas.width > 0 ? rect.width / canvas.width : 1
const scaleY = canvas.height > 0 ? rect.height / canvas.height : 1
const projectToScreen = (worldX: number, worldY: number) => {
const domX = rect.left + rect.width * 0.5 + (worldX - camX) * camera.zoom * scaleX
const domY = rect.top + rect.height * 0.5 + (worldY - camY) * camera.zoom * scaleY
if (domX < -150 || domX > rect.width + 150 || domY < -150 || domY > rect.height + 150) {
return null
}
return { x: domX, y: domY }
}
if (showLabels) {
const initialLayouts = computeInitialGroundLabelLayouts(engine.groundItems.all, projectToScreen)
const resolvedLayouts = resolveLadderCollisions(initialLayouts)
groundLabelOverlay.update(true, resolvedLayouts)
} else if (hoveredItem !== null) {
const initialLayouts = computeInitialGroundLabelLayouts([hoveredItem], projectToScreen)
groundLabelOverlay.update(true, initialLayouts)
}
} else {
groundLabelOverlay.update(false, [])
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,406 @@
import { DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS } from '../../game/animated-tiles.ts'
import type { SorceressAppearanceSpec } from '../../game/char-appearance.ts'
import type { Monster, MonsterStats } from '../../game/combat.ts'
import { getEmbeddedDropTables } from '../../game/embedded-drop-tables.ts'
import type { NpcEntity } from '../../game/engine.ts'
import { enrichMonsterStats } from '../../game/monsters.ts'
import type { OpenPortal } from '../../game/portal.ts'
import { getTblLang, type TblLang } from '../../i18n/lang.ts'
import type {
AutomapOverlayLabel,
AutomapViewportMetrics,
Minimap,
} from '../../ui/minimap.ts'
import type { MonsterTargetHudInfo } from '../../ui/world-panels.ts'
import type { DensityMultiplier, EliteMultiplier } from '../backend/scene-population.ts'
import type { LightingPreset } from './lighting-presets.ts'
import type { MapRuntime } from './pack-loader.ts'
import { countLoadedPages } from './pack-loader.ts'
/**
* `localStorage` key holding the single save slot.
*
* Deliberately the same key the retired `map.html` sandbox used, so a save made
* there still loads here — the snapshot format never differed, only the scene
* that drove it.
*/
export const SAVE_KEY = 'd2web.save'
/**
* How often the HUD text is rewritten, in milliseconds.
*
* The overlay reports averages, so refreshing it at frame rate only buys string
* churn: four `toFixed` calls and ten concatenations that the reader cannot
* follow anyway.
*/
export const HUD_INTERVAL_MS = 250
/** Live scene state, also published for automated checks. */
export interface ActSceneState {
ready: boolean
/** Where the map came from: a prebaked pack or the archives. */
source: 'pack' | 'live' | ''
act: number
level: string
variant: string
variants: string[]
/** @deprecated Use `variant` instead. */
quadrant: string
/** @deprecated Use `variants` instead. */
quadrants: string[]
/** The archive/pack location actually used. */
base: string
cellsX: number
cellsY: number
widthPx: number
heightPx: number
floors: number
shadows?: number
walls: number
frames: number
/** Atlas pages loaded and total. */
pagesLoaded: number
pagesTotal: number
/** Pages that had arrived when the first frame was drawn. */
pagesAtFirstFrame: number
/** Milliseconds from boot to the first drawn frame. */
firstFrameMs: number
/** Page indices the spawn viewport needed, for diagnostics. */
priorityPages: number[]
/** Draws skipped because their page had not arrived yet. */
skippedDraws: number
/** Objects the pack places (their art still needs the DCC/COF decoder). */
objects: number
/** Roof draws, painted in their own pass after the character. */
roofs: number
/** Roof opacity alpha (0.0..1.0), faded dynamically when player enters shelter. */
roofAlpha: number
/** Whether the composited Sorceress sprite is in use (else the marker). */
character: boolean
/** DCC members that went into the character sprite. */
characterMembers: number
/** Frames the character path has actually drawn; 0 while the marker is in use. */
characterFrames: number
/** Last group the character path selected (walk or stand, times 8 directions). */
characterGroup: number
/** Active hero class token (e.g. 'so', 'am', 'ne', 'pa', 'ba', 'dz', 'ai'). */
heroToken?: string
/** Active hero class English name (e.g. 'Sorceress', 'Barbarian'). */
heroName?: string
/** Bounding box of the player sprite drawn this frame in scene coordinates. */
playerDrawRect?: { x: number; y: number; w: number; h: number } | undefined
/** Equipped or active weapon class code (e.g. 'hth', '1hs', '2hs', 'stf', 'orb', 'bow', 'xbw'). */
weaponClass?: string | null | undefined
/** Current animation clip mode name of the player (e.g. 'nu', 'wl', 'rn', 'a1', 'sc'). */
playerClip?: string | null | undefined
/** Canonical appearance cache key for dynamic modular paperdoll compositing. */
characterAppearanceKey?: string | undefined
/** Active Sorceress 16-component appearance and palette shift spec. */
characterAppearance?: SorceressAppearanceSpec | undefined
/** Milliseconds from boot to the first rendered frame. */
loadMs: number
missing: number
walkable: number
x: number
y: number
facing: number
tick: number
tickRate: number
simTps: number
renderFps: number
fps: number
/**
* GPU draw calls the last frame actually issued.
*/
drawCalls: number
/** Quads the last frame submitted, i.e. what survived culling. */
quadsDrawn: number
/** Draws rejected last frame because their bounds fell outside the viewport. */
culledDraws: number
/** Wall-clock milliseconds the last `onRender` took. */
renderMs: number
/** Active lighting preset id ('auto', 'noon', 'morning', 'sunset', 'night', 'cave', 'hell', 'eclipse', 'off'). */
lightingPreset?: LightingPreset | undefined
/** Active lighting mode ('Glide 1.13c' or undefined). */
lightingMode?: string | undefined
/** Ambient light intensity [0..255]. */
ambientIntensity?: number | undefined
/** Ambient color [R, G, B] in [0..255]. */
ambientColor?: readonly [number, number, number] | undefined
/** Current zoom factor and the canvas backing-store size. */
zoom: number
canvasWidth: number
canvasHeight: number
error: string | null
dialog: string[]
npcs: number
npcsNear: NpcEntity[]
/** Count of animated tiles in the active map. */
animatedTiles: number
/** Count of animated map objects in the active map. */
animatedObjects: number
/** Animation speed from LvlPrest.txt `Animate` column (or undefined if not specified). */
animSpeed?: number | undefined
/** Per-frame duration in ms computed from animSpeed. */
frameDurationMs?: number | undefined
/** Monster ids the art loader could not dress, lower case. */
missingMonsterArt: string[]
/** Monster-art loads that threw: a whole level, or one monster type. */
monsterArtErrors: number
/** COF layers dropped inside art that did load: monsters missing body parts. */
monsterArtLayerFailures: number
/** Monsters the level's own plan produced. */
monstersPlanned: number
/** Active monster density multiplier (0.5, 1, 2, 4, 8). */
densityMultiplier?: DensityMultiplier | undefined
/** Active elite chance multiplier (0.5, 1, 2, 4, 'all'). */
eliteMultiplier?: EliteMultiplier | undefined
/** Dynamic monster re-budget and re-stream method without page reload. */
reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise<void> | void
/** Active player Town Portal (if open). */
portal?: OpenPortal | null | undefined
/** Whether the automap/minimap is visible. */
minimapVisible?: boolean | undefined
/** Active automap mode ('minimap' | 'full'). */
automapMode?: 'minimap' | 'full' | undefined
/** Active automap projection divisor (20 for minimap, 10 for full). */
automapDivisor?: 10 | 20 | undefined
/** Number of loaded automap sprite atlases (0..8). */
automapAtlasesLoaded?: number | undefined
/** Pre-baked/active automap cell counts per layer. */
automapCellCounts?: {
floors: number
walls: number
objects: number
extras: number
total: number
} | undefined
/** Revealed DRLG room count for the active level. */
automapRevealedRooms?: number | undefined
/** Automap sprites drawn last frame per layer. */
automapSpritesDrawn?: {
floors: number
walls: number
objects: number
extras: number
portals: number
total: number
} | undefined
/** Automap 12-segment crosshairs drawn last frame. */
automapCrosshairsDrawn?: {
player: number
party: number
npcs: number
mercenaries: number
bosses: number
total: number
} | undefined
/** Revealed warp/stash Font6 labels on the automap. */
automapWarpLabels?: AutomapOverlayLabel[] | undefined
/** Top-right gold Font16 Level Name + Difficulty HUD readout. */
automapHudText?: {
levelName: string
difficulty: string
x: number
y1: number
y2: number
} | null | undefined
/** Active automap viewport metrics. */
automapViewport?: AutomapViewportMetrics | undefined
}
declare global {
interface Window {
/** Act scene state for automation and debugging. */
__d2webAct?: ActSceneState
__d2webActs?: ActSceneState
__d2webMinimap?: Minimap
__d2webActScene?: {
switchHero: (token: string) => Promise<boolean>
getHero: () => string
switchLighting?: (preset: string) => LightingPreset
getLighting?: () => LightingPreset
reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise<void> | void
setPlayers?: (count: number, partyCount?: number) => void
executeCommand?: (cmd: string) => boolean
castTownPortal?: () => boolean
enterTownPortal?: () => void
getPortal?: () => OpenPortal | null
getMinimap?: () => Minimap
syncAppearance?: () => Promise<boolean>
}
}
}
export const state: ActSceneState = {
ready: false, source: '', act: 1, level: '', variant: '', variants: [], quadrant: '', quadrants: [], base: '', roofs: 0, roofAlpha: 1,
cellsX: 0, cellsY: 0, widthPx: 0, heightPx: 0, floors: 0, shadows: 0, walls: 0, frames: 0,
pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [],
character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1,
heroToken: 'so', heroName: 'Sorceress',
playerDrawRect: undefined, weaponClass: 'hth', playerClip: 'nu',
skippedDraws: 0, objects: 0, loadMs: 0,
missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0, simTps: 0, renderFps: 0, fps: 0,
drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0,
zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0, animatedObjects: 0,
animSpeed: undefined, frameDurationMs: DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
missingMonsterArt: [], monsterArtErrors: 0, monsterArtLayerFailures: 0, monstersPlanned: 0,
densityMultiplier: 1, eliteMultiplier: 1,
}
if (typeof window !== 'undefined') {
window.__d2webAct = state
window.__d2webActs = state
}
export function monsterToTargetHudInfo(monster: Monster): MonsterTargetHudInfo {
const dropTables = getEmbeddedDropTables()
const stats =
monster.stats.isDemon === undefined &&
monster.stats.isUndead === undefined &&
monster.stats.nameKey === undefined
? enrichMonsterStats(monster.stats, dropTables.monsterKinds, dropTables.superUniques)
: monster.stats
const r = stats.rank
const statsAny = stats as MonsterStats & {
readonly descStr?: string
readonly subType?: string
readonly affixes?: readonly string[]
}
return {
name: stats.name || stats.id,
...(stats.nameKey !== undefined ? { nameKey: stats.nameKey } : {}),
hp: monster.hp,
maxHp: stats.hp,
rank:
r === 'champion'
? 'champion'
: r === 'unique'
? 'unique'
: (r as string) === 'superunique'
? 'superunique'
: r === 'minion'
? 'minion'
: r === 'boss' || stats.isBoss === true
? 'boss'
: 'normal',
...(stats.isBoss !== undefined ? { isBoss: stats.isBoss } : {}),
...(stats.isUndead !== undefined ? { isUndead: stats.isUndead } : {}),
...(stats.isDemon !== undefined ? { isDemon: stats.isDemon } : {}),
...(stats.descKey !== undefined ? { descKey: stats.descKey } : {}),
...(statsAny.descStr !== undefined ? { descStr: statsAny.descStr } : {}),
...(statsAny.subType !== undefined ? { subType: statsAny.subType } : {}),
...(stats.modifiers !== undefined ? { modifiers: stats.modifiers } : {}),
...(statsAny.affixes !== undefined ? { affixes: statsAny.affixes } : {}),
...(stats.resists !== undefined ? { resists: stats.resists } : {}),
...(stats.resistances !== undefined ? { resistances: stats.resistances } : {}),
}
}
export interface MonsterArtDiagnosticState {
readonly missing: readonly string[]
readonly errors: number
readonly layerFailures: number
}
/**
* The HUD fragment describing a monster-art load, empty when it was clean.
*
* @param load - the counters, from {@link ActSceneState} or {@link MonsterArtLoad}.
* @returns the fragment, ready to append to the HUD line.
*/
export function monsterArtStatusText(
load: MonsterArtDiagnosticState,
lang: TblLang = getTblLang(),
): string {
const isEn = lang === 'ENG'
const parts: string[] = []
if (load.missing.length > 0) {
const shown = load.missing.slice(0, 3).join(isEn ? ', ' : '、')
const more = load.missing.length > 3 ? `…+${String(load.missing.length - 3)}` : ''
parts.push(
isEn
? `Monster art missing ${String(load.missing.length)} (red box: ${shown}${more})`
: `怪物美术缺失 ${String(load.missing.length)}(红方块:${shown}${more})`,
)
}
if (load.errors > 0) parts.push(isEn ? `Load errors ${String(load.errors)}` : `加载错误 ${String(load.errors)}`)
if (load.layerFailures > 0) parts.push(isEn ? `Missing layers ${String(load.layerFailures)}` : `缺层 ${String(load.layerFailures)}`)
return parts.length === 0 ? '' : ` · ${parts.join(' · ')}`
}
/**
* The HUD fragment describing a level's planned population.
*
* @param planned - how many monsters the plan produced.
* @returns the fragment, ready to append to the HUD line.
*/
export function monsterPlanStatusText(planned: number, lang: TblLang = getTblLang()): string {
if (lang === 'ENG') {
return planned === 0 ? ' · Monsters 0 (none planned for level)' : ` · Monsters ${String(planned)}`
}
return planned === 0 ? ' · 怪物 0(本关未规划到怪物)' : ` · 怪物 ${String(planned)}`
}
/**
* Publish a level's planned population into {@link ActSceneState}.
*/
export function reportMonsterPlan(runtime: MapRuntime): void {
const planned = runtime.monsterPacks.reduce((sum, pack) => sum + pack.members.length, 0)
state.monstersPlanned = planned
if (planned === 0) {
console.info(`level ${runtime.level}: no monsters planned; the level stays empty`)
}
}
/**
* Publish a load's failures into {@link ActSceneState}.
*/
export function recordMonsterArtLoad(load: MonsterArtDiagnosticState): void {
state.missingMonsterArt = [...load.missing]
state.monsterArtErrors = load.errors
state.monsterArtLayerFailures = load.layerFailures
if (load.missing.length > 0 || load.errors > 0 || load.layerFailures > 0) {
console.error(
`monster art incomplete: ${String(load.missing.length)} type(s) without art `
+ `(${load.missing.join(', ')}), ${String(load.errors)} load error(s), `
+ `${String(load.layerFailures)} dropped layer(s)`,
)
}
}
/**
* Copy a runtime's description into the published state.
*
* @param runtime - the runtime.
* @param base - location the data came from.
*/
export function publish(runtime: MapRuntime, base: string): void {
state.source = runtime.source
state.base = base
state.act = runtime.act
state.level = runtime.level
state.variant = runtime.variant
state.variants = [...runtime.variants]
state.quadrant = runtime.quadrant
state.quadrants = [...runtime.quadrants]
state.cellsX = runtime.cellsX
state.cellsY = runtime.cellsY
state.widthPx = runtime.widthPx
state.heightPx = runtime.heightPx
state.floors = runtime.floors.length
state.shadows = runtime.shadows?.length ?? 0
state.walls = runtime.walls.length
state.frames = runtime.frames
state.pagesTotal = runtime.pages.length
state.pagesLoaded = countLoadedPages(runtime.pages)
state.priorityPages = [...runtime.priorityPages]
state.objects = runtime.objects
state.roofs = runtime.roofs.length
state.animatedTiles = runtime.animatedDrawables?.length ?? 0
state.animatedObjects = runtime.animatedObjects?.length ?? 0
state.animSpeed = runtime.animSpeed
state.frameDurationMs = runtime.frameDurationMs
state.roofAlpha = 1
state.missing = 0
state.walkable = 1 - [...runtime.grid.blocked].reduce((sum, value) => sum + value, 0) / runtime.grid.blocked.length
}

View File

@ -0,0 +1,858 @@
import type { Palette } from '../../formats/pal.ts'
import { WORLD_VARIANT_SEED } from '../../game/act-variants.ts'
import type { ActorAnimator } from '../../game/actor-animator.ts'
import {
SorceressLayerCompositor,
appearanceKey,
resolveSorceressAppearance,
} from '../../game/char-appearance.ts'
import type { CharacterClassCode } from '../../game/classes.ts'
import {
spawnMonsterPacks,
type MonsterPack,
type SafeZone,
} from '../../game/combat.ts'
import { cellCentre } from '../../game/d2map.ts'
import type { DropTables } from '../../game/drop-pipeline.ts'
import type { GameEngine } from '../../game/engine.ts'
import {
ACT_NAMES_EN,
ACT_NAMES_ZH,
getLevelNames,
sceneDisplayName,
variantDisplayLabel,
} from '../../game/level-names-zh.ts'
import type { LoadedMonsterArt } from '../../game/monster-art.ts'
import { MonsterStreamingManager } from '../../game/monster-streaming.ts'
import {
buildLangSwitchHref,
getTblLang,
type TblLang,
} from '../../i18n/lang.ts'
import type { AtlasFrame } from '../../render/atlas.ts'
import type { SpriteRenderer } from '../../render/renderer.ts'
import { cleanChiItemName } from '../../ui/ground-labels.ts'
import { HudManager } from '../../ui/hud-manager.ts'
import type { Minimap } from '../../ui/minimap.ts'
import { handleActSceneChatCommand } from '../backend/chat-commands.ts'
import { subTileToScene } from '../backend/scene-links-nav.ts'
import {
HERO_CLASSES,
ROOM_ACTIVATION_RADIUS,
ROOM_DEACTIVATION_RADIUS,
buildStreamingRooms,
isTownLevel,
normalizeHeroToken,
parseDensityMultiplier,
parseEliteMultiplier,
rebudgetMonsterPacks,
type DensityMultiplier,
type EliteMultiplier,
} from '../backend/scene-population.ts'
import {
DEFAULT_BASES,
loadCharacterArt,
loadMonsterArtMap,
type LoadedCharacter,
} from './entity-art-loader.ts'
import {
LIGHTING_PRESETS,
normalizeLightingPreset,
type LightingPreset,
} from './lighting-presets.ts'
import {
ACT_COUNT,
fetchJson,
param,
type MapRuntime,
type PackIndex,
} from './pack-loader.ts'
import {
recordMonsterArtLoad,
reportMonsterPlan,
state,
} from './scene-state.ts'
export { handleActSceneChatCommand }
export function updateSelectorWarningStyle(select: HTMLSelectElement | null, defaultValue: string = '1'): void {
if (!select) return
const isNonDefault = select.value !== defaultValue
select.classList.toggle('non-default', isNonDefault)
if (isNonDefault) {
select.style.border = '1px solid #e74c3c'
} else {
select.style.border = ''
}
}
export function updateToolbarUrl(
currentUrlOrSearch: string | URL,
options: { density?: number | string | undefined; elite?: number | string | undefined },
): string {
const isUrl = typeof currentUrlOrSearch === 'object' || currentUrlOrSearch.startsWith('http://') || currentUrlOrSearch.startsWith('https://')
const url = isUrl
? new URL(typeof currentUrlOrSearch === 'string' ? currentUrlOrSearch : currentUrlOrSearch.href)
: new URL(`http://localhost${currentUrlOrSearch.startsWith('?') ? currentUrlOrSearch : '?' + currentUrlOrSearch}`)
if (options.density !== undefined) {
const density = parseDensityMultiplier(options.density)
if (density === 1) {
url.searchParams.delete('density')
} else {
url.searchParams.set('density', String(density))
}
}
if (options.elite !== undefined) {
const elite = parseEliteMultiplier(options.elite)
if (elite === 1) {
url.searchParams.delete('elite')
} else {
url.searchParams.set('elite', String(elite))
}
}
if (isUrl) {
return url.toString()
}
return url.search
}
export interface MountChatAndToolbarUiOptions {
readonly engine: GameEngine
readonly status: HTMLElement
readonly executeChatCommand: (rawCommand: string) => boolean
readonly densitySelect: HTMLSelectElement | null
readonly eliteSelect: HTMLSelectElement | null
readonly densityMultiplier: number
readonly eliteMultiplier: number | 'all'
readonly reloadMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise<void>
}
/**
* Mount the in-game chat/console input overlay (`#d2-chat-input`) and wire
* toolbar density/elite selectors.
*/
export function mountChatAndToolbarUi(options: MountChatAndToolbarUiOptions): void {
const {
executeChatCommand,
densitySelect,
eliteSelect,
densityMultiplier,
eliteMultiplier,
reloadMonsters,
} = options
// In-game chat/console input overlay supporting "/players <1-8>"
if (typeof document !== 'undefined') {
let chatInput = document.querySelector<HTMLInputElement>('#d2-chat-input')
if (!chatInput) {
chatInput = document.createElement('input')
chatInput.id = 'd2-chat-input'
chatInput.type = 'text'
chatInput.placeholder = getTblLang() === 'ENG'
? 'Type /players 1-8 or message, press Enter to send…'
: '输入 /players 1-8 或消息,按 Enter 发送…'
chatInput.className = 'overlay'
chatInput.style.position = 'fixed'
chatInput.style.left = '12px'
chatInput.style.bottom = '56px'
chatInput.style.width = '320px'
chatInput.style.padding = '4px 8px'
chatInput.style.background = 'rgba(11, 9, 6, 0.9)'
chatInput.style.color = '#e8dcc8'
chatInput.style.border = '1px solid #c8a15a'
chatInput.style.borderRadius = '4px'
chatInput.style.font = 'inherit'
chatInput.style.fontSize = '12px'
chatInput.style.zIndex = '10'
chatInput.style.display = 'none'
document.body.appendChild(chatInput)
}
const openChat = () => {
if (!chatInput) return
chatInput.placeholder = getTblLang() === 'ENG'
? 'Type /players 1-8 or message, press Enter to send…'
: '输入 /players 1-8 或消息,按 Enter 发送…'
chatInput.style.display = 'block'
chatInput.value = ''
chatInput.focus()
}
const closeChat = () => {
if (!chatInput) return
chatInput.value = ''
chatInput.blur()
chatInput.style.display = 'none'
}
chatInput.addEventListener('keydown', (e) => {
e.stopPropagation()
if (e.key === 'Enter') {
e.preventDefault()
const text = chatInput!.value
closeChat()
if (text) {
executeChatCommand(text)
}
} else if (e.key === 'Escape') {
e.preventDefault()
closeChat()
}
})
window.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const active = document.activeElement
if (
active instanceof HTMLInputElement ||
active instanceof HTMLSelectElement ||
active instanceof HTMLTextAreaElement
) {
return
}
e.preventDefault()
openChat()
}
})
}
if (densitySelect !== null) {
densitySelect.disabled = false
densitySelect.value = String(densityMultiplier)
updateSelectorWarningStyle(densitySelect, '1')
densitySelect.onchange = () => {
void reloadMonsters({ density: densitySelect.value })
}
}
if (eliteSelect !== null) {
eliteSelect.disabled = false
eliteSelect.value = String(eliteMultiplier)
updateSelectorWarningStyle(eliteSelect, '1')
eliteSelect.onchange = () => {
void reloadMonsters({ elite: eliteSelect.value })
}
}
}
export interface HeroLightingAndMonsterControlsContext {
readonly renderer: SpriteRenderer
readonly dropTables: DropTables
readonly playerAnimator: ActorAnimator
readonly status: HTMLElement
readonly heroSelect: HTMLSelectElement | null
readonly lightingSelect: HTMLSelectElement | null
readonly densitySelect: HTMLSelectElement | null
readonly eliteSelect: HTMLSelectElement | null
readonly getRuntime: () => MapRuntime
readonly getEngine: () => GameEngine
readonly getHudManager: () => HudManager | null
readonly getCurrentHeroToken: () => string
readonly setCurrentHeroToken: (token: string) => void
readonly getCharacter: () => LoadedCharacter | null
readonly setCharacter: (char: LoadedCharacter | null) => void
readonly getActivePalette: () => Palette | null
readonly getPackEntityBase: () => string | undefined
readonly getPackEntityAct: () => number | undefined
readonly getCurrentLightingPreset: () => LightingPreset
readonly setLightingState: (preset: LightingPreset, enabled: boolean) => void
readonly getMultipliers: () => { densityMultiplier: DensityMultiplier; eliteMultiplier: EliteMultiplier }
readonly setMultipliers: (density: DensityMultiplier, elite: EliteMultiplier) => void
readonly setMonsterArtMap: (map: Map<string, LoadedMonsterArt>) => void
}
export interface HeroLightingAndMonsterControls {
readonly syncSorceressAppearance: () => Promise<boolean>
readonly switchLighting: (preset: string) => LightingPreset
readonly switchHero: (newHeroToken: string) => Promise<boolean>
readonly reloadMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise<void>
}
export function createHeroLightingAndMonsterControls(
ctx: HeroLightingAndMonsterControlsContext,
): HeroLightingAndMonsterControls {
const soCompositor = new SorceressLayerCompositor(ctx.getPackEntityBase() ?? 'samples/d2-packs')
let activeAppearanceKey = ''
let appearanceSeq = 0
const syncSorceressAppearance = async (): Promise<boolean> => {
const hudInst = ctx.getHudManager() ?? (
typeof window !== 'undefined'
? (window as unknown as { __d2webHudInstance?: HudManager }).__d2webHudInstance ?? null
: null
)
const currentHeroToken = ctx.getCurrentHeroToken()
if (currentHeroToken !== 'so' || hudInst === null) {
activeAppearanceKey = ''
state.characterAppearanceKey = undefined
state.characterAppearance = undefined
if (hudInst !== null) {
const wCode = hudInst.inventory.equipped.weapon1?.code
state.weaponClass = wCode === '7wa' ? '2hs' : (wCode ? '1hs' : 'hth')
}
return false
}
const spec = resolveSorceressAppearance(hudInst.inventory.equipped, ctx.dropTables)
const key = appearanceKey(spec)
state.weaponClass = spec.wclass
state.characterAppearance = spec
if (key === activeAppearanceKey) return false
activeAppearanceKey = key
state.characterAppearanceKey = key
const seq = ++appearanceSeq
try {
const composited = await soCompositor.composite(spec)
if (seq !== appearanceSeq || ctx.getCurrentHeroToken() !== 'so') return false
const newHandle = ctx.renderer.addIndexedAtlas(composited.r8, composited.width, composited.height)
const groups: AtlasFrame[][] = composited.groups.map(group =>
group.map(([x, y, width, height, anchorX, anchorY]) => ({
x,
y,
width,
height,
anchorX,
anchorY,
})),
)
const prevChar = ctx.getCharacter()
if (prevChar?.handle) ctx.renderer.deleteAtlas(prevChar.handle)
ctx.setCharacter({
handle: newHandle,
groups,
directions: composited.directions,
walk: composited.walk,
stand: composited.stand,
members: composited.members,
clips: composited.clips,
})
state.character = true
state.characterMembers = composited.members
state.characterAppearanceKey = key
state.characterAppearance = spec
if (ctx.playerAnimator.mode !== null) {
const nextClip = composited.clips[ctx.playerAnimator.mode]
if (nextClip !== undefined) ctx.playerAnimator.syncClip(nextClip)
}
return true
} catch (err) {
console.warn('Sorceress appearance composite failed:', err)
return false
}
}
const switchLighting = (preset: string): LightingPreset => {
const nextPreset = normalizeLightingPreset(preset)
const cfg = LIGHTING_PRESETS[nextPreset]
ctx.setLightingState(nextPreset, cfg.enabled)
const isEn = getTblLang() === 'ENG'
const modeLabel = isEn ? cfg.labelEn : cfg.labelZh
state.lightingPreset = nextPreset
state.lightingMode = modeLabel
if (ctx.lightingSelect !== null && ctx.lightingSelect.value !== nextPreset) {
ctx.lightingSelect.value = nextPreset
}
try {
const url = new URL(location.href)
if (nextPreset === 'auto') url.searchParams.delete('lighting')
else url.searchParams.set('lighting', nextPreset)
history.replaceState(null, '', url.toString())
} catch {
// ignore in non-browser context
}
const msgPrefix = isEn ? 'Scene lighting switched to: ' : '已切换场景光照为:'
ctx.status.textContent = `${msgPrefix}${modeLabel}`
setTimeout(() => {
if (ctx.status.textContent.startsWith(msgPrefix)) ctx.status.textContent = ''
}, 2500)
return nextPreset
}
const switchHero = async (newHeroToken: string): Promise<boolean> => {
const nextToken = normalizeHeroToken(newHeroToken)
if (nextToken === ctx.getCurrentHeroToken() && ctx.getCharacter() !== null) return true
const nextInfo = HERO_CLASSES[nextToken] ?? HERO_CLASSES.so
const isEn = getTblLang() === 'ENG'
const heroDispName = isEn ? nextInfo.nameEn : nextInfo.nameZh
ctx.status.textContent = isEn ? `Switching hero to ${heroDispName} …` : `正在切换角色至 ${heroDispName} …`
const runtime = ctx.getRuntime()
const nextChar = await loadCharacterArt(
runtime.charBases,
ctx.getActivePalette(),
ctx.renderer,
ctx.getPackEntityBase(),
ctx.getPackEntityAct(),
nextToken,
)
if (nextChar !== null) {
const prevChar = ctx.getCharacter()
if (prevChar?.handle) ctx.renderer.deleteAtlas(prevChar.handle)
ctx.setCharacter(nextChar)
ctx.setCurrentHeroToken(nextToken)
state.heroToken = nextToken
state.heroName = nextInfo.nameEn
state.character = true
state.characterMembers = nextChar.members
const hudManager = ctx.getHudManager()
if (hudManager !== null) hudManager.switchClass(nextInfo.code as CharacterClassCode)
activeAppearanceKey = ''
if (nextToken === 'so') await syncSorceressAppearance()
else {
state.characterAppearanceKey = undefined
state.characterAppearance = undefined
}
if (ctx.heroSelect !== null && ctx.heroSelect.value !== nextToken) ctx.heroSelect.value = nextToken
try {
const url = new URL(location.href)
url.searchParams.set('hero', nextToken)
history.replaceState(null, '', url.toString())
} catch {
// ignore in non-browser context
}
const curChar = ctx.getCharacter()
if (curChar?.clips?.nu) ctx.playerAnimator.play('nu', curChar.clips.nu, { restart: true })
const switchedPrefix = isEn ? 'Switched hero to ' : '已切换角色为 '
ctx.status.textContent = isEn
? `${switchedPrefix}${heroDispName} (${String(curChar?.members ?? nextChar.members)} layers).`
: `${switchedPrefix}${heroDispName}(${String(curChar?.members ?? nextChar.members)} 层素材)。`
setTimeout(() => {
if (ctx.status.textContent.startsWith(switchedPrefix)) ctx.status.textContent = ''
}, 3000)
return true
}
ctx.status.textContent = isEn ? `Failed to switch hero to ${heroDispName}.` : `切换角色 ${heroDispName} 失败。`
return false
}
const reloadMonsters = async (options?: { density?: number | string; elite?: number | string }): Promise<void> => {
let { densityMultiplier, eliteMultiplier } = ctx.getMultipliers()
if (options?.density !== undefined) densityMultiplier = parseDensityMultiplier(options.density)
if (options?.elite !== undefined) eliteMultiplier = parseEliteMultiplier(options.elite)
ctx.setMultipliers(densityMultiplier, eliteMultiplier)
state.densityMultiplier = densityMultiplier
state.eliteMultiplier = eliteMultiplier
if (ctx.densitySelect !== null) {
ctx.densitySelect.value = String(densityMultiplier)
updateSelectorWarningStyle(ctx.densitySelect, '1')
}
if (ctx.eliteSelect !== null) {
ctx.eliteSelect.value = String(eliteMultiplier)
updateSelectorWarningStyle(ctx.eliteSelect, '1')
}
try {
if (typeof location !== 'undefined' && typeof history !== 'undefined') {
const nextSearch = updateToolbarUrl(location.search, { density: densityMultiplier, elite: eliteMultiplier })
const newUrl = `${location.pathname}${nextSearch}${location.hash}`
history.replaceState(null, '', newUrl)
}
} catch {
// ignore in non-browser context
}
const runtime = ctx.getRuntime()
const engine = ctx.getEngine()
if (isTownLevel(runtime.levelId)) {
runtime.monsterPacks = []
engine.world.monsters.length = 0
engine.streamingManager = undefined
reportMonsterPlan(runtime)
return
}
const basePacks = runtime.baseMonsterPacks ?? runtime.monsterPacks
runtime.baseMonsterPacks = basePacks
runtime.monsterPacks = rebudgetMonsterPacks(basePacks, {
densityMultiplier,
eliteMultiplier,
levelId: runtime.levelId,
seed: WORLD_VARIANT_SEED ^ runtime.levelId,
})
engine.world.monsters.length = 0
const player = engine.world.player
const currentSafeZones: SafeZone[] = [{ x: player.x, y: player.y, radius: 180 }]
for (const wp of runtime.waypoints) {
const pos = subTileToScene(wp.x, wp.y, runtime.grid)
currentSafeZones.push({ x: pos.x, y: pos.y, radius: 160 })
}
for (const ent of runtime.entrances) {
const pos = subTileToScene(ent.x, ent.y, runtime.grid)
currentSafeZones.push({ x: pos.x, y: pos.y, radius: 160 })
}
for (const s of runtime.stashes ?? []) {
currentSafeZones.push({ x: s.x, y: s.y, radius: 160 })
}
const currentResolvedPacks: MonsterPack[] = runtime.monsterPacks.map(pack => {
if (pack.landmarkTile !== undefined && pack.fixedPosition === undefined) {
return {
...pack,
fixedPosition: cellCentre(runtime.grid, pack.landmarkTile.tileX, pack.landmarkTile.tileY),
}
}
return pack
})
const nextRooms = buildStreamingRooms(runtime, currentResolvedPacks, currentSafeZones)
if (nextRooms.length > 0) {
engine.streamingManager = new MonsterStreamingManager(
engine.world,
engine.terrain,
nextRooms,
{
activationRadius: ROOM_ACTIVATION_RADIUS,
deactivationRadius: ROOM_DEACTIVATION_RADIUS,
safeZones: currentSafeZones,
},
)
} else {
engine.streamingManager = undefined
if (currentResolvedPacks.length > 0) {
spawnMonsterPacks(
engine.world,
currentResolvedPacks,
{ x: player.x, y: player.y },
Math.max(runtime.widthPx, runtime.heightPx) / 4,
engine.terrain,
currentSafeZones,
)
}
}
reportMonsterPlan(runtime)
if (runtime.palette !== null) {
const packMonsterIds = runtime.monsterPacks.flatMap(p => p.members.map(m => m.id))
const activeMonsterIds = Array.from(new Set([...runtime.monsterTypes, ...packMonsterIds]))
if (activeMonsterIds.length > 0) {
const loaded = await loadMonsterArtMap(
DEFAULT_BASES,
activeMonsterIds,
runtime.palette,
ctx.renderer,
ctx.getPackEntityBase(),
ctx.getPackEntityAct(),
)
ctx.setMonsterArtMap(loaded.map)
recordMonsterArtLoad(loaded)
}
}
}
return {
syncSorceressAppearance,
switchLighting,
switchHero,
reloadMonsters,
}
}
export async function createActSceneHudManager(options: {
readonly runtime: MapRuntime
readonly engine: GameEngine
readonly minimap: Minimap
readonly status: HTMLElement
readonly currentHeroToken: string
readonly onWaypointTeleport: (targetAct: number, slug: string, levelId?: number) => void
readonly onCastTownPortal: () => void
}): Promise<HudManager | null> {
const {
runtime,
engine,
minimap,
status,
currentHeroToken,
onWaypointTeleport,
onCastTownPortal,
} = options
const hudCanvas = document.querySelector<HTMLCanvasElement>('#d2-hud-canvas')
const hudManager = hudCanvas !== null
? new HudManager(hudCanvas, {
onToggleAutomap: () => {
minimap.toggleVisibility()
state.minimapVisible = minimap.visible
},
onToggleAutomapMode: () => {
minimap.toggleMinimapMode()
state.minimapVisible = minimap.visible
state.automapMode = minimap.mode
state.automapDivisor = minimap.divisor
},
onCycleAutomapFade: () => { minimap.cycleFade() },
onToggleAutomapNames: () => { minimap.toggleNames() },
onResetAutomapPan: () => { minimap.resetPan() },
onPanAutomap: (dx, dy) => minimap.pan(dx, dy),
onRenderAutomapOverlay: (ctx, font, logicalWidth, logicalHeight) => {
minimap.drawHudOverlay(ctx, font, logicalWidth, logicalHeight)
},
onWaypointTeleport: (targetAct, slug, levelId) => {
onWaypointTeleport(targetAct, slug, levelId)
},
onCastTownPortal: () => onCastTownPortal(),
onDropItemToGround: (item) => {
const player = engine.world.player
const groundItem = engine.dropItem(item, player.x, player.y)
const isEn = getTblLang() === 'ENG'
status.textContent = isEn
? `Dropped "${groundItem.name}" on ground`
: `已将「${cleanChiItemName(groundItem.nameZh)}」丢弃在地面`
},
onDropGoldToGround: (amount) => {
const player = engine.world.player
const groundGold = engine.dropGold(amount, player.x, player.y)
if (groundGold) {
const isEn = getTblLang() === 'ENG'
status.textContent = isEn
? `Dropped ${amount.toLocaleString()} Gold on ground`
: `已将 ${amount.toLocaleString()} 金币丢弃在地面`
}
},
})
: null
if (hudManager !== null) {
hudManager.worldPanels.setCurrentLevel(runtime.levelId ?? null)
hudManager.worldPanels.setCurrentWaypoint(runtime.waypoints[0]?.waypointId ?? null, runtime.levelId ?? null)
hudManager.worldPanels.selectedActTab = runtime.act
try {
await hudManager.init('/ui')
const initialHeroInfo = HERO_CLASSES[currentHeroToken] ?? HERO_CLASSES.so
hudManager.switchClass(initialHeroInfo.code as CharacterClassCode)
const customCharName = param('charName', '').trim()
if (customCharName) hudManager.charSheet.attrs.name = customCharName
} catch (err) {
console.error('Failed to initialize HudManager:', err)
}
if (typeof window !== 'undefined' && window.__d2webHud) {
(window.__d2webHud as any).proceduralFallbackCount = hudManager.globes.proceduralFallbackCount
}
}
return hudManager
}
export function wireBootToolbarSelectors(options: {
readonly isEn: boolean
readonly heroSelect: HTMLSelectElement | null
readonly actSelect: HTMLSelectElement
readonly sceneSelect: HTMLSelectElement
readonly variantSelect: HTMLSelectElement
readonly lightingSelect: HTMLSelectElement | null
readonly langSwitch: HTMLAnchorElement | null
}): number {
const {
isEn,
heroSelect,
actSelect,
sceneSelect,
variantSelect,
lightingSelect,
langSwitch,
} = options
if (langSwitch !== null && typeof location !== 'undefined') {
const targetLang: TblLang = isEn ? 'CHI' : 'ENG'
langSwitch.textContent = isEn ? 'Lang: ZH' : '语言: EN'
langSwitch.href = buildLangSwitchHref(targetLang, location.pathname, location.search)
langSwitch.addEventListener('click', () => {
langSwitch.href = buildLangSwitchHref(targetLang, location.pathname, location.search)
})
}
if (lightingSelect !== null) {
lightingSelect.disabled = false
lightingSelect.value = normalizeLightingPreset(param('lighting', 'auto'))
lightingSelect.onchange = () => {
const params = new URLSearchParams(location.search)
if (lightingSelect.value === 'auto') params.delete('lighting')
else params.set('lighting', lightingSelect.value)
location.search = params.toString()
}
}
if (heroSelect !== null) {
heroSelect.disabled = false
heroSelect.value = normalizeHeroToken(param('hero', param('class', 'so')))
heroSelect.onchange = () => {
const params = new URLSearchParams(location.search)
params.set('hero', heroSelect.value)
location.search = params.toString()
}
}
const densitySelect = document.querySelector<HTMLSelectElement>('#density')
const eliteSelect = document.querySelector<HTMLSelectElement>('#elite')
if (densitySelect !== null) {
densitySelect.disabled = false
densitySelect.value = String(parseDensityMultiplier(param('density', '1')))
updateSelectorWarningStyle(densitySelect, '1')
}
if (eliteSelect !== null) {
eliteSelect.disabled = false
eliteSelect.value = String(parseEliteMultiplier(param('elite', '1')))
updateSelectorWarningStyle(eliteSelect, '1')
}
actSelect.disabled = false
sceneSelect.disabled = true
variantSelect.disabled = true
if (typeof Image !== 'undefined') {
const criticalImages = [
'/ui/ctrlpnl-800.png',
'/ui/globe-life.png',
'/ui/globe-mana.png',
'/ui/globe-poison.png',
'/ui/overlap-left.png',
'/ui/overlap-right.png',
'/ui/minipanel.png',
'/ui/minipanel-btns.png',
'/ui/runbutton.png',
'/ui/menubutton.png',
'/ui/border-left.png',
'/ui/border-right.png',
]
for (const src of criticalImages) {
const img = new Image()
img.src = src
}
}
const act = Math.min(ACT_COUNT, Math.max(1, Number(param('act', '1'))))
state.act = act
actSelect.value = String(act)
actSelect.addEventListener('change', () => {
const params = new URLSearchParams(location.search)
params.set('act', actSelect.value)
params.delete('level')
location.search = params.toString()
})
return act
}
/**
* Fill the level picker from a pack index and wire it to navigation.
*
* Every baked map is one entry, grouped by act. Picking one reloads the page with
* `?act=` and `?level=`, which is the same query the loader already understands,
* so the picker needs no second loading path.
*
* @param packBase - directory holding `index.json`.
* @param current - the act and the entry label currently displayed (`runtime.quadrant`),
* so the list marks exactly the map that is on screen.
*/
export async function populatePackSelectors(
packBase: string,
current: { act: number; label: string },
): Promise<void> {
const actSelect = document.querySelector<HTMLSelectElement>('#act')
const sceneSelect = document.querySelector<HTMLSelectElement>('#scene')
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')
if (actSelect === null || sceneSelect === null || variantSelect === null) return
let index: PackIndex
try {
index = await fetchJson<PackIndex>(`${packBase}/index.json`)
} catch {
return
}
const isEn = getTblLang() === 'ENG'
/** A scene type: one `Levels.txt` level, with every baked DS1 variant under it. */
interface Scene {
readonly slug: string
readonly name: string
readonly variants: readonly PackIndex['levels'][number][]
}
const scenesByAct = new Map<number, Scene[]>()
for (const entry of index.levels) {
let scenes = scenesByAct.get(entry.act)
if (scenes === undefined) { scenes = []; scenesByAct.set(entry.act, scenes) }
let scene = scenes.find(candidate => candidate.slug === entry.slug)
if (scene === undefined) {
scene = {
slug: entry.slug,
name: sceneDisplayName(entry.slug, entry.levelName, isEn ? 'ENG' : 'CHI'),
variants: [],
}
scenes.push(scene)
}
;(scene.variants as PackIndex['levels'][number][]).push(entry)
}
/** The suffix after the level slug, which is the DS1's own name fragment. */
const suffixOf = (entry: PackIndex['levels'][number]): string =>
entry.label.startsWith(`${entry.slug}-`) ? entry.label.slice(entry.slug.length + 1) : entry.label
const optionFor = (value: string, text: string, selected: boolean): HTMLOptionElement => {
const option = document.createElement('option')
option.value = value
option.textContent = text
option.selected = selected
return option
}
const acts = [...scenesByAct.keys()].sort((left, right) => left - right)
actSelect.replaceChildren(...acts.map(act => optionFor(
String(act),
isEn
? `${ACT_NAMES_EN[act] ?? `Act ${String(act)}`}`
: `${ACT_NAMES_ZH[act] ?? `第 ${String(act)} 章`}`,
act === current.act,
)))
actSelect.disabled = acts.length === 0
const scenes = scenesByAct.get(current.act) ?? []
const currentEntry = index.levels.find(entry => entry.act === current.act && entry.label === current.label)
const currentSceneSlug = currentEntry?.slug ?? scenes[0]?.slug ?? ''
sceneSelect.replaceChildren(...scenes.map(scene => optionFor(
scene.slug,
scene.name,
scene.slug === currentSceneSlug,
)))
sceneSelect.disabled = scenes.length === 0
const populateVariants = (slug: string, selectedLabel: string): void => {
const scene = scenes.find(candidate => candidate.slug === slug)
const variants = scene?.variants ?? []
// A single-variant scene still shows its own DS1 so the third selector is never
// an empty box; the option text repeats the scene name only when it is the only one.
variantSelect.replaceChildren(...variants.map(entry => {
const suffix = suffixOf(entry)
const label = variants.length > 1
? variantDisplayLabel(suffix, isEn ? 'ENG' : 'CHI')
: isEn
? `${scene?.name ?? entry.levelName} (${suffix})`
: `${scene?.name ?? entry.levelName}(${suffix})`
return optionFor(entry.label, label, entry.label === selectedLabel)
}))
variantSelect.disabled = variants.length === 0
}
populateVariants(currentSceneSlug, current.label)
variantSelect.dataset.scene = currentSceneSlug
const go = (params: URLSearchParams): void => { location.search = params.toString() }
const navigateToLabel = (label: string, act: number): void => {
const params = new URLSearchParams(location.search)
params.set('act', String(act))
params.set('level', label)
go(params)
}
actSelect.onchange = () => {
const act = Number(actSelect.value)
const first = scenesByAct.get(act)?.[0]
// Keep the address bar and the map in sync: an act with no baked scene yet (there
// are none today) simply leaves the current map alone.
if (first === undefined) return
navigateToLabel(first.variants[0]?.label ?? '', act)
}
sceneSelect.onchange = () => {
const scene = scenes.find(candidate => candidate.slug === sceneSelect.value)
if (scene === undefined) return
populateVariants(scene.slug, '')
navigateToLabel(scene.variants[0]?.label ?? '', current.act)
}
variantSelect.onchange = () => { navigateToLabel(variantSelect.value, current.act) }
}

File diff suppressed because it is too large Load Diff

View File

@ -33,9 +33,8 @@ import { decodePal } from '../formats/pal.ts'
import { decodePl2 } from '../formats/pl2.ts' import { decodePl2 } from '../formats/pl2.ts'
import type { Palette } from '../formats/pal.ts' import type { Palette } from '../formats/pal.ts'
import type { SpriteSheet } from '../formats/sprite.ts' import type { SpriteSheet } from '../formats/sprite.ts'
import { PLAYER_SPRITE_WIDTH } from '../formats/sprite.ts' import { addPlayer, createWorld, monsterStatsFromTable, spawnMonsters } from '../game/combat.ts'
import { addPlayer, createWorld, monsterStatsFromTable, spawnMonsters, tickCombatMulti } from '../game/combat.ts' import type { CombatOptions, CombatWorld } from '../game/combat.ts'
import type { CombatInput, CombatOptions, CombatTerrain, CombatWorld, MonsterStats } from '../game/combat.ts'
import { parseTable } from '../game/tables.ts' import { parseTable } from '../game/tables.ts'
import { ActorAnimator } from '../game/animation.ts' import { ActorAnimator } from '../game/animation.ts'
import { buildMapScene, blockedOverlap, findFreeSpawn } from '../game/map.ts' import { buildMapScene, blockedOverlap, findFreeSpawn } from '../game/map.ts'
@ -47,17 +46,15 @@ import { GameLoop } from '../sim/loop.ts'
import { KeyboardInput, directionOf } from '../sim/input.ts' import { KeyboardInput, directionOf } from '../sim/input.ts'
import { NetplaySession } from '../net/netplay.ts' import { NetplaySession } from '../net/netplay.ts'
import { socketTransport } from '../net/transport.ts' import { socketTransport } from '../net/transport.ts'
import { LockstepSession, computeLockstepHash } from '../net/lockstep.ts' import {
import { Inventory, type Item } from '../game/items.ts' NET_SEED,
import { QuestLog } from '../game/quests.ts' DEMO_MONSTERS,
import { executeDropPipeline, type DropTables } from '../game/drop-pipeline.ts' DEMO_EXPERIENCE,
import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts' createNetSimulation,
import { getMonsterTreasureClass, type Difficulty } from '../game/monsters.ts' type NetSources,
import { D2Rng } from '../game/d2-rng.ts' } from './backend/net-simulation.ts'
import { findSafeDropPosition, findIsometricDropPosition, getPickupEdgeDistance } from '../game/ground-items.ts'
import { worldToCell } from '../game/engine.ts'
import type { InputFrame, LockstepSimulation } from '../net/lockstep.ts' export { NET_SEED, DEMO_MONSTERS, DEMO_EXPERIENCE, createNetSimulation, type NetSources }
/** Collision box at the character's feet. */ /** Collision box at the character's feet. */
const FEET_WIDTH = 20 const FEET_WIDTH = 20
@ -72,8 +69,6 @@ const INPUT_DELAY_TICKS = 4
const HASH_INTERVAL = 5 const HASH_INTERVAL = 5
/** Maximum map libraries to merge, matching the campaign scene. */ /** Maximum map libraries to merge, matching the campaign scene. */
const MAX_LIBRARIES = 4 const MAX_LIBRARIES = 4
/** World seed: fixed, because both peers must build the same world. */
const NET_SEED = 0x0d2c0de
/** /**
* The page's state, exposed for the harness. Everything the automated checks look * The page's state, exposed for the harness. Everything the automated checks look
@ -164,23 +159,6 @@ const state: NetState = {
} }
if (typeof window !== 'undefined') window.__d2webNet = state if (typeof window !== 'undefined') window.__d2webNet = state
/** Raw members the scene needs. */
interface NetSources {
readonly ds1: Uint8Array
readonly libraries: readonly Uint8Array[]
readonly palette: Uint8Array
readonly paletteName: string
readonly actor: { readonly name: string; readonly sheet: SpriteSheet } | null
readonly monstats: Uint8Array | null
}
/** The monster table used when the archive has none, so the page is never empty. */
const DEMO_MONSTERS: MonsterStats[] = monsterStatsFromTable(parseTable([
'Id\tName\tHP\tDamage\tCooldownTicks\tReach\tAggroRadius\tSpeed\tXP',
'fallen\tFallen One\t16\t2\t24\t38\t420\t150\t12',
'zombie\tZombie\t30\t3\t32\t42\t360\t120\t20',
].join('\n')))
let running: GameLoop | null = null let running: GameLoop | null = null
let input: KeyboardInput | null = null let input: KeyboardInput | null = null
let activeRenderer: SpriteRenderer | null = null let activeRenderer: SpriteRenderer | null = null
@ -514,9 +492,6 @@ function startScene(
+ '少一人就等,因为「抢先开跑」会让先发出的输入掉进虚空,两边永久互等。' + '少一人就等,因为「抢先开跑」会让先发出的输入掉进虚空,两边永久互等。'
} }
/** Fallback experience thresholds: the co-op sandbox is not about levelling. */
const DEMO_EXPERIENCE: readonly number[] = [0, 0, 250, 700, 1500, 3000]
/** /**
* Digest the world. * Digest the world.
* *
@ -622,179 +597,3 @@ if (typeof window !== 'undefined') {
/** Whether the page is running inside a document (avoids running under Node). */ /** Whether the page is running inside a document (avoids running under Node). */
if (typeof document !== 'undefined') boot() if (typeof document !== 'undefined') boot()
export function createNetSimulation(
world: CombatWorld,
options: CombatOptions,
terrain: CombatTerrain,
xpTable: readonly number[],
numPeers: number,
seed: number = NET_SEED,
dropTables: DropTables = getEmbeddedDropTables(),
difficulty: Difficulty = 'normal',
) {
const inventories = Array.from({ length: Math.max(numPeers, 1) }, () => new Inventory(10, 4))
const quests = new QuestLog([])
const ground: { x: number; y: number; item: Item }[] = []
world.pendingKills = world.pendingKills ?? []
for (let i = 0; i < world.monsters.length; i += 1) {
const monster = world.monsters[i]!
if (monster.dwInitSeed === undefined) {
monster.dwInitSeed = (((seed ^ Math.imul(monster.index + 1, 0x9e3779b9)) >>> 0) || 1)
}
}
const mapTerrain = 'widthPx' in terrain
? (terrain as any)
: { ...terrain, widthPx: 10000, heightPx: 10000 }
const simulation: LockstepSimulation = {
advance: (inputs: readonly InputFrame[]) => {
const frames: CombatInput[] = inputs.map(input => ({
movement: input.movement,
attack: input.attack,
}))
tickCombatMulti(world, frames, options, terrain, xpTable)
const killEvents = [
...(world.pendingKills ?? []),
...world.events.filter(e => e.kind === 'kill'),
]
if (world.pendingKills !== undefined) {
world.pendingKills.length = 0
}
const activePlayers = Math.max(1, Math.min(8, world.players.filter(p => p.alive).length || numPeers || 1))
for (const event of killEvents) {
if (event.kind !== 'kill') continue
const monster = event.monsterIndex !== undefined
? world.monsters[event.monsterIndex] ?? world.monsters.find(m => m.index === event.monsterIndex)
: world.monsters.find(m => m.stats.id === event.subjectId && Math.hypot(m.x - event.x, m.y - event.y) < 10)
if (monster?.dropRolled) continue
if (monster) {
monster.dropRolled = true
}
const subjectId = (event.subjectId ?? monster?.stats.id ?? '').trim()
const monsterRank = event.monsterRank ?? monster?.stats.rank ?? 'normal'
const isMinion = monsterRank === 'minion'
const monsterType = isMinion
? 3
: (event.monsterType ?? (monsterRank === 'boss' ? 4 : monsterRank === 'unique' ? 3 : monsterRank === 'champion' ? 2 : 1))
const suDef = event.superUniqueId
? (dropTables.superUniques.get(event.superUniqueId) ??
(() => {
const target = event.superUniqueId!.trim().toLowerCase()
for (const [k, v] of dropTables.superUniques) {
if (k.toLowerCase() === target || v.id.toLowerCase() === target || v.nameKey?.toLowerCase() === target) {
return v
}
}
return undefined
})())
: undefined
let tcName = ''
if (!isMinion && suDef) {
tcName = typeof suDef.getTreasureClass === 'function'
? suDef.getTreasureClass(difficulty)
: suDef.treasureClass
}
const monsterKind =
dropTables.monsterKinds.get(subjectId) ??
dropTables.monsterKinds.get(`${subjectId}1`) ??
(suDef ? dropTables.monsterKinds.get(suDef.monsterId) : undefined)
if (!tcName && monsterKind) {
tcName = getMonsterTreasureClass(monsterKind, difficulty, monsterType)
}
if (!tcName && !monsterKind && !suDef) {
tcName = 'Act 1 H2H A'
}
if (!tcName) continue
const kindLevel = typeof (monsterKind as any)?.level === 'number'
? (monsterKind as any).level
: (monsterKind as any)?.level?.[difficulty]
const nLevel = Math.max(1, event.monsterLevel ?? monster?.stats.level ?? kindLevel ?? 1)
const monsterIndex = monster?.index ?? event.monsterIndex ?? 0
const monsterSeed =
monster?.dwInitSeed ??
((((seed ^ Math.imul(monsterIndex + 1, 0x9e3779b9)) >>> 0) || 1))
const droppedItems = executeDropPipeline(dropTables, {
tcName,
nLevel,
monsterType,
difficulty,
gamePlayers: activePlayers,
partyPlayers: activePlayers,
playerMf: 0,
playerGf: 0,
isBoss: isMinion ? false : Boolean(monsterKind?.boss),
isNoRatio: Boolean(monsterKind?.noRatio),
monsterRng: new D2Rng(monsterSeed),
})
const burstOccupied: { x: number; y: number }[] = ground.map(g => ({ x: g.x, y: g.y }))
const alivePlayers = world.players.filter(p => p.alive)
const refPlayers = alivePlayers.length > 0 ? alivePlayers : world.players
let reachableFrom: { x: number; y: number } | undefined
if (refPlayers.length > 0) {
let bestPlayer = refPlayers[0]!
let bestDist = Math.hypot(bestPlayer.x - event.x, bestPlayer.y - event.y)
for (let pIdx = 1; pIdx < refPlayers.length; pIdx += 1) {
const p = refPlayers[pIdx]!
const d = Math.hypot(p.x - event.x, p.y - event.y)
if (d < bestDist) {
bestDist = d
bestPlayer = p
}
}
reachableFrom = { x: bestPlayer.x, y: bestPlayer.y }
}
for (const drop of droppedItems) {
const dropPos = findIsometricDropPosition(
event.x,
event.y,
burstOccupied,
mapTerrain,
undefined,
undefined,
undefined,
undefined,
reachableFrom,
)
burstOccupied.push(dropPos)
ground.push({ x: dropPos.x, y: dropPos.y, item: drop })
}
}
inputs.forEach((input, index) => {
if (input.pickup && ground.length > 0) {
const player = world.players[index]
if (player) {
let bestIndex = -1
let bestEdgeDist = 64
for (let gIdx = 0; gIdx < ground.length; gIdx += 1) {
const g = ground[gIdx]!
const edgeDist = getPickupEdgeDistance(player.x, player.y, g.x, g.y)
if (edgeDist <= bestEdgeDist) {
bestEdgeDist = edgeDist
bestIndex = gIdx
}
}
if (bestIndex !== -1 && inventories[index]!.add(ground[bestIndex]!.item) !== null) {
ground.splice(bestIndex, 1)
}
}
}
})
},
hash: () => computeLockstepHash(world, inventories, ground, quests.all),
}
return { simulation, inventories, quests, ground }
}