refactor(client): remove leftover single-player client code
Delete the 29 src/client files that were no longer reachable from src/client/main.ts (launcher/play-entry, world/act-runtime, the SP scene modules, sim/*, cull/roofs/unit-sprites/animation/char-appearance, ground-labels, browser-mpq-source, and the dead barrels/re-export shims). Strip the GameEngine coupling from the online HUD: HudManager no longer binds/syncs an engine (bindEngine, syncFromEngine, switchClass, classProfiles, grantExperience/takeDamage/heal, pickupGroundGold, ...) or publishes window.__d2webHud/__d2webHudInstance; inventory/world-panels drop _boundEngine; globes stop writing __d2webHud. world-renderer, lighting-presets and ground-item-renderer keep only the online halves. Remove demo-only exports from src/common and src/client (DEFAULT_UNIVERSAL/SORCERESS_SKILLS, STARTER_EQUIPPED_GEAR/BAG_ITEMS, DEFAULT_BELT_POTIONS, BeltModel.seedDefaultPotions, createStarterProfileForClass, CLASS_DEFAULT_SKILLS). The data now lives in tests/fixtures/demo-profile-data.ts. Tables re-exported by src/server/world/class-starter-profiles.ts are kept. Tests: retarget imports of re-exported helpers to their origin modules; delete cases that only covered removed SP code; rewrite surviving behavior against GameEngine / common data (p0-520, p0-521, p0-528, starter-items, issue-503/506, class-switching, glacial-spike, skill-quickbar-tooltip, monster-target-bar MonUMod). e2e-module-split now targets src/client/main.ts instead of play-entry. Tools that imported deleted modules are removed or trimmed (verify-renderer-lifecycle).
This commit is contained in:
parent
4ba79b4c82
commit
13b2a4c326
|
|
@ -1,71 +0,0 @@
|
|||
/**
|
||||
* Browser-only MPQ sources (`Blob`/`File` and HTTP `Range` `fetch`).
|
||||
*/
|
||||
|
||||
import type { MpqSource } from '../../common/mpq/source.ts'
|
||||
|
||||
/**
|
||||
* Build a source over a browser `Blob`/`File` (drag & drop, `<input type=file>`,
|
||||
* OPFS handles all expose a Blob).
|
||||
*
|
||||
* @param blob - the blob to read.
|
||||
* @param label - identity shown in diagnostics.
|
||||
* @returns the source.
|
||||
*/
|
||||
export function blobSource(blob: Blob, label = blob instanceof File ? blob.name : 'blob'): MpqSource {
|
||||
return {
|
||||
size: blob.size,
|
||||
label,
|
||||
read: async (offset, length) => {
|
||||
if (offset < 0 || offset + length > blob.size) {
|
||||
throw new RangeError(`${label}: read ${String(offset)}+${String(length)} beyond ${String(blob.size)}`)
|
||||
}
|
||||
return new Uint8Array(await blob.slice(offset, offset + length).arrayBuffer())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Bytes to probe with when deciding whether a server honours `Range`. */
|
||||
const RANGE_PROBE_BYTES = 32
|
||||
|
||||
/**
|
||||
* Build a source over HTTP, reading only the byte ranges asked for.
|
||||
*
|
||||
* @param url - archive URL.
|
||||
* @param label - identity for diagnostics.
|
||||
* @returns the source.
|
||||
*/
|
||||
export async function httpRangeSource(url: string, label = url): Promise<MpqSource> {
|
||||
const probe = await fetch(url, { headers: { Range: `bytes=0-${String(RANGE_PROBE_BYTES - 1)}` } })
|
||||
if (probe.status !== 206) {
|
||||
const type = probe.headers.get('content-type') ?? 'unknown'
|
||||
throw new Error(
|
||||
`${label}: server answered ${String(probe.status)} (${type}) instead of 206 — `
|
||||
+ 'range requests are required to read an archive this large',
|
||||
)
|
||||
}
|
||||
const contentRange = probe.headers.get('content-range') ?? ''
|
||||
const total = Number(/\/(\d+)$/.exec(contentRange)?.[1] ?? '0')
|
||||
probe.body?.cancel().catch(() => {})
|
||||
if (!Number.isFinite(total) || total <= 0) {
|
||||
throw new Error(`${label}: server sent no usable Content-Range total (${contentRange})`)
|
||||
}
|
||||
return {
|
||||
size: total,
|
||||
label,
|
||||
read: async (offset, length) => {
|
||||
if (offset < 0 || offset + length > total) {
|
||||
throw new RangeError(`${label}: read ${String(offset)}+${String(length)} beyond ${String(total)}`)
|
||||
}
|
||||
const response = await fetch(url, { headers: { Range: `bytes=${String(offset)}-${String(offset + length - 1)}` } })
|
||||
if (response.status !== 206 && response.status !== 200) {
|
||||
throw new Error(`${label}: range read failed with HTTP ${String(response.status)}`)
|
||||
}
|
||||
const buffer = new Uint8Array(await response.arrayBuffer())
|
||||
if (buffer.byteLength !== length) {
|
||||
throw new Error(`${label}: asked for ${String(length)} bytes at ${String(offset)}, got ${String(buffer.byteLength)}`)
|
||||
}
|
||||
return buffer
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export * from './lang.ts'
|
||||
export { resolveTblKey } from '../../common/data/canonical-tbl-strings.ts'
|
||||
export { decodeTbl, tblLookup } from '../../common/formats/tbl.ts'
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
export {
|
||||
CommandMapper,
|
||||
ViewportCamera,
|
||||
type CameraLike,
|
||||
type CommandMapperDeps,
|
||||
type DispatchedCommandRecord,
|
||||
type PointerWorldTarget,
|
||||
type SkillHotkeyBinding,
|
||||
} from './command-mapper.ts'
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from '../main.ts'
|
||||
export * from '../world/act-runtime.ts'
|
||||
export * from '../session/online-session.ts'
|
||||
export * from '../world/client-world.ts'
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from "../../common/units/animation.ts"
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from "../../common/units/char-appearance.ts"
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
/**
|
||||
* 视口剔除:把"这一帧到底要画哪些东西"从整张地图缩小到屏幕那一块。
|
||||
*
|
||||
* 起因是一次实测:把 `act-scene` 的绘制顺序在 365 张烘焙关卡上静态重放一遍,
|
||||
* 平均每帧提交 2 848 个四边形,最差一帧(act4 迪亚波罗)14 400 个,而其中真正
|
||||
* 落在视口里的只有 7%~11%。也就是说九成的顶点写入、九成的图集页切换,都是在
|
||||
* 给屏幕外的瓦片做无用功——这正是主循环掉到 20 tps 的直接原因。
|
||||
*
|
||||
* 这里只做两件事:把绘制列表的包围盒预先摊平成 `Float32Array`,以及提供一个
|
||||
* 矩形相交判断。之所以不用四叉树/网格索引,是因为绘制列表本身**不是**按 y 单调
|
||||
* 的(实测地面 0/365 单调,最大回退 9 520 px),任何空间分桶都得重新合并回
|
||||
* 画家顺序;而线性扫描 14 400 个条目只需要 4 次浮点比较 × 14 400 ≈ 15~30 µs,
|
||||
* 占 16 ms 预算的 0.2%,却能原样保住顺序。
|
||||
*/
|
||||
|
||||
/** 一个可绘制物需要的最小信息:世界坐标 + 图集帧尺寸。 */
|
||||
export interface BoundedDrawable {
|
||||
readonly frame: { readonly width: number; readonly height: number }
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
/** 视口矩形,世界像素。 */
|
||||
export interface ViewRect {
|
||||
readonly left: number
|
||||
readonly top: number
|
||||
readonly right: number
|
||||
readonly bottom: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 视口四周额外放宽的余量,单位为场景像素。
|
||||
*
|
||||
* 包围盒已经算进了每帧的宽高,所以这点余量只是为了兜住 CPU 侧矩形与顶点着色器
|
||||
* 变换之间的浮点舍入,不是用来遮掩边界算错的。
|
||||
*/
|
||||
export const CULL_MARGIN = 32
|
||||
|
||||
/**
|
||||
* 把一条绘制列表的世界包围盒摊平成 `[x0, y0, x1, y1]` 四元组。
|
||||
*
|
||||
* 列表在关卡建好之后就不再变动,所以包围盒可以一次算完;用一整块连续内存而不是
|
||||
* 每帧去点两层属性(`draw.frame.width`),是为了让每帧的拒绝测试只碰缓存友好的
|
||||
* 相邻浮点。
|
||||
*
|
||||
* @param list - 带世界坐标和图集帧的可绘制物。
|
||||
* @returns 包围盒,每个条目 4 个浮点。
|
||||
*/
|
||||
export function buildBounds(list: readonly BoundedDrawable[]): Float32Array {
|
||||
const bounds = new Float32Array(list.length * 4)
|
||||
for (let index = 0; index < list.length; index += 1) {
|
||||
const draw = list[index]!
|
||||
const at = index * 4
|
||||
bounds[at] = draw.x
|
||||
bounds[at + 1] = draw.y
|
||||
bounds[at + 2] = draw.x + draw.frame.width
|
||||
bounds[at + 3] = draw.y + draw.frame.height
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
|
||||
/**
|
||||
* 由相机和画布尺寸算出视口矩形。
|
||||
*
|
||||
* 顶点着色器保留一个四边形的条件是 `|x - camX| <= drawingBufferWidth / (2 * zoom)`,
|
||||
* 而 `drawingBufferWidth` 就是 `canvas.width`,所以这里用同一个表达式复现它——
|
||||
* 两边算法一旦不一致,靠近屏幕边缘的瓦片就会闪烁。
|
||||
*
|
||||
* @param camX - 相机中心 x,世界像素。
|
||||
* @param camY - 相机中心 y,世界像素。
|
||||
* @param canvasWidth - 画布后备存储宽度。
|
||||
* @param canvasHeight - 画布后备存储高度。
|
||||
* @param zoom - 当前缩放。
|
||||
*/
|
||||
export function viewportRect(
|
||||
camX: number,
|
||||
camY: number,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
zoom: number,
|
||||
): ViewRect {
|
||||
const halfWidth = canvasWidth / (2 * zoom) + CULL_MARGIN
|
||||
const halfHeight = canvasHeight / (2 * zoom) + CULL_MARGIN
|
||||
return {
|
||||
left: camX - halfWidth,
|
||||
top: camY - halfHeight,
|
||||
right: camX + halfWidth,
|
||||
bottom: camY + halfHeight,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 `bounds` 中第 `index` 个条目是否与视口相交。
|
||||
*
|
||||
* 这是整帧里调用次数最多的一个函数(最差 14 400 次/帧),所以它只做四次比较,
|
||||
* 不建临时对象、不解构。
|
||||
*
|
||||
* @param bounds - `buildBounds` 的输出。
|
||||
* @param index - 条目下标(不是浮点下标)。
|
||||
* @param view - 视口矩形。
|
||||
*/
|
||||
export function intersects(bounds: Float32Array, index: number, view: ViewRect): boolean {
|
||||
const at = index * 4
|
||||
return bounds[at + 2]! >= view.left && bounds[at]! <= view.right
|
||||
&& bounds[at + 3]! >= view.top && bounds[at + 1]! <= view.bottom
|
||||
}
|
||||
|
|
@ -1,611 +0,0 @@
|
|||
/**
|
||||
* Dynamic roof occlusion detection and opacity fade (Roof Fade).
|
||||
*
|
||||
* In Diablo II, roof tiles (`TileRoof = 15`) are placed over shelters like
|
||||
* Akara's tent, Charsi's forge, and town houses. They are rendered in Pass 4
|
||||
* (topmost layer) after floors, walls, and characters.
|
||||
*
|
||||
* When the player steps inside a shelter or under an overhanging roof, the roof
|
||||
* tile(s) occlude the character. This module detects player occlusion
|
||||
* (via bounding box overlap or cell/sub-tile coverage) and smoothly transitions
|
||||
* the roof opacity between 1.0 (opaque) and a semi-transparent alpha (e.g. 0.25),
|
||||
* restoring full opacity upon exit.
|
||||
*/
|
||||
|
||||
/** Minimal roof tile drawable interface. */
|
||||
export interface RoofDrawable {
|
||||
readonly frame: { readonly width: number; readonly height: number }
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly cellX?: number
|
||||
readonly cellY?: number
|
||||
readonly roofHeight?: number
|
||||
readonly wallType?: number
|
||||
}
|
||||
|
||||
/** 2D axis-aligned bounding box in world/scene coordinates. */
|
||||
export interface RoofBounds {
|
||||
readonly minX: number
|
||||
readonly minY: number
|
||||
readonly maxX: number
|
||||
readonly maxY: number
|
||||
}
|
||||
|
||||
/** Point position in world/scene pixels. */
|
||||
export interface Point2D {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
/** Axis-aligned bounding box with left/top/right/bottom. */
|
||||
export interface Box2D {
|
||||
readonly left: number
|
||||
readonly top: number
|
||||
readonly right: number
|
||||
readonly bottom: number
|
||||
}
|
||||
|
||||
/** Player target representation: a point { x, y }, a box { left, top, right, bottom }, or RoofBounds. */
|
||||
export type PlayerTarget = Point2D | Box2D | RoofBounds
|
||||
|
||||
/** Configuration options for roof fade and occlusion detection. */
|
||||
export interface RoofFadeConfig {
|
||||
/**
|
||||
* Minimum opacity when player is under roof (0.0 .. 1.0).
|
||||
* Default: 0.0 (vanilla Diablo II completely fades occluding roofs).
|
||||
*/
|
||||
readonly minAlpha?: number
|
||||
|
||||
/**
|
||||
* Maximum opacity when player is outside (0.0 .. 1.0).
|
||||
* Default: 1.0.
|
||||
*/
|
||||
readonly maxAlpha?: number
|
||||
|
||||
/**
|
||||
* Fade transition duration in milliseconds.
|
||||
* Default: 200 ms.
|
||||
*/
|
||||
readonly fadeDurationMs?: number
|
||||
|
||||
/**
|
||||
* Player bounding box half-width (padding) for point-based player positions.
|
||||
* Default: 16 pixels.
|
||||
*/
|
||||
readonly playerPaddingX?: number
|
||||
|
||||
/**
|
||||
* Player bounding box height for point-based player positions (feet to head).
|
||||
* Default: 48 pixels.
|
||||
*/
|
||||
readonly playerHeight?: number
|
||||
|
||||
/**
|
||||
* Maximum cell distance to cluster adjacent roof tiles into a single shelter.
|
||||
* Default: 4 cells.
|
||||
*/
|
||||
readonly clusterCellDistance?: number
|
||||
|
||||
/**
|
||||
* Pixel proximity margin to connect roof bounding boxes into a cluster.
|
||||
* Default: 40 pixels.
|
||||
*/
|
||||
readonly clusterPixelMargin?: number
|
||||
}
|
||||
|
||||
export const DEFAULT_MIN_ROOF_ALPHA = 0.0
|
||||
export const DEFAULT_MAX_ROOF_ALPHA = 1.0
|
||||
export const DEFAULT_ROOF_FADE_DURATION_MS = 200
|
||||
export const DEFAULT_PLAYER_PADDING_X = 16
|
||||
export const DEFAULT_PLAYER_HEIGHT = 48
|
||||
export const DEFAULT_CLUSTER_CELL_DISTANCE = 4
|
||||
export const DEFAULT_CLUSTER_PIXEL_MARGIN = 40
|
||||
|
||||
/**
|
||||
* Compute the world-space bounding box of a single roof tile.
|
||||
*
|
||||
* For elevated roofs (with `roofHeight > 0`), the bounding box is projected
|
||||
* downward to the ground footprint so that a character standing on the ground
|
||||
* beneath the elevated sprite is detected within the occlusion area.
|
||||
*
|
||||
* @param roof - Roof drawable with position and dimensions.
|
||||
* @returns 2D axis-aligned bounding box.
|
||||
*/
|
||||
export function getRoofBounds(roof: RoofDrawable): RoofBounds {
|
||||
let elevation = roof.roofHeight ?? 0
|
||||
if (elevation === 0 && roof.cellX !== undefined && roof.cellY !== undefined) {
|
||||
const orthoY = (roof.cellX + roof.cellY) * 40
|
||||
if (orthoY > roof.y) {
|
||||
elevation = orthoY - roof.y
|
||||
}
|
||||
}
|
||||
return {
|
||||
minX: roof.x,
|
||||
minY: roof.y,
|
||||
maxX: roof.x + roof.frame.width,
|
||||
maxY: roof.y + roof.frame.height + Math.max(0, elevation),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the combined bounding box enclosing an array of roof tiles.
|
||||
*
|
||||
* @param roofs - Array of roof drawables.
|
||||
* @returns Combined bounding box, or null if the array is empty.
|
||||
*/
|
||||
export function getCombinedRoofBounds(roofs: readonly RoofDrawable[]): RoofBounds | null {
|
||||
if (roofs.length === 0) return null
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
for (let i = 0; i < roofs.length; i += 1) {
|
||||
const b = getRoofBounds(roofs[i]!)
|
||||
if (b.minX < minX) minX = b.minX
|
||||
if (b.minY < minY) minY = b.minY
|
||||
if (b.maxX > maxX) maxX = b.maxX
|
||||
if (b.maxY > maxY) maxY = b.maxY
|
||||
}
|
||||
|
||||
return { minX, minY, maxX, maxY }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a 2D point lies within a bounding box (inclusive).
|
||||
*
|
||||
* @param px - Point x coordinate.
|
||||
* @param py - Point y coordinate.
|
||||
* @param bounds - Bounding box.
|
||||
*/
|
||||
export function isPointInRoofBounds(px: number, py: number, bounds: RoofBounds): boolean {
|
||||
return px >= bounds.minX && px <= bounds.maxX && py >= bounds.minY && py <= bounds.maxY
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two axis-aligned bounding boxes overlap.
|
||||
*
|
||||
* @param a - First bounding box.
|
||||
* @param b - Second bounding box.
|
||||
*/
|
||||
export function boundsOverlap(a: RoofBounds, b: RoofBounds): boolean {
|
||||
return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a player target (point or box) into a world-space bounding box.
|
||||
*
|
||||
* @param player - Point { x, y }, Box { left, top, right, bottom }, or RoofBounds.
|
||||
* @param paddingX - Half-width when expanding a point (default: 16).
|
||||
* @param height - Height when expanding a point upward from feet (default: 48).
|
||||
*/
|
||||
export function getPlayerBounds(
|
||||
player: PlayerTarget,
|
||||
paddingX = DEFAULT_PLAYER_PADDING_X,
|
||||
height = DEFAULT_PLAYER_HEIGHT,
|
||||
): RoofBounds {
|
||||
if ('minX' in player && 'minY' in player && 'maxX' in player && 'maxY' in player) {
|
||||
return player
|
||||
}
|
||||
if ('left' in player && 'right' in player && 'top' in player && 'bottom' in player) {
|
||||
return {
|
||||
minX: Math.min(player.left, player.right),
|
||||
minY: Math.min(player.top, player.bottom),
|
||||
maxX: Math.max(player.left, player.right),
|
||||
maxY: Math.max(player.top, player.bottom),
|
||||
}
|
||||
}
|
||||
return {
|
||||
minX: player.x - paddingX,
|
||||
minY: player.y - height,
|
||||
maxX: player.x + paddingX,
|
||||
maxY: player.y,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a player's position or bounding box overlaps a roof's bounding box.
|
||||
*
|
||||
* @param player - Player position or box.
|
||||
* @param roof - Roof drawable or precomputed bounds.
|
||||
* @param paddingX - Optional player padding in x.
|
||||
* @param playerHeight - Optional player height.
|
||||
*/
|
||||
export function isPlayerOverlappingRoofBounds(
|
||||
player: PlayerTarget,
|
||||
roof: RoofDrawable | RoofBounds,
|
||||
paddingX = DEFAULT_PLAYER_PADDING_X,
|
||||
playerHeight = DEFAULT_PLAYER_HEIGHT,
|
||||
): boolean {
|
||||
const pBounds = getPlayerBounds(player, paddingX, playerHeight)
|
||||
const rBounds = 'frame' in roof ? getRoofBounds(roof) : roof
|
||||
return boundsOverlap(pBounds, rBounds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if player's cell matches any roof tile's cell.
|
||||
*
|
||||
* @param playerCell - Player cell coordinates { x, y }.
|
||||
* @param roofs - Roof drawable(s) or array with cellX/cellY.
|
||||
*/
|
||||
export function isPlayerInRoofCell(
|
||||
playerCell: Point2D,
|
||||
roofs: { readonly cellX?: number; readonly cellY?: number } | readonly { readonly cellX?: number; readonly cellY?: number }[],
|
||||
): boolean {
|
||||
if (Array.isArray(roofs)) {
|
||||
for (let i = 0; i < roofs.length; i += 1) {
|
||||
const r = roofs[i]!
|
||||
if (r.cellX !== undefined && r.cellY !== undefined && r.cellX === playerCell.x && r.cellY === playerCell.y) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
const single = roofs as { readonly cellX?: number; readonly cellY?: number }
|
||||
return single.cellX !== undefined && single.cellY !== undefined && single.cellX === playerCell.x && single.cellY === playerCell.y
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if player's sub-tile coordinates fall inside any cell covered by a roof.
|
||||
* In Diablo II, 1 cell = 5x5 sub-tiles (subTilesPerTile = 5).
|
||||
*
|
||||
* @param playerSubTile - Player sub-tile coordinates { x, y }.
|
||||
* @param roofs - Roof drawable(s) with cellX/cellY.
|
||||
* @param subTilesPerTile - Sub-tiles per cell edge (default: 5).
|
||||
*/
|
||||
export function isPlayerInRoofSubTile(
|
||||
playerSubTile: Point2D,
|
||||
roofs: { readonly cellX?: number; readonly cellY?: number } | readonly { readonly cellX?: number; readonly cellY?: number }[],
|
||||
subTilesPerTile = 5,
|
||||
): boolean {
|
||||
const cellX = Math.floor(playerSubTile.x / subTilesPerTile)
|
||||
const cellY = Math.floor(playerSubTile.y / subTilesPerTile)
|
||||
return isPlayerInRoofCell({ x: cellX, y: cellY }, roofs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether player is under a specific roof tile, checking bounding box
|
||||
* overlap OR cell/sub-tile match.
|
||||
*
|
||||
* @param player - Player coordinates or bounding box.
|
||||
* @param roof - Roof drawable.
|
||||
* @param playerCell - Optional player cell coordinates.
|
||||
* @param config - Optional configuration.
|
||||
*/
|
||||
export function isPlayerUnderRoof(
|
||||
player: PlayerTarget,
|
||||
roof: RoofDrawable,
|
||||
playerCell?: Point2D,
|
||||
config?: RoofFadeConfig,
|
||||
): boolean {
|
||||
if (playerCell && roof.cellX !== undefined && roof.cellY !== undefined) {
|
||||
if (roof.cellX === playerCell.x && roof.cellY === playerCell.y) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return isPlayerOverlappingRoofBounds(
|
||||
player,
|
||||
roof,
|
||||
config?.playerPaddingX ?? DEFAULT_PLAYER_PADDING_X,
|
||||
config?.playerHeight ?? DEFAULT_PLAYER_HEIGHT,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Step an opacity value towards targetAlpha given elapsed time deltaMs and duration.
|
||||
*
|
||||
* @param currentAlpha - Current alpha (0.0 .. 1.0).
|
||||
* @param targetAlpha - Target alpha (0.0 .. 1.0).
|
||||
* @param deltaMs - Elapsed wall-clock milliseconds.
|
||||
* @param durationMs - Transition duration in ms (default: 200).
|
||||
* @param minAlpha - Minimum allowed alpha (default: 0.25).
|
||||
* @param maxAlpha - Maximum allowed alpha (default: 1.0).
|
||||
*/
|
||||
export function stepRoofAlpha(
|
||||
currentAlpha: number,
|
||||
targetAlpha: number,
|
||||
deltaMs: number,
|
||||
durationMs = DEFAULT_ROOF_FADE_DURATION_MS,
|
||||
minAlpha = DEFAULT_MIN_ROOF_ALPHA,
|
||||
maxAlpha = DEFAULT_MAX_ROOF_ALPHA,
|
||||
): number {
|
||||
if (durationMs <= 0) return targetAlpha
|
||||
if (deltaMs <= 0) return currentAlpha
|
||||
|
||||
const speed = (maxAlpha - minAlpha) / durationMs
|
||||
const change = speed * deltaMs
|
||||
|
||||
if (currentAlpha < targetAlpha) {
|
||||
return Math.min(targetAlpha, currentAlpha + change)
|
||||
}
|
||||
if (currentAlpha > targetAlpha) {
|
||||
return Math.max(targetAlpha, currentAlpha - change)
|
||||
}
|
||||
return currentAlpha
|
||||
}
|
||||
|
||||
/** Represents a clustered shelter composed of one or more adjacent roof tiles. */
|
||||
export interface RoofCluster {
|
||||
readonly id: number
|
||||
readonly roofs: readonly RoofDrawable[]
|
||||
readonly roofIndices: readonly number[]
|
||||
readonly bounds: RoofBounds
|
||||
readonly cells: ReadonlySet<string>
|
||||
/** Current opacity alpha (0.0 .. 1.0). */
|
||||
alpha: number
|
||||
/** Target opacity alpha (0.0 .. 1.0). */
|
||||
targetAlpha: number
|
||||
/** Whether the player was detected under this cluster on the latest update. */
|
||||
isPlayerUnder: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Group roof drawables into connected shelters (clusters).
|
||||
*
|
||||
* Roof tiles that share adjacent cells or have overlapping/nearby bounding boxes
|
||||
* are grouped together so an entire building/tent roof fades in unison.
|
||||
*
|
||||
* @param roofs - Array of roof drawables.
|
||||
* @param config - Clustering parameters.
|
||||
*/
|
||||
export function clusterRoofs(
|
||||
roofs: readonly RoofDrawable[],
|
||||
config: RoofFadeConfig = {},
|
||||
): RoofCluster[] {
|
||||
if (roofs.length === 0) return []
|
||||
|
||||
const cellDist = config.clusterCellDistance ?? DEFAULT_CLUSTER_CELL_DISTANCE
|
||||
const pixelMargin = config.clusterPixelMargin ?? DEFAULT_CLUSTER_PIXEL_MARGIN
|
||||
|
||||
const n = roofs.length
|
||||
const parent = new Int32Array(n)
|
||||
for (let i = 0; i < n; i += 1) parent[i] = i
|
||||
|
||||
const find = (i: number): number => {
|
||||
let root = i
|
||||
while (parent[root] !== root) root = parent[root]!
|
||||
let curr = i
|
||||
while (curr !== root) {
|
||||
const nxt = parent[curr]!
|
||||
parent[curr] = root
|
||||
curr = nxt
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
const union = (i: number, j: number): void => {
|
||||
const rootI = find(i)
|
||||
const rootJ = find(j)
|
||||
if (rootI !== rootJ) parent[rootI] = rootJ
|
||||
}
|
||||
|
||||
const boundsList = roofs.map(getRoofBounds)
|
||||
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const a = roofs[i]!
|
||||
const bA = boundsList[i]!
|
||||
const expandedA: RoofBounds = {
|
||||
minX: bA.minX - pixelMargin,
|
||||
minY: bA.minY - pixelMargin,
|
||||
maxX: bA.maxX + pixelMargin,
|
||||
maxY: bA.maxY + pixelMargin,
|
||||
}
|
||||
|
||||
for (let j = i + 1; j < n; j += 1) {
|
||||
const b = roofs[j]!
|
||||
let connected = false
|
||||
|
||||
// 1. Cell adjacency
|
||||
if (
|
||||
a.cellX !== undefined && a.cellY !== undefined &&
|
||||
b.cellX !== undefined && b.cellY !== undefined
|
||||
) {
|
||||
if (
|
||||
Math.abs(a.cellX - b.cellX) <= cellDist &&
|
||||
Math.abs(a.cellY - b.cellY) <= cellDist
|
||||
) {
|
||||
connected = true
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Pixel bounding box proximity
|
||||
if (!connected && boundsOverlap(expandedA, boundsList[j]!)) {
|
||||
connected = true
|
||||
}
|
||||
|
||||
if (connected) union(i, j)
|
||||
}
|
||||
}
|
||||
|
||||
const groups = new Map<number, number[]>()
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const root = find(i)
|
||||
const existing = groups.get(root)
|
||||
if (existing !== undefined) {
|
||||
existing.push(i)
|
||||
} else {
|
||||
groups.set(root, [i])
|
||||
}
|
||||
}
|
||||
|
||||
const maxAlpha = config.maxAlpha ?? DEFAULT_MAX_ROOF_ALPHA
|
||||
const clusters: RoofCluster[] = []
|
||||
let clusterId = 0
|
||||
|
||||
for (const indices of groups.values()) {
|
||||
const clusterRoofsList: RoofDrawable[] = []
|
||||
const cells = new Set<string>()
|
||||
|
||||
for (const idx of indices) {
|
||||
const r = roofs[idx]!
|
||||
clusterRoofsList.push(r)
|
||||
if (r.cellX !== undefined && r.cellY !== undefined) {
|
||||
cells.add(`${String(r.cellX)},${String(r.cellY)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Bridge passage / corridor cells between nearby roofs in the same cluster
|
||||
const len = clusterRoofsList.length
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
const a = clusterRoofsList[i]!
|
||||
if (a.cellX === undefined || a.cellY === undefined) continue
|
||||
for (let j = i + 1; j < len; j += 1) {
|
||||
const b = clusterRoofsList[j]!
|
||||
if (b.cellX === undefined || b.cellY === undefined) continue
|
||||
if (
|
||||
Math.abs(a.cellX - b.cellX) <= cellDist &&
|
||||
Math.abs(a.cellY - b.cellY) <= cellDist
|
||||
) {
|
||||
const minCx = Math.min(a.cellX, b.cellX)
|
||||
const maxCx = Math.max(a.cellX, b.cellX)
|
||||
const minCy = Math.min(a.cellY, b.cellY)
|
||||
const maxCy = Math.max(a.cellY, b.cellY)
|
||||
for (let cx = minCx; cx <= maxCx; cx += 1) {
|
||||
for (let cy = minCy; cy <= maxCy; cy += 1) {
|
||||
cells.add(`${String(cx)},${String(cy)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bounds = getCombinedRoofBounds(clusterRoofsList)!
|
||||
clusters.push({
|
||||
id: clusterId,
|
||||
roofs: clusterRoofsList,
|
||||
roofIndices: indices,
|
||||
bounds,
|
||||
cells,
|
||||
alpha: maxAlpha,
|
||||
targetAlpha: maxAlpha,
|
||||
isPlayerUnder: false,
|
||||
})
|
||||
clusterId += 1
|
||||
}
|
||||
|
||||
return clusters
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller managing roof opacity and dynamic roof fading for a scene.
|
||||
*/
|
||||
export class RoofFadeController {
|
||||
readonly clusters: RoofCluster[]
|
||||
readonly roofClusterMap: Int32Array
|
||||
readonly config: Required<RoofFadeConfig>
|
||||
|
||||
constructor(
|
||||
roofs: readonly RoofDrawable[],
|
||||
config: RoofFadeConfig = {},
|
||||
) {
|
||||
this.config = {
|
||||
minAlpha: config.minAlpha ?? DEFAULT_MIN_ROOF_ALPHA,
|
||||
maxAlpha: config.maxAlpha ?? DEFAULT_MAX_ROOF_ALPHA,
|
||||
fadeDurationMs: config.fadeDurationMs ?? DEFAULT_ROOF_FADE_DURATION_MS,
|
||||
playerPaddingX: config.playerPaddingX ?? DEFAULT_PLAYER_PADDING_X,
|
||||
playerHeight: config.playerHeight ?? DEFAULT_PLAYER_HEIGHT,
|
||||
clusterCellDistance: config.clusterCellDistance ?? DEFAULT_CLUSTER_CELL_DISTANCE,
|
||||
clusterPixelMargin: config.clusterPixelMargin ?? DEFAULT_CLUSTER_PIXEL_MARGIN,
|
||||
}
|
||||
|
||||
this.clusters = clusterRoofs(roofs, this.config)
|
||||
this.roofClusterMap = new Int32Array(roofs.length)
|
||||
|
||||
for (let cIdx = 0; cIdx < this.clusters.length; cIdx += 1) {
|
||||
const cluster = this.clusters[cIdx]!
|
||||
for (const rIdx of cluster.roofIndices) {
|
||||
this.roofClusterMap[rIdx] = cIdx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update opacity for all roof clusters based on player position and elapsed time.
|
||||
*
|
||||
* @param player - Current player world position or bounding box.
|
||||
* @param deltaMs - Elapsed wall-clock time in milliseconds.
|
||||
* @param playerCell - Optional player cell { x, y } in tile grid.
|
||||
*/
|
||||
update(
|
||||
player: PlayerTarget,
|
||||
deltaMs: number,
|
||||
playerCell?: Point2D,
|
||||
): void {
|
||||
const pBounds = getPlayerBounds(player, this.config.playerPaddingX, this.config.playerHeight)
|
||||
|
||||
for (let i = 0; i < this.clusters.length; i += 1) {
|
||||
const cluster = this.clusters[i]!
|
||||
let under = false
|
||||
|
||||
// 1. Check cell coverage
|
||||
if (playerCell && cluster.cells.has(`${String(playerCell.x)},${String(playerCell.y)}`)) {
|
||||
under = true
|
||||
}
|
||||
|
||||
// 2. Check bounding box overlap if not already triggered by cell
|
||||
if (!under && boundsOverlap(pBounds, cluster.bounds)) {
|
||||
for (let r = 0; r < cluster.roofs.length; r += 1) {
|
||||
if (isPlayerOverlappingRoofBounds(pBounds, cluster.roofs[r]!)) {
|
||||
under = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cluster.isPlayerUnder = under
|
||||
cluster.targetAlpha = under ? this.config.minAlpha : this.config.maxAlpha
|
||||
cluster.alpha = stepRoofAlpha(
|
||||
cluster.alpha,
|
||||
cluster.targetAlpha,
|
||||
deltaMs,
|
||||
this.config.fadeDurationMs,
|
||||
this.config.minAlpha,
|
||||
this.config.maxAlpha,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current opacity alpha for a roof drawable by its index.
|
||||
*
|
||||
* @param roofIndex - Index of the roof in the original list passed to the constructor.
|
||||
* @returns Opacity alpha in range [minAlpha, maxAlpha].
|
||||
*/
|
||||
getAlpha(roofIndex: number): number {
|
||||
const clusterIdx = this.roofClusterMap[roofIndex]
|
||||
if (clusterIdx === undefined || clusterIdx < 0) return this.config.maxAlpha
|
||||
return this.clusters[clusterIdx]?.alpha ?? this.config.maxAlpha
|
||||
}
|
||||
|
||||
/**
|
||||
* Set alpha directly for all clusters (useful for testing or scene reset).
|
||||
*/
|
||||
setAlpha(alpha: number): void {
|
||||
for (let i = 0; i < this.clusters.length; i += 1) {
|
||||
this.clusters[i]!.alpha = alpha
|
||||
this.clusters[i]!.targetAlpha = alpha
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the player is currently detected under any roof cluster.
|
||||
*/
|
||||
get isPlayerUnderAnyRoof(): boolean {
|
||||
for (let i = 0; i < this.clusters.length; i += 1) {
|
||||
if (this.clusters[i]!.isPlayerUnder) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowest opacity alpha among all roof clusters (for global state/HUD reporting).
|
||||
*/
|
||||
get overallAlpha(): number {
|
||||
if (this.clusters.length === 0) return this.config.maxAlpha
|
||||
let min = 1.0
|
||||
for (let i = 0; i < this.clusters.length; i += 1) {
|
||||
if (this.clusters[i]!.alpha < min) min = this.clusters[i]!.alpha
|
||||
}
|
||||
return min
|
||||
}
|
||||
}
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
/**
|
||||
* Diablo II: Lord of Destruction v1.13c Unified 7-Class, Missile, Overlay & Summon Sprite Loader (`UnitSpriteLoader`).
|
||||
*
|
||||
* - Maps all 7 player classes (`ama`->`am`, `sor`->`so`, `nec`->`ne`, `pal`->`pa`, `bar`->`ba`, `dru`->`dz`, `ass`->`ai`)
|
||||
* across `d2char.mpq` and `d2exp.mpq` with 16 composite layers (`hd..s8`).
|
||||
* - Decodes missile `.dcc` sprites (`data\global\missiles\<CelFile>.dcc`) via `decodeDcc`.
|
||||
* - Decodes state/cast overlay `.dcc`/`.dc6` sprites (`data\global\overlays\<Filename>.dcc` or `.dc6`)
|
||||
* and distinguishes `PreDraw === true` (ground aura rings) vs `PreDraw === false` (overhead curses/flashes).
|
||||
* - Decodes summon/trap/morph monster sprites (`data\global\monsters\<Code>\`).
|
||||
*/
|
||||
import type { MountedArchives } from '../../common/mpq/mount.ts'
|
||||
import { decodeDcc } from '../../common/formats/dcc.ts'
|
||||
import { decodeDc6 } from '../../common/formats/dc6.ts'
|
||||
import type { SpriteFrame, SpriteSheet } from '../../common/formats/sprite.ts'
|
||||
import { loadCharacterSheet, type CharacterSheet } from '../../common/units/character.ts'
|
||||
|
||||
import { CLASS_CODE_TO_CHAR_TOKEN, resolvePlayerCharToken } from '../../common/units/classes.ts'
|
||||
export { CLASS_CODE_TO_CHAR_TOKEN, resolvePlayerCharToken }
|
||||
|
||||
export class UnitSpriteLoader {
|
||||
readonly archives: MountedArchives
|
||||
private readonly charSheetCache = new Map<string, Promise<CharacterSheet>>()
|
||||
private readonly missileSheetCache = new Map<string, Promise<SpriteSheet>>()
|
||||
private readonly overlaySheetCache = new Map<string, Promise<SpriteSheet>>()
|
||||
private readonly monsterSheetCache = new Map<string, Promise<CharacterSheet>>()
|
||||
|
||||
constructor(archives: MountedArchives) {
|
||||
this.archives = archives
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and cache a composite character sheet for any of the 7 hero classes (`am`, `so`, `ne`, `pa`, `ba`, `dz`, `ai`).
|
||||
*/
|
||||
loadPlayerClassSheet(
|
||||
classOrToken: string,
|
||||
mode = 'sc',
|
||||
weaponClass = 'hth',
|
||||
): Promise<CharacterSheet> {
|
||||
const token = resolvePlayerCharToken(classOrToken)
|
||||
const anim = mode.trim().toLowerCase()
|
||||
const wclass = weaponClass.trim().toLowerCase()
|
||||
const key = `${token}:${anim}:${wclass}`
|
||||
|
||||
const existing = this.charSheetCache.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
const promise = loadCharacterSheet(this.archives, token, anim, wclass)
|
||||
this.charSheetCache.set(key, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and cache a missile sprite (`data\global\missiles\<celFile>.dcc`).
|
||||
*/
|
||||
loadMissileSprite(celFile: string): Promise<SpriteSheet> {
|
||||
const clean = celFile.trim().replace(/\.(dcc|dc6)$/i, '')
|
||||
const key = clean.toLowerCase()
|
||||
const existing = this.missileSheetCache.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
const promise = (async (): Promise<SpriteSheet> => {
|
||||
const dccPath = `data\\global\\missiles\\${clean}.dcc`
|
||||
const bytes = await this.archives.read(dccPath)
|
||||
const dcc = decodeDcc(bytes)
|
||||
const groups = dcc.directions.map(dir => ({
|
||||
frames: dir.frames.map(
|
||||
(f): SpriteFrame => ({
|
||||
width: f.frame.width,
|
||||
height: f.frame.height,
|
||||
indices: f.frame.indices,
|
||||
mask: f.frame.mask,
|
||||
anchorX: dir.box.left,
|
||||
anchorY: dir.box.top,
|
||||
}),
|
||||
),
|
||||
}))
|
||||
return { groups, width: null }
|
||||
})()
|
||||
|
||||
this.missileSheetCache.set(key, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and cache a state/cast overlay sprite (`data\global\overlays\<filename>.dcc` with `.dc6` fallback).
|
||||
* Accepts either an `Overlay.txt` `Filename` (`CurseAmplifyDamageEffect`) or `overlay` ID (`curseamplifydamage`).
|
||||
*/
|
||||
loadOverlaySprite(filenameOrOverlayId: string): Promise<SpriteSheet> {
|
||||
const clean = filenameOrOverlayId.trim().replace(/\.(dcc|dc6)$/i, '')
|
||||
const key = clean.toLowerCase()
|
||||
const existing = this.overlaySheetCache.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
const promise = (async (): Promise<SpriteSheet> => {
|
||||
const candidates = [
|
||||
clean,
|
||||
key === 'cursampdamage' || key === 'curseamplifydamage' ? 'CurseAmplifyDamageEffect' : '',
|
||||
'AuraFanatic',
|
||||
].filter(Boolean)
|
||||
|
||||
for (const cand of candidates) {
|
||||
const dccPath = `data\\global\\overlays\\${cand}.dcc`
|
||||
if (this.archives.find(dccPath)) {
|
||||
const bytes = await this.archives.read(dccPath)
|
||||
const dcc = decodeDcc(bytes)
|
||||
const groups = dcc.directions.map(dir => ({
|
||||
frames: dir.frames.map(
|
||||
(f): SpriteFrame => ({
|
||||
width: f.frame.width,
|
||||
height: f.frame.height,
|
||||
indices: f.frame.indices,
|
||||
mask: f.frame.mask,
|
||||
anchorX: dir.box.left,
|
||||
anchorY: dir.box.top,
|
||||
}),
|
||||
),
|
||||
}))
|
||||
return { groups, width: null }
|
||||
}
|
||||
}
|
||||
|
||||
const dc6Path = `data\\global\\overlays\\${clean}.dc6`
|
||||
const bytes = await this.archives.read(dc6Path)
|
||||
return decodeDc6(bytes)
|
||||
})()
|
||||
|
||||
this.overlaySheetCache.set(key, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and cache a summoned pet, trap, or shapeshift monster sprite (`data\global\monsters\<code>\`).
|
||||
*/
|
||||
loadMonsterOrPetSheet(
|
||||
monCode: string,
|
||||
mode = 'nu',
|
||||
weaponClass = 'hth',
|
||||
): Promise<CharacterSheet> {
|
||||
const code = monCode.trim().toLowerCase()
|
||||
const anim = mode.trim().toLowerCase()
|
||||
const wclass = weaponClass.trim().toLowerCase()
|
||||
const key = `${code}:${anim}:${wclass}`
|
||||
|
||||
const existing = this.monsterSheetCache.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
const promise = loadCharacterSheet(
|
||||
this.archives,
|
||||
code,
|
||||
anim,
|
||||
wclass,
|
||||
`data\\global\\monsters\\${code}\\`,
|
||||
)
|
||||
this.monsterSheetCache.set(key, promise)
|
||||
return promise
|
||||
}
|
||||
}
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
import { getSharedDataRegistry } from '../../common/data/data-registry.ts'
|
||||
import { MpqArchive } from '../../common/mpq/archive.ts'
|
||||
import { MountedArchives } from '../../common/mpq/mount.ts'
|
||||
import { memorySource } from '../../common/mpq/source.ts'
|
||||
|
||||
export type WorldArenaType = any
|
||||
export type WorldArenaFactory = (opts: Record<string, unknown>) => WorldArenaType
|
||||
|
||||
let registeredWorldArenaFactory: WorldArenaFactory | null = null
|
||||
|
||||
export function registerWorldArenaFactory(factory: WorldArenaFactory | null): void {
|
||||
registeredWorldArenaFactory = factory
|
||||
}
|
||||
|
||||
export async function initBrowserArenaMode(factory?: WorldArenaFactory): Promise<void> {
|
||||
const createArena = factory ?? registeredWorldArenaFactory
|
||||
if (!createArena) {
|
||||
throw new Error(
|
||||
'[Arena] No WorldArenaFactory registered; registerWorldArenaFactory must be called before initializing arena mode',
|
||||
)
|
||||
}
|
||||
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 [patchRes, animRes, hitClassRes] = await Promise.all([
|
||||
fetch('/samples/d2/Patch_D2.mpq'),
|
||||
fetch('/samples/d2-packs/anim/AnimData.d2'),
|
||||
fetch('/samples/d2-packs/anim/HitClass.txt'),
|
||||
])
|
||||
if (!patchRes.ok || !animRes.ok || !hitClassRes.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch 1.13c registry assets (Patch_D2=${String(patchRes.status)}, AnimData=${String(animRes.status)}, HitClass=${String(hitClassRes.status)})`,
|
||||
)
|
||||
}
|
||||
const patchBytes = new Uint8Array(await patchRes.arrayBuffer())
|
||||
const animBytes = new Uint8Array(await animRes.arrayBuffer())
|
||||
const hitClassBytes = new Uint8Array(await hitClassRes.arrayBuffer())
|
||||
const patchMpq = MpqArchive.openSync(memorySource(patchBytes, 'Patch_D2.mpq'))
|
||||
const mounted = new MountedArchives()
|
||||
mounted.add('Patch_D2.mpq', patchMpq)
|
||||
const browserSource = {
|
||||
readBytes(path: string): Uint8Array | null {
|
||||
const lower = path.trim().replace(/\//g, '\\').toLowerCase()
|
||||
if (lower.endsWith('animdata.d2')) return animBytes
|
||||
if (lower.endsWith('hitclass.txt')) return hitClassBytes
|
||||
return mounted.readFile(path)
|
||||
},
|
||||
}
|
||||
|
||||
const registry = getSharedDataRegistry(browserSource)
|
||||
let arena: WorldArenaType = createArena({
|
||||
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 = createArena({
|
||||
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: { monId: string; hp: number }, idx: number) => {
|
||||
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?: (factory?: WorldArenaFactory) => Promise<void> }).__initBrowserArenaMode =
|
||||
initBrowserArenaMode
|
||||
if (typeof location !== 'undefined' && new URLSearchParams(location.search).get('arena') === '1') {
|
||||
initBrowserArenaMode().catch((err) => {
|
||||
;(window as unknown as { __d2webArenaError?: string }).__d2webArenaError = String(
|
||||
(err as Error)?.stack || err,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +1,8 @@
|
|||
import type { SceneEngineLike as GameEngine } from '../../common/units/unit-types.ts'
|
||||
import {
|
||||
calculateBounceHeight,
|
||||
type GroundItemEntity,
|
||||
} from '../../common/items/ground-items.ts'
|
||||
import type { AtlasFrame } from '../render/atlas.ts'
|
||||
import type {
|
||||
AtlasHandle,
|
||||
SpriteRenderer,
|
||||
} from '../render/renderer.ts'
|
||||
import { BAKED_UI_MANIFEST } from '../../common/items/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
|
||||
|
||||
|
|
@ -72,167 +47,3 @@ export async function loadItemsAtlas(
|
|||
console.error(msg)
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the authentic Blizzard flippy ground sprite frame key for a ground item entity or item.
|
||||
*/
|
||||
export function resolveGroundItemFlippyFrame(item: GroundItemEntity | Record<string, unknown> | unknown): string {
|
||||
const raw = (item as any)?.item ?? item
|
||||
const code = typeof raw?.code === 'string' ? raw.code.trim().toLowerCase() : ''
|
||||
const isGold = Boolean((item as any)?.isGold || raw?.isGold || code === 'gld')
|
||||
if (isGold) {
|
||||
const amount = Number((item as any)?.amount ?? raw?.amount ?? raw?.value ?? 1)
|
||||
if (amount >= 5000) return 'flpgld_3'
|
||||
if (amount >= 500) return 'flpgld_2'
|
||||
if (amount >= 100) return 'flpgld_1'
|
||||
return 'flpgld_0'
|
||||
}
|
||||
const directFlp = (item as any)?.flippyFile ?? raw?.flippyFile
|
||||
if (typeof directFlp === 'string' && directFlp.length > 0) {
|
||||
return directFlp.toLowerCase()
|
||||
}
|
||||
const name = typeof raw?.name === 'string' ? raw.name : typeof (item as any)?.name === 'string' ? (item as any).name : ''
|
||||
if (name && BAKED_UI_MANIFEST.codeToFlippyFile[name]) {
|
||||
return BAKED_UI_MANIFEST.codeToFlippyFile[name]!
|
||||
}
|
||||
if (code && BAKED_UI_MANIFEST.codeToFlippyFile[code]) {
|
||||
return BAKED_UI_MANIFEST.codeToFlippyFile[code]!
|
||||
}
|
||||
return code ? `flp${code}` : 'flpcap'
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 | { readonly handle?: AtlasHandle | number; readonly frames?: unknown } | null | 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,
|
||||
}
|
||||
|
||||
const effectiveAtlas: AtlasHandle | undefined =
|
||||
itemsAtlas && typeof itemsAtlas === 'object' && 'texture' in itemsAtlas
|
||||
? (itemsAtlas as AtlasHandle)
|
||||
: itemsAtlas && typeof itemsAtlas === 'object' && 'handle' in itemsAtlas && itemsAtlas.handle && typeof itemsAtlas.handle === 'object' && 'texture' in itemsAtlas.handle
|
||||
? (itemsAtlas.handle as AtlasHandle)
|
||||
: undefined
|
||||
|
||||
if (typeof (renderer as any).draw === 'function') {
|
||||
renderer.draw(frame, drawX, drawY, {
|
||||
...(effectiveAtlas !== undefined ? { atlas: effectiveAtlas } : {}),
|
||||
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: effectiveAtlas,
|
||||
})
|
||||
}
|
||||
|
||||
// 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,7 @@
|
|||
import {
|
||||
ORTHO_SUB_TILE_HEIGHT,
|
||||
ORTHO_SUB_TILE_WIDTH,
|
||||
type CollisionGrid,
|
||||
} from '../../common/world/d2map.ts'
|
||||
import type { SceneEngineLike as GameEngine } from '../../common/units/unit-types.ts'
|
||||
import type { CollisionGrid } from '../../common/world/d2map.ts'
|
||||
import type { Environment } from '../../common/world/environment.ts'
|
||||
import type { LightGrid } from '../../common/drlg/light-grid.ts'
|
||||
import { ADDITIVE_OBJECT_TOKENS } from '../../common/units/objects.ts'
|
||||
import type { TownPortalSlot } from '../../common/world/portal.ts'
|
||||
import { getMissileTxtData } from '../../common/skills/missiles-data.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 ClientLightingPreset =
|
||||
| 'auto'
|
||||
|
|
@ -201,7 +190,7 @@ export interface PointLightInput {
|
|||
}
|
||||
|
||||
/**
|
||||
* Engine-decoupled lighting inputs for both single-player and online D2GS scenes.
|
||||
* Engine-decoupled lighting inputs for online D2GS scenes.
|
||||
*/
|
||||
export interface LightingInputs {
|
||||
readonly preset: LightingPreset
|
||||
|
|
@ -230,26 +219,18 @@ export interface LightingPassTargets {
|
|||
): void
|
||||
}
|
||||
readonly environment?: Pick<Environment, 'currentIntensity' | 'currentRed' | 'currentGreen' | 'currentBlue'> | undefined
|
||||
readonly state?: Pick<ActSceneState, 'lightingPreset' | 'lightingMode' | 'ambientIntensity' | 'ambientColor'> | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute and upload the 1.13c sub-tile lightmap pass from pure `LightingInputs`
|
||||
* without requiring a `GameEngine` instance.
|
||||
* Compute and upload the 1.13c sub-tile lightmap pass from pure `LightingInputs`.
|
||||
*/
|
||||
export function applyLightingInputs(inputs: LightingInputs, targets: LightingPassTargets): void {
|
||||
const presetCfg = LIGHTING_PRESETS[inputs.preset] ?? LIGHTING_PRESETS.auto
|
||||
const lightingEnabled = presetCfg.enabled && inputs.preset !== 'fullbright' && inputs.preset !== 'off'
|
||||
const { lightGrid, renderer, environment, state } = targets
|
||||
const { lightGrid, renderer, environment } = targets
|
||||
|
||||
if (!lightingEnabled) {
|
||||
renderer.setLighting(null)
|
||||
if (state) {
|
||||
state.lightingPreset = inputs.preset
|
||||
state.lightingMode = getTblLang() === 'ENG' ? presetCfg.labelEn : presetCfg.labelZh
|
||||
state.ambientIntensity = 255
|
||||
state.ambientColor = [255, 255, 255]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -309,213 +290,4 @@ export function applyLightingInputs(inputs: LightingInputs, targets: LightingPas
|
|||
sceneOriginY: inputs.collisionGrid.originY,
|
||||
lightmap: lightGrid.exportTextureBuffer(),
|
||||
})
|
||||
|
||||
if (state) {
|
||||
state.lightingPreset = inputs.preset
|
||||
state.lightingMode = getTblLang() === 'ENG' ? presetCfg.labelEn : presetCfg.labelZh
|
||||
state.ambientIntensity = effectiveAmbIntensity
|
||||
state.ambientColor = [effR, effG, effB]
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,136 +0,0 @@
|
|||
import type { AnimatableTile, AnimatedFrame } from '../../common/world/animated-tiles.ts'
|
||||
import { ADDITIVE_OBJECT_TOKENS } from '../../common/units/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
|
||||
}
|
||||
|
|
@ -1,462 +0,0 @@
|
|||
import { type AudioManager, sharedAudioManager } from '../audio/manager.ts'
|
||||
import type { Monster, SceneEngineLike as GameEngine } from '../../common/units/unit-types.ts'
|
||||
import type { GroundItemEntity } from '../../common/items/ground-items.ts'
|
||||
import { getLevelNames, levelDisplayName } from '../../common/world/level-names-zh.ts'
|
||||
import { getTownNpcDescriptorByName, npcDisplayName } from '../../common/units/npc-table.ts'
|
||||
import type { TownPortalSlot } from '../../common/world/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 '../../common/world/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 synthesized Diablo II-style audio rejection feedback when inventory is full.
|
||||
* Low descending refusal tone paired with delayed secondary touch landing thud.
|
||||
*/
|
||||
export function playInventoryFullFeedback(audioManager: AudioManager = sharedAudioManager): void {
|
||||
if (typeof audioManager?.playSfx === 'function') {
|
||||
audioManager.playSfx('inventory_full')
|
||||
return
|
||||
}
|
||||
audioManager?.playTone?.({
|
||||
type: 'sawtooth',
|
||||
from: 175,
|
||||
to: 90,
|
||||
gain: 0.3,
|
||||
durationS: 0.3,
|
||||
delayS: 0,
|
||||
})
|
||||
audioManager?.playTone?.({
|
||||
type: 'triangle',
|
||||
from: 110,
|
||||
to: 45,
|
||||
gain: 0.2,
|
||||
durationS: 0.1,
|
||||
delayS: 0.35,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
|
@ -1,406 +0,0 @@
|
|||
import { DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS } from '../../common/world/animated-tiles.ts'
|
||||
import type { SorceressAppearanceSpec } from '../../common/units/char-appearance.ts'
|
||||
import type { Monster, MonsterStats, NpcEntity } from '../../common/units/unit-types.ts'
|
||||
import { getEmbeddedDropTables } from '../../common/items/embedded-drop-tables.ts'
|
||||
import { enrichMonsterStats } from '../../common/units/monsters-data.ts'
|
||||
import type { OpenPortal } from '../../common/world/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 '../../common/world/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
|
||||
;(window as unknown as { __d2Scene?: ActSceneState }).__d2Scene = 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
|
||||
}
|
||||
|
|
@ -1,948 +0,0 @@
|
|||
import type { Palette } from '../../common/formats/pal.ts'
|
||||
import { WORLD_VARIANT_SEED } from '../../common/world/act-variants.ts'
|
||||
import type { ActorAnimator } from '../render/actor-animator.ts'
|
||||
import {
|
||||
SorceressLayerCompositor,
|
||||
appearanceKey,
|
||||
resolveSorceressAppearance,
|
||||
} from '../../common/units/char-appearance.ts'
|
||||
import type { CharacterClassCode } from '../../common/units/classes.ts'
|
||||
import { spawnMonsterPacks } from '../../common/units/monster-rooms.ts'
|
||||
import type { MonsterPack, SafeZone, SceneEngineLike as GameEngine } from '../../common/units/unit-types.ts'
|
||||
import { cellCentre } from '../../common/world/d2map.ts'
|
||||
import type { DropTables } from '../../common/items/drop-tables.ts'
|
||||
import {
|
||||
ACT_NAMES_EN,
|
||||
ACT_NAMES_ZH,
|
||||
getLevelNames,
|
||||
sceneDisplayName,
|
||||
variantDisplayLabel,
|
||||
} from '../../common/world/level-names-zh.ts'
|
||||
import type { LoadedMonsterArt } from '../render/monster-art.ts'
|
||||
import { MonsterStreamingManager } from '../../common/units/monster-streaming.ts'
|
||||
import {
|
||||
buildLangSwitchHref,
|
||||
getTblLang,
|
||||
type TblLang,
|
||||
} from '../i18n/lang.ts'
|
||||
import type { AtlasFrame } from '../render/atlas.ts'
|
||||
import { LoadGeneration, type AtlasHandle, 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 '../../common/world/chat-commands.ts'
|
||||
import { subTileToScene } from '../../common/world/scene-links-nav.ts'
|
||||
import {
|
||||
HERO_CLASSES,
|
||||
ROOM_ACTIVATION_RADIUS,
|
||||
ROOM_DEACTIVATION_RADIUS,
|
||||
buildStreamingRooms,
|
||||
isTownLevel,
|
||||
normalizeHeroToken,
|
||||
parseDensityMultiplier,
|
||||
parseEliteMultiplier,
|
||||
rebudgetMonsterPacks,
|
||||
type DensityMultiplier,
|
||||
type EliteMultiplier,
|
||||
} from '../../common/world/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 getMonsterArtMap?: (() => Map<string, LoadedMonsterArt>) | undefined
|
||||
readonly setMonsterArtMap: (map: Map<string, LoadedMonsterArt>) => void
|
||||
}
|
||||
|
||||
export interface HeroLightingAndMonsterControls {
|
||||
readonly heroGen: LoadGeneration
|
||||
readonly monsterGen: LoadGeneration
|
||||
readonly lightingGen: LoadGeneration
|
||||
readonly invalidateMonsterLoads: () => void
|
||||
readonly syncSorceressAppearance: () => Promise<boolean>
|
||||
readonly switchLighting: (preset: string) => LightingPreset
|
||||
readonly switchHero: (newHeroToken: string) => Promise<boolean>
|
||||
readonly changeHeroClass: (newHeroToken: string) => Promise<boolean>
|
||||
readonly reloadMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise<void>
|
||||
readonly respawnMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise<void>
|
||||
}
|
||||
|
||||
export function createHeroLightingAndMonsterControls(
|
||||
ctx: HeroLightingAndMonsterControlsContext,
|
||||
): HeroLightingAndMonsterControls {
|
||||
const soCompositor = new SorceressLayerCompositor(ctx.getPackEntityBase() ?? 'samples/d2-packs')
|
||||
const heroGen = new LoadGeneration()
|
||||
const monsterGen = new LoadGeneration()
|
||||
const lightingGen = new LoadGeneration()
|
||||
let activeAppearanceKey = ''
|
||||
let appearanceSeq = 0
|
||||
|
||||
const invalidateMonsterLoads = (): void => {
|
||||
monsterGen.invalidate()
|
||||
}
|
||||
|
||||
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 && prevChar.handle !== newHandle) 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 { gen } = lightingGen.next()
|
||||
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 (lightingGen.isCurrent(gen) && ctx.status.textContent.startsWith(msgPrefix)) {
|
||||
ctx.status.textContent = ''
|
||||
}
|
||||
}, 2500)
|
||||
return nextPreset
|
||||
}
|
||||
|
||||
const switchHero = async (newHeroToken: string): Promise<boolean> => {
|
||||
const { gen } = heroGen.next()
|
||||
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 (!heroGen.isCurrent(gen)) {
|
||||
if (nextChar !== null && nextChar.handle && nextChar.handle !== ctx.getCharacter()?.handle) {
|
||||
ctx.renderer.deleteAtlas(nextChar.handle)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (nextChar !== null) {
|
||||
const prevChar = ctx.getCharacter()
|
||||
if (prevChar?.handle && prevChar.handle !== nextChar.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()
|
||||
if (!heroGen.isCurrent(gen)) return false
|
||||
} 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 (heroGen.isCurrent(gen) && 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> => {
|
||||
const { gen } = monsterGen.next()
|
||||
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
|
||||
for (const oldArt of ctx.getMonsterArtMap?.().values() ?? []) {
|
||||
ctx.renderer.deleteAtlas(oldArt.handle)
|
||||
}
|
||||
ctx.setMonsterArtMap(new Map())
|
||||
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(),
|
||||
)
|
||||
if (!monsterGen.isCurrent(gen) || runtime.disposed || ctx.getRuntime() !== runtime) {
|
||||
for (const art of loaded.map.values()) {
|
||||
ctx.renderer.deleteAtlas(art.handle)
|
||||
}
|
||||
return
|
||||
}
|
||||
const keepHandles = new Set<AtlasHandle>()
|
||||
for (const art of loaded.map.values()) {
|
||||
keepHandles.add(art.handle)
|
||||
}
|
||||
for (const oldArt of ctx.getMonsterArtMap?.().values() ?? []) {
|
||||
if (!keepHandles.has(oldArt.handle)) {
|
||||
ctx.renderer.deleteAtlas(oldArt.handle)
|
||||
}
|
||||
}
|
||||
ctx.setMonsterArtMap(loaded.map)
|
||||
recordMonsterArtLoad(loaded)
|
||||
} else {
|
||||
for (const oldArt of ctx.getMonsterArtMap?.().values() ?? []) {
|
||||
ctx.renderer.deleteAtlas(oldArt.handle)
|
||||
}
|
||||
ctx.setMonsterArtMap(new Map())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
heroGen,
|
||||
monsterGen,
|
||||
lightingGen,
|
||||
invalidateMonsterLoads,
|
||||
syncSorceressAppearance,
|
||||
switchLighting,
|
||||
switchHero,
|
||||
changeHeroClass: switchHero,
|
||||
reloadMonsters,
|
||||
respawnMonsters: reloadMonsters,
|
||||
}
|
||||
}
|
||||
|
||||
export async function changeHeroClass(
|
||||
controlsOrCtx: HeroLightingAndMonsterControls | HeroLightingAndMonsterControlsContext,
|
||||
newHeroToken: string,
|
||||
): Promise<boolean> {
|
||||
if ('switchHero' in controlsOrCtx) {
|
||||
return await controlsOrCtx.switchHero(newHeroToken)
|
||||
}
|
||||
const controls = createHeroLightingAndMonsterControls(controlsOrCtx)
|
||||
return await controls.switchHero(newHeroToken)
|
||||
}
|
||||
|
||||
export async function respawnMonsters(
|
||||
controlsOrCtx: HeroLightingAndMonsterControls | HeroLightingAndMonsterControlsContext,
|
||||
options?: { density?: number | string; elite?: number | string },
|
||||
): Promise<void> {
|
||||
if ('reloadMonsters' in controlsOrCtx) {
|
||||
await controlsOrCtx.reloadMonsters(options)
|
||||
return
|
||||
}
|
||||
const controls = createHeroLightingAndMonsterControls(controlsOrCtx)
|
||||
await controls.reloadMonsters(options)
|
||||
}
|
||||
|
||||
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 matchingGround = engine.ground.find((g: { item?: unknown }) => (g.item as unknown) === item)
|
||||
if (matchingGround) {
|
||||
;(matchingGround as { groundItemId?: string }).groundItemId = groundItem.id
|
||||
}
|
||||
const isEn = getTblLang() === 'ENG'
|
||||
status.textContent = isEn
|
||||
? `Dropped "${groundItem.name}" on ground`
|
||||
: `已将「${cleanChiItemName(groundItem.nameZh)}」丢弃在地面`
|
||||
},
|
||||
onDropGoldToGround: (amount) => {
|
||||
const player = engine.world.player
|
||||
let groundGold: unknown = true
|
||||
if (!hudManager?.engine) {
|
||||
if (hudManager && engine.bag) {
|
||||
engine.bag.gold = hudManager.inventory.gold
|
||||
}
|
||||
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) }
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
/**
|
||||
* Level transitions: the fade, and the arithmetic of arriving.
|
||||
*
|
||||
* Two kinds of crossing, one pipeline. Walking through a gap in the border
|
||||
* between the Cold Plains and the Stony Field is, in the original game, not a
|
||||
* transition at all — the two levels share one coordinate space and the engine
|
||||
* streams the neighbour in as you approach. This engine does not have that
|
||||
* shared space yet, so both kinds load a fresh level; the seam simply gets a
|
||||
* much shorter fade, short enough to read as continuous motion rather than as
|
||||
* a loading screen.
|
||||
*
|
||||
* The fade is presentation, not simulation. It runs on wall-clock time in a DOM
|
||||
* overlay and never touches the fixed-rate tick, because the tick has to stay a
|
||||
* pure function of the inputs for lockstep networking to work.
|
||||
*/
|
||||
|
||||
import {
|
||||
SEAM_FADE_MS,
|
||||
WARP_FADE_MS,
|
||||
subTileToScene,
|
||||
type ScenePoint,
|
||||
} from '../../common/world/d2map.ts'
|
||||
|
||||
export { SEAM_FADE_MS, WARP_FADE_MS, subTileToScene, type ScenePoint }
|
||||
|
||||
/**
|
||||
* The fade overlay.
|
||||
*
|
||||
* A plain absolutely-positioned div rather than a renderer pass: it has to
|
||||
* cover the HUD and the NPC name labels, which are DOM, and a WebGL quad would
|
||||
* sit underneath them.
|
||||
*/
|
||||
export class FadeOverlay {
|
||||
private readonly element: HTMLElement
|
||||
|
||||
/**
|
||||
* @param host - the element to cover; the overlay is appended to it.
|
||||
*/
|
||||
constructor(host: HTMLElement) {
|
||||
const element = document.createElement('div')
|
||||
element.style.position = 'fixed'
|
||||
element.style.inset = '0'
|
||||
element.style.background = '#000'
|
||||
element.style.opacity = '0'
|
||||
element.style.pointerEvents = 'none'
|
||||
element.style.zIndex = '50'
|
||||
element.style.transition = 'opacity 0ms linear'
|
||||
host.appendChild(element)
|
||||
this.element = element
|
||||
}
|
||||
|
||||
/**
|
||||
* Fade to black.
|
||||
*
|
||||
* @param durationMs - how long to take.
|
||||
*/
|
||||
async out(durationMs: number): Promise<void> {
|
||||
await this.ramp('1', durationMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fade back in.
|
||||
*
|
||||
* @param durationMs - how long to take.
|
||||
*/
|
||||
async in(durationMs: number): Promise<void> {
|
||||
await this.ramp('0', durationMs)
|
||||
}
|
||||
|
||||
/** Remove the overlay from the page. */
|
||||
dispose(): void {
|
||||
this.element.remove()
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one leg of the fade and resolve when it is over.
|
||||
*
|
||||
* @param opacity - the target opacity.
|
||||
* @param durationMs - how long to take.
|
||||
*/
|
||||
private async ramp(opacity: string, durationMs: number): Promise<void> {
|
||||
this.element.style.transition = `opacity ${String(durationMs)}ms linear`
|
||||
// Read back a layout property so the browser commits the starting opacity
|
||||
// before the new one is set; without this the two assignments coalesce and
|
||||
// the transition never runs.
|
||||
void this.element.offsetHeight
|
||||
this.element.style.opacity = opacity
|
||||
await new Promise<void>(resolve => { setTimeout(resolve, durationMs) })
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,17 +0,0 @@
|
|||
export {
|
||||
OutboundRateLimiter,
|
||||
DEFAULT_RATE_LIMITS,
|
||||
classifyClientCommand,
|
||||
type CommandRateCategory,
|
||||
type RateLimitAcquireResult,
|
||||
type RateLimiterConfig,
|
||||
} from './rate-limiter.ts'
|
||||
|
||||
export {
|
||||
OnlineSession,
|
||||
decodeCapturedGameS2cPacket,
|
||||
type OnlineSessionLobbySnapshot,
|
||||
type OnlineSessionLoginParams,
|
||||
type OnlineSessionOptions,
|
||||
type OnlineSessionPhase,
|
||||
} from './online-session.ts'
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
export class ViewportCamera {
|
||||
zoom: number
|
||||
manualZoom = false
|
||||
|
||||
private readonly onWheel: (event: WheelEvent) => void
|
||||
private readonly onKeyZoom: (event: KeyboardEvent) => void
|
||||
private readonly onResize: () => void
|
||||
|
||||
constructor(
|
||||
private canvas: HTMLCanvasElement,
|
||||
public mapWidthPx: number,
|
||||
public mapHeightPx: number
|
||||
) {
|
||||
this.zoom = ViewportCamera.coverZoom(mapWidthPx, mapHeightPx, ViewportCamera.syncCanvasSize(canvas))
|
||||
|
||||
this.onWheel = (event: WheelEvent) => {
|
||||
event.preventDefault()
|
||||
this.setZoom(this.zoom * (event.deltaY < 0 ? 1.12 : 1 / 1.12))
|
||||
}
|
||||
|
||||
this.onKeyZoom = (event: KeyboardEvent) => {
|
||||
if (event.code === 'Equal' || event.code === 'NumpadAdd') this.setZoom(this.zoom * 1.15)
|
||||
if (event.code === 'Minus' || event.code === 'NumpadSubtract') this.setZoom(this.zoom / 1.15)
|
||||
if (event.code === 'Digit0') {
|
||||
this.manualZoom = false
|
||||
this.zoom = ViewportCamera.coverZoom(this.mapWidthPx, this.mapHeightPx, ViewportCamera.syncCanvasSize(this.canvas))
|
||||
}
|
||||
}
|
||||
|
||||
this.onResize = () => {
|
||||
const size = ViewportCamera.syncCanvasSize(this.canvas)
|
||||
if (!this.manualZoom) {
|
||||
this.zoom = ViewportCamera.coverZoom(this.mapWidthPx, this.mapHeightPx, size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setZoom(value: number): void {
|
||||
this.manualZoom = true
|
||||
this.zoom = Math.min(8, Math.max(0.4, value))
|
||||
}
|
||||
|
||||
attach(): void {
|
||||
this.canvas.addEventListener('wheel', this.onWheel, { passive: false })
|
||||
window.addEventListener('keydown', this.onKeyZoom)
|
||||
window.addEventListener('resize', this.onResize)
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
this.canvas.removeEventListener('wheel', this.onWheel)
|
||||
window.removeEventListener('keydown', this.onKeyZoom)
|
||||
window.removeEventListener('resize', this.onResize)
|
||||
}
|
||||
|
||||
static coverZoom(mapWidth: number, mapHeight: number, viewport: { width: number; height: number }): number {
|
||||
if (mapWidth <= 0 || mapHeight <= 0) return 1.6
|
||||
const cover = Math.max(viewport.width / mapWidth, viewport.height / mapHeight)
|
||||
return Math.min(6, Math.max(0.9, cover))
|
||||
}
|
||||
|
||||
static syncCanvasSize(canvas: HTMLCanvasElement): { width: number; height: number } {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const cssWidth = Math.max(320, canvas.clientWidth || window.innerWidth)
|
||||
const cssHeight = Math.max(240, canvas.clientHeight || window.innerHeight)
|
||||
const maxPixels = 1920 * 1200
|
||||
const scale = Math.min(dpr, Math.sqrt(maxPixels / (cssWidth * cssHeight)))
|
||||
const width = Math.max(1, Math.round(cssWidth * scale))
|
||||
const height = Math.max(1, Math.round(cssHeight * scale))
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
}
|
||||
return { width, height }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,265 +0,0 @@
|
|||
/**
|
||||
* Keyboard input, resolved into the game's own vocabulary.
|
||||
*
|
||||
* Two things are game facts rather than conveniences:
|
||||
*
|
||||
* - Diablo's eight facings are ordered starting at **south and turning west**
|
||||
* (`Direction::South, SouthWest, West, NorthWest, North, ...`), and that
|
||||
* order is exactly the group order inside a direction-animated sprite file.
|
||||
* So facing is computed from the movement vector, not from which key was
|
||||
* pressed.
|
||||
* - Diagonal movement is normalised: the original moves diagonally at the same
|
||||
* speed as along an axis, not at √2 times it.
|
||||
*/
|
||||
|
||||
/** Diablo's facing order: the index of a direction inside a sprite file. */
|
||||
export const DIRECTION = {
|
||||
South: 0,
|
||||
SouthWest: 1,
|
||||
West: 2,
|
||||
NorthWest: 3,
|
||||
North: 4,
|
||||
NorthEast: 5,
|
||||
East: 6,
|
||||
SouthEast: 7,
|
||||
} as const
|
||||
|
||||
/** A direction index (0..7) or -1 for "no direction". */
|
||||
export type Direction = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
|
||||
/** A normalized movement request in screen coordinates (y grows downward). */
|
||||
export interface Movement {
|
||||
/** Horizontal component in -1..1. */
|
||||
readonly x: number
|
||||
/** Vertical component in -1..1. */
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
/** Keys that mean "attack". */
|
||||
const ATTACK_KEYS = new Set(['KeyJ', 'Enter'])
|
||||
/** Keys that mean "pick up". */
|
||||
const PICKUP_KEYS = new Set(['KeyE', 'KeyF'])
|
||||
/** Keys that mean "talk to whoever is in reach". */
|
||||
const TALK_KEYS = new Set<string>()
|
||||
/** Keys that save and load the game. */
|
||||
const SAVE_KEYS = new Set(['KeyK'])
|
||||
const LOAD_KEYS = new Set(['KeyL'])
|
||||
/** Keys that toggle the automap. `Tab` is where Diablo II puts it. */
|
||||
const MAP_KEYS = new Set(['Tab', 'KeyM'])
|
||||
/** Keys that take the stairs, cave mouth or portal the player is standing on. */
|
||||
const USE_KEYS = new Set<string>()
|
||||
/** Keys that cast a town portal. */
|
||||
const PORTAL_KEYS = new Set(['KeyP'])
|
||||
/** Keys that open the waypoint destination list. */
|
||||
const WAYPOINT_KEYS = new Set<string>()
|
||||
/** Shift keys for force stand-still attack. */
|
||||
const SHIFT_KEYS = new Set(['ShiftLeft', 'ShiftRight'])
|
||||
|
||||
/** Recognised keys, mapped to a movement contribution. */
|
||||
const KEYS: Readonly<Record<string, readonly [number, number]>> = {
|
||||
ArrowUp: [0, -1],
|
||||
ArrowDown: [0, 1],
|
||||
ArrowLeft: [-1, 0],
|
||||
ArrowRight: [1, 0],
|
||||
}
|
||||
|
||||
/**
|
||||
* Facing for a movement vector.
|
||||
*
|
||||
* @param movement - normalized movement.
|
||||
* @returns the direction index, or null when there is no movement.
|
||||
*/
|
||||
export function directionOf(movement: Movement): Direction | null {
|
||||
const sx = Math.sign(movement.x)
|
||||
const sy = Math.sign(movement.y)
|
||||
if (sx === 0 && sy === 0) return null
|
||||
if (sx === 0) return sy > 0 ? DIRECTION.South : DIRECTION.North
|
||||
if (sy === 0) return sx > 0 ? DIRECTION.East : DIRECTION.West
|
||||
if (sx < 0) return sy > 0 ? DIRECTION.SouthWest : DIRECTION.NorthWest
|
||||
return sy > 0 ? DIRECTION.SouthEast : DIRECTION.NorthEast
|
||||
}
|
||||
|
||||
/**
|
||||
* Key state for one window, sampled once per tick.
|
||||
*/
|
||||
export class KeyboardInput {
|
||||
private readonly held = new Set<string>()
|
||||
/**
|
||||
* Keys pressed since the last time anyone looked.
|
||||
*
|
||||
* Movement and attack are held states: the question is "is the key down this
|
||||
* tick". Taking a staircase is not — holding `G` on a cave mouth must not
|
||||
* re-trigger the transition sixty times a second — so those keys are edge
|
||||
* triggered, recorded here on the way down and drained by the reader.
|
||||
*/
|
||||
private readonly pressed = new Set<string>()
|
||||
|
||||
private readonly onKeyDown = (event: KeyboardEvent): void => {
|
||||
const oneShot = MAP_KEYS.has(event.code)
|
||||
|| USE_KEYS.has(event.code)
|
||||
|| PORTAL_KEYS.has(event.code)
|
||||
|| WAYPOINT_KEYS.has(event.code)
|
||||
const recognised = KEYS[event.code] !== undefined
|
||||
|| ATTACK_KEYS.has(event.code)
|
||||
|| PICKUP_KEYS.has(event.code)
|
||||
|| TALK_KEYS.has(event.code)
|
||||
|| SAVE_KEYS.has(event.code)
|
||||
|| LOAD_KEYS.has(event.code)
|
||||
|| SHIFT_KEYS.has(event.code)
|
||||
|| oneShot
|
||||
if (!recognised) return
|
||||
// Arrows scroll the page otherwise, and held keys would repeat.
|
||||
event.preventDefault()
|
||||
// Auto-repeat re-fires keydown without an intervening keyup; ignoring it
|
||||
// is what makes these presses rather than a stream.
|
||||
if (oneShot && !this.held.has(event.code)) this.pressed.add(event.code)
|
||||
this.held.add(event.code)
|
||||
}
|
||||
|
||||
private readonly onKeyUp = (event: KeyboardEvent): void => {
|
||||
this.held.delete(event.code)
|
||||
}
|
||||
|
||||
private readonly onBlur = (): void => {
|
||||
this.held.clear()
|
||||
this.pressed.clear()
|
||||
}
|
||||
|
||||
private readonly target: Window
|
||||
|
||||
/**
|
||||
* @param target - the window to listen on.
|
||||
*/
|
||||
constructor(target: Window = window) {
|
||||
this.target = target
|
||||
}
|
||||
|
||||
/** Start listening. */
|
||||
attach(): void {
|
||||
this.target.addEventListener('keydown', this.onKeyDown)
|
||||
this.target.addEventListener('keyup', this.onKeyUp)
|
||||
this.target.addEventListener('blur', this.onBlur)
|
||||
}
|
||||
|
||||
/** Stop listening and release every key. */
|
||||
detach(): void {
|
||||
this.target.removeEventListener('keydown', this.onKeyDown)
|
||||
this.target.removeEventListener('keyup', this.onKeyUp)
|
||||
this.target.removeEventListener('blur', this.onBlur)
|
||||
this.held.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* The movement requested this tick, normalised so diagonals are not faster.
|
||||
*
|
||||
* @returns the movement vector.
|
||||
*/
|
||||
movement(): Movement {
|
||||
let x = 0
|
||||
let y = 0
|
||||
for (const code of this.held) {
|
||||
const contribution = KEYS[code]
|
||||
if (contribution === undefined) continue
|
||||
x += contribution[0]
|
||||
y += contribution[1]
|
||||
}
|
||||
const magnitude = Math.hypot(x, y)
|
||||
if (magnitude === 0) return { x: 0, y: 0 }
|
||||
return { x: x / magnitude, y: y / magnitude }
|
||||
}
|
||||
|
||||
/** Whether any movement key is held. */
|
||||
get active(): boolean {
|
||||
for (const code of this.held) if (KEYS[code] !== undefined) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Whether an attack key is held this tick. */
|
||||
get attacking(): boolean {
|
||||
for (const code of this.held) if (ATTACK_KEYS.has(code)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Whether a pickup key is held this tick. */
|
||||
get pickingUp(): boolean {
|
||||
for (const code of this.held) if (PICKUP_KEYS.has(code)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Whether a talk key is held this tick. */
|
||||
get talking(): boolean {
|
||||
for (const code of this.held) if (TALK_KEYS.has(code)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Whether a save key is held this tick. */
|
||||
get saving(): boolean {
|
||||
for (const code of this.held) if (SAVE_KEYS.has(code)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Whether a load key is held this tick. */
|
||||
get loading(): boolean {
|
||||
for (const code of this.held) if (LOAD_KEYS.has(code)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Skill slots requested by the number keys currently held. */
|
||||
get digits(): readonly number[] {
|
||||
return []
|
||||
}
|
||||
|
||||
/** Whether Shift (left or right) is held down. */
|
||||
get shiftHeld(): boolean {
|
||||
return this.held.has('ShiftLeft') || this.held.has('ShiftRight')
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the automap key was pressed since this was last called.
|
||||
*
|
||||
* @returns true once per press.
|
||||
*/
|
||||
takeMapToggle(): boolean {
|
||||
return this.takeOnce(MAP_KEYS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the use key was pressed since this was last called.
|
||||
*
|
||||
* @returns true once per press.
|
||||
*/
|
||||
takeUse(): boolean {
|
||||
return this.takeOnce(USE_KEYS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the town portal key was pressed since this was last called.
|
||||
*
|
||||
* @returns true once per press.
|
||||
*/
|
||||
takePortal(): boolean {
|
||||
return this.takeOnce(PORTAL_KEYS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the waypoint key was pressed since this was last called.
|
||||
*
|
||||
* @returns true once per press.
|
||||
*/
|
||||
takeWaypoint(): boolean {
|
||||
return this.takeOnce(WAYPOINT_KEYS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume a pending press.
|
||||
*
|
||||
* @param keys - the codes that count.
|
||||
* @returns true when one of them was pressed, clearing it.
|
||||
*/
|
||||
private takeOnce(keys: ReadonlySet<string>): boolean {
|
||||
for (const code of keys) {
|
||||
if (this.pressed.delete(code)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -1,318 +0,0 @@
|
|||
/**
|
||||
* Fixed-timestep game loop.
|
||||
*
|
||||
* Diablo's simulation runs at 25 ticks per second, and that rate is not
|
||||
* cosmetic: animation frames, movement, and monster AI all advance per tick,
|
||||
* so anything derived from wall-clock time would diverge from the original
|
||||
* behaviour.
|
||||
*
|
||||
* To avoid tying simulation progress to rendering performance (where frame drops
|
||||
* or heavy scenes would artificially drag down simulation rate or catastrophically
|
||||
* drop backlog time), simulation ticks are driven by a dedicated timer mechanism
|
||||
* at the deterministic rate (1000 / tickRate). Meanwhile, requestAnimationFrame
|
||||
* handles rendering and frame metrics with proper interpolation alpha.
|
||||
*/
|
||||
|
||||
/** Loop wiring. */
|
||||
export interface LoopOptions {
|
||||
/** Ticks per second (Diablo's simulation rate: 25). */
|
||||
readonly tickRate: number
|
||||
/** Advance the simulation by one tick. */
|
||||
readonly onTick: (tick: number) => void
|
||||
/**
|
||||
* Draw one frame.
|
||||
*
|
||||
* @param alpha - how far the next tick is into the future, in `0..1`, for
|
||||
* interpolation between ticks.
|
||||
* @param frameMs - wall-clock duration of the previous frame.
|
||||
*/
|
||||
readonly onRender: (alpha: number, frameMs: number) => void
|
||||
/** Most ticks to run in one timer/frame step before clamping the backlog. */
|
||||
readonly maxCatchUp?: number
|
||||
/** Optional custom clock source for testing. Defaults to performance.now. */
|
||||
readonly clock?: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
* A fixed-rate simulation loop decoupled from rendering.
|
||||
*/
|
||||
export class GameLoop {
|
||||
private readonly options: LoopOptions
|
||||
readonly tickMs: number
|
||||
readonly maxCatchUp: number
|
||||
|
||||
private rawAccumulator = 0
|
||||
private consumed = 0
|
||||
private running = false
|
||||
|
||||
// Simulation timer & metrics
|
||||
private simTimerHandle: ReturnType<typeof setTimeout> | null = null
|
||||
private lastSimTime = 0
|
||||
private simWindowStart = 0
|
||||
private simTicksInWindow = 0
|
||||
private simVirtualElapsed = 0
|
||||
private measuredSimRate = 0
|
||||
|
||||
// Render loop & metrics
|
||||
private renderHandle = 0
|
||||
private lastRenderTime = 0
|
||||
private lastFrameMs = 0
|
||||
private renderWindowStart = 0
|
||||
private renderFramesInWindow = 0
|
||||
private measuredFps = 0
|
||||
|
||||
/**
|
||||
* @param options - loop wiring.
|
||||
*/
|
||||
constructor(options: LoopOptions) {
|
||||
this.options = options
|
||||
this.tickMs = 1000 / options.tickRate
|
||||
this.maxCatchUp = options.maxCatchUp ?? 10
|
||||
}
|
||||
|
||||
/** Current time in ms from options.clock or performance.now. */
|
||||
private now(): number {
|
||||
return this.options.clock ? this.options.clock() : performance.now()
|
||||
}
|
||||
|
||||
/** Ticks simulated since the loop started. */
|
||||
get tick(): number {
|
||||
return this.consumed
|
||||
}
|
||||
|
||||
/** Measured simulation ticks per second over the last window (or target rate when active). */
|
||||
get tickRate(): number {
|
||||
return this.simTps
|
||||
}
|
||||
|
||||
/** Simulation ticks per second. */
|
||||
get simTps(): number {
|
||||
if (this.measuredSimRate > 0) return this.measuredSimRate
|
||||
const elapsed = this.now() - this.simWindowStart
|
||||
if (elapsed >= 200 && this.simTicksInWindow > 0) {
|
||||
return (this.simTicksInWindow * 1000) / elapsed
|
||||
}
|
||||
if (this.simVirtualElapsed >= 200 && this.simTicksInWindow > 0) {
|
||||
return (this.simTicksInWindow * 1000) / this.simVirtualElapsed
|
||||
}
|
||||
return this.running ? this.options.tickRate : 0
|
||||
}
|
||||
|
||||
/** Measured render frames per second. */
|
||||
get fps(): number {
|
||||
return this.renderFps
|
||||
}
|
||||
|
||||
/** Render frames per second. */
|
||||
get renderFps(): number {
|
||||
if (this.measuredFps > 0) return this.measuredFps
|
||||
const elapsed = this.now() - this.renderWindowStart
|
||||
if (elapsed >= 200 && this.renderFramesInWindow > 0) {
|
||||
return (this.renderFramesInWindow * 1000) / elapsed
|
||||
}
|
||||
return this.lastFrameMs > 0 ? Math.min(120, 1000 / this.lastFrameMs) : 0
|
||||
}
|
||||
|
||||
/** Duration of the last render frame in milliseconds. */
|
||||
get frameMs(): number {
|
||||
return this.lastFrameMs
|
||||
}
|
||||
|
||||
/** Whether the loop is currently running. */
|
||||
get isRunning(): boolean {
|
||||
return this.running
|
||||
}
|
||||
|
||||
/** Current simulation accumulator in milliseconds. */
|
||||
get accumulator(): number {
|
||||
const elapsedSinceLastSim = this.running ? Math.max(0, this.now() - this.lastSimTime) : 0
|
||||
return this.rawAccumulator + elapsedSinceLastSim
|
||||
}
|
||||
|
||||
/** Interpolation factor in 0..1 between the current tick and the next tick. */
|
||||
get alpha(): number {
|
||||
return Math.min(1, Math.max(0, this.accumulator / this.tickMs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually advance the simulation by deltaMs milliseconds.
|
||||
* Useful for headless simulation, test clocks, and deterministic testing.
|
||||
*
|
||||
* @param deltaMs - Milliseconds to advance.
|
||||
* @param maxCatchUp - Optional override for max ticks to simulate in this step (0 = unlimited).
|
||||
*/
|
||||
step(deltaMs: number, maxCatchUp?: number): void {
|
||||
if (deltaMs <= 0) return
|
||||
this.rawAccumulator += deltaMs
|
||||
this.lastSimTime = this.now()
|
||||
|
||||
const maxSteps = maxCatchUp !== undefined ? maxCatchUp : (this.options.maxCatchUp !== undefined ? this.maxCatchUp : 0)
|
||||
let steps = 0
|
||||
while (this.rawAccumulator >= this.tickMs && (maxSteps <= 0 || steps < maxSteps)) {
|
||||
this.rawAccumulator -= this.tickMs
|
||||
this.consumed += 1
|
||||
this.simTicksInWindow += 1
|
||||
this.options.onTick(this.consumed)
|
||||
steps += 1
|
||||
}
|
||||
|
||||
if (maxSteps > 0) {
|
||||
const maxBacklog = this.tickMs * maxSteps
|
||||
if (this.rawAccumulator > maxBacklog) {
|
||||
this.rawAccumulator = maxBacklog
|
||||
}
|
||||
}
|
||||
|
||||
this.simVirtualElapsed += deltaMs
|
||||
if (this.simVirtualElapsed >= 1000) {
|
||||
this.measuredSimRate = (this.simTicksInWindow * 1000) / this.simVirtualElapsed
|
||||
this.simVirtualElapsed = 0
|
||||
this.simTicksInWindow = 0
|
||||
} else if (this.simVirtualElapsed >= 200) {
|
||||
this.measuredSimRate = (this.simTicksInWindow * 1000) / this.simVirtualElapsed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger one render call.
|
||||
*/
|
||||
render(frameMs?: number): void {
|
||||
const elapsed = frameMs ?? (this.lastFrameMs > 0 ? this.lastFrameMs : 16.6)
|
||||
this.lastFrameMs = elapsed
|
||||
this.options.onRender(this.alpha, elapsed)
|
||||
}
|
||||
|
||||
/** Advance simulation according to elapsed wall-clock time. */
|
||||
private advanceSimulation(now: number): void {
|
||||
const elapsed = now - this.lastSimTime
|
||||
if (elapsed <= 0) return
|
||||
this.lastSimTime = now
|
||||
this.rawAccumulator += elapsed
|
||||
|
||||
let steps = 0
|
||||
while (this.rawAccumulator >= this.tickMs && (this.maxCatchUp <= 0 || steps < this.maxCatchUp)) {
|
||||
this.rawAccumulator -= this.tickMs
|
||||
this.consumed += 1
|
||||
this.simTicksInWindow += 1
|
||||
this.options.onTick(this.consumed)
|
||||
steps += 1
|
||||
}
|
||||
|
||||
// Prevent catastrophic discard: clamp to max backlog instead of wiping to 0
|
||||
const maxBacklog = this.tickMs * this.maxCatchUp
|
||||
if (this.rawAccumulator > maxBacklog) {
|
||||
this.rawAccumulator = maxBacklog
|
||||
}
|
||||
|
||||
const windowElapsed = now - this.simWindowStart
|
||||
if (windowElapsed >= 1000) {
|
||||
this.measuredSimRate = (this.simTicksInWindow * 1000) / windowElapsed
|
||||
this.simWindowStart = now
|
||||
this.simTicksInWindow = 0
|
||||
} else if (this.measuredSimRate === 0 && windowElapsed >= 200) {
|
||||
this.measuredSimRate = (this.simTicksInWindow * 1000) / windowElapsed
|
||||
}
|
||||
}
|
||||
|
||||
/** Begin ticking and rendering. Idempotent. */
|
||||
start(): void {
|
||||
if (this.running) return
|
||||
this.running = true
|
||||
|
||||
const now = this.now()
|
||||
this.lastSimTime = now
|
||||
this.lastRenderTime = now
|
||||
this.simWindowStart = now
|
||||
this.renderWindowStart = now
|
||||
this.simTicksInWindow = 0
|
||||
this.renderFramesInWindow = 0
|
||||
|
||||
this.startSimTimer()
|
||||
this.startRenderLoop()
|
||||
}
|
||||
|
||||
/** Start the self-correcting timer loop for simulation ticks. */
|
||||
private startSimTimer(): void {
|
||||
if (this.simTimerHandle !== null) return
|
||||
let nextTick = this.now() + this.tickMs
|
||||
|
||||
const simStep = (): void => {
|
||||
if (!this.running) return
|
||||
const now = this.now()
|
||||
this.advanceSimulation(now)
|
||||
|
||||
// Self-correct next tick timestamp to eliminate drift
|
||||
nextTick += this.tickMs
|
||||
if (nextTick < now - this.tickMs * this.maxCatchUp) {
|
||||
nextTick = now + this.tickMs
|
||||
} else {
|
||||
while (nextTick <= now) {
|
||||
nextTick += this.tickMs
|
||||
}
|
||||
}
|
||||
const delay = Math.max(0, nextTick - this.now())
|
||||
this.simTimerHandle = setTimeout(simStep, delay)
|
||||
}
|
||||
|
||||
this.simTimerHandle = setTimeout(simStep, this.tickMs)
|
||||
}
|
||||
|
||||
/** Start requestAnimationFrame render loop. */
|
||||
private startRenderLoop(): void {
|
||||
if (this.renderHandle !== 0) return
|
||||
|
||||
const renderStep = (): void => {
|
||||
if (!this.running) return
|
||||
const now = this.now()
|
||||
const frameMs = this.lastRenderTime > 0 ? now - this.lastRenderTime : 16.6
|
||||
this.lastRenderTime = now
|
||||
this.lastFrameMs = frameMs
|
||||
this.renderFramesInWindow += 1
|
||||
|
||||
const renderWindowElapsed = now - this.renderWindowStart
|
||||
if (renderWindowElapsed >= 1000) {
|
||||
this.measuredFps = (this.renderFramesInWindow * 1000) / renderWindowElapsed
|
||||
this.renderWindowStart = now
|
||||
this.renderFramesInWindow = 0
|
||||
} else if (this.measuredFps === 0 && renderWindowElapsed >= 200) {
|
||||
this.measuredFps = (this.renderFramesInWindow * 1000) / renderWindowElapsed
|
||||
}
|
||||
|
||||
this.options.onRender(this.alpha, frameMs)
|
||||
|
||||
if (this.running) {
|
||||
this.renderHandle = this.requestFrame(renderStep)
|
||||
}
|
||||
}
|
||||
|
||||
this.renderHandle = this.requestFrame(renderStep)
|
||||
}
|
||||
|
||||
private requestFrame(cb: (time: number) => void): number {
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
return requestAnimationFrame(cb)
|
||||
}
|
||||
return setTimeout(() => cb(this.now()), 16) as unknown as number
|
||||
}
|
||||
|
||||
private cancelFrame(handle: number): void {
|
||||
if (typeof cancelAnimationFrame === 'function') {
|
||||
cancelAnimationFrame(handle)
|
||||
return
|
||||
}
|
||||
clearTimeout(handle as unknown as ReturnType<typeof setTimeout>)
|
||||
}
|
||||
|
||||
/** Stop ticking and rendering. Idempotent. */
|
||||
stop(): void {
|
||||
this.running = false
|
||||
if (this.simTimerHandle !== null) {
|
||||
clearTimeout(this.simTimerHandle)
|
||||
this.simTimerHandle = null
|
||||
}
|
||||
if (this.renderHandle !== 0) {
|
||||
this.cancelFrame(this.renderHandle)
|
||||
this.renderHandle = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
export {
|
||||
BODY_LOC_TO_EQUIP_SLOT,
|
||||
CLASS_ID_TO_CODE,
|
||||
CLASS_ID_TO_NAMES,
|
||||
EQUIP_SLOT_TO_BODY_LOC,
|
||||
HudModel,
|
||||
STAT_KEY_TO_D2_STAT_ID,
|
||||
clientUnitToMonsterTargetInfo,
|
||||
itemDataToUiInventoryItem,
|
||||
type HudModelOptions,
|
||||
type HudModelSnapshot,
|
||||
} from './hud-model.ts'
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from '../../common/items/baked-ui-meta.ts'
|
||||
|
|
@ -22,7 +22,6 @@ import {
|
|||
areBeltPotionsCompatible,
|
||||
itemToBeltPotion,
|
||||
isAutoBeltablePotion,
|
||||
DEFAULT_BELT_POTIONS,
|
||||
BeltModel,
|
||||
type BeltPotionKind,
|
||||
type BeltPotion,
|
||||
|
|
@ -39,7 +38,6 @@ export {
|
|||
areBeltPotionsCompatible,
|
||||
itemToBeltPotion,
|
||||
isAutoBeltablePotion,
|
||||
DEFAULT_BELT_POTIONS,
|
||||
BeltModel,
|
||||
type BeltPotionKind,
|
||||
type BeltPotion,
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export * from '../../common/items/cursor-meta.ts'
|
||||
|
|
@ -48,16 +48,6 @@ export class GlobesHud {
|
|||
proceduralFallbackCount = 0
|
||||
lang: TblLang = 'CHI'
|
||||
|
||||
constructor() {
|
||||
this.syncFallbackCountToWindow()
|
||||
}
|
||||
|
||||
private syncFallbackCountToWindow(): void {
|
||||
if (typeof window !== 'undefined' && window.__d2webHud) {
|
||||
(window.__d2webHud as any).proceduralFallbackCount = this.proceduralFallbackCount
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute vertical clip rectangle for a globe given current/max values.
|
||||
*/
|
||||
|
|
@ -114,8 +104,6 @@ export class GlobesHud {
|
|||
const lifeClip = GlobesHud.computeClipRect(LIFE_GLOBE_BOUNDS, state.hp, state.maxHp)
|
||||
const manaClip = GlobesHud.computeClipRect(MANA_GLOBE_BOUNDS, state.mana, state.maxMana)
|
||||
|
||||
this.syncFallbackCountToWindow()
|
||||
|
||||
// Dark socket bowl under globes
|
||||
this.drawSocketBowl(ctx, LIFE_GLOBE_BOUNDS)
|
||||
this.drawSocketBowl(ctx, MANA_GLOBE_BOUNDS)
|
||||
|
|
@ -136,7 +124,6 @@ export class GlobesHud {
|
|||
)
|
||||
} else if (lifeClip.clipH > 0) {
|
||||
this.proceduralFallbackCount += 1
|
||||
this.syncFallbackCountToWindow()
|
||||
console.warn('[GlobesHud] Authentic life globe asset missing on draw; rendering procedural fallback')
|
||||
this.drawProceduralLiquid(ctx, LIFE_GLOBE_BOUNDS, lifeClip.ratio, state.poisoned ? '#24b624' : '#b81414')
|
||||
}
|
||||
|
|
@ -156,7 +143,6 @@ export class GlobesHud {
|
|||
)
|
||||
} else if (manaClip.clipH > 0) {
|
||||
this.proceduralFallbackCount += 1
|
||||
this.syncFallbackCountToWindow()
|
||||
console.warn('[GlobesHud] Authentic mana globe asset missing on draw; rendering procedural fallback')
|
||||
this.drawProceduralLiquid(ctx, MANA_GLOBE_BOUNDS, manaClip.ratio, '#143cb8')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,495 +0,0 @@
|
|||
/**
|
||||
* Diablo II v1.13c Authentic Ground Item Alt Labels (`src/ui/ground-labels.ts`).
|
||||
*
|
||||
* Implements:
|
||||
* - Holding the `Alt` key displays floating text labels for all ground items on screen.
|
||||
* - Text color and border styling strictly following Diablo II 1.13c item quality colors:
|
||||
* Normal (white), Low (gray), Superior (white), Magic (blue), Rare (yellow),
|
||||
* Set (green), Unique (gold/tan), Craft/Rune (orange), Gold (yellow/gold).
|
||||
* - Vertical ladder collision avoidance (anti-overlap):
|
||||
* When multiple items drop in close proximity (e.g. boss drops, packed loot),
|
||||
* labels are neatly stacked vertically into a non-overlapping ladder.
|
||||
* - Clickable labels to trigger pathfinding and item pickup.
|
||||
*/
|
||||
|
||||
import { isGroundSourceUnidentified, type GroundItemEntity } from '../../common/items/ground-items.ts'
|
||||
import {
|
||||
resolveBaseItemNameEn,
|
||||
translateItemName,
|
||||
translateRareName,
|
||||
translateMagicName,
|
||||
} from '../../common/i18n/tooltip-i18n.ts'
|
||||
import { isEn } from '../i18n/lang.ts'
|
||||
|
||||
/**
|
||||
* Authentic 1.13c Diablo II quality color codes for ground item labels.
|
||||
* Matching D2 palette tables & D2WinFont values.
|
||||
*/
|
||||
export const GROUND_LABEL_QUALITY_COLORS: Record<string, string> = {
|
||||
normal: '#d8d8d8',
|
||||
low: '#808080',
|
||||
superior: '#ffffff',
|
||||
magic: '#6868ff',
|
||||
rare: '#ffff64',
|
||||
set: '#00fc00',
|
||||
unique: '#c8a15a',
|
||||
craft: '#ff9c18',
|
||||
rune: '#ff9c18',
|
||||
gold: '#d8b420',
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a ground item entity or its underlying item source is ethereal.
|
||||
*/
|
||||
export function isGroundItemEthereal(item: GroundItemEntity): boolean {
|
||||
const src = item.item
|
||||
return Boolean(
|
||||
item.ethereal === true ||
|
||||
src?.ethereal === true ||
|
||||
src?.isEthereal === true ||
|
||||
src?.item?.ethereal === true ||
|
||||
src?.item?.isEthereal === true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a ground item entity or its underlying item source has sockets (> 0).
|
||||
*/
|
||||
export function isGroundItemSocketed(item: GroundItemEntity): boolean {
|
||||
const src = item.item
|
||||
const sockets =
|
||||
item.sockets ??
|
||||
src?.sockets ??
|
||||
src?.totalSockets ??
|
||||
src?.socketedCount ??
|
||||
src?.item?.sockets ??
|
||||
src?.item?.totalSockets ??
|
||||
src?.item?.socketedCount ??
|
||||
0
|
||||
return typeof sockets === 'number' && sockets > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a ground item entity is ethereal or socketed.
|
||||
*/
|
||||
export function isGroundItemEtherealOrSocketed(item: GroundItemEntity): boolean {
|
||||
return isGroundItemEthereal(item) || isGroundItemSocketed(item)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the quality color string for a ground item.
|
||||
*
|
||||
* Authentic Diablo II v1.13c rule (`D2Client.dll` ground item label color lookup):
|
||||
* - Gold -> `#d8b420` (Gold)
|
||||
* - Low Quality (`low` / `inferior` / `crude` / `damaged` / `cracked`) -> `#808080` (Gray)
|
||||
* - Normal or Superior (`normal` / `superior` / `high` / `hi-quality`) items that are
|
||||
* Ethereal OR have Sockets (`sockets > 0`, excluding completed runewords) -> `#808080` (Gray)
|
||||
* - Magic, Rare, Set, Unique, Craft, Rune -> retain their respective quality colors even when ethereal/socketed.
|
||||
*/
|
||||
export function getGroundItemQualityColor(item: GroundItemEntity): string {
|
||||
if (item.isGold) {
|
||||
return GROUND_LABEL_QUALITY_COLORS.gold!
|
||||
}
|
||||
const q = item.quality?.toLowerCase() ?? 'normal'
|
||||
if (q === 'low' || q === 'inferior' || q === 'crude' || q === 'damaged' || q === 'cracked') {
|
||||
return GROUND_LABEL_QUALITY_COLORS.low!
|
||||
}
|
||||
const isNormalOrSuperior = q === 'normal' || q === 'superior' || q === 'high' || q === 'hi-quality'
|
||||
const isRuneword = Boolean(
|
||||
(item as any).isRuneword ||
|
||||
item.item?.isRuneword ||
|
||||
item.item?.runewordRunes ||
|
||||
item.item?.item?.isRuneword ||
|
||||
item.item?.item?.runewordRunes,
|
||||
)
|
||||
if (isRuneword) {
|
||||
return GROUND_LABEL_QUALITY_COLORS.unique!
|
||||
}
|
||||
if (isNormalOrSuperior && isGroundItemEtherealOrSocketed(item)) {
|
||||
return GROUND_LABEL_QUALITY_COLORS.low!
|
||||
}
|
||||
return GROUND_LABEL_QUALITY_COLORS[q] ?? GROUND_LABEL_QUALITY_COLORS.normal!
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips mixed English parentheticals from Chinese item names in CHI mode
|
||||
* while preserving authentic Chinese annotations like `(5/5 火升)`.
|
||||
*/
|
||||
export function cleanChiItemName(nameZh: string): string {
|
||||
return nameZh
|
||||
.replace(/^精神\s*\(Spirit\)\s*统盾$/, '精神统盾')
|
||||
.replace(/\s*\(([A-Za-z]+)\s+符文\)$/, '符文')
|
||||
.replace(/\s*\(Rainbow Facet\s*-\s*([^)]+)\)$/, ' ($1)')
|
||||
.replace(/\s*\([A-Za-z0-9'+\-% /×·,.#:]+\)/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the user-facing display text for a ground item label.
|
||||
*/
|
||||
export function formatGroundItemLabelText(item: GroundItemEntity): string {
|
||||
const en = isEn()
|
||||
if (item.isGold) {
|
||||
return en ? `${item.amount} Gold` : `${item.amount} 金币`
|
||||
}
|
||||
const src = item.item as any
|
||||
if (isGroundSourceUnidentified(src)) {
|
||||
const baseObj = src?.base ?? src?.rawItem?.base ?? src?.item?.base
|
||||
const rawCode =
|
||||
typeof src?.code === 'string'
|
||||
? src.code.trim().toLowerCase()
|
||||
: typeof baseObj?.id === 'string'
|
||||
? baseObj.id.trim().toLowerCase()
|
||||
: ''
|
||||
const rawBaseName =
|
||||
typeof baseObj?.name === 'string' && baseObj.name.trim().length > 0
|
||||
? baseObj.name
|
||||
: item.name
|
||||
if (rawBaseName) {
|
||||
const baseNameEn = resolveBaseItemNameEn(rawBaseName, rawCode)
|
||||
if (en) {
|
||||
return baseNameEn
|
||||
}
|
||||
if (item.nameZh && item.name === baseNameEn) {
|
||||
return cleanChiItemName(item.nameZh)
|
||||
}
|
||||
const translatedBase =
|
||||
translateItemName(rawBaseName) !== rawBaseName
|
||||
? translateItemName(rawBaseName)
|
||||
: translateItemName(baseNameEn)
|
||||
return translatedBase || baseNameEn
|
||||
}
|
||||
}
|
||||
if (en) {
|
||||
return item.name || src?.name || src?.item?.name || 'Item'
|
||||
}
|
||||
if (item.nameZh) {
|
||||
return cleanChiItemName(item.nameZh)
|
||||
}
|
||||
const srcNameZh = src?.nameZh ?? src?.item?.nameZh
|
||||
if (typeof srcNameZh === 'string' && srcNameZh.trim() !== '') {
|
||||
return cleanChiItemName(srcNameZh)
|
||||
}
|
||||
if (item.name) {
|
||||
const q = (item.quality || 'normal').toLowerCase()
|
||||
if (q === 'rare') {
|
||||
return translateRareName(item.name)
|
||||
}
|
||||
if (q === 'magic') {
|
||||
const baseName = src?.base?.name ?? src?.item?.base?.name
|
||||
return translateMagicName(item.name, baseName)
|
||||
}
|
||||
if (src?.base || src?.code || src?.item?.base || src?.item?.code) {
|
||||
return translateItemName(item.name)
|
||||
}
|
||||
return cleanChiItemName(item.name)
|
||||
}
|
||||
return '物品'
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates the pixel width of a ground item label based on CJK and Latin character counts.
|
||||
*/
|
||||
export function estimateLabelWidth(text: string, fontSizePx = 13): number {
|
||||
let charWidths = 0
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const code = text.charCodeAt(i)
|
||||
// CJK ideographs / punctuation roughly full-width
|
||||
if (code > 0x2e80) {
|
||||
charWidths += fontSizePx * 1.05
|
||||
} else {
|
||||
// Latin characters / numbers / spaces roughly half-width
|
||||
charWidths += fontSizePx * 0.58
|
||||
}
|
||||
}
|
||||
// Base padding: 12px (6px left + 6px right) + 2px border
|
||||
return Math.ceil(charWidths + 14)
|
||||
}
|
||||
|
||||
export interface GroundLabelLayout {
|
||||
readonly id: string
|
||||
readonly itemId: string
|
||||
readonly item: GroundItemEntity
|
||||
readonly text: string
|
||||
readonly color: string
|
||||
readonly borderColor: string
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly screenX: number
|
||||
screenY: number
|
||||
readonly naturalY: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes initial screen layouts for a list of ground items.
|
||||
*/
|
||||
export function computeInitialGroundLabelLayouts(
|
||||
items: readonly GroundItemEntity[],
|
||||
projectToScreen: (worldX: number, worldY: number) => { x: number; y: number } | null,
|
||||
fontSizePx = 13,
|
||||
labelHeightPx = 18,
|
||||
): GroundLabelLayout[] {
|
||||
const layouts: GroundLabelLayout[] = []
|
||||
|
||||
for (const item of items) {
|
||||
const pt = projectToScreen(item.x, item.y)
|
||||
if (pt === null) continue
|
||||
|
||||
const text = formatGroundItemLabelText(item)
|
||||
const color = getGroundItemQualityColor(item)
|
||||
const width = estimateLabelWidth(text, fontSizePx)
|
||||
const naturalY = pt.y - 12 // Positioned 12px above ground contact point
|
||||
|
||||
layouts.push({
|
||||
id: `label-${item.id}`,
|
||||
itemId: item.id,
|
||||
item,
|
||||
text,
|
||||
color,
|
||||
borderColor: color,
|
||||
width,
|
||||
height: labelHeightPx,
|
||||
screenX: pt.x,
|
||||
screenY: naturalY,
|
||||
naturalY,
|
||||
})
|
||||
}
|
||||
|
||||
return layouts
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if two rectangular labels overlap within specified padding tolerances.
|
||||
*/
|
||||
export function doLabelsOverlap(
|
||||
a: GroundLabelLayout,
|
||||
b: GroundLabelLayout,
|
||||
padX = 4,
|
||||
padY = 2,
|
||||
): boolean {
|
||||
const aLeft = a.screenX - a.width / 2
|
||||
const aRight = a.screenX + a.width / 2
|
||||
const aTop = a.screenY - a.height / 2
|
||||
const aBottom = a.screenY + a.height / 2
|
||||
|
||||
const bLeft = b.screenX - b.width / 2
|
||||
const bRight = b.screenX + b.width / 2
|
||||
const bTop = b.screenY - b.height / 2
|
||||
const bBottom = b.screenY + b.height / 2
|
||||
|
||||
const xOverlap = aLeft - padX < bRight && aRight + padX > bLeft
|
||||
const yOverlap = aTop - padY < bBottom && aBottom + padY > bTop
|
||||
|
||||
return xOverlap && yOverlap
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentic Diablo II 1.13c Vertical Ladder Collision Avoidance Algorithm.
|
||||
*
|
||||
* Stacks overlapping ground item labels vertically so they remain readable and clickable.
|
||||
* - Groups labels into overlapping horizontal clusters.
|
||||
* - Stacks each cluster from bottom (closest to the ground item) upwards.
|
||||
* - Preserves natural anchor proximity while eliminating 100% of bounding box overlaps.
|
||||
*/
|
||||
export function resolveLadderCollisions(
|
||||
labels: GroundLabelLayout[],
|
||||
padX = 4,
|
||||
padY = 2,
|
||||
): GroundLabelLayout[] {
|
||||
if (labels.length <= 1) return labels
|
||||
|
||||
// Work on a copy with mutable screenY
|
||||
const result: GroundLabelLayout[] = labels.map(l => ({ ...l }))
|
||||
|
||||
// Partition into connected overlapping components (clusters)
|
||||
const n = result.length
|
||||
const adj: number[][] = Array.from({ length: n }, () => [])
|
||||
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
for (let j = i + 1; j < n; j += 1) {
|
||||
// Horizontal overlap check: do their X spans overlap?
|
||||
const a = result[i]!
|
||||
const b = result[j]!
|
||||
const aLeft = a.screenX - a.width / 2
|
||||
const aRight = a.screenX + a.width / 2
|
||||
const bLeft = b.screenX - b.width / 2
|
||||
const bRight = b.screenX + b.width / 2
|
||||
|
||||
if (aLeft - padX < bRight && aRight + padX > bLeft) {
|
||||
adj[i]!.push(j)
|
||||
adj[j]!.push(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find connected clusters via BFS
|
||||
const visited = new Uint8Array(n)
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
if (visited[i]) continue
|
||||
const cluster: number[] = []
|
||||
const queue = [i]
|
||||
visited[i] = 1
|
||||
|
||||
while (queue.length > 0) {
|
||||
const curr = queue.shift()!
|
||||
cluster.push(curr)
|
||||
for (const neighbor of adj[curr]!) {
|
||||
if (!visited[neighbor]) {
|
||||
visited[neighbor] = 1
|
||||
queue.push(neighbor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cluster.length <= 1) continue
|
||||
|
||||
// Sort cluster items by their natural Y ascending
|
||||
cluster.sort((idxA, idxB) => {
|
||||
const a = result[idxA]!
|
||||
const b = result[idxB]!
|
||||
if (a.naturalY !== b.naturalY) {
|
||||
return a.naturalY - b.naturalY
|
||||
}
|
||||
return a.screenX - b.screenX
|
||||
})
|
||||
|
||||
// Ladder layout: Bottom-most label is placed closest to the ground item,
|
||||
// and subsequent labels stack upward neatly.
|
||||
// Base Y is the natural Y of the lowest (highest Y coordinate) item in the cluster.
|
||||
const bottomItem = result[cluster[cluster.length - 1]!]!
|
||||
let currentBottomY = bottomItem.naturalY
|
||||
|
||||
// Stack upward from bottom to top
|
||||
for (let k = cluster.length - 1; k >= 0; k -= 1) {
|
||||
const itemIdx = cluster[k]!
|
||||
const item = result[itemIdx]!
|
||||
item.screenY = currentBottomY
|
||||
currentBottomY -= item.height + padY
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* DOM Manager for ground item Alt labels overlay.
|
||||
*/
|
||||
export class GroundLabelOverlay {
|
||||
private readonly container: HTMLElement
|
||||
private readonly labelPool: HTMLElement[] = []
|
||||
private onPickupCallback: ((item: GroundItemEntity) => void) | null = null
|
||||
private onHoverCallback: ((item: GroundItemEntity | null) => void) | null = null
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container
|
||||
this.container.style.position = 'fixed'
|
||||
this.container.style.inset = '0'
|
||||
this.container.style.pointerEvents = 'none'
|
||||
this.container.style.zIndex = '5'
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback when a ground item label is clicked.
|
||||
*/
|
||||
setOnPickupCallback(callback: (item: GroundItemEntity) => void): void {
|
||||
this.onPickupCallback = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback when a ground item label is hovered or unhovered.
|
||||
*/
|
||||
setOnHoverCallback(callback: (item: GroundItemEntity | null) => void): void {
|
||||
this.onHoverCallback = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Update and render ground item labels based on current visible items and Alt key state.
|
||||
*/
|
||||
update(
|
||||
visible: boolean,
|
||||
layouts: readonly GroundLabelLayout[],
|
||||
): void {
|
||||
if (!visible || layouts.length === 0) {
|
||||
this.hideAll()
|
||||
return
|
||||
}
|
||||
|
||||
let count = 0
|
||||
for (const layout of layouts) {
|
||||
let el = this.labelPool[count]
|
||||
if (el === undefined) {
|
||||
el = document.createElement('div')
|
||||
el.className = 'd2-ground-label ground-label'
|
||||
el.style.position = 'absolute'
|
||||
el.style.transform = 'translate(-50%, -50%)'
|
||||
el.style.background = 'rgba(0, 0, 0, 0.88)'
|
||||
el.style.padding = '2px 6px'
|
||||
el.style.fontFamily = '"D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif'
|
||||
el.style.fontSize = '13px'
|
||||
el.style.lineHeight = '14px'
|
||||
el.style.whiteSpace = 'nowrap'
|
||||
el.style.pointerEvents = 'auto'
|
||||
el.style.cursor = 'pointer'
|
||||
el.style.userSelect = 'none'
|
||||
el.style.boxShadow = '0 1px 4px rgba(0, 0, 0, 0.8)'
|
||||
el.style.textShadow = '1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000'
|
||||
el.style.transition = 'border-color 0.1s, background-color 0.1s'
|
||||
|
||||
el.addEventListener('mouseenter', () => {
|
||||
el.style.backgroundColor = 'rgba(28, 28, 48, 0.96)'
|
||||
el.style.borderColor = '#ffffff'
|
||||
const currentItem = (el as any).__groundItem as GroundItemEntity | undefined
|
||||
if (currentItem && this.onHoverCallback) {
|
||||
this.onHoverCallback(currentItem)
|
||||
}
|
||||
})
|
||||
el.addEventListener('mouseleave', () => {
|
||||
el.style.backgroundColor = 'rgba(0, 0, 0, 0.88)'
|
||||
el.style.borderColor = el.dataset.qualityColor || '#404040'
|
||||
if (this.onHoverCallback) {
|
||||
this.onHoverCallback(null)
|
||||
}
|
||||
})
|
||||
el.addEventListener('pointerdown', (e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
const currentItem = (el as any).__groundItem as GroundItemEntity | undefined
|
||||
if (currentItem && this.onPickupCallback) {
|
||||
this.onPickupCallback(currentItem)
|
||||
}
|
||||
})
|
||||
|
||||
this.labelPool.push(el)
|
||||
this.container.appendChild(el)
|
||||
}
|
||||
|
||||
;(el as any).__groundItem = layout.item
|
||||
el.dataset.itemId = layout.itemId
|
||||
el.dataset.qualityColor = layout.borderColor
|
||||
if (el.textContent !== layout.text) el.textContent = layout.text
|
||||
el.style.color = layout.color
|
||||
el.style.border = `1px solid ${layout.borderColor}`
|
||||
el.style.left = `${Math.round(layout.screenX)}px`
|
||||
el.style.top = `${Math.round(layout.screenY)}px`
|
||||
el.style.display = 'block'
|
||||
el.hidden = false
|
||||
count += 1
|
||||
}
|
||||
|
||||
// Hide surplus pooled elements
|
||||
for (let i = count; i < this.labelPool.length; i += 1) {
|
||||
this.labelPool[i]!.style.display = 'none'
|
||||
this.labelPool[i]!.hidden = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide all active labels.
|
||||
*/
|
||||
hideAll(): void {
|
||||
for (const el of this.labelPool) {
|
||||
el.style.display = 'none'
|
||||
el.hidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,8 +22,6 @@ import {
|
|||
isLeftUsableSkill,
|
||||
PALADIN_AURA_SKILL_IDS,
|
||||
isAuraSkill,
|
||||
DEFAULT_UNIVERSAL_SKILLS,
|
||||
DEFAULT_SORCERESS_SKILLS,
|
||||
type HotkeySkillEntry,
|
||||
} from '../../common/skills/skills-meta.ts'
|
||||
|
||||
|
|
@ -34,8 +32,6 @@ export {
|
|||
isLeftUsableSkill,
|
||||
PALADIN_AURA_SKILL_IDS,
|
||||
isAuraSkill,
|
||||
DEFAULT_UNIVERSAL_SKILLS,
|
||||
DEFAULT_SORCERESS_SKILLS,
|
||||
type HotkeySkillEntry,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
* Inventory & Equipment Paperdoll (`I`/`B`), Skill Tree (`T`)
|
||||
* - Top Monster Health Bar, Item Hover Tooltips, Ground `Alt` Item Labels, Area Entry Banner,
|
||||
* and Authentic Hand Cursor (`CURSOR\ohand.dc6`) / Held `cursorItem` Sprite.
|
||||
* - Publishes `window.__d2webHud` for automated headless browser verification (Issue #27).
|
||||
*/
|
||||
|
||||
import { D2FontRenderer } from './font.ts'
|
||||
|
|
@ -46,12 +45,6 @@ import {
|
|||
type CursorState,
|
||||
} from './cursor.ts'
|
||||
import type { CharacterClassCode } from '../../common/units/classes.ts'
|
||||
import type { SceneEngineLike as GameEngine } from '../../common/units/unit-types.ts'
|
||||
import { resolvePotionSpec } from '../../common/items/misc-items.ts'
|
||||
import {
|
||||
createStarterProfileForClass,
|
||||
type ClassProfileState,
|
||||
} from '../../common/units/class-starter-profiles.ts'
|
||||
import type { TownNpcServiceDescriptor, VendorNpcId } from '../../common/units/npc-table.ts'
|
||||
import {
|
||||
detectInitialLang,
|
||||
|
|
@ -121,13 +114,6 @@ export interface PublishedHudState {
|
|||
npcMenuOpen?: boolean
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__d2webHud?: PublishedHudState
|
||||
__d2webHudInstance?: HudManager
|
||||
}
|
||||
}
|
||||
|
||||
interface UiManifest {
|
||||
readonly version: string
|
||||
readonly decodedDc6Count: number
|
||||
|
|
@ -435,7 +421,6 @@ export class HudManager {
|
|||
this.syncPublishedState()
|
||||
}
|
||||
|
||||
private classProfiles = new Map<CharacterClassCode, ClassProfileState>()
|
||||
private _lang: TblLang = 'CHI'
|
||||
|
||||
get lang(): TblLang {
|
||||
|
|
@ -478,11 +463,8 @@ export class HudManager {
|
|||
onDropItemToGround?: (item: UiInventoryItem, mousePos?: { x: number; y: number }) => void
|
||||
onDropGoldToGround?: (amount: number, mousePos?: { x: number; y: number }) => void
|
||||
}
|
||||
engine: GameEngine | null = null
|
||||
private changeListeners: Array<() => void> = []
|
||||
|
||||
constructor(
|
||||
hudCanvasOrEngine?: HTMLCanvasElement | GameEngine | null,
|
||||
hudCanvas?: HTMLCanvasElement | null,
|
||||
callbacks?: {
|
||||
onToggleAutomap?: () => void
|
||||
onWaypointTeleport?: (act: number, slug: string, levelId?: number) => void
|
||||
|
|
@ -502,15 +484,8 @@ export class HudManager {
|
|||
onDropGoldToGround?: (amount: number, mousePos?: { x: number; y: number }) => void
|
||||
},
|
||||
) {
|
||||
const isEngineArg =
|
||||
hudCanvasOrEngine !== null &&
|
||||
hudCanvasOrEngine !== undefined &&
|
||||
typeof hudCanvasOrEngine === 'object' &&
|
||||
'world' in hudCanvasOrEngine &&
|
||||
'bag' in hudCanvasOrEngine
|
||||
const rawCanvas = isEngineArg ? null : (hudCanvasOrEngine ?? null)
|
||||
this.hudCanvas =
|
||||
rawCanvas ??
|
||||
hudCanvas ??
|
||||
({
|
||||
width: 800,
|
||||
height: 600,
|
||||
|
|
@ -542,182 +517,34 @@ export class HudManager {
|
|||
if (this.hudCanvas && this.hudCanvas.style) {
|
||||
this.hudCanvas.style.cursor = 'none'
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__d2webHud = this.state
|
||||
window.__d2webHudInstance = this
|
||||
}
|
||||
if (isEngineArg) {
|
||||
this.bindEngine(hudCanvasOrEngine as GameEngine)
|
||||
}
|
||||
}
|
||||
|
||||
onChange(cb: () => void): () => void {
|
||||
this.changeListeners.push(cb)
|
||||
return () => {
|
||||
const idx = this.changeListeners.indexOf(cb)
|
||||
if (idx !== -1) this.changeListeners.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
bindEngine(engine: GameEngine): void {
|
||||
this.engine = engine
|
||||
this.inventory._boundEngine = engine
|
||||
this.currentClass = engine.classCode
|
||||
this.syncFromEngine()
|
||||
engine.onStateChange(() => {
|
||||
this.syncFromEngine()
|
||||
})
|
||||
}
|
||||
|
||||
syncFromEngine(): void {
|
||||
if (!this.engine) return
|
||||
const p = this.engine.world.player
|
||||
this.currentClass = this.engine.classCode
|
||||
this.hp = p.hp
|
||||
this.maxHp = p.maxHp
|
||||
this.mana = p.mana
|
||||
this.maxMana = p.maxMana
|
||||
this.stamina = p.stamina ?? 100
|
||||
this.maxStamina = p.maxStamina ?? 100
|
||||
this.inventory.playerLevel = p.level ?? 1
|
||||
this.charSheet.attrs = {
|
||||
...this.charSheet.attrs,
|
||||
classCode: this.engine.classCode,
|
||||
level: p.level ?? 1,
|
||||
xp: p.experience ?? p.xp ?? 0,
|
||||
nextLevelXp: p.nextLevelExp ?? 500,
|
||||
str: p.strength ?? p.str ?? 10,
|
||||
dex: p.dexterity ?? p.dex ?? 25,
|
||||
vit: p.vitality ?? p.vit ?? 10,
|
||||
ene: p.energy ?? p.ene ?? 35,
|
||||
statPoints: p.statPoints ?? 0,
|
||||
baseMaxHp: p.maxHp,
|
||||
baseMaxMana: p.maxMana,
|
||||
baseMaxStamina: p.maxStamina ?? 100,
|
||||
}
|
||||
this.skillTree.unspentSkillPoints = p.skillPoints ?? 0
|
||||
this.syncPublishedState()
|
||||
for (const cb of this.changeListeners) {
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
/** Server authority: only requests `AddStatPoint`; attributes/vitals change when the server answers. */
|
||||
allocateStat(attr: 'strength' | 'dexterity' | 'vitality' | 'energy' | BaseStatKey): boolean {
|
||||
const normAttr: 'strength' | 'dexterity' | 'vitality' | 'energy' =
|
||||
attr === 'str'
|
||||
? 'strength'
|
||||
: attr === 'dex'
|
||||
? 'dexterity'
|
||||
: attr === 'vit'
|
||||
? 'vitality'
|
||||
: attr === 'ene'
|
||||
? 'energy'
|
||||
: attr
|
||||
if (this.engine) {
|
||||
const ok = this.engine.allocateStat(normAttr)
|
||||
if (ok) this.syncFromEngine()
|
||||
return ok
|
||||
}
|
||||
const shortKey: BaseStatKey =
|
||||
normAttr === 'strength'
|
||||
attr === 'strength'
|
||||
? 'str'
|
||||
: normAttr === 'dexterity'
|
||||
: attr === 'dexterity'
|
||||
? 'dex'
|
||||
: normAttr === 'vitality'
|
||||
: attr === 'vitality'
|
||||
? 'vit'
|
||||
: 'ene'
|
||||
// Server authority: only request `AddStatPoint`; attributes/vitals change when the server answers.
|
||||
: attr === 'energy'
|
||||
? 'ene'
|
||||
: attr
|
||||
if (this._commandSink) return this._commandSink.allocateStat(shortKey)
|
||||
return false
|
||||
}
|
||||
|
||||
grantExperience(amount: number): { levelsGained: number; newLevel: number } {
|
||||
if (this.engine) {
|
||||
const res = this.engine.grantExperience(amount)
|
||||
this.syncFromEngine()
|
||||
return res
|
||||
}
|
||||
return { levelsGained: 0, newLevel: this.charSheet.attrs.level }
|
||||
}
|
||||
|
||||
takeDamage(amount: number): void {
|
||||
if (this.engine) {
|
||||
this.engine.world.player.hp = Math.max(0, this.engine.world.player.hp - amount)
|
||||
this.syncFromEngine()
|
||||
return
|
||||
}
|
||||
this.hp = Math.max(0, this.hp - amount)
|
||||
this.syncPublishedState()
|
||||
for (const cb of this.changeListeners) cb()
|
||||
}
|
||||
|
||||
heal(amount: number): void {
|
||||
if (this.engine) {
|
||||
this.engine.world.player.hp = Math.min(this.engine.world.player.maxHp, this.engine.world.player.hp + amount)
|
||||
this.syncFromEngine()
|
||||
return
|
||||
}
|
||||
this.hp = Math.min(this.maxHp, this.hp + amount)
|
||||
this.syncPublishedState()
|
||||
for (const cb of this.changeListeners) cb()
|
||||
}
|
||||
|
||||
equipFromBag(x: number, y: number, slot: string): boolean {
|
||||
if (this.engine) {
|
||||
const placed = this.engine.bag.contents.find((p: { x: number; y: number; item: any }) => p.x === x && p.y === y)
|
||||
const ok = placed ? this.engine.equipFromBag(placed.item, slot) : false
|
||||
if (ok) this.syncFromEngine()
|
||||
return ok
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
unequipSlot(slot: string): boolean {
|
||||
if (this.engine) {
|
||||
const ok = this.engine.unequipSlot(slot)
|
||||
if (ok) this.syncFromEngine()
|
||||
return ok
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
addToBelt(item: any): boolean {
|
||||
if (this.engine && this.engine.belt) {
|
||||
const placed = this.engine.belt.autoPlacePotion(item)
|
||||
this.syncFromEngine()
|
||||
return Boolean(placed)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
useBeltSlot(col: number): boolean {
|
||||
return this.drinkBeltSlot(col)
|
||||
}
|
||||
|
||||
dropInventoryGold(amount: number, mousePos?: { x: number; y: number }): number {
|
||||
if (this.engine) {
|
||||
const pile = this.engine.dropPlayerGold(amount)
|
||||
const dropped = pile ? (pile.goldAmount ?? 0) : 0
|
||||
if (dropped > 0) {
|
||||
this.callbacks.onDropGoldToGround?.(dropped, mousePos)
|
||||
}
|
||||
this.syncFromEngine()
|
||||
return dropped
|
||||
}
|
||||
dropInventoryGold(amount: number, _mousePos?: { x: number; y: number }): number {
|
||||
// Server authority: request `0x50 DropGold`; gold stat and ground pile arrive from the server.
|
||||
this._commandSink?.dropGold(amount)
|
||||
return 0
|
||||
}
|
||||
|
||||
pickupGroundGold(groundItemId: number | string): number {
|
||||
if (this.engine) {
|
||||
const picked = this.engine.pickupGroundGoldPile(groundItemId)
|
||||
this.syncFromEngine()
|
||||
return picked
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
get screenWidth(): number {
|
||||
return this.viewportProfile.width
|
||||
}
|
||||
|
|
@ -777,90 +604,6 @@ export class HudManager {
|
|||
this.handleHudMouseDown(fakeEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches active character class with complete isolation and persistence across
|
||||
* inventory, exclusive signature sets on body ONLY, stash treasures (runes/gems/jewels/gold),
|
||||
* attributes, 30-skill trees, and hotkeys (Parity with Issue #379).
|
||||
*/
|
||||
switchClass(classCode: CharacterClassCode): void {
|
||||
// 1. Snapshot current class state if currentClass is already initialized
|
||||
if (this.currentClass && this.classProfiles.has(this.currentClass)) {
|
||||
const currentProfile: ClassProfileState = {
|
||||
classCode: this.currentClass,
|
||||
equipped: { ...this.inventory.equipped },
|
||||
bagItems: this.inventory.gridItems.map(gp => ({ ...gp, item: { ...gp.item } })),
|
||||
stashItems: this.worldPanels.stashItems.map(gp => ({ ...gp, item: { ...gp.item } })),
|
||||
stashGold: this.worldPanels.stashGold,
|
||||
attrs: { ...this.charSheet.attrs },
|
||||
hardPoints: new Map(this.skillTree.hardPoints),
|
||||
unspentSkillPoints: this.skillTree.unspentSkillPoints,
|
||||
activeSkillTab: this.skillTree.activeTab,
|
||||
defaultLeftSkill: this.hotkeys.leftSkillId,
|
||||
defaultRightSkill: this.hotkeys.rightSkillId,
|
||||
availableSkills: [...this.hotkeys.availableSkills],
|
||||
hp: this.hp,
|
||||
mana: this.mana,
|
||||
}
|
||||
this.classProfiles.set(this.currentClass, currentProfile)
|
||||
}
|
||||
|
||||
// 2. Fetch target profile or create fresh starter profile
|
||||
let targetProfile = this.classProfiles.get(classCode)
|
||||
if (!targetProfile) {
|
||||
targetProfile = createStarterProfileForClass(classCode)
|
||||
this.classProfiles.set(classCode, targetProfile)
|
||||
}
|
||||
|
||||
// 3. Apply profile to current systems
|
||||
this.currentClass = classCode
|
||||
|
||||
// 3.1 Inventory & Equipment: Body has ONLY exclusive set items
|
||||
for (const key of Object.keys(this.inventory.equipped) as EquipSlotId[]) {
|
||||
delete this.inventory.equipped[key]
|
||||
}
|
||||
Object.assign(this.inventory.equipped, targetProfile.equipped)
|
||||
this.inventory.gridItems = targetProfile.bagItems.map(gp => ({ ...gp, item: { ...gp.item } }))
|
||||
|
||||
// 3.2 Stash: gems, runes, jewels, and gold
|
||||
this.worldPanels.stashItems = targetProfile.stashItems.map(gp => ({ ...gp, item: { ...gp.item } }))
|
||||
this.worldPanels.stashGold = targetProfile.stashGold
|
||||
|
||||
// 3.3 Character Sheet
|
||||
this.charSheet.attrs = { ...targetProfile.attrs }
|
||||
if (typeof window !== 'undefined' && window.location?.search) {
|
||||
const customName = new URLSearchParams(window.location.search).get('charName')?.trim()
|
||||
if (customName) {
|
||||
this.charSheet.attrs.name = customName
|
||||
}
|
||||
}
|
||||
|
||||
// 3.4 Skill Tree
|
||||
this.skillTree.loadProfile(
|
||||
targetProfile.classCode,
|
||||
targetProfile.hardPoints,
|
||||
targetProfile.unspentSkillPoints,
|
||||
targetProfile.activeSkillTab,
|
||||
)
|
||||
|
||||
// 3.5 Hotkeys
|
||||
this.hotkeys.setAvailableSkills(
|
||||
targetProfile.availableSkills,
|
||||
targetProfile.defaultLeftSkill,
|
||||
targetProfile.defaultRightSkill,
|
||||
)
|
||||
this.hotkeys.hasEquippedWeapon = Boolean(this.inventory.equipped.weapon1 || this.inventory.equipped.weapon2)
|
||||
|
||||
// 3.6 Published vitals
|
||||
this.maxHp = targetProfile.attrs.baseMaxHp
|
||||
this.hp = Math.min(targetProfile.hp, this.maxHp)
|
||||
this.maxMana = targetProfile.attrs.baseMaxMana
|
||||
this.mana = Math.min(targetProfile.mana, this.maxMana)
|
||||
this.stamina = targetProfile.attrs.baseMaxStamina
|
||||
this.maxStamina = targetProfile.attrs.baseMaxStamina
|
||||
|
||||
this.syncPublishedState()
|
||||
}
|
||||
|
||||
async init(baseUrl = '/ui'): Promise<void> {
|
||||
this.hotkeys.preloadSkillIcons()
|
||||
this.skillTree.preloadAssets()
|
||||
|
|
@ -1147,11 +890,6 @@ export class HudManager {
|
|||
}
|
||||
|
||||
drinkBeltSlot(col: number): boolean {
|
||||
if (this.engine && this.engine.belt?.getSlot(col, 0)) {
|
||||
const ok = this.engine.useBeltSlot(col)
|
||||
if (ok) this.syncFromEngine()
|
||||
return ok
|
||||
}
|
||||
// Server authority: request `0x26 BeltUse`; healing and belt contents come back from the server.
|
||||
if (this._commandSink) return this._commandSink.useBeltSlot(col)
|
||||
return false
|
||||
|
|
@ -1436,19 +1174,6 @@ export class HudManager {
|
|||
const leftX = pt.x - deltaLeft
|
||||
if (this.leftPanel === 'char') {
|
||||
this.charSheet.handleClick(leftX, pt.y, (key: BaseStatKey) => {
|
||||
if (this.engine) {
|
||||
const normAttr =
|
||||
key === 'str'
|
||||
? 'strength'
|
||||
: key === 'dex'
|
||||
? 'dexterity'
|
||||
: key === 'vit'
|
||||
? 'vitality'
|
||||
: 'energy'
|
||||
this.engine.allocateStat(normAttr)
|
||||
this.syncFromEngine()
|
||||
return
|
||||
}
|
||||
this._commandSink?.allocateStat(key)
|
||||
})
|
||||
if (!this.charSheet.visible) this.leftPanel = 'none'
|
||||
|
|
@ -1691,13 +1416,6 @@ export class HudManager {
|
|||
rightUsable: true,
|
||||
isAura: isAuraSkill(skillId),
|
||||
})
|
||||
|
||||
const profile = this.currentClass ? this.classProfiles.get(this.currentClass) : undefined
|
||||
if (profile) {
|
||||
profile.availableSkills = [...this.hotkeys.availableSkills]
|
||||
profile.hardPoints = new Map(this.skillTree.hardPoints)
|
||||
profile.unspentSkillPoints = this.skillTree.unspentSkillPoints
|
||||
}
|
||||
}
|
||||
|
||||
private buildControlBarState() {
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export * from "./inventory.ts"
|
||||
|
|
@ -564,426 +564,6 @@ export function resolveGroundItemSpriteRect(
|
|||
return resolveItemSpriteRect(item, BAKED_UI_MANIFEST.itemRects)
|
||||
}
|
||||
|
||||
export const STARTER_EQUIPPED_GEAR: Readonly<Partial<Record<EquipSlotId, UiInventoryItem>>> = {
|
||||
helm: {
|
||||
id: 'eq-shako',
|
||||
code: 'uap',
|
||||
invFile: 'invcap',
|
||||
invtransform: 'cgrn',
|
||||
chrtransform: 'cgrn',
|
||||
name: 'Harlequin Crest',
|
||||
nameZh: '谐角之冠 (Harlequin Crest)',
|
||||
baseNameZh: '军帽 (Shako)',
|
||||
quality: 'unique',
|
||||
invWidth: 2,
|
||||
invHeight: 2,
|
||||
allowedSlots: ['helm'],
|
||||
defense: 141,
|
||||
durability: { current: 12, max: 12 },
|
||||
reqLevel: 62,
|
||||
reqStr: 50,
|
||||
stats: [
|
||||
{ text: '+2 所有技能 (+2 To All Skills)', color: 'blue' },
|
||||
{ text: '+1.5 生命 (依角色等级而定)', color: 'blue' },
|
||||
{ text: '+1.5 法力 (依角色等级而定)', color: 'blue' },
|
||||
{ text: '物理伤害减少 10%', color: 'blue' },
|
||||
{ text: '50% 更佳机会取得魔法装备 (MF)', color: 'blue' },
|
||||
{ text: '+2 所有属性', color: 'blue' },
|
||||
],
|
||||
},
|
||||
amulet: {
|
||||
id: 'eq-maras',
|
||||
code: 'amu',
|
||||
invFile: 'invamu',
|
||||
name: "Mara's Kaleidoscope",
|
||||
nameZh: "马拉的万花筒 (Mara's Kaleidoscope)",
|
||||
baseNameZh: '项链 (Amulet)',
|
||||
quality: 'unique',
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
allowedSlots: ['amulet'],
|
||||
reqLevel: 67,
|
||||
stats: [
|
||||
{ text: '+2 所有技能 (+2 To All Skills)', color: 'blue' },
|
||||
{ text: '+5 所有属性', color: 'blue' },
|
||||
{ text: '所有抗性 +30 (All Resistances +30)', color: 'blue' },
|
||||
],
|
||||
},
|
||||
weapon1: {
|
||||
id: 'eq-oculus',
|
||||
code: 'oba',
|
||||
invFile: 'invob5',
|
||||
invtransform: 'whit',
|
||||
chrtransform: 'whit',
|
||||
name: 'The Oculus',
|
||||
nameZh: '眼球 (The Oculus)',
|
||||
baseNameZh: '涡流水晶 (Swirling Crystal)',
|
||||
quality: 'unique',
|
||||
invWidth: 1,
|
||||
invHeight: 3,
|
||||
allowedSlots: ['weapon1'],
|
||||
damage: '18 - 42',
|
||||
durability: { current: 50, max: 50 },
|
||||
speedText: '法球类 - 普通攻击速度',
|
||||
reqLevel: 42,
|
||||
stats: [
|
||||
{ text: '被击中时有 25% 几率施放等级 1 传送', color: 'blue' },
|
||||
{ text: '+3 法师技能等级', color: 'blue' },
|
||||
{ text: '+30% 快速施法速度 (FCR)', color: 'blue' },
|
||||
{ text: '+20% 增强防御', color: 'blue' },
|
||||
{ text: '+20 体力', color: 'blue' },
|
||||
{ text: '+20 精力', color: 'blue' },
|
||||
{ text: '所有抗性 +20', color: 'blue' },
|
||||
{ text: '+5 每次击杀获得法力', color: 'blue' },
|
||||
{ text: '50% 更佳机会取得魔法装备', color: 'blue' },
|
||||
],
|
||||
},
|
||||
armor: {
|
||||
id: 'eq-tal-armor',
|
||||
code: 'uth',
|
||||
invFile: 'invgth',
|
||||
invtransform: 'dpur',
|
||||
chrtransform: 'dpur',
|
||||
name: "Tal Rasha's Guardianship",
|
||||
nameZh: '塔·拉夏的守护',
|
||||
baseNameZh: '漆甲 (Lacquered Plate)',
|
||||
quality: 'set',
|
||||
invWidth: 2,
|
||||
invHeight: 3,
|
||||
allowedSlots: ['armor'],
|
||||
defense: 941,
|
||||
durability: { current: 55, max: 55 },
|
||||
reqLevel: 71,
|
||||
reqStr: 84,
|
||||
stats: [
|
||||
{ text: '+400 防御', color: 'blue' },
|
||||
{ text: '抗寒 +40%', color: 'blue' },
|
||||
{ text: '抗电 +40%', color: 'blue' },
|
||||
{ text: '抗火 +40%', color: 'blue' },
|
||||
{ text: '魔法伤害减少 15', color: 'blue' },
|
||||
{ text: '88% 更佳机会取得魔法装备', color: 'blue' },
|
||||
],
|
||||
setPieces: [
|
||||
'塔·拉夏的守护 (Tal Rasha\'s Guardianship)',
|
||||
'塔·拉夏的赫拉迪克纹章 (Tal Rasha\'s Horadric Crest)',
|
||||
'塔·拉夏的判决 (Tal Rasha\'s Adjudication)',
|
||||
'塔·拉夏的无睑之眼 (Tal Rasha\'s Lidless Eye)',
|
||||
'塔·拉夏的织细衣服 (Tal Rasha\'s Fine-Spun Cloth)',
|
||||
],
|
||||
setBonuses: [
|
||||
{ text: '+10% 快速再度攻击 (2 件装备)', color: 'green' },
|
||||
{ text: '+3 法师技能等级 (完整套装)', color: 'green' },
|
||||
{ text: '所有抗性 +50 (完整套装)', color: 'green' },
|
||||
{ text: '+150 生命 (完整套装)', color: 'green' },
|
||||
],
|
||||
},
|
||||
weapon2: {
|
||||
id: 'eq-spirit',
|
||||
code: 'uit',
|
||||
invFile: 'invtow',
|
||||
name: 'Spirit Monarch',
|
||||
nameZh: '精神 (Spirit) 统盾',
|
||||
baseNameZh: '统治者大盾 (Monarch)',
|
||||
quality: 'rune',
|
||||
runewordRunes: "'TalThulOrtAmn'",
|
||||
invWidth: 2,
|
||||
invHeight: 3,
|
||||
allowedSlots: ['weapon2'],
|
||||
defense: 148,
|
||||
durability: { current: 148, max: 148 },
|
||||
reqLevel: 54,
|
||||
reqStr: 156,
|
||||
sockets: 4,
|
||||
stats: [
|
||||
{ text: '+2 所有技能 (+2 To All Skills)', color: 'blue' },
|
||||
{ text: '+35% 快速施法速度 (FCR)', color: 'blue' },
|
||||
{ text: '+55% 快速再度攻击 (FHR)', color: 'blue' },
|
||||
{ text: '+250 对远距离攻击的防御', color: 'blue' },
|
||||
{ text: '+22 体力', color: 'blue' },
|
||||
{ text: '+112 法力', color: 'blue' },
|
||||
{ text: '抗寒 +35%', color: 'blue' },
|
||||
{ text: '抗电 +35%', color: 'blue' },
|
||||
{ text: '抗毒 +35%', color: 'blue' },
|
||||
{ text: '+8 魔法吸收', color: 'blue' },
|
||||
],
|
||||
},
|
||||
gloves: {
|
||||
id: 'eq-magefist',
|
||||
code: 'tgl',
|
||||
invFile: 'invtgl',
|
||||
invtransform: 'lgry',
|
||||
name: 'Magefist',
|
||||
nameZh: '法师之拳 (Magefist)',
|
||||
baseNameZh: '轻型铁手套',
|
||||
quality: 'unique',
|
||||
invWidth: 2,
|
||||
invHeight: 2,
|
||||
allowedSlots: ['gloves'],
|
||||
defense: 25,
|
||||
durability: { current: 14, max: 14 },
|
||||
reqLevel: 23,
|
||||
reqStr: 45,
|
||||
stats: [
|
||||
{ text: '+1 火焰技能', color: 'blue' },
|
||||
{ text: '+20% 快速施法速度 (FCR)', color: 'blue' },
|
||||
{ text: '法力再生速度提高 25%', color: 'blue' },
|
||||
{ text: '增加 1-6 火焰伤害', color: 'blue' },
|
||||
{ text: '+30% 增强防御', color: 'blue' },
|
||||
],
|
||||
},
|
||||
ring1: {
|
||||
id: 'eq-soj-1',
|
||||
code: 'rin',
|
||||
invFile: 'invrin',
|
||||
name: 'The Stone of Jordan',
|
||||
nameZh: '乔丹之石 (Stone of Jordan)',
|
||||
baseNameZh: '戒指 (Ring)',
|
||||
quality: 'unique',
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
allowedSlots: ['ring1', 'ring2'],
|
||||
reqLevel: 29,
|
||||
stats: [
|
||||
{ text: '+1 所有技能', color: 'blue' },
|
||||
{ text: '增加法力上限 25%', color: 'blue' },
|
||||
{ text: '+20 法力', color: 'blue' },
|
||||
{ text: '增加 1-12 闪电伤害', color: 'blue' },
|
||||
],
|
||||
},
|
||||
belt: {
|
||||
id: 'eq-arach',
|
||||
code: 'ulc',
|
||||
invFile: 'invvbl',
|
||||
invtransform: 'blac',
|
||||
name: 'Arachnid Mesh',
|
||||
nameZh: '蜘蛛之网 (Arachnid Mesh)',
|
||||
baseNameZh: '蛛网腰带 (Spiderweb Sash)',
|
||||
quality: 'unique',
|
||||
invWidth: 2,
|
||||
invHeight: 1,
|
||||
allowedSlots: ['belt'],
|
||||
defense: 138,
|
||||
durability: { current: 12, max: 12 },
|
||||
reqLevel: 80,
|
||||
reqStr: 50,
|
||||
stats: [
|
||||
{ text: '+1 所有技能', color: 'blue' },
|
||||
{ text: '+20% 快速施法速度 (FCR)', color: 'blue' },
|
||||
{ text: '减慢目标速度 10%', color: 'blue' },
|
||||
{ text: '增加法力上限 5%', color: 'blue' },
|
||||
{ text: '+120% 增强防御', color: 'blue' },
|
||||
],
|
||||
},
|
||||
ring2: {
|
||||
id: 'eq-bk-2',
|
||||
code: 'rin',
|
||||
invFile: 'invrin',
|
||||
name: "Bul-Kathos' Wedding Band",
|
||||
nameZh: '布尔凯索之婚戒',
|
||||
baseNameZh: '戒指 (Ring)',
|
||||
quality: 'unique',
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
allowedSlots: ['ring1', 'ring2'],
|
||||
reqLevel: 58,
|
||||
stats: [
|
||||
{ text: '+1 所有技能', color: 'blue' },
|
||||
{ text: '+45 生命 (依角色等级而定)', color: 'blue' },
|
||||
{ text: '5% 每次命中偷取生命', color: 'blue' },
|
||||
{ text: '+50 最大耐力', color: 'blue' },
|
||||
],
|
||||
},
|
||||
boots: {
|
||||
id: 'eq-wartrav',
|
||||
code: 'xtb',
|
||||
invFile: 'invtbt',
|
||||
name: 'War Traveler',
|
||||
nameZh: '战争旅者 (War Traveler)',
|
||||
baseNameZh: '战场之靴 (Battle Boots)',
|
||||
quality: 'unique',
|
||||
invWidth: 2,
|
||||
invHeight: 2,
|
||||
allowedSlots: ['boots'],
|
||||
defense: 139,
|
||||
durability: { current: 48, max: 48 },
|
||||
reqLevel: 42,
|
||||
reqStr: 95,
|
||||
stats: [
|
||||
{ text: '+25% 高速跑步/行走', color: 'blue' },
|
||||
{ text: '+10 力量', color: 'blue' },
|
||||
{ text: '+10 体力', color: 'blue' },
|
||||
{ text: '增加 15-25 伤害', color: 'blue' },
|
||||
{ text: '+190% 增强防御', color: 'blue' },
|
||||
{ text: '减慢耐力消耗 40%', color: 'blue' },
|
||||
{ text: '攻击者受到伤害 10', color: 'blue' },
|
||||
{ text: '50% 更佳机会取得魔法装备 (MF)', color: 'blue' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const STARTER_BAG_ITEMS: readonly GridPlacement[] = [
|
||||
{
|
||||
col: 0,
|
||||
row: 0,
|
||||
item: {
|
||||
id: 'bag-cube',
|
||||
code: 'box',
|
||||
invFile: 'invbox',
|
||||
name: 'Horadric Cube',
|
||||
nameZh: '赫拉迪克方块 (Horadric Cube)',
|
||||
baseNameZh: '任务宝盒',
|
||||
quality: 'unique',
|
||||
invWidth: 2,
|
||||
invHeight: 2,
|
||||
allowedSlots: [],
|
||||
stats: [{ text: '右键点击打开合成界面 (3×4 网格)', color: 'gold' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
col: 2,
|
||||
row: 0,
|
||||
item: {
|
||||
id: 'bag-tbk',
|
||||
code: 'tbk',
|
||||
invFile: 'invbbk',
|
||||
name: 'Tome of Town Portal',
|
||||
nameZh: '回城之书 (Tome of Town Portal)',
|
||||
baseNameZh: '法术书 (数量: 20)',
|
||||
quality: 'normal',
|
||||
invWidth: 1,
|
||||
invHeight: 2,
|
||||
allowedSlots: [],
|
||||
quantity: 20,
|
||||
maxQuantity: 20,
|
||||
stats: [{ text: '右键施放城镇传送门', color: 'white' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
col: 2,
|
||||
row: 2,
|
||||
item: {
|
||||
id: 'bag-ibk',
|
||||
code: 'ibk',
|
||||
invFile: 'invrbk',
|
||||
name: 'Tome of Identify',
|
||||
nameZh: '鉴定之书 (Tome of Identify)',
|
||||
baseNameZh: '法术书 (数量: 20)',
|
||||
quality: 'normal',
|
||||
invWidth: 1,
|
||||
invHeight: 2,
|
||||
allowedSlots: [],
|
||||
quantity: 20,
|
||||
maxQuantity: 20,
|
||||
stats: [{ text: '鉴定未辨识的魔法/暗金装备', color: 'white' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
col: 3,
|
||||
row: 0,
|
||||
item: {
|
||||
id: 'bag-torch',
|
||||
code: 'cm2',
|
||||
invFile: 'invtrch',
|
||||
name: 'Hellfire Torch',
|
||||
nameZh: '地狱火炬 (Hellfire Torch)',
|
||||
baseNameZh: '大型护身符 (Large Charm)',
|
||||
quality: 'unique',
|
||||
invWidth: 1,
|
||||
invHeight: 2,
|
||||
allowedSlots: [],
|
||||
reqLevel: 75,
|
||||
stats: [
|
||||
{ text: '击中时有 5% 几率施放等级 10 火风暴', color: 'blue' },
|
||||
{ text: '+3 法师技能等级', color: 'blue' },
|
||||
{ text: '+20 所有属性', color: 'blue' },
|
||||
{ text: '所有抗性 +20', color: 'blue' },
|
||||
{ text: '+8 照亮范围', color: 'blue' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
col: 3,
|
||||
row: 2,
|
||||
item: {
|
||||
id: 'bag-anni',
|
||||
code: 'cm1',
|
||||
invFile: 'invmss',
|
||||
name: 'Annihilus',
|
||||
nameZh: '毁灭 (Annihilus)',
|
||||
baseNameZh: '小型护身符 (Small Charm)',
|
||||
quality: 'unique',
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
allowedSlots: [],
|
||||
reqLevel: 70,
|
||||
stats: [
|
||||
{ text: '+1 所有技能 (+1 To All Skills)', color: 'blue' },
|
||||
{ text: '+20 所有属性', color: 'blue' },
|
||||
{ text: '所有抗性 +20', color: 'blue' },
|
||||
{ text: '+10% 获得经验值', color: 'blue' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
col: 4,
|
||||
row: 0,
|
||||
item: {
|
||||
id: 'bag-gheeds',
|
||||
code: 'cm3',
|
||||
invFile: 'invch3',
|
||||
name: "Gheed's Fortune",
|
||||
nameZh: '基德的运气 (Gheed\'s Fortune)',
|
||||
baseNameZh: '超大型护身符 (Grand Charm)',
|
||||
quality: 'unique',
|
||||
invWidth: 1,
|
||||
invHeight: 3,
|
||||
allowedSlots: [],
|
||||
reqLevel: 62,
|
||||
stats: [
|
||||
{ text: '160% 额外金币从怪物身上取得', color: 'blue' },
|
||||
{ text: '40% 更佳机会取得魔法装备 (MF)', color: 'blue' },
|
||||
{ text: '降低所有商店价格 15%', color: 'blue' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
col: 8,
|
||||
row: 0,
|
||||
item: {
|
||||
id: 'bag-cta',
|
||||
code: 'crs',
|
||||
invFile: 'invcrs',
|
||||
name: 'Call to Arms',
|
||||
nameZh: '战争召唤 (Call to Arms)',
|
||||
baseNameZh: '水晶剑 (Crystal Sword)',
|
||||
quality: 'rune',
|
||||
runewordRunes: "'AmnRalMalIstOhm'",
|
||||
invWidth: 2,
|
||||
invHeight: 3,
|
||||
allowedSlots: ['weapon1'],
|
||||
damage: '17 - 52',
|
||||
durability: { current: 20, max: 20 },
|
||||
reqStr: 43,
|
||||
reqLevel: 57,
|
||||
sockets: 5,
|
||||
speedText: '剑类 - 极快的攻击速度',
|
||||
stats: [
|
||||
{ text: '+1 所有技能 (+1 To All Skills)', color: 'blue' },
|
||||
{ text: '+40% 增加攻击速度 (+40% Increased Attack Speed)', color: 'blue' },
|
||||
{ text: '+255% 增强伤害 (+255% Enhanced Damage)', color: 'blue' },
|
||||
{ text: '增加 5-30 火焰伤害 (Adds 5-30 Fire Damage)', color: 'blue' },
|
||||
{ text: '7% 生命偷取 / 每次命中偷取生命 (7% Life Stolen)', color: 'blue' },
|
||||
{ text: '防止怪物自愈 / 防止怪物自疗 (Prevent Monster Heal)', color: 'blue' },
|
||||
{ text: '+6 战斗体制 (Battle Orders)', color: 'blue' },
|
||||
{ text: '+6 战斗指挥 (Battle Command)', color: 'blue' },
|
||||
{ text: '+4 战斗狂嗥 / 战嚎 (Battle Cry)', color: 'blue' },
|
||||
{ text: '生命回复 +12 / 生命补满 +12 (Replenish Life +12)', color: 'blue' },
|
||||
{ text: '30% 更佳机会取得魔法装备 (30% Better Chance of Getting Magic Items)', color: 'blue' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/** Maximum scrolls held in a Tome of Town Portal (`tbk`) or Tome of Identify (`ibk`) per `Books.txt`. */
|
||||
export const TOME_MAX_QUANTITY = 20
|
||||
|
||||
|
|
@ -2129,33 +1709,22 @@ export class InventoryPanel {
|
|||
this.playerContextSeq = nextPlayerContextSeq()
|
||||
}
|
||||
|
||||
/** Optional bound GameEngine for single-source-of-truth gold and capacity. */
|
||||
_boundEngine?: any | undefined
|
||||
/** Server stat 14 (`gold`); 0 until received (D2GS omits zero-valued stats). */
|
||||
private _gold = 0
|
||||
|
||||
/** Authentic 1.13c inventory gold capacity: playerLevel * 10,000. */
|
||||
get maxGold(): number {
|
||||
if (this._boundEngine) {
|
||||
return this._boundEngine.maxGoldCapacity
|
||||
}
|
||||
return getInventoryGoldLimit(this.playerLevel)
|
||||
}
|
||||
|
||||
/** Player gold in inventory. */
|
||||
get gold(): number {
|
||||
if (this._boundEngine) {
|
||||
return this._boundEngine.bag.gold
|
||||
}
|
||||
return this._gold
|
||||
}
|
||||
|
||||
set gold(value: number) {
|
||||
const safe = Math.max(0, Math.floor(Number.isFinite(value) ? value : 0))
|
||||
this._gold = safe
|
||||
if (this._boundEngine) {
|
||||
this._boundEngine.bag.gold = safe
|
||||
}
|
||||
}
|
||||
|
||||
isIdentifyMode(): boolean {
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export * from '../../common/items/item-bridge.ts'
|
||||
|
|
@ -1977,11 +1977,11 @@ export class WorldPanelsHud {
|
|||
|
||||
withdrawGold(
|
||||
amount: number,
|
||||
playerInventory: { gold: number; maxGold?: number; _playerLevelExplicitlySet?: boolean; _boundEngine?: unknown },
|
||||
playerInventory: { gold: number; maxGold?: number; _playerLevelExplicitlySet?: boolean },
|
||||
): number {
|
||||
if (amount <= 0) return 0
|
||||
const cap =
|
||||
(playerInventory._playerLevelExplicitlySet || playerInventory._boundEngine) &&
|
||||
playerInventory._playerLevelExplicitlySet &&
|
||||
playerInventory.maxGold !== undefined
|
||||
? playerInventory.maxGold
|
||||
: PLAYER_GOLD_CAP
|
||||
|
|
|
|||
|
|
@ -1,154 +0,0 @@
|
|||
import { AudioManager, SoundManager, sharedAudioManager } from '../audio/manager.ts'
|
||||
import { getTblLang, initLangFromBrowser, type TblLang } from '../i18n/lang.ts'
|
||||
import { MapService } from '../map/map-service.ts'
|
||||
import { ActorAnimator } from '../render/actor-animator.ts'
|
||||
import { buildBounds } from '../render/cull.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 { LoadGeneration, SpriteRenderer, WebGLRenderer2D, type AtlasHandle } from '../render/renderer.ts'
|
||||
import { RoofFadeController } from '../render/roofs.ts'
|
||||
import { initBrowserArenaMode } from '../scene/arena-overlay.ts'
|
||||
import {
|
||||
ADDITIVE_MONSTER_TOKENS, DEFAULT_BASES, loadCharacterArt, loadMonsterArtMap,
|
||||
missingMonsterArtIds, type LoadedCharacter, type MonsterArtLoad,
|
||||
} from '../scene/entity-art-loader.ts'
|
||||
import {
|
||||
GROUND_ITEM_QUALITY_COLORS, drawGroundItem, globalItemsAtlasHandle,
|
||||
loadItemsAtlas, resolveGroundItemFlippyFrame, syncHudGroundDrops,
|
||||
} from '../scene/ground-item-renderer.ts'
|
||||
import {
|
||||
LIGHTING_PRESETS, lightingLabel, normalizeLightingPreset,
|
||||
type LightingPreset, type LightingPresetConfig,
|
||||
} from '../scene/lighting-presets.ts'
|
||||
import {
|
||||
drawCastOverlay, drawExplosion, drawFireExplosion, drawFireboltProjectile,
|
||||
drawMissileProjectile, drawTownPortal, loadMissileArtMap, loadOverlayArtMap,
|
||||
type LoadedMissileArt, type LoadedOverlayArt,
|
||||
} from '../scene/missile-overlay-renderer.ts'
|
||||
import {
|
||||
SceneMouseController, playStashOpenSound, setupMouseControls, type MouseControllerContext,
|
||||
type PendingInteraction, type SceneMouseControllerOptions,
|
||||
} from '../scene/mouse-controller.ts'
|
||||
import {
|
||||
ADDITIVE_OBJECT_TOKENS, PERSISTENT_ILLUMINATION_TOKENS, computeObjectPhaseOffsetMs,
|
||||
updateAnimatedObject, updateAnimatedObjects, type AnimatedObjectFrame,
|
||||
type Drawable, type ObjectDrawable,
|
||||
} from '../scene/object-animation.ts'
|
||||
import {
|
||||
playInventoryFullFeedback, projectNpcLabel, showNotification,
|
||||
updateGroundItemLabelsOverlay, updateOverheadLabels,
|
||||
type NpcLabelProjectionParams, type NpcLabelProjectionResult,
|
||||
} from '../scene/overhead-labels.ts'
|
||||
import {
|
||||
DEFAULT_PACKS, FEET_HEIGHT, FEET_WIDTH, WALK_SPEED, buildPackRuntime,
|
||||
countLoadedPages, createLevelTransitionController, disposeRuntimeAtlases,
|
||||
fetchJson, getPackIndex, loadPackRuntime, loadRuntimeForLevel,
|
||||
pagesNeededForViewport, param, preloadRemaining, switchArea, worldVariantsFor,
|
||||
type MapRuntime, type PackIndex, type PackIndexEntry, type PackObject,
|
||||
type PackObjectFrame, type PackSceneJson,
|
||||
} from '../scene/pack-loader.ts'
|
||||
import {
|
||||
SAVE_KEY, monsterArtStatusText, monsterPlanStatusText,
|
||||
monsterToTargetHudInfo, publish, recordMonsterArtLoad, reportMonsterPlan,
|
||||
state, type ActSceneState,
|
||||
} from '../scene/scene-state.ts'
|
||||
import {
|
||||
changeHeroClass, createActSceneHudManager, createHeroLightingAndMonsterControls,
|
||||
mountChatAndToolbarUi, populatePackSelectors, respawnMonsters,
|
||||
updateSelectorWarningStyle, updateToolbarUrl, wireBootToolbarSelectors,
|
||||
} from '../scene/toolbar-chat-ui.ts'
|
||||
import { FadeOverlay } from '../scene/transition.ts'
|
||||
import {
|
||||
applyDynamicSceneLighting, buildMinimapLevel, cellOf, playerSubTile,
|
||||
renderWorldLayers, stepPlayerAndMonsterAnimations,
|
||||
} from '../scene/world-renderer.ts'
|
||||
import { ViewportCamera } from '../sim/camera.ts'
|
||||
import { KeyboardInput } from '../sim/input.ts'
|
||||
import { GroundLabelOverlay } from '../ui/ground-labels.ts'
|
||||
import { resolveGroundItemSpriteRect } from '../ui/inventory.ts'
|
||||
import { Minimap, type MinimapLevel } from '../ui/minimap.ts'
|
||||
import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts'
|
||||
import { getMissileTxtData, isAuraSkill } from '../../common/skills/missiles-data.ts'
|
||||
import {
|
||||
PASSIVE_SKILL_IDS, checkAmmunition, getAttackWeaponInfo, getSkillMana,
|
||||
getSkillRange, isPassiveSkill, type AttackWeaponInfo, type AttackWeaponInputItem,
|
||||
type AttackWeaponKind, type AttackWeaponSubtype, type SkillCastOutcome,
|
||||
} from '../../common/skills/weapon-combat-meta.ts'
|
||||
import { executeSceneChatCommand, handleActSceneChatCommand } from '../../common/world/chat-commands.ts'
|
||||
import { resolveTownPortalSubTile } from '../../common/world/portal.ts'
|
||||
import {
|
||||
ENTRANCE_CLICK_RADIUS_PX, SEAM_FADE_MS, SEAM_TRIGGER_SUBTILES, TELEKINESIS_RANGE_PX,
|
||||
WARP_CLICK_RADIUS_PX, WARP_FADE_MS, WARP_TRIGGER_SUBTILES, facingOf,
|
||||
getEntranceLabelWorldPos, getWarpLabelWorldPos, hitTestEntrance, hitTestWarp,
|
||||
isPlayerWithinLinkRange, resolveObstacleAwareSteerTarget, sceneToSubTile,
|
||||
screenToWorld, subTileToScene, type SceneEntrance, type SceneWarp,
|
||||
type SceneWaypoint, type ScreenToWorldParams,
|
||||
} from '../../common/world/scene-links-nav.ts'
|
||||
import {
|
||||
CANONICAL_TOWN_STASHES, HERO_CLASSES, ROOM_ACTIVATION_RADIUS, ROOM_DEACTIVATION_RADIUS,
|
||||
TOWN_LEVEL_IDS, buildStreamingRooms, demoteToNormal, getSharedDropTables,
|
||||
isTownLevel, normalizeHeroToken, parseDensityMultiplier, parseEliteMultiplier,
|
||||
promoteToChampion, promoteToUnique, rebudgetMonsterPacks, roomHasWalkableGround,
|
||||
type DensityMultiplier, type EliteMultiplier, type HeroClassInfo,
|
||||
type RebudgetOptions, type TownStashEntity,
|
||||
} from '../../common/world/scene-population.ts'
|
||||
import { syncEngineState } from '../../common/units/unit-types.ts'
|
||||
import { ClientWorld } from './client-world.ts'
|
||||
|
||||
// GroundItem render failure: missing flippy sprite rect in BAKED_UI_MANIFEST (validated via resolveGroundItemSpriteRect in ../scene/ground-item-renderer.ts)
|
||||
export {
|
||||
ADDITIVE_MONSTER_TOKENS, ADDITIVE_OBJECT_TOKENS, ActorAnimator, AudioManager,
|
||||
CANONICAL_TOWN_STASHES, ClientWorld, D2_EXPERIENCE_TABLE_99, DEFAULT_BASES,
|
||||
DEFAULT_PACKS, ENTRANCE_CLICK_RADIUS_PX, FEET_HEIGHT, FEET_WIDTH, FadeOverlay,
|
||||
GROUND_ITEM_QUALITY_COLORS, GroundLabelOverlay, HERO_CLASSES, KeyboardInput,
|
||||
LIGHTING_PRESETS, LightningTrailManager, LoadGeneration, MISSILE_METAS,
|
||||
MapService, Minimap, OVERLAY_METAS, PASSIVE_SKILL_IDS,
|
||||
PERSISTENT_ILLUMINATION_TOKENS, ROOM_ACTIVATION_RADIUS, ROOM_DEACTIVATION_RADIUS,
|
||||
RoofFadeController, SAVE_KEY, SEAM_FADE_MS, SEAM_TRIGGER_SUBTILES,
|
||||
SceneMouseController, SoundManager, SpriteRenderer, TELEKINESIS_RANGE_PX,
|
||||
TOWN_LEVEL_IDS, ViewportCamera, WALK_SPEED, WARP_CLICK_RADIUS_PX,
|
||||
WARP_FADE_MS, WARP_TRIGGER_SUBTILES, WebGLRenderer2D,
|
||||
applyDynamicSceneLighting, buildBounds, buildMinimapLevel, buildPackRuntime,
|
||||
buildStreamingRooms, cellOf, changeHeroClass, checkAmmunition,
|
||||
computeObjectPhaseOffsetMs,
|
||||
countLoadedPages, createActSceneHudManager, createHeroLightingAndMonsterControls,
|
||||
createLevelTransitionController, demoteToNormal,
|
||||
disposeRuntimeAtlases, drawCastOverlay, drawElectricBoltFallback, drawExplosion,
|
||||
drawFireExplosion, drawFireboltProjectile, drawGroundItem, drawMissileProjectile,
|
||||
drawTownPortal, executeSceneChatCommand, facingOf, fetchJson, getAttackWeaponInfo,
|
||||
getEntranceLabelWorldPos, getMissileTxtData, getPackIndex, getSharedDropTables,
|
||||
getSkillMana, getSkillRange, getTblLang, getWarpLabelWorldPos,
|
||||
globalItemsAtlasHandle, handleActSceneChatCommand, hitTestEntrance, hitTestWarp,
|
||||
initBrowserArenaMode, initLangFromBrowser, isAuraSkill, isPassiveSkill,
|
||||
isPlayerWithinLinkRange, isTownLevel, lightingLabel, loadCharacterArt,
|
||||
loadItemsAtlas, loadMissileArtMap, loadMonsterArtMap, loadOverlayArtMap,
|
||||
loadPackRuntime, loadRuntimeForLevel, missingMonsterArtIds, monsterArtStatusText,
|
||||
monsterPlanStatusText, monsterToTargetHudInfo, mountChatAndToolbarUi,
|
||||
normalizeHeroToken, normalizeLightingPreset, pagesNeededForViewport, param,
|
||||
parseDensityMultiplier, parseEliteMultiplier, playInventoryFullFeedback,
|
||||
playStashOpenSound, playerSubTile, populatePackSelectors, preloadRemaining,
|
||||
projectNpcLabel, promoteToChampion, promoteToUnique, publish, rebudgetMonsterPacks,
|
||||
recordMonsterArtLoad, renderWorldLayers, reportMonsterPlan,
|
||||
resolveGroundItemFlippyFrame,
|
||||
resolveGroundItemSpriteRect, resolveObstacleAwareSteerTarget, resolveTownPortalSubTile,
|
||||
respawnMonsters, roomHasWalkableGround, sceneToSubTile, screenToWorld,
|
||||
setupMouseControls, sharedAudioManager, showNotification, state,
|
||||
stepPlayerAndMonsterAnimations, subTileToScene, switchArea, syncEngineState,
|
||||
syncHudGroundDrops, updateAnimatedObject, updateAnimatedObjects,
|
||||
updateGroundItemLabelsOverlay, updateOverheadLabels, updateSelectorWarningStyle,
|
||||
updateToolbarUrl, velocityToDccDirection, wireBootToolbarSelectors, worldVariantsFor,
|
||||
type ActSceneState, type AnimatedObjectFrame, type AtlasHandle,
|
||||
type AttackWeaponInfo, type AttackWeaponInputItem, type AttackWeaponKind,
|
||||
type AttackWeaponSubtype, type DensityMultiplier, type Drawable,
|
||||
type EliteMultiplier, type HeroClassInfo, type LightingPreset,
|
||||
type LightingPresetConfig, type LoadedCharacter, type LoadedMissileArt,
|
||||
type LoadedOverlayArt, type MapRuntime, type MinimapLevel, type MissileFrameTuple,
|
||||
type MissileMeta, type MonsterArtLoad, type MouseControllerContext,
|
||||
type NpcLabelProjectionParams, type NpcLabelProjectionResult, type ObjectDrawable,
|
||||
type OverlayMeta, type PackIndex, type PackIndexEntry, type PackObject,
|
||||
type PackObjectFrame, type PackSceneJson, type PendingInteraction,
|
||||
type RebudgetOptions, type SceneEntrance, type SceneMouseControllerOptions,
|
||||
type SceneWarp, type SceneWaypoint, type ScreenToWorldParams,
|
||||
type SkillCastOutcome, type TblLang, type TownStashEntity,
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
export * from './client-unit.ts'
|
||||
export * from './client-world.ts'
|
||||
export * from './inventory.ts'
|
||||
export * from './item-tables-provider.ts'
|
||||
export * from './self.ts'
|
||||
export * from './stat-list.ts'
|
||||
export * from './states.ts'
|
||||
|
|
@ -163,49 +163,6 @@ export function isAutoBeltablePotion(item: unknown): boolean {
|
|||
return itemToBeltPotion(item) !== null
|
||||
}
|
||||
|
||||
export const DEFAULT_BELT_POTIONS: readonly BeltPotion[] = [
|
||||
{
|
||||
id: 'pot-hp-1',
|
||||
code: 'hp4',
|
||||
invFile: 'invhp4',
|
||||
name: 'Greater Healing Potion',
|
||||
nameZh: '强效治疗药剂',
|
||||
kind: 'hp',
|
||||
healHp: 180,
|
||||
healMana: 0,
|
||||
},
|
||||
{
|
||||
id: 'pot-hp-2',
|
||||
code: 'hp5',
|
||||
invFile: 'invhp5',
|
||||
name: 'Super Healing Potion',
|
||||
nameZh: '超级治疗药剂',
|
||||
kind: 'hp',
|
||||
healHp: 320,
|
||||
healMana: 0,
|
||||
},
|
||||
{
|
||||
id: 'pot-mp-1',
|
||||
code: 'mp4',
|
||||
invFile: 'invmp4',
|
||||
name: 'Greater Mana Potion',
|
||||
nameZh: '强效法力药剂',
|
||||
kind: 'mana',
|
||||
healHp: 0,
|
||||
healMana: 150,
|
||||
},
|
||||
{
|
||||
id: 'pot-rv-1',
|
||||
code: 'rvl',
|
||||
invFile: 'invvpl',
|
||||
name: 'Full Rejuvenation Potion',
|
||||
nameZh: '全面回复活力药剂',
|
||||
kind: 'rejuv',
|
||||
healHp: 400,
|
||||
healMana: 400,
|
||||
},
|
||||
]
|
||||
|
||||
export class BeltModel {
|
||||
/** 4 columns × 4 rows (`grid[row][col]`, row 0 is bottom visible row). */
|
||||
readonly grid: (BeltPotion | null)[][] = Array.from({ length: BELT_ROWS }, () =>
|
||||
|
|
@ -218,18 +175,6 @@ export class BeltModel {
|
|||
/** Currently hovered slot `(row, col)`. */
|
||||
hoveredSlot: { row: number; col: number } | null = null
|
||||
|
||||
seedDefaultPotions(): void {
|
||||
for (let row = 0; row < BELT_ROWS; row++) {
|
||||
for (let col = 0; col < BELT_COLS; col++) {
|
||||
const template = DEFAULT_BELT_POTIONS[col]!
|
||||
this.grid[row]![col] = {
|
||||
...template,
|
||||
id: `${template.id}-r${row}-c${col}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get isExpanded(): boolean {
|
||||
return this.lockedOpen || this.hovered
|
||||
}
|
||||
|
|
|
|||
|
|
@ -183,45 +183,5 @@ export function isAuraSkill(skillId: number): boolean {
|
|||
return false
|
||||
}
|
||||
|
||||
export const DEFAULT_UNIVERSAL_SKILLS: readonly HotkeySkillEntry[] = [
|
||||
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true },
|
||||
]
|
||||
|
||||
export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [
|
||||
// Universal Skills
|
||||
...DEFAULT_UNIVERSAL_SKILLS,
|
||||
// Fire Spells (8 active)
|
||||
{ skillId: 36, name: 'Fire Bolt', nameZh: '火弹', level: 5, manaCost: 2.5, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 41, name: 'Inferno', nameZh: '地狱之火', level: 1, manaCost: 0.6, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 46, name: 'Blaze', nameZh: '炽烈之径', level: 1, manaCost: 11, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 8, manaCost: 8.5, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 51, name: 'Fire Wall', nameZh: '火墙', level: 1, manaCost: 22, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 52, name: 'Enchant', nameZh: '强化', level: 1, manaCost: 25, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 56, name: 'Meteor', nameZh: '陨石', level: 6, manaCost: 22, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 62, name: 'Hydra', nameZh: '九头海蛇', level: 1, manaCost: 20, leftUsable: false, rightUsable: true },
|
||||
// Lightning Spells (9 active)
|
||||
{ skillId: 38, name: 'Charged Bolt', nameZh: '充能弹', level: 4, manaCost: 4, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 42, name: 'Static Field', nameZh: '静态力场', level: 5, manaCost: 9, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 43, name: 'Telekinesis', nameZh: '心灵传动', level: 1, manaCost: 7, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 48, name: 'Nova', nameZh: '新星', level: 1, manaCost: 15, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 49, name: 'Lightning', nameZh: '闪电', level: 7, manaCost: 11, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 53, name: 'Chain Lightning', nameZh: '连锁闪电', level: 1, manaCost: 9, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 54, name: 'Teleport', nameZh: '传送', level: 4, manaCost: 21, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 57, name: 'Thunder Storm', nameZh: '雷云风暴', level: 1, manaCost: 19, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 58, name: 'Energy Shield', nameZh: '能量护盾', level: 1, manaCost: 5, leftUsable: false, rightUsable: true },
|
||||
// Cold Spells (9 active)
|
||||
{ skillId: 39, name: 'Ice Bolt', nameZh: '冰弹', level: 1, manaCost: 3, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 40, name: 'Frozen Armor', nameZh: '冰封装甲', level: 3, manaCost: 7, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 44, name: 'Frost Nova', nameZh: '霜之新星', level: 3, manaCost: 11, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 45, name: 'Ice Blast', nameZh: '冰风暴', level: 5, manaCost: 8, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 50, name: 'Shiver Armor', nameZh: '碎冰甲', level: 1, manaCost: 11, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 55, name: 'Glacial Spike', nameZh: '冰尖柱', level: 10, manaCost: 14.5, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 59, name: 'Blizzard', nameZh: '暴风雪', level: 10, manaCost: 27, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 60, name: 'Chilling Armor', nameZh: '寒冰装甲', level: 1, manaCost: 17, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 64, name: 'Frozen Orb', nameZh: '冰封球', level: 12, manaCost: 30, leftUsable: true, rightUsable: true },
|
||||
]
|
||||
|
||||
export type { CharacterClassCode }
|
||||
|
||||
|
|
|
|||
|
|
@ -12,21 +12,11 @@
|
|||
* - Druid: Aldur's Watchtower (Hunter's Guise, Shadow Plate, Jagged Star, Battle Boots)
|
||||
* - Assassin: Natalya's Odium (Grim Helm, Loricated Mail, Scissors Suwayyah, Mesh Boots)
|
||||
* - Stash contains authentic Runes (Jah, Ber, Lo, etc.), Perfect & Flawless Gems/Skulls, and unique/magic/rare Jewels.
|
||||
* - Complete isolation and persistence across class switching.
|
||||
*/
|
||||
|
||||
import type { CharacterClassCode } from './classes.ts'
|
||||
import type { EquipSlotId, GridPlacement, UiInventoryItem } from '../items/inventory-model.ts'
|
||||
import type { CharacterAttributes } from './unit-types.ts'
|
||||
import {
|
||||
type HotkeySkillEntry,
|
||||
DEFAULT_SORCERESS_SKILLS,
|
||||
isLeftUsableSkill,
|
||||
isPassiveSkill,
|
||||
isAuraSkill,
|
||||
} from '../skills/skills-meta.ts'
|
||||
import { SKILLS_BY_CLASS } from '../data/skills-catalog.ts'
|
||||
import { calculateManaCost } from '../skills/skill-calc-engine.ts'
|
||||
import { getSocketableInfo } from '../items/gems-data.ts'
|
||||
|
||||
function buildSocketableStats(code: string): readonly { text: string; color: 'white' }[] {
|
||||
|
|
@ -41,23 +31,6 @@ function buildSocketableStats(code: string): readonly { text: string; color: 'wh
|
|||
]
|
||||
}
|
||||
|
||||
export interface ClassProfileState {
|
||||
readonly classCode: CharacterClassCode
|
||||
equipped: Partial<Record<EquipSlotId, UiInventoryItem>>
|
||||
bagItems: GridPlacement[]
|
||||
stashItems: GridPlacement[]
|
||||
stashGold: number
|
||||
attrs: CharacterAttributes
|
||||
hardPoints: Map<number, number>
|
||||
unspentSkillPoints: number
|
||||
activeSkillTab: 0 | 1 | 2
|
||||
defaultLeftSkill: number
|
||||
defaultRightSkill: number
|
||||
availableSkills: readonly HotkeySkillEntry[]
|
||||
hp: number
|
||||
mana: number
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// 1. CLASS EXCLUSIVE SET ITEMS (Body Only)
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
|
@ -1700,91 +1673,3 @@ export const CLASS_STARTER_HARD_POINTS: Record<CharacterClassCode, Readonly<Reco
|
|||
254: 1, 255: 5, 260: 1, 270: 1, // Martial Arts
|
||||
},
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// 6. DEFAULT SKILLS & HOTKEYS PER CLASS
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
function buildClassDefaultSkills(classCode: CharacterClassCode): readonly HotkeySkillEntry[] {
|
||||
if (classCode === 'sor') return DEFAULT_SORCERESS_SKILLS
|
||||
|
||||
const universal: HotkeySkillEntry[] = [
|
||||
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true },
|
||||
]
|
||||
|
||||
const classSkills = SKILLS_BY_CLASS[classCode] ?? []
|
||||
const hardPoints = CLASS_STARTER_HARD_POINTS[classCode] ?? {}
|
||||
const entries: HotkeySkillEntry[] = []
|
||||
|
||||
for (const sk of classSkills) {
|
||||
if (isPassiveSkill(sk.id)) continue
|
||||
const hard = hardPoints[sk.id] ?? 1
|
||||
const mana = calculateManaCost(sk, hard)
|
||||
entries.push({
|
||||
skillId: sk.id,
|
||||
name: sk.name,
|
||||
nameZh: sk.nameZh,
|
||||
level: hard,
|
||||
manaCost: mana,
|
||||
leftUsable: isLeftUsableSkill(sk.id),
|
||||
rightUsable: true,
|
||||
isAura: isAuraSkill(sk.id),
|
||||
})
|
||||
}
|
||||
|
||||
return [...universal, ...entries]
|
||||
}
|
||||
|
||||
export const CLASS_DEFAULT_SKILLS: Record<CharacterClassCode, readonly HotkeySkillEntry[]> = {
|
||||
ama: buildClassDefaultSkills('ama'),
|
||||
sor: DEFAULT_SORCERESS_SKILLS,
|
||||
nec: buildClassDefaultSkills('nec'),
|
||||
pal: buildClassDefaultSkills('pal'),
|
||||
bar: buildClassDefaultSkills('bar'),
|
||||
dru: buildClassDefaultSkills('dru'),
|
||||
ass: buildClassDefaultSkills('ass'),
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// 7. STARTER PROFILE FACTORY
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
export function createStarterProfileForClass(classCode: CharacterClassCode): ClassProfileState {
|
||||
const equipped = { ...(CLASS_EXCLUSIVE_SETS[classCode] ?? CLASS_EXCLUSIVE_SETS.sor) }
|
||||
const bagItems = createStarterBagForClass(classCode)
|
||||
const stashItems = STARTER_STASH_ITEMS.map((gp) => ({ ...gp, item: { ...gp.item } }))
|
||||
const attrs = { ...(CLASS_STARTER_ATTRS[classCode] ?? CLASS_STARTER_ATTRS.sor) }
|
||||
|
||||
const hardPointsEntries = Object.entries(CLASS_STARTER_HARD_POINTS[classCode] ?? CLASS_STARTER_HARD_POINTS.sor)
|
||||
const hardPoints = new Map<number, number>(hardPointsEntries.map(([id, pts]) => [Number(id), pts]))
|
||||
|
||||
const defaultSkills: Record<CharacterClassCode, { left: number; right: number; tab: 0 | 1 | 2 }> = {
|
||||
ama: { left: 6, right: 7, tab: 0 },
|
||||
sor: { left: 47, right: 64, tab: 2 },
|
||||
nec: { left: 67, right: 70, tab: 2 },
|
||||
pal: { left: 96, right: 112, tab: 0 },
|
||||
bar: { left: 126, right: 133, tab: 0 },
|
||||
dru: { left: 225, right: 229, tab: 2 },
|
||||
ass: { left: 254, right: 251, tab: 0 },
|
||||
}
|
||||
const defaults = defaultSkills[classCode] ?? defaultSkills.sor
|
||||
|
||||
return {
|
||||
classCode,
|
||||
equipped,
|
||||
bagItems,
|
||||
stashItems,
|
||||
stashGold: 2_500_000,
|
||||
attrs,
|
||||
hardPoints,
|
||||
unspentSkillPoints: 12,
|
||||
activeSkillTab: defaults.tab,
|
||||
defaultLeftSkill: defaults.left,
|
||||
defaultRightSkill: defaults.right,
|
||||
availableSkills: CLASS_DEFAULT_SKILLS[classCode] ?? CLASS_DEFAULT_SKILLS.sor,
|
||||
hp: attrs.baseMaxHp,
|
||||
mana: attrs.baseMaxMana,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { ADDITIVE_MONSTER_TOKENS as SCENE_TOKENS } from '../src/client/world/act-runtime.ts'
|
||||
import { ADDITIVE_MONSTER_TOKENS as SCENE_TOKENS } from '../src/client/scene/entity-art-loader.ts'
|
||||
import { ADDITIVE_MONSTER_TOKENS } from '../src/client/render/monster-art.ts'
|
||||
import {
|
||||
executeMonsterRenderLoop,
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@
|
|||
* content twice over.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { monsterPlanStatusText } from '../src/client/world/act-runtime.ts'
|
||||
import * as demoData from '../src/common/data/demo-data.ts'
|
||||
import { planLevelMonsters } from '../src/common/units/monsters-data.ts'
|
||||
import { parseTable } from '../src/common/world/acts.ts'
|
||||
|
|
@ -20,38 +18,12 @@ function mockTable(tsv: string): D2Table {
|
|||
return parseTable(encoder.encode(tsv.trim()))
|
||||
}
|
||||
|
||||
const ACT_SCENE_SOURCE = readFileSync(new URL('../src/client/world/act-runtime.ts', import.meta.url), 'utf8')
|
||||
|
||||
/**
|
||||
* Comments are stripped before scanning: the docstrings deliberately still name
|
||||
* the deleted fallback to explain why it is gone, and that is not a code path.
|
||||
*/
|
||||
const ACT_SCENE_CODE = ACT_SCENE_SOURCE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')
|
||||
|
||||
describe('no synthetic monster fallback', () => {
|
||||
it('demo-data no longer exports a monster roster', () => {
|
||||
expect(Object.keys(demoData)).not.toContain('DEMO_MONSTERS')
|
||||
// The other demo tables are still used by the scene's engine options.
|
||||
expect(Object.keys(demoData)).toContain('DEMO_SKILLS')
|
||||
})
|
||||
|
||||
it('the act scene has no placeholder spawn path left', () => {
|
||||
expect(ACT_SCENE_CODE).not.toContain('DEMO_MONSTERS')
|
||||
expect(ACT_SCENE_CODE).not.toContain('PLACEHOLDER_MONSTER_COUNT')
|
||||
// The spawn gate was an English substring match on the *display* name, so a
|
||||
// renamed or translated town spawned 12 fake monsters in the square. (The
|
||||
// pack index's `slug.includes('town')` level lookup is a different thing
|
||||
// and is still there.)
|
||||
expect(ACT_SCENE_CODE).not.toContain("toLowerCase().includes('town')")
|
||||
expect(ACT_SCENE_CODE).not.toContain('spawnMonsters(')
|
||||
})
|
||||
|
||||
it('reports an empty plan rather than hiding it', () => {
|
||||
expect(monsterPlanStatusText(0)).toContain('本关未规划到怪物')
|
||||
expect(monsterPlanStatusText(0)).toContain('怪物 0')
|
||||
expect(monsterPlanStatusText(37)).toBe(' · 怪物 37')
|
||||
})
|
||||
|
||||
it('includes superunique boss and minion monster IDs in PlannedLevel.types', () => {
|
||||
const levelsTxt = mockTable([
|
||||
'Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3',
|
||||
|
|
|
|||
|
|
@ -1,141 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { ADDITIVE_OBJECT_TOKENS, type ObjectDrawable } from '../src/client/world/act-runtime.ts'
|
||||
import {
|
||||
buildObjectDrawables,
|
||||
executeDepthDrawDispatch,
|
||||
MockRenderer,
|
||||
type PackObjectInput,
|
||||
type SimulatedWall,
|
||||
} from '../tools/verify-scene-object-dispatch.ts'
|
||||
|
||||
describe('Scene Object Rendering Dispatch (Milestone 1 / R1)', () => {
|
||||
it('correctly maps all 1.13c additive tokens to blendMode: "additive"', () => {
|
||||
const tokens = ['11', '12', 'R1', 'R2', 'R3', 'R4', 'R5', '1R', '2R', '3R', '4R', 'HA']
|
||||
for (const token of tokens) {
|
||||
expect(ADDITIVE_OBJECT_TOKENS.has(token)).toBe(true)
|
||||
|
||||
const drawables = buildObjectDrawables([
|
||||
{
|
||||
x: 100,
|
||||
y: 200,
|
||||
token,
|
||||
frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 },
|
||||
},
|
||||
])
|
||||
expect(drawables).toHaveLength(1)
|
||||
expect(drawables[0]!.blendMode).toBe('additive')
|
||||
}
|
||||
})
|
||||
|
||||
it('correctly identifies trans=7 and trans=1 objects as additive', () => {
|
||||
const drawables = buildObjectDrawables([
|
||||
{
|
||||
x: 10,
|
||||
y: 20,
|
||||
token: 'UNKNOWN',
|
||||
trans: 7,
|
||||
frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 },
|
||||
},
|
||||
{
|
||||
x: 30,
|
||||
y: 40,
|
||||
token: 'OTHER',
|
||||
trans: 1,
|
||||
frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 },
|
||||
},
|
||||
])
|
||||
expect(drawables).toHaveLength(2)
|
||||
expect(drawables[0]!.blendMode).toBe('additive')
|
||||
expect(drawables[1]!.blendMode).toBe('additive')
|
||||
})
|
||||
|
||||
it('preserves blendMode: "normal" for regular objects and torches', () => {
|
||||
const drawables = buildObjectDrawables([
|
||||
{
|
||||
x: 10,
|
||||
y: 20,
|
||||
token: 'CHEST',
|
||||
trans: 0,
|
||||
frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 },
|
||||
},
|
||||
{
|
||||
x: 30,
|
||||
y: 40,
|
||||
token: 'TO',
|
||||
trans: 0,
|
||||
frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 },
|
||||
},
|
||||
])
|
||||
expect(drawables).toHaveLength(2)
|
||||
expect(drawables[0]!.blendMode).toBe('normal')
|
||||
expect(drawables[1]!.blendMode).toBe('normal')
|
||||
})
|
||||
|
||||
it('dispatches additive objects with blendMode: "additive" and regular objects with "normal"', () => {
|
||||
const rawObjects: PackObjectInput[] = [
|
||||
{ id: 1, token: '11', depth: 2, frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 10, y: 0 },
|
||||
{ id: 2, token: 'BOX', depth: 6, frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 30, y: 0 },
|
||||
]
|
||||
const objs = buildObjectDrawables(rawObjects)
|
||||
|
||||
const walls: SimulatedWall[] = [
|
||||
{ frame: { x: 0, y: 0, width: 32, height: 32, _depth: 4 } as any, x: 0, y: 0, cellX: 2, cellY: 2, page: 0 },
|
||||
]
|
||||
|
||||
const renderer = new MockRenderer()
|
||||
executeDepthDrawDispatch(renderer, {
|
||||
walls,
|
||||
objs,
|
||||
maxDepthSteps: [Infinity],
|
||||
})
|
||||
|
||||
expect(renderer.draws).toHaveLength(3)
|
||||
|
||||
// Draw 0: Object 1 (Token 11, depth 2) -> additive
|
||||
expect(renderer.draws[0]!.target).toBe('object')
|
||||
expect(renderer.draws[0]!.depth).toBe(2)
|
||||
expect(renderer.draws[0]!.blendMode).toBe('additive')
|
||||
|
||||
// Draw 1: Wall (depth 4) -> blendMode undefined (options has { atlas })
|
||||
expect(renderer.draws[1]!.target).toBe('wall')
|
||||
expect(renderer.draws[1]!.depth).toBe(4)
|
||||
expect(renderer.draws[1]!.blendMode).toBeUndefined()
|
||||
|
||||
// Draw 2: Object 2 (BOX, depth 6) -> normal
|
||||
expect(renderer.draws[2]!.target).toBe('object')
|
||||
expect(renderer.draws[2]!.depth).toBe(6)
|
||||
expect(renderer.draws[2]!.blendMode).toBe('normal')
|
||||
})
|
||||
|
||||
it('strictly preserves depth ordering across mixed walls, objects, and incremental depth steps', () => {
|
||||
const rawObjects: PackObjectInput[] = [
|
||||
{ id: 1, depth: 3, token: '11', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 15, y: 0 },
|
||||
{ id: 2, depth: 8, token: '12', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 40, y: 0 },
|
||||
{ id: 3, depth: 15, token: 'CHEST', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 75, y: 0 },
|
||||
]
|
||||
const objs = buildObjectDrawables(rawObjects)
|
||||
|
||||
const walls: SimulatedWall[] = [
|
||||
{ frame: { x: 0, y: 0, width: 32, height: 32, _depth: 1 } as any, x: 0, y: 0, cellX: 0, cellY: 1, page: 0 },
|
||||
{ frame: { x: 0, y: 0, width: 32, height: 32, _depth: 5 } as any, x: 0, y: 0, cellX: 2, cellY: 3, page: 0 },
|
||||
{ frame: { x: 0, y: 0, width: 32, height: 32, _depth: 10 } as any, x: 0, y: 0, cellX: 5, cellY: 5, page: 0 },
|
||||
]
|
||||
|
||||
const renderer = new MockRenderer()
|
||||
executeDepthDrawDispatch(renderer, {
|
||||
walls,
|
||||
objs,
|
||||
maxDepthSteps: [4, 9, Infinity],
|
||||
})
|
||||
|
||||
expect(renderer.draws).toHaveLength(6)
|
||||
|
||||
// Verify non-decreasing order
|
||||
for (let i = 1; i < renderer.draws.length; i++) {
|
||||
expect(renderer.draws[i]!.depth).toBeGreaterThanOrEqual(renderer.draws[i - 1]!.depth)
|
||||
}
|
||||
|
||||
const depths = renderer.draws.map(d => d.depth)
|
||||
expect(depths).toEqual([1, 3, 5, 8, 10, 15])
|
||||
})
|
||||
})
|
||||
|
|
@ -5,21 +5,18 @@
|
|||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
DEFAULT_SORCERESS_SKILLS,
|
||||
resolveSkillIcon,
|
||||
getAvailableSkillsForSide,
|
||||
} from '../src/client/ui/hotkeys.ts'
|
||||
import { resolveSkillIcon, getAvailableSkillsForSide } from '../src/client/ui/hotkeys.ts'
|
||||
import { createDemoHotkeys } from './fixtures/hud-demo-state.ts'
|
||||
import { getAttackWeaponInfo, checkAmmunition, getSkillRange, type AttackWeaponInputItem } from '../src/client/world/act-runtime.ts'
|
||||
import { getAttackWeaponInfo, checkAmmunition, getSkillRange, type AttackWeaponInputItem } from '../src/common/skills/weapon-combat-meta.ts'
|
||||
import { castSkill } from '../src/server/scene/skill-caster.ts'
|
||||
import { GameEngine, type GameEngineOptions } from '../src/server/engine/engine.ts'
|
||||
import type { Monster } from '../src/server/engine/combat.ts'
|
||||
import { DEMO_SORCERESS_SKILLS } from './fixtures/demo-profile-data.ts'
|
||||
|
||||
describe('Issue #151: Normal Attack Icon Completion & Dual Slot Binding', () => {
|
||||
describe('Skill 0 Definition & Dual-Palette Usability', () => {
|
||||
it('defines Skill 0 Attack with leftUsable: true and rightUsable: true', () => {
|
||||
const attackSkill = DEFAULT_SORCERESS_SKILLS.find((s) => s.skillId === 0)
|
||||
const attackSkill = DEMO_SORCERESS_SKILLS.find((s) => s.skillId === 0)
|
||||
expect(attackSkill).toBeDefined()
|
||||
expect(attackSkill!.name).toBe('Attack')
|
||||
expect(attackSkill!.nameZh).toBe('普通攻击')
|
||||
|
|
@ -29,8 +26,8 @@ describe('Issue #151: Normal Attack Icon Completion & Dual Slot Binding', () =>
|
|||
})
|
||||
|
||||
it('includes Skill 0 in both left and right available skill palettes', () => {
|
||||
const leftSkills = getAvailableSkillsForSide('left', DEFAULT_SORCERESS_SKILLS)
|
||||
const rightSkills = getAvailableSkillsForSide('right', DEFAULT_SORCERESS_SKILLS)
|
||||
const leftSkills = getAvailableSkillsForSide('left', DEMO_SORCERESS_SKILLS)
|
||||
const rightSkills = getAvailableSkillsForSide('right', DEMO_SORCERESS_SKILLS)
|
||||
|
||||
const leftAttack = leftSkills.find((s) => s.skillId === 0)
|
||||
const rightAttack = rightSkills.find((s) => s.skillId === 0)
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import { getSharedDataRegistry } from '../src/baker/mpq-archive-source.ts'
|
|||
import { UnitStatList } from '../src/server/engine/stat-list.ts'
|
||||
import { StateBus } from '../src/server/engine/state-bus.ts'
|
||||
import type { CombatUnitContext } from '../src/server/engine/combat-pipeline.ts'
|
||||
import { drawMissileProjectile } from '../src/client/world/act-runtime.ts'
|
||||
import { drawMissileProjectile } from '../src/client/scene/missile-overlay-renderer.ts'
|
||||
|
||||
describe('[Skill #059] Blizzard — Empirical Stress & Physics Challenger Suite', () => {
|
||||
const openTerrain = { overlap: () => 0 }
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import {
|
|||
type OverlayTxtData,
|
||||
castSkill,
|
||||
} from '../src/server/skills/skills.ts'
|
||||
import { drawCastOverlay, loadOverlayArtMap, type LoadedOverlayArt } from '../src/client/world/act-runtime.ts'
|
||||
import { drawCastOverlay, loadOverlayArtMap, type LoadedOverlayArt } from '../src/client/scene/missile-overlay-renderer.ts'
|
||||
import { GameEngine } from '../src/server/engine/engine.ts'
|
||||
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.ts'
|
||||
import { SpriteRenderer, type AtlasHandle } from '../src/client/render/renderer.ts'
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import {
|
|||
computeEffectivePlayers,
|
||||
computeScaledNoDrop,
|
||||
} from '../src/common/items/treasure-engine.ts'
|
||||
import { handleActSceneChatCommand } from '../src/client/world/act-runtime.ts'
|
||||
import { handleActSceneChatCommand } from '../src/common/world/chat-commands.ts'
|
||||
import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts'
|
||||
|
||||
const dropTables = getEmbeddedDropTables()
|
||||
|
|
|
|||
|
|
@ -4,12 +4,11 @@
|
|||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { KeyboardInput } from '../src/client/sim/input.ts'
|
||||
import { HudManager } from '../src/client/ui/hud-manager.ts'
|
||||
import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/server/engine/engine.ts'
|
||||
import { ViewportCamera } from '../src/client/sim/camera.ts'
|
||||
import { WaypointNetwork } from '../src/common/world/portal.ts'
|
||||
import { SceneMouseController, screenToWorld, facingOf, projectNpcLabel, getSkillRange, getSkillMana, type MapRuntime } from '../src/client/world/act-runtime.ts'
|
||||
import { screenToWorld, facingOf } from '../src/common/world/scene-links-nav.ts'
|
||||
import { getSkillRange, getSkillMana } from '../src/common/skills/weapon-combat-meta.ts'
|
||||
|
||||
class MockWindow extends EventTarget {
|
||||
innerWidth = 1920
|
||||
|
|
@ -78,220 +77,6 @@ globalThis.HTMLInputElement = class HTMLInputElement {}
|
|||
// @ts-ignore
|
||||
globalThis.HTMLSelectElement = class HTMLSelectElement {}
|
||||
|
||||
function createChallengerScene(options?: {
|
||||
playerPos?: { x: number; y: number }
|
||||
mana?: number
|
||||
leftSkillId?: number
|
||||
rightSkillId?: number
|
||||
npcs?: Array<{ id: number; name: string; x: number; y: number }>
|
||||
waypoints?: Array<{ waypointId: number; x: number; y: number }>
|
||||
monsters?: Array<{ id: string; name: string; x: number; y: number; hp: number }>
|
||||
blockedOverlap?: (x: number, y: number) => number
|
||||
canvasRect?: { left: number; top: number; width: number; height: number }
|
||||
canvasSize?: { width: number; height: number }
|
||||
cameraZoom?: number
|
||||
}) {
|
||||
const canvas = new MockCanvas() as unknown as HTMLCanvasElement & { rect: { left: number; top: number; width: number; height: number } }
|
||||
if (options?.canvasRect) {
|
||||
canvas.rect = options.canvasRect
|
||||
canvas.clientWidth = options.canvasRect.width
|
||||
canvas.clientHeight = options.canvasRect.height
|
||||
}
|
||||
if (options?.canvasSize) {
|
||||
canvas.width = options.canvasSize.width
|
||||
canvas.height = options.canvasSize.height
|
||||
}
|
||||
|
||||
const win = new MockWindow()
|
||||
const input = new KeyboardInput(win as unknown as Window)
|
||||
input.attach()
|
||||
|
||||
const dummyTerrain: WorldMapProvider = {
|
||||
widthPx: 4000,
|
||||
heightPx: 4000,
|
||||
overlap: options?.blockedOverlap ?? (() => 0),
|
||||
}
|
||||
|
||||
const dummyOpts: GameEngineOptions = {
|
||||
spawn: options?.playerPos ?? { x: 1000, y: 1000 },
|
||||
stats: (options?.monsters ?? []).map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
hp: m.hp,
|
||||
damage: 10,
|
||||
cooldownTicks: 100,
|
||||
reach: 20,
|
||||
aggroRadius: 100,
|
||||
speed: 0,
|
||||
xp: 10,
|
||||
})),
|
||||
xpTable: [0, 0, 10, 30, 60],
|
||||
itemBases: [],
|
||||
prefixAffixes: [],
|
||||
suffixAffixes: [],
|
||||
skills: [{ id: 'attack', name: 'Attack', manaCost: 0, cooldownTicks: 1, range: 48, projectile: false, speed: 0, baseMinDamage: 10, baseMaxDamage: 15, damagePerLevel: 1, radius: 20 }],
|
||||
npcDefs: (options?.npcs ?? []).map(n => ({
|
||||
id: `npc-${n.id}`,
|
||||
name: n.name,
|
||||
questId: null,
|
||||
offerLines: ['Greetings, wanderer.'],
|
||||
progressLines: [],
|
||||
doneLines: [],
|
||||
})),
|
||||
questDefs: [],
|
||||
combatOptions: { playerSpeed: 100, playerReach: 52, playerCooldownTicks: 1, playerDamage: 10, playerManaPerAttack: 0, respawnTicks: 100 },
|
||||
lootSeed: 12345,
|
||||
talkRadius: 80,
|
||||
pickupRadius: 50,
|
||||
inventoryCols: 10,
|
||||
inventoryRows: 4,
|
||||
}
|
||||
|
||||
const engine = new GameEngine(dummyTerrain, dummyOpts)
|
||||
if (options?.monsters) {
|
||||
for (let i = 0; i < options.monsters.length; i++) {
|
||||
engine.world.monsters[i]!.x = options.monsters[i]!.x
|
||||
engine.world.monsters[i]!.y = options.monsters[i]!.y
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.npcs) {
|
||||
for (const n of options.npcs) {
|
||||
engine.npcEntities.push({
|
||||
def: {
|
||||
id: `npc-${n.id}`,
|
||||
name: n.name,
|
||||
questId: null,
|
||||
offerLines: ['Greetings, wanderer.'],
|
||||
progressLines: [],
|
||||
doneLines: [],
|
||||
},
|
||||
x: n.x,
|
||||
y: n.y,
|
||||
hasSprite: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const camera = new ViewportCamera(canvas, 4000, 4000)
|
||||
camera.zoom = options?.cameraZoom ?? 1
|
||||
|
||||
const hudManager = new HudManager(canvas, {
|
||||
onToggleAutomap: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
})
|
||||
hudManager.mana = options?.mana ?? 200
|
||||
hudManager.hotkeys.leftSkillId = options?.leftSkillId ?? 0
|
||||
hudManager.hotkeys.rightSkillId = options?.rightSkillId ?? 0
|
||||
hudManager.syncPublishedState()
|
||||
|
||||
const waypointNetwork = new WaypointNetwork()
|
||||
if (options?.waypoints) {
|
||||
for (const wp of options.waypoints) {
|
||||
waypointNetwork.register({
|
||||
waypointId: wp.waypointId,
|
||||
levelId: 1,
|
||||
act: 1,
|
||||
name: 'Test WP',
|
||||
x: wp.x,
|
||||
y: wp.y,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const gridWidth = 500
|
||||
const blockedGrid = new Uint8Array(500 * 500)
|
||||
const runtime: MapRuntime = {
|
||||
source: 'pack',
|
||||
base: 'samples/d2-packs',
|
||||
act: 1,
|
||||
level: 'Rogue Encampment',
|
||||
levelId: 1,
|
||||
variant: 'default',
|
||||
variants: ['default'],
|
||||
quadrant: 'default',
|
||||
quadrants: ['default'],
|
||||
cellsX: 100,
|
||||
cellsY: 100,
|
||||
widthPx: 4000,
|
||||
heightPx: 4000,
|
||||
floors: [],
|
||||
walls: [],
|
||||
roofs: [],
|
||||
objectDrawables: [],
|
||||
pages: [],
|
||||
priorityPages: [],
|
||||
objectPages: [],
|
||||
grid: {
|
||||
originX: 0,
|
||||
originY: 0,
|
||||
cellsX: 100,
|
||||
cellsY: 100,
|
||||
blocked: blockedGrid,
|
||||
gridWidth,
|
||||
},
|
||||
npcs: (options?.npcs ?? []).map(n => ({
|
||||
id: n.id,
|
||||
type: 1,
|
||||
name: n.name,
|
||||
x: n.x,
|
||||
y: n.y,
|
||||
})),
|
||||
entrances: [],
|
||||
warps: [],
|
||||
waypoints: (options?.waypoints ?? []).map(wp => {
|
||||
const dx = wp.x / 16
|
||||
const dy = wp.y / 8
|
||||
const subX = Math.round((dy + dx) / 2)
|
||||
const subY = Math.round((dy - dx) / 2)
|
||||
return {
|
||||
source: 'placed' as const,
|
||||
waypointId: wp.waypointId,
|
||||
arriveX: subX,
|
||||
arriveY: subY,
|
||||
x: subX,
|
||||
y: subY,
|
||||
}
|
||||
}),
|
||||
animatedDrawables: [],
|
||||
animSpeed: 1,
|
||||
frameDurationMs: 40,
|
||||
palette: null,
|
||||
charBases: [],
|
||||
monsterTypes: [],
|
||||
monsterPacks: [],
|
||||
entryPath: '',
|
||||
loadPages: async () => {},
|
||||
} as unknown as MapRuntime
|
||||
|
||||
const status = { textContent: '' } as HTMLElement
|
||||
|
||||
const controller = new SceneMouseController({
|
||||
canvas,
|
||||
engine,
|
||||
camera,
|
||||
input,
|
||||
getRuntime: () => runtime,
|
||||
hudManager,
|
||||
waypointNetwork,
|
||||
status,
|
||||
})
|
||||
controller.attach()
|
||||
|
||||
return {
|
||||
canvas,
|
||||
win,
|
||||
input,
|
||||
engine,
|
||||
camera,
|
||||
hudManager,
|
||||
waypointNetwork,
|
||||
runtime,
|
||||
status,
|
||||
controller,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Challenger Stress Tests: Milestone 2 Left-Click & Shift Controls', () => {
|
||||
describe('1. screenToWorld projection with varying camera zoom levels and DPRs', () => {
|
||||
it('exact round-trip invertibility across zoom levels [0.4x..8.0x] and DPRs [1.0..4.0]', () => {
|
||||
|
|
@ -379,526 +164,5 @@ describe('Challenger Stress Tests: Milestone 2 Left-Click & Shift Controls', ()
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('consistency between screenToWorld and projectNpcLabel', () => {
|
||||
const rect = { left: 0, top: 0, width: 1920, height: 1080 }
|
||||
const canvas = { width: 3840, height: 2160 } // DPR 2.0
|
||||
const camX = 1000
|
||||
const camY = 1000
|
||||
const zoom = 1.75
|
||||
|
||||
const npc = { x: 1150, y: 920, frame: null }
|
||||
const projection = projectNpcLabel({
|
||||
npc,
|
||||
camX,
|
||||
camY,
|
||||
cameraZoom: zoom,
|
||||
canvas,
|
||||
rect,
|
||||
markerHeight: 0,
|
||||
})
|
||||
|
||||
// unproject the projected dom position
|
||||
const worldBack = screenToWorld({
|
||||
clientX: projection.domX,
|
||||
clientY: projection.domY + 2, // projectNpcLabel offsets domY by -2
|
||||
rect,
|
||||
canvas,
|
||||
camX,
|
||||
camY,
|
||||
cameraZoom: zoom,
|
||||
})
|
||||
|
||||
expect(worldBack.x).toBeCloseTo(npc.x, 3)
|
||||
expect(worldBack.y).toBeCloseTo(npc.y, 3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('2. Ground Click-to-Move: navTarget, player movement, arrival threshold <= 8px', () => {
|
||||
it('clicking on ground sets navTarget and produces normalized directional movement vector', () => {
|
||||
const scene = createChallengerScene({ playerPos: { x: 1000, y: 1000 } })
|
||||
|
||||
// Click ground at world (1100, 1000) -> 100px East
|
||||
// Camera is centered at (player.x, player.y - 16) = (1000, 984)
|
||||
// client center is (960, 540). (1100, 1000) corresponds to clientX = 960 + 100 = 1060, clientY = 540 + 16 = 556
|
||||
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
||||
clientX: 1060,
|
||||
clientY: 556,
|
||||
button: 0,
|
||||
}))
|
||||
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', {
|
||||
clientX: 1060,
|
||||
clientY: 556,
|
||||
button: 0,
|
||||
}))
|
||||
|
||||
expect(scene.controller.navTarget).not.toBeNull()
|
||||
expect(scene.controller.navTarget!.x).toBeCloseTo(1100, 1)
|
||||
expect(scene.controller.navTarget!.y).toBeCloseTo(1000, 1)
|
||||
|
||||
const tickResult = scene.controller.tick()
|
||||
expect(tickResult.movement.x).toBeGreaterThan(0.99)
|
||||
expect(tickResult.movement.y).toBeCloseTo(0, 2)
|
||||
expect(tickResult.attacking).toBe(false)
|
||||
})
|
||||
|
||||
it('strict arrival threshold <= 8px boundary check', () => {
|
||||
const scene = createChallengerScene({ playerPos: { x: 1000, y: 1000 } })
|
||||
scene.controller.navTarget = { x: 1000, y: 1000 }
|
||||
|
||||
// Test distance exactly 8.000px: MUST stop and clear navTarget
|
||||
scene.controller.navTarget = { x: 1008, y: 1000 }
|
||||
let tick = scene.controller.tick()
|
||||
expect(tick.movement).toEqual({ x: 0, y: 0 })
|
||||
expect(scene.controller.navTarget).toBeNull()
|
||||
|
||||
// Test distance 7.5px (< 8px): MUST stop
|
||||
scene.controller.navTarget = { x: 1000, y: 1007.5 }
|
||||
tick = scene.controller.tick()
|
||||
expect(tick.movement).toEqual({ x: 0, y: 0 })
|
||||
expect(scene.controller.navTarget).toBeNull()
|
||||
|
||||
// Test distance 8.05px (> 8px): MUST continue moving
|
||||
scene.controller.navTarget = { x: 1008.05, y: 1000 }
|
||||
tick = scene.controller.tick()
|
||||
expect(tick.movement.x).toBeGreaterThan(0)
|
||||
expect(scene.controller.navTarget).not.toBeNull()
|
||||
})
|
||||
|
||||
it('continuous drag updates navTarget with cursor coordinates on pointermove', () => {
|
||||
const scene = createChallengerScene({ playerPos: { x: 1000, y: 1000 } })
|
||||
|
||||
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
||||
clientX: 1060,
|
||||
clientY: 556,
|
||||
button: 0,
|
||||
}))
|
||||
expect(scene.controller.isLeftMouseDown).toBe(true)
|
||||
|
||||
// Move cursor across multiple positions
|
||||
const positions = [
|
||||
{ clientX: 1100, clientY: 556 },
|
||||
{ clientX: 1200, clientY: 600 },
|
||||
{ clientX: 900, clientY: 400 },
|
||||
]
|
||||
|
||||
for (const pos of positions) {
|
||||
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', {
|
||||
clientX: pos.clientX,
|
||||
clientY: pos.clientY,
|
||||
button: 0,
|
||||
}))
|
||||
const expectedWorld = scene.controller.getPointerWorldCoords(pos.clientX, pos.clientY)
|
||||
expect(scene.controller.navTarget!.x).toBeCloseTo(expectedWorld.x, 3)
|
||||
expect(scene.controller.navTarget!.y).toBeCloseTo(expectedWorld.y, 3)
|
||||
}
|
||||
|
||||
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', {
|
||||
clientX: 900,
|
||||
clientY: 400,
|
||||
button: 0,
|
||||
}))
|
||||
expect(scene.controller.isLeftMouseDown).toBe(false)
|
||||
})
|
||||
|
||||
it('running mode applies 1.5x runFactor to movement vector', () => {
|
||||
const scene = createChallengerScene({ playerPos: { x: 1000, y: 1000 } })
|
||||
scene.controller.navTarget = { x: 1100, y: 1000 } // 100px East
|
||||
|
||||
// Walk mode
|
||||
scene.hudManager.isRunning = false
|
||||
const walkTick = scene.controller.tick()
|
||||
expect(walkTick.movement.x).toBeCloseTo(1.0, 3)
|
||||
|
||||
// Run mode
|
||||
scene.hudManager.isRunning = true
|
||||
const runTick = scene.controller.tick()
|
||||
expect(runTick.movement.x).toBeCloseTo(1.5, 3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. NPC interaction: immediate dialogue (<= 80px) vs pathing and arrival (> 80px)', () => {
|
||||
it('immediate dialogue when clicking NPC at distance <= 80px without moving', () => {
|
||||
// NPC placed at (1050, 1000) -> distance 50px <= 80px
|
||||
const scene = createChallengerScene({
|
||||
playerPos: { x: 1000, y: 1000 },
|
||||
npcs: [{ id: 1, name: 'Akara', x: 1050, y: 1000 }],
|
||||
})
|
||||
|
||||
// Click near Akara's feet (1050, 1000)
|
||||
const clientCoords = {
|
||||
clientX: 960 + 50,
|
||||
clientY: 540 + 16,
|
||||
}
|
||||
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
||||
clientX: clientCoords.clientX,
|
||||
clientY: clientCoords.clientY,
|
||||
button: 0,
|
||||
}))
|
||||
|
||||
// Must trigger dialogue immediately
|
||||
expect(scene.controller.navTarget).toBeNull()
|
||||
expect(scene.controller.pendingInteraction).toBeNull()
|
||||
expect(scene.status.textContent).toContain('Akara')
|
||||
expect(scene.engine.dialog).not.toBeNull()
|
||||
expect(scene.engine.world.player.facing).toBe(6) // Facing East
|
||||
expect(scene.controller.tick().movement).toEqual({ x: 0, y: 0 })
|
||||
})
|
||||
|
||||
it('paths towards NPC when distance > 80px and opens dialogue on arrival', () => {
|
||||
// NPC placed at (1300, 1000) -> distance 300px > 80px
|
||||
const scene = createChallengerScene({
|
||||
playerPos: { x: 1000, y: 1000 },
|
||||
npcs: [{ id: 2, name: 'Warriv', x: 1300, y: 1000 }],
|
||||
})
|
||||
|
||||
const clientCoords = {
|
||||
clientX: 960 + 300,
|
||||
clientY: 540 + 16,
|
||||
}
|
||||
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
||||
clientX: clientCoords.clientX,
|
||||
clientY: clientCoords.clientY,
|
||||
button: 0,
|
||||
}))
|
||||
|
||||
// Must set pendingInteraction and navTarget
|
||||
expect(scene.controller.pendingInteraction).toEqual({
|
||||
kind: 'npc',
|
||||
npc: expect.objectContaining({ x: 1300, y: 1000 }),
|
||||
})
|
||||
expect(scene.controller.navTarget).toEqual({ x: 1300, y: 1000 })
|
||||
|
||||
// Tick moves towards Warriv
|
||||
const tick1 = scene.controller.tick()
|
||||
expect(tick1.movement.x).toBeGreaterThan(0)
|
||||
expect(scene.engine.dialog).toBeNull()
|
||||
|
||||
// Step player closer to distance 80px: x = 1220 (dist = 80px)
|
||||
scene.engine.world.player.x = 1220
|
||||
const arrivalTick = scene.controller.tick()
|
||||
|
||||
expect(arrivalTick.movement).toEqual({ x: 0, y: 0 })
|
||||
expect(scene.controller.pendingInteraction).toBeNull()
|
||||
expect(scene.controller.navTarget).toBeNull()
|
||||
expect(scene.status.textContent).toContain('Warriv')
|
||||
expect(scene.engine.dialog).not.toBeNull()
|
||||
expect(scene.engine.world.player.facing).toBe(6) // Facing East
|
||||
})
|
||||
|
||||
it('selects closest NPC when multiple NPCs are nearby', () => {
|
||||
const scene = createChallengerScene({
|
||||
playerPos: { x: 1000, y: 1000 },
|
||||
npcs: [
|
||||
{ id: 1, name: 'Akara', x: 1030, y: 1000 }, // 30px away
|
||||
{ id: 2, name: 'Charsi', x: 1060, y: 1000 }, // 60px away
|
||||
],
|
||||
})
|
||||
|
||||
// Click on Akara (1030, 1000)
|
||||
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
||||
clientX: 960 + 30,
|
||||
clientY: 540 + 16,
|
||||
button: 0,
|
||||
}))
|
||||
|
||||
expect(scene.status.textContent).toContain('Akara')
|
||||
expect(scene.status.textContent).not.toContain('Charsi')
|
||||
})
|
||||
})
|
||||
|
||||
describe('4. Waypoint interaction: immediate activation (<= 80px) vs pathing and arrival (> 80px)', () => {
|
||||
it('immediate activation and panel opening when clicking Waypoint at distance <= 80px', () => {
|
||||
const scene = createChallengerScene({
|
||||
playerPos: { x: 1000, y: 1000 },
|
||||
waypoints: [{ waypointId: 1, x: 1040, y: 1000 }], // 40px away
|
||||
})
|
||||
|
||||
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
||||
clientX: 960 + 40,
|
||||
clientY: 540 + 16,
|
||||
button: 0,
|
||||
}))
|
||||
|
||||
expect(scene.controller.navTarget).toBeNull()
|
||||
expect(scene.controller.pendingInteraction).toBeNull()
|
||||
expect(scene.waypointNetwork.destinations().some(d => d.waypointId === 1)).toBe(true)
|
||||
expect(scene.hudManager.leftPanel).toBe('waypoint')
|
||||
expect(scene.controller.tick().movement).toEqual({ x: 0, y: 0 })
|
||||
})
|
||||
|
||||
it('paths towards Waypoint when distance > 80px and activates upon arrival', () => {
|
||||
const scene = createChallengerScene({
|
||||
playerPos: { x: 1000, y: 1000 },
|
||||
waypoints: [{ waypointId: 2, x: 1400, y: 1000 }], // 400px away
|
||||
})
|
||||
|
||||
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
||||
clientX: 960 + 400,
|
||||
clientY: 540 + 16,
|
||||
button: 0,
|
||||
}))
|
||||
|
||||
expect(scene.controller.pendingInteraction?.kind).toBe('waypoint')
|
||||
expect(scene.controller.navTarget).not.toBeNull()
|
||||
expect(scene.hudManager.leftPanel).toBe('none')
|
||||
|
||||
// Tick moves towards waypoint
|
||||
const walkTick = scene.controller.tick()
|
||||
expect(walkTick.movement.x).toBeGreaterThan(0)
|
||||
|
||||
// Move player within 80px: x = 1330 (dist = 70px)
|
||||
scene.engine.world.player.x = 1330
|
||||
const arriveTick = scene.controller.tick()
|
||||
|
||||
expect(arriveTick.movement).toEqual({ x: 0, y: 0 })
|
||||
expect(scene.controller.pendingInteraction).toBeNull()
|
||||
expect(scene.controller.navTarget).toBeNull()
|
||||
expect(scene.waypointNetwork.destinations().some(d => d.waypointId === 2)).toBe(true)
|
||||
expect(scene.hudManager.leftPanel).toBe('waypoint')
|
||||
})
|
||||
})
|
||||
|
||||
describe('5. Monster targeting: melee reach 52px vs spell range, approaches and attacks', () => {
|
||||
it('melee attack (skill 0): attacks immediately at distance <= 52px without pathing', () => {
|
||||
const scene = createChallengerScene({
|
||||
playerPos: { x: 1000, y: 1000 },
|
||||
leftSkillId: 0,
|
||||
monsters: [{ id: 'mon1', name: 'Fallen', x: 1040, y: 1000, hp: 50 }], // 40px <= 52px
|
||||
})
|
||||
|
||||
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
||||
clientX: 960 + 40,
|
||||
clientY: 540 + 16,
|
||||
button: 0,
|
||||
}))
|
||||
|
||||
// Attacks immediately
|
||||
expect(scene.controller.navTarget).toBeNull()
|
||||
expect(scene.controller.pendingInteraction).toBeNull()
|
||||
expect(scene.engine.world.monsters[0]!.hp).toBeLessThan(50)
|
||||
expect(scene.engine.metrics.playerHits).toBe(1)
|
||||
expect(scene.engine.world.player.cooldown).toBe(12)
|
||||
expect(scene.controller.tick().movement).toEqual({ x: 0, y: 0 })
|
||||
})
|
||||
|
||||
it('melee attack (skill 0): paths to monster when distance > 52px and attacks on arrival', () => {
|
||||
const scene = createChallengerScene({
|
||||
playerPos: { x: 1000, y: 1000 },
|
||||
leftSkillId: 0,
|
||||
monsters: [{ id: 'mon2', name: 'Zombie', x: 1200, y: 1000, hp: 80 }], // 200px > 52px
|
||||
})
|
||||
|
||||
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
||||
clientX: 960 + 200,
|
||||
clientY: 540 + 16,
|
||||
button: 0,
|
||||
}))
|
||||
|
||||
expect(scene.controller.pendingInteraction?.kind).toBe('monster')
|
||||
expect(scene.controller.navTarget).toEqual({ x: 1200, y: 1000 })
|
||||
expect(scene.engine.world.monsters[0]!.hp).toBe(80)
|
||||
|
||||
// Move player into reach: x = 1160 (dist = 40px <= 52px)
|
||||
scene.engine.world.player.x = 1160
|
||||
scene.engine.world.player.cooldown = 0
|
||||
const attackTick = scene.controller.tick()
|
||||
|
||||
expect(attackTick.movement).toEqual({ x: 0, y: 0 })
|
||||
expect(scene.controller.pendingInteraction).toBeNull()
|
||||
expect(scene.controller.navTarget).toBeNull()
|
||||
expect(scene.engine.world.monsters[0]!.hp).toBeLessThan(80)
|
||||
expect(scene.engine.metrics.playerHits).toBe(1)
|
||||
})
|
||||
|
||||
it('spell attack (skill 47 Fire Ball): casts from range (<= 450px) without pathing', () => {
|
||||
const scene = createChallengerScene({
|
||||
playerPos: { x: 1000, y: 1000 },
|
||||
leftSkillId: 47, // Fire Ball, range 450
|
||||
mana: 100,
|
||||
monsters: [{ id: 'mon3', name: 'Gargoyle', x: 1350, y: 1000, hp: 120 }], // 350px <= 450px
|
||||
})
|
||||
|
||||
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
||||
clientX: 960 + 350,
|
||||
clientY: 540 + 16,
|
||||
button: 0,
|
||||
}))
|
||||
|
||||
// Casts immediately from 350px away: spawns projectile, deducts mana, no pathing
|
||||
expect(scene.controller.navTarget).toBeNull()
|
||||
expect(scene.controller.pendingInteraction).toBeNull()
|
||||
expect(scene.engine.projectiles.length).toBe(1)
|
||||
expect(scene.engine.projectiles[0]!.skillId).toBe('47')
|
||||
expect(scene.hudManager.mana).toBeLessThan(100)
|
||||
expect(scene.controller.tick().movement).toEqual({ x: 0, y: 0 })
|
||||
})
|
||||
|
||||
it('spell attack: paths when monster is beyond spell range (> 450px)', () => {
|
||||
const scene = createChallengerScene({
|
||||
playerPos: { x: 1000, y: 1000 },
|
||||
leftSkillId: 47,
|
||||
mana: 100,
|
||||
monsters: [{ id: 'mon4', name: 'Skeleton', x: 1600, y: 1000, hp: 100 }], // 600px > 450px
|
||||
})
|
||||
|
||||
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
||||
clientX: 960 + 600,
|
||||
clientY: 540 + 16,
|
||||
button: 0,
|
||||
}))
|
||||
|
||||
expect(scene.controller.pendingInteraction?.kind).toBe('monster')
|
||||
expect(scene.controller.navTarget).toEqual({ x: 1600, y: 1000 })
|
||||
expect(scene.engine.projectiles.length).toBe(0)
|
||||
|
||||
// Move player into spell range: x = 1200 (dist = 400px <= 450px)
|
||||
scene.engine.world.player.x = 1200
|
||||
scene.engine.world.player.cooldown = 0
|
||||
const castTick = scene.controller.tick()
|
||||
|
||||
expect(castTick.movement).toEqual({ x: 0, y: 0 })
|
||||
expect(scene.controller.pendingInteraction).toBeNull()
|
||||
expect(scene.controller.navTarget).toBeNull()
|
||||
expect(scene.engine.projectiles.length).toBe(1)
|
||||
})
|
||||
|
||||
it('dead monsters cannot be targeted or clicked', () => {
|
||||
const scene = createChallengerScene({
|
||||
playerPos: { x: 1000, y: 1000 },
|
||||
monsters: [{ id: 'monDead', name: 'Dead Zombie', x: 1040, y: 1000, hp: 0 }],
|
||||
})
|
||||
scene.engine.world.monsters[0]!.state = 'dead'
|
||||
|
||||
// Click directly on dead monster
|
||||
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
||||
clientX: 960 + 40,
|
||||
clientY: 540 + 16,
|
||||
button: 0,
|
||||
}))
|
||||
|
||||
// Should be treated as ground click, NOT monster interaction
|
||||
expect(scene.controller.pendingInteraction).toBeNull()
|
||||
expect(scene.controller.navTarget).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('6. Shift + Left Click: forced {0,0} velocity, face cursor, execute attack without pathing', () => {
|
||||
it('Shift + Left Click forces velocity {0, 0}, faces cursor, and attacks without pathing', () => {
|
||||
const scene = createChallengerScene({
|
||||
playerPos: { x: 1000, y: 1000 },
|
||||
leftSkillId: 47, // Fire Ball
|
||||
mana: 100,
|
||||
})
|
||||
|
||||
// Hold Shift
|
||||
scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' }))
|
||||
expect(scene.input.shiftHeld).toBe(true)
|
||||
|
||||
// Click ground far away (1500, 1000) -> East
|
||||
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
||||
clientX: 960 + 500,
|
||||
clientY: 540 + 16,
|
||||
button: 0,
|
||||
shiftKey: true,
|
||||
}))
|
||||
|
||||
// Navigation target must remain null (stand still)
|
||||
expect(scene.controller.navTarget).toBeNull()
|
||||
expect(scene.controller.pendingInteraction).toBeNull()
|
||||
|
||||
// Player faces cursor (East = 6)
|
||||
expect(scene.engine.world.player.facing).toBe(6)
|
||||
|
||||
// Spell cast executed
|
||||
expect(scene.engine.projectiles.length).toBe(1)
|
||||
expect(scene.engine.projectiles[0]!.vx).toBeGreaterThan(0)
|
||||
expect(scene.hudManager.mana).toBeLessThan(100)
|
||||
|
||||
// Tick movement must be strictly {0, 0}
|
||||
const tick = scene.controller.tick()
|
||||
expect(tick.movement).toEqual({ x: 0, y: 0 })
|
||||
})
|
||||
|
||||
it('Shift + Left Click with Melee Attack (skill 0) swings in place without moving', () => {
|
||||
const scene = createChallengerScene({
|
||||
playerPos: { x: 1000, y: 1000 },
|
||||
leftSkillId: 0, // Melee attack
|
||||
})
|
||||
|
||||
scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftRight', key: 'Shift' }))
|
||||
expect(scene.input.shiftHeld).toBe(true)
|
||||
|
||||
// Click North-West
|
||||
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
||||
clientX: 960 - 200,
|
||||
clientY: 540 - 200,
|
||||
button: 0,
|
||||
shiftKey: true,
|
||||
}))
|
||||
|
||||
expect(scene.controller.navTarget).toBeNull()
|
||||
expect(scene.engine.world.player.facing).toBe(3) // North-West
|
||||
expect(scene.engine.world.player.cooldown).toBe(12)
|
||||
expect(scene.engine.world.player.swingTicks).toBe(6)
|
||||
expect(scene.controller.tick().movement).toEqual({ x: 0, y: 0 })
|
||||
})
|
||||
|
||||
it('continuous hold: player stays rooted in place across multiple frames while casting', () => {
|
||||
const scene = createChallengerScene({
|
||||
playerPos: { x: 1000, y: 1000 },
|
||||
leftSkillId: 47,
|
||||
mana: 300,
|
||||
})
|
||||
|
||||
scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' }))
|
||||
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
||||
clientX: 960 + 200,
|
||||
clientY: 540 + 16,
|
||||
button: 0,
|
||||
shiftKey: true,
|
||||
}))
|
||||
|
||||
// Run 30 consecutive ticks
|
||||
for (let t = 0; t < 30; t++) {
|
||||
scene.engine.world.player.cooldown = 0 // simulate cooldown completing
|
||||
const tick = scene.controller.tick()
|
||||
expect(tick.movement).toEqual({ x: 0, y: 0 })
|
||||
expect(scene.controller.navTarget).toBeNull()
|
||||
}
|
||||
|
||||
// Multiple projectiles fired while player never moved
|
||||
expect(scene.engine.projectiles.length).toBeGreaterThan(1)
|
||||
expect(scene.engine.world.player.x).toBe(1000)
|
||||
expect(scene.engine.world.player.y).toBe(1000)
|
||||
})
|
||||
|
||||
it('pressing Shift while navTarget is active must immediately arrest movement to {0,0}', () => {
|
||||
const scene = createChallengerScene({
|
||||
playerPos: { x: 1000, y: 1000 },
|
||||
leftSkillId: 47,
|
||||
})
|
||||
|
||||
// Ground click to start walking East
|
||||
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
||||
clientX: 960 + 200,
|
||||
clientY: 540 + 16,
|
||||
button: 0,
|
||||
}))
|
||||
expect(scene.controller.navTarget).not.toBeNull()
|
||||
|
||||
// First tick moves player East
|
||||
const tick1 = scene.controller.tick()
|
||||
expect(tick1.movement.x).toBeGreaterThan(0)
|
||||
|
||||
// Player presses Shift while left mouse is still down
|
||||
scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' }))
|
||||
expect(scene.input.shiftHeld).toBe(true)
|
||||
|
||||
// Next tick with Shift held MUST arrest movement to {0, 0}
|
||||
const tickWithShift = scene.controller.tick()
|
||||
expect(tickWithShift.movement).toEqual({ x: 0, y: 0 })
|
||||
expect(scene.controller.navTarget).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,16 +8,7 @@
|
|||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { drawGroundItem, GROUND_ITEM_QUALITY_COLORS } from '../src/client/world/act-runtime.ts'
|
||||
import { ActorAnimator } from '../src/client/render/actor-animator.ts'
|
||||
import {
|
||||
GROUND_LABEL_QUALITY_COLORS,
|
||||
getGroundItemQualityColor,
|
||||
isGroundItemEthereal,
|
||||
isGroundItemSocketed,
|
||||
isGroundItemEtherealOrSocketed,
|
||||
computeInitialGroundLabelLayouts,
|
||||
} from '../src/client/ui/ground-labels.ts'
|
||||
import {
|
||||
BeltHud,
|
||||
BELT_COLS,
|
||||
|
|
@ -75,133 +66,6 @@ describe('Challenger M4-1 — Adversarial & Boundary Stress Verification', () =>
|
|||
// 1. Issue #423: Flippy DC6 Coverage, Fail-Fast Throw & Frozen Death Anim
|
||||
// ============================================================================
|
||||
describe('Issue #423 — Exhaustive Flippy DC6 Coverage & Fail-Fast Rendering', () => {
|
||||
it('exhaustively resolves and renders every weapon, armor, and misc base item plus all gold tiers without procedural color boxes', () => {
|
||||
const tables = getEmbeddedDropTables()
|
||||
const allBases = [
|
||||
...tables.weapons.all,
|
||||
...tables.armor.all,
|
||||
...tables.misc.all,
|
||||
]
|
||||
expect(allBases.length).toBe(659)
|
||||
|
||||
const mgr = new GroundItemManager()
|
||||
|
||||
for (const base of allBases) {
|
||||
const sr = resolveGroundItemSpriteRect(
|
||||
{ code: base.code, name: base.name, flippyFile: (base as any).flippyfile, invFile: (base as any).invfile },
|
||||
BAKED_UI_MANIFEST.flippyRects,
|
||||
BAKED_UI_MANIFEST.codeToFlippyFile,
|
||||
)
|
||||
expect(sr, `Missing flippy rect for ${base.code} (${base.name})`).not.toBeNull()
|
||||
expect(sr!.w).toBeGreaterThan(0)
|
||||
expect(sr!.h).toBeGreaterThan(0)
|
||||
|
||||
const quads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
|
||||
const sprites: { frame: any; x: number; y: number; options: any }[] = []
|
||||
const renderer = {
|
||||
drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) {
|
||||
quads.push({ x, y, w, h, color })
|
||||
},
|
||||
draw(frame: any, x: number, y: number, options: any) {
|
||||
sprites.push({ frame, x, y, options })
|
||||
},
|
||||
} as unknown as SpriteRenderer
|
||||
|
||||
const entity = mgr.add(
|
||||
{ code: base.code, name: base.name, flippyFile: (base as any).flippyfile, invFile: (base as any).invfile },
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
300,
|
||||
)
|
||||
drawGroundItem(renderer, entity, 1000, undefined)
|
||||
expect(sprites.length).toBe(1)
|
||||
expect(sprites[0]!.frame.width).toBeGreaterThan(0)
|
||||
expect(sprites[0]!.frame.height).toBeGreaterThan(0)
|
||||
// Outside sparkle window (t=1000, cellX=0, cellY=0), only 1 ground shadow quad is drawn
|
||||
expect(quads.length).toBe(1)
|
||||
expect(quads[0]!.color[0]).toBe(0)
|
||||
expect(quads[0]!.color[1]).toBe(0)
|
||||
expect(quads[0]!.color[2]).toBe(0)
|
||||
}
|
||||
|
||||
// Gold boundary tiers: 1, 49 (small: 24x7), 50, 499 (medium: 26x13), 500, 10000 (large: 32x20)
|
||||
const goldCases = [
|
||||
{ amount: 1, w: 24, h: 7 },
|
||||
{ amount: 49, w: 24, h: 7 },
|
||||
{ amount: 50, w: 26, h: 13 },
|
||||
{ amount: 499, w: 26, h: 13 },
|
||||
{ amount: 500, w: 32, h: 20 },
|
||||
{ amount: 10000, w: 32, h: 20 },
|
||||
]
|
||||
for (const gc of goldCases) {
|
||||
const sr = resolveGroundItemSpriteRect(
|
||||
{ isGold: true, amount: gc.amount, code: 'gld' },
|
||||
BAKED_UI_MANIFEST.flippyRects,
|
||||
BAKED_UI_MANIFEST.codeToFlippyFile,
|
||||
)
|
||||
expect(sr).not.toBeNull()
|
||||
expect(sr!.w).toBe(gc.w)
|
||||
expect(sr!.h).toBe(gc.h)
|
||||
|
||||
const sprites: { frame: any; x: number; y: number; options: any }[] = []
|
||||
const renderer = {
|
||||
drawSolid() {},
|
||||
draw(frame: any, x: number, y: number, options: any) {
|
||||
sprites.push({ frame, x, y, options })
|
||||
},
|
||||
} as unknown as SpriteRenderer
|
||||
const goldEntity = mgr.add({ isGold: true, amount: gc.amount, code: 'gld' }, 0, 0, 200, 200, { amount: gc.amount })
|
||||
drawGroundItem(renderer, goldEntity, 1000, undefined)
|
||||
expect(sprites.length).toBe(1)
|
||||
expect(sprites[0]!.options.width).toBe(gc.w)
|
||||
expect(sprites[0]!.options.height).toBe(gc.h)
|
||||
}
|
||||
})
|
||||
|
||||
it('fails fast with an explicit Error in drawGroundItem when flippy sprite rect resolves to null', () => {
|
||||
// Temporarily empty flippyRects and itemRects to simulate a corrupt unmapped item with null sprite rect
|
||||
const savedFlippy = { ...BAKED_UI_MANIFEST.flippyRects }
|
||||
const savedItems = { ...BAKED_UI_MANIFEST.itemRects }
|
||||
try {
|
||||
for (const k of Object.keys(BAKED_UI_MANIFEST.flippyRects)) {
|
||||
delete (BAKED_UI_MANIFEST.flippyRects as any)[k]
|
||||
}
|
||||
for (const k of Object.keys(BAKED_UI_MANIFEST.itemRects)) {
|
||||
delete (BAKED_UI_MANIFEST.itemRects as any)[k]
|
||||
}
|
||||
|
||||
const renderer = {
|
||||
drawSolid() {},
|
||||
draw() {},
|
||||
} as unknown as SpriteRenderer
|
||||
|
||||
const corruptEntity: GroundItemEntity = {
|
||||
id: 'corrupt_999',
|
||||
item: { code: 'zzz', name: 'Corrupt Unknown Item' },
|
||||
name: 'Corrupt Unknown Item',
|
||||
nameZh: '损坏未知物品',
|
||||
quality: 'normal',
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
invWidth: 9,
|
||||
invHeight: 9,
|
||||
dropTime: 0,
|
||||
x: 100,
|
||||
y: 100,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
sparklePhase: 0,
|
||||
}
|
||||
|
||||
expect(() => drawGroundItem(renderer, corruptEntity, 1000, undefined)).toThrow(
|
||||
/GroundItem render failure: missing flippy sprite rect/,
|
||||
)
|
||||
} finally {
|
||||
Object.assign(BAKED_UI_MANIFEST.flippyRects, savedFlippy)
|
||||
Object.assign(BAKED_UI_MANIFEST.itemRects, savedItems)
|
||||
}
|
||||
})
|
||||
|
||||
it('advances death animation (DT -> DD) even when monster has frozenTicks > 0', () => {
|
||||
const animator = new ActorAnimator()
|
||||
|
|
@ -239,132 +103,6 @@ describe('Challenger M4-1 — Adversarial & Boundary Stress Verification', () =>
|
|||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 2. Issue #424: Ground Label Color Permutations & UI Round-Trip
|
||||
// ============================================================================
|
||||
describe('Issue #424 — Ground Label Color Full Cartesian Permutations & UI Round-Trip', () => {
|
||||
it('fuzzes all combinations of quality, ethereal, sockets, isRuneword, and isGold across getGroundItemQualityColor and computeInitialGroundLabelLayouts', () => {
|
||||
const qualities = ['low', 'normal', 'superior', 'magic', 'rare', 'set', 'unique', 'craft', 'rune'] as const
|
||||
const ethereals = [false, true] as const
|
||||
const socketCounts = [0, 1, 6] as const
|
||||
const runewords = [false, true] as const
|
||||
const goldFlags = [false, true] as const
|
||||
|
||||
const mgr = new GroundItemManager()
|
||||
let totalPermutations = 0
|
||||
|
||||
for (const quality of qualities) {
|
||||
for (const ethereal of ethereals) {
|
||||
for (const sockets of socketCounts) {
|
||||
for (const isRuneword of runewords) {
|
||||
for (const isGold of goldFlags) {
|
||||
totalPermutations += 1
|
||||
const entity: GroundItemEntity = {
|
||||
id: `perm_${totalPermutations}`,
|
||||
item: {
|
||||
code: isGold ? 'gld' : 'bsd',
|
||||
name: isGold ? '100 Gold' : 'Broad Sword',
|
||||
quality,
|
||||
ethereal,
|
||||
sockets,
|
||||
isRuneword,
|
||||
isGold,
|
||||
},
|
||||
name: isGold ? '100 Gold' : 'Broad Sword',
|
||||
nameZh: isGold ? '100 金币' : '阔剑',
|
||||
quality,
|
||||
isGold,
|
||||
amount: isGold ? 100 : 1,
|
||||
invWidth: 2,
|
||||
invHeight: 3,
|
||||
dropTime: 0,
|
||||
x: 200,
|
||||
y: 200,
|
||||
cellX: 5,
|
||||
cellY: 5,
|
||||
sparklePhase: 0,
|
||||
...(ethereal ? { ethereal: true } : {}),
|
||||
...(sockets > 0 ? { sockets } : {}),
|
||||
}
|
||||
|
||||
// Independent 1.13c ground truth oracle
|
||||
let expectedColor: string
|
||||
if (isGold) {
|
||||
expectedColor = GROUND_LABEL_QUALITY_COLORS.gold!
|
||||
} else if (quality === 'low') {
|
||||
expectedColor = GROUND_LABEL_QUALITY_COLORS.low!
|
||||
} else if (isRuneword) {
|
||||
expectedColor = GROUND_LABEL_QUALITY_COLORS.unique!
|
||||
} else if ((quality === 'normal' || quality === 'superior') && (ethereal || sockets > 0)) {
|
||||
expectedColor = '#808080'
|
||||
} else {
|
||||
expectedColor = GROUND_LABEL_QUALITY_COLORS[quality]!
|
||||
}
|
||||
|
||||
const actualColor = getGroundItemQualityColor(entity)
|
||||
expect(
|
||||
actualColor,
|
||||
`Mismatch for quality=${quality}, eth=${ethereal}, sock=${sockets}, rw=${isRuneword}, gold=${isGold}`,
|
||||
).toBe(expectedColor)
|
||||
|
||||
const layouts = computeInitialGroundLabelLayouts([entity], (wx, wy) => ({ x: wx, y: wy }))
|
||||
expect(layouts.length).toBe(1)
|
||||
expect(layouts[0]!.color).toBe(expectedColor)
|
||||
expect(layouts[0]!.borderColor).toBe(expectedColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 9 * 2 * 3 * 2 * 2 = 216 exhaustive permutations verified
|
||||
expect(totalPermutations).toBe(216)
|
||||
expect(mgr.count).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves ethereal, sockets, and #808080 ground label color across ground -> UI inventory -> ground re-drop round-trip', () => {
|
||||
const tables = getEmbeddedDropTables()
|
||||
const mgr = new GroundItemManager()
|
||||
|
||||
// 1. Spawn ethereal socketed normal item on ground
|
||||
const droppedEthSocketed = mgr.add(
|
||||
{
|
||||
id: 'roundtrip_monarch',
|
||||
code: 'uit',
|
||||
name: 'Monarch',
|
||||
quality: 'normal',
|
||||
ethereal: true,
|
||||
sockets: 4,
|
||||
invWidth: 2,
|
||||
invHeight: 3,
|
||||
},
|
||||
10,
|
||||
10,
|
||||
320,
|
||||
320,
|
||||
)
|
||||
expect(isGroundItemEthereal(droppedEthSocketed)).toBe(true)
|
||||
expect(isGroundItemSocketed(droppedEthSocketed)).toBe(true)
|
||||
expect(getGroundItemQualityColor(droppedEthSocketed)).toBe('#808080')
|
||||
|
||||
// 2. Pick up into UI inventory via itemToUiInventoryItem
|
||||
const uiItem = itemToUiInventoryItem(droppedEthSocketed.item, tables)
|
||||
expect(uiItem.ethereal).toBe(true)
|
||||
expect(uiItem.sockets).toBe(4)
|
||||
|
||||
// 3. Re-drop UI inventory item back onto ground
|
||||
const redropped = mgr.add(uiItem, 11, 11, 340, 340)
|
||||
expect(redropped.ethereal).toBe(true)
|
||||
expect(redropped.sockets).toBe(4)
|
||||
expect(isGroundItemEthereal(redropped)).toBe(true)
|
||||
expect(isGroundItemSocketed(redropped)).toBe(true)
|
||||
expect(getGroundItemQualityColor(redropped)).toBe('#808080')
|
||||
|
||||
const layouts = computeInitialGroundLabelLayouts([redropped], (wx, wy) => ({ x: wx, y: wy }))
|
||||
expect(layouts[0]!.color).toBe('#808080')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 3. Issue #425: Pickup Bounds Fuzzing & 2-Pass Auto-Belt Stress
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -10,15 +10,12 @@ import { describe, expect, it } from 'vitest'
|
|||
import { join } from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
||||
import {
|
||||
resolveItemSpriteRect,
|
||||
STARTER_BAG_ITEMS,
|
||||
STARTER_EQUIPPED_GEAR,
|
||||
} from '../src/client/ui/inventory.ts'
|
||||
import { resolveItemSpriteRect } from '../src/client/ui/inventory.ts'
|
||||
import { MountedArchives } from '../src/common/mpq/mount.ts'
|
||||
import { MpqArchive } from '../src/common/mpq/archive.ts'
|
||||
import { fileSource } from '../src/baker/io/file-source.ts'
|
||||
import { parseTable } from '../src/common/items/tables.ts'
|
||||
import { DEMO_BAG_ITEMS, DEMO_EQUIPPED_GEAR } from './fixtures/demo-profile-data.ts'
|
||||
|
||||
describe('Challenger M4 Inventory & Item Atlas Audit (Issue #149)', () => {
|
||||
const itemRects = BAKED_UI_MANIFEST.itemRects
|
||||
|
|
@ -253,7 +250,7 @@ describe('Challenger M4 Inventory & Item Atlas Audit (Issue #149)', () => {
|
|||
})
|
||||
|
||||
it('confirms starter inventory and equipped gear resolution integrity', () => {
|
||||
for (const [slot, item] of Object.entries(STARTER_EQUIPPED_GEAR)) {
|
||||
for (const [slot, item] of Object.entries(DEMO_EQUIPPED_GEAR)) {
|
||||
if (!item) continue
|
||||
const rect = resolveItemSpriteRect(item, itemRects)
|
||||
expect(rect, `Equipped item ${slot} (${item.name}) must resolve`).not.toBeNull()
|
||||
|
|
@ -263,7 +260,7 @@ describe('Challenger M4 Inventory & Item Atlas Audit (Issue #149)', () => {
|
|||
expect(rect!.y + rect!.h).toBeLessThanOrEqual(atlasHeight)
|
||||
}
|
||||
|
||||
for (const { item, col, row } of STARTER_BAG_ITEMS) {
|
||||
for (const { item, col, row } of DEMO_BAG_ITEMS) {
|
||||
const rect = resolveItemSpriteRect(item, itemRects)
|
||||
expect(rect, `Bag item at (${col}, ${row}) (${item.name}) must resolve`).not.toBeNull()
|
||||
expect(rect!.w).toBeGreaterThan(0)
|
||||
|
|
|
|||
|
|
@ -33,13 +33,6 @@ import {
|
|||
type CombatOptions,
|
||||
} from '../src/server/engine/combat.ts'
|
||||
import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts'
|
||||
import { drawGroundItem, GROUND_ITEM_QUALITY_COLORS } from '../src/client/world/act-runtime.ts'
|
||||
import {
|
||||
GROUND_LABEL_QUALITY_COLORS,
|
||||
getGroundItemQualityColor,
|
||||
isGroundItemEtherealOrSocketed,
|
||||
computeInitialGroundLabelLayouts,
|
||||
} from '../src/client/ui/ground-labels.ts'
|
||||
import {
|
||||
GroundItemManager,
|
||||
getPickupEdgeDistance,
|
||||
|
|
@ -680,208 +673,6 @@ describe('Challenger M4-2 — Multiplayer Lockstep & Cross-Feature Stress Suite'
|
|||
})
|
||||
|
||||
describe('3. Cross-Feature Integration Stress (#423 + #424 + #425 + #427)', () => {
|
||||
it('verifies all multiplayer lockstep drops have valid flippy DC6 rects (#423) and accurate #808080 gray label overrides (#424)', () => {
|
||||
const dropTables = getEmbeddedDropTables()
|
||||
const terrain = { widthPx: 10000, heightPx: 10000, overlap: () => 0 }
|
||||
const mgr = new GroundItemManager()
|
||||
|
||||
let ethOrSocketedNormalCount = 0
|
||||
let plainNormalOrSuperiorCount = 0
|
||||
let coloredMagicRareSetUniqueCount = 0
|
||||
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
const seed = (0xabcdef00 + i * 0x13579bdf) >>> 0
|
||||
const world = createWorld(200, 200)
|
||||
// Queue multiple high-level Hell kills to generate diverse normal, socketed, ethereal, magic, rare, set, unique items
|
||||
world.pendingKills = [
|
||||
{
|
||||
kind: 'kill',
|
||||
subjectId: 'diablo',
|
||||
x: 200,
|
||||
y: 200,
|
||||
monsterIndex: 0,
|
||||
monsterRank: 'boss',
|
||||
monsterLevel: 85,
|
||||
},
|
||||
{
|
||||
kind: 'kill',
|
||||
subjectId: 'zombie1',
|
||||
x: 220,
|
||||
y: 200,
|
||||
monsterIndex: 1,
|
||||
monsterRank: 'normal',
|
||||
monsterLevel: 85,
|
||||
},
|
||||
{
|
||||
kind: 'kill',
|
||||
subjectId: 'skeleton1',
|
||||
x: 240,
|
||||
y: 200,
|
||||
monsterIndex: 2,
|
||||
monsterRank: 'champion',
|
||||
monsterLevel: 85,
|
||||
},
|
||||
]
|
||||
|
||||
const sim = createNetSimulation(world, COMBAT_OPTIONS, terrain, DEMO_EXPERIENCE, 8, seed, dropTables, 'hell')
|
||||
sim.simulation.advance(
|
||||
Array.from({ length: 8 }, () => ({
|
||||
tick: 0,
|
||||
movement: { x: 0, y: 0 },
|
||||
attack: false,
|
||||
pickup: false,
|
||||
talk: false,
|
||||
skill: 0,
|
||||
})),
|
||||
)
|
||||
|
||||
for (const g of sim.ground) {
|
||||
const cell = worldToCell(terrain, g.x, g.y)
|
||||
const entity = mgr.add(g.item, g.x, g.y, cell.cellX, cell.cellY, { now: 0 })
|
||||
|
||||
// #423: Every non-gold item dropped in lockstep MUST resolve a valid flippy DC6 rect
|
||||
if (!entity.isGold) {
|
||||
const rect = resolveGroundItemSpriteRect(
|
||||
{
|
||||
code: g.item.code,
|
||||
name: g.item.name,
|
||||
flippyFile: (g.item.base as any)?.flippyfile,
|
||||
invFile: (g.item.base as any)?.invfile,
|
||||
},
|
||||
BAKED_UI_MANIFEST.flippyRects,
|
||||
BAKED_UI_MANIFEST.codeToFlippyFile,
|
||||
)
|
||||
expect(rect, `Missing flippy rect for lockstep drop ${g.item.code} (${g.item.name})`).not.toBeNull()
|
||||
expect(rect!.w).toBeGreaterThan(0)
|
||||
expect(rect!.h).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
// Render via drawGroundItem outside sparkle window -> 1 sprite draw + 1 shadow quad, never color boxes
|
||||
const quads: { color: readonly [number, number, number, number] }[] = []
|
||||
const sprites: { frame: any }[] = []
|
||||
const mockRenderer = {
|
||||
drawSolid(_x: number, _y: number, _w: number, _h: number, color: readonly [number, number, number, number]) {
|
||||
quads.push({ color })
|
||||
},
|
||||
draw(frame: any) {
|
||||
sprites.push({ frame })
|
||||
},
|
||||
} as unknown as SpriteRenderer
|
||||
|
||||
const phaseOffset = Math.abs(Math.floor(entity.cellX * 31 + entity.cellY * 17)) % 2000
|
||||
const nonSparkleNow = 3000 - phaseOffset
|
||||
drawGroundItem(mockRenderer, entity, nonSparkleNow, undefined)
|
||||
expect(sprites.length).toBe(1)
|
||||
expect(quads.length).toBe(1) // Shadow only
|
||||
|
||||
// #424: Verify ground label color rules
|
||||
const labelColor = getGroundItemQualityColor(entity)
|
||||
const isNormalTier =
|
||||
entity.quality === 'low' || entity.quality === 'normal' || entity.quality === 'superior'
|
||||
const isEthOrSock = isGroundItemEtherealOrSocketed(entity)
|
||||
|
||||
if (entity.isGold) {
|
||||
expect(labelColor).toBe(GROUND_LABEL_QUALITY_COLORS.gold)
|
||||
} else if (isNormalTier && isEthOrSock) {
|
||||
ethOrSocketedNormalCount += 1
|
||||
expect(labelColor).toBe('#808080')
|
||||
} else if (entity.quality === 'normal' || entity.quality === 'superior') {
|
||||
plainNormalOrSuperiorCount += 1
|
||||
expect(labelColor).toBe(GROUND_LABEL_QUALITY_COLORS[entity.quality])
|
||||
} else if (
|
||||
entity.quality === 'magic' ||
|
||||
entity.quality === 'rare' ||
|
||||
entity.quality === 'set' ||
|
||||
entity.quality === 'unique'
|
||||
) {
|
||||
coloredMagicRareSetUniqueCount += 1
|
||||
expect(labelColor).toBe(GROUND_LABEL_QUALITY_COLORS[entity.quality])
|
||||
}
|
||||
|
||||
// Verify UI bridge preserves ethereal and socket metadata consistently with ground-labels
|
||||
if (!entity.isGold) {
|
||||
const uiItem = itemToUiInventoryItem(g.item, dropTables)
|
||||
expect(Boolean(uiItem.ethereal)).toBe(Boolean((g.item as any).ethereal))
|
||||
expect(isGroundItemEtherealOrSocketed(entity)).toBe(
|
||||
Boolean(uiItem.ethereal || (entity.sockets ?? 0) > 0),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also explicitly inject an ethereal normal item and a socketed superior item if random rolls didn't hit enough
|
||||
const explicitEthNormal = mgr.add(
|
||||
{ code: 'ci3', name: 'Diadem', quality: 'normal', ethereal: true },
|
||||
200,
|
||||
200,
|
||||
6,
|
||||
6,
|
||||
{ now: 0 },
|
||||
)
|
||||
const explicitSockSup = mgr.add(
|
||||
{ code: 'uar', name: 'Sacred Armor', quality: 'superior', sockets: 4 },
|
||||
210,
|
||||
200,
|
||||
7,
|
||||
6,
|
||||
{ now: 0 },
|
||||
)
|
||||
expect(getGroundItemQualityColor(explicitEthNormal)).toBe('#808080')
|
||||
expect(getGroundItemQualityColor(explicitSockSup)).toBe('#808080')
|
||||
const layouts = computeInitialGroundLabelLayouts(
|
||||
[explicitEthNormal, explicitSockSup],
|
||||
(wx, wy) => ({ x: wx, y: wy }),
|
||||
)
|
||||
expect(layouts[0]!.color).toBe('#808080')
|
||||
expect(layouts[1]!.color).toBe('#808080')
|
||||
|
||||
expect(ethOrSocketedNormalCount + 2).toBeGreaterThanOrEqual(2)
|
||||
expect(plainNormalOrSuperiorCount).toBeGreaterThan(0)
|
||||
expect(coloredMagicRareSetUniqueCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('fails fast when drawGroundItem encounters an unmapped item with no resolvable sprite rect (#423 anti-silent failure)', () => {
|
||||
const mockRenderer = {
|
||||
drawSolid() {},
|
||||
draw() {},
|
||||
} as unknown as SpriteRenderer
|
||||
|
||||
const savedRects = { ...BAKED_UI_MANIFEST.itemRects }
|
||||
const savedFlippy = { ...BAKED_UI_MANIFEST.flippyRects }
|
||||
try {
|
||||
for (const k of Object.keys(BAKED_UI_MANIFEST.itemRects)) {
|
||||
delete (BAKED_UI_MANIFEST.itemRects as any)[k]
|
||||
}
|
||||
for (const k of Object.keys(BAKED_UI_MANIFEST.flippyRects)) {
|
||||
delete (BAKED_UI_MANIFEST.flippyRects as any)[k]
|
||||
}
|
||||
|
||||
const invalidEntity = {
|
||||
id: 'bad_item',
|
||||
item: { code: 'zzz_nonexistent_code', name: 'Nonexistent Item Totally Unmapped' },
|
||||
name: 'Nonexistent Item Totally Unmapped',
|
||||
nameZh: '未知',
|
||||
quality: 'normal' as const,
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
invWidth: 99,
|
||||
invHeight: 99,
|
||||
dropTime: 0,
|
||||
x: 100,
|
||||
y: 100,
|
||||
cellX: 3,
|
||||
cellY: 3,
|
||||
sparklePhase: 0,
|
||||
}
|
||||
|
||||
expect(() => drawGroundItem(mockRenderer, invalidEntity, 1000, undefined)).toThrow(
|
||||
/missing flippy sprite rect/i,
|
||||
)
|
||||
} finally {
|
||||
Object.assign(BAKED_UI_MANIFEST.itemRects, savedRects)
|
||||
Object.assign(BAKED_UI_MANIFEST.flippyRects, savedFlippy)
|
||||
}
|
||||
})
|
||||
|
||||
it('enforces 2-subtile (64px edge / 96px center) pickup bounds in multiplayer lockstep and routes potions to BeltHud (#425 + #427)', () => {
|
||||
const terrain = { widthPx: 10000, heightPx: 10000, overlap: () => 0 }
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import {
|
|||
BATCH1_SKILLS,
|
||||
CANONICAL_113C_MISSILES,
|
||||
} from '../src/server/skills/skills.ts'
|
||||
import { getSkillRange, getSkillMana } from '../src/client/world/act-runtime.ts'
|
||||
import { getSkillRange, getSkillMana } from '../src/common/skills/weapon-combat-meta.ts'
|
||||
|
||||
describe('Adversarial Stress Test: Milestone M4 (Skills 57, 58, 63)', () => {
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ import { getSharedDataRegistry } from '../src/baker/mpq-archive-source.ts'
|
|||
import { UnitStatList, FIXED_ONE } from '../src/server/engine/stat-list.ts'
|
||||
import { StateBus } from '../src/server/engine/state-bus.ts'
|
||||
import { GameEngine, type WorldMapProvider, type GameEngineOptions } from '../src/server/engine/engine.ts'
|
||||
import { getSkillRange, getSkillMana } from '../src/client/world/act-runtime.ts'
|
||||
import { getSkillRange, getSkillMana } from '../src/common/skills/weapon-combat-meta.ts'
|
||||
import { castSkill } from '../src/server/scene/skill-caster.ts'
|
||||
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.ts'
|
||||
import { OVERLAY_METAS } from '../src/client/render/overlays-meta.ts'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { CANONICAL_TOWN_STASHES, type TownStashEntity, SceneMouseController, type MapRuntime } from '../src/client/world/act-runtime.ts'
|
||||
import { CANONICAL_TOWN_STASHES, type TownStashEntity } from '../src/common/world/scene-population.ts'
|
||||
import {
|
||||
WorldPanelsHud,
|
||||
STASH_GOLD_CAP,
|
||||
|
|
@ -8,8 +8,6 @@ import {
|
|||
import { HudManager } from '../src/client/ui/hud-manager.ts'
|
||||
import { GameEngine } from '../src/server/engine/engine.ts'
|
||||
import { createIsoTerrain } from '../src/common/world/iso-terrain.ts'
|
||||
import { ViewportCamera } from '../src/client/sim/camera.ts'
|
||||
import { KeyboardInput } from '../src/client/sim/input.ts'
|
||||
import { WaypointNetwork } from '../src/common/world/portal.ts'
|
||||
|
||||
class MockWindow extends EventTarget {
|
||||
|
|
@ -122,274 +120,6 @@ describe('Challenger M5 Empirical Stress Test: Town Stash Entities, Interaction
|
|||
})
|
||||
})
|
||||
|
||||
describe('2. Interaction Geometry Stress Testing', () => {
|
||||
let engine: GameEngine
|
||||
let camera: ViewportCamera
|
||||
let input: KeyboardInput
|
||||
let waypointNetwork: WaypointNetwork
|
||||
let statusElem: HTMLElement
|
||||
let controller: SceneMouseController
|
||||
let runtime: MapRuntime
|
||||
let stashOpenCount: number
|
||||
let mockHudManager: any
|
||||
|
||||
beforeEach(() => {
|
||||
stashOpenCount = 0
|
||||
const grid = {
|
||||
originX: 0,
|
||||
originY: 0,
|
||||
cellsX: 200,
|
||||
cellsY: 200,
|
||||
blocked: new Uint8Array(1000 * 1000),
|
||||
gridWidth: 1000,
|
||||
}
|
||||
const terrain = createIsoTerrain(grid, 10000, 5000, { width: 20, height: 10 })
|
||||
engine = new GameEngine(terrain, {
|
||||
spawn: { x: 4500, y: 1700 },
|
||||
stats: [],
|
||||
monsterCount: 0,
|
||||
questDefs: [],
|
||||
npcDefs: [],
|
||||
} as any)
|
||||
|
||||
const mockCanvas = {
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
||||
clientWidth: 800,
|
||||
clientHeight: 600,
|
||||
width: 800,
|
||||
height: 600,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
getContext: () => null,
|
||||
style: { cursor: '' },
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
camera = new ViewportCamera(mockCanvas, 10000, 5000)
|
||||
input = new KeyboardInput()
|
||||
waypointNetwork = new WaypointNetwork()
|
||||
statusElem = { textContent: '' } as unknown as HTMLElement
|
||||
|
||||
mockHudManager = {
|
||||
leftPanel: 'none',
|
||||
toggleLeftPanel: (panel: any) => {
|
||||
if (panel === 'stash') stashOpenCount++
|
||||
mockHudManager.leftPanel = panel
|
||||
},
|
||||
clientToLogical: (x: number, y: number) => ({ x, y }),
|
||||
isPointInterceptedByHud: () => false,
|
||||
} as unknown as HudManager
|
||||
|
||||
runtime = {
|
||||
source: 'pack',
|
||||
base: 'samples/d2-packs',
|
||||
level: 'Rogue Encampment',
|
||||
act: 1,
|
||||
levelId: 1,
|
||||
entryPath: 'act1-towne1',
|
||||
entrances: [],
|
||||
warps: [],
|
||||
waypoints: [],
|
||||
stashes: [
|
||||
CANONICAL_TOWN_STASHES[1]!,
|
||||
CANONICAL_TOWN_STASHES[2]!,
|
||||
CANONICAL_TOWN_STASHES[3]!,
|
||||
CANONICAL_TOWN_STASHES[4]!,
|
||||
CANONICAL_TOWN_STASHES[5]!,
|
||||
],
|
||||
variants: ['towne1'],
|
||||
variantParam: 'level',
|
||||
variant: 'towne1',
|
||||
quadrants: ['towne1'],
|
||||
quadrantParam: 'level',
|
||||
quadrant: 'towne1',
|
||||
cellsX: 200,
|
||||
cellsY: 200,
|
||||
widthPx: 10000,
|
||||
heightPx: 5000,
|
||||
grid,
|
||||
floors: [],
|
||||
walls: [],
|
||||
roofs: [],
|
||||
objectDrawables: [],
|
||||
spawn: { x: 4500, y: 1700 },
|
||||
npcs: [],
|
||||
monsterPacks: [],
|
||||
monsterTypes: [],
|
||||
objects: 5,
|
||||
frames: 0,
|
||||
notes: [],
|
||||
pages: [],
|
||||
objectPages: [],
|
||||
charBases: [],
|
||||
palette: null,
|
||||
priorityPages: [],
|
||||
loadOrder: [],
|
||||
loadPages: async () => {},
|
||||
}
|
||||
|
||||
controller = new SceneMouseController({
|
||||
canvas: mockCanvas,
|
||||
engine,
|
||||
camera,
|
||||
input,
|
||||
getRuntime: () => runtime,
|
||||
hudManager: mockHudManager,
|
||||
waypointNetwork,
|
||||
status: statusElem,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Click radius (<= 48px hit, > 48px miss)', () => {
|
||||
const stash1 = CANONICAL_TOWN_STASHES[1]! // (4512, 1720)
|
||||
|
||||
it('asserts exact center click hits the stash', () => {
|
||||
expect(controller.findNearestStash(stash1.x, stash1.y, 48)).toEqual(stash1)
|
||||
})
|
||||
|
||||
it('asserts clicks at distance <= 48px hit the stash in all cardinal directions', () => {
|
||||
expect(controller.findNearestStash(stash1.x + 48, stash1.y, 48)).toEqual(stash1)
|
||||
expect(controller.findNearestStash(stash1.x - 48, stash1.y, 48)).toEqual(stash1)
|
||||
expect(controller.findNearestStash(stash1.x, stash1.y + 48, 48)).toEqual(stash1)
|
||||
expect(controller.findNearestStash(stash1.x, stash1.y - 48, 48)).toEqual(stash1)
|
||||
})
|
||||
|
||||
it('asserts 36-angle radial sweep at radius 47.5px (strictly <= 48px) all hit the stash', () => {
|
||||
for (let i = 0; i < 36; i++) {
|
||||
const angle = (i * 2 * Math.PI) / 36
|
||||
const x = stash1.x + 47.5 * Math.cos(angle)
|
||||
const y = stash1.y + 47.5 * Math.sin(angle)
|
||||
const hit = controller.findNearestStash(x, y, 48)
|
||||
expect(hit, `Angle ${i * 10}deg at distance <= 48 should hit stash`).toEqual(stash1)
|
||||
}
|
||||
})
|
||||
|
||||
it('asserts clicks at distance > 48px do NOT hit the stash', () => {
|
||||
// Just beyond boundary (49px, 50px, 60px)
|
||||
expect(controller.findNearestStash(stash1.x + 49, stash1.y, 48)).toBeNull()
|
||||
expect(controller.findNearestStash(stash1.x - 49, stash1.y, 48)).toBeNull()
|
||||
expect(controller.findNearestStash(stash1.x, stash1.y + 49, 48)).toBeNull()
|
||||
expect(controller.findNearestStash(stash1.x, stash1.y - 49, 48)).toBeNull()
|
||||
|
||||
// Diagonal beyond boundary (35, 35) -> dist = 49.5px > 48px
|
||||
expect(controller.findNearestStash(stash1.x + 35, stash1.y + 35, 48)).toBeNull()
|
||||
expect(controller.findNearestStash(stash1.x - 35, stash1.y - 35, 48)).toBeNull()
|
||||
|
||||
// Distant clicks
|
||||
expect(controller.findNearestStash(stash1.x + 100, stash1.y, 48)).toBeNull()
|
||||
expect(controller.findNearestStash(0, 0, 48)).toBeNull()
|
||||
})
|
||||
|
||||
it('asserts 36-angle radial sweep at radius 50px all miss the stash', () => {
|
||||
for (let i = 0; i < 36; i++) {
|
||||
const angle = (i * 2 * Math.PI) / 36
|
||||
const x = stash1.x + 50 * Math.cos(angle)
|
||||
const y = stash1.y + 50 * Math.sin(angle)
|
||||
const hit = controller.findNearestStash(x, y, 48)
|
||||
expect(hit, `Angle ${i * 10}deg at distance 50px must not hit stash`).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('discriminates between closest stash when multiple stashes are present', () => {
|
||||
const stash3 = CANONICAL_TOWN_STASHES[3]! // (5264, 1584)
|
||||
const stash2 = CANONICAL_TOWN_STASHES[2]! // (5328, 1568)
|
||||
// Click near stash3
|
||||
expect(controller.findNearestStash(stash3.x + 10, stash3.y, 48)).toEqual(stash3)
|
||||
// Click near stash2
|
||||
expect(controller.findNearestStash(stash2.x + 10, stash2.y, 48)).toEqual(stash2)
|
||||
})
|
||||
|
||||
it('tests custom maxRadius overrides (e.g. 24px, 64px)', () => {
|
||||
// At 30px away: misses with maxRadius=24, hits with maxRadius=48 and maxRadius=64
|
||||
expect(controller.findNearestStash(stash1.x + 30, stash1.y, 24)).toBeNull()
|
||||
expect(controller.findNearestStash(stash1.x + 30, stash1.y, 48)).toEqual(stash1)
|
||||
expect(controller.findNearestStash(stash1.x + 60, stash1.y, 64)).toEqual(stash1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Distance threshold (<= 80px immediate open, > 80px pathfind)', () => {
|
||||
const stash = CANONICAL_TOWN_STASHES[1]! // (4512, 1720)
|
||||
|
||||
it('player at distance <= 80px opens stash immediately without movement', () => {
|
||||
const testDistances = [0, 20, 50, 75, 80]
|
||||
for (const dist of testDistances) {
|
||||
stashOpenCount = 0
|
||||
mockHudManager.leftPanel = 'none'
|
||||
engine.world.player.x = stash.x - dist
|
||||
engine.world.player.y = stash.y
|
||||
|
||||
controller.handleLeftClickWorld(stash.x, stash.y)
|
||||
|
||||
expect(controller.activeClickType).toBe('stash')
|
||||
expect(controller.pendingInteraction).toBeNull()
|
||||
expect(controller.navTarget).toBeNull()
|
||||
expect(stashOpenCount).toBe(1)
|
||||
expect(statusElem.textContent).toContain('储物箱')
|
||||
}
|
||||
})
|
||||
|
||||
it('player at distance > 80px schedules pathfinding/walk and does not open stash immediately', () => {
|
||||
const testDistances = [81, 100, 250, 500]
|
||||
for (const dist of testDistances) {
|
||||
stashOpenCount = 0
|
||||
mockHudManager.leftPanel = 'none'
|
||||
engine.world.player.x = stash.x - dist
|
||||
engine.world.player.y = stash.y
|
||||
|
||||
controller.handleLeftClickWorld(stash.x, stash.y)
|
||||
|
||||
expect(controller.activeClickType).toBe('stash')
|
||||
expect(controller.pendingInteraction).toEqual({ kind: 'stash', stash })
|
||||
expect(controller.navTarget).toEqual({ x: stash.x, y: stash.y })
|
||||
expect(stashOpenCount).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('player walks toward stash and opens automatically upon reaching <= 80px in tick()', () => {
|
||||
engine.world.player.x = stash.x - 300
|
||||
engine.world.player.y = stash.y
|
||||
|
||||
controller.handleLeftClickWorld(stash.x, stash.y)
|
||||
|
||||
expect(stashOpenCount).toBe(0)
|
||||
expect(controller.pendingInteraction).toEqual({ kind: 'stash', stash })
|
||||
expect(controller.navTarget).toEqual({ x: stash.x, y: stash.y })
|
||||
|
||||
// Step 1: Walk to 200px away -> still too far
|
||||
engine.world.player.x = stash.x - 200
|
||||
controller.tick()
|
||||
expect(stashOpenCount).toBe(0)
|
||||
expect(controller.pendingInteraction).not.toBeNull()
|
||||
expect(controller.navTarget).toEqual({ x: stash.x, y: stash.y })
|
||||
|
||||
// Step 2: Walk to 90px away -> still outside 80px
|
||||
engine.world.player.x = stash.x - 90
|
||||
controller.tick()
|
||||
expect(stashOpenCount).toBe(0)
|
||||
expect(controller.pendingInteraction).not.toBeNull()
|
||||
|
||||
// Step 3: Arrive at 75px away (<= 80px) -> triggers open!
|
||||
engine.world.player.x = stash.x - 75
|
||||
controller.tick()
|
||||
expect(stashOpenCount).toBe(1)
|
||||
expect(controller.pendingInteraction).toBeNull()
|
||||
expect(controller.navTarget).toBeNull()
|
||||
expect(statusElem.textContent).toContain('储物箱')
|
||||
})
|
||||
|
||||
it('cancels pending stash interaction if user left-clicks ground or elsewhere', () => {
|
||||
engine.world.player.x = stash.x - 200
|
||||
engine.world.player.y = stash.y
|
||||
controller.handleLeftClickWorld(stash.x, stash.y)
|
||||
expect(controller.pendingInteraction).toEqual({ kind: 'stash', stash })
|
||||
|
||||
// Click ground at an unrelated point
|
||||
controller.handleLeftClickWorld(stash.x + 500, stash.y + 500)
|
||||
expect(controller.activeClickType).toBe('ground')
|
||||
expect(controller.pendingInteraction).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. Gold Mechanics Stress Testing', () => {
|
||||
let panels: WorldPanelsHud
|
||||
let inv: { gold: number }
|
||||
|
|
|
|||
|
|
@ -8,15 +8,8 @@
|
|||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import { readFileSync, existsSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
DEFAULT_SORCERESS_SKILLS,
|
||||
SkillHotkeysHud,
|
||||
resolveSkillIcon,
|
||||
getAvailableSkillsForSide,
|
||||
LEFT_SKILL_BOUNDS,
|
||||
RIGHT_SKILL_BOUNDS,
|
||||
type HotkeySkillEntry,
|
||||
} from '../src/client/ui/hotkeys.ts'
|
||||
import { SkillHotkeysHud, resolveSkillIcon, getAvailableSkillsForSide, LEFT_SKILL_BOUNDS, RIGHT_SKILL_BOUNDS, type HotkeySkillEntry } from '../src/client/ui/hotkeys.ts'
|
||||
import { DEMO_SORCERESS_SKILLS } from './fixtures/demo-profile-data.ts'
|
||||
|
||||
describe('Empirical Challenger Stress Suite: M6 Normal Attack & Dual Slot Bindings', () => {
|
||||
const rootDir = process.cwd()
|
||||
|
|
@ -131,12 +124,12 @@ describe('Empirical Challenger Stress Suite: M6 Normal Attack & Dual Slot Bindin
|
|||
|
||||
beforeEach(() => {
|
||||
hud = new SkillHotkeysHud()
|
||||
hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS])
|
||||
hud.setAvailableSkills([...DEMO_SORCERESS_SKILLS])
|
||||
})
|
||||
|
||||
it('verifies getAvailableSkillsForSide includes Skill 0 (Attack) in both left and right palettes', () => {
|
||||
const leftSkills = getAvailableSkillsForSide('left', DEFAULT_SORCERESS_SKILLS)
|
||||
const rightSkills = getAvailableSkillsForSide('right', DEFAULT_SORCERESS_SKILLS)
|
||||
const leftSkills = getAvailableSkillsForSide('left', DEMO_SORCERESS_SKILLS)
|
||||
const rightSkills = getAvailableSkillsForSide('right', DEMO_SORCERESS_SKILLS)
|
||||
|
||||
const leftAttack = leftSkills.find((s) => s.skillId === 0)
|
||||
const rightAttack = rightSkills.find((s) => s.skillId === 0)
|
||||
|
|
@ -266,7 +259,7 @@ describe('Empirical Challenger Stress Suite: M6 Normal Attack & Dual Slot Bindin
|
|||
|
||||
beforeEach(() => {
|
||||
hud = new SkillHotkeysHud()
|
||||
hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS])
|
||||
hud.setAvailableSkills([...DEMO_SORCERESS_SKILLS])
|
||||
})
|
||||
|
||||
it('binds F1 through F8 to Attack (0) and verifies instant key activation', () => {
|
||||
|
|
@ -319,7 +312,7 @@ describe('Empirical Challenger Stress Suite: M6 Normal Attack & Dual Slot Bindin
|
|||
})
|
||||
|
||||
it('asserts leftUsable: true and rightUsable: true remain immutable on Skill 0', () => {
|
||||
const attackDef = DEFAULT_SORCERESS_SKILLS.find((s) => s.skillId === 0)
|
||||
const attackDef = DEMO_SORCERESS_SKILLS.find((s) => s.skillId === 0)
|
||||
expect(attackDef).toBeDefined()
|
||||
expect(attackDef!.leftUsable).toBe(true)
|
||||
expect(attackDef!.rightUsable).toBe(true)
|
||||
|
|
@ -336,8 +329,8 @@ describe('Empirical Challenger Stress Suite: M6 Normal Attack & Dual Slot Bindin
|
|||
hud.handleKeyDown('F1')
|
||||
}
|
||||
|
||||
// Check DEFAULT_SORCERESS_SKILLS
|
||||
const attackDefAfter = DEFAULT_SORCERESS_SKILLS.find((s) => s.skillId === 0)
|
||||
// Check DEMO_SORCERESS_SKILLS
|
||||
const attackDefAfter = DEMO_SORCERESS_SKILLS.find((s) => s.skillId === 0)
|
||||
expect(attackDefAfter!.leftUsable).toBe(true)
|
||||
expect(attackDefAfter!.rightUsable).toBe(true)
|
||||
|
||||
|
|
|
|||
|
|
@ -19,14 +19,13 @@
|
|||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import { getAttackWeaponInfo, checkAmmunition, getSkillRange, SceneMouseController, screenToWorld, facingOf, type AttackWeaponInputItem, type AttackWeaponInfo, type MapRuntime } from '../src/client/world/act-runtime.ts'
|
||||
import { getAttackWeaponInfo, checkAmmunition, getSkillRange, type AttackWeaponInputItem, type AttackWeaponInfo } from '../src/common/skills/weapon-combat-meta.ts'
|
||||
import { screenToWorld, facingOf } from '../src/common/world/scene-links-nav.ts'
|
||||
import { castSkill } from '../src/server/scene/skill-caster.ts'
|
||||
import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/server/engine/engine.ts'
|
||||
import type { Projectile } from '../src/server/skills/skills.ts'
|
||||
import { damageMonster, type Monster } from '../src/server/engine/combat.ts'
|
||||
import { HudManager } from '../src/client/ui/hud-manager.ts'
|
||||
import { ViewportCamera } from '../src/client/sim/camera.ts'
|
||||
import { KeyboardInput } from '../src/client/sim/input.ts'
|
||||
import { WaypointNetwork } from '../src/common/world/portal.ts'
|
||||
|
||||
// DOM mocks for headless testing environment
|
||||
|
|
@ -101,182 +100,6 @@ if (typeof globalThis.HTMLSelectElement === 'undefined') {
|
|||
globalThis.HTMLSelectElement = class HTMLSelectElement {}
|
||||
}
|
||||
|
||||
function createCombatTestScene(options?: {
|
||||
playerPos?: { x: number; y: number }
|
||||
leftSkillId?: number
|
||||
rightSkillId?: number
|
||||
equippedWeapon?: AttackWeaponInputItem | null
|
||||
equippedAmmo?: { code?: string; name?: string; nameZh?: string } | null
|
||||
hasAmmo?: boolean
|
||||
monsters?: Array<{ id: string; name: string; x: number; y: number; hp: number }>
|
||||
}) {
|
||||
const canvas = new MockCanvas() as unknown as HTMLCanvasElement
|
||||
const win = new MockWindow()
|
||||
const input = new KeyboardInput(win as unknown as Window)
|
||||
input.attach()
|
||||
|
||||
const dummyTerrain: WorldMapProvider = {
|
||||
widthPx: 2000,
|
||||
heightPx: 2000,
|
||||
overlap: () => 0,
|
||||
}
|
||||
|
||||
const dummyOpts: GameEngineOptions = {
|
||||
spawn: options?.playerPos ?? { x: 500, y: 500 },
|
||||
stats: (options?.monsters ?? []).map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
hp: m.hp,
|
||||
damage: 10,
|
||||
cooldownTicks: 100,
|
||||
reach: 20,
|
||||
aggroRadius: 100,
|
||||
speed: 0,
|
||||
xp: 10,
|
||||
})),
|
||||
xpTable: [0, 0, 10, 30, 60],
|
||||
itemBases: [],
|
||||
prefixAffixes: [],
|
||||
suffixAffixes: [],
|
||||
skills: [],
|
||||
npcDefs: [],
|
||||
questDefs: [],
|
||||
combatOptions: {
|
||||
playerSpeed: 100,
|
||||
playerReach: 52,
|
||||
playerCooldownTicks: 1,
|
||||
playerDamage: 10,
|
||||
playerManaPerAttack: 0,
|
||||
respawnTicks: 100,
|
||||
},
|
||||
lootSeed: 12345,
|
||||
talkRadius: 80,
|
||||
pickupRadius: 50,
|
||||
inventoryCols: 10,
|
||||
inventoryRows: 4,
|
||||
}
|
||||
|
||||
const engine = new GameEngine(dummyTerrain, dummyOpts)
|
||||
if (options?.monsters) {
|
||||
for (let i = 0; i < options.monsters.length; i++) {
|
||||
engine.world.monsters[i]!.x = options.monsters[i]!.x
|
||||
engine.world.monsters[i]!.y = options.monsters[i]!.y
|
||||
}
|
||||
}
|
||||
|
||||
const camera = new ViewportCamera(canvas, 2000, 2000)
|
||||
camera.zoom = 1
|
||||
|
||||
const hudManager = new HudManager(canvas, {
|
||||
onToggleAutomap: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
})
|
||||
hudManager.mana = 200
|
||||
hudManager.hotkeys.leftSkillId = options?.leftSkillId ?? 0
|
||||
hudManager.hotkeys.rightSkillId = options?.rightSkillId ?? 0
|
||||
|
||||
if (options?.equippedWeapon !== undefined) {
|
||||
if (options.equippedWeapon === null) {
|
||||
delete hudManager.inventory.equipped.weapon1
|
||||
} else {
|
||||
hudManager.inventory.equipped.weapon1 = options.equippedWeapon as any
|
||||
}
|
||||
}
|
||||
if (options?.equippedAmmo !== undefined) {
|
||||
if (options.equippedAmmo === null) {
|
||||
delete hudManager.inventory.equipped.weapon2
|
||||
} else {
|
||||
hudManager.inventory.equipped.weapon2 = options.equippedAmmo as any
|
||||
}
|
||||
}
|
||||
|
||||
hudManager.syncPublishedState()
|
||||
|
||||
const waypointNetwork = new WaypointNetwork()
|
||||
const status = { textContent: '' } as HTMLElement
|
||||
|
||||
const runtime: MapRuntime = {
|
||||
base: 'samples/d2-packs',
|
||||
act: 1,
|
||||
level: 'Rogue Encampment',
|
||||
levelId: 1,
|
||||
variant: 'default',
|
||||
variants: ['default'],
|
||||
quadrant: 'default',
|
||||
quadrants: ['default'],
|
||||
cellsX: 100,
|
||||
cellsY: 100,
|
||||
widthPx: 2000,
|
||||
heightPx: 2000,
|
||||
floors: [],
|
||||
walls: [],
|
||||
roofs: [],
|
||||
objectDrawables: [],
|
||||
pages: [],
|
||||
priorityPages: [],
|
||||
objectPages: [],
|
||||
grid: {
|
||||
originX: 0,
|
||||
originY: 0,
|
||||
cellsX: 100,
|
||||
cellsY: 100,
|
||||
blocked: new Uint8Array(500 * 500),
|
||||
gridWidth: 500,
|
||||
},
|
||||
npcs: [],
|
||||
entrances: [],
|
||||
warps: [],
|
||||
waypoints: [],
|
||||
animatedDrawables: [],
|
||||
animSpeed: 1,
|
||||
frameDurationMs: 40,
|
||||
palette: null,
|
||||
charBases: [],
|
||||
monsterTypes: [],
|
||||
monsterPacks: [],
|
||||
entryPath: '',
|
||||
loadPages: async () => {},
|
||||
} as unknown as MapRuntime
|
||||
|
||||
const controller = new SceneMouseController({
|
||||
canvas,
|
||||
engine,
|
||||
camera,
|
||||
input,
|
||||
getRuntime: () => runtime,
|
||||
hudManager,
|
||||
waypointNetwork,
|
||||
status,
|
||||
})
|
||||
controller.attach()
|
||||
|
||||
return {
|
||||
canvas,
|
||||
win,
|
||||
input,
|
||||
engine,
|
||||
camera,
|
||||
hudManager,
|
||||
runtime,
|
||||
status,
|
||||
controller,
|
||||
}
|
||||
}
|
||||
|
||||
function tickScene(scene: ReturnType<typeof createCombatTestScene>) {
|
||||
const mouseStep = scene.controller.tick()
|
||||
scene.engine.tick({
|
||||
movement: mouseStep.movement,
|
||||
attacking: false,
|
||||
pickingUp: false,
|
||||
talking: false,
|
||||
saving: false,
|
||||
loading: false,
|
||||
digits: [],
|
||||
})
|
||||
return mouseStep
|
||||
}
|
||||
|
||||
describe('Challenger M6 Empirical Stress Test Suite: Attack Execution Mechanics', () => {
|
||||
// =========================================================================
|
||||
// 1. Unarmed vs Equipped Melee Weapon Combat Timing & Reach
|
||||
|
|
@ -823,356 +646,4 @@ describe('Challenger M6 Empirical Stress Test Suite: Attack Execution Mechanics'
|
|||
expect(checkAmmunition('arrow', null, { items: [], gridItems: [] })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// 3. Stand-Ground Shift-Click Attack
|
||||
// =========================================================================
|
||||
describe('3. Stand-Ground Shift-Click Attack (In-Place Execution)', () => {
|
||||
it('executes Shift + left-click attack in-place towards cursor direction without pathfinding or moving player', () => {
|
||||
const scene = createCombatTestScene({
|
||||
playerPos: { x: 500, y: 500 },
|
||||
leftSkillId: 0, // Attack
|
||||
equippedWeapon: {
|
||||
code: 'crs',
|
||||
name: 'Crystal Sword',
|
||||
damage: '10 to 20',
|
||||
},
|
||||
})
|
||||
|
||||
// Hold Shift
|
||||
scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' }))
|
||||
expect(scene.input.shiftHeld).toBe(true)
|
||||
|
||||
// Click East: clientX = 960 + 150 = 1110, clientY = 556
|
||||
scene.canvas.dispatchEvent(
|
||||
new SyntheticPointerEvent('pointerdown', {
|
||||
clientX: 1110,
|
||||
clientY: 556,
|
||||
button: 0,
|
||||
shiftKey: true,
|
||||
})
|
||||
)
|
||||
|
||||
// Invariants:
|
||||
// 1. Navigation target must be strictly null (no pathfinding)
|
||||
expect(scene.controller.navTarget).toBeNull()
|
||||
// 2. Pending interaction must be strictly null
|
||||
expect(scene.controller.pendingInteraction).toBeNull()
|
||||
// 3. Player faces cursor direction (East = 6)
|
||||
expect(scene.engine.world.player.facing).toBe(6)
|
||||
// 4. Attack was executed (player cooldown set to 14)
|
||||
expect(scene.engine.world.player.cooldown).toBe(14)
|
||||
// 5. Player world position unchanged
|
||||
expect(scene.engine.world.player.x).toBe(500)
|
||||
expect(scene.engine.world.player.y).toBe(500)
|
||||
|
||||
// Tick scene: movement must be strictly zero
|
||||
const tick = tickScene(scene)
|
||||
expect(tick.movement).toEqual({ x: 0, y: 0 })
|
||||
expect(scene.engine.world.player.x).toBe(500)
|
||||
expect(scene.engine.world.player.y).toBe(500)
|
||||
})
|
||||
|
||||
it('executes Shift + right-click attack in-place towards cursor direction without pathfinding or moving player', () => {
|
||||
const scene = createCombatTestScene({
|
||||
playerPos: { x: 500, y: 500 },
|
||||
rightSkillId: 0, // Attack
|
||||
equippedWeapon: {
|
||||
code: 'crs',
|
||||
name: 'Crystal Sword',
|
||||
damage: '10 to 20',
|
||||
},
|
||||
})
|
||||
|
||||
// Hold Shift
|
||||
scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftRight', key: 'Shift' }))
|
||||
expect(scene.input.shiftHeld).toBe(true)
|
||||
|
||||
// Click North: clientX = 960, clientY = 556 - 150 = 406
|
||||
scene.canvas.dispatchEvent(
|
||||
new SyntheticPointerEvent('pointerdown', {
|
||||
clientX: 960,
|
||||
clientY: 406,
|
||||
button: 2,
|
||||
shiftKey: true,
|
||||
})
|
||||
)
|
||||
|
||||
// Invariants:
|
||||
expect(scene.controller.navTarget).toBeNull()
|
||||
expect(scene.controller.pendingInteraction).toBeNull()
|
||||
// Player faces cursor direction (North = 4)
|
||||
expect(scene.engine.world.player.facing).toBe(4)
|
||||
expect(scene.engine.world.player.cooldown).toBe(14)
|
||||
expect(scene.engine.world.player.x).toBe(500)
|
||||
expect(scene.engine.world.player.y).toBe(500)
|
||||
|
||||
const tick = tickScene(scene)
|
||||
expect(tick.movement).toEqual({ x: 0, y: 0 })
|
||||
expect(scene.engine.world.player.x).toBe(500)
|
||||
expect(scene.engine.world.player.y).toBe(500)
|
||||
})
|
||||
|
||||
it('does not pathfind or move towards distant monster when Shift-clicking on monster', () => {
|
||||
const scene = createCombatTestScene({
|
||||
playerPos: { x: 500, y: 500 },
|
||||
leftSkillId: 0,
|
||||
equippedWeapon: {
|
||||
code: 'crs',
|
||||
name: 'Crystal Sword',
|
||||
damage: '10 to 20',
|
||||
},
|
||||
monsters: [{ id: 'far_mon', name: 'Far Skeleton', x: 800, y: 800, hp: 100 }],
|
||||
})
|
||||
|
||||
// Hold Shift
|
||||
scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' }))
|
||||
|
||||
// Click towards distant monster (300px away)
|
||||
const clientX = 960 + (800 - 500)
|
||||
const clientY = 556 + (800 - 500)
|
||||
scene.canvas.dispatchEvent(
|
||||
new SyntheticPointerEvent('pointerdown', {
|
||||
clientX,
|
||||
clientY,
|
||||
button: 0,
|
||||
shiftKey: true,
|
||||
})
|
||||
)
|
||||
|
||||
// Stand-ground prevents pathfinding
|
||||
expect(scene.controller.navTarget).toBeNull()
|
||||
expect(scene.controller.pendingInteraction).toBeNull()
|
||||
|
||||
// Ticking does not move player towards monster
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const tick = tickScene(scene)
|
||||
expect(tick.movement).toEqual({ x: 0, y: 0 })
|
||||
}
|
||||
expect(scene.engine.world.player.x).toBe(500)
|
||||
expect(scene.engine.world.player.y).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// 4. Continuous Attack Hold
|
||||
// =========================================================================
|
||||
describe('4. Continuous Attack Hold (Cooldown Interval Cadence)', () => {
|
||||
it('attacks continuously at 14-tick cooldown intervals when holding right-click with bow', () => {
|
||||
const scene = createCombatTestScene({
|
||||
playerPos: { x: 500, y: 500 },
|
||||
rightSkillId: 0,
|
||||
equippedWeapon: {
|
||||
code: 'sbw',
|
||||
name: 'Short Bow',
|
||||
damage: '10 to 20',
|
||||
},
|
||||
equippedAmmo: {
|
||||
code: 'aqv',
|
||||
name: 'Arrows',
|
||||
},
|
||||
})
|
||||
|
||||
// Mouse down right-click at East (1110, 556)
|
||||
scene.canvas.dispatchEvent(
|
||||
new SyntheticPointerEvent('pointerdown', {
|
||||
clientX: 1110,
|
||||
clientY: 556,
|
||||
button: 2,
|
||||
})
|
||||
)
|
||||
|
||||
// Attack #1 fired immediately on pointerdown (tick 0)
|
||||
expect(scene.engine.metrics.casts).toBe(1)
|
||||
expect(scene.engine.world.player.cooldown).toBe(14)
|
||||
|
||||
// Ticks 1..14: Cooldown is ticking down. No new attack must fire!
|
||||
for (let tick = 1; tick <= 14; tick++) {
|
||||
tickScene(scene)
|
||||
expect(scene.engine.metrics.casts, `Tick ${tick} should not fire early`).toBe(1)
|
||||
}
|
||||
expect(scene.engine.world.player.cooldown).toBe(0)
|
||||
|
||||
// Tick 15: Exactly 14 ticks after tick 1. Continuous attack #2 fires!
|
||||
tickScene(scene)
|
||||
expect(scene.engine.metrics.casts).toBe(2)
|
||||
expect(scene.engine.world.player.cooldown).toBe(13) // reset to 14, minus 1
|
||||
|
||||
// Ticks 16..28: 13 ticks of cooldown
|
||||
for (let tick = 16; tick <= 28; tick++) {
|
||||
tickScene(scene)
|
||||
expect(scene.engine.metrics.casts).toBe(2)
|
||||
}
|
||||
expect(scene.engine.world.player.cooldown).toBe(0)
|
||||
|
||||
// Tick 29: Exactly 14 ticks after tick 15. Continuous attack #3 fires!
|
||||
tickScene(scene)
|
||||
expect(scene.engine.metrics.casts).toBe(3)
|
||||
|
||||
// Release right mouse button
|
||||
scene.canvas.dispatchEvent(
|
||||
new SyntheticPointerEvent('pointerup', {
|
||||
clientX: 1110,
|
||||
clientY: 556,
|
||||
button: 2,
|
||||
})
|
||||
)
|
||||
|
||||
// Subsequent ticks should NOT fire any more attacks
|
||||
for (let tick = 30; tick <= 50; tick++) {
|
||||
tickScene(scene)
|
||||
}
|
||||
expect(scene.engine.metrics.casts).toBe(3)
|
||||
})
|
||||
|
||||
it('attacks continuously at 14-tick cooldown intervals when holding right-click on a melee monster target', () => {
|
||||
const scene = createCombatTestScene({
|
||||
playerPos: { x: 500, y: 500 },
|
||||
rightSkillId: 0,
|
||||
equippedWeapon: {
|
||||
code: 'crs',
|
||||
name: 'Crystal Sword',
|
||||
damage: '20 to 30', // avg damage = 25
|
||||
},
|
||||
monsters: [{ id: 'dummy_mon', name: 'Dummy Monster', x: 530, y: 500, hp: 100 }],
|
||||
})
|
||||
|
||||
const monster = scene.engine.world.monsters[0]!
|
||||
expect(monster.hp).toBe(100)
|
||||
|
||||
// Click directly on monster (distance 30 <= reach 52)
|
||||
const clientX = 960 + 30
|
||||
const clientY = 556
|
||||
scene.canvas.dispatchEvent(
|
||||
new SyntheticPointerEvent('pointerdown', {
|
||||
clientX,
|
||||
clientY,
|
||||
button: 2,
|
||||
})
|
||||
)
|
||||
|
||||
// Hit #1 fired on pointerdown
|
||||
expect(monster.hp).toBe(75) // 100 - 25
|
||||
expect(scene.engine.world.player.cooldown).toBe(14)
|
||||
|
||||
// Ticks 1..14: cooldown ticks down, no extra damage
|
||||
for (let tick = 1; tick <= 14; tick++) {
|
||||
tickScene(scene)
|
||||
expect(monster.hp).toBe(75)
|
||||
}
|
||||
expect(scene.engine.world.player.cooldown).toBe(0)
|
||||
|
||||
// Tick 15: Hit #2 fires (interval of 14 ticks)
|
||||
tickScene(scene)
|
||||
expect(monster.hp).toBe(50) // 75 - 25
|
||||
expect(scene.engine.world.player.cooldown).toBe(13)
|
||||
|
||||
// Ticks 16..28: cooldown ticks down
|
||||
for (let tick = 16; tick <= 28; tick++) {
|
||||
tickScene(scene)
|
||||
expect(monster.hp).toBe(50)
|
||||
}
|
||||
expect(scene.engine.world.player.cooldown).toBe(0)
|
||||
|
||||
// Tick 29: Hit #3 fires (interval of 14 ticks)
|
||||
tickScene(scene)
|
||||
expect(monster.hp).toBe(25) // 50 - 25
|
||||
|
||||
// Release right mouse button
|
||||
scene.canvas.dispatchEvent(
|
||||
new SyntheticPointerEvent('pointerup', {
|
||||
clientX,
|
||||
clientY,
|
||||
button: 2,
|
||||
})
|
||||
)
|
||||
|
||||
// Ticks 30..50: No further attacks
|
||||
for (let tick = 30; tick <= 50; tick++) {
|
||||
tickScene(scene)
|
||||
}
|
||||
expect(monster.hp).toBe(25)
|
||||
})
|
||||
|
||||
it('attacks continuously at exact 12-tick interval for unarmed combat', () => {
|
||||
const scene = createCombatTestScene({
|
||||
playerPos: { x: 500, y: 500 },
|
||||
rightSkillId: 0,
|
||||
equippedWeapon: null, // unarmed (cooldown 12)
|
||||
monsters: [{ id: 'punching_bag', name: 'Punching Bag', x: 530, y: 500, hp: 100 }],
|
||||
})
|
||||
|
||||
const monster = scene.engine.world.monsters[0]!
|
||||
let hitsCount = 0
|
||||
|
||||
// Pointer down on monster
|
||||
scene.canvas.dispatchEvent(
|
||||
new SyntheticPointerEvent('pointerdown', {
|
||||
clientX: 960 + 30,
|
||||
clientY: 556,
|
||||
button: 2,
|
||||
})
|
||||
)
|
||||
hitsCount++ // hit #1 at tick 0
|
||||
expect(scene.engine.world.player.cooldown).toBe(12)
|
||||
|
||||
// Simulate 37 ticks: hits should occur at tick 13 (after 12 ticks), tick 25 (+12 ticks), tick 37 (+12 ticks)
|
||||
const hitTicks: number[] = []
|
||||
for (let tick = 1; tick <= 37; tick++) {
|
||||
const prevHp = monster.hp
|
||||
tickScene(scene)
|
||||
if (monster.hp < prevHp) {
|
||||
hitTicks.push(tick)
|
||||
hitsCount++
|
||||
}
|
||||
}
|
||||
|
||||
expect(hitsCount).toBe(4) // ticks 0, 13, 25, 37
|
||||
expect(hitTicks).toEqual([13, 25, 37])
|
||||
// Assert each interval between consecutive hits is exactly 12 ticks
|
||||
expect(hitTicks[1] - hitTicks[0]).toBe(12)
|
||||
expect(hitTicks[2] - hitTicks[1]).toBe(12)
|
||||
})
|
||||
|
||||
it('attacks continuously at exact 16-tick interval for crossbow combat', () => {
|
||||
const scene = createCombatTestScene({
|
||||
playerPos: { x: 500, y: 500 },
|
||||
rightSkillId: 0,
|
||||
equippedWeapon: {
|
||||
code: 'lxb',
|
||||
name: 'Light Crossbow',
|
||||
damage: '10 to 20',
|
||||
},
|
||||
equippedAmmo: {
|
||||
code: 'cqv',
|
||||
name: 'Bolts',
|
||||
},
|
||||
})
|
||||
|
||||
scene.canvas.dispatchEvent(
|
||||
new SyntheticPointerEvent('pointerdown', {
|
||||
clientX: 1110,
|
||||
clientY: 556,
|
||||
button: 2,
|
||||
})
|
||||
)
|
||||
|
||||
expect(scene.engine.metrics.casts).toBe(1) // projectile #1 at tick 0
|
||||
expect(scene.engine.world.player.cooldown).toBe(16)
|
||||
|
||||
// Simulate 33 ticks: new projectile should fire at tick 17 (+16 ticks) and tick 33 (+16 ticks)
|
||||
const fireTicks: number[] = []
|
||||
for (let tick = 1; tick <= 33; tick++) {
|
||||
const prevCount = scene.engine.metrics.casts
|
||||
tickScene(scene)
|
||||
if (scene.engine.metrics.casts > prevCount) {
|
||||
fireTicks.push(tick)
|
||||
}
|
||||
}
|
||||
|
||||
expect(scene.engine.metrics.casts).toBe(3)
|
||||
expect(fireTicks).toEqual([17, 33])
|
||||
expect(fireTicks[1] - fireTicks[0]).toBe(16)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,12 +1,5 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
CLASS_EXCLUSIVE_SETS,
|
||||
STARTER_STASH_ITEMS,
|
||||
CLASS_STARTER_ATTRS,
|
||||
CLASS_STARTER_HARD_POINTS,
|
||||
CLASS_DEFAULT_SKILLS,
|
||||
createStarterProfileForClass,
|
||||
} from '../src/common/units/class-starter-profiles.ts'
|
||||
import { CLASS_EXCLUSIVE_SETS, STARTER_STASH_ITEMS, CLASS_STARTER_ATTRS, CLASS_STARTER_HARD_POINTS } from '../src/common/units/class-starter-profiles.ts'
|
||||
import { HudManager } from '../src/client/ui/hud-manager.ts'
|
||||
import { SkillTreePanel, CLASS_VERTICAL_TABS, CLASS_SKILL_TREES } from '../src/client/ui/skill-tree-panel.ts'
|
||||
import { resolveItemSpriteRect, type EquipSlotId } from '../src/client/ui/inventory.ts'
|
||||
|
|
@ -210,123 +203,14 @@ describe('Issue #379: 7-Class Starter Profiles, Exclusive Sets, Stash Treasures
|
|||
expect(panel.getSkillTree().length).toBe(30)
|
||||
expect(panel.getVerticalTabs()[0].titleZh).toBe('防御')
|
||||
|
||||
// Load specific profile state
|
||||
const profile = createStarterProfileForClass('bar')
|
||||
panel.loadProfile(profile.classCode, profile.hardPoints, profile.unspentSkillPoints, profile.activeSkillTab)
|
||||
// Load the Barbarian starter skill allocation
|
||||
const barHardPoints = new Map(
|
||||
Object.entries(CLASS_STARTER_HARD_POINTS.bar).map(([id, pts]): [number, number] => [Number(id), pts]),
|
||||
)
|
||||
panel.loadProfile('bar', barHardPoints, 12, 0)
|
||||
expect(panel.characterClass).toBe('bar')
|
||||
expect(panel.getHardPoints(151)).toBe(20) // Whirlwind 20
|
||||
expect(panel.unspentSkillPoints).toBe(12)
|
||||
})
|
||||
})
|
||||
|
||||
describe('4. HudManager Multi-Class State Switching & Persistence', () => {
|
||||
const mockCanvas = {
|
||||
width: 800,
|
||||
height: 600,
|
||||
style: { cursor: '' },
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
getContext: () => null,
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
it('initializes with Sorceress exclusive set and stash treasures', () => {
|
||||
const hud = new HudManager(mockCanvas, {
|
||||
onToggleAutomap: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
})
|
||||
|
||||
hud.switchClass('sor')
|
||||
expect(hud.currentClass).toBe('sor')
|
||||
// Body has Tal Rasha set only
|
||||
expect(hud.inventory.equipped.helm?.name).toContain("Tal Rasha's Horadric Crest")
|
||||
expect(hud.inventory.equipped.armor?.name).toContain("Tal Rasha's Guardianship")
|
||||
expect(hud.inventory.equipped.weapon1?.name).toContain("Tal Rasha's Lidless Eye")
|
||||
expect(hud.inventory.equipped.weapon2).toBeUndefined()
|
||||
expect(hud.inventory.equipped.gloves).toBeUndefined()
|
||||
expect(hud.inventory.equipped.boots).toBeUndefined()
|
||||
|
||||
// Stash has 30 treasure items and 2.5m gold
|
||||
expect(hud.worldPanels.stashItems.length).toBe(30)
|
||||
expect(hud.worldPanels.stashGold).toBe(2_500_000)
|
||||
})
|
||||
|
||||
it('seamlessly switches between classes, updating inventory, stash, skills, and attributes', () => {
|
||||
const hud = new HudManager(mockCanvas, {
|
||||
onToggleAutomap: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
})
|
||||
|
||||
// Switch to Paladin
|
||||
hud.switchClass('pal')
|
||||
expect(hud.currentClass).toBe('pal')
|
||||
expect(hud.charSheet.attrs.classNameZh).toBe('圣骑士')
|
||||
expect(hud.inventory.equipped.helm?.name).toContain("Griswold's Valor")
|
||||
expect(hud.inventory.equipped.weapon1?.name).toContain("Griswold's Redemption")
|
||||
expect(hud.inventory.equipped.weapon2?.name).toContain("Griswold's Honor")
|
||||
expect(hud.inventory.equipped.gloves).toBeUndefined()
|
||||
expect(hud.hotkeys.leftSkillId).toBe(96) // Sacrifice
|
||||
expect(hud.hotkeys.rightSkillId).toBe(112) // Blessed Hammer
|
||||
expect(hud.worldPanels.stashItems.length).toBe(30)
|
||||
|
||||
// Switch to Barbarian
|
||||
hud.switchClass('bar')
|
||||
expect(hud.currentClass).toBe('bar')
|
||||
expect(hud.charSheet.attrs.classNameZh).toBe('野蛮人')
|
||||
expect(hud.inventory.equipped.helm?.name).toContain("Immortal King's Will")
|
||||
expect(hud.inventory.equipped.weapon1?.name).toContain("Immortal King's Stone Crusher")
|
||||
expect(hud.inventory.equipped.boots?.name).toContain("Immortal King's Pillar")
|
||||
expect(hud.skillTree.getHardPoints(151)).toBe(20) // Whirlwind
|
||||
|
||||
// Switch to Amazon
|
||||
hud.switchClass('ama')
|
||||
expect(hud.currentClass).toBe('ama')
|
||||
expect(hud.inventory.equipped.weapon1?.name).toContain("M'avina's Caster")
|
||||
|
||||
// Switch to Necromancer
|
||||
hud.switchClass('nec')
|
||||
expect(hud.currentClass).toBe('nec')
|
||||
expect(hud.inventory.equipped.weapon2?.name).toContain("Trang-Oul's Wing")
|
||||
|
||||
// Switch to Druid
|
||||
hud.switchClass('dru')
|
||||
expect(hud.currentClass).toBe('dru')
|
||||
expect(hud.inventory.equipped.weapon1?.name).toContain("Aldur's Rhythm")
|
||||
|
||||
// Switch to Assassin
|
||||
hud.switchClass('ass')
|
||||
expect(hud.currentClass).toBe('ass')
|
||||
expect(hud.inventory.equipped.weapon1?.name).toContain("Natalya's Mark")
|
||||
})
|
||||
|
||||
it('preserves individual class modifications across switches', () => {
|
||||
const hud = new HudManager(mockCanvas, {
|
||||
onToggleAutomap: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
})
|
||||
|
||||
// Paladin: deposit gold and spend a skill point
|
||||
hud.switchClass('pal')
|
||||
hud.worldPanels.stashGold = 1_800_000
|
||||
hud.skillTree.unspentSkillPoints = 5
|
||||
|
||||
// Switch to Sorceress: has her own gold & skill points
|
||||
hud.switchClass('sor')
|
||||
expect(hud.worldPanels.stashGold).toBe(2_500_000)
|
||||
expect(hud.skillTree.unspentSkillPoints).toBe(12)
|
||||
|
||||
// Modify Sorceress
|
||||
hud.worldPanels.stashGold = 2_000_000
|
||||
hud.skillTree.unspentSkillPoints = 10
|
||||
|
||||
// Switch back to Paladin: modifications were preserved!
|
||||
hud.switchClass('pal')
|
||||
expect(hud.worldPanels.stashGold).toBe(1_800_000)
|
||||
expect(hud.skillTree.unspentSkillPoints).toBe(5)
|
||||
|
||||
// Switch back to Sorceress: modifications were preserved!
|
||||
hud.switchClass('sor')
|
||||
expect(hud.worldPanels.stashGold).toBe(2_000_000)
|
||||
expect(hud.skillTree.unspentSkillPoints).toBe(10)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -65,10 +65,6 @@ import {
|
|||
applyLightingInputs,
|
||||
LIGHTING_PRESETS,
|
||||
} from '../../src/client/scene/lighting-presets.ts'
|
||||
import {
|
||||
resolveClientCanvasPoint,
|
||||
translateScreenClickToClientCommand,
|
||||
} from '../../src/client/scene/mouse-controller.ts'
|
||||
import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts'
|
||||
import {
|
||||
computeDockingLayout,
|
||||
|
|
@ -370,133 +366,6 @@ describe('Dual Viewport Profiles, HudManager Anchoring & Coordinate Transforms (
|
|||
})
|
||||
|
||||
describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F17)', () => {
|
||||
it('builds depth-sorted unit, missile (Trans: 1 additive), and overlay drawables and translates clicks to ClientCommands', () => {
|
||||
const automap = new AutomapView({ initialMode: 'level' })
|
||||
const levelView = automap.setContext(0x12345678, 0, 1)
|
||||
|
||||
const units: RenderableUnitInput[] = [
|
||||
{
|
||||
kind: 'player',
|
||||
id: 1,
|
||||
classId: 1, // Sorceress
|
||||
x: 100,
|
||||
y: 100,
|
||||
states: [32], // BattleOrders overlay
|
||||
},
|
||||
{
|
||||
kind: 'monster',
|
||||
id: 50,
|
||||
classId: 10,
|
||||
x: 104,
|
||||
y: 100,
|
||||
lifePct: 80,
|
||||
name: 'Fallen',
|
||||
states: [1], // Freeze blue tint
|
||||
},
|
||||
{
|
||||
kind: 'missile',
|
||||
id: 200,
|
||||
classId: 62, // Missiles.txt row 62 = fireball (Trans: 1 additive)
|
||||
x: 102,
|
||||
y: 100,
|
||||
targetX: 110,
|
||||
targetY: 100,
|
||||
},
|
||||
{
|
||||
kind: 'missile',
|
||||
id: 201,
|
||||
classId: 0, // Missiles.txt row 0 = arrow (Trans: 0 normal)
|
||||
x: 101,
|
||||
y: 101,
|
||||
},
|
||||
]
|
||||
|
||||
const drawables = buildSceneDrawables(units, { tick: 5 })
|
||||
expect(drawables.unitDrawables).toHaveLength(2)
|
||||
expect(drawables.missileDrawables).toHaveLength(2)
|
||||
expect(drawables.overlayDrawables.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
const fireball = drawables.missileDrawables.find((m) => m.unitId === 200)!
|
||||
const arrow = drawables.missileDrawables.find((m) => m.unitId === 201)!
|
||||
expect(fireball.blendMode).toBe('additive')
|
||||
expect(fireball.lightRadius).toBeGreaterThan(0)
|
||||
expect(arrow.blendMode).toBe('normal')
|
||||
|
||||
const frozenMonster = drawables.unitDrawables.find((u) => u.unitId === 50)!
|
||||
expect(frozenMonster.tintColor).toEqual([90, 155, 255])
|
||||
|
||||
const source = new ClientWorldSceneSource(
|
||||
(): ClientSceneWorldAdapter => ({
|
||||
tick: 10,
|
||||
actId: 1,
|
||||
levelId: 1,
|
||||
playerSubX: 100,
|
||||
playerSubY: 100,
|
||||
units,
|
||||
levelView,
|
||||
}),
|
||||
'1024x768',
|
||||
)
|
||||
|
||||
// Click directly on monster at (104, 100) in 1024x768
|
||||
const monsterScreen = subTileToScreen(104, 100, 100, 100, VIEWPORT_1024x768)
|
||||
const attackCmd = translateScreenClickToClientCommand(
|
||||
monsterScreen.x,
|
||||
monsterScreen.y - 16,
|
||||
source,
|
||||
{ button: 'left', runMode: true },
|
||||
)
|
||||
expect(attackCmd).toEqual({
|
||||
type: 'RunToTarget',
|
||||
unitKind: 'monster',
|
||||
unitId: asUnitId(50),
|
||||
})
|
||||
|
||||
// Right-click in empty space casts right skill on location
|
||||
const castRightCmd = translateScreenClickToClientCommand(200, 200, source, {
|
||||
button: 'right',
|
||||
})
|
||||
expect(castRightCmd.type).toBe('CastRightSkillOnLocation')
|
||||
|
||||
// Shift + Left-click in empty space casts left skill on location
|
||||
const shiftLeftCmd = translateScreenClickToClientCommand(200, 200, source, {
|
||||
button: 'left',
|
||||
shiftHeld: true,
|
||||
})
|
||||
expect(shiftLeftCmd.type).toBe('CastLeftSkillOnLocation')
|
||||
|
||||
// Canvas CSS scaling helper
|
||||
const normPt = resolveClientCanvasPoint(
|
||||
{ clientX: 512, clientY: 384 },
|
||||
{ left: 0, top: 0, width: 1024, height: 768 },
|
||||
getViewportProfile('1024x768'),
|
||||
)
|
||||
expect(normPt).toEqual({ x: 512, y: 384 })
|
||||
|
||||
// Lighting pass via applyLightingInputs & renderSceneSourceFrame
|
||||
const lightGrid = new LightGrid()
|
||||
let lastLightingCall: unknown = 'unset'
|
||||
const mockRenderer = {
|
||||
setLighting(params: unknown) {
|
||||
lastLightingCall = params
|
||||
},
|
||||
}
|
||||
|
||||
const snapTorch = renderSceneSourceFrame({
|
||||
source,
|
||||
lightingPreset: 'torch',
|
||||
lightingTargets: { lightGrid, renderer: mockRenderer },
|
||||
})
|
||||
expect(snapTorch.viewportProfile.width).toBe(1024)
|
||||
expect(lastLightingCall).not.toBeNull()
|
||||
|
||||
applyLightingInputs(source.buildLightingInputs('fullbright'), {
|
||||
lightGrid,
|
||||
renderer: mockRenderer,
|
||||
})
|
||||
expect(lastLightingCall).toBeNull()
|
||||
expect(LIGHTING_PRESETS.fullbright.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes D2GS world sub-tiles by LevelView.subOriginX/Y and submits tile + unit quads to SpriteRenderer', () => {
|
||||
const automap = new AutomapView({ initialMode: 'level' })
|
||||
|
|
|
|||
|
|
@ -1,206 +0,0 @@
|
|||
import { describe, test, expect } from 'vitest'
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { buildBounds, intersects, viewportRect, CULL_MARGIN } from '../src/client/render/cull.ts'
|
||||
|
||||
// 视口剔除的回归护栏。
|
||||
//
|
||||
// 背景(见 issue #16):主循环原本把整张地图无条件提交给渲染器,实测 365 张烘焙
|
||||
// 关卡平均每帧 2 848 个四边形、最差 14 400 个,而真正落在视口里的只有 7%~11%。
|
||||
// 剔除本身不难,难的是**别把画家顺序弄坏**:墙和对象是按深度两路归并出来的,
|
||||
// 一旦剔除时让某一路的游标停住,后面所有东西的前后遮挡关系就全乱了。
|
||||
//
|
||||
// 所以这里除了边界条件,还用真实关卡数据做一次全量重放,断言"开剔除后画出来的
|
||||
// 序列,必须是不开剔除时那个序列的子序列"——顺序一致、只少不多、不多不错。
|
||||
|
||||
/** 一个最小可绘制物。 */
|
||||
function drawable(x: number, y: number, width: number, height: number) {
|
||||
return { x, y, frame: { width, height } }
|
||||
}
|
||||
|
||||
describe('视口剔除', () => {
|
||||
test('包围盒摊平成 [x0, y0, x1, y1]', () => {
|
||||
const bounds = buildBounds([drawable(10, 20, 80, 40), drawable(-5, -6, 1, 2)])
|
||||
expect(Array.from(bounds)).toEqual([10, 20, 90, 60, -5, -6, -4, -4])
|
||||
})
|
||||
|
||||
test('空列表得到空包围盒', () => {
|
||||
expect(buildBounds([]).length).toBe(0)
|
||||
})
|
||||
|
||||
test('视口矩形与顶点着色器用同一个半宽公式', () => {
|
||||
const view = viewportRect(1000, 500, 1280, 720, 2)
|
||||
expect(view.left).toBe(1000 - 1280 / 4 - CULL_MARGIN)
|
||||
expect(view.right).toBe(1000 + 1280 / 4 + CULL_MARGIN)
|
||||
expect(view.top).toBe(500 - 720 / 4 - CULL_MARGIN)
|
||||
expect(view.bottom).toBe(500 + 720 / 4 + CULL_MARGIN)
|
||||
})
|
||||
|
||||
test('缩小时视口变大,放大时视口变小', () => {
|
||||
const wide = viewportRect(0, 0, 1280, 720, 0.5)
|
||||
const tight = viewportRect(0, 0, 1280, 720, 4)
|
||||
expect(wide.right - wide.left).toBeGreaterThan(tight.right - tight.left)
|
||||
})
|
||||
|
||||
test('视口内的留下,视口外的剔掉', () => {
|
||||
const view = viewportRect(0, 0, 200, 200, 1)
|
||||
// 半宽 100 + 余量 32 = 132。
|
||||
const bounds = buildBounds([
|
||||
drawable(0, 0, 10, 10), // 正中
|
||||
drawable(1000, 0, 10, 10), // 右边远处
|
||||
drawable(0, -5000, 10, 10), // 上方远处
|
||||
])
|
||||
expect(intersects(bounds, 0, view)).toBe(true)
|
||||
expect(intersects(bounds, 1, view)).toBe(false)
|
||||
expect(intersects(bounds, 2, view)).toBe(false)
|
||||
})
|
||||
|
||||
test('只有一角搭进视口也算可见', () => {
|
||||
const view = viewportRect(0, 0, 200, 200, 1)
|
||||
// 左上角在视口右下角外,但自身右下角刚好压线。
|
||||
const bounds = buildBounds([drawable(132, 132, 10, 10)])
|
||||
expect(intersects(bounds, 0, view)).toBe(true)
|
||||
})
|
||||
|
||||
test('大精灵即使锚点在屏外也不能被剔掉', () => {
|
||||
const view = viewportRect(0, 0, 200, 200, 1)
|
||||
// 资源包里最大的帧是 160×960:锚点远在视口上方,画出来的部分却盖住整屏。
|
||||
const bounds = buildBounds([drawable(0, -900, 160, 960)])
|
||||
expect(intersects(bounds, 0, view)).toBe(true)
|
||||
// 高度换成 10 之后就真的看不见了,说明上面那条靠的是帧尺寸而不是坐标。
|
||||
expect(intersects(buildBounds([drawable(0, -900, 160, 10)]), 0, view)).toBe(false)
|
||||
})
|
||||
|
||||
test('余量让刚出界的一圈继续留下', () => {
|
||||
const view = viewportRect(0, 0, 200, 200, 1)
|
||||
// 半宽 100,落在 100~132 之间的只有靠余量才留得住。
|
||||
expect(intersects(buildBounds([drawable(120, 0, 1, 1)]), 0, view)).toBe(true)
|
||||
expect(intersects(buildBounds([drawable(140, 0, 1, 1)]), 0, view)).toBe(false)
|
||||
})
|
||||
|
||||
test('换关卡时重建包围盒避免越界误剔除', () => {
|
||||
// 模拟从 57x41 关卡(2337 个图元)切换到 80x80 关卡(6400 个图元)
|
||||
const smallLevelDrawables = Array.from({ length: 2337 }, (_, i) => drawable(i * 10, 0, 80, 40))
|
||||
const largeLevelDrawables = Array.from({ length: 6400 }, () => drawable(0, 0, 80, 40))
|
||||
|
||||
const view = viewportRect(0, 0, 1280, 720, 1)
|
||||
|
||||
// 旧实现未重建 bounds,largeLevel 使用 smallLevel 的 stale bounds
|
||||
const staleBounds = buildBounds(smallLevelDrawables)
|
||||
let visibleWithStaleBounds = 0
|
||||
for (let i = 0; i < largeLevelDrawables.length; i += 1) {
|
||||
if (intersects(staleBounds, i, view)) visibleWithStaleBounds += 1
|
||||
}
|
||||
expect(visibleWithStaleBounds).toBeLessThanOrEqual(2337)
|
||||
|
||||
// 修复后重建 bounds,所有落在视口内的图元正确通过剔除
|
||||
const freshBounds = buildBounds(largeLevelDrawables)
|
||||
let visibleWithFreshBounds = 0
|
||||
for (let i = 0; i < largeLevelDrawables.length; i += 1) {
|
||||
if (intersects(freshBounds, i, view)) visibleWithFreshBounds += 1
|
||||
}
|
||||
expect(visibleWithFreshBounds).toBe(6400)
|
||||
})
|
||||
})
|
||||
|
||||
/** 资源包位置;缺资源时整组测试跳过而不是报红。 */
|
||||
const PACKS = path.resolve(__dirname, '../samples/d2-packs')
|
||||
|
||||
/** 所有含 scene.json 的关卡目录。 */
|
||||
function levelDirs(): { name: string; scene: string }[] {
|
||||
if (!fs.existsSync(PACKS)) return []
|
||||
const out: { name: string; scene: string }[] = []
|
||||
for (const act of fs.readdirSync(PACKS)) {
|
||||
const actPath = path.join(PACKS, act)
|
||||
if (!fs.statSync(actPath).isDirectory()) continue
|
||||
for (const level of fs.readdirSync(actPath)) {
|
||||
const scene = path.join(actPath, level, 'scene.json')
|
||||
if (fs.existsSync(scene)) out.push({ name: `${act}/${level}`, scene })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
interface Item { x: number; y: number; frame: { width: number; height: number }; depth: number }
|
||||
|
||||
/** 把一张 scene.json 摊成场景实际持有的四条绘制列表。 */
|
||||
function loadLevel(scenePath: string) {
|
||||
const json = JSON.parse(fs.readFileSync(scenePath, 'utf8')) as Record<string, any>
|
||||
const place = (json.framePlacement ?? []) as number[][]
|
||||
const tiles = (rows: number[][] | undefined): Item[] => (rows ?? []).map(row => {
|
||||
const [frameIndex, x, y, cellX, cellY] = row
|
||||
const p = place[frameIndex!] ?? [0, 0, 0, 0, 0]
|
||||
return { x: x!, y: y!, frame: { width: p[3]!, height: p[4]! }, depth: cellX! + cellY! }
|
||||
})
|
||||
const objs: Item[] = (json.objects ?? [])
|
||||
.filter((o: any) => o.frame)
|
||||
.map((o: any) => ({ x: o.x, y: o.y, frame: { width: o.frame.width, height: o.frame.height }, depth: o.depth ?? 0 }))
|
||||
.sort((a: Item, b: Item) => a.depth - b.depth)
|
||||
const spawn = Array.isArray(json.spawn) ? { x: json.spawn[0] as number, y: json.spawn[1] as number } : { x: 0, y: 0 }
|
||||
return { floors: tiles(json.floors), walls: tiles(json.walls), roofs: tiles(json.roofs), objs, spawn }
|
||||
}
|
||||
|
||||
/**
|
||||
* 重放一张关卡的绘制顺序。
|
||||
*
|
||||
* 这里刻意复刻 `act-scene` 的两路深度归并:剔除只跳过"画"这一步,游标照常前进。
|
||||
*
|
||||
* @param cull - 是否启用视口剔除。
|
||||
* @returns 依次画出的标记,以及剔除数量。
|
||||
*/
|
||||
function replay(level: ReturnType<typeof loadLevel>, cull: boolean) {
|
||||
const view = viewportRect(level.spawn.x, level.spawn.y - 16, 1280, 720, 1)
|
||||
const floorBounds = buildBounds(level.floors)
|
||||
const wallBounds = buildBounds(level.walls)
|
||||
const roofBounds = buildBounds(level.roofs)
|
||||
const objBounds = buildBounds(level.objs)
|
||||
|
||||
const order: string[] = []
|
||||
let culled = 0
|
||||
const emit = (bounds: Float32Array, index: number, item: Item, tag: string): void => {
|
||||
if (cull && !intersects(bounds, index, view)) { culled += 1; return }
|
||||
order.push(`${tag}:${String(item.x)},${String(item.y)}`)
|
||||
}
|
||||
|
||||
for (let i = 0; i < level.floors.length; i += 1) emit(floorBounds, i, level.floors[i]!, 'f')
|
||||
let wi = 0
|
||||
let oi = 0
|
||||
while (wi < level.walls.length || oi < level.objs.length) {
|
||||
const wd = wi < level.walls.length ? level.walls[wi]!.depth : Infinity
|
||||
const od = oi < level.objs.length ? level.objs[oi]!.depth : Infinity
|
||||
if (wd <= od) { emit(wallBounds, wi, level.walls[wi]!, 'w'); wi += 1 }
|
||||
else { emit(objBounds, oi, level.objs[oi]!, 'o'); oi += 1 }
|
||||
}
|
||||
for (let i = 0; i < level.roofs.length; i += 1) emit(roofBounds, i, level.roofs[i]!, 'r')
|
||||
return { order, culled }
|
||||
}
|
||||
|
||||
/** `sub` 是否按相同相对顺序出现在 `full` 里。 */
|
||||
function isSubsequence(sub: string[], full: string[]): boolean {
|
||||
let i = 0
|
||||
for (const item of full) if (i < sub.length && sub[i] === item) i += 1
|
||||
return i === sub.length
|
||||
}
|
||||
|
||||
const levels = levelDirs()
|
||||
|
||||
describe.runIf(levels.length > 0)('剔除后的画家顺序(全量资源包重放)', () => {
|
||||
test('每张关卡剔除后的绘制序列都是原序列的子序列', () => {
|
||||
const broken: string[] = []
|
||||
let before = 0
|
||||
let after = 0
|
||||
for (const { name, scene } of levels) {
|
||||
const level = loadLevel(scene)
|
||||
const full = replay(level, false)
|
||||
const culled = replay(level, true)
|
||||
if (!isSubsequence(culled.order, full.order)) broken.push(name)
|
||||
before += full.order.length
|
||||
after += culled.order.length
|
||||
}
|
||||
// 具名收集,失败时能直接看出是哪几张关卡乱了序。
|
||||
expect(broken).toEqual([])
|
||||
// 顺带守住收益:真剔掉了绝大多数,而不是悄悄退化成"全画"。
|
||||
expect(after).toBeGreaterThan(0)
|
||||
expect(after).toBeLessThan(before * 0.25)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { TOWN_LEVEL_IDS, isTownLevel, parseDensityMultiplier, parseEliteMultiplier, updateSelectorWarningStyle, updateToolbarUrl, rebudgetMonsterPacks } from '../src/client/world/act-runtime.ts'
|
||||
import { TOWN_LEVEL_IDS, isTownLevel, parseDensityMultiplier, parseEliteMultiplier, rebudgetMonsterPacks } from '../src/common/world/scene-population.ts'
|
||||
import type { CombatTerrain, CombatWorld, MonsterPack, MonsterStats } from '../src/server/engine/combat.ts'
|
||||
import { MonsterStreamingManager } from '../src/common/units/monster-streaming.ts'
|
||||
|
||||
|
|
@ -119,66 +119,6 @@ describe('Debug Toolbar: Monster Density & Elite Multipliers (Issue #450)', () =
|
|||
})
|
||||
})
|
||||
|
||||
describe('Non-default warning styling', () => {
|
||||
it('applies red border and non-default class when value is not 1', () => {
|
||||
const select = createMockSelect('2')
|
||||
updateSelectorWarningStyle(select)
|
||||
expect(select.classList.contains('non-default')).toBe(true)
|
||||
expect(select.style.border).toBe('1px solid #e74c3c')
|
||||
})
|
||||
|
||||
it('removes red border and non-default class when value is 1', () => {
|
||||
const select = createMockSelect('1')
|
||||
updateSelectorWarningStyle(select)
|
||||
expect(select.classList.contains('non-default')).toBe(false)
|
||||
expect(select.style.border).toBe('')
|
||||
})
|
||||
|
||||
it('handles all elite non-default styling', () => {
|
||||
const select = createMockSelect('all')
|
||||
updateSelectorWarningStyle(select)
|
||||
expect(select.classList.contains('non-default')).toBe(true)
|
||||
expect(select.style.border).toBe('1px solid #e74c3c')
|
||||
})
|
||||
|
||||
it('gracefully handles null select element', () => {
|
||||
expect(() => { updateSelectorWarningStyle(null) }).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Bidirectional URL sync logic', () => {
|
||||
it('adds non-default density and elite to URL query parameters', () => {
|
||||
const result = updateToolbarUrl('?act=1&level=bloodmoor', { density: 2, elite: 4 })
|
||||
expect(result).toContain('density=2')
|
||||
expect(result).toContain('elite=4')
|
||||
expect(result).toContain('act=1')
|
||||
expect(result).toContain('level=bloodmoor')
|
||||
})
|
||||
|
||||
it('omits density and elite from URL when they are 1 (default)', () => {
|
||||
const initial = '?act=1&level=bloodmoor&density=4&elite=2'
|
||||
const updated = updateToolbarUrl(initial, { density: 1, elite: 1 })
|
||||
expect(updated).not.toContain('density=')
|
||||
expect(updated).not.toContain('elite=')
|
||||
expect(updated).toContain('act=1')
|
||||
expect(updated).toContain('level=bloodmoor')
|
||||
})
|
||||
|
||||
it('handles "all" elite in URL query', () => {
|
||||
const result = updateToolbarUrl('?act=1', { elite: 'all' })
|
||||
expect(result).toContain('elite=all')
|
||||
})
|
||||
|
||||
it('supports full URL strings', () => {
|
||||
const url = 'http://localhost:3000/acts.html?act=2&level=rockywaste'
|
||||
const updated = updateToolbarUrl(url, { density: 8, elite: 2 })
|
||||
expect(updated).toContain('http://localhost:3000/acts.html?')
|
||||
expect(updated).toContain('density=8')
|
||||
expect(updated).toContain('elite=2')
|
||||
expect(updated).toContain('level=rockywaste')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Town safe-zone invariant: strictly 0 density in town levels', () => {
|
||||
it('identifies all 5 canonical town levels across Acts 1..5', () => {
|
||||
expect(TOWN_LEVEL_IDS.size).toBe(5)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { computeObjectPhaseOffsetMs, updateAnimatedObject, updateAnimatedObjects, PERSISTENT_ILLUMINATION_TOKENS, type ObjectDrawable, type AnimatedObjectFrame } from '../src/client/world/act-runtime.ts'
|
||||
import {
|
||||
updateAnimatableTiles,
|
||||
type AnimatableTile,
|
||||
|
|
@ -9,152 +8,6 @@ import {
|
|||
} from '../src/common/world/animated-tiles.ts'
|
||||
|
||||
describe('Dungeon & Wilderness Illumination & Animated Tile Sequences (Issue #398)', () => {
|
||||
describe('PERSISTENT_ILLUMINATION_TOKENS definitions', () => {
|
||||
it('includes all canonical illumination and environmental fire tokens', () => {
|
||||
const requiredTokens = ['TO', 'A1', 'A2', '3O', '3o', 'FX', 'FY', 'FZ', 'BR', 'BF', 'FL']
|
||||
for (const token of requiredTokens) {
|
||||
expect(PERSISTENT_ILLUMINATION_TOKENS.has(token)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Persistent illumination looping parity (updateAnimatedObject)', () => {
|
||||
const dummyAtlasFrame = (i: number) => ({
|
||||
x: i * 32,
|
||||
y: 0,
|
||||
width: 32,
|
||||
height: 64,
|
||||
})
|
||||
|
||||
const createTestIlluminationDrawable = (
|
||||
token: string,
|
||||
frameCount = 10,
|
||||
frameDurationMs = 80,
|
||||
forceCycleAnim?: boolean,
|
||||
): ObjectDrawable => {
|
||||
const frames: AnimatedObjectFrame[] = Array.from({ length: frameCount }, (_, i) => ({
|
||||
frame: dummyAtlasFrame(i),
|
||||
page: 0,
|
||||
offsetX: -16,
|
||||
offsetY: -32,
|
||||
}))
|
||||
|
||||
return {
|
||||
token,
|
||||
frame: frames[0]!.frame,
|
||||
x: 100,
|
||||
y: 200,
|
||||
depth: 60,
|
||||
page: 0,
|
||||
baseX: 100,
|
||||
baseY: 200,
|
||||
animatedFrames: frames,
|
||||
frameDurationMs,
|
||||
cycleAnim: forceCycleAnim,
|
||||
phaseOffsetMs: 0,
|
||||
currentFrameIndex: 0,
|
||||
}
|
||||
}
|
||||
|
||||
it('cycles continuously even if cycleAnim was false for persistent illumination tokens', () => {
|
||||
// Tokens like FX, FY, FZ (environmental fires) or BR (braziers) might have cycleAnim=0 in Objects.txt mode 0
|
||||
const tokensToTest = ['TO', 'A1', 'A2', '3O', '3o', 'FX', 'FY', 'FZ', 'BR', 'BF', 'FL']
|
||||
|
||||
for (const token of tokensToTest) {
|
||||
const obj = createTestIlluminationDrawable(token, 10, 80, false)
|
||||
const totalDuration = 10 * 80 // 800ms
|
||||
|
||||
// At t=0
|
||||
updateAnimatedObject(obj, 0)
|
||||
expect(obj.currentFrameIndex).toBe(0)
|
||||
|
||||
// At t=160ms (frame 2)
|
||||
updateAnimatedObject(obj, 160)
|
||||
expect(obj.currentFrameIndex).toBe(2)
|
||||
|
||||
// Beyond cycle: at t=800ms, should loop back to frame 0 (NOT clamped to frame 9)
|
||||
updateAnimatedObject(obj, 800)
|
||||
expect(obj.currentFrameIndex).toBe(0)
|
||||
|
||||
// At t=1040ms (800 + 3 * 80), should be frame 3
|
||||
updateAnimatedObject(obj, 1040)
|
||||
expect(obj.currentFrameIndex).toBe(3)
|
||||
}
|
||||
})
|
||||
|
||||
it('clamps non-persistent objects when cycleAnim is false', () => {
|
||||
const nonFireObj = createTestIlluminationDrawable('XX', 10, 80, false)
|
||||
// At t=1000ms (> 800ms total duration)
|
||||
updateAnimatedObject(nonFireObj, 1000)
|
||||
expect(nonFireObj.currentFrameIndex).toBe(9) // clamped to last frame
|
||||
})
|
||||
|
||||
it('maintains independent spatial phase offsets across torches and candles', () => {
|
||||
const duration = 20 * 51.2
|
||||
const p1 = computeObjectPhaseOffsetMs(100, 200, duration)
|
||||
const p2 = computeObjectPhaseOffsetMs(104, 208, duration)
|
||||
expect(p1).not.toBe(p2)
|
||||
expect(p1).toBeGreaterThanOrEqual(0)
|
||||
expect(p1).toBeLessThan(duration)
|
||||
})
|
||||
|
||||
it('prevents sprite coordinate drift across frame transitions and loops', () => {
|
||||
const frames: AnimatedObjectFrame[] = [
|
||||
{ frame: dummyAtlasFrame(0), page: 0, offsetX: -10, offsetY: -20 },
|
||||
{ frame: dummyAtlasFrame(1), page: 0, offsetX: -15, offsetY: -25 },
|
||||
{ frame: dummyAtlasFrame(2), page: 0, offsetX: -8, offsetY: -18 },
|
||||
]
|
||||
const obj: ObjectDrawable = {
|
||||
token: 'TO',
|
||||
frame: frames[0]!.frame,
|
||||
x: 100 + frames[0]!.offsetX,
|
||||
y: 200 + frames[0]!.offsetY,
|
||||
depth: 60,
|
||||
page: 0,
|
||||
baseX: 100,
|
||||
baseY: 200,
|
||||
animatedFrames: frames,
|
||||
frameDurationMs: 100,
|
||||
cycleAnim: true,
|
||||
currentFrameIndex: 0,
|
||||
}
|
||||
|
||||
for (let clock = 0; clock <= 3000; clock += 50) {
|
||||
updateAnimatedObject(obj, clock)
|
||||
const expectedFrameIdx = Math.floor(clock / 100) % 3
|
||||
expect(obj.currentFrameIndex).toBe(expectedFrameIdx)
|
||||
const expectedFrame = frames[expectedFrameIdx]!
|
||||
expect(obj.x).toBe(100 + expectedFrame.offsetX)
|
||||
expect(obj.y).toBe(200 + expectedFrame.offsetY)
|
||||
}
|
||||
})
|
||||
|
||||
it('safely handles degenerate frame inputs (empty, single frame, zero/negative duration)', () => {
|
||||
const singleFrameObj: ObjectDrawable = {
|
||||
token: 'TO',
|
||||
frame: dummyAtlasFrame(0),
|
||||
x: 100,
|
||||
y: 200,
|
||||
depth: 60,
|
||||
page: 0,
|
||||
baseX: 100,
|
||||
baseY: 200,
|
||||
animatedFrames: [{ frame: dummyAtlasFrame(0), page: 0, offsetX: 0, offsetY: 0 }],
|
||||
frameDurationMs: 80,
|
||||
currentFrameIndex: 0,
|
||||
}
|
||||
expect(updateAnimatedObject(singleFrameObj, 1000)).toBe(false)
|
||||
expect(singleFrameObj.currentFrameIndex).toBe(0)
|
||||
|
||||
const zeroDurationObj = createTestIlluminationDrawable('TO', 5, 0, true)
|
||||
expect(updateAnimatedObject(zeroDurationObj, 120)).toBe(true)
|
||||
expect(zeroDurationObj.currentFrameIndex).toBe(3)
|
||||
|
||||
const negClockObj = createTestIlluminationDrawable('TO', 5, 40, true)
|
||||
expect(updateAnimatedObject(negClockObj, -100)).toBe(false)
|
||||
expect(negClockObj.currentFrameIndex).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DT1 animated tile serialization and unpacking parity', () => {
|
||||
it('correctly unpacks extended animated tile rows [frameIndex, x, y, cx, cy, ...animFrames]', () => {
|
||||
|
|
@ -250,30 +103,6 @@ describe('Dungeon & Wilderness Illumination & Animated Tile Sequences (Issue #39
|
|||
}
|
||||
})
|
||||
|
||||
it('verifies Cave (Level 10) packs have animated illumination objects', () => {
|
||||
const act1Dir = join(packsDir, 'act1')
|
||||
expect(existsSync(act1Dir)).toBe(true)
|
||||
|
||||
const caveDirs = readdirSync(act1Dir).filter(name => name.startsWith('10-act-1-cave'))
|
||||
expect(caveDirs.length).toBeGreaterThan(0)
|
||||
for (const d of caveDirs) {
|
||||
const scenePath = join(act1Dir, d, 'scene.json')
|
||||
expect(existsSync(scenePath)).toBe(true)
|
||||
const scene = JSON.parse(readFileSync(scenePath, 'utf8'))
|
||||
const illumination = (scene.objects as any[]).filter(o =>
|
||||
o.token && PERSISTENT_ILLUMINATION_TOKENS.has(o.token.trim().toUpperCase())
|
||||
)
|
||||
expect(illumination.length).toBeGreaterThan(0)
|
||||
for (const item of illumination) {
|
||||
if (item.animatedFrames) {
|
||||
expect(item.animatedFrames.length).toBeGreaterThan(1)
|
||||
expect(item.cycleAnim).toBe(true)
|
||||
expect(item.frameDelta).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies Catacombs (Level 34) packs have animated candles (A1, A2) and fires (BF, BR)', () => {
|
||||
const act1Dir = join(packsDir, 'act1')
|
||||
expect(existsSync(act1Dir)).toBe(true)
|
||||
|
|
@ -301,27 +130,6 @@ describe('Dungeon & Wilderness Illumination & Animated Tile Sequences (Issue #39
|
|||
expect(totalCandlesOrFires).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('verifies Wilderness and Crypt maps contain cycleAnim=true on illumination objects', () => {
|
||||
const act1Dir = join(packsDir, 'act1')
|
||||
expect(existsSync(act1Dir)).toBe(true)
|
||||
|
||||
const cryptDirs = readdirSync(act1Dir).filter(name => name.startsWith('18-act-1-crypt') || name.startsWith('19-act-1-crypt'))
|
||||
expect(cryptDirs.length).toBeGreaterThan(0)
|
||||
for (const d of cryptDirs) {
|
||||
const scenePath = join(act1Dir, d, 'scene.json')
|
||||
expect(existsSync(scenePath)).toBe(true)
|
||||
const scene = JSON.parse(readFileSync(scenePath, 'utf8'))
|
||||
const items = (scene.objects as any[]).filter(o =>
|
||||
o.token && PERSISTENT_ILLUMINATION_TOKENS.has(o.token.trim().toUpperCase())
|
||||
)
|
||||
for (const item of items) {
|
||||
if (item.animatedFrames) {
|
||||
expect(item.cycleAnim).toBe(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies DT1 animated tile sequences in scenes that contain animated tiles', () => {
|
||||
const act4Dir = join(packsDir, 'act4')
|
||||
expect(existsSync(act4Dir)).toBe(true)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { findSafeDropPosition } from '../../src/common/items/ground-items.ts'
|
|||
import { COLLIDE_WALL, COLLIDE_BLANK, COLLIDE_MASK_INVALID, COLLIDE_NONE } from '../../src/common/world/d2map.ts'
|
||||
import { BAKED_UI_MANIFEST } from '../../src/common/items/baked-ui-meta.ts'
|
||||
import { BeltHud } from '../../src/client/ui/belt.ts'
|
||||
import { seedDemoBeltPotions } from '../fixtures/demo-profile-data.ts'
|
||||
|
||||
let oracle: DropOracleStack
|
||||
const dropTables = getEmbeddedDropTables()
|
||||
|
|
@ -611,7 +612,7 @@ describe('Tier 2 — Boundary, Corner & Negative Cases (All 17 Features, Issues
|
|||
const belt = new BeltHud()
|
||||
// The belt starts empty; seed the legacy full 16-potion layout explicitly
|
||||
expect(belt.countTotalPotions()).toBe(0)
|
||||
belt.seedDefaultPotions()
|
||||
seedDemoBeltPotions(belt)
|
||||
expect(belt.countTotalPotions()).toBe(16)
|
||||
const extraPotion = { id: 'extra', name: 'Health Potion', col: 0 } as any
|
||||
// Trying to place in any column returns the unplaced potion
|
||||
|
|
@ -629,7 +630,7 @@ describe('Tier 2 — Boundary, Corner & Negative Cases (All 17 Features, Issues
|
|||
|
||||
it('B14.3 picked potion targets column with matching potion type first', () => {
|
||||
const belt = new BeltHud()
|
||||
belt.seedDefaultPotions() // explicit legacy full belt
|
||||
seedDemoBeltPotions(belt) // explicit legacy full belt
|
||||
belt.useSlot(0) // empty top row in col 0 (Health Potion col)
|
||||
expect(belt.grid[3][0]).toBeNull()
|
||||
const newHealth = { id: 'new_hp', name: 'Health Potion' } as any
|
||||
|
|
|
|||
|
|
@ -379,7 +379,7 @@ describe('Tier 1 — Feature Coverage: Module Split & Single Entry Consolidation
|
|||
for (const tool of requiredTools) {
|
||||
expect(existsInRepo(tool), `Expected CLI tool ${tool} to exist`).toBe(true)
|
||||
}
|
||||
expect(collectTsFiles('tools').length).toBeGreaterThanOrEqual(80)
|
||||
expect(collectTsFiles('tools').length).toBeGreaterThanOrEqual(60)
|
||||
})
|
||||
|
||||
it('F5.2: migrates tools/lib/drlg-oracle.ts and tools/d2s-unlock.ts with intact exports', async () => {
|
||||
|
|
@ -527,7 +527,6 @@ describe('Tier 1 — Feature Coverage: Module Split & Single Entry Consolidation
|
|||
'src/client/frontend',
|
||||
'src/client/scene',
|
||||
'src/client/audio',
|
||||
'src/client/sim',
|
||||
'src/client/i18n',
|
||||
'src/client/map',
|
||||
'src/client/session',
|
||||
|
|
|
|||
|
|
@ -622,16 +622,16 @@ describe('Tier 2: F7 — Unified play.html Single-Entry & Legacy Entry Purge Ver
|
|||
}
|
||||
})
|
||||
|
||||
it('F7.B5: src/client/launcher/play-entry.ts contains zero imports from src/server, src/baker, or legacy src/* directories', () => {
|
||||
const source = readRepoFile('src/client/launcher/play-entry.ts')
|
||||
it('F7.B5: src/client/main.ts contains zero imports from src/server, src/baker, or legacy src/* directories', () => {
|
||||
const source = readRepoFile('src/client/main.ts')
|
||||
const specs = extractImportSpecifiers(source)
|
||||
const entryFileAbs = `${ROOT_DIR}/src/client/launcher/play-entry.ts`
|
||||
const entryFileAbs = `${ROOT_DIR}/src/client/main.ts`
|
||||
for (const spec of specs) {
|
||||
const target = resolveImportTargetRel(entryFileAbs, spec)
|
||||
expect(target.startsWith('src/server')).toBe(false)
|
||||
expect(target.startsWith('src/baker')).toBe(false)
|
||||
for (const legacyDir of LEGACY_SRC_DIRS) {
|
||||
expect(target.startsWith(`src/${legacyDir}`)).toBe(false)
|
||||
expect(target.startsWith(`src/${legacyDir}/`)).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
* Covers 16 pairwise & multi-feature integration tests across F1–F10:
|
||||
* - F2 + F5: `common/data` (`D2DataRegistry`) + `server/engine` (`buildGameEngine` / `createPlayerState`)
|
||||
* - F3 + F5: `common/items` (`gems` + `runeword`) + `server/engine` item modifier application
|
||||
* - F3 + F6: `common/items/item-bridge` + `client/ui/inventory-panel` UI descriptor contract
|
||||
* - F3 + F6: `common/units/monsters-data` + `common/units/monster-mapping` + `client/world/act-runtime`
|
||||
* - F3 + F6: `common/items/item-bridge` + `client/ui/inventory` UI descriptor contract
|
||||
* - F3 + F6: `common/units/monsters-data` + `common/units/monster-mapping` + `client/scene/entity-art-loader`
|
||||
* - F1 + F3 + F8: `common/pack-contract` + `common/world/acts` + `baker` universal DT1 contract
|
||||
* - F4 + F5 + F6: `netproto` + `server/loopback-adapter` + `client/session/online-session` + `client/world/client-world`
|
||||
* - F4 + F6: `netproto` S2C packet stream + `OnlineSession` + `ClientWorld`
|
||||
|
|
@ -13,8 +13,8 @@
|
|||
* - F3 + F5: `common/items/inventory-model` + `belt-model` + `server/engine`
|
||||
* - F3 + F5: `common/world/d2map` collision & spawn grounding + `server/world/server-world`
|
||||
* - F2 + F8: `common/data/data-registry` `ArchiveSource` contract + `baker/mpq-archive-source`
|
||||
* - F6 + F7: `play.html` + `vite.config.ts` + `client/launcher/play-entry.ts`
|
||||
* - F7 + F9: `tsconfig.client.json` + `src/client/launcher/play-entry.ts` full transitive import graph
|
||||
* - F6 + F7: `play.html` + `vite.config.ts` + `client/main.ts`
|
||||
* - F7 + F9: `tsconfig.client.json` + `src/client/main.ts` full transitive import graph
|
||||
* - F5 + F9: `tsconfig.server.json` + `src/server/index.ts` full transitive import graph
|
||||
* - F8 + F9: `tsconfig.baker.json` + `src/baker/index.ts` & `tools/pack-all.ts` transitive import graph
|
||||
* - F4 + F6 + F10: `tools/netproto-replay.ts` + `src/netproto` + `src/client/world/client-world.ts`
|
||||
|
|
@ -138,9 +138,9 @@ describe('Tier 3: Pairwise Cross-Feature Interactions (F1–F10)', () => {
|
|||
expect(match.name).toBe('Enigma')
|
||||
})
|
||||
|
||||
it('CF3 (F3 + F6): common/items/item-bridge converts domain items into UI descriptors consumed by client/ui/inventory-panel', async () => {
|
||||
it('CF3 (F3 + F6): common/items/item-bridge converts domain items into UI descriptors consumed by client/ui/inventory', async () => {
|
||||
const bridgeMod = await importRepoModule('src/common/items/item-bridge.ts')
|
||||
const invPanelMod = await importRepoModule('src/client/ui/inventory-panel.ts')
|
||||
const invPanelMod = await importRepoModule('src/client/ui/inventory.ts')
|
||||
expect(invPanelMod).toBeDefined()
|
||||
|
||||
const domainSword = {
|
||||
|
|
@ -172,11 +172,8 @@ describe('Tier 3: Pairwise Cross-Feature Interactions (F1–F10)', () => {
|
|||
it('CF4 (F3 + F6): SuperUnique bosses in common/units/monsters-data map to valid art specs and minion art in common/units/monster-mapping', async () => {
|
||||
const monDataMod = await importRepoModule('src/common/units/monsters-data.ts')
|
||||
const monMapMod = await importRepoModule('src/common/units/monster-mapping.ts')
|
||||
const actRuntimeMod = await importRepoModule(
|
||||
'src/client/world/act-runtime.ts',
|
||||
'src/client/map/act-runtime.ts',
|
||||
)
|
||||
expect(actRuntimeMod).toBeDefined()
|
||||
const entityArtMod = await importRepoModule('src/client/scene/entity-art-loader.ts')
|
||||
expect(entityArtMod).toBeDefined()
|
||||
|
||||
const corpsefire = monDataMod.CANONICAL_SUPERUNIQUES_TABLE.find(
|
||||
(s: any) => s.superunique === 'Corpsefire',
|
||||
|
|
@ -290,9 +287,7 @@ describe('Tier 3: Pairwise Cross-Feature Interactions (F1–F10)', () => {
|
|||
const langMod = await importRepoModule('src/common/i18n/lang.ts')
|
||||
const monDataMod = await importRepoModule('src/common/units/monsters-data.ts')
|
||||
const clientI18n = await importRepoModule(
|
||||
'src/client/i18n/labels.ts',
|
||||
'src/client/i18n/index.ts',
|
||||
'src/client/i18n/monster-names.ts',
|
||||
'src/client/i18n/lang.ts',
|
||||
)
|
||||
expect(clientI18n).toBeDefined()
|
||||
|
||||
|
|
@ -401,15 +396,15 @@ describe('Tier 3: Pairwise Cross-Feature Interactions (F1–F10)', () => {
|
|||
expect(bakerSource).toContain('common')
|
||||
})
|
||||
|
||||
it('CF12 (F6 + F7): play.html script entry, vite.config.ts rollupOptions.input, and src/client/launcher/play-entry.ts are aligned', () => {
|
||||
it('CF12 (F6 + F7): play.html script entry, vite.config.ts rollupOptions.input, and src/client/main.ts are aligned', () => {
|
||||
const playHtml = readRepoFile('play.html')
|
||||
const viteCfg = readRepoFile('vite.config.ts')
|
||||
expect(playHtml).toContain('/src/client/main.ts')
|
||||
expect(viteCfg).toContain('play.html')
|
||||
})
|
||||
|
||||
it('CF13 (F7 + F9): transitive import closure of src/client/launcher/play-entry.ts stays strictly within common, netproto, and client', () => {
|
||||
const reachable = collectTransitiveTsImports('src/client/launcher/play-entry.ts')
|
||||
it('CF13 (F7 + F9): transitive import closure of src/client/main.ts stays strictly within common, netproto, and client', () => {
|
||||
const reachable = collectTransitiveTsImports('src/client/main.ts')
|
||||
expect(reachable.size).toBeGreaterThan(5)
|
||||
const violations: string[] = []
|
||||
for (const rel of reachable) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
* (`tests/e2e-module-split/tier5-adversarial-hardening.test.ts`).
|
||||
*
|
||||
* Covers white-box regression guards and stress probes from Iteration 1:
|
||||
* - adv_BUG_01: `src/client/scene/arena-overlay.ts` zero dynamic/cross-module server imports & fail-fast WorldArenaFactory DI
|
||||
* - adv_BUG_02: zero `.test.ts`/`.spec.ts` files under `src/**` & `tests/frontend/flow-state.test.ts` relocation
|
||||
* - adv_BUG_03: `src/netproto/transport/endpoint.ts` zero `new Function` dynamic imports & fail-fast `NetprotoError` when TCP opener is unregistered
|
||||
* - adv_F1_01..06: `D2DataRegistry` & `getSharedDataRegistry` fail-fast & cache-poisoning guards
|
||||
|
|
@ -15,10 +14,6 @@
|
|||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runBakerCli, runBakerVerify } from '../../src/baker/cli.ts'
|
||||
import {
|
||||
initBrowserArenaMode,
|
||||
registerWorldArenaFactory,
|
||||
} from '../../src/client/scene/arena-overlay.ts'
|
||||
import { OnlineSession } from '../../src/client/session/online-session.ts'
|
||||
import { ClientWorld } from '../../src/client/world/client-world.ts'
|
||||
import {
|
||||
|
|
@ -53,19 +48,7 @@ import {
|
|||
} from './helpers.ts'
|
||||
|
||||
describe('Tier 5 — Adversarial Hardening & Challenger Regression Suite', () => {
|
||||
describe('1. White-Box Boundary & Architecture Regression Guards (adv_BUG_01..03)', () => {
|
||||
it('adv_BUG_01: src/client/scene/arena-overlay.ts has zero dynamic imports or server references and fails fast without WorldArenaFactory', async () => {
|
||||
const code = stripComments(readRepoFile('src/client/scene/arena-overlay.ts'))
|
||||
expect(code).not.toContain('import(')
|
||||
expect(code).not.toContain('world-arena')
|
||||
expect(code).not.toContain("'server'")
|
||||
expect(typeof registerWorldArenaFactory).toBe('function')
|
||||
|
||||
registerWorldArenaFactory(null)
|
||||
await expect(initBrowserArenaMode()).rejects.toThrow(
|
||||
/No WorldArenaFactory registered; registerWorldArenaFactory must be called/i,
|
||||
)
|
||||
})
|
||||
describe('1. White-Box Boundary & Architecture Regression Guards (adv_BUG_02..03)', () => {
|
||||
|
||||
it('adv_BUG_02: zero .test.ts or .spec.ts files exist under src/** and tests/frontend/flow-state.test.ts is present', () => {
|
||||
const srcTestFiles = collectTsFiles('src').filter(
|
||||
|
|
|
|||
|
|
@ -170,7 +170,6 @@ import {
|
|||
applyLightingInputs,
|
||||
LIGHTING_PRESETS,
|
||||
} from '../../src/client/scene/lighting-presets.ts'
|
||||
import { translateScreenClickToClientCommand } from '../../src/client/scene/mouse-controller.ts'
|
||||
import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts'
|
||||
import { computeDockingLayout, computeHudLayout } from '../../src/client/ui/hud-manager.ts'
|
||||
|
||||
|
|
@ -1521,39 +1520,6 @@ describe('Tier 1 — F17: Viewport, Camera, Lighting & World Scene Integration',
|
|||
expect(drawables.missileDrawables[0]!.blendMode).toBe('additive')
|
||||
})
|
||||
|
||||
it('F17.4: ClientWorldSceneSource projects world state and translates screen clicks to ClientCommands', () => {
|
||||
const automap = new AutomapView({ initialMode: 'level' })
|
||||
const levelView = automap.setContext(0x12345678, 0, 1)
|
||||
const units: RenderableUnitInput[] = [
|
||||
{ kind: 'player', id: 1, classId: 1, x: 100, y: 100 },
|
||||
{ kind: 'monster', id: 50, classId: 10, x: 104, y: 100, lifePct: 80, name: 'Fallen' },
|
||||
]
|
||||
const source = new ClientWorldSceneSource(
|
||||
(): ClientSceneWorldAdapter => ({
|
||||
tick: 10,
|
||||
actId: 1,
|
||||
levelId: 1,
|
||||
playerSubX: 100,
|
||||
playerSubY: 100,
|
||||
units,
|
||||
levelView,
|
||||
}),
|
||||
'1024x768',
|
||||
)
|
||||
const monsterScreen = subTileToScreen(104, 100, 100, 100, VIEWPORT_1024x768)
|
||||
const cmd = translateScreenClickToClientCommand(
|
||||
monsterScreen.x,
|
||||
monsterScreen.y - 16,
|
||||
source,
|
||||
{ button: 'left', runMode: true },
|
||||
)
|
||||
expect(cmd).toEqual({
|
||||
type: 'RunToTarget',
|
||||
unitKind: 'monster',
|
||||
unitId: asUnitId(50),
|
||||
})
|
||||
})
|
||||
|
||||
it('F17.5: renderSceneSourceFrame and applyLightingInputs configure torch vs fullbright lighting', () => {
|
||||
const automap = new AutomapView({ initialMode: 'level' })
|
||||
const levelView = automap.setContext(0x12345678, 0, 1)
|
||||
|
|
|
|||
|
|
@ -113,7 +113,6 @@ import {
|
|||
} from '../../src/client/view/scene-source.ts'
|
||||
import { type RenderableUnitInput } from '../../src/client/view/unit-drawables.ts'
|
||||
import { applyLightingInputs } from '../../src/client/scene/lighting-presets.ts'
|
||||
import { translateScreenClickToClientCommand } from '../../src/client/scene/mouse-controller.ts'
|
||||
import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts'
|
||||
import { computeDockingLayout } from '../../src/client/ui/hud-manager.ts'
|
||||
import { AutomapView } from '../../src/client/automap/automap-view.ts'
|
||||
|
|
@ -786,49 +785,6 @@ describe('Tier 3 — Cross-Feature Pairwise Interactions (F1–F20)', () => {
|
|||
s2.close()
|
||||
})
|
||||
|
||||
it('X18 (F11 × F17): ClientWorld units project into ClientWorldSceneSource and screen click translates to ClientCommand', () => {
|
||||
const world = new ClientWorld()
|
||||
world.apply({ type: 'LoadAct', act: 0, mapSeed: 0x12345678, areaId: 1 })
|
||||
world.apply({
|
||||
type: 'UnitAssign',
|
||||
unit: { unitType: 'player', id: 1, classId: 1, x: 100, y: 100, name: 'd2webbot_sorc' },
|
||||
})
|
||||
world.apply({
|
||||
type: 'UnitAssign',
|
||||
unit: { unitType: 'monster', id: 77, classId: 10, x: 106, y: 100, lifePct: 100, name: 'Fallen' },
|
||||
})
|
||||
|
||||
const automap = new AutomapView({ initialMode: 'level' })
|
||||
const levelView = automap.setContext(world.mapSeed, 0, world.areaId)
|
||||
const units: RenderableUnitInput[] = [
|
||||
{ kind: 'player', id: 1, classId: 1, x: 100, y: 100 },
|
||||
{ kind: 'monster', id: 77, classId: 10, x: 106, y: 100, lifePct: 100, name: 'Fallen' },
|
||||
]
|
||||
const source = new ClientWorldSceneSource(
|
||||
(): ClientSceneWorldAdapter => ({
|
||||
tick: 1,
|
||||
actId: 1,
|
||||
levelId: 1,
|
||||
playerSubX: 100,
|
||||
playerSubY: 100,
|
||||
units,
|
||||
levelView,
|
||||
}),
|
||||
'1024x768',
|
||||
)
|
||||
|
||||
const monsterScreen = subTileToScreen(106, 100, 100, 100, VIEWPORT_1024x768)
|
||||
const cmd = translateScreenClickToClientCommand(monsterScreen.x, monsterScreen.y - 16, source, {
|
||||
button: 'right',
|
||||
runMode: true,
|
||||
})
|
||||
expect(cmd).toEqual({
|
||||
type: 'CastRightSkillOnTarget',
|
||||
unitKind: 'monster',
|
||||
unitId: asUnitId(77),
|
||||
})
|
||||
})
|
||||
|
||||
it('X19 (F16 × F17): Toolbar viewport and lighting toggles drive ClientWorldSceneSource and LightGrid rendering', () => {
|
||||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||||
const toolbar = new Toolbar({ store })
|
||||
|
|
|
|||
|
|
@ -94,7 +94,6 @@ import {
|
|||
import { type RenderableUnitInput } from '../../src/client/view/unit-drawables.ts'
|
||||
import { LightGrid } from '../../src/common/drlg/light-grid.ts'
|
||||
import { applyLightingInputs } from '../../src/client/scene/lighting-presets.ts'
|
||||
import { translateScreenClickToClientCommand } from '../../src/client/scene/mouse-controller.ts'
|
||||
import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts'
|
||||
import { computeDockingLayout, computeHudLayout } from '../../src/client/ui/hud-manager.ts'
|
||||
import { AutomapView } from '../../src/client/automap/automap-view.ts'
|
||||
|
|
@ -637,76 +636,6 @@ describe('Tier 4 — End-to-End Real-World Application Scenarios (S1–S12)', ()
|
|||
}
|
||||
})
|
||||
|
||||
it('S9: Interactive screen-click -> A* pathfinding -> movement prediction -> rate-limited C2S -> S2C rubber-band & interpolation loop', () => {
|
||||
const automap = new AutomapView({ initialMode: 'level' })
|
||||
const levelView = automap.setContext(0x12345678, 0, 1)
|
||||
const selfPos = { x: 100, y: 100 }
|
||||
const targetPos = { x: 108, y: 100 }
|
||||
|
||||
const source = new ClientWorldSceneSource(
|
||||
(): ClientSceneWorldAdapter => ({
|
||||
tick: 1,
|
||||
actId: 1,
|
||||
levelId: 1,
|
||||
playerSubX: selfPos.x,
|
||||
playerSubY: selfPos.y,
|
||||
units: [{ kind: 'player', id: 1, classId: 1, x: selfPos.x, y: selfPos.y }],
|
||||
levelView,
|
||||
}),
|
||||
'800x600',
|
||||
)
|
||||
|
||||
const targetScreen = subTileToScreen(targetPos.x, targetPos.y, selfPos.x, selfPos.y, VIEWPORT_800x600)
|
||||
const clickCmd = translateScreenClickToClientCommand(targetScreen.x, targetScreen.y, source, {
|
||||
button: 'left',
|
||||
runMode: true,
|
||||
})
|
||||
expect(clickCmd?.type).toBe('RunToLocation')
|
||||
|
||||
// Compute local A* path and step LocalMovementPredictor
|
||||
const walkable = new Uint8Array(200 * 200).fill(1)
|
||||
const pathRes = findPathLocal(
|
||||
{ subWidth: 200, subHeight: 200, walkable },
|
||||
selfPos.x,
|
||||
selfPos.y,
|
||||
targetPos.x,
|
||||
targetPos.y,
|
||||
{ smooth: true },
|
||||
)
|
||||
expect(pathRes.reachedGoal).toBe(true)
|
||||
|
||||
const predictor = new LocalMovementPredictor({ initialX: selfPos.x, initialY: selfPos.y })
|
||||
predictor.setPath(pathRes.path, true)
|
||||
const stepped = predictor.stepTicks(10)
|
||||
expect(stepped.x).toBeGreaterThan(selfPos.x)
|
||||
|
||||
// Pass command through OutboundRateLimiter and encode to C2S
|
||||
const limiter = new OutboundRateLimiter({ moveMinIntervalMs: 120 })
|
||||
const decision = limiter.tryAcquire(clickCmd!, 1000)
|
||||
expect(decision.allowed).toBe(true)
|
||||
const [wire] = encodeD2gsClientCommand({ type: 'Move', run: true, to: targetPos })
|
||||
expect(wire![0]).toBe(0x03)
|
||||
|
||||
// Simulate S2C 0x15 ReassignPlayer reconciliation + remote unit interpolation
|
||||
const rec = predictor.reconcileServerPosition(targetPos, true)
|
||||
expect(rec.mode).toBe('hard-snap')
|
||||
expect(predictor.stepTicks(0).x).toBe(targetPos.x)
|
||||
|
||||
const interp = new RemoteEntityInterpolator()
|
||||
interp.spawnOrSnap({ unitType: 1, unitId: 50, x: 100, y: 100 })
|
||||
interp.startMove({
|
||||
unitType: 1,
|
||||
unitId: 50,
|
||||
currentX: 100,
|
||||
currentY: 100,
|
||||
targetX: 108,
|
||||
targetY: 100,
|
||||
moveType: 0x01,
|
||||
})
|
||||
interp.update(300)
|
||||
expect(interp.getEntityState(1, 50)!.x).toBeGreaterThan(100)
|
||||
})
|
||||
|
||||
it('S10: End-to-End Audio & Asset streaming workflow (SettingsStore <-> SoundService spatial SFX/BGM + OPFS HTTP Range 206 cache)', async () => {
|
||||
const playedBgm: number[] = []
|
||||
const playedSfx: string[] = []
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ import { AuraScanner } from '../../src/server/engine/aura-scanner.ts'
|
|||
import { SummonManager } from '../../src/server/ai/summon-manager.ts'
|
||||
import { WorldArena } from '../../src/server/engine/world-arena.ts'
|
||||
import { evaluateSkill113c } from '../../src/server/skills/registry.ts'
|
||||
import { UnitSpriteLoader } from '../../src/client/render/unit-sprites.ts'
|
||||
import { createAndCastSkillInArena, evaluateSkillCurve113c } from '../helpers/skill-test-harness.ts'
|
||||
|
||||
describe('R1 Universal 1.13c Skill Engine & 7-Class Sandbox Foundation', () => {
|
||||
|
|
@ -490,34 +489,4 @@ describe('R1 Universal 1.13c Skill Engine & 7-Class Sandbox Foundation', () => {
|
|||
expect(debugState.castCount).toBeGreaterThanOrEqual(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('R1.4: decodes all 7 player classes (am, so, ne, pa, ba, dz, ai), missile .dcc, overlay .dcc, and summon sprites with zero skipped frames', async () => {
|
||||
const mounted = await getSharedMountedArchives()
|
||||
const loader = new UnitSpriteLoader(mounted)
|
||||
|
||||
for (const cls of ['am', 'so', 'ne', 'pa', 'ba', 'dz', 'ai']) {
|
||||
const sheet = await loader.loadPlayerClassSheet(cls, 'sc', 'hth')
|
||||
expect(sheet.directions).toBe(16)
|
||||
expect(sheet.framesPerDirection).toBeGreaterThanOrEqual(6)
|
||||
expect(sheet.skipped).toBe(0)
|
||||
}
|
||||
|
||||
const fireballSprite = await loader.loadMissileSprite('fireball')
|
||||
expect(fireballSprite.groups.length).toBeGreaterThan(0)
|
||||
expect(fireballSprite.groups[0]!.frames.length).toBeGreaterThan(0)
|
||||
|
||||
const ampOverlay = await loader.loadOverlaySprite('cursampdamage')
|
||||
expect(ampOverlay.groups.length).toBeGreaterThan(0)
|
||||
|
||||
const skeletonPet = await loader.loadMonsterOrPetSheet('sk', 'nu', '1hs')
|
||||
expect(skeletonPet.directions).toBeGreaterThanOrEqual(8)
|
||||
expect(skeletonPet.skipped).toBe(0)
|
||||
|
||||
const registry = await getSharedDataRegistry()
|
||||
const arena = new WorldArena({ registry, classCode: 'ass', skillId: 280, slvl: 20 })
|
||||
arena.triggerCast()
|
||||
const dbg = arena.getDebugState()
|
||||
expect(dbg.charToken).toBe('ai') // Assassin uses 'ai' token per PlrType.txt
|
||||
expect(dbg.castCount).toBe(1)
|
||||
}, 20_000)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ import {
|
|||
tickProjectiles,
|
||||
type Projectile,
|
||||
} from '../src/server/skills/skills.ts'
|
||||
import { getSkillRange, drawFireboltProjectile, drawFireExplosion, type LoadedMissileArt } from '../src/client/world/act-runtime.ts'
|
||||
import { getSkillRange } from '../src/common/skills/weapon-combat-meta.ts'
|
||||
import { drawFireboltProjectile, drawFireExplosion, type LoadedMissileArt } from '../src/client/scene/missile-overlay-renderer.ts'
|
||||
import { GameEngine } from '../src/server/engine/engine.ts'
|
||||
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.ts'
|
||||
import { SpriteRenderer, type AtlasHandle } from '../src/client/render/renderer.ts'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,525 @@
|
|||
/**
|
||||
* Test-only legacy demo data (former single-player Sorceress profile).
|
||||
*
|
||||
* These literals used to live in `src/` (`DEFAULT_*_SKILLS`, starter gear/bag, default belt potions) and
|
||||
* seeded the retired single-player HUD. The online client starts from a neutral server-not-ready state, so the
|
||||
* data now exists only as fixtures for widget/tooltip/layout tests.
|
||||
*/
|
||||
|
||||
import type { HotkeySkillEntry } from '../../src/common/skills/skills-meta.ts'
|
||||
import { BELT_COLS, BELT_ROWS, type BeltModel, type BeltPotion } from '../../src/common/items/belt-model.ts'
|
||||
import type { EquipSlotId, GridPlacement, UiInventoryItem } from '../../src/common/items/inventory-model.ts'
|
||||
|
||||
export const DEMO_UNIVERSAL_SKILLS: readonly HotkeySkillEntry[] = [
|
||||
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true },
|
||||
]
|
||||
|
||||
export const DEMO_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [
|
||||
// Universal Skills
|
||||
...DEMO_UNIVERSAL_SKILLS,
|
||||
// Fire Spells (8 active)
|
||||
{ skillId: 36, name: 'Fire Bolt', nameZh: '火弹', level: 5, manaCost: 2.5, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 41, name: 'Inferno', nameZh: '地狱之火', level: 1, manaCost: 0.6, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 46, name: 'Blaze', nameZh: '炽烈之径', level: 1, manaCost: 11, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 8, manaCost: 8.5, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 51, name: 'Fire Wall', nameZh: '火墙', level: 1, manaCost: 22, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 52, name: 'Enchant', nameZh: '强化', level: 1, manaCost: 25, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 56, name: 'Meteor', nameZh: '陨石', level: 6, manaCost: 22, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 62, name: 'Hydra', nameZh: '九头海蛇', level: 1, manaCost: 20, leftUsable: false, rightUsable: true },
|
||||
// Lightning Spells (9 active)
|
||||
{ skillId: 38, name: 'Charged Bolt', nameZh: '充能弹', level: 4, manaCost: 4, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 42, name: 'Static Field', nameZh: '静态力场', level: 5, manaCost: 9, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 43, name: 'Telekinesis', nameZh: '心灵传动', level: 1, manaCost: 7, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 48, name: 'Nova', nameZh: '新星', level: 1, manaCost: 15, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 49, name: 'Lightning', nameZh: '闪电', level: 7, manaCost: 11, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 53, name: 'Chain Lightning', nameZh: '连锁闪电', level: 1, manaCost: 9, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 54, name: 'Teleport', nameZh: '传送', level: 4, manaCost: 21, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 57, name: 'Thunder Storm', nameZh: '雷云风暴', level: 1, manaCost: 19, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 58, name: 'Energy Shield', nameZh: '能量护盾', level: 1, manaCost: 5, leftUsable: false, rightUsable: true },
|
||||
// Cold Spells (9 active)
|
||||
{ skillId: 39, name: 'Ice Bolt', nameZh: '冰弹', level: 1, manaCost: 3, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 40, name: 'Frozen Armor', nameZh: '冰封装甲', level: 3, manaCost: 7, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 44, name: 'Frost Nova', nameZh: '霜之新星', level: 3, manaCost: 11, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 45, name: 'Ice Blast', nameZh: '冰风暴', level: 5, manaCost: 8, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 50, name: 'Shiver Armor', nameZh: '碎冰甲', level: 1, manaCost: 11, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 55, name: 'Glacial Spike', nameZh: '冰尖柱', level: 10, manaCost: 14.5, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 59, name: 'Blizzard', nameZh: '暴风雪', level: 10, manaCost: 27, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 60, name: 'Chilling Armor', nameZh: '寒冰装甲', level: 1, manaCost: 17, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 64, name: 'Frozen Orb', nameZh: '冰封球', level: 12, manaCost: 30, leftUsable: true, rightUsable: true },
|
||||
]
|
||||
|
||||
export const DEMO_EQUIPPED_GEAR: Readonly<Partial<Record<EquipSlotId, UiInventoryItem>>> = {
|
||||
helm: {
|
||||
id: 'eq-shako',
|
||||
code: 'uap',
|
||||
invFile: 'invcap',
|
||||
invtransform: 'cgrn',
|
||||
chrtransform: 'cgrn',
|
||||
name: 'Harlequin Crest',
|
||||
nameZh: '谐角之冠 (Harlequin Crest)',
|
||||
baseNameZh: '军帽 (Shako)',
|
||||
quality: 'unique',
|
||||
invWidth: 2,
|
||||
invHeight: 2,
|
||||
allowedSlots: ['helm'],
|
||||
defense: 141,
|
||||
durability: { current: 12, max: 12 },
|
||||
reqLevel: 62,
|
||||
reqStr: 50,
|
||||
stats: [
|
||||
{ text: '+2 所有技能 (+2 To All Skills)', color: 'blue' },
|
||||
{ text: '+1.5 生命 (依角色等级而定)', color: 'blue' },
|
||||
{ text: '+1.5 法力 (依角色等级而定)', color: 'blue' },
|
||||
{ text: '物理伤害减少 10%', color: 'blue' },
|
||||
{ text: '50% 更佳机会取得魔法装备 (MF)', color: 'blue' },
|
||||
{ text: '+2 所有属性', color: 'blue' },
|
||||
],
|
||||
},
|
||||
amulet: {
|
||||
id: 'eq-maras',
|
||||
code: 'amu',
|
||||
invFile: 'invamu',
|
||||
name: "Mara's Kaleidoscope",
|
||||
nameZh: "马拉的万花筒 (Mara's Kaleidoscope)",
|
||||
baseNameZh: '项链 (Amulet)',
|
||||
quality: 'unique',
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
allowedSlots: ['amulet'],
|
||||
reqLevel: 67,
|
||||
stats: [
|
||||
{ text: '+2 所有技能 (+2 To All Skills)', color: 'blue' },
|
||||
{ text: '+5 所有属性', color: 'blue' },
|
||||
{ text: '所有抗性 +30 (All Resistances +30)', color: 'blue' },
|
||||
],
|
||||
},
|
||||
weapon1: {
|
||||
id: 'eq-oculus',
|
||||
code: 'oba',
|
||||
invFile: 'invob5',
|
||||
invtransform: 'whit',
|
||||
chrtransform: 'whit',
|
||||
name: 'The Oculus',
|
||||
nameZh: '眼球 (The Oculus)',
|
||||
baseNameZh: '涡流水晶 (Swirling Crystal)',
|
||||
quality: 'unique',
|
||||
invWidth: 1,
|
||||
invHeight: 3,
|
||||
allowedSlots: ['weapon1'],
|
||||
damage: '18 - 42',
|
||||
durability: { current: 50, max: 50 },
|
||||
speedText: '法球类 - 普通攻击速度',
|
||||
reqLevel: 42,
|
||||
stats: [
|
||||
{ text: '被击中时有 25% 几率施放等级 1 传送', color: 'blue' },
|
||||
{ text: '+3 法师技能等级', color: 'blue' },
|
||||
{ text: '+30% 快速施法速度 (FCR)', color: 'blue' },
|
||||
{ text: '+20% 增强防御', color: 'blue' },
|
||||
{ text: '+20 体力', color: 'blue' },
|
||||
{ text: '+20 精力', color: 'blue' },
|
||||
{ text: '所有抗性 +20', color: 'blue' },
|
||||
{ text: '+5 每次击杀获得法力', color: 'blue' },
|
||||
{ text: '50% 更佳机会取得魔法装备', color: 'blue' },
|
||||
],
|
||||
},
|
||||
armor: {
|
||||
id: 'eq-tal-armor',
|
||||
code: 'uth',
|
||||
invFile: 'invgth',
|
||||
invtransform: 'dpur',
|
||||
chrtransform: 'dpur',
|
||||
name: "Tal Rasha's Guardianship",
|
||||
nameZh: '塔·拉夏的守护',
|
||||
baseNameZh: '漆甲 (Lacquered Plate)',
|
||||
quality: 'set',
|
||||
invWidth: 2,
|
||||
invHeight: 3,
|
||||
allowedSlots: ['armor'],
|
||||
defense: 941,
|
||||
durability: { current: 55, max: 55 },
|
||||
reqLevel: 71,
|
||||
reqStr: 84,
|
||||
stats: [
|
||||
{ text: '+400 防御', color: 'blue' },
|
||||
{ text: '抗寒 +40%', color: 'blue' },
|
||||
{ text: '抗电 +40%', color: 'blue' },
|
||||
{ text: '抗火 +40%', color: 'blue' },
|
||||
{ text: '魔法伤害减少 15', color: 'blue' },
|
||||
{ text: '88% 更佳机会取得魔法装备', color: 'blue' },
|
||||
],
|
||||
setPieces: [
|
||||
'塔·拉夏的守护 (Tal Rasha\'s Guardianship)',
|
||||
'塔·拉夏的赫拉迪克纹章 (Tal Rasha\'s Horadric Crest)',
|
||||
'塔·拉夏的判决 (Tal Rasha\'s Adjudication)',
|
||||
'塔·拉夏的无睑之眼 (Tal Rasha\'s Lidless Eye)',
|
||||
'塔·拉夏的织细衣服 (Tal Rasha\'s Fine-Spun Cloth)',
|
||||
],
|
||||
setBonuses: [
|
||||
{ text: '+10% 快速再度攻击 (2 件装备)', color: 'green' },
|
||||
{ text: '+3 法师技能等级 (完整套装)', color: 'green' },
|
||||
{ text: '所有抗性 +50 (完整套装)', color: 'green' },
|
||||
{ text: '+150 生命 (完整套装)', color: 'green' },
|
||||
],
|
||||
},
|
||||
weapon2: {
|
||||
id: 'eq-spirit',
|
||||
code: 'uit',
|
||||
invFile: 'invtow',
|
||||
name: 'Spirit Monarch',
|
||||
nameZh: '精神 (Spirit) 统盾',
|
||||
baseNameZh: '统治者大盾 (Monarch)',
|
||||
quality: 'rune',
|
||||
runewordRunes: "'TalThulOrtAmn'",
|
||||
invWidth: 2,
|
||||
invHeight: 3,
|
||||
allowedSlots: ['weapon2'],
|
||||
defense: 148,
|
||||
durability: { current: 148, max: 148 },
|
||||
reqLevel: 54,
|
||||
reqStr: 156,
|
||||
sockets: 4,
|
||||
stats: [
|
||||
{ text: '+2 所有技能 (+2 To All Skills)', color: 'blue' },
|
||||
{ text: '+35% 快速施法速度 (FCR)', color: 'blue' },
|
||||
{ text: '+55% 快速再度攻击 (FHR)', color: 'blue' },
|
||||
{ text: '+250 对远距离攻击的防御', color: 'blue' },
|
||||
{ text: '+22 体力', color: 'blue' },
|
||||
{ text: '+112 法力', color: 'blue' },
|
||||
{ text: '抗寒 +35%', color: 'blue' },
|
||||
{ text: '抗电 +35%', color: 'blue' },
|
||||
{ text: '抗毒 +35%', color: 'blue' },
|
||||
{ text: '+8 魔法吸收', color: 'blue' },
|
||||
],
|
||||
},
|
||||
gloves: {
|
||||
id: 'eq-magefist',
|
||||
code: 'tgl',
|
||||
invFile: 'invtgl',
|
||||
invtransform: 'lgry',
|
||||
name: 'Magefist',
|
||||
nameZh: '法师之拳 (Magefist)',
|
||||
baseNameZh: '轻型铁手套',
|
||||
quality: 'unique',
|
||||
invWidth: 2,
|
||||
invHeight: 2,
|
||||
allowedSlots: ['gloves'],
|
||||
defense: 25,
|
||||
durability: { current: 14, max: 14 },
|
||||
reqLevel: 23,
|
||||
reqStr: 45,
|
||||
stats: [
|
||||
{ text: '+1 火焰技能', color: 'blue' },
|
||||
{ text: '+20% 快速施法速度 (FCR)', color: 'blue' },
|
||||
{ text: '法力再生速度提高 25%', color: 'blue' },
|
||||
{ text: '增加 1-6 火焰伤害', color: 'blue' },
|
||||
{ text: '+30% 增强防御', color: 'blue' },
|
||||
],
|
||||
},
|
||||
ring1: {
|
||||
id: 'eq-soj-1',
|
||||
code: 'rin',
|
||||
invFile: 'invrin',
|
||||
name: 'The Stone of Jordan',
|
||||
nameZh: '乔丹之石 (Stone of Jordan)',
|
||||
baseNameZh: '戒指 (Ring)',
|
||||
quality: 'unique',
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
allowedSlots: ['ring1', 'ring2'],
|
||||
reqLevel: 29,
|
||||
stats: [
|
||||
{ text: '+1 所有技能', color: 'blue' },
|
||||
{ text: '增加法力上限 25%', color: 'blue' },
|
||||
{ text: '+20 法力', color: 'blue' },
|
||||
{ text: '增加 1-12 闪电伤害', color: 'blue' },
|
||||
],
|
||||
},
|
||||
belt: {
|
||||
id: 'eq-arach',
|
||||
code: 'ulc',
|
||||
invFile: 'invvbl',
|
||||
invtransform: 'blac',
|
||||
name: 'Arachnid Mesh',
|
||||
nameZh: '蜘蛛之网 (Arachnid Mesh)',
|
||||
baseNameZh: '蛛网腰带 (Spiderweb Sash)',
|
||||
quality: 'unique',
|
||||
invWidth: 2,
|
||||
invHeight: 1,
|
||||
allowedSlots: ['belt'],
|
||||
defense: 138,
|
||||
durability: { current: 12, max: 12 },
|
||||
reqLevel: 80,
|
||||
reqStr: 50,
|
||||
stats: [
|
||||
{ text: '+1 所有技能', color: 'blue' },
|
||||
{ text: '+20% 快速施法速度 (FCR)', color: 'blue' },
|
||||
{ text: '减慢目标速度 10%', color: 'blue' },
|
||||
{ text: '增加法力上限 5%', color: 'blue' },
|
||||
{ text: '+120% 增强防御', color: 'blue' },
|
||||
],
|
||||
},
|
||||
ring2: {
|
||||
id: 'eq-bk-2',
|
||||
code: 'rin',
|
||||
invFile: 'invrin',
|
||||
name: "Bul-Kathos' Wedding Band",
|
||||
nameZh: '布尔凯索之婚戒',
|
||||
baseNameZh: '戒指 (Ring)',
|
||||
quality: 'unique',
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
allowedSlots: ['ring1', 'ring2'],
|
||||
reqLevel: 58,
|
||||
stats: [
|
||||
{ text: '+1 所有技能', color: 'blue' },
|
||||
{ text: '+45 生命 (依角色等级而定)', color: 'blue' },
|
||||
{ text: '5% 每次命中偷取生命', color: 'blue' },
|
||||
{ text: '+50 最大耐力', color: 'blue' },
|
||||
],
|
||||
},
|
||||
boots: {
|
||||
id: 'eq-wartrav',
|
||||
code: 'xtb',
|
||||
invFile: 'invtbt',
|
||||
name: 'War Traveler',
|
||||
nameZh: '战争旅者 (War Traveler)',
|
||||
baseNameZh: '战场之靴 (Battle Boots)',
|
||||
quality: 'unique',
|
||||
invWidth: 2,
|
||||
invHeight: 2,
|
||||
allowedSlots: ['boots'],
|
||||
defense: 139,
|
||||
durability: { current: 48, max: 48 },
|
||||
reqLevel: 42,
|
||||
reqStr: 95,
|
||||
stats: [
|
||||
{ text: '+25% 高速跑步/行走', color: 'blue' },
|
||||
{ text: '+10 力量', color: 'blue' },
|
||||
{ text: '+10 体力', color: 'blue' },
|
||||
{ text: '增加 15-25 伤害', color: 'blue' },
|
||||
{ text: '+190% 增强防御', color: 'blue' },
|
||||
{ text: '减慢耐力消耗 40%', color: 'blue' },
|
||||
{ text: '攻击者受到伤害 10', color: 'blue' },
|
||||
{ text: '50% 更佳机会取得魔法装备 (MF)', color: 'blue' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const DEMO_BAG_ITEMS: readonly GridPlacement[] = [
|
||||
{
|
||||
col: 0,
|
||||
row: 0,
|
||||
item: {
|
||||
id: 'bag-cube',
|
||||
code: 'box',
|
||||
invFile: 'invbox',
|
||||
name: 'Horadric Cube',
|
||||
nameZh: '赫拉迪克方块 (Horadric Cube)',
|
||||
baseNameZh: '任务宝盒',
|
||||
quality: 'unique',
|
||||
invWidth: 2,
|
||||
invHeight: 2,
|
||||
allowedSlots: [],
|
||||
stats: [{ text: '右键点击打开合成界面 (3×4 网格)', color: 'gold' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
col: 2,
|
||||
row: 0,
|
||||
item: {
|
||||
id: 'bag-tbk',
|
||||
code: 'tbk',
|
||||
invFile: 'invbbk',
|
||||
name: 'Tome of Town Portal',
|
||||
nameZh: '回城之书 (Tome of Town Portal)',
|
||||
baseNameZh: '法术书 (数量: 20)',
|
||||
quality: 'normal',
|
||||
invWidth: 1,
|
||||
invHeight: 2,
|
||||
allowedSlots: [],
|
||||
quantity: 20,
|
||||
maxQuantity: 20,
|
||||
stats: [{ text: '右键施放城镇传送门', color: 'white' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
col: 2,
|
||||
row: 2,
|
||||
item: {
|
||||
id: 'bag-ibk',
|
||||
code: 'ibk',
|
||||
invFile: 'invrbk',
|
||||
name: 'Tome of Identify',
|
||||
nameZh: '鉴定之书 (Tome of Identify)',
|
||||
baseNameZh: '法术书 (数量: 20)',
|
||||
quality: 'normal',
|
||||
invWidth: 1,
|
||||
invHeight: 2,
|
||||
allowedSlots: [],
|
||||
quantity: 20,
|
||||
maxQuantity: 20,
|
||||
stats: [{ text: '鉴定未辨识的魔法/暗金装备', color: 'white' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
col: 3,
|
||||
row: 0,
|
||||
item: {
|
||||
id: 'bag-torch',
|
||||
code: 'cm2',
|
||||
invFile: 'invtrch',
|
||||
name: 'Hellfire Torch',
|
||||
nameZh: '地狱火炬 (Hellfire Torch)',
|
||||
baseNameZh: '大型护身符 (Large Charm)',
|
||||
quality: 'unique',
|
||||
invWidth: 1,
|
||||
invHeight: 2,
|
||||
allowedSlots: [],
|
||||
reqLevel: 75,
|
||||
stats: [
|
||||
{ text: '击中时有 5% 几率施放等级 10 火风暴', color: 'blue' },
|
||||
{ text: '+3 法师技能等级', color: 'blue' },
|
||||
{ text: '+20 所有属性', color: 'blue' },
|
||||
{ text: '所有抗性 +20', color: 'blue' },
|
||||
{ text: '+8 照亮范围', color: 'blue' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
col: 3,
|
||||
row: 2,
|
||||
item: {
|
||||
id: 'bag-anni',
|
||||
code: 'cm1',
|
||||
invFile: 'invmss',
|
||||
name: 'Annihilus',
|
||||
nameZh: '毁灭 (Annihilus)',
|
||||
baseNameZh: '小型护身符 (Small Charm)',
|
||||
quality: 'unique',
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
allowedSlots: [],
|
||||
reqLevel: 70,
|
||||
stats: [
|
||||
{ text: '+1 所有技能 (+1 To All Skills)', color: 'blue' },
|
||||
{ text: '+20 所有属性', color: 'blue' },
|
||||
{ text: '所有抗性 +20', color: 'blue' },
|
||||
{ text: '+10% 获得经验值', color: 'blue' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
col: 4,
|
||||
row: 0,
|
||||
item: {
|
||||
id: 'bag-gheeds',
|
||||
code: 'cm3',
|
||||
invFile: 'invch3',
|
||||
name: "Gheed's Fortune",
|
||||
nameZh: '基德的运气 (Gheed\'s Fortune)',
|
||||
baseNameZh: '超大型护身符 (Grand Charm)',
|
||||
quality: 'unique',
|
||||
invWidth: 1,
|
||||
invHeight: 3,
|
||||
allowedSlots: [],
|
||||
reqLevel: 62,
|
||||
stats: [
|
||||
{ text: '160% 额外金币从怪物身上取得', color: 'blue' },
|
||||
{ text: '40% 更佳机会取得魔法装备 (MF)', color: 'blue' },
|
||||
{ text: '降低所有商店价格 15%', color: 'blue' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
col: 8,
|
||||
row: 0,
|
||||
item: {
|
||||
id: 'bag-cta',
|
||||
code: 'crs',
|
||||
invFile: 'invcrs',
|
||||
name: 'Call to Arms',
|
||||
nameZh: '战争召唤 (Call to Arms)',
|
||||
baseNameZh: '水晶剑 (Crystal Sword)',
|
||||
quality: 'rune',
|
||||
runewordRunes: "'AmnRalMalIstOhm'",
|
||||
invWidth: 2,
|
||||
invHeight: 3,
|
||||
allowedSlots: ['weapon1'],
|
||||
damage: '17 - 52',
|
||||
durability: { current: 20, max: 20 },
|
||||
reqStr: 43,
|
||||
reqLevel: 57,
|
||||
sockets: 5,
|
||||
speedText: '剑类 - 极快的攻击速度',
|
||||
stats: [
|
||||
{ text: '+1 所有技能 (+1 To All Skills)', color: 'blue' },
|
||||
{ text: '+40% 增加攻击速度 (+40% Increased Attack Speed)', color: 'blue' },
|
||||
{ text: '+255% 增强伤害 (+255% Enhanced Damage)', color: 'blue' },
|
||||
{ text: '增加 5-30 火焰伤害 (Adds 5-30 Fire Damage)', color: 'blue' },
|
||||
{ text: '7% 生命偷取 / 每次命中偷取生命 (7% Life Stolen)', color: 'blue' },
|
||||
{ text: '防止怪物自愈 / 防止怪物自疗 (Prevent Monster Heal)', color: 'blue' },
|
||||
{ text: '+6 战斗体制 (Battle Orders)', color: 'blue' },
|
||||
{ text: '+6 战斗指挥 (Battle Command)', color: 'blue' },
|
||||
{ text: '+4 战斗狂嗥 / 战嚎 (Battle Cry)', color: 'blue' },
|
||||
{ text: '生命回复 +12 / 生命补满 +12 (Replenish Life +12)', color: 'blue' },
|
||||
{ text: '30% 更佳机会取得魔法装备 (30% Better Chance of Getting Magic Items)', color: 'blue' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export const DEMO_BELT_POTIONS: readonly BeltPotion[] = [
|
||||
{
|
||||
id: 'pot-hp-1',
|
||||
code: 'hp4',
|
||||
invFile: 'invhp4',
|
||||
name: 'Greater Healing Potion',
|
||||
nameZh: '强效治疗药剂',
|
||||
kind: 'hp',
|
||||
healHp: 180,
|
||||
healMana: 0,
|
||||
},
|
||||
{
|
||||
id: 'pot-hp-2',
|
||||
code: 'hp5',
|
||||
invFile: 'invhp5',
|
||||
name: 'Super Healing Potion',
|
||||
nameZh: '超级治疗药剂',
|
||||
kind: 'hp',
|
||||
healHp: 320,
|
||||
healMana: 0,
|
||||
},
|
||||
{
|
||||
id: 'pot-mp-1',
|
||||
code: 'mp4',
|
||||
invFile: 'invmp4',
|
||||
name: 'Greater Mana Potion',
|
||||
nameZh: '强效法力药剂',
|
||||
kind: 'mana',
|
||||
healHp: 0,
|
||||
healMana: 150,
|
||||
},
|
||||
{
|
||||
id: 'pot-rv-1',
|
||||
code: 'rvl',
|
||||
invFile: 'invvpl',
|
||||
name: 'Full Rejuvenation Potion',
|
||||
nameZh: '全面回复活力药剂',
|
||||
kind: 'rejuv',
|
||||
healHp: 400,
|
||||
healMana: 400,
|
||||
},
|
||||
]
|
||||
|
||||
/** Fill every 4x4 belt slot with the demo potion of its column. */
|
||||
export function seedDemoBeltPotions<T extends BeltModel>(belt: T): T {
|
||||
for (let row = 0; row < BELT_ROWS; row++) {
|
||||
for (let col = 0; col < BELT_COLS; col++) {
|
||||
const template = DEMO_BELT_POTIONS[col]!
|
||||
belt.grid[row]![col] = { ...template, id: `${template.id}-r${row}-c${col}` }
|
||||
}
|
||||
}
|
||||
return belt
|
||||
}
|
||||
|
|
@ -9,16 +9,12 @@
|
|||
|
||||
import { CharacterSheetPanel } from '../../src/client/ui/character-sheet.ts'
|
||||
import type { HudManager } from '../../src/client/ui/hud-manager.ts'
|
||||
import { DEFAULT_SORCERESS_SKILLS, SkillHotkeysHud } from '../../src/client/ui/hotkeys.ts'
|
||||
import {
|
||||
InventoryPanel,
|
||||
STARTER_BAG_ITEMS,
|
||||
STARTER_EQUIPPED_GEAR,
|
||||
type EquipSlotId,
|
||||
} from '../../src/client/ui/inventory.ts'
|
||||
import { SkillHotkeysHud } from '../../src/client/ui/hotkeys.ts'
|
||||
import { InventoryPanel, type EquipSlotId } from '../../src/client/ui/inventory.ts'
|
||||
import { SkillTreePanel } from '../../src/client/ui/skill-tree-panel.ts'
|
||||
import { ACT_WAYPOINTS, ALL_QUESTS, WorldPanelsHud } from '../../src/client/ui/world-panels.ts'
|
||||
import type { BeltModel } from '../../src/common/items/belt-model.ts'
|
||||
import { DEMO_BAG_ITEMS, DEMO_EQUIPPED_GEAR, DEMO_SORCERESS_SKILLS, seedDemoBeltPotions } from './demo-profile-data.ts'
|
||||
|
||||
/** Former demo Sorceress profile (level 85). */
|
||||
export const DEMO_LEVEL = 85
|
||||
|
|
@ -57,7 +53,7 @@ export const DEMO_COMPLETED_QUEST_IDS: ReadonlySet<string> = new Set(['a1q1', 'a
|
|||
|
||||
/** Seed Sorceress quickbar: all default skills, Fire Ball (47) left, Frozen Orb (64) right, F1..F8 bindings. */
|
||||
export function seedDemoHotkeys(hotkeys: SkillHotkeysHud): SkillHotkeysHud {
|
||||
hotkeys.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS])
|
||||
hotkeys.setAvailableSkills([...DEMO_SORCERESS_SKILLS])
|
||||
hotkeys.leftSkillId = 47
|
||||
hotkeys.rightSkillId = 64
|
||||
hotkeys.bindings.clear()
|
||||
|
|
@ -108,10 +104,10 @@ export function seedDemoCharSheet(sheet: CharacterSheetPanel): CharacterSheetPan
|
|||
/** Seed starter gear + bag items (deep-cloned), 850k gold and the level-85 Sorceress requirement context. */
|
||||
export function seedDemoInventory(inv: InventoryPanel): InventoryPanel {
|
||||
for (const k of Object.keys(inv.equipped) as EquipSlotId[]) delete inv.equipped[k]
|
||||
for (const [slot, item] of Object.entries(STARTER_EQUIPPED_GEAR) as [EquipSlotId, NonNullable<(typeof STARTER_EQUIPPED_GEAR)[EquipSlotId]>][]) {
|
||||
for (const [slot, item] of Object.entries(DEMO_EQUIPPED_GEAR) as [EquipSlotId, NonNullable<(typeof DEMO_EQUIPPED_GEAR)[EquipSlotId]>][]) {
|
||||
inv.equipped[slot] = { ...item, stats: [...item.stats] }
|
||||
}
|
||||
inv.gridItems = STARTER_BAG_ITEMS.map(p => ({ ...p, item: { ...p.item, stats: [...p.item.stats] } }))
|
||||
inv.gridItems = DEMO_BAG_ITEMS.map(p => ({ ...p, item: { ...p.item, stats: [...p.item.stats] } }))
|
||||
inv.gold = 850_000
|
||||
inv.setPlayerContext({ level: DEMO_LEVEL, str: DEMO_STR, dex: DEMO_DEX, classCode: 'sor' })
|
||||
return inv
|
||||
|
|
@ -129,7 +125,7 @@ export function seedDemoWorldPanels(wp: WorldPanelsHud): WorldPanelsHud {
|
|||
|
||||
/** Seed the 4x4 belt with the legacy default potions. */
|
||||
export function seedDemoBelt<T extends BeltModel>(belt: T): T {
|
||||
belt.seedDefaultPotions()
|
||||
seedDemoBeltPotions(belt)
|
||||
return belt
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -799,12 +799,5 @@ describe('Issue #428: Full-Spectrum Statistical Drop Verification', () => {
|
|||
expect(embeddedSrc).not.toMatch(/monsterKinds\s*:\s*new\s+Map\s*\(\s*\)/)
|
||||
expect(embeddedSrc).not.toMatch(/superUniques\s*:\s*new\s+Map\s*\(\s*\)/)
|
||||
})
|
||||
|
||||
it('src/scene/act-scene.ts fails fast on missing flippy sprites and never uses a procedural colored box fallback for ground items', () => {
|
||||
const sceneSrc = readFileSync(resolve(process.cwd(), 'src/client/world/act-runtime.ts'), 'utf8')
|
||||
expect(sceneSrc).toContain('resolveGroundItemSpriteRect')
|
||||
expect(sceneSrc).toContain('GroundItem render failure: missing flippy sprite rect')
|
||||
expect(sceneSrc).not.toContain('Fallback procedural isometric ground item')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { SkillHotkeysHud, DEFAULT_SORCERESS_SKILLS, LEFT_SKILL_BOUNDS, RIGHT_SKILL_BOUNDS } from '../src/client/ui/hotkeys.ts'
|
||||
import { SkillHotkeysHud, LEFT_SKILL_BOUNDS, RIGHT_SKILL_BOUNDS } from '../src/client/ui/hotkeys.ts'
|
||||
import { HudManager } from '../src/client/ui/hud-manager.ts'
|
||||
import { CLASS_DEFAULT_SKILLS, createStarterProfileForClass } from '../src/common/units/class-starter-profiles.ts'
|
||||
import { SKILLS_BY_ID } from '../src/common/data/skills-catalog.ts'
|
||||
import { createDemoHotkeys } from './fixtures/hud-demo-state.ts'
|
||||
import { createDemoHotkeys, seedDemoHotkeys, seedDemoSkillTree } from './fixtures/hud-demo-state.ts'
|
||||
import { DEMO_SORCERESS_SKILLS } from './fixtures/demo-profile-data.ts'
|
||||
|
||||
describe('Issue Fix: Glacial Spike (#55) and All Active Skills on Quickbar Speedbars', () => {
|
||||
let hud: SkillHotkeysHud
|
||||
|
|
@ -14,8 +14,8 @@ describe('Issue Fix: Glacial Spike (#55) and All Active Skills on Quickbar Speed
|
|||
})
|
||||
|
||||
describe('1. Sorceress Right-Click & Left-Click Quickbar includes Glacial Spike', () => {
|
||||
it('has Glacial Spike (55) in DEFAULT_SORCERESS_SKILLS with level 10 and authentic mana', () => {
|
||||
const entry = DEFAULT_SORCERESS_SKILLS.find(s => s.skillId === 55)
|
||||
it('has Glacial Spike (55) in DEMO_SORCERESS_SKILLS with level 10 and authentic mana', () => {
|
||||
const entry = DEMO_SORCERESS_SKILLS.find(s => s.skillId === 55)
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.name).toBe('Glacial Spike')
|
||||
expect(entry!.nameZh).toBe('冰尖柱')
|
||||
|
|
@ -69,40 +69,6 @@ describe('Issue Fix: Glacial Spike (#55) and All Active Skills on Quickbar Speed
|
|||
})
|
||||
})
|
||||
|
||||
describe('2. All 7 Classes Starter Profiles include all Active Skills', () => {
|
||||
it('provides all 26 active skills on Sorceress right-click speedbar', () => {
|
||||
const sorProfile = createStarterProfileForClass('sor')
|
||||
const rightSkills = sorProfile.availableSkills.filter(s => s.rightUsable)
|
||||
// 3 universal (Attack 0, Throw 2, Unsummon 3) + 26 Sorceress active = 29
|
||||
expect(rightSkills.length).toBe(29)
|
||||
|
||||
// Glacial Spike is explicitly present
|
||||
expect(rightSkills.some(s => s.skillId === 55)).toBe(true)
|
||||
})
|
||||
|
||||
it('provides all active skills for all 7 classes without passives', () => {
|
||||
const classes = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'] as const
|
||||
const expectedActiveCounts: Record<string, number> = {
|
||||
ama: 27, // 3 universal + 24 active
|
||||
sor: 29, // 3 universal + 26 active
|
||||
nec: 30, // 3 universal + 27 active
|
||||
pal: 33, // 3 universal + 30 active
|
||||
bar: 23, // 3 universal + 20 active
|
||||
dru: 32, // 3 universal + 29 active
|
||||
ass: 31, // 3 universal + 28 active
|
||||
}
|
||||
|
||||
for (const cls of classes) {
|
||||
const skills = CLASS_DEFAULT_SKILLS[cls]
|
||||
expect(skills.length).toBe(expectedActiveCounts[cls])
|
||||
// None should be passive
|
||||
for (const s of skills) {
|
||||
expect(s.isPassive).toBeFalsy()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. HudManager Skill Tree Allocation dynamically updates Available Skills', () => {
|
||||
const mockCanvas = {
|
||||
width: 800,
|
||||
|
|
@ -118,15 +84,13 @@ describe('Issue Fix: Glacial Spike (#55) and All Active Skills on Quickbar Speed
|
|||
onToggleAutomap: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
})
|
||||
hud.switchClass('sor')
|
||||
// Server-assigned Sorceress with the legacy demo quickbar/skill tree seeded explicitly.
|
||||
hud.setCharacterClass('sor')
|
||||
seedDemoHotkeys(hud.hotkeys)
|
||||
seedDemoSkillTree(hud.skillTree)
|
||||
return hud
|
||||
}
|
||||
|
||||
it('initializes HudManager with Glacial Spike available in Sorceress profile', () => {
|
||||
const hudManager = createMockHud()
|
||||
expect(hudManager.hotkeys.availableSkills.some(s => s.skillId === 55)).toBe(true)
|
||||
})
|
||||
|
||||
it('requests skill allocation through the command sink without local mutation', () => {
|
||||
const hudManager = createMockHud()
|
||||
// Open Skill tree panel
|
||||
|
|
|
|||
|
|
@ -13,11 +13,6 @@ import { D2Rng } from '../src/common/rng/d2-rng.ts'
|
|||
import { DEMO_EXPERIENCE, DEMO_SKILLS } from '../src/common/data/demo-data.ts'
|
||||
import { itemToUiInventoryItem } from '../src/common/items/item-bridge.ts'
|
||||
import { resolveGroundItemSpriteRect } from '../src/client/ui/inventory.ts'
|
||||
import {
|
||||
formatGroundItemLabelText,
|
||||
getGroundItemQualityColor,
|
||||
GROUND_LABEL_QUALITY_COLORS,
|
||||
} from '../src/client/ui/ground-labels.ts'
|
||||
import { ORTHO_SUB_TILE_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_CELL_WIDTH } from '../src/common/world/d2map.ts'
|
||||
import type { Monster } from '../src/server/engine/combat.ts'
|
||||
|
||||
|
|
@ -133,68 +128,6 @@ describe('Issue #479 — Ground Item Drop Spacing, Metadata Parity & Anti-Stacki
|
|||
})
|
||||
})
|
||||
|
||||
describe('2. Monster Drop vs. Inventory/Body Drop Metadata & Bilingual Name Parity', () => {
|
||||
it('produces identical bilingual nameZh, quality, label color, sound, and sprite rect for monster-dropped vs inventory-dropped items', () => {
|
||||
const testCases = [
|
||||
{ code: 'hp2', level: 5, mf: 0 }, // Light Healing Potion
|
||||
{ code: 'mp4', level: 20, mf: 0 }, // Greater Mana Potion
|
||||
{ code: 'rvl', level: 30, mf: 0 }, // Full Rejuvenation Potion
|
||||
{ code: 'hax', level: 5, mf: 0 }, // Hand Axe
|
||||
{ code: 'cap', level: 5, mf: 0 }, // Cap
|
||||
{ code: 'r01', level: 15, mf: 0 }, // El Rune
|
||||
{ code: 'r31', level: 85, mf: 0 }, // Jah Rune
|
||||
]
|
||||
|
||||
for (const tc of testCases) {
|
||||
const engine = createEngine()
|
||||
const rng = new D2Rng(999)
|
||||
const base = dropTables.getBase(tc.code)
|
||||
expect(base).toBeDefined()
|
||||
const rawDroppedItem = createDroppedItem(base!, 'normal', {
|
||||
ilvl: tc.level,
|
||||
dwInitSeed: 999,
|
||||
itemRng: rng,
|
||||
itemTypes: dropTables.itemTypes,
|
||||
})
|
||||
expect(rawDroppedItem).not.toBeNull()
|
||||
|
||||
// Path A: Dropped directly by a monster onto the ground (raw Item)
|
||||
const monsterGroundEntity = engine.groundItems.add(rawDroppedItem, 5, 5, 400, 300, { bounce: true, now: 1000 })
|
||||
|
||||
// Path B: Picked up into player UI inventory (converted via itemToUiInventoryItem) and dropped from body/inventory
|
||||
const uiInventoryItem = itemToUiInventoryItem(rawDroppedItem, dropTables)
|
||||
const playerGroundEntity = engine.dropItem(uiInventoryItem, 500, 300)
|
||||
|
||||
// 1. Authentic Chinese nameZh parity (both must match and NEVER be pure English on monster drop)
|
||||
expect(monsterGroundEntity.name).toBe(playerGroundEntity.name)
|
||||
expect(monsterGroundEntity.nameZh).toBe(playerGroundEntity.nameZh)
|
||||
expect(monsterGroundEntity.nameZh).toMatch(/[\u4e00-\u9fff]/)
|
||||
expect(monsterGroundEntity.nameZh).not.toBe(monsterGroundEntity.name)
|
||||
expect(formatGroundItemLabelText(monsterGroundEntity)).toBe(formatGroundItemLabelText(playerGroundEntity))
|
||||
|
||||
// 2. Quality & ground label color parity (especially Runes -> 'rune' -> #ff9c18 orange)
|
||||
expect(monsterGroundEntity.quality).toBe(playerGroundEntity.quality)
|
||||
expect(getGroundItemQualityColor(monsterGroundEntity)).toBe(getGroundItemQualityColor(playerGroundEntity))
|
||||
if (tc.code.startsWith('r0') || tc.code.startsWith('r3')) {
|
||||
expect(monsterGroundEntity.quality).toBe('rune')
|
||||
expect(getGroundItemQualityColor(monsterGroundEntity)).toBe(GROUND_LABEL_QUALITY_COLORS.rune)
|
||||
}
|
||||
|
||||
// 3. Audio & dimensions parity
|
||||
expect(monsterGroundEntity.invWidth).toBe(playerGroundEntity.invWidth)
|
||||
expect(monsterGroundEntity.invHeight).toBe(playerGroundEntity.invHeight)
|
||||
expect(monsterGroundEntity.dropSound).toBe(playerGroundEntity.dropSound)
|
||||
expect(monsterGroundEntity.dropSfxFrame).toBe(playerGroundEntity.dropSfxFrame)
|
||||
|
||||
// 4. Ground flippy sprite rect parity
|
||||
const monsterRect = resolveGroundItemSpriteRect(monsterGroundEntity.item)
|
||||
const playerRect = resolveGroundItemSpriteRect(playerGroundEntity.item)
|
||||
expect(monsterRect).not.toBeNull()
|
||||
expect(monsterRect).toEqual(playerRect)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. Player Body/Inventory Dropped Items Anti-Stacking (Potions & Equipment)', () => {
|
||||
it('scatters 8 sequentially dropped potions/items from player inventory onto distinct sub-tile slots with zero stacking', () => {
|
||||
const engine = createEngine()
|
||||
|
|
|
|||
|
|
@ -1,568 +0,0 @@
|
|||
/**
|
||||
* Diablo II v1.13c Ground Items Alt Labels & Ladder Collision Tests (Issue #390).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'
|
||||
import type { GroundItemEntity } from '../src/common/items/ground-items.ts'
|
||||
import {
|
||||
GROUND_LABEL_QUALITY_COLORS,
|
||||
getGroundItemQualityColor,
|
||||
formatGroundItemLabelText,
|
||||
estimateLabelWidth,
|
||||
doLabelsOverlap,
|
||||
resolveLadderCollisions,
|
||||
computeInitialGroundLabelLayouts,
|
||||
GroundLabelOverlay,
|
||||
type GroundLabelLayout,
|
||||
} from '../src/client/ui/ground-labels.ts'
|
||||
|
||||
function createMockItem(partial: Partial<GroundItemEntity> = {}): GroundItemEntity {
|
||||
return {
|
||||
id: partial.id ?? 'item-1',
|
||||
item: partial.item ?? {},
|
||||
name: partial.name ?? 'Short Sword',
|
||||
nameZh: partial.nameZh ?? '短剑',
|
||||
quality: partial.quality ?? 'normal',
|
||||
isGold: partial.isGold ?? false,
|
||||
amount: partial.amount ?? 1,
|
||||
invWidth: partial.invWidth ?? 1,
|
||||
invHeight: partial.invHeight ?? 2,
|
||||
dropTime: 1000,
|
||||
x: partial.x ?? 400,
|
||||
y: partial.y ?? 300,
|
||||
cellX: 20,
|
||||
cellY: 15,
|
||||
sparklePhase: 0,
|
||||
ethereal: partial.ethereal,
|
||||
sockets: partial.sockets,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Ground Items Alt Labels (Issue #390)', () => {
|
||||
describe('Quality Colors & Text Formatting', () => {
|
||||
it('formats gold amounts accurately as authentic Chinese localized text', () => {
|
||||
const gold1 = createMockItem({ isGold: true, amount: 150 })
|
||||
expect(formatGroundItemLabelText(gold1)).toBe('150 金币')
|
||||
|
||||
const gold2 = createMockItem({ isGold: true, amount: 25000 })
|
||||
expect(formatGroundItemLabelText(gold2)).toBe('25000 金币')
|
||||
})
|
||||
|
||||
it('formats item display names preferring nameZh with fallback to name', () => {
|
||||
const item1 = createMockItem({ name: 'Short Sword', nameZh: '短剑' })
|
||||
expect(formatGroundItemLabelText(item1)).toBe('短剑')
|
||||
|
||||
const item2 = createMockItem({ name: 'Grand Charm', nameZh: '' })
|
||||
expect(formatGroundItemLabelText(item2)).toBe('Grand Charm')
|
||||
})
|
||||
|
||||
it('maps all Diablo II 1.13c item qualities to authentic palette colors', () => {
|
||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'normal' }))).toBe(
|
||||
GROUND_LABEL_QUALITY_COLORS.normal,
|
||||
)
|
||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'low' }))).toBe(
|
||||
GROUND_LABEL_QUALITY_COLORS.low,
|
||||
)
|
||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'superior' }))).toBe(
|
||||
GROUND_LABEL_QUALITY_COLORS.superior,
|
||||
)
|
||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'magic' }))).toBe(
|
||||
GROUND_LABEL_QUALITY_COLORS.magic,
|
||||
)
|
||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'rare' }))).toBe(
|
||||
GROUND_LABEL_QUALITY_COLORS.rare,
|
||||
)
|
||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'set' }))).toBe(
|
||||
GROUND_LABEL_QUALITY_COLORS.set,
|
||||
)
|
||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'unique' }))).toBe(
|
||||
GROUND_LABEL_QUALITY_COLORS.unique,
|
||||
)
|
||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'craft' }))).toBe(
|
||||
GROUND_LABEL_QUALITY_COLORS.craft,
|
||||
)
|
||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'rune' }))).toBe(
|
||||
GROUND_LABEL_QUALITY_COLORS.rune,
|
||||
)
|
||||
expect(getGroundItemQualityColor(createMockItem({ isGold: true }))).toBe(
|
||||
GROUND_LABEL_QUALITY_COLORS.gold,
|
||||
)
|
||||
})
|
||||
|
||||
it('colors ethereal or socketed normal and superior items gray (#808080) while preserving magic/rare/set/unique/runeword colors (Issue #424)', () => {
|
||||
// Ethereal normal -> gray (#808080)
|
||||
expect(
|
||||
getGroundItemQualityColor(createMockItem({ quality: 'normal', ethereal: true })),
|
||||
).toBe('#808080')
|
||||
expect(
|
||||
getGroundItemQualityColor(createMockItem({ quality: 'normal', item: { ethereal: true } })),
|
||||
).toBe('#808080')
|
||||
|
||||
// Socketed normal -> gray (#808080)
|
||||
expect(
|
||||
getGroundItemQualityColor(createMockItem({ quality: 'normal', sockets: 3 })),
|
||||
).toBe('#808080')
|
||||
expect(
|
||||
getGroundItemQualityColor(createMockItem({ quality: 'normal', item: { sockets: 4 } })),
|
||||
).toBe('#808080')
|
||||
|
||||
// Socketed or ethereal superior / high -> gray (#808080)
|
||||
expect(
|
||||
getGroundItemQualityColor(createMockItem({ quality: 'superior', sockets: 2 })),
|
||||
).toBe('#808080')
|
||||
expect(
|
||||
getGroundItemQualityColor(createMockItem({ quality: 'high', ethereal: true })),
|
||||
).toBe('#808080')
|
||||
|
||||
// Non-ethereal 0-socket normal/superior remains normal/superior white
|
||||
expect(
|
||||
getGroundItemQualityColor(createMockItem({ quality: 'normal', ethereal: false, sockets: 0 })),
|
||||
).toBe(GROUND_LABEL_QUALITY_COLORS.normal)
|
||||
expect(
|
||||
getGroundItemQualityColor(createMockItem({ quality: 'superior', ethereal: false, sockets: 0 })),
|
||||
).toBe(GROUND_LABEL_QUALITY_COLORS.superior)
|
||||
|
||||
// Completed runewords keep unique gold (#908858)
|
||||
expect(
|
||||
getGroundItemQualityColor(createMockItem({ quality: 'normal', sockets: 4, item: { isRuneword: true, sockets: 4 } })),
|
||||
).toBe(GROUND_LABEL_QUALITY_COLORS.unique)
|
||||
|
||||
// Magic, rare, set, unique keep their quality colors even when ethereal or socketed
|
||||
expect(
|
||||
getGroundItemQualityColor(createMockItem({ quality: 'magic', ethereal: true, sockets: 2 })),
|
||||
).toBe(GROUND_LABEL_QUALITY_COLORS.magic)
|
||||
expect(
|
||||
getGroundItemQualityColor(createMockItem({ quality: 'rare', ethereal: true, sockets: 2 })),
|
||||
).toBe(GROUND_LABEL_QUALITY_COLORS.rare)
|
||||
expect(
|
||||
getGroundItemQualityColor(createMockItem({ quality: 'set', sockets: 3 })),
|
||||
).toBe(GROUND_LABEL_QUALITY_COLORS.set)
|
||||
expect(
|
||||
getGroundItemQualityColor(createMockItem({ quality: 'unique', ethereal: true, sockets: 1 })),
|
||||
).toBe(GROUND_LABEL_QUALITY_COLORS.unique)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Label Width Estimation', () => {
|
||||
it('correctly accounts for both ASCII and CJK wide characters', () => {
|
||||
const asciiW = estimateLabelWidth('OK', 13)
|
||||
const cjkW = estimateLabelWidth('戒指', 13)
|
||||
// CJK characters have greater pixel width than Latin characters for same character count
|
||||
expect(cjkW).toBeGreaterThan(asciiW)
|
||||
expect(asciiW).toBeGreaterThan(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Overlap Detection & Layout Calculation', () => {
|
||||
it('projects ground items to initial layouts with natural Y offset', () => {
|
||||
const items = [
|
||||
createMockItem({ id: 'i1', x: 100, y: 200 }),
|
||||
createMockItem({ id: 'i2', x: 300, y: 400 }),
|
||||
]
|
||||
const project = (wx: number, wy: number) => ({ x: wx * 2, y: wy * 2 })
|
||||
const layouts = computeInitialGroundLabelLayouts(items, project, 13, 18)
|
||||
|
||||
expect(layouts.length).toBe(2)
|
||||
expect(layouts[0]!.screenX).toBe(200)
|
||||
expect(layouts[0]!.naturalY).toBe(388) // 200 * 2 - 12
|
||||
expect(layouts[0]!.screenY).toBe(388)
|
||||
expect(layouts[1]!.screenX).toBe(600)
|
||||
expect(layouts[1]!.naturalY).toBe(788)
|
||||
})
|
||||
|
||||
it('filters out items outside viewport when project returns null', () => {
|
||||
const items = [createMockItem({ id: 'i1' })]
|
||||
const layouts = computeInitialGroundLabelLayouts(items, () => null)
|
||||
expect(layouts.length).toBe(0)
|
||||
})
|
||||
|
||||
it('accurately identifies overlapping and non-overlapping label rectangles', () => {
|
||||
const l1: GroundLabelLayout = {
|
||||
id: '1',
|
||||
itemId: '1',
|
||||
item: createMockItem(),
|
||||
text: 'A',
|
||||
color: '#fff',
|
||||
borderColor: '#fff',
|
||||
width: 60,
|
||||
height: 18,
|
||||
screenX: 100,
|
||||
screenY: 100,
|
||||
naturalY: 100,
|
||||
}
|
||||
const l2Overlapping: GroundLabelLayout = {
|
||||
id: '2',
|
||||
itemId: '2',
|
||||
item: createMockItem(),
|
||||
text: 'B',
|
||||
color: '#fff',
|
||||
borderColor: '#fff',
|
||||
width: 60,
|
||||
height: 18,
|
||||
screenX: 110,
|
||||
screenY: 105,
|
||||
naturalY: 105,
|
||||
}
|
||||
const l3Distant: GroundLabelLayout = {
|
||||
id: '3',
|
||||
itemId: '3',
|
||||
item: createMockItem(),
|
||||
text: 'C',
|
||||
color: '#fff',
|
||||
borderColor: '#fff',
|
||||
width: 60,
|
||||
height: 18,
|
||||
screenX: 300,
|
||||
screenY: 100,
|
||||
naturalY: 100,
|
||||
}
|
||||
|
||||
expect(doLabelsOverlap(l1, l2Overlapping)).toBe(true)
|
||||
expect(doLabelsOverlap(l1, l3Distant)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Diablo II 1.13c Ladder Collision Avoidance Algorithm', () => {
|
||||
it('returns empty array or single label unchanged', () => {
|
||||
expect(resolveLadderCollisions([])).toEqual([])
|
||||
const single: GroundLabelLayout[] = [
|
||||
{
|
||||
id: '1',
|
||||
itemId: '1',
|
||||
item: createMockItem(),
|
||||
text: 'A',
|
||||
color: '#fff',
|
||||
borderColor: '#fff',
|
||||
width: 50,
|
||||
height: 18,
|
||||
screenX: 100,
|
||||
screenY: 100,
|
||||
naturalY: 100,
|
||||
},
|
||||
]
|
||||
expect(resolveLadderCollisions(single)).toEqual(single)
|
||||
})
|
||||
|
||||
it('retains natural positions when labels do not overlap horizontally', () => {
|
||||
const nonOverlapping: GroundLabelLayout[] = [
|
||||
{
|
||||
id: '1',
|
||||
itemId: '1',
|
||||
item: createMockItem(),
|
||||
text: 'Left Item',
|
||||
color: '#fff',
|
||||
borderColor: '#fff',
|
||||
width: 60,
|
||||
height: 18,
|
||||
screenX: 100,
|
||||
screenY: 200,
|
||||
naturalY: 200,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
itemId: '2',
|
||||
item: createMockItem(),
|
||||
text: 'Right Item',
|
||||
color: '#fff',
|
||||
borderColor: '#fff',
|
||||
width: 60,
|
||||
height: 18,
|
||||
screenX: 300,
|
||||
screenY: 200,
|
||||
naturalY: 200,
|
||||
},
|
||||
]
|
||||
const resolved = resolveLadderCollisions(nonOverlapping)
|
||||
expect(resolved[0]!.screenY).toBe(200)
|
||||
expect(resolved[1]!.screenY).toBe(200)
|
||||
})
|
||||
|
||||
it('stacks 2 overlapping labels vertically into a clean ladder with zero overlap', () => {
|
||||
const overlapping: GroundLabelLayout[] = [
|
||||
{
|
||||
id: '1',
|
||||
itemId: '1',
|
||||
item: createMockItem(),
|
||||
text: 'Item A',
|
||||
color: '#fff',
|
||||
borderColor: '#fff',
|
||||
width: 60,
|
||||
height: 18,
|
||||
screenX: 100,
|
||||
screenY: 200,
|
||||
naturalY: 200,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
itemId: '2',
|
||||
item: createMockItem(),
|
||||
text: 'Item B',
|
||||
color: '#fff',
|
||||
borderColor: '#fff',
|
||||
width: 60,
|
||||
height: 18,
|
||||
screenX: 105,
|
||||
screenY: 202,
|
||||
naturalY: 202,
|
||||
},
|
||||
]
|
||||
|
||||
const resolved = resolveLadderCollisions(overlapping, 4, 2)
|
||||
expect(resolved.length).toBe(2)
|
||||
|
||||
// Zero overlap verification
|
||||
expect(doLabelsOverlap(resolved[0]!, resolved[1]!, 4, 2)).toBe(false)
|
||||
|
||||
// Vertical distance between centers must be at least height + padY = 20px
|
||||
const distY = Math.abs(resolved[0]!.screenY - resolved[1]!.screenY)
|
||||
expect(distY).toBeGreaterThanOrEqual(20)
|
||||
})
|
||||
|
||||
it('resolves dense 5-item loot explosion cluster into an orderly 5-step ladder', () => {
|
||||
const cluster: GroundLabelLayout[] = []
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
cluster.push({
|
||||
id: `item-${i}`,
|
||||
itemId: `item-${i}`,
|
||||
item: createMockItem({ id: `item-${i}` }),
|
||||
text: `Drop ${i}`,
|
||||
color: '#fff',
|
||||
borderColor: '#fff',
|
||||
width: 70,
|
||||
height: 18,
|
||||
screenX: 200 + (i % 2) * 5, // all clustered within 5px horizontally
|
||||
screenY: 300,
|
||||
naturalY: 300,
|
||||
})
|
||||
}
|
||||
|
||||
const resolved = resolveLadderCollisions(cluster, 4, 2)
|
||||
expect(resolved.length).toBe(5)
|
||||
|
||||
// Assert pairwise zero-overlap across all 5 labels
|
||||
for (let i = 0; i < resolved.length; i += 1) {
|
||||
for (let j = i + 1; j < resolved.length; j += 1) {
|
||||
expect(doLabelsOverlap(resolved[i]!, resolved[j]!, 4, 2)).toBe(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Check ladder sorting: positions strictly decreasing (stacking upward)
|
||||
const sortedByY = [...resolved].sort((a, b) => a.screenY - b.screenY)
|
||||
for (let k = 0; k < sortedByY.length - 1; k += 1) {
|
||||
expect(sortedByY[k + 1]!.screenY - sortedByY[k]!.screenY).toBeGreaterThanOrEqual(20)
|
||||
}
|
||||
})
|
||||
|
||||
it('handles multiple independent clusters simultaneously', () => {
|
||||
const items: GroundLabelLayout[] = [
|
||||
// Cluster 1 around X=100
|
||||
{
|
||||
id: 'c1-1',
|
||||
itemId: 'c1-1',
|
||||
item: createMockItem(),
|
||||
text: 'Loot A',
|
||||
color: '#fff',
|
||||
borderColor: '#fff',
|
||||
width: 50,
|
||||
height: 18,
|
||||
screenX: 100,
|
||||
screenY: 200,
|
||||
naturalY: 200,
|
||||
},
|
||||
{
|
||||
id: 'c1-2',
|
||||
itemId: 'c1-2',
|
||||
item: createMockItem(),
|
||||
text: 'Loot B',
|
||||
color: '#fff',
|
||||
borderColor: '#fff',
|
||||
width: 50,
|
||||
height: 18,
|
||||
screenX: 102,
|
||||
screenY: 200,
|
||||
naturalY: 200,
|
||||
},
|
||||
// Cluster 2 around X=500
|
||||
{
|
||||
id: 'c2-1',
|
||||
itemId: 'c2-1',
|
||||
item: createMockItem(),
|
||||
text: 'Loot C',
|
||||
color: '#fff',
|
||||
borderColor: '#fff',
|
||||
width: 50,
|
||||
height: 18,
|
||||
screenX: 500,
|
||||
screenY: 200,
|
||||
naturalY: 200,
|
||||
},
|
||||
{
|
||||
id: 'c2-2',
|
||||
itemId: 'c2-2',
|
||||
item: createMockItem(),
|
||||
text: 'Loot D',
|
||||
color: '#fff',
|
||||
borderColor: '#fff',
|
||||
width: 50,
|
||||
height: 18,
|
||||
screenX: 503,
|
||||
screenY: 200,
|
||||
naturalY: 200,
|
||||
},
|
||||
]
|
||||
|
||||
const resolved = resolveLadderCollisions(items)
|
||||
// Both clusters have their own pairwise overlap resolved
|
||||
expect(doLabelsOverlap(resolved[0]!, resolved[1]!)).toBe(false)
|
||||
expect(doLabelsOverlap(resolved[2]!, resolved[3]!)).toBe(false)
|
||||
// Cluster 1 and Cluster 2 remain at their distinct X
|
||||
expect(resolved[0]!.screenX).toBeLessThan(200)
|
||||
expect(resolved[2]!.screenX).toBeGreaterThan(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroundLabelOverlay DOM Integration', () => {
|
||||
function createMockDomElement(tag = 'div'): any {
|
||||
const listeners: Record<string, ((e: any) => void)[]> = {}
|
||||
const children: any[] = []
|
||||
const el: any = {
|
||||
tagName: tag.toUpperCase(),
|
||||
className: '',
|
||||
textContent: '',
|
||||
style: {},
|
||||
dataset: {},
|
||||
hidden: false,
|
||||
children,
|
||||
appendChild(child: any) {
|
||||
children.push(child)
|
||||
return child
|
||||
},
|
||||
addEventListener(type: string, fn: any) {
|
||||
listeners[type] = listeners[type] ?? []
|
||||
listeners[type].push(fn)
|
||||
},
|
||||
dispatchEvent(event: any) {
|
||||
for (const fn of listeners[event.type] ?? []) {
|
||||
fn(event)
|
||||
}
|
||||
},
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
const origDocument = (globalThis as any).document
|
||||
beforeAll(() => {
|
||||
;(globalThis as any).document = {
|
||||
createElement: (tag: string) => createMockDomElement(tag),
|
||||
}
|
||||
})
|
||||
afterAll(() => {
|
||||
;(globalThis as any).document = origDocument
|
||||
})
|
||||
|
||||
it('hides all pooled label elements when visible is false or layouts empty', () => {
|
||||
const container = createMockDomElement('div')
|
||||
const overlay = new GroundLabelOverlay(container)
|
||||
|
||||
const layout: GroundLabelLayout = {
|
||||
id: '1',
|
||||
itemId: '1',
|
||||
item: createMockItem(),
|
||||
text: 'Short Sword',
|
||||
color: '#d8d8d8',
|
||||
borderColor: '#d8d8d8',
|
||||
width: 60,
|
||||
height: 18,
|
||||
screenX: 100,
|
||||
screenY: 100,
|
||||
naturalY: 100,
|
||||
}
|
||||
|
||||
overlay.update(true, [layout])
|
||||
expect(container.children.length).toBe(1)
|
||||
expect(container.children[0].style.display).toBe('block')
|
||||
|
||||
overlay.update(false, [])
|
||||
expect(container.children[0].style.display).toBe('none')
|
||||
})
|
||||
|
||||
it('renders labels with authentic quality colors and triggers pickup callback on click', () => {
|
||||
const container = createMockDomElement('div')
|
||||
const overlay = new GroundLabelOverlay(container)
|
||||
const onPickup = vi.fn()
|
||||
overlay.setOnPickupCallback(onPickup)
|
||||
|
||||
const uniqueItem = createMockItem({
|
||||
id: 'soj',
|
||||
name: 'The Stone of Jordan',
|
||||
nameZh: '乔丹之石',
|
||||
quality: 'unique',
|
||||
})
|
||||
|
||||
const layout: GroundLabelLayout = {
|
||||
id: 'soj',
|
||||
itemId: 'soj',
|
||||
item: uniqueItem,
|
||||
text: '乔丹之石',
|
||||
color: GROUND_LABEL_QUALITY_COLORS.unique!,
|
||||
borderColor: GROUND_LABEL_QUALITY_COLORS.unique!,
|
||||
width: 80,
|
||||
height: 18,
|
||||
screenX: 250,
|
||||
screenY: 150,
|
||||
naturalY: 150,
|
||||
}
|
||||
|
||||
overlay.update(true, [layout])
|
||||
const el = container.children[0]
|
||||
expect(el.textContent).toBe('乔丹之石')
|
||||
expect(el.style.color).toBe(GROUND_LABEL_QUALITY_COLORS.unique)
|
||||
expect(el.style.left).toBe('250px')
|
||||
expect(el.style.top).toBe('150px')
|
||||
|
||||
// Simulate pointerdown
|
||||
el.dispatchEvent({ type: 'pointerdown', stopPropagation: vi.fn(), preventDefault: vi.fn() })
|
||||
expect(onPickup).toHaveBeenCalledWith(uniqueItem)
|
||||
})
|
||||
|
||||
it('triggers onHoverCallback on mouseenter and mouseleave for hovered labels (Issue #447)', () => {
|
||||
const container = createMockDomElement('div')
|
||||
const overlay = new GroundLabelOverlay(container)
|
||||
const onHover = vi.fn()
|
||||
overlay.setOnHoverCallback(onHover)
|
||||
|
||||
const magicItem = createMockItem({
|
||||
id: 'ring-1',
|
||||
name: 'Ring of the Apprentice',
|
||||
nameZh: '学徒之戒',
|
||||
quality: 'magic',
|
||||
})
|
||||
|
||||
const layout: GroundLabelLayout = {
|
||||
id: 'ring-1',
|
||||
itemId: 'ring-1',
|
||||
item: magicItem,
|
||||
text: '学徒之戒',
|
||||
color: GROUND_LABEL_QUALITY_COLORS.magic!,
|
||||
borderColor: GROUND_LABEL_QUALITY_COLORS.magic!,
|
||||
width: 70,
|
||||
height: 18,
|
||||
screenX: 200,
|
||||
screenY: 100,
|
||||
naturalY: 100,
|
||||
}
|
||||
|
||||
overlay.update(true, [layout])
|
||||
const el = container.children[0]
|
||||
|
||||
// Mouseenter
|
||||
el.dispatchEvent({ type: 'mouseenter' })
|
||||
expect(onHover).toHaveBeenCalledWith(magicItem)
|
||||
expect(el.style.borderColor).toBe('#ffffff')
|
||||
|
||||
// Mouseleave
|
||||
el.dispatchEvent({ type: 'mouseleave' })
|
||||
expect(onHover).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { resolveGroundItemSpriteRect } from '../src/client/ui/inventory.ts'
|
||||
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
||||
import { drawGroundItem } from '../src/client/world/act-runtime.ts'
|
||||
import type { GroundItemEntity } from '../src/common/items/ground-items.ts'
|
||||
import type { SpriteRenderer } from '../src/client/render/renderer.ts'
|
||||
import { GameEngine } from '../src/server/engine/engine.ts'
|
||||
|
|
@ -90,107 +89,6 @@ describe('Authentic Diablo II v1.13c Ground Items Flippy Parity', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('2. Ground Item Rendering with Atlas Parity', () => {
|
||||
it('renders authentic Blizzard flippy sprite with 1:1 isometric dimensions and ground shadow', () => {
|
||||
const drawnQuads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
|
||||
const drawnSprites: { frame: any; x: number; y: number; options: any }[] = []
|
||||
const renderer = {
|
||||
drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) {
|
||||
drawnQuads.push({ x, y, w, h, color })
|
||||
},
|
||||
draw(frame: any, x: number, y: number, options: any) {
|
||||
drawnSprites.push({ frame, x, y, options })
|
||||
},
|
||||
} as unknown as SpriteRenderer
|
||||
|
||||
const mockAtlas = { texture: {} as any, width: 1024, height: 2145 } as any
|
||||
const groundSword: GroundItemEntity = {
|
||||
id: 'ground_sword_test',
|
||||
item: { name: 'Short Sword', code: 'ssd' },
|
||||
name: 'Short Sword',
|
||||
nameZh: '短剑',
|
||||
quality: 'magic',
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
invWidth: 1,
|
||||
invHeight: 3,
|
||||
dropTime: 0,
|
||||
x: 300,
|
||||
y: 250,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
sparklePhase: 0,
|
||||
}
|
||||
|
||||
drawGroundItem(renderer, groundSword, 1000, mockAtlas)
|
||||
|
||||
// Verified: Exactly 1 sprite drawn via renderer.draw using flpssd rect
|
||||
expect(drawnSprites.length).toBe(1)
|
||||
const sprite = drawnSprites[0]!
|
||||
expect(sprite.options.atlas).toBe(mockAtlas)
|
||||
expect(sprite.options.width).toBe(27)
|
||||
expect(sprite.options.height).toBe(9)
|
||||
expect(sprite.x).toBe(Math.round(300 - 27 / 2))
|
||||
expect(sprite.y).toBe(250 - 9)
|
||||
|
||||
// Ground contact shadow is drawn at item.y - 2
|
||||
expect(drawnQuads.length).toBe(1)
|
||||
const shadow = drawnQuads[0]!
|
||||
expect(shadow.y).toBe(250 - 2)
|
||||
expect(shadow.color[3]).toBeLessThanOrEqual(0.4) // Subtle shadow alpha
|
||||
|
||||
// Zero blue bounding boxes
|
||||
const blueBox = drawnQuads.find(q => q.color[2] > 0.8 && q.w > 10 && q.h > 10)
|
||||
expect(blueBox).toBeUndefined()
|
||||
|
||||
// Zero white sparkle star quads
|
||||
const whiteGlint = drawnQuads.find(q => q.color[0] === 1.0 && q.color[1] === 1.0 && q.color[2] === 1.0)
|
||||
expect(whiteGlint).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renders authentic flpgld sprite for dropped gold when atlas is present', () => {
|
||||
const drawnQuads: any[] = []
|
||||
const drawnSprites: any[] = []
|
||||
const renderer = {
|
||||
drawSolid(x: number, y: number, w: number, h: number, color: any) {
|
||||
drawnQuads.push({ x, y, w, h, color })
|
||||
},
|
||||
draw(frame: any, x: number, y: number, options: any) {
|
||||
drawnSprites.push({ frame, x, y, options })
|
||||
},
|
||||
} as unknown as SpriteRenderer
|
||||
|
||||
const mockAtlas = { texture: {} as any, width: 1024, height: 2145 } as any
|
||||
const groundGold: GroundItemEntity = {
|
||||
id: 'gold_huge_test',
|
||||
item: { isGold: true },
|
||||
name: '5000 Gold',
|
||||
nameZh: '5000 金币',
|
||||
quality: 'normal',
|
||||
isGold: true,
|
||||
amount: 5000,
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
dropTime: 0,
|
||||
x: 400,
|
||||
y: 350,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
sparklePhase: 0,
|
||||
}
|
||||
|
||||
drawGroundItem(renderer, groundGold, 1000, mockAtlas)
|
||||
|
||||
expect(drawnSprites.length).toBe(1)
|
||||
const goldSprite = drawnSprites[0]!
|
||||
// Tier 2 large pile (500+): 32x20
|
||||
expect(goldSprite.options.width).toBe(32)
|
||||
expect(goldSprite.options.height).toBe(20)
|
||||
expect(goldSprite.x).toBe(Math.round(400 - 32 / 2))
|
||||
expect(goldSprite.y).toBe(350 - 20)
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. Clean Pickup Lifecycle (Zero Remaining Dots / Artifacts)', () => {
|
||||
it('completely cleans groundItems on engine pickup tick without leaving orphaned entities', () => {
|
||||
const engine = new GameEngine(
|
||||
|
|
@ -272,45 +170,6 @@ describe('Authentic Diablo II v1.13c Ground Items Flippy Parity', () => {
|
|||
expect(sr!.h).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('never draws procedural colored boxes even when itemsAtlas is undefined', () => {
|
||||
const drawnQuads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
|
||||
const drawnSprites: { frame: any; x: number; y: number; options: any }[] = []
|
||||
const renderer = {
|
||||
drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) {
|
||||
drawnQuads.push({ x, y, w, h, color })
|
||||
},
|
||||
draw(frame: any, x: number, y: number, options: any) {
|
||||
drawnSprites.push({ frame, x, y, options })
|
||||
},
|
||||
} as unknown as SpriteRenderer
|
||||
|
||||
const groundSword: GroundItemEntity = {
|
||||
id: 'no_atlas_sword',
|
||||
item: { name: 'Short Sword', code: 'ssd' },
|
||||
name: 'Short Sword',
|
||||
nameZh: '短剑',
|
||||
quality: 'magic',
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
invWidth: 1,
|
||||
invHeight: 3,
|
||||
dropTime: 0,
|
||||
x: 300,
|
||||
y: 250,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
sparklePhase: 0,
|
||||
}
|
||||
|
||||
drawGroundItem(renderer, groundSword, 1000, undefined)
|
||||
|
||||
expect(drawnSprites.length).toBe(1)
|
||||
expect(drawnQuads.length).toBe(1) // Only the drop shadow
|
||||
expect(drawnQuads[0]!.color[0]).toBe(0)
|
||||
expect(drawnQuads[0]!.color[1]).toBe(0)
|
||||
expect(drawnQuads[0]!.color[2]).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import {
|
|||
GroundItemManager,
|
||||
type GroundItemEntity,
|
||||
} from '../src/common/items/ground-items.ts'
|
||||
import { SceneMouseController, drawGroundItem } from '../src/client/world/act-runtime.ts'
|
||||
import type { SpriteRenderer } from '../src/client/render/renderer.ts'
|
||||
|
||||
function createSmallEngine() {
|
||||
|
|
@ -156,162 +155,4 @@ describe('Issue #392 — Inventory Full Flippy Bounce & Feedback Ground Truth',
|
|||
expect(item.bounceState).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. Airborne Rendering & Grounded Shadow Anchoring', () => {
|
||||
const createMockRenderer = () => {
|
||||
const drawnQuads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
|
||||
const drawnSprites: { frame: any; x: number; y: number; options: any }[] = []
|
||||
const renderer = {
|
||||
drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) {
|
||||
drawnQuads.push({ x, y, w, h, color })
|
||||
},
|
||||
draw(frame: any, x: number, y: number, options: any) {
|
||||
drawnSprites.push({ frame, x, y, options })
|
||||
},
|
||||
} as unknown as SpriteRenderer
|
||||
|
||||
return { renderer, drawnQuads, drawnSprites }
|
||||
}
|
||||
|
||||
it('anchors drop shadow on ground plane (item.y - 2) while flippy sprite rises to baseY', () => {
|
||||
const { renderer, drawnQuads, drawnSprites } = createMockRenderer()
|
||||
const item: GroundItemEntity = {
|
||||
id: 'flippy_helm',
|
||||
item: { code: 'ghm', name: 'Great Helm' },
|
||||
name: 'Great Helm',
|
||||
nameZh: '高级头盔',
|
||||
quality: 'rare',
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
invWidth: 2,
|
||||
invHeight: 2,
|
||||
dropTime: 0,
|
||||
x: 300,
|
||||
y: 400,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
sparklePhase: 0,
|
||||
bounceState: {
|
||||
startTime: 1000,
|
||||
durationMs: 350,
|
||||
peakHeightPx: 28,
|
||||
phase: 'primary',
|
||||
},
|
||||
}
|
||||
|
||||
// Render at apex (t = 1175, bounceH = 28px)
|
||||
drawGroundItem(renderer, item, 1175)
|
||||
|
||||
const shadow = drawnQuads[0]!
|
||||
const sprite = drawnSprites[0]!
|
||||
|
||||
// Shadow y remains anchored at ground plane item.y - 2 = 398
|
||||
expect(shadow.y).toBe(item.y - 2)
|
||||
|
||||
// Sprite y rises by bounceH (28px): baseY - baseH = (400 - 28) - sprite.options.height
|
||||
expect(sprite.y).toBe(item.y - 28 - sprite.options.height)
|
||||
})
|
||||
|
||||
it('attenuates ground shadow alpha proportionally to airborne bounce height', () => {
|
||||
const { renderer: rGround, drawnQuads: qGround } = createMockRenderer()
|
||||
const { renderer: rAir, drawnQuads: qAir } = createMockRenderer()
|
||||
|
||||
const baseItem: GroundItemEntity = {
|
||||
id: 'tumble_item',
|
||||
item: { code: 'qui', name: 'Quilted Armor' },
|
||||
name: 'Armor',
|
||||
nameZh: '盔甲',
|
||||
quality: 'normal',
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
invWidth: 2,
|
||||
invHeight: 3,
|
||||
dropTime: 0,
|
||||
x: 200,
|
||||
y: 200,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
sparklePhase: 0,
|
||||
}
|
||||
|
||||
// Resting item
|
||||
drawGroundItem(rGround, baseItem, 1000)
|
||||
const restShadow = qGround[0]!
|
||||
expect(restShadow.color[3]).toBeCloseTo(0.38, 2)
|
||||
|
||||
// Airborne bouncing item at apex (t = 1175, bounceH = 28px)
|
||||
const airborneItem: GroundItemEntity = {
|
||||
...baseItem,
|
||||
bounceState: {
|
||||
startTime: 1000,
|
||||
durationMs: 350,
|
||||
peakHeightPx: 28,
|
||||
phase: 'primary',
|
||||
},
|
||||
}
|
||||
|
||||
drawGroundItem(rAir, airborneItem, 1175)
|
||||
const airShadow = qAir[0]!
|
||||
expect(airShadow.color[3]).toBeLessThan(restShadow.color[3])
|
||||
expect(airShadow.color[3]).toBeGreaterThanOrEqual(0.1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('4. Audio-Visual Feedback & Notification Integration', () => {
|
||||
let mockStatus: { textContent: string }
|
||||
let mockCanvas: any
|
||||
|
||||
beforeEach(() => {
|
||||
mockStatus = { textContent: '' }
|
||||
mockCanvas = {
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
it('triggers 包裹已满 notification and audio refusal when picking up with full inventory', () => {
|
||||
const engine = createSmallEngine()
|
||||
// Fill bag completely
|
||||
engine.bag.add({ id: 'f1', name: 'F1', invWidth: 1, invHeight: 2 } as any)
|
||||
engine.bag.add({ id: 'f2', name: 'F2', invWidth: 1, invHeight: 2 } as any)
|
||||
|
||||
const dropped = engine.dropItem(
|
||||
{ id: 'full-shield', name: 'Large Shield', nameZh: '大盾牌', invWidth: 2, invHeight: 2 },
|
||||
515,
|
||||
500,
|
||||
)
|
||||
|
||||
const controller = new SceneMouseController({
|
||||
canvas: mockCanvas,
|
||||
engine,
|
||||
camera: { zoom: 1 } as any,
|
||||
input: {
|
||||
takeLeftClick: () => null,
|
||||
takeRightClick: () => null,
|
||||
shiftHeld: false,
|
||||
movement: () => ({ x: 0, y: 0 }),
|
||||
} as any,
|
||||
getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any,
|
||||
hudManager: null,
|
||||
waypointNetwork: null as any,
|
||||
status: mockStatus as any,
|
||||
playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any,
|
||||
getCharacter: () => null,
|
||||
})
|
||||
|
||||
const feedbackSpy = vi.spyOn(controller, 'playInventoryFullFeedback')
|
||||
const notificationSpy = vi.spyOn(controller, 'showNotification')
|
||||
|
||||
// Execute pickup on full bag
|
||||
controller.pickupGroundItem(dropped)
|
||||
|
||||
expect(mockStatus.textContent).toBe('包裹已满。')
|
||||
expect(feedbackSpy).toHaveBeenCalled()
|
||||
expect(notificationSpy).toHaveBeenCalledWith('包裹已满。')
|
||||
expect(dropped.bounceState).toBeDefined()
|
||||
expect(dropped.bounceState!.phase).toBe('primary')
|
||||
expect(engine.metrics.inventoryRefusals).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { GameEngine } from '../src/server/engine/engine.ts'
|
||||
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.ts'
|
||||
import { SceneMouseController } from '../src/client/world/act-runtime.ts'
|
||||
import type { GroundItemEntity } from '../src/common/items/ground-items.ts'
|
||||
import { seedDemoBeltPotions } from './fixtures/demo-profile-data.ts'
|
||||
|
||||
function createTestEngine() {
|
||||
const terrain = { widthPx: 1000, heightPx: 1000, overlap: () => 0 }
|
||||
|
|
@ -32,7 +32,7 @@ function createTestEngine() {
|
|||
npcDefs: [],
|
||||
})
|
||||
// Explicit legacy full belt (16 potions) so picked-up potions route to the bag, not the belt.
|
||||
engine.belt.seedDefaultPotions()
|
||||
seedDemoBeltPotions(engine.belt)
|
||||
return engine
|
||||
}
|
||||
|
||||
|
|
@ -110,149 +110,4 @@ describe('Ground Items Pickup & Pathfinding (Issue #391)', () => {
|
|||
expect(engine.metrics.inventoryRefusals).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SceneMouseController Click & Pathfinding Integration', () => {
|
||||
function createMockController(engine: GameEngine) {
|
||||
const mockCanvas: any = {
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}
|
||||
const mockStatus: any = { textContent: '' }
|
||||
const mockInput: any = {
|
||||
takeLeftClick: () => null,
|
||||
takeRightClick: () => null,
|
||||
shiftHeld: false,
|
||||
movement: () => ({ x: 0, y: 0 }),
|
||||
}
|
||||
const mockRuntime: any = {
|
||||
grid: { cellsX: 50, cellsY: 50 },
|
||||
waypoints: [],
|
||||
stashes: [],
|
||||
}
|
||||
|
||||
const controller = new SceneMouseController({
|
||||
canvas: mockCanvas,
|
||||
engine,
|
||||
camera: { zoom: 1 } as any,
|
||||
input: mockInput,
|
||||
getRuntime: () => mockRuntime,
|
||||
hudManager: null,
|
||||
waypointNetwork: null as any,
|
||||
status: mockStatus,
|
||||
playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any,
|
||||
getCharacter: () => null,
|
||||
})
|
||||
return { controller, status: mockStatus }
|
||||
}
|
||||
|
||||
it('immediately picks up ground item within authentic 48px range', () => {
|
||||
const engine = createTestEngine()
|
||||
engine.world.player.x = 500
|
||||
engine.world.player.y = 500
|
||||
|
||||
const entity = engine.dropItem(
|
||||
{ id: 'near-potion', name: 'Potion', nameZh: '治疗药剂', invWidth: 1, invHeight: 1 },
|
||||
520, // distance = 20px <= 48px
|
||||
500,
|
||||
)
|
||||
|
||||
const { controller, status } = createMockController(engine)
|
||||
controller.pickupGroundItem(entity)
|
||||
|
||||
expect(engine.groundItems.count).toBe(0)
|
||||
expect(engine.bag.contents.length).toBe(1)
|
||||
expect(status.textContent).toBe('拾起物品:治疗药剂')
|
||||
expect(controller.navTarget).toBeNull()
|
||||
expect(controller.pendingInteraction).toBeNull()
|
||||
})
|
||||
|
||||
it('sets pathfinding navTarget when ground item is farther than 48px', () => {
|
||||
const engine = createTestEngine()
|
||||
engine.world.player.x = 500
|
||||
engine.world.player.y = 500
|
||||
|
||||
const entity = engine.dropItem(
|
||||
{ id: 'far-sword', name: 'Broad Sword', nameZh: '阔剑', invWidth: 1, invHeight: 3 },
|
||||
650, // distance = 150px > 48px
|
||||
500,
|
||||
)
|
||||
|
||||
const { controller } = createMockController(engine)
|
||||
controller.pickupGroundItem(entity)
|
||||
|
||||
// Item should still be on ground, player begins navigating
|
||||
expect(engine.groundItems.count).toBe(1)
|
||||
expect(controller.navTarget).toEqual({ x: 650, y: 500 })
|
||||
expect(controller.pendingInteraction).toEqual({ kind: 'ground-item', item: entity })
|
||||
})
|
||||
|
||||
it('automatically executes pickup upon reaching ground item on tick', () => {
|
||||
const engine = createTestEngine()
|
||||
engine.world.player.x = 500
|
||||
engine.world.player.y = 500
|
||||
|
||||
const entity = engine.dropItem(
|
||||
{ id: 'distant-gold', isGold: true, amount: 1200 },
|
||||
600,
|
||||
500,
|
||||
)
|
||||
|
||||
const { controller, status } = createMockController(engine)
|
||||
controller.pickupGroundItem(entity)
|
||||
|
||||
// Move player close to item
|
||||
engine.world.player.x = 580
|
||||
engine.world.player.y = 500 // distance = 20px <= 48px
|
||||
|
||||
// Tick controller
|
||||
controller.tick()
|
||||
|
||||
expect(engine.groundItems.count).toBe(0)
|
||||
expect(status.textContent).toBe('拾起金币:1200')
|
||||
expect(controller.pendingInteraction).toBeNull()
|
||||
expect(controller.navTarget).toBeNull()
|
||||
})
|
||||
|
||||
it('detects ground item click in handleLeftClickWorld within 32px', () => {
|
||||
const engine = createTestEngine()
|
||||
engine.world.player.x = 500
|
||||
engine.world.player.y = 500
|
||||
|
||||
const entity = engine.dropItem(
|
||||
{ id: 'ring', name: 'Ring', nameZh: '戒指', invWidth: 1, invHeight: 1 },
|
||||
510,
|
||||
510,
|
||||
)
|
||||
|
||||
const { controller, status } = createMockController(engine)
|
||||
// Player clicks near item coordinates (515, 512), within 32px of (510, 510)
|
||||
controller.handleLeftClickWorld(515, 512)
|
||||
|
||||
expect(engine.groundItems.count).toBe(0)
|
||||
expect(status.textContent).toBe('拾起物品:戒指')
|
||||
})
|
||||
|
||||
it('cleans up both groundItems and engine.ground on pickup, leaving zero residual dots', () => {
|
||||
const engine = createTestEngine()
|
||||
engine.world.player.x = 500
|
||||
engine.world.player.y = 500
|
||||
|
||||
const entity = engine.dropItem(
|
||||
{ id: 'sword', name: 'Short Sword', nameZh: '短剑', invWidth: 1, invHeight: 2 },
|
||||
505,
|
||||
505,
|
||||
)
|
||||
|
||||
expect(engine.groundItems.count).toBe(1)
|
||||
expect(engine.ground.length).toBe(1)
|
||||
|
||||
const { controller } = createMockController(engine)
|
||||
controller.pickupGroundItem(entity)
|
||||
|
||||
// Verified: Both groundItems and engine.ground are cleanly cleared
|
||||
expect(engine.groundItems.count).toBe(0)
|
||||
expect(engine.ground.length).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,376 +0,0 @@
|
|||
/**
|
||||
* Diablo II: Lord of Destruction v1.13c — Ground Item Rendering & Glint Sparkle Test Suite
|
||||
*
|
||||
* Requirements (Issue #389):
|
||||
* 1. Isometric depth sorting & entity queue integration.
|
||||
* 2. Adaptive item & gold icon silhouettes with bottom-center anchor and shadows.
|
||||
* 3. Authentic quality colors: normal, magic, rare, set, unique, craft, rune, gold.
|
||||
* 4. Periodic 4-point star glint & sparkle animation with sine easing.
|
||||
* 5. Parabolic bounce vertical translation.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { drawGroundItem, GROUND_ITEM_QUALITY_COLORS } from '../src/client/world/act-runtime.ts'
|
||||
import type { GroundItemEntity } from '../src/common/items/ground-items.ts'
|
||||
import type { SpriteRenderer } from '../src/client/render/renderer.ts'
|
||||
|
||||
describe('Issue #389 — Ground Item Rendering & Glint Sparkle Ground Truth', () => {
|
||||
const createMockRenderer = () => {
|
||||
const drawnQuads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
|
||||
const drawnSprites: { frame: any; x: number; y: number; options: any }[] = []
|
||||
const renderer = {
|
||||
drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) {
|
||||
drawnQuads.push({ x, y, w, h, color })
|
||||
},
|
||||
draw(frame: any, x: number, y: number, options: any) {
|
||||
drawnSprites.push({ frame, x, y, options })
|
||||
},
|
||||
} as unknown as SpriteRenderer
|
||||
|
||||
return { renderer, drawnQuads, drawnSprites }
|
||||
}
|
||||
|
||||
describe('1. GROUND_ITEM_QUALITY_COLORS Palette Parity', () => {
|
||||
it('provides distinct, authentic RGBA color tuples for all D2 quality tiers', () => {
|
||||
const requiredQualities = ['normal', 'low', 'superior', 'magic', 'rare', 'set', 'unique', 'craft', 'rune', 'gold']
|
||||
for (const q of requiredQualities) {
|
||||
const col = GROUND_ITEM_QUALITY_COLORS[q]
|
||||
expect(col).toBeDefined()
|
||||
expect(col!.length).toBe(4)
|
||||
for (const c of col!) {
|
||||
expect(c).toBeGreaterThanOrEqual(0)
|
||||
expect(c).toBeGreaterThanOrEqual(0)
|
||||
expect(c).toBeLessThanOrEqual(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Magic is blue
|
||||
expect(GROUND_ITEM_QUALITY_COLORS.magic![2]).toBeGreaterThan(GROUND_ITEM_QUALITY_COLORS.magic![0])
|
||||
// Rare is yellow (red & green high, blue low)
|
||||
expect(GROUND_ITEM_QUALITY_COLORS.rare![0]).toBeGreaterThan(0.8)
|
||||
expect(GROUND_ITEM_QUALITY_COLORS.rare![1]).toBeGreaterThan(0.8)
|
||||
// Set is green
|
||||
expect(GROUND_ITEM_QUALITY_COLORS.set![1]).toBeGreaterThan(GROUND_ITEM_QUALITY_COLORS.set![0])
|
||||
// Unique is gold/tan
|
||||
expect(GROUND_ITEM_QUALITY_COLORS.unique![0]).toBeGreaterThan(0.7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('2. Gold Pile Rendering & Scaling', () => {
|
||||
it('draws medium gold pile (50 gold) using authentic flpgld_1 sprite and ground shadow', () => {
|
||||
const { renderer, drawnQuads, drawnSprites } = createMockRenderer()
|
||||
const goldItem: GroundItemEntity = {
|
||||
id: 'gold_small',
|
||||
item: { isGold: true },
|
||||
name: '50 Gold',
|
||||
nameZh: '50 金币',
|
||||
quality: 'normal',
|
||||
isGold: true,
|
||||
amount: 50,
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
dropTime: 0,
|
||||
x: 200,
|
||||
y: 150,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
sparklePhase: 0,
|
||||
}
|
||||
|
||||
// Use a timestamp outside the sparkle window (e.g. 1000ms in a 2000ms cycle)
|
||||
drawGroundItem(renderer, goldItem, 1000)
|
||||
|
||||
// Exactly 1 ground contact shadow quad and 1 authentic flippy sprite
|
||||
expect(drawnQuads.length).toBe(1)
|
||||
expect(drawnSprites.length).toBe(1)
|
||||
|
||||
const shadow = drawnQuads[0]!
|
||||
expect(shadow.y).toBe(150 - 2)
|
||||
expect(shadow.color[3]).toBeLessThan(0.5)
|
||||
|
||||
const sprite = drawnSprites[0]!
|
||||
expect(sprite.options.width).toBe(26)
|
||||
expect(sprite.options.height).toBe(13)
|
||||
})
|
||||
|
||||
it('scales gold pile sprite dimensions for large piles (>= 500 gold -> flpgld_2 32x20)', () => {
|
||||
const { renderer, drawnSprites } = createMockRenderer()
|
||||
const largeGold: GroundItemEntity = {
|
||||
id: 'gold_large',
|
||||
item: { isGold: true },
|
||||
name: '10000 Gold',
|
||||
nameZh: '10000 金币',
|
||||
quality: 'normal',
|
||||
isGold: true,
|
||||
amount: 10000,
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
dropTime: 0,
|
||||
x: 300,
|
||||
y: 250,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
sparklePhase: 0,
|
||||
}
|
||||
|
||||
drawGroundItem(renderer, largeGold, 1000)
|
||||
expect(drawnSprites.length).toBe(1)
|
||||
const sprite = drawnSprites[0]!
|
||||
expect(sprite.options.width).toBe(32)
|
||||
expect(sprite.options.height).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. Equipment Item Rendering with Authentic Flippy Sprite & Bounce Offset', () => {
|
||||
it('draws equipment item with ground shadow and authentic flippy sprite (zero procedural border quads)', () => {
|
||||
const { renderer, drawnQuads, drawnSprites } = createMockRenderer()
|
||||
const uniqueSword: GroundItemEntity = {
|
||||
id: 'unique_sword',
|
||||
item: { name: 'The Patriarch', code: 'gis' },
|
||||
name: 'The Patriarch',
|
||||
nameZh: '族长',
|
||||
quality: 'unique',
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
invWidth: 1,
|
||||
invHeight: 3,
|
||||
dropTime: 0,
|
||||
x: 400,
|
||||
y: 350,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
sparklePhase: 0,
|
||||
}
|
||||
|
||||
drawGroundItem(renderer, uniqueSword, 1000)
|
||||
|
||||
// Only 1 shadow quad (outside sparkle window) + 1 flippy sprite
|
||||
expect(drawnQuads.length).toBe(1)
|
||||
expect(drawnSprites.length).toBe(1)
|
||||
expect(drawnQuads[0]!.y).toBe(350 - 2)
|
||||
expect(drawnSprites[0]!.options.width).toBeGreaterThan(0)
|
||||
expect(drawnSprites[0]!.options.height).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('translates vertical sprite rendering position upwards during parabolic bounce', () => {
|
||||
const { renderer: rGround, drawnQuads: quadsGround, drawnSprites: spritesGround } = createMockRenderer()
|
||||
const { renderer: rAir, drawnQuads: quadsAir, drawnSprites: spritesAir } = createMockRenderer()
|
||||
|
||||
const restingItem: GroundItemEntity = {
|
||||
id: 'item_rest',
|
||||
item: { code: 'hlm', name: 'Helm' },
|
||||
name: 'Helm',
|
||||
nameZh: '头盔',
|
||||
quality: 'magic',
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
invWidth: 2,
|
||||
invHeight: 2,
|
||||
dropTime: 0,
|
||||
x: 100,
|
||||
y: 200,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
sparklePhase: 0,
|
||||
}
|
||||
|
||||
const bouncingItem: GroundItemEntity = {
|
||||
...restingItem,
|
||||
id: 'item_bounce',
|
||||
bounceState: {
|
||||
startTime: 1000,
|
||||
durationMs: 400,
|
||||
peakHeightPx: 30,
|
||||
},
|
||||
}
|
||||
|
||||
// Draw resting item at t = 1200
|
||||
drawGroundItem(rGround, restingItem, 1200)
|
||||
// Draw bouncing item at apex (t = 1200, apex = 30px)
|
||||
drawGroundItem(rAir, bouncingItem, 1200)
|
||||
|
||||
const groundSprite = spritesGround[0]!
|
||||
const airSprite = spritesAir[0]!
|
||||
|
||||
// Shadow stays anchored on ground plane (item.y - 2 = 198)
|
||||
expect(quadsGround[0]!.y).toBe(198)
|
||||
expect(quadsAir[0]!.y).toBe(198)
|
||||
|
||||
// Air sprite y should be exactly ground sprite y - 30
|
||||
expect(airSprite.y).toBe(groundSprite.y - 30)
|
||||
})
|
||||
})
|
||||
|
||||
describe('4. Glint & Sparkle 4-Point Star Animation', () => {
|
||||
it('spawns 4-point star rays, core, and quality aura during the active flash window', () => {
|
||||
const { renderer, drawnQuads, drawnSprites } = createMockRenderer()
|
||||
const gem: GroundItemEntity = {
|
||||
id: 'gem_ruby',
|
||||
item: { code: 'gcr', name: 'Perfect Ruby' },
|
||||
name: 'Perfect Ruby',
|
||||
nameZh: '完美红宝石',
|
||||
quality: 'rare',
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
dropTime: 0,
|
||||
x: 500,
|
||||
y: 400,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
sparklePhase: 0,
|
||||
}
|
||||
|
||||
// At t = 160ms (middle of flash window 0..320ms, p = 0.5, intensity = 1.0)
|
||||
drawGroundItem(renderer, gem, 160)
|
||||
|
||||
// Total quads: shadow (1) + sparkle (horizontal ray, vertical ray, core, aura = 4) = 5
|
||||
expect(drawnSprites.length).toBe(1)
|
||||
expect(drawnQuads.length).toBe(5)
|
||||
|
||||
const horizontalRay = drawnQuads[1]!
|
||||
const verticalRay = drawnQuads[2]!
|
||||
const core = drawnQuads[3]!
|
||||
const aura = drawnQuads[4]!
|
||||
|
||||
// Brilliant core color (near-white)
|
||||
expect(core.color[0]).toBeGreaterThan(0.9)
|
||||
expect(core.color[1]).toBeGreaterThan(0.9)
|
||||
expect(core.color[3]).toBeCloseTo(1.0, 1)
|
||||
|
||||
// Aura matches rare quality color
|
||||
expect(aura.color[0]).toBeCloseTo(GROUND_ITEM_QUALITY_COLORS.rare![0], 2)
|
||||
|
||||
// Rays are centered on sparkle point
|
||||
expect(horizontalRay.h).toBe(2)
|
||||
expect(verticalRay.w).toBe(2)
|
||||
expect(horizontalRay.w).toBeGreaterThan(10)
|
||||
expect(verticalRay.h).toBeGreaterThan(10)
|
||||
})
|
||||
|
||||
it('suppresses sparkle rendering during the dormant period of the cycle', () => {
|
||||
const { renderer, drawnQuads, drawnSprites } = createMockRenderer()
|
||||
const item: GroundItemEntity = {
|
||||
id: 'item_dormant',
|
||||
item: { code: 'rin', name: 'Ring' },
|
||||
name: 'Ring',
|
||||
nameZh: '戒指',
|
||||
quality: 'unique',
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
dropTime: 0,
|
||||
x: 200,
|
||||
y: 200,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
sparklePhase: 0,
|
||||
}
|
||||
|
||||
// At t = 1000ms (far outside 0..320ms window)
|
||||
drawGroundItem(renderer, item, 1000)
|
||||
|
||||
// Only ground shadow quad (1), no sparkle quads
|
||||
expect(drawnSprites.length).toBe(1)
|
||||
expect(drawnQuads.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('5. Authentic Ground Item Sprite Parity (Anti-Blue-Box & Clean Pickup)', () => {
|
||||
it('renders authentic item sprite using itemsAtlas instead of solid color box', () => {
|
||||
const drawnQuads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
|
||||
const drawnSprites: { frame: any; x: number; y: number; options: any }[] = []
|
||||
const renderer = {
|
||||
drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) {
|
||||
drawnQuads.push({ x, y, w, h, color })
|
||||
},
|
||||
draw(frame: any, x: number, y: number, options: any) {
|
||||
drawnSprites.push({ frame, x, y, options })
|
||||
},
|
||||
} as unknown as SpriteRenderer
|
||||
|
||||
const mockAtlas = { texture: {} as WebGLTexture, width: 1024, height: 1024 } as any
|
||||
const magicSword: GroundItemEntity = {
|
||||
id: 'magic_sword',
|
||||
item: { name: 'Short Sword', code: 'ssd' },
|
||||
name: 'Short Sword',
|
||||
nameZh: '短剑',
|
||||
quality: 'magic',
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
invWidth: 1,
|
||||
invHeight: 3,
|
||||
dropTime: 0,
|
||||
x: 100,
|
||||
y: 200,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
sparklePhase: 0,
|
||||
}
|
||||
|
||||
drawGroundItem(renderer, magicSword, 1000, mockAtlas)
|
||||
|
||||
// Verified: Sprite is drawn using itemsAtlas
|
||||
expect(drawnSprites.length).toBe(1)
|
||||
const sprite = drawnSprites[0]!
|
||||
expect(sprite.options.atlas).toBe(mockAtlas)
|
||||
expect(sprite.options.width).toBeGreaterThan(0)
|
||||
expect(sprite.options.height).toBeGreaterThan(0)
|
||||
// Drop shadow is drawn on ground
|
||||
expect(drawnQuads.some(q => q.color[3] < 0.5 && q.y >= 198)).toBe(true)
|
||||
// Crucial: No opaque blue body box drawn in quads
|
||||
const blueBody = drawnQuads.find(q => q.color[2] > 0.8 && q.w > 10 && q.h > 10)
|
||||
expect(blueBody).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('6. Authentic Ground Slanted Quad Projection (D2 1.13c Ground Truth)', () => {
|
||||
it('calls renderer.drawQuad with 2:1 isometric ground foreshortened height and shear angle', () => {
|
||||
const drawnQuads: any[] = []
|
||||
const renderer = {
|
||||
drawSolid() {},
|
||||
drawQuad(frame: any, x0: number, y0: number, x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, options: any) {
|
||||
drawnQuads.push({ frame, x0, y0, x1, y1, x2, y2, x3, y3, options })
|
||||
},
|
||||
} as unknown as SpriteRenderer
|
||||
|
||||
const mockAtlas = { texture: {} as WebGLTexture, width: 1024, height: 1024 } as any
|
||||
const groundSword: GroundItemEntity = {
|
||||
id: 'ground_sword',
|
||||
item: { name: 'Broad Sword', code: 'bsd' },
|
||||
name: 'Broad Sword',
|
||||
nameZh: '阔剑',
|
||||
quality: 'normal',
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
invWidth: 1,
|
||||
invHeight: 3,
|
||||
dropTime: 0,
|
||||
x: 300,
|
||||
y: 250,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
sparklePhase: 0,
|
||||
}
|
||||
|
||||
drawGroundItem(renderer, groundSword, 1000, mockAtlas)
|
||||
|
||||
expect(drawnQuads.length).toBe(1)
|
||||
const q = drawnQuads[0]!
|
||||
// Bottom edge rests at baseY (y = 250)
|
||||
expect(q.y2).toBe(250)
|
||||
expect(q.y3).toBe(250)
|
||||
// Top edge is above ground (y0 < 250)
|
||||
expect(q.y0).toBeLessThan(250)
|
||||
expect(q.y1).toBeLessThan(250)
|
||||
// Top edge is sheared horizontally along 2:1 isometric diamond (x0 < x2)
|
||||
expect(q.x0).toBeLessThan(q.x2)
|
||||
expect(q.x1).toBeLessThan(q.x3)
|
||||
// Slanted parallelogram: width of top edge equals width of bottom edge
|
||||
expect(q.x1 - q.x0).toBe(q.x3 - q.x2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -30,7 +30,6 @@ import {
|
|||
subTileAt,
|
||||
} from '../src/common/world/d2map.ts'
|
||||
import type { CollisionGrid } from '../src/common/world/d2map.ts'
|
||||
import { drawGroundItem } from '../src/client/world/act-runtime.ts'
|
||||
import { resolveGroundItemSpriteRect } from '../src/client/ui/inventory.ts'
|
||||
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
||||
import type { SpriteRenderer } from '../src/client/render/renderer.ts'
|
||||
|
|
@ -621,80 +620,6 @@ describe('Milestone M11.3 (Issue #406) — Ground Item & Gold Spawning, Isometri
|
|||
})
|
||||
|
||||
describe('6. Rendering & Sprite Selection Parity', () => {
|
||||
it('drawGroundItem renders sparkle glints on resting items when itemsAtlas is available', () => {
|
||||
const { renderer, drawnQuads, drawnSprites } = createMockRenderer()
|
||||
const mockAtlas = { texture: {} as any, width: 1024, height: 1024 } as any
|
||||
|
||||
const groundSword: GroundItemEntity = {
|
||||
id: 'sword_resting',
|
||||
item: { name: 'Short Sword', code: 'ssd' },
|
||||
name: 'Short Sword',
|
||||
nameZh: '短剑',
|
||||
quality: 'magic',
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
invWidth: 1,
|
||||
invHeight: 3,
|
||||
dropTime: 0,
|
||||
x: 300,
|
||||
y: 250,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
sparklePhase: 0,
|
||||
bounceState: null, // resting!
|
||||
}
|
||||
|
||||
// Render during active flash window (t = 160ms, period = 2000, 160 < 320)
|
||||
drawGroundItem(renderer, groundSword, 160, mockAtlas)
|
||||
|
||||
// Verified: Sprite is drawn
|
||||
expect(drawnSprites.length).toBe(1)
|
||||
expect(drawnSprites[0]!.options.atlas).toBe(mockAtlas)
|
||||
|
||||
// Verified: Drop shadow is drawn
|
||||
const shadow = drawnQuads.find(q => q.y === 250 - 2 && q.color[3] <= 0.4)
|
||||
expect(shadow).toBeDefined()
|
||||
|
||||
// Verified: Sparkle star rays and core are drawn even with itemsAtlas present!
|
||||
const whiteGlint = drawnQuads.find(q => q.color[0] === 1.0 && q.color[1] === 1.0 && q.color[2] === 1.0)
|
||||
expect(whiteGlint).toBeDefined()
|
||||
})
|
||||
|
||||
it('suppresses sparkle glints while item is airborne in bounce trajectory', () => {
|
||||
const { renderer, drawnQuads } = createMockRenderer()
|
||||
const mockAtlas = { texture: {} as any, width: 1024, height: 1024 } as any
|
||||
|
||||
const airborneSword: GroundItemEntity = {
|
||||
id: 'sword_airborne',
|
||||
item: { name: 'Short Sword', code: 'ssd' },
|
||||
name: 'Short Sword',
|
||||
nameZh: '短剑',
|
||||
quality: 'magic',
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
invWidth: 1,
|
||||
invHeight: 3,
|
||||
dropTime: 0,
|
||||
x: 300,
|
||||
y: 250,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
sparklePhase: 0,
|
||||
bounceState: {
|
||||
startTime: 0,
|
||||
durationMs: 400,
|
||||
peakHeightPx: 24,
|
||||
phase: 'primary',
|
||||
},
|
||||
}
|
||||
|
||||
// At t = 160ms (apex bounceH > 0), item is mid-air
|
||||
drawGroundItem(renderer, airborneSword, 160, mockAtlas)
|
||||
|
||||
// While airborne, sparkle rays should NOT be drawn
|
||||
const whiteGlint = drawnQuads.find(q => q.color[0] === 1.0 && q.color[1] === 1.0 && q.color[2] === 1.0)
|
||||
expect(whiteGlint).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves gold pile sprite tier based on amount (1-49, 50-499, 500+)', () => {
|
||||
const flippyRects = BAKED_UI_MANIFEST.flippyRects
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { HERO_SPECS } from '../src/baker/packers/pack-characters.ts'
|
||||
import { HERO_CLASSES, normalizeHeroToken } from '../src/client/world/act-runtime.ts'
|
||||
import { HERO_CLASSES, normalizeHeroToken } from '../src/common/world/scene-population.ts'
|
||||
|
||||
describe('7 Playable Character Classes Artwork & Selection Parity', () => {
|
||||
const packRoot = join(process.cwd(), 'samples', 'd2-packs')
|
||||
|
|
|
|||
|
|
@ -9,19 +9,9 @@
|
|||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
||||
import {
|
||||
SkillHotkeysHud,
|
||||
LEFT_SKILL_BOUNDS,
|
||||
RIGHT_SKILL_BOUNDS,
|
||||
DEFAULT_SORCERESS_SKILLS,
|
||||
PASSIVE_SKILL_IDS,
|
||||
PALADIN_AURA_SKILL_IDS,
|
||||
isPassiveSkill,
|
||||
isAuraSkill,
|
||||
getAvailableSkillsForSide,
|
||||
type HotkeySkillEntry,
|
||||
} from '../src/client/ui/hotkeys.ts'
|
||||
import { SkillHotkeysHud, LEFT_SKILL_BOUNDS, RIGHT_SKILL_BOUNDS, PASSIVE_SKILL_IDS, PALADIN_AURA_SKILL_IDS, isPassiveSkill, isAuraSkill, getAvailableSkillsForSide, type HotkeySkillEntry } from '../src/client/ui/hotkeys.ts'
|
||||
import { D2FontRenderer } from '../src/client/ui/font.ts'
|
||||
import { DEMO_SORCERESS_SKILLS } from './fixtures/demo-profile-data.ts'
|
||||
|
||||
describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)', () => {
|
||||
let hud: SkillHotkeysHud
|
||||
|
|
|
|||
|
|
@ -3,15 +3,9 @@
|
|||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import {
|
||||
SkillHotkeysHud,
|
||||
LEFT_SKILL_BOUNDS,
|
||||
RIGHT_SKILL_BOUNDS,
|
||||
DEFAULT_SORCERESS_SKILLS,
|
||||
resolveSkillIcon,
|
||||
getAvailableSkillsForSide,
|
||||
} from '../src/client/ui/hotkeys.ts'
|
||||
import { SkillHotkeysHud, LEFT_SKILL_BOUNDS, RIGHT_SKILL_BOUNDS, resolveSkillIcon, getAvailableSkillsForSide } from '../src/client/ui/hotkeys.ts'
|
||||
import { createDemoHotkeys } from './fixtures/hud-demo-state.ts'
|
||||
import { DEMO_SORCERESS_SKILLS } from './fixtures/demo-profile-data.ts'
|
||||
|
||||
describe('SkillHotkeysHud & Dual Slot Mechanics', () => {
|
||||
let hud: SkillHotkeysHud
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import {
|
|||
FIREBOLT_META,
|
||||
MISSILE_METAS,
|
||||
} from '../src/client/render/missiles-meta.ts'
|
||||
import { getSkillRange, getSkillMana } from '../src/client/world/act-runtime.ts'
|
||||
import { getSkillRange, getSkillMana } from '../src/common/skills/weapon-combat-meta.ts'
|
||||
import { castSkill } from '../src/server/scene/skill-caster.ts'
|
||||
import { GameEngine } from '../src/server/engine/engine.ts'
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -8,19 +8,13 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
resolveItemSpriteRect,
|
||||
STARTER_BAG_ITEMS,
|
||||
STARTER_EQUIPPED_GEAR,
|
||||
InventoryPanel,
|
||||
EQUIP_SLOTS_LAYOUT,
|
||||
INV_GRID_ORIGIN,
|
||||
} from '../src/client/ui/inventory.ts'
|
||||
import { resolveItemSpriteRect, InventoryPanel, EQUIP_SLOTS_LAYOUT, INV_GRID_ORIGIN } from '../src/client/ui/inventory.ts'
|
||||
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
||||
import { MountedArchives } from '../src/common/mpq/mount.ts'
|
||||
import { MpqArchive } from '../src/common/mpq/archive.ts'
|
||||
import { fileSource } from '../src/baker/io/file-source.ts'
|
||||
import { parseTable } from '../src/common/items/tables.ts'
|
||||
import { DEMO_BAG_ITEMS, DEMO_EQUIPPED_GEAR } from './fixtures/demo-profile-data.ts'
|
||||
|
||||
describe('Milestone M4 (Issue #149): Base item invfile audit & charm icons', () => {
|
||||
const itemRects = BAKED_UI_MANIFEST.itemRects
|
||||
|
|
@ -241,7 +235,7 @@ describe('Milestone M4 (Issue #149): Base item invfile audit & charm icons', ()
|
|||
})
|
||||
|
||||
it('resolves valid sprite rectangles for all starter equipped items and bag items', () => {
|
||||
for (const [slot, item] of Object.entries(STARTER_EQUIPPED_GEAR)) {
|
||||
for (const [slot, item] of Object.entries(DEMO_EQUIPPED_GEAR)) {
|
||||
if (!item) continue
|
||||
const rect = resolveItemSpriteRect(item, itemRects)
|
||||
expect(rect, `Starter equipped ${slot} (${item.name}) must resolve`).not.toBeNull()
|
||||
|
|
@ -249,7 +243,7 @@ describe('Milestone M4 (Issue #149): Base item invfile audit & charm icons', ()
|
|||
expect(rect!.h).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
for (const { item, col, row } of STARTER_BAG_ITEMS) {
|
||||
for (const { item, col, row } of DEMO_BAG_ITEMS) {
|
||||
const rect = resolveItemSpriteRect(item, itemRects)
|
||||
expect(rect, `Starter bag item at (${col}, ${row}) (${item.name}) must resolve`).not.toBeNull()
|
||||
expect(rect!.w).toBeGreaterThan(0)
|
||||
|
|
|
|||
|
|
@ -28,12 +28,12 @@ import {
|
|||
import { createDroppedItem } from '../src/common/items/drop-tables.ts'
|
||||
import { ItemQuality, type ItemBase } from '../src/common/items/items.ts'
|
||||
import type { GroundItemEntity } from '../src/common/items/ground-items.ts'
|
||||
import { formatGroundItemLabelText } from '../src/client/ui/ground-labels.ts'
|
||||
import { STARTER_EQUIPPED_GEAR } from '../src/client/ui/inventory.ts'
|
||||
|
||||
import {
|
||||
CLASS_EXCLUSIVE_SETS,
|
||||
STARTER_STASH_ITEMS,
|
||||
} from '../src/common/units/class-starter-profiles.ts'
|
||||
import { DEMO_EQUIPPED_GEAR } from './fixtures/demo-profile-data.ts'
|
||||
|
||||
describe('Issue #480: Canonical 1.13c .tbl & ItemStatCost.txt Parity across Inventory, Stash, and Ground', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -140,57 +140,6 @@ describe('Issue #480: Canonical 1.13c .tbl & ItemStatCost.txt Parity across Inve
|
|||
level: 1,
|
||||
}
|
||||
|
||||
it('resolves dropped item .name and .nameZh through .tbl and renders ground labels accurately', () => {
|
||||
// hp1 has internal Misc.txt name "Lesser Healing Potion", must resolve to "Minor Healing Potion" / "轻微治疗药剂"
|
||||
const hp1Drop = createDroppedItem(hp1Base, 'normal', { ilvl: 1, dwInitSeed: 480 })
|
||||
expect(hp1Drop.name).toBe('Minor Healing Potion')
|
||||
expect(hp1Drop.nameZh).toBe('轻微治疗药剂')
|
||||
|
||||
const makeGroundEntity = (partial: Partial<GroundItemEntity>): GroundItemEntity => ({
|
||||
id: 'g-1',
|
||||
item: hp1Drop,
|
||||
name: hp1Drop.name,
|
||||
nameZh: hp1Drop.nameZh ?? '',
|
||||
quality: 'normal',
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
dropTime: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
sparklePhase: 0,
|
||||
...partial,
|
||||
})
|
||||
|
||||
expect(formatGroundItemLabelText(makeGroundEntity({}))).toBe('轻微治疗药剂')
|
||||
|
||||
// Ground item without pre-populated nameZh resolves via .tbl in formatGroundItemLabelText
|
||||
expect(
|
||||
formatGroundItemLabelText(
|
||||
makeGroundEntity({
|
||||
name: 'GhoulRI barRI',
|
||||
nameZh: '',
|
||||
quality: 'rare',
|
||||
item: undefined,
|
||||
}),
|
||||
),
|
||||
).toBe('食尸鬼之酒吧')
|
||||
|
||||
expect(
|
||||
formatGroundItemLabelText(
|
||||
makeGroundEntity({
|
||||
name: "Mechanist's Cap of the Colosuss",
|
||||
nameZh: '',
|
||||
quality: 'magic',
|
||||
item: undefined,
|
||||
}),
|
||||
),
|
||||
).toBe('机械师之巨神之帽子')
|
||||
})
|
||||
|
||||
it('formats tooltip titles for Rare and Magic items with internal .txt keys via .tbl', () => {
|
||||
const capDrop = createDroppedItem(capBase, 'rare', { ilvl: 20, dwInitSeed: 480 })
|
||||
const rareItem = {
|
||||
|
|
@ -213,8 +162,8 @@ describe('Issue #480: Canonical 1.13c .tbl & ItemStatCost.txt Parity across Inve
|
|||
})
|
||||
|
||||
describe('4. Inventory & Equipped Gear (背包与装备栏): 1-Stat-Per-Line .tbl Compliance', () => {
|
||||
it('ensures STARTER_EQUIPPED_GEAR has no non-.tbl "/" merged multi-stat lines', () => {
|
||||
for (const [slot, item] of Object.entries(STARTER_EQUIPPED_GEAR)) {
|
||||
it('ensures DEMO_EQUIPPED_GEAR has no non-.tbl "/" merged multi-stat lines', () => {
|
||||
for (const [slot, item] of Object.entries(DEMO_EQUIPPED_GEAR)) {
|
||||
if (!item) continue
|
||||
for (const stat of item.stats) {
|
||||
// "高速跑步/行走" is the single stat FRW name in Chinese, not a multi-stat merge
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import {
|
|||
monsterDisplayName,
|
||||
} from '../src/common/units/monsters-data.ts'
|
||||
import { TOWN_NPC_DESCRIPTORS, getLocalizedNpcName } from '../src/common/units/npc-table.ts'
|
||||
import { createStarterProfileForClass } from '../src/common/units/class-starter-profiles.ts'
|
||||
import { CLASS_EXCLUSIVE_SETS, createStarterBagForClass } from '../src/common/units/class-starter-profiles.ts'
|
||||
import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts'
|
||||
import { CharacterSheetPanel } from '../src/client/ui/character-sheet.ts'
|
||||
import { InventoryPanel, itemToUiInventoryItem } from '../src/client/ui/inventory.ts'
|
||||
|
|
@ -46,7 +46,6 @@ import { ACT_QUESTS, ACT_WAYPOINTS, WorldPanelsHud } from '../src/client/ui/worl
|
|||
import { ControlBarHud } from '../src/client/ui/control-bar.ts'
|
||||
import { GlobesHud } from '../src/client/ui/globes.ts'
|
||||
import { SkillHotkeysHud } from '../src/client/ui/hotkeys.ts'
|
||||
import { cleanChiItemName, formatGroundItemLabelText } from '../src/client/ui/ground-labels.ts'
|
||||
import type { D2FontRenderer } from '../src/client/ui/font.ts'
|
||||
|
||||
const CJK_REGEX = /[\u4e00-\u9fff]/
|
||||
|
|
@ -539,15 +538,14 @@ describe('Issue #503: HUD & Panel Rendering in ENG (Zero CJK) and CHI Modes', ()
|
|||
}
|
||||
})
|
||||
|
||||
it('renders zero CJK in ENG tooltips and zero mixed parentheticals in CHI ground labels across all 7 class starter profiles', () => {
|
||||
it('renders zero CJK in ENG tooltips across all 7 class starter sets and bags', () => {
|
||||
const classCodes = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'] as const
|
||||
expect(classCodes.length).toBe(7)
|
||||
|
||||
for (const code of classCodes) {
|
||||
const profile = createStarterProfileForClass(code)
|
||||
const allItems = [
|
||||
...Object.values(profile.equipped).filter(Boolean),
|
||||
...profile.bagItems.map(b => b.item),
|
||||
...Object.values(CLASS_EXCLUSIVE_SETS[code]).filter(Boolean),
|
||||
...createStarterBagForClass(code).map(b => b.item),
|
||||
]
|
||||
for (const item of allItems) {
|
||||
if (!item) continue
|
||||
|
|
@ -559,24 +557,6 @@ describe('Issue #503: HUD & Panel Rendering in ENG (Zero CJK) and CHI Modes', ()
|
|||
const cjkLines = textCalls.filter(line => CJK_REGEX.test(line))
|
||||
expect(cjkLines, `Starter item ${item.id} (${code}) leaked CJK in ENG tooltip: ${cjkLines.join(' | ')}`).toEqual([])
|
||||
|
||||
// 2. CHI Ground Label & Drop Notification check
|
||||
setTblLang('CHI')
|
||||
const groundEntity = {
|
||||
id: 1,
|
||||
name: item.name,
|
||||
nameZh: item.nameZh,
|
||||
quality: item.quality,
|
||||
x: 0,
|
||||
y: 0,
|
||||
item,
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
droppedAt: 0,
|
||||
} as any
|
||||
const labelChi = formatGroundItemLabelText(groundEntity)
|
||||
expect(/\([A-Za-z]/.test(labelChi), `Ground label in CHI leaked English parens: ${labelChi}`).toBe(false)
|
||||
const cleanedDropName = cleanChiItemName(item.nameZh)
|
||||
expect(/\([A-Za-z]/.test(cleanedDropName), `cleanChiItemName leaked English parens: ${cleanedDropName}`).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -18,11 +18,6 @@ import {
|
|||
} from '../src/client/ui/font.ts'
|
||||
import { CharacterSheetPanel } from '../src/client/ui/character-sheet.ts'
|
||||
import { WorldPanelsHud } from '../src/client/ui/world-panels.ts'
|
||||
import {
|
||||
GroundLabelOverlay,
|
||||
computeInitialGroundLabelLayouts,
|
||||
resolveLadderCollisions,
|
||||
} from '../src/client/ui/ground-labels.ts'
|
||||
import {
|
||||
computeHudLayout,
|
||||
computeDockingLayout,
|
||||
|
|
@ -227,7 +222,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
|||
expect(view.getUint32(0, false)).toBe(0x00010000)
|
||||
})
|
||||
|
||||
it('registers local @font-face D2Exocet and D2Formal436 across HTML pages and GroundLabelOverlay with zero external CDN links', () => {
|
||||
it('registers local @font-face D2Exocet and D2Formal436 across HTML pages with zero external CDN links', () => {
|
||||
const htmlFiles = ['play.html']
|
||||
for (const file of htmlFiles) {
|
||||
const content = readFileSync(join(process.cwd(), file), 'utf8')
|
||||
|
|
@ -244,11 +239,6 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
|||
expect(content).not.toContain('https://')
|
||||
expect(content).not.toContain('http://')
|
||||
}
|
||||
|
||||
const groundLabelsSrc = readFileSync(join(process.cwd(), 'src', 'client', 'ui', 'ground-labels.ts'), 'utf8')
|
||||
expect(groundLabelsSrc).toContain('"D2Exocet"')
|
||||
expect(groundLabelsSrc).toContain('"Times New Roman"')
|
||||
expect(groundLabelsSrc).not.toContain('Georgia')
|
||||
})
|
||||
|
||||
it('configures D2FontRenderer with D2Exocet and D2Formal436 stacks (never Georgia) and blits genuine 1.13c DC6 glyphs when loaded', () => {
|
||||
|
|
@ -860,80 +850,4 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
|||
renderer.measureText('+20% Faster Cast Rate (Lightning Resist +40%) (Fire Resist +35%)', 'font8'),
|
||||
)
|
||||
})
|
||||
|
||||
it('scales #d2-hud-canvas and GroundLabelOverlay accurately under High-DPI (devicePixelRatio > 1) viewport zoom', () => {
|
||||
const cssW = 1280
|
||||
const cssH = 840
|
||||
const layout1x = computeHudLayout(cssW, cssH, 1)
|
||||
const layout15x = computeHudLayout(cssW, cssH, 1.5)
|
||||
const layout2x = computeHudLayout(cssW, cssH, 2)
|
||||
|
||||
expect(layout1x.uiScale).toBeCloseTo(840 / 600, 6)
|
||||
expect(layout2x.uiScale).toBeCloseTo(layout1x.uiScale, 6)
|
||||
expect(layout15x.physicalScale).toBeCloseTo(layout1x.uiScale * 1.5, 6)
|
||||
expect(layout2x.physicalScale).toBeCloseTo(layout1x.uiScale * 2, 6)
|
||||
expect(layout2x.physicalOffsetX).toBe(Math.round(layout1x.offsetX * 2))
|
||||
expect(layout2x.physicalOffsetY).toBe(Math.round(layout1x.offsetY * 2))
|
||||
|
||||
// Verify clientToLogical / logicalToClient round-trip invariance across DPR = 1, 1.5, 2
|
||||
const rect = { left: 0, top: 0, width: cssW, height: cssH }
|
||||
for (const dpr of [1, 1.5, 2]) {
|
||||
const docking = computeDockingLayout(cssW, cssH, dpr)
|
||||
expect(docking.isWidescreen).toBe(true)
|
||||
const clientPt = logicalToClient(561, 93, rect, dpr)
|
||||
const logicalPt = clientToLogical(clientPt.x, clientPt.y, rect, dpr)
|
||||
expect(logicalPt.x).toBeCloseTo(561, 5)
|
||||
expect(logicalPt.y).toBeCloseTo(93, 5)
|
||||
}
|
||||
|
||||
// Verify GroundLabelOverlay projection invariance when canvas physical buffer scales by dpr
|
||||
const items = [
|
||||
{
|
||||
id: 'item-shako',
|
||||
name: 'Shako',
|
||||
nameZh: '军帽',
|
||||
quality: 'unique' as const,
|
||||
x: 120,
|
||||
y: 80,
|
||||
subTileX: 15,
|
||||
subTileY: 10,
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
droppedAtMs: 0,
|
||||
},
|
||||
{
|
||||
id: 'item-monarch',
|
||||
name: 'Monarch',
|
||||
nameZh: '统治者大盾',
|
||||
quality: 'rare' as const,
|
||||
x: 122,
|
||||
y: 81,
|
||||
subTileX: 15,
|
||||
subTileY: 10,
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
droppedAtMs: 0,
|
||||
},
|
||||
]
|
||||
|
||||
const projectAtDpr = (dpr: number) => {
|
||||
const canvasWidth = Math.round(cssW * dpr)
|
||||
const canvasHeight = Math.round(cssH * dpr)
|
||||
const cameraZoom = 1.0 * dpr
|
||||
const scaleX = rect.width / canvasWidth
|
||||
const scaleY = rect.height / canvasHeight
|
||||
return (worldX: number, worldY: number) => ({
|
||||
x: rect.left + rect.width * 0.5 + (worldX - 100) * cameraZoom * scaleX,
|
||||
y: rect.top + rect.height * 0.5 + (worldY - 70) * cameraZoom * scaleY,
|
||||
})
|
||||
}
|
||||
|
||||
const resolved1x = resolveLadderCollisions(computeInitialGroundLabelLayouts(items as any, projectAtDpr(1)))
|
||||
const resolved2x = resolveLadderCollisions(computeInitialGroundLabelLayouts(items as any, projectAtDpr(2)))
|
||||
expect(resolved2x).toHaveLength(2)
|
||||
expect(resolved2x[0]!.screenX).toBeCloseTo(resolved1x[0]!.screenX, 5)
|
||||
expect(resolved2x[0]!.screenY).toBeCloseTo(resolved1x[0]!.screenY, 5)
|
||||
expect(resolved2x[1]!.screenX).toBeCloseTo(resolved1x[1]!.screenX, 5)
|
||||
expect(resolved2x[1]!.screenY).toBeCloseTo(resolved1x[1]!.screenY, 5)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
import { WorldPanelsHud } from '../src/client/ui/world-panels.ts'
|
||||
import { D2FontRenderer } from '../src/client/ui/font.ts'
|
||||
import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts'
|
||||
import { CLASS_STARTER_ATTRS, createStarterProfileForClass } from '../src/common/units/class-starter-profiles.ts'
|
||||
import { CLASS_EXCLUSIVE_SETS, CLASS_STARTER_ATTRS } from '../src/common/units/class-starter-profiles.ts'
|
||||
import type { CharacterClassCode } from '../src/common/units/classes.ts'
|
||||
|
||||
function createMockCtx() {
|
||||
|
|
@ -583,14 +583,13 @@ describe('Issue #506: Diablo II v1.13c Character Equipment Restrictions & Red Re
|
|||
const classes: CharacterClassCode[] = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass']
|
||||
for (const classCode of classes) {
|
||||
const attrs = CLASS_STARTER_ATTRS[classCode]
|
||||
const profile = createStarterProfileForClass(classCode)
|
||||
const ctx = {
|
||||
level: attrs.level,
|
||||
str: attrs.str,
|
||||
dex: attrs.dex,
|
||||
classCode,
|
||||
}
|
||||
for (const [slotId, item] of Object.entries(profile.equipped)) {
|
||||
for (const [slotId, item] of Object.entries(CLASS_EXCLUSIVE_SETS[classCode])) {
|
||||
if (!item) continue
|
||||
expect(
|
||||
canPlayerUseUiItem(item, ctx),
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import {
|
|||
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
||||
import { GameEngine } from '../src/server/engine/engine.ts'
|
||||
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.ts'
|
||||
import { SceneMouseController } from '../src/client/world/act-runtime.ts'
|
||||
import { castSkill } from '../src/server/scene/skill-caster.ts'
|
||||
|
||||
describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge & Pickup Loop', () => {
|
||||
|
|
@ -392,45 +391,6 @@ describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge &
|
|||
return engine
|
||||
}
|
||||
|
||||
it('converts raw ground item into UiInventoryItem upon normal pickup and places into inventory grid', () => {
|
||||
const engine = createTestEngine()
|
||||
const invPanel = new InventoryPanel()
|
||||
invPanel.gridItems = []
|
||||
const hudManager: any = {
|
||||
inventory: invPanel,
|
||||
syncPublishedState: vi.fn(),
|
||||
}
|
||||
|
||||
const rawItem = createTestItem({
|
||||
base: { id: 'hax', name: 'Hand Axe', kind: 'weapon', damage: 6, invWidth: 1, invHeight: 2 },
|
||||
})
|
||||
const dropped = engine.dropItem(rawItem as any, 510, 500)
|
||||
|
||||
const controller = new SceneMouseController({
|
||||
canvas: mockCanvas,
|
||||
engine,
|
||||
camera: { zoom: 1 } as any,
|
||||
input: { takeLeftClick: () => null, takeRightClick: () => null, shiftHeld: false, movement: () => ({ x: 0, y: 0 }) } as any,
|
||||
getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any,
|
||||
hudManager,
|
||||
waypointNetwork: null as any,
|
||||
status: mockStatus as any,
|
||||
playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any,
|
||||
getCharacter: () => null,
|
||||
})
|
||||
|
||||
controller.pickupGroundItem(dropped)
|
||||
|
||||
// Item should be picked up into invPanel.gridItems
|
||||
expect(invPanel.gridItems.length).toBe(1)
|
||||
const placedItem = invPanel.gridItems[0]!.item
|
||||
expect(placedItem.allowedSlots).toEqual(['weapon1', 'weapon2'])
|
||||
expect(placedItem.invFile).toBe('invhax')
|
||||
expect(placedItem.nameZh).toContain('斧')
|
||||
expect(hudManager.syncPublishedState).toHaveBeenCalled()
|
||||
expect(engine.groundItems.count).toBe(0)
|
||||
})
|
||||
|
||||
it('converts raw ground item into UiInventoryItem upon Telekinesis pickup', () => {
|
||||
const engine = createTestEngine()
|
||||
const invPanel = new InventoryPanel()
|
||||
|
|
@ -501,43 +461,6 @@ describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge &
|
|||
})
|
||||
}
|
||||
|
||||
it('accumulates gold and clamps to authentic inventory gold capacity on normal pickup', () => {
|
||||
const engine = createTestEngine()
|
||||
engine.world.player.level = 10
|
||||
engine.bag.gold = 95_000
|
||||
const invPanel = new InventoryPanel()
|
||||
invPanel.playerLevel = 10
|
||||
invPanel.gold = 95_000
|
||||
const hudManager: any = {
|
||||
inventory: invPanel,
|
||||
syncPublishedState: vi.fn(),
|
||||
}
|
||||
|
||||
// Drop 10,000 gold pile
|
||||
const goldDrop = engine.dropGold(10_000, 510, 500, undefined, undefined, { fromWorld: true })
|
||||
expect(goldDrop).not.toBeNull()
|
||||
|
||||
const controller = new SceneMouseController({
|
||||
canvas: mockCanvas,
|
||||
engine,
|
||||
camera: { zoom: 1 } as any,
|
||||
input: { takeLeftClick: () => null, takeRightClick: () => null, shiftHeld: false, movement: () => ({ x: 0, y: 0 }) } as any,
|
||||
getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any,
|
||||
hudManager,
|
||||
waypointNetwork: null as any,
|
||||
status: mockStatus as any,
|
||||
playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any,
|
||||
getCharacter: () => null,
|
||||
})
|
||||
|
||||
controller.pickupGroundItem(goldDrop!)
|
||||
|
||||
// 95,000 + 5,000 clamped to 100,000
|
||||
expect(invPanel.gold).toBe(100_000)
|
||||
expect(hudManager.syncPublishedState).toHaveBeenCalled()
|
||||
expect(mockStatus.textContent).toContain('拾起金币:5000')
|
||||
})
|
||||
|
||||
it('accumulates gold and clamps to authentic inventory gold capacity on Telekinesis pickup', () => {
|
||||
const engine = createTestEngine()
|
||||
engine.world.player.level = 10
|
||||
|
|
@ -566,112 +489,4 @@ describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge &
|
|||
expect(hudManager.syncPublishedState).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('7. Inventory Full Refusal & Parabolic Flippy Bounce', () => {
|
||||
let mockCanvas: any
|
||||
let mockStatus: { textContent: string }
|
||||
|
||||
beforeEach(() => {
|
||||
mockStatus = { textContent: '' }
|
||||
mockCanvas = {
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses pickup when 10x4 grid has no free space, triggering bounce and refusal feedback', () => {
|
||||
const terrain = { widthPx: 1000, heightPx: 1000, overlap: () => 0 }
|
||||
const engine = new GameEngine(terrain, {
|
||||
spawn: { x: 500, y: 500 },
|
||||
stats: [],
|
||||
xpTable: DEMO_EXPERIENCE,
|
||||
skills: DEMO_SKILLS,
|
||||
questDefs: DEMO_QUESTS,
|
||||
combatOptions: {
|
||||
playerSpeed: 4,
|
||||
playerReach: 50,
|
||||
playerCooldownTicks: 10,
|
||||
playerDamage: 5,
|
||||
playerManaPerAttack: 1,
|
||||
respawnTicks: 100,
|
||||
},
|
||||
talkRadius: 48,
|
||||
pickupRadius: 48,
|
||||
inventoryCols: 10,
|
||||
inventoryRows: 4,
|
||||
lootSeed: 1234,
|
||||
npcDefs: [],
|
||||
})
|
||||
engine.setDropTables(dropTables)
|
||||
|
||||
const invPanel = new InventoryPanel()
|
||||
invPanel.gridItems = []
|
||||
// Completely fill the 10x4 inventory grid with 2x2 items (10 items = 40 cells)
|
||||
for (let c = 0; c < 10; c += 2) {
|
||||
for (let r = 0; r < 4; r += 2) {
|
||||
invPanel.gridItems.push({
|
||||
item: {
|
||||
id: `filler-${c}-${r}`,
|
||||
code: 'cap',
|
||||
invFile: 'invcap',
|
||||
name: 'Cap',
|
||||
nameZh: '帽子 (Cap)',
|
||||
baseNameZh: '帽子 (Cap)',
|
||||
quality: 'normal',
|
||||
invWidth: 2,
|
||||
invHeight: 2,
|
||||
allowedSlots: ['helm'],
|
||||
stats: [],
|
||||
},
|
||||
col: c,
|
||||
row: r,
|
||||
})
|
||||
}
|
||||
}
|
||||
expect(invPanel.gridItems.length).toBe(10) // 10 * 4 = 40 cells, 100% full
|
||||
|
||||
const dropped = engine.dropItem(
|
||||
createTestItem({
|
||||
base: { id: 'hax', name: 'Hand Axe', kind: 'weapon', invWidth: 1, invHeight: 2 },
|
||||
}) as any,
|
||||
510,
|
||||
500,
|
||||
)
|
||||
|
||||
const hudManager: any = {
|
||||
inventory: invPanel,
|
||||
syncPublishedState: vi.fn(),
|
||||
}
|
||||
|
||||
const controller = new SceneMouseController({
|
||||
canvas: mockCanvas,
|
||||
engine,
|
||||
camera: { zoom: 1 } as any,
|
||||
input: { takeLeftClick: () => null, takeRightClick: () => null, shiftHeld: false, movement: () => ({ x: 0, y: 0 }) } as any,
|
||||
getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any,
|
||||
hudManager,
|
||||
waypointNetwork: null as any,
|
||||
status: mockStatus as any,
|
||||
playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any,
|
||||
getCharacter: () => null,
|
||||
})
|
||||
|
||||
const feedbackSpy = vi.spyOn(controller, 'playInventoryFullFeedback')
|
||||
const notificationSpy = vi.spyOn(controller, 'showNotification')
|
||||
|
||||
controller.pickupGroundItem(dropped)
|
||||
|
||||
// Item refused
|
||||
expect(engine.metrics.inventoryRefusals).toBe(1)
|
||||
expect(engine.groundItems.count).toBe(1)
|
||||
expect(dropped.bounceState).toBeDefined()
|
||||
expect(dropped.bounceState!.phase).toBe('primary')
|
||||
expect(dropped.bounceState!.durationMs).toBe(350)
|
||||
expect(dropped.bounceState!.peakHeightPx).toBe(28)
|
||||
expect(feedbackSpy).toHaveBeenCalled()
|
||||
expect(notificationSpy).toHaveBeenCalledWith('包裹已满。')
|
||||
expect(mockStatus.textContent).toBe('包裹已满。')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@
|
|||
* Test Architecture:
|
||||
* - Tier 1: Feature Coverage (Happy Path)
|
||||
* 1.1 resolveItemSpriteRect resolves dyed sprites: invcap_cgrn, invgth_dpur, invtgl_lgry, invvbl_blac
|
||||
* 1.2 STARTER_EQUIPPED_GEAR definitions contain canonical 1.13c stats, durability, speed text, dye tokens
|
||||
* 1.3 Call to Arms Crystal Sword in STARTER_BAG_ITEMS contains runewordRunes, durability 20/20, reqStr 43, reqLevel 57, sockets 5, speedText, damage 17-52, and 11 canonical stats
|
||||
* 1.2 DEMO_EQUIPPED_GEAR definitions contain canonical 1.13c stats, durability, speed text, dye tokens
|
||||
* 1.3 Call to Arms Crystal Sword in DEMO_BAG_ITEMS contains runewordRunes, durability 20/20, reqStr 43, reqLevel 57, sockets 5, speedText, damage 17-52, and 11 canonical stats
|
||||
* 1.4 Tooltip header hierarchy (Title fontexocet10 Gold, Runes font8 Gold, Base Type font8 White/Grey)
|
||||
* 1.5 Base stats block (Damage/Defense, Durability, Speed Class, Req Strength, Req Level in White/Grey)
|
||||
* 1.6 Magic affixes in Blue, Sockets line in Blue ('凹槽 (5)')
|
||||
|
|
@ -36,22 +36,12 @@
|
|||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
InventoryPanel,
|
||||
EQUIP_SLOTS_LAYOUT,
|
||||
INV_GRID_ORIGIN,
|
||||
resolveItemSpriteRect,
|
||||
STARTER_BAG_ITEMS,
|
||||
STARTER_EQUIPPED_GEAR,
|
||||
type EquipSlotId,
|
||||
type ResolvableItem,
|
||||
type SpriteRect,
|
||||
type UiInventoryItem,
|
||||
} from '../src/client/ui/inventory.ts'
|
||||
import { InventoryPanel, EQUIP_SLOTS_LAYOUT, INV_GRID_ORIGIN, resolveItemSpriteRect, type EquipSlotId, type ResolvableItem, type SpriteRect, type UiInventoryItem } from '../src/client/ui/inventory.ts'
|
||||
import { WorldPanelsHud } from '../src/client/ui/world-panels.ts'
|
||||
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
||||
import type { D2FontName, D2ColorCode, D2FontRenderer } from '../src/client/ui/font.ts'
|
||||
import { createDemoInventory, createDemoWorldPanels } from './fixtures/hud-demo-state.ts'
|
||||
import { DEMO_BAG_ITEMS, DEMO_EQUIPPED_GEAR } from './fixtures/demo-profile-data.ts'
|
||||
|
||||
// --- Mock & Test Utilities ---
|
||||
|
||||
|
|
@ -182,10 +172,10 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', ()
|
|||
expect(arachRect, 'Arachnid Mesh must resolve to invvbl_blac').toEqual(MOCK_DYED_ITEM_RECTS.invvbl_blac)
|
||||
})
|
||||
|
||||
it('1.2 defines STARTER_EQUIPPED_GEAR with canonical 1.13c stats, durability, speed text, and dye tokens', () => {
|
||||
it('1.2 defines DEMO_EQUIPPED_GEAR with canonical 1.13c stats, durability, speed text, and dye tokens', () => {
|
||||
// Helm: Harlequin Crest (Shako)
|
||||
const helm = STARTER_EQUIPPED_GEAR.helm as any
|
||||
expect(helm, 'STARTER_EQUIPPED_GEAR.helm must exist').toBeDefined()
|
||||
const helm = DEMO_EQUIPPED_GEAR.helm as any
|
||||
expect(helm, 'DEMO_EQUIPPED_GEAR.helm must exist').toBeDefined()
|
||||
expect(helm.invtransform, 'Harlequin Crest must have invtransform: cgrn').toBe('cgrn')
|
||||
expect(helm.defense, 'Shako defense must be 141').toBe(141)
|
||||
expect(helm.durability, 'Shako durability must be 12/12').toEqual({ current: 12, max: 12 })
|
||||
|
|
@ -194,8 +184,8 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', ()
|
|||
expect(helm.stats.length, 'Shako must have exactly 6 canonical modifiers').toBe(6)
|
||||
|
||||
// Armor: Tal Rasha\'s Guardianship (Lacquered Plate)
|
||||
const armor = STARTER_EQUIPPED_GEAR.armor as any
|
||||
expect(armor, 'STARTER_EQUIPPED_GEAR.armor must exist').toBeDefined()
|
||||
const armor = DEMO_EQUIPPED_GEAR.armor as any
|
||||
expect(armor, 'DEMO_EQUIPPED_GEAR.armor must exist').toBeDefined()
|
||||
expect(armor.invtransform, 'Tal Rasha Armor must have invtransform: dpur').toBe('dpur')
|
||||
expect(armor.defense, 'Tal Rasha Armor defense must be 941').toBe(941)
|
||||
expect(armor.durability, 'Tal Rasha Armor durability must be 55/55').toEqual({ current: 55, max: 55 })
|
||||
|
|
@ -205,8 +195,8 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', ()
|
|||
expect(armor.setPieces.length, 'Tal Rasha set must contain 5 set pieces').toBeGreaterThanOrEqual(5)
|
||||
|
||||
// Gloves: Magefist (Light Gauntlets)
|
||||
const gloves = STARTER_EQUIPPED_GEAR.gloves as any
|
||||
expect(gloves, 'STARTER_EQUIPPED_GEAR.gloves must exist').toBeDefined()
|
||||
const gloves = DEMO_EQUIPPED_GEAR.gloves as any
|
||||
expect(gloves, 'DEMO_EQUIPPED_GEAR.gloves must exist').toBeDefined()
|
||||
expect(gloves.invtransform, 'Magefist must have invtransform: lgry').toBe('lgry')
|
||||
expect(gloves.defense, 'Magefist defense must be 25').toBe(25)
|
||||
expect(gloves.durability, 'Magefist durability must be 14/14').toEqual({ current: 14, max: 14 })
|
||||
|
|
@ -214,8 +204,8 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', ()
|
|||
expect(gloves.reqLevel, 'Magefist reqLevel must be 23').toBe(23)
|
||||
|
||||
// Belt: Arachnid Mesh (Spiderweb Sash)
|
||||
const belt = STARTER_EQUIPPED_GEAR.belt as any
|
||||
expect(belt, 'STARTER_EQUIPPED_GEAR.belt must exist').toBeDefined()
|
||||
const belt = DEMO_EQUIPPED_GEAR.belt as any
|
||||
expect(belt, 'DEMO_EQUIPPED_GEAR.belt must exist').toBeDefined()
|
||||
expect(belt.invtransform, 'Arachnid Mesh must have invtransform: blac').toBe('blac')
|
||||
expect(belt.defense, 'Arachnid Mesh defense must be 138').toBe(138)
|
||||
expect(belt.durability, 'Arachnid Mesh durability must be 12/12').toEqual({ current: 12, max: 12 })
|
||||
|
|
@ -223,16 +213,16 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', ()
|
|||
expect(belt.reqLevel, 'Arachnid Mesh reqLevel must be 80').toBe(80)
|
||||
|
||||
// Weapon 1: The Oculus
|
||||
const oculus = STARTER_EQUIPPED_GEAR.weapon1 as any
|
||||
expect(oculus, 'STARTER_EQUIPPED_GEAR.weapon1 must exist').toBeDefined()
|
||||
const oculus = DEMO_EQUIPPED_GEAR.weapon1 as any
|
||||
expect(oculus, 'DEMO_EQUIPPED_GEAR.weapon1 must exist').toBeDefined()
|
||||
expect(oculus.damage, 'Oculus damage must be 18 - 42').toBe('18 - 42')
|
||||
expect(oculus.durability, 'Oculus durability must be 50/50').toEqual({ current: 50, max: 50 })
|
||||
expect(oculus.speedText, 'Oculus weapon speed class must be defined').toBe('法球类 - 普通攻击速度')
|
||||
expect(oculus.reqLevel, 'Oculus reqLevel must be 42').toBe(42)
|
||||
|
||||
// Weapon 2: Spirit Monarch
|
||||
const spirit = STARTER_EQUIPPED_GEAR.weapon2 as any
|
||||
expect(spirit, 'STARTER_EQUIPPED_GEAR.weapon2 must exist').toBeDefined()
|
||||
const spirit = DEMO_EQUIPPED_GEAR.weapon2 as any
|
||||
expect(spirit, 'DEMO_EQUIPPED_GEAR.weapon2 must exist').toBeDefined()
|
||||
expect(spirit.runewordRunes, 'Spirit Monarch must declare runeword runes').toBe("'TalThulOrtAmn'")
|
||||
expect(spirit.defense, 'Spirit Monarch defense must be 148').toBe(148)
|
||||
expect(spirit.durability, 'Spirit Monarch durability must be 148/148').toEqual({ current: 148, max: 148 })
|
||||
|
|
@ -241,17 +231,17 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', ()
|
|||
expect(spirit.sockets, 'Spirit Monarch must have 4 sockets').toBe(4)
|
||||
|
||||
// Boots: War Traveler
|
||||
const boots = STARTER_EQUIPPED_GEAR.boots as any
|
||||
expect(boots, 'STARTER_EQUIPPED_GEAR.boots must exist').toBeDefined()
|
||||
const boots = DEMO_EQUIPPED_GEAR.boots as any
|
||||
expect(boots, 'DEMO_EQUIPPED_GEAR.boots must exist').toBeDefined()
|
||||
expect(boots.defense, 'War Traveler defense must be 139').toBe(139)
|
||||
expect(boots.durability, 'War Traveler durability must be 48/48').toEqual({ current: 48, max: 48 })
|
||||
expect(boots.reqStr, 'War Traveler reqStr must be 95').toBe(95)
|
||||
expect(boots.reqLevel, 'War Traveler reqLevel must be 42').toBe(42)
|
||||
})
|
||||
|
||||
it('1.3 defines Call to Arms Crystal Sword in STARTER_BAG_ITEMS with all 11 canonical runeword & rune stats', () => {
|
||||
const ctaPlacement = STARTER_BAG_ITEMS.find(p => p.item.id === 'bag-cta')
|
||||
expect(ctaPlacement, 'Call to Arms must be present in STARTER_BAG_ITEMS').toBeDefined()
|
||||
it('1.3 defines Call to Arms Crystal Sword in DEMO_BAG_ITEMS with all 11 canonical runeword & rune stats', () => {
|
||||
const ctaPlacement = DEMO_BAG_ITEMS.find(p => p.item.id === 'bag-cta')
|
||||
expect(ctaPlacement, 'Call to Arms must be present in DEMO_BAG_ITEMS').toBeDefined()
|
||||
const cta = ctaPlacement!.item as any
|
||||
|
||||
expect(cta.runewordRunes, 'Call to Arms must have runewordRunes: AmnRalMalIstOhm').toBe("'AmnRalMalIstOhm'")
|
||||
|
|
@ -782,7 +772,7 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', ()
|
|||
|
||||
const targetBagIds = ['bag-cta', 'bag-torch', 'bag-anni', 'bag-gheeds']
|
||||
|
||||
for (const placement of STARTER_BAG_ITEMS) {
|
||||
for (const placement of DEMO_BAG_ITEMS) {
|
||||
if (!targetBagIds.includes(placement.item.id)) continue
|
||||
|
||||
const hoverX = INV_GRID_ORIGIN.x + placement.col * INV_GRID_ORIGIN.cellPx + 14
|
||||
|
|
@ -807,7 +797,7 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', ()
|
|||
const mockFont = createMockFontRenderer()
|
||||
const mockCtx = createMockCanvasContext()
|
||||
|
||||
const ctaPlacement = STARTER_BAG_ITEMS.find(p => p.item.id === 'bag-cta')!
|
||||
const ctaPlacement = DEMO_BAG_ITEMS.find(p => p.item.id === 'bag-cta')!
|
||||
worldPanels.drawItemTooltip(mockCtx, { item: ctaPlacement.item, x: 400, y: 350 }, mockFont)
|
||||
|
||||
// Verify fillRect and strokeRect
|
||||
|
|
|
|||
|
|
@ -1,231 +0,0 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { GameLoop } from '../src/client/sim/loop.ts'
|
||||
|
||||
describe('GameLoop (Issue #19: Decouple simulation from rAF)', () => {
|
||||
it('1. Deterministic ticking at 25 tps in headless/step mode', () => {
|
||||
let tickCount = 0
|
||||
const ticks: number[] = []
|
||||
|
||||
const loop = new GameLoop({
|
||||
tickRate: 25, // 40ms per tick
|
||||
onTick: (tick) => {
|
||||
tickCount++
|
||||
ticks.push(tick)
|
||||
},
|
||||
onRender: () => {}
|
||||
})
|
||||
|
||||
expect(loop.tickMs).toBe(40)
|
||||
expect(loop.tick).toBe(0)
|
||||
|
||||
// Advancing by less than tickMs does not produce a tick
|
||||
loop.step(20)
|
||||
expect(tickCount).toBe(0)
|
||||
expect(loop.accumulator).toBe(20)
|
||||
expect(loop.alpha).toBeCloseTo(0.5, 2)
|
||||
|
||||
// Advancing another 20ms completes 40ms -> exactly 1 tick
|
||||
loop.step(20)
|
||||
expect(tickCount).toBe(1)
|
||||
expect(loop.tick).toBe(1)
|
||||
expect(loop.accumulator).toBe(0)
|
||||
expect(loop.alpha).toBe(0)
|
||||
|
||||
// Advancing by 80ms produces exactly 2 ticks
|
||||
loop.step(80)
|
||||
expect(tickCount).toBe(3)
|
||||
expect(loop.tick).toBe(3)
|
||||
expect(loop.accumulator).toBe(0)
|
||||
|
||||
// Advancing by 1000ms produces exactly 25 ticks
|
||||
loop.step(1000)
|
||||
expect(tickCount).toBe(28) // 3 + 25
|
||||
expect(loop.tick).toBe(28)
|
||||
expect(loop.tickRate).toBe(25.0)
|
||||
expect(loop.simTps).toBe(25.0)
|
||||
})
|
||||
|
||||
it('1b. Deterministic ticking at 25 tps with timers', async () => {
|
||||
let tickCount = 0
|
||||
|
||||
const loop = new GameLoop({
|
||||
tickRate: 25, // 40ms per tick
|
||||
onTick: () => {
|
||||
tickCount++
|
||||
},
|
||||
onRender: () => {}
|
||||
})
|
||||
|
||||
loop.start()
|
||||
expect(loop.isRunning).toBe(true)
|
||||
|
||||
// Wait ~260ms -> should produce at least 5 ticks (40ms * 5 = 200ms)
|
||||
await new Promise(resolve => setTimeout(resolve, 260))
|
||||
loop.stop()
|
||||
expect(loop.isRunning).toBe(false)
|
||||
|
||||
expect(tickCount).toBeGreaterThanOrEqual(5)
|
||||
expect(tickCount).toBeLessThanOrEqual(8)
|
||||
})
|
||||
|
||||
it('2. Artificial render delay test: when onRender has a 50ms stall, simulation ticks do NOT drop and world.tick advances smoothly', async () => {
|
||||
const world = { tick: 0 }
|
||||
let renderCalls = 0
|
||||
|
||||
const loop = new GameLoop({
|
||||
tickRate: 25, // 40ms per tick
|
||||
onTick: (tick) => {
|
||||
world.tick = tick
|
||||
},
|
||||
onRender: () => {
|
||||
renderCalls++
|
||||
// Simulate heavy render frame stall of 50ms
|
||||
const stallStart = performance.now()
|
||||
while (performance.now() - stallStart < 50) {
|
||||
// busy wait
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
loop.start()
|
||||
|
||||
// Run for ~220ms
|
||||
// With 50ms render stall per frame, at most 4 render calls can happen (~20 FPS)
|
||||
// But simulation runs at 25 TPS (40ms per tick) -> should produce ~5 ticks
|
||||
await new Promise(resolve => setTimeout(resolve, 220))
|
||||
loop.stop()
|
||||
|
||||
expect(renderCalls).toBeLessThanOrEqual(4)
|
||||
expect(world.tick).toBeGreaterThanOrEqual(5)
|
||||
// Simulation TPS remains high despite slowed rendering
|
||||
expect(loop.simTps).toBeGreaterThanOrEqual(20)
|
||||
})
|
||||
|
||||
it('3. Accumulator recovery and no catastrophic drop on transient lag', () => {
|
||||
let tickCount = 0
|
||||
|
||||
const loop = new GameLoop({
|
||||
tickRate: 25, // 40ms per tick
|
||||
maxCatchUp: 5, // max 5 ticks (200ms) per step
|
||||
onTick: () => {
|
||||
tickCount++
|
||||
},
|
||||
onRender: () => {}
|
||||
})
|
||||
|
||||
// Transient lag of 150ms (3.75 ticks)
|
||||
loop.step(150)
|
||||
// Consumed 3 ticks (120ms), 30ms remaining
|
||||
expect(tickCount).toBe(3)
|
||||
expect(loop.accumulator).toBe(30)
|
||||
expect(loop.alpha).toBeCloseTo(30 / 40, 2)
|
||||
|
||||
// Next step of 20ms: 30ms + 20ms = 50ms >= 40ms -> consumes 1 tick, 10ms left
|
||||
loop.step(20)
|
||||
expect(tickCount).toBe(4)
|
||||
expect(loop.accumulator).toBe(10)
|
||||
expect(loop.alpha).toBeCloseTo(10 / 40, 2)
|
||||
|
||||
// Huge lag of 500ms (exceeding maxCatchUp * tickMs = 200ms)
|
||||
// Old implementation had `if (this.accumulator > this.tickMs * this.maxCatchUp) this.accumulator = 0`
|
||||
// which wiped out all remaining simulation time!
|
||||
// New implementation clamps the backlog to maxBacklog (200ms) instead of wiping to 0.
|
||||
loop.step(500)
|
||||
// 5 ticks executed in this step (tickCount 4 + 5 = 9)
|
||||
expect(tickCount).toBe(9)
|
||||
// Accumulator is clamped to maxBacklog (200ms), NOT catastrophically dropped to 0!
|
||||
expect(loop.accumulator).toBe(200)
|
||||
|
||||
// Subsequent steps recover and consume the preserved backlog
|
||||
loop.step(40)
|
||||
// Consumes 5 ticks from the 200ms backlog + 40ms new time
|
||||
expect(tickCount).toBe(14)
|
||||
expect(loop.accumulator).toBe(40)
|
||||
|
||||
loop.step(40)
|
||||
expect(tickCount).toBe(16)
|
||||
expect(loop.accumulator).toBe(0)
|
||||
})
|
||||
|
||||
it('4. Clean start / stop lifecycle and timer cleanup', async () => {
|
||||
let tickCount = 0
|
||||
let renderCount = 0
|
||||
|
||||
const loop = new GameLoop({
|
||||
tickRate: 25,
|
||||
onTick: () => {
|
||||
tickCount++
|
||||
},
|
||||
onRender: () => {
|
||||
renderCount++
|
||||
}
|
||||
})
|
||||
|
||||
expect(loop.isRunning).toBe(false)
|
||||
|
||||
// Start
|
||||
loop.start()
|
||||
expect(loop.isRunning).toBe(true)
|
||||
|
||||
// Calling start() again is idempotent
|
||||
loop.start()
|
||||
expect(loop.isRunning).toBe(true)
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 120))
|
||||
expect(tickCount).toBeGreaterThanOrEqual(1)
|
||||
expect(renderCount).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Stop
|
||||
loop.stop()
|
||||
expect(loop.isRunning).toBe(false)
|
||||
const stoppedTicks = tickCount
|
||||
const stoppedRenders = renderCount
|
||||
|
||||
// Calling stop() again is idempotent
|
||||
loop.stop()
|
||||
expect(loop.isRunning).toBe(false)
|
||||
|
||||
// Wait and verify timers are cleaned up and no more callbacks fire
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
expect(tickCount).toBe(stoppedTicks)
|
||||
expect(renderCount).toBe(stoppedRenders)
|
||||
|
||||
// Restart cleanly
|
||||
loop.start()
|
||||
expect(loop.isRunning).toBe(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 120))
|
||||
expect(tickCount).toBeGreaterThan(stoppedTicks)
|
||||
expect(renderCount).toBeGreaterThan(stoppedRenders)
|
||||
loop.stop()
|
||||
})
|
||||
|
||||
it('5. Interpolation alpha calculation and manual render stepping', () => {
|
||||
let lastAlpha = -1
|
||||
let lastFrameMs = -1
|
||||
|
||||
const loop = new GameLoop({
|
||||
tickRate: 25, // 40ms
|
||||
onTick: () => {},
|
||||
onRender: (alpha, frameMs) => {
|
||||
lastAlpha = alpha
|
||||
lastFrameMs = frameMs
|
||||
}
|
||||
})
|
||||
|
||||
loop.render(16.6)
|
||||
expect(lastAlpha).toBe(0)
|
||||
expect(lastFrameMs).toBeCloseTo(16.6, 1)
|
||||
|
||||
loop.step(10) // accumulator = 10ms (10/40 = 0.25)
|
||||
loop.render(16.6)
|
||||
expect(lastAlpha).toBeCloseTo(0.25, 2)
|
||||
|
||||
loop.step(20) // accumulator = 30ms (30/40 = 0.75)
|
||||
loop.render(16.6)
|
||||
expect(lastAlpha).toBeCloseTo(0.75, 2)
|
||||
|
||||
loop.step(10) // accumulator = 40ms -> ticks, accumulator = 0ms
|
||||
loop.render(16.6)
|
||||
expect(lastAlpha).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -8,17 +8,8 @@
|
|||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { drawGroundItem, GROUND_ITEM_QUALITY_COLORS, SceneMouseController } from '../src/client/world/act-runtime.ts'
|
||||
import { createNetSimulation } from '../src/server/engine/state-hash.ts'
|
||||
import { computeLockstepHash, LockstepSession } from '../src/server/engine/state-hash.ts'
|
||||
import {
|
||||
GROUND_LABEL_QUALITY_COLORS,
|
||||
getGroundItemQualityColor,
|
||||
isGroundItemEthereal,
|
||||
isGroundItemSocketed,
|
||||
isGroundItemEtherealOrSocketed,
|
||||
computeInitialGroundLabelLayouts,
|
||||
} from '../src/client/ui/ground-labels.ts'
|
||||
import {
|
||||
BeltHud,
|
||||
BELT_COLS,
|
||||
|
|
@ -107,196 +98,9 @@ describe('Milestone 4 — Visual Integrity, Ground Labels, Auto-Belt & Multiplay
|
|||
expect(rect!.h).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('renders flippy sprite via renderer.draw and never falls back to procedural color rectangles even when itemsAtlas is undefined', () => {
|
||||
const quads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
|
||||
const sprites: { frame: any; x: number; y: number; options: any }[] = []
|
||||
const renderer = {
|
||||
drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) {
|
||||
quads.push({ x, y, w, h, color })
|
||||
},
|
||||
draw(frame: any, x: number, y: number, options: any) {
|
||||
sprites.push({ frame, x, y, options })
|
||||
},
|
||||
} as unknown as SpriteRenderer
|
||||
|
||||
const entity: GroundItemEntity = {
|
||||
id: 'm4_test_cap',
|
||||
item: { code: 'cap', name: 'Cap' },
|
||||
name: 'Cap',
|
||||
nameZh: '帽子',
|
||||
quality: 'magic',
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
invWidth: 2,
|
||||
invHeight: 2,
|
||||
dropTime: 0,
|
||||
x: 320,
|
||||
y: 240,
|
||||
cellX: 10,
|
||||
cellY: 10,
|
||||
sparklePhase: 0,
|
||||
}
|
||||
|
||||
// Timestamp 1000ms is outside the sparkle window
|
||||
drawGroundItem(renderer, entity, 1000, undefined)
|
||||
|
||||
expect(sprites.length).toBe(1)
|
||||
expect(sprites[0]!.frame.width).toBe(BAKED_UI_MANIFEST.flippyRects['flpcap']!.w)
|
||||
expect(sprites[0]!.frame.height).toBe(BAKED_UI_MANIFEST.flippyRects['flpcap']!.h)
|
||||
// Only 1 solid quad (ground shadow), zero colored border/body rectangles
|
||||
expect(quads.length).toBe(1)
|
||||
expect(quads[0]!.color[0]).toBe(0)
|
||||
expect(quads[0]!.color[1]).toBe(0)
|
||||
expect(quads[0]!.color[2]).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Issue #424 — Ground Label Colors & Ethereal/Socketed Gray (#808080) Override', () => {
|
||||
it('detects ethereal and socketed ground items from entity or underlying item metadata', () => {
|
||||
const ethEntity = {
|
||||
id: 'e1',
|
||||
item: { ethereal: true, sockets: 0 },
|
||||
name: 'Monarch',
|
||||
nameZh: '统治者大盾',
|
||||
quality: 'normal' as const,
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
invWidth: 2,
|
||||
invHeight: 3,
|
||||
dropTime: 0,
|
||||
x: 100,
|
||||
y: 100,
|
||||
cellX: 3,
|
||||
cellY: 3,
|
||||
sparklePhase: 0,
|
||||
}
|
||||
const sockEntity = {
|
||||
...ethEntity,
|
||||
id: 's1',
|
||||
item: { ethereal: false, sockets: 4 },
|
||||
}
|
||||
const plainEntity = {
|
||||
...ethEntity,
|
||||
id: 'p1',
|
||||
item: { ethereal: false, sockets: 0 },
|
||||
}
|
||||
|
||||
expect(isGroundItemEthereal(ethEntity)).toBe(true)
|
||||
expect(isGroundItemSocketed(ethEntity)).toBe(false)
|
||||
expect(isGroundItemEtherealOrSocketed(ethEntity)).toBe(true)
|
||||
|
||||
expect(isGroundItemEthereal(sockEntity)).toBe(false)
|
||||
expect(isGroundItemSocketed(sockEntity)).toBe(true)
|
||||
expect(isGroundItemEtherealOrSocketed(sockEntity)).toBe(true)
|
||||
|
||||
expect(isGroundItemEtherealOrSocketed(plainEntity)).toBe(false)
|
||||
})
|
||||
|
||||
it('overrides normal and superior items to #808080 when ethereal or socketed, while preserving magic/rare/set/unique/runeword colors', () => {
|
||||
const mgr = new GroundItemManager()
|
||||
const ethSword = mgr.add({ code: 'bsd', name: 'Broad Sword', quality: 'normal', ethereal: true }, 100, 100, 3, 3)
|
||||
const sockArmor = mgr.add({ code: 'xtp', name: 'Mage Plate', quality: 'superior', sockets: 3 }, 120, 100, 4, 3)
|
||||
const plainArmor = mgr.add({ code: 'xtp', name: 'Mage Plate', quality: 'superior', sockets: 0, ethereal: false }, 140, 100, 5, 3)
|
||||
const ethUnique = mgr.add({ code: 'ame', name: "Titan's Revenge", quality: 'unique', ethereal: true }, 160, 100, 6, 3)
|
||||
const sockMagic = mgr.add({ code: 'bsd', name: 'Mechanic Broad Sword', quality: 'magic', sockets: 2 }, 180, 100, 7, 3)
|
||||
const runeword = mgr.add({ code: 'xtp', name: 'Enigma', quality: 'normal', sockets: 3, isRuneword: true }, 200, 100, 8, 3)
|
||||
|
||||
expect(ethSword.ethereal).toBe(true)
|
||||
expect(sockArmor.sockets).toBe(3)
|
||||
expect(getGroundItemQualityColor(ethSword)).toBe('#808080')
|
||||
expect(getGroundItemQualityColor(sockArmor)).toBe('#808080')
|
||||
expect(getGroundItemQualityColor(plainArmor)).toBe(GROUND_LABEL_QUALITY_COLORS.superior)
|
||||
expect(getGroundItemQualityColor(ethUnique)).toBe(GROUND_LABEL_QUALITY_COLORS.unique)
|
||||
expect(getGroundItemQualityColor(sockMagic)).toBe(GROUND_LABEL_QUALITY_COLORS.magic)
|
||||
expect(getGroundItemQualityColor(runeword)).toBe(GROUND_LABEL_QUALITY_COLORS.unique)
|
||||
|
||||
// Verify GroundLabelLayout uses the gray color and borderColor
|
||||
const layouts = computeInitialGroundLabelLayouts(
|
||||
[ethSword, sockArmor, plainArmor, ethUnique],
|
||||
(wx, wy) => ({ x: wx, y: wy }),
|
||||
)
|
||||
expect(layouts[0]!.color).toBe('#808080')
|
||||
expect(layouts[1]!.color).toBe('#808080')
|
||||
expect(layouts[2]!.color).toBe(GROUND_LABEL_QUALITY_COLORS.superior)
|
||||
expect(layouts[3]!.color).toBe(GROUND_LABEL_QUALITY_COLORS.unique)
|
||||
|
||||
// Verify itemToUiInventoryItem preserves ethereal flag
|
||||
const uiEth = itemToUiInventoryItem(ethSword.item, getEmbeddedDropTables())
|
||||
expect(uiEth.ethereal).toBe(true)
|
||||
})
|
||||
|
||||
it('uses gray (#808080) sparkle aura in drawGroundItem for ethereal/socketed normal items', () => {
|
||||
const quads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
|
||||
const renderer = {
|
||||
drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) {
|
||||
quads.push({ x, y, w, h, color })
|
||||
},
|
||||
draw() {},
|
||||
} as unknown as SpriteRenderer
|
||||
|
||||
const mgr = new GroundItemManager()
|
||||
const ethNormal = mgr.add({ code: 'ssd', name: 'Short Sword', quality: 'normal', ethereal: true }, 0, 0, 0, 0)
|
||||
|
||||
// Render at t = 160ms (peak of sparkle flash window)
|
||||
drawGroundItem(renderer, ethNormal, 160)
|
||||
expect(quads.length).toBe(5)
|
||||
const aura = quads[4]!
|
||||
expect(aura.color[0]).toBeCloseTo(GROUND_ITEM_QUALITY_COLORS.low![0], 2)
|
||||
expect(aura.color[1]).toBeCloseTo(GROUND_ITEM_QUALITY_COLORS.low![1], 2)
|
||||
expect(aura.color[2]).toBeCloseTo(GROUND_ITEM_QUALITY_COLORS.low![2], 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Issue #425 — Pickup Radius (2-Subtile Collision-Box Edge Distance) & Belt Quick-Slotting', () => {
|
||||
it('computes collision-box edge distance and allows pickup up to 2 subtiles (64px edge / 96px center)', () => {
|
||||
expect(PLAYER_COLLISION_EXTENT_PX).toBe(16)
|
||||
expect(ITEM_COLLISION_EXTENT_PX).toBe(16)
|
||||
expect(D2_PICKUP_SUBTILE_REACH_PX).toBe(64)
|
||||
|
||||
// Center distance 96px -> edge distance 96 - 32 = 64px (within reach)
|
||||
expect(getPickupEdgeDistance(500, 500, 596, 500)).toBeCloseTo(64, 5)
|
||||
expect(isWithinPickupBounds(500, 500, 596, 500, 64)).toBe(true)
|
||||
|
||||
// Center distance 97px -> edge distance 65px (outside 64px reach)
|
||||
expect(isWithinPickupBounds(500, 500, 597, 500, 64)).toBe(false)
|
||||
|
||||
// SceneMouseController.tick triggers pickup at 80px center distance (which previously stalled at dist <= 48)
|
||||
const belt = new BeltHud()
|
||||
belt.clear()
|
||||
const engine = createTestEngine(64, belt)
|
||||
const dropped = engine.dropItem({ code: 'hp1', name: 'Minor Healing Potion', invWidth: 1, invHeight: 1 }, 580, 500)
|
||||
// Force exact coordinates 80px from player (500, 500)
|
||||
dropped.x = 580
|
||||
dropped.y = 500
|
||||
|
||||
const mockStatus = { textContent: '' }
|
||||
const controller = new SceneMouseController({
|
||||
canvas: {
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
} as any,
|
||||
engine,
|
||||
camera: { zoom: 1 } as any,
|
||||
input: {
|
||||
takeLeftClick: () => null,
|
||||
takeRightClick: () => null,
|
||||
shiftHeld: false,
|
||||
movement: () => ({ x: 0, y: 0 }),
|
||||
} as any,
|
||||
getRuntime: () => ({ grid: { cellsX: 100, cellsY: 100 }, waypoints: [], stashes: [] }) as any,
|
||||
hudManager: null,
|
||||
waypointNetwork: null as any,
|
||||
status: mockStatus as any,
|
||||
playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any,
|
||||
getCharacter: () => null,
|
||||
})
|
||||
|
||||
controller.pickupGroundItem(dropped)
|
||||
expect(engine.groundItems.count).toBe(0)
|
||||
expect(belt.grid[0]![0]?.code).toBe('hp1')
|
||||
})
|
||||
|
||||
it('implements 2-pass INVENTORY_GetFreeBeltSlot auto-belt placement and falls back to bag when belt is full', () => {
|
||||
const belt = new BeltHud()
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ import {
|
|||
tickProjectiles,
|
||||
type Projectile,
|
||||
} from '../src/server/skills/skills.ts'
|
||||
import { getSkillRange, getSkillMana, drawMissileProjectile, drawExplosion, type LoadedMissileArt } from '../src/client/world/act-runtime.ts'
|
||||
import { getSkillRange, getSkillMana } from '../src/common/skills/weapon-combat-meta.ts'
|
||||
import { drawMissileProjectile, drawExplosion, type LoadedMissileArt } from '../src/client/scene/missile-overlay-renderer.ts'
|
||||
import { castSkill } from '../src/server/scene/skill-caster.ts'
|
||||
import { GameEngine } from '../src/server/engine/engine.ts'
|
||||
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.ts'
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { compositeMonsterAnimation } from '../src/client/render/monster-art.ts'
|
||||
import type { MountedArchives } from '../src/common/mpq/mount.ts'
|
||||
import { missingMonsterArtIds, monsterArtStatusText } from '../src/client/world/act-runtime.ts'
|
||||
import { missingMonsterArtIds } from '../src/client/scene/entity-art-loader.ts'
|
||||
|
||||
/**
|
||||
* A COF with `layerCount` layers, one frame, one direction.
|
||||
|
|
@ -119,20 +119,4 @@ describe('act scene monster art diagnostics', () => {
|
|||
.toEqual(['fallen1'])
|
||||
expect(missingMonsterArtIds(['fallen1'], new Set(['fallen1']))).toEqual([])
|
||||
})
|
||||
|
||||
it('says nothing in the HUD when the load was clean', () => {
|
||||
expect(monsterArtStatusText({ missing: [], errors: 0, layerFailures: 0 })).toBe('')
|
||||
})
|
||||
|
||||
it('shows missing art, load errors and dropped layers in the HUD', () => {
|
||||
const text = monsterArtStatusText({
|
||||
missing: ['fallen1', 'zombie1', 'skeleton1', 'quillrat1'],
|
||||
errors: 2,
|
||||
layerFailures: 5,
|
||||
})
|
||||
expect(text).toContain('怪物美术缺失 4')
|
||||
expect(text).toContain('fallen1、zombie1、skeleton1…+1')
|
||||
expect(text).toContain('加载错误 2')
|
||||
expect(text).toContain('缺层 5')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ describe('monster-art', () => {
|
|||
|
||||
it('declares and re-exports ADDITIVE_MONSTER_TOKENS with ground truth tokens', async () => {
|
||||
const { ADDITIVE_MONSTER_TOKENS } = await import('../src/client/render/monster-art.ts')
|
||||
const { ADDITIVE_MONSTER_TOKENS: SCENE_TOKENS } = await import('../src/client/world/act-runtime.ts')
|
||||
const { ADDITIVE_MONSTER_TOKENS: SCENE_TOKENS } = await import('../src/client/scene/entity-art-loader.ts')
|
||||
|
||||
expect(ADDITIVE_MONSTER_TOKENS).toBe(SCENE_TOKENS)
|
||||
const expected = ['WR', 'WW', 'FR', 'K9', '17', '46', 'FI', 'X3', 'X4', 'XW']
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue