Merge branch 'refactor/remove-legacy-sp' into main

This commit is contained in:
troytt 2026-10-01 09:47:19 +00:00
commit c96e7254cc
155 changed files with 1183 additions and 32492 deletions

View File

@ -48,11 +48,8 @@
"pack:overlays": "tsx src/baker/packers/pack-overlays.ts", "pack:overlays": "tsx src/baker/packers/pack-overlays.ts",
"verify:entity-packs": "tsx src/baker/audit/verify-entity-packs.ts", "verify:entity-packs": "tsx src/baker/audit/verify-entity-packs.ts",
"verify:entities": "tsx src/baker/audit/verify-entity-packs.ts", "verify:entities": "tsx src/baker/audit/verify-entity-packs.ts",
"verify:char-appearance": "tsx tools/verify-char-appearance.ts",
"verify:packs": "tsx src/baker/audit/verify-packs.ts", "verify:packs": "tsx src/baker/audit/verify-packs.ts",
"verify:bundle-no-mpq": "tsx tools/verify-bundle-no-mpq.ts", "verify:bundle-no-mpq": "tsx tools/verify-bundle-no-mpq.ts",
"verify:animation": "tsx tools/verify-animation-browser.ts",
"verify:ground-browser": "tsx tools/verify-ground-combat-browser.ts",
"verify:deploy": "tsx tools/verify-deploy.ts", "verify:deploy": "tsx tools/verify-deploy.ts",
"verify:listfile": "tsx tools/verify-listfile.ts", "verify:listfile": "tsx tools/verify-listfile.ts",
"verify:object-lookup": "tsx tools/verify-object-lookup.ts", "verify:object-lookup": "tsx tools/verify-object-lookup.ts",

137
play.html
View File

@ -46,9 +46,146 @@
#d2-toolbar-container { #d2-toolbar-container {
flex: 0 0 auto; flex: 0 0 auto;
width: 100%;
z-index: 50; z-index: 50;
} }
#d2-toolbar,
.d2-toolbar {
position: relative;
z-index: 1000;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
height: 32px;
min-height: 32px;
max-height: 32px;
padding: 0 12px;
box-sizing: border-box;
background: linear-gradient(180deg, #1b1712 0%, #0d0b08 100%);
border-bottom: 1px solid #4a3c24;
color: #d6c496;
font-family: var(--font-display);
font-size: 12px;
line-height: 1;
white-space: nowrap;
overflow-x: auto;
overflow-y: hidden;
user-select: none;
}
.d2-toolbar__group {
display: inline-flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
flex-shrink: 0;
gap: 6px;
white-space: nowrap;
}
.d2-toolbar__status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border-radius: 3px;
background: #14110d;
border: 1px solid #352b1b;
}
.d2-toolbar__status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
background: #6b6b6b;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
}
.d2-toolbar__status-dot--disconnected { background: #767676; }
.d2-toolbar__status-dot--connecting { background: #e6a822; box-shadow: 0 0 6px #e6a822; }
.d2-toolbar__status-dot--bncs { background: #4495e8; box-shadow: 0 0 6px #4495e8; }
.d2-toolbar__status-dot--mcp { background: #36c5d6; box-shadow: 0 0 6px #36c5d6; }
.d2-toolbar__status-dot--d2gs { background: #2ecc71; box-shadow: 0 0 6px #2ecc71; }
.d2-toolbar__status-dot--error { background: #e74c3c; box-shadow: 0 0 6px #e74c3c; }
.d2-toolbar__rtt {
font-family: "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
color: #a89974;
}
.d2-toolbar__label {
color: #b59e68;
font-size: 11px;
letter-spacing: 0.03em;
}
.d2-toolbar__select {
height: 22px;
padding: 0 6px;
background: #120f0b;
border: 1px solid #4a3c24;
border-radius: 2px;
color: #e2d1a0;
font-size: 11px;
cursor: pointer;
outline: none;
}
.d2-toolbar__select:hover,
.d2-toolbar__btn:hover {
border-color: #c7b377;
color: #fff2c6;
}
.d2-toolbar__btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 22px;
padding: 0 8px;
background: linear-gradient(180deg, #2b2316 0%, #17120b 100%);
border: 1px solid #574629;
border-radius: 2px;
color: #dfc991;
font-size: 11px;
cursor: pointer;
}
.d2-toolbar__btn--active {
background: linear-gradient(180deg, #524020 0%, #2e2310 100%);
border-color: #d4b46a;
color: #fff5d6;
}
.d2-toolbar__diag {
position: absolute;
top: 32px;
left: 12px;
right: 12px;
z-index: 1001;
display: none;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: rgba(38, 12, 10, 0.96);
border: 1px solid #a93226;
border-top: none;
border-radius: 0 0 4px 4px;
color: #ffd6d1;
font-size: 12px;
}
.d2-toolbar__diag--visible {
display: flex;
}
#play-workspace { #play-workspace {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;

View File

@ -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
},
}
}

View File

@ -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'

View File

@ -1,9 +0,0 @@
export {
CommandMapper,
ViewportCamera,
type CameraLike,
type CommandMapperDeps,
type DispatchedCommandRecord,
type PointerWorldTarget,
type SkillHotkeyBinding,
} from './command-mapper.ts'

View File

@ -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'

View File

@ -169,7 +169,39 @@ async function bootPlayApp(): Promise<PlayAppController> {
const initialSettings = settingsStore.get() const initialSettings = settingsStore.get()
setLang(initialSettings.lang) setLang(initialSettings.lang)
let sessionRef: OnlineSession | null = null
const toolbar = new Toolbar({
store: settingsStore,
container: toolbarContainer,
callbacks: {
onLangChange: (lang: GameLang) => {
setLang(lang)
sessionRef?.hudManager.setLang(lang)
},
onViewportChange: (profile: ViewportProfile) => {
applyViewportProfileToDom(profile, {
stageEl,
glCanvas: viewCanvas,
hudCanvas,
})
sessionRef?.hudManager.resize(profile.width, profile.height)
sessionRef?.camera.resize(profile)
},
onAutomapRevealChange: (mode) => {
sessionRef?.automapView.setMode(mode)
},
onInspectorToggle: (open) => {
sessionRef?.packetInspector.setOpen(open)
},
onServerHostReconnect: (newHost) => {
void sessionRef?.disconnect(`Reconnecting to ${newHost}`)
},
},
})
const initialCacheStatus = await registerAssetCache() const initialCacheStatus = await registerAssetCache()
toolbar.setAssetCacheUsageBytes(initialCacheStatus.usageBytes)
const minimap = new Minimap() const minimap = new Minimap()
const renderer = new SpriteRenderer(viewCanvas) const renderer = new SpriteRenderer(viewCanvas)
@ -183,8 +215,6 @@ async function bootPlayApp(): Promise<PlayAppController> {
let itemsAtlasHandle: AtlasHandle | undefined let itemsAtlasHandle: AtlasHandle | undefined
let lastRenderSnapshot: SceneFrameSnapshot | null = null let lastRenderSnapshot: SceneFrameSnapshot | null = null
let sessionRef: OnlineSession | null = null
const hudManager = new HudManager(hudCanvas, { const hudManager = new HudManager(hudCanvas, {
onRenderAutomapOverlay: (ctx, font, logicalWidth, logicalHeight) => { onRenderAutomapOverlay: (ctx, font, logicalWidth, logicalHeight) => {
minimap.drawHudOverlay(ctx, font, logicalWidth, logicalHeight) minimap.drawHudOverlay(ctx, font, logicalWidth, logicalHeight)
@ -339,35 +369,6 @@ async function bootPlayApp(): Promise<PlayAppController> {
} }
} }
const toolbar = new Toolbar({
store: settingsStore,
container: toolbarContainer,
callbacks: {
onLangChange: (lang: GameLang) => {
setLang(lang)
session.hudManager.setLang(lang)
},
onViewportChange: (profile: ViewportProfile) => {
applyViewportProfileToDom(profile, {
stageEl,
glCanvas: viewCanvas,
hudCanvas,
})
session.hudManager.resize(profile.width, profile.height)
session.camera.resize(profile)
},
onAutomapRevealChange: (mode) => {
session.automapView.setMode(mode)
},
onInspectorToggle: (open) => {
inspector.setOpen(open)
},
onServerHostReconnect: (newHost) => {
void session.disconnect(`Reconnecting to ${newHost}`)
},
},
})
toolbar.setAssetCacheUsageBytes(initialCacheStatus.usageBytes)
void assetsReadyPromise.then(async () => { void assetsReadyPromise.then(async () => {
const updatedStatus = await getAssetCacheStatus() const updatedStatus = await getAssetCacheStatus()
toolbar.setAssetCacheUsageBytes(updatedStatus.usageBytes) toolbar.setAssetCacheUsageBytes(updatedStatus.usageBytes)

View File

@ -1 +0,0 @@
export * from "../../common/units/animation.ts"

View File

@ -1 +0,0 @@
export * from "../../common/units/char-appearance.ts"

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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,
)
})
}
}

View File

@ -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 { import type {
AtlasHandle, AtlasHandle,
SpriteRenderer, SpriteRenderer,
} from '../render/renderer.ts' } from '../render/renderer.ts'
import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.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 export let globalItemsAtlasHandle: AtlasHandle | undefined
@ -72,167 +47,3 @@ export async function loadItemsAtlas(
console.error(msg) console.error(msg)
throw new 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])
}
}
}
}

View File

@ -1,18 +1,7 @@
import { import type { CollisionGrid } from '../../common/world/d2map.ts'
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 { Environment } from '../../common/world/environment.ts' import type { Environment } from '../../common/world/environment.ts'
import type { LightGrid } from '../../common/drlg/light-grid.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 { 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 = export type ClientLightingPreset =
| 'auto' | '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 { export interface LightingInputs {
readonly preset: LightingPreset readonly preset: LightingPreset
@ -230,26 +219,18 @@ export interface LightingPassTargets {
): void ): void
} }
readonly environment?: Pick<Environment, 'currentIntensity' | 'currentRed' | 'currentGreen' | 'currentBlue'> | undefined 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` * Compute and upload the 1.13c sub-tile lightmap pass from pure `LightingInputs`.
* without requiring a `GameEngine` instance.
*/ */
export function applyLightingInputs(inputs: LightingInputs, targets: LightingPassTargets): void { export function applyLightingInputs(inputs: LightingInputs, targets: LightingPassTargets): void {
const presetCfg = LIGHTING_PRESETS[inputs.preset] ?? LIGHTING_PRESETS.auto const presetCfg = LIGHTING_PRESETS[inputs.preset] ?? LIGHTING_PRESETS.auto
const lightingEnabled = presetCfg.enabled && inputs.preset !== 'fullbright' && inputs.preset !== 'off' const lightingEnabled = presetCfg.enabled && inputs.preset !== 'fullbright' && inputs.preset !== 'off'
const { lightGrid, renderer, environment, state } = targets const { lightGrid, renderer, environment } = targets
if (!lightingEnabled) { if (!lightingEnabled) {
renderer.setLighting(null) 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 return
} }
@ -309,213 +290,4 @@ export function applyLightingInputs(inputs: LightingInputs, targets: LightingPas
sceneOriginY: inputs.collisionGrid.originY, sceneOriginY: inputs.collisionGrid.originY,
lightmap: lightGrid.exportTextureBuffer(), 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

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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) }
}

View File

@ -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

View File

@ -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'

View File

@ -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 }
}
}

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -5,9 +5,11 @@
position: relative; position: relative;
z-index: 1000; z-index: 1000;
display: flex; display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 12px;
width: 100%; width: 100%;
height: 32px; height: 32px;
min-height: 32px; min-height: 32px;
@ -17,15 +19,21 @@
background: linear-gradient(180deg, #1b1712 0%, #0d0b08 100%); background: linear-gradient(180deg, #1b1712 0%, #0d0b08 100%);
border-bottom: 1px solid #4a3c24; border-bottom: 1px solid #4a3c24;
color: #d6c496; color: #d6c496;
font-family: "Exocet", "Cinzel", "Noto Serif SC", "PingFang SC", Georgia, serif; font-family: var(--font-display, "D2Exocet", "Cinzel", "Noto Serif SC", "PingFang SC", Georgia, serif);
font-size: 12px; font-size: 12px;
line-height: 1; line-height: 1;
white-space: nowrap;
overflow-x: auto;
overflow-y: hidden;
user-select: none; user-select: none;
} }
.d2-toolbar__group { .d2-toolbar__group {
display: inline-flex; display: inline-flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center; align-items: center;
flex-shrink: 0;
gap: 6px; gap: 6px;
white-space: nowrap; white-space: nowrap;
} }

View File

@ -82,13 +82,12 @@ export interface ToolbarSnapshot {
} }
/** /**
* Persistent 32px Top DOM Toolbar (`#d2-toolbar`) with 6 primary controls: * Persistent 32px Top DOM Toolbar (`#d2-toolbar`) with 5 primary controls:
* 1. Connection Status + RTT (`ms`) + Diagnostics (5-per-IP quota / WSS TLS error) + Inspector toggle * 1. Connection Status + RTT (`ms`) + Diagnostics (5-per-IP quota / WSS TLS error) + Inspector toggle
* 2. Server Host Input (`www.laiseek.xyz` default, bare-IP over HTTPS guard, mid-game reconnect prompt) * 2. Live Language Toggle (`中` / `EN`)
* 3. Live Language Toggle (`中` / `EN`) * 3. Lighting Preset Selector (`auto | noon | dusk | night | torch | cold | fullbright`)
* 4. Lighting Preset Selector (`auto | noon | dusk | night | torch | cold | fullbright`) * 4. Automap Reveal Selector (`off | level | act`)
* 5. Automap Reveal Selector (`off | level | act`) * 5. Viewport Size Toggle (`800x600 | 1024x768`) + Asset Cache Clear button
* 6. Viewport Size Toggle (`800x600 | 1024x768`) + Asset Cache Clear button
*/ */
export class Toolbar { export class Toolbar {
private readonly store: SettingsStore private readonly store: SettingsStore
@ -112,9 +111,6 @@ export class Toolbar {
private statusDotEl: HTMLElement | null = null private statusDotEl: HTMLElement | null = null
private statusTextEl: HTMLElement | null = null private statusTextEl: HTMLElement | null = null
private rttTextEl: HTMLElement | null = null private rttTextEl: HTMLElement | null = null
private serverLabelEl: HTMLElement | null = null
private serverInputEl: HTMLInputElement | null = null
private serverApplyBtnEl: HTMLButtonElement | null = null
private langBtnZhEl: HTMLButtonElement | null = null private langBtnZhEl: HTMLButtonElement | null = null
private langBtnEnEl: HTMLButtonElement | null = null private langBtnEnEl: HTMLButtonElement | null = null
private lightingLabelEl: HTMLElement | null = null private lightingLabelEl: HTMLElement | null = null
@ -395,8 +391,146 @@ export class Toolbar {
this.rootEl = null this.rootEl = null
} }
private ensureStyles(doc: Document): void {
if (!doc.head || doc.getElementById('d2-toolbar-styles')) return
const styleEl = doc.createElement('style')
styleEl.id = 'd2-toolbar-styles'
styleEl.textContent = `
#d2-toolbar,
.d2-toolbar {
position: relative;
z-index: 1000;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
height: 32px;
min-height: 32px;
max-height: 32px;
padding: 0 12px;
box-sizing: border-box;
background: linear-gradient(180deg, #1b1712 0%, #0d0b08 100%);
border-bottom: 1px solid #4a3c24;
color: #d6c496;
font-family: var(--font-display, "D2Exocet", "Cinzel", "Noto Serif SC", "PingFang SC", Georgia, serif);
font-size: 12px;
line-height: 1;
white-space: nowrap;
overflow-x: auto;
overflow-y: hidden;
user-select: none;
}
.d2-toolbar__group {
display: inline-flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
flex-shrink: 0;
gap: 6px;
white-space: nowrap;
}
.d2-toolbar__divider {
width: 1px;
height: 18px;
background: #3a2f1d;
}
.d2-toolbar__status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border-radius: 3px;
background: #14110d;
border: 1px solid #352b1b;
}
.d2-toolbar__status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
background: #6b6b6b;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
}
.d2-toolbar__status-dot--disconnected { background: #767676; }
.d2-toolbar__status-dot--connecting { background: #e6a822; box-shadow: 0 0 6px #e6a822; }
.d2-toolbar__status-dot--bncs { background: #4495e8; box-shadow: 0 0 6px #4495e8; }
.d2-toolbar__status-dot--mcp { background: #36c5d6; box-shadow: 0 0 6px #36c5d6; }
.d2-toolbar__status-dot--d2gs { background: #2ecc71; box-shadow: 0 0 6px #2ecc71; }
.d2-toolbar__status-dot--error { background: #e74c3c; box-shadow: 0 0 6px #e74c3c; }
.d2-toolbar__rtt {
font-family: "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
color: #a89974;
}
.d2-toolbar__label {
color: #b59e68;
font-size: 11px;
letter-spacing: 0.03em;
}
.d2-toolbar__select {
height: 22px;
padding: 0 6px;
background: #120f0b;
border: 1px solid #4a3c24;
border-radius: 2px;
color: #e2d1a0;
font-size: 11px;
cursor: pointer;
outline: none;
}
.d2-toolbar__select:hover,
.d2-toolbar__btn:hover {
border-color: #c7b377;
color: #fff2c6;
}
.d2-toolbar__btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 22px;
padding: 0 8px;
background: linear-gradient(180deg, #2b2316 0%, #17120b 100%);
border: 1px solid #574629;
border-radius: 2px;
color: #dfc991;
font-size: 11px;
cursor: pointer;
}
.d2-toolbar__btn--active {
background: linear-gradient(180deg, #524020 0%, #2e2310 100%);
border-color: #d4b46a;
color: #fff5d6;
}
.d2-toolbar__diag {
position: absolute;
top: 32px;
left: 12px;
right: 12px;
z-index: 1001;
display: none;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: rgba(38, 12, 10, 0.96);
border: 1px solid #a93226;
border-top: none;
border-radius: 0 0 4px 4px;
color: #ffd6d1;
font-size: 12px;
}
.d2-toolbar__diag--visible {
display: flex;
}
`
doc.head.appendChild(styleEl)
}
private mountDom(container: HTMLElement | null): void { private mountDom(container: HTMLElement | null): void {
const doc = document const doc = document
this.ensureStyles(doc)
const root = doc.createElement('header') const root = doc.createElement('header')
root.id = 'd2-toolbar' root.id = 'd2-toolbar'
root.className = 'd2-toolbar' root.className = 'd2-toolbar'
@ -430,35 +564,7 @@ export class Toolbar {
statusGroup.appendChild(statusBadge) statusGroup.appendChild(statusBadge)
statusGroup.appendChild(inspectorBtn) statusGroup.appendChild(inspectorBtn)
// Group 2: Server Host Input // Group 2: Language Toggle (中 / EN)
const serverGroup = doc.createElement('div')
serverGroup.className = 'd2-toolbar__group'
const serverLabel = doc.createElement('label')
serverLabel.className = 'd2-toolbar__label'
serverLabel.htmlFor = 'd2-toolbar-server-host'
const serverInput = doc.createElement('input')
serverInput.type = 'text'
serverInput.id = 'd2-toolbar-server-host'
serverInput.className = 'd2-toolbar__input'
serverInput.value = this.serverInputValue
serverInput.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') {
ev.preventDefault()
this.submitServerHost(serverInput.value)
}
})
const serverApplyBtn = doc.createElement('button')
serverApplyBtn.type = 'button'
serverApplyBtn.id = 'd2-toolbar-server-apply'
serverApplyBtn.className = 'd2-toolbar__btn'
serverApplyBtn.addEventListener('click', () => {
this.submitServerHost(serverInput.value)
})
serverGroup.appendChild(serverLabel)
serverGroup.appendChild(serverInput)
serverGroup.appendChild(serverApplyBtn)
// Group 3: Language Toggle (中 / EN)
const langGroup = doc.createElement('div') const langGroup = doc.createElement('div')
langGroup.className = 'd2-toolbar__group' langGroup.className = 'd2-toolbar__group'
const langBtnZh = doc.createElement('button') const langBtnZh = doc.createElement('button')
@ -478,7 +584,7 @@ export class Toolbar {
langGroup.appendChild(langBtnZh) langGroup.appendChild(langBtnZh)
langGroup.appendChild(langBtnEn) langGroup.appendChild(langBtnEn)
// Group 4: Lighting Selector // Group 3: Lighting Selector
const lightingGroup = doc.createElement('div') const lightingGroup = doc.createElement('div')
lightingGroup.className = 'd2-toolbar__group' lightingGroup.className = 'd2-toolbar__group'
const lightingLabel = doc.createElement('label') const lightingLabel = doc.createElement('label')
@ -493,7 +599,7 @@ export class Toolbar {
lightingGroup.appendChild(lightingLabel) lightingGroup.appendChild(lightingLabel)
lightingGroup.appendChild(lightingSelect) lightingGroup.appendChild(lightingSelect)
// Group 5: Automap Reveal Selector (Off / Level / Act) // Group 4: Automap Reveal Selector (Off / Level / Act)
const automapGroup = doc.createElement('div') const automapGroup = doc.createElement('div')
automapGroup.className = 'd2-toolbar__group' automapGroup.className = 'd2-toolbar__group'
const automapLabel = doc.createElement('label') const automapLabel = doc.createElement('label')
@ -508,7 +614,7 @@ export class Toolbar {
automapGroup.appendChild(automapLabel) automapGroup.appendChild(automapLabel)
automapGroup.appendChild(automapSelect) automapGroup.appendChild(automapSelect)
// Group 6: Viewport Size Selector (800x600 / 1024x768) + Asset Cache Clear button // Group 5: Viewport Size Selector (800x600 / 1024x768) + Asset Cache Clear button
const viewportGroup = doc.createElement('div') const viewportGroup = doc.createElement('div')
viewportGroup.className = 'd2-toolbar__group' viewportGroup.className = 'd2-toolbar__group'
const viewportLabel = doc.createElement('label') const viewportLabel = doc.createElement('label')
@ -545,7 +651,6 @@ export class Toolbar {
diagBanner.className = 'd2-toolbar__diag' diagBanner.className = 'd2-toolbar__diag'
root.appendChild(statusGroup) root.appendChild(statusGroup)
root.appendChild(serverGroup)
root.appendChild(langGroup) root.appendChild(langGroup)
root.appendChild(lightingGroup) root.appendChild(lightingGroup)
root.appendChild(automapGroup) root.appendChild(automapGroup)
@ -560,9 +665,6 @@ export class Toolbar {
this.statusDotEl = dot this.statusDotEl = dot
this.statusTextEl = statusText this.statusTextEl = statusText
this.rttTextEl = rttText this.rttTextEl = rttText
this.serverLabelEl = serverLabel
this.serverInputEl = serverInput
this.serverApplyBtnEl = serverApplyBtn
this.langBtnZhEl = langBtnZh this.langBtnZhEl = langBtnZh
this.langBtnEnEl = langBtnEn this.langBtnEnEl = langBtnEn
this.lightingLabelEl = lightingLabel this.lightingLabelEl = lightingLabel
@ -598,21 +700,6 @@ export class Toolbar {
? 'd2-toolbar__btn d2-toolbar__btn--active' ? 'd2-toolbar__btn d2-toolbar__btn--active'
: 'd2-toolbar__btn' : 'd2-toolbar__btn'
} }
if (this.serverLabelEl) {
this.serverLabelEl.textContent = tToolbar('toolbar.server.label', lang)
}
if (this.serverInputEl) {
this.serverInputEl.placeholder = tToolbar('toolbar.server.placeholder', lang)
if (this.serverInputEl.value !== snap.serverInputValue) {
this.serverInputEl.value = snap.serverInputValue
}
this.serverInputEl.className = snap.serverInputValid
? 'd2-toolbar__input'
: 'd2-toolbar__input d2-toolbar__input--invalid'
}
if (this.serverApplyBtnEl) {
this.serverApplyBtnEl.textContent = tToolbar('toolbar.server.apply', lang)
}
if (this.langBtnZhEl) { if (this.langBtnZhEl) {
this.langBtnZhEl.className = this.langBtnZhEl.className =
lang === 'zh' ? 'd2-toolbar__btn d2-toolbar__btn--active' : 'd2-toolbar__btn' lang === 'zh' ? 'd2-toolbar__btn d2-toolbar__btn--active' : 'd2-toolbar__btn'

View File

@ -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'

View File

@ -1 +0,0 @@
export * from '../../common/items/baked-ui-meta.ts'

View File

@ -22,7 +22,6 @@ import {
areBeltPotionsCompatible, areBeltPotionsCompatible,
itemToBeltPotion, itemToBeltPotion,
isAutoBeltablePotion, isAutoBeltablePotion,
DEFAULT_BELT_POTIONS,
BeltModel, BeltModel,
type BeltPotionKind, type BeltPotionKind,
type BeltPotion, type BeltPotion,
@ -39,7 +38,6 @@ export {
areBeltPotionsCompatible, areBeltPotionsCompatible,
itemToBeltPotion, itemToBeltPotion,
isAutoBeltablePotion, isAutoBeltablePotion,
DEFAULT_BELT_POTIONS,
BeltModel, BeltModel,
type BeltPotionKind, type BeltPotionKind,
type BeltPotion, type BeltPotion,

View File

@ -1 +0,0 @@
export * from '../../common/items/cursor-meta.ts'

View File

@ -48,16 +48,6 @@ export class GlobesHud {
proceduralFallbackCount = 0 proceduralFallbackCount = 0
lang: TblLang = 'CHI' 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. * 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 lifeClip = GlobesHud.computeClipRect(LIFE_GLOBE_BOUNDS, state.hp, state.maxHp)
const manaClip = GlobesHud.computeClipRect(MANA_GLOBE_BOUNDS, state.mana, state.maxMana) const manaClip = GlobesHud.computeClipRect(MANA_GLOBE_BOUNDS, state.mana, state.maxMana)
this.syncFallbackCountToWindow()
// Dark socket bowl under globes // Dark socket bowl under globes
this.drawSocketBowl(ctx, LIFE_GLOBE_BOUNDS) this.drawSocketBowl(ctx, LIFE_GLOBE_BOUNDS)
this.drawSocketBowl(ctx, MANA_GLOBE_BOUNDS) this.drawSocketBowl(ctx, MANA_GLOBE_BOUNDS)
@ -136,7 +124,6 @@ export class GlobesHud {
) )
} else if (lifeClip.clipH > 0) { } else if (lifeClip.clipH > 0) {
this.proceduralFallbackCount += 1 this.proceduralFallbackCount += 1
this.syncFallbackCountToWindow()
console.warn('[GlobesHud] Authentic life globe asset missing on draw; rendering procedural fallback') 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') this.drawProceduralLiquid(ctx, LIFE_GLOBE_BOUNDS, lifeClip.ratio, state.poisoned ? '#24b624' : '#b81414')
} }
@ -156,7 +143,6 @@ export class GlobesHud {
) )
} else if (manaClip.clipH > 0) { } else if (manaClip.clipH > 0) {
this.proceduralFallbackCount += 1 this.proceduralFallbackCount += 1
this.syncFallbackCountToWindow()
console.warn('[GlobesHud] Authentic mana globe asset missing on draw; rendering procedural fallback') console.warn('[GlobesHud] Authentic mana globe asset missing on draw; rendering procedural fallback')
this.drawProceduralLiquid(ctx, MANA_GLOBE_BOUNDS, manaClip.ratio, '#143cb8') this.drawProceduralLiquid(ctx, MANA_GLOBE_BOUNDS, manaClip.ratio, '#143cb8')
} }

View File

@ -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
}
}
}

View File

@ -22,8 +22,6 @@ import {
isLeftUsableSkill, isLeftUsableSkill,
PALADIN_AURA_SKILL_IDS, PALADIN_AURA_SKILL_IDS,
isAuraSkill, isAuraSkill,
DEFAULT_UNIVERSAL_SKILLS,
DEFAULT_SORCERESS_SKILLS,
type HotkeySkillEntry, type HotkeySkillEntry,
} from '../../common/skills/skills-meta.ts' } from '../../common/skills/skills-meta.ts'
@ -34,8 +32,6 @@ export {
isLeftUsableSkill, isLeftUsableSkill,
PALADIN_AURA_SKILL_IDS, PALADIN_AURA_SKILL_IDS,
isAuraSkill, isAuraSkill,
DEFAULT_UNIVERSAL_SKILLS,
DEFAULT_SORCERESS_SKILLS,
type HotkeySkillEntry, type HotkeySkillEntry,
} }

View File

@ -11,7 +11,6 @@
* Inventory & Equipment Paperdoll (`I`/`B`), Skill Tree (`T`) * Inventory & Equipment Paperdoll (`I`/`B`), Skill Tree (`T`)
* - Top Monster Health Bar, Item Hover Tooltips, Ground `Alt` Item Labels, Area Entry Banner, * - Top Monster Health Bar, Item Hover Tooltips, Ground `Alt` Item Labels, Area Entry Banner,
* and Authentic Hand Cursor (`CURSOR\ohand.dc6`) / Held `cursorItem` Sprite. * 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' import { D2FontRenderer } from './font.ts'
@ -46,12 +45,6 @@ import {
type CursorState, type CursorState,
} from './cursor.ts' } from './cursor.ts'
import type { CharacterClassCode } from '../../common/units/classes.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 type { TownNpcServiceDescriptor, VendorNpcId } from '../../common/units/npc-table.ts'
import { import {
detectInitialLang, detectInitialLang,
@ -121,13 +114,6 @@ export interface PublishedHudState {
npcMenuOpen?: boolean npcMenuOpen?: boolean
} }
declare global {
interface Window {
__d2webHud?: PublishedHudState
__d2webHudInstance?: HudManager
}
}
interface UiManifest { interface UiManifest {
readonly version: string readonly version: string
readonly decodedDc6Count: number readonly decodedDc6Count: number
@ -435,7 +421,6 @@ export class HudManager {
this.syncPublishedState() this.syncPublishedState()
} }
private classProfiles = new Map<CharacterClassCode, ClassProfileState>()
private _lang: TblLang = 'CHI' private _lang: TblLang = 'CHI'
get lang(): TblLang { get lang(): TblLang {
@ -478,11 +463,8 @@ export class HudManager {
onDropItemToGround?: (item: UiInventoryItem, mousePos?: { x: number; y: number }) => void onDropItemToGround?: (item: UiInventoryItem, mousePos?: { x: number; y: number }) => void
onDropGoldToGround?: (amount: number, mousePos?: { x: number; y: number }) => void onDropGoldToGround?: (amount: number, mousePos?: { x: number; y: number }) => void
} }
engine: GameEngine | null = null
private changeListeners: Array<() => void> = []
constructor( constructor(
hudCanvasOrEngine?: HTMLCanvasElement | GameEngine | null, hudCanvas?: HTMLCanvasElement | null,
callbacks?: { callbacks?: {
onToggleAutomap?: () => void onToggleAutomap?: () => void
onWaypointTeleport?: (act: number, slug: string, levelId?: number) => 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 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 = this.hudCanvas =
rawCanvas ?? hudCanvas ??
({ ({
width: 800, width: 800,
height: 600, height: 600,
@ -542,182 +517,34 @@ export class HudManager {
if (this.hudCanvas && this.hudCanvas.style) { if (this.hudCanvas && this.hudCanvas.style) {
this.hudCanvas.style.cursor = 'none' 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 { 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 = const shortKey: BaseStatKey =
normAttr === 'strength' attr === 'strength'
? 'str' ? 'str'
: normAttr === 'dexterity' : attr === 'dexterity'
? 'dex' ? 'dex'
: normAttr === 'vitality' : attr === 'vitality'
? 'vit' ? 'vit'
: 'ene' : attr === 'energy'
// Server authority: only request `AddStatPoint`; attributes/vitals change when the server answers. ? 'ene'
: attr
if (this._commandSink) return this._commandSink.allocateStat(shortKey) if (this._commandSink) return this._commandSink.allocateStat(shortKey)
return false 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 { useBeltSlot(col: number): boolean {
return this.drinkBeltSlot(col) return this.drinkBeltSlot(col)
} }
dropInventoryGold(amount: number, mousePos?: { x: number; y: number }): number { 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
}
// Server authority: request `0x50 DropGold`; gold stat and ground pile arrive from the server. // Server authority: request `0x50 DropGold`; gold stat and ground pile arrive from the server.
this._commandSink?.dropGold(amount) this._commandSink?.dropGold(amount)
return 0 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 { get screenWidth(): number {
return this.viewportProfile.width return this.viewportProfile.width
} }
@ -777,90 +604,6 @@ export class HudManager {
this.handleHudMouseDown(fakeEvent) 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> { async init(baseUrl = '/ui'): Promise<void> {
this.hotkeys.preloadSkillIcons() this.hotkeys.preloadSkillIcons()
this.skillTree.preloadAssets() this.skillTree.preloadAssets()
@ -1147,11 +890,6 @@ export class HudManager {
} }
drinkBeltSlot(col: number): boolean { 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. // Server authority: request `0x26 BeltUse`; healing and belt contents come back from the server.
if (this._commandSink) return this._commandSink.useBeltSlot(col) if (this._commandSink) return this._commandSink.useBeltSlot(col)
return false return false
@ -1436,19 +1174,6 @@ export class HudManager {
const leftX = pt.x - deltaLeft const leftX = pt.x - deltaLeft
if (this.leftPanel === 'char') { if (this.leftPanel === 'char') {
this.charSheet.handleClick(leftX, pt.y, (key: BaseStatKey) => { 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) this._commandSink?.allocateStat(key)
}) })
if (!this.charSheet.visible) this.leftPanel = 'none' if (!this.charSheet.visible) this.leftPanel = 'none'
@ -1691,13 +1416,6 @@ export class HudManager {
rightUsable: true, rightUsable: true,
isAura: isAuraSkill(skillId), 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() { private buildControlBarState() {

View File

@ -1 +0,0 @@
export * from "./inventory.ts"

View File

@ -564,426 +564,6 @@ export function resolveGroundItemSpriteRect(
return resolveItemSpriteRect(item, BAKED_UI_MANIFEST.itemRects) 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`. */ /** Maximum scrolls held in a Tome of Town Portal (`tbk`) or Tome of Identify (`ibk`) per `Books.txt`. */
export const TOME_MAX_QUANTITY = 20 export const TOME_MAX_QUANTITY = 20
@ -2129,33 +1709,22 @@ export class InventoryPanel {
this.playerContextSeq = nextPlayerContextSeq() 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). */ /** Server stat 14 (`gold`); 0 until received (D2GS omits zero-valued stats). */
private _gold = 0 private _gold = 0
/** Authentic 1.13c inventory gold capacity: playerLevel * 10,000. */ /** Authentic 1.13c inventory gold capacity: playerLevel * 10,000. */
get maxGold(): number { get maxGold(): number {
if (this._boundEngine) {
return this._boundEngine.maxGoldCapacity
}
return getInventoryGoldLimit(this.playerLevel) return getInventoryGoldLimit(this.playerLevel)
} }
/** Player gold in inventory. */ /** Player gold in inventory. */
get gold(): number { get gold(): number {
if (this._boundEngine) {
return this._boundEngine.bag.gold
}
return this._gold return this._gold
} }
set gold(value: number) { set gold(value: number) {
const safe = Math.max(0, Math.floor(Number.isFinite(value) ? value : 0)) const safe = Math.max(0, Math.floor(Number.isFinite(value) ? value : 0))
this._gold = safe this._gold = safe
if (this._boundEngine) {
this._boundEngine.bag.gold = safe
}
} }
isIdentifyMode(): boolean { isIdentifyMode(): boolean {

View File

@ -1 +0,0 @@
export * from '../../common/items/item-bridge.ts'

View File

@ -1977,11 +1977,11 @@ export class WorldPanelsHud {
withdrawGold( withdrawGold(
amount: number, amount: number,
playerInventory: { gold: number; maxGold?: number; _playerLevelExplicitlySet?: boolean; _boundEngine?: unknown }, playerInventory: { gold: number; maxGold?: number; _playerLevelExplicitlySet?: boolean },
): number { ): number {
if (amount <= 0) return 0 if (amount <= 0) return 0
const cap = const cap =
(playerInventory._playerLevelExplicitlySet || playerInventory._boundEngine) && playerInventory._playerLevelExplicitlySet &&
playerInventory.maxGold !== undefined playerInventory.maxGold !== undefined
? playerInventory.maxGold ? playerInventory.maxGold
: PLAYER_GOLD_CAP : PLAYER_GOLD_CAP

View File

@ -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,
}

View File

@ -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'

View File

@ -163,49 +163,6 @@ export function isAutoBeltablePotion(item: unknown): boolean {
return itemToBeltPotion(item) !== null 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 { export class BeltModel {
/** 4 columns × 4 rows (`grid[row][col]`, row 0 is bottom visible row). */ /** 4 columns × 4 rows (`grid[row][col]`, row 0 is bottom visible row). */
readonly grid: (BeltPotion | null)[][] = Array.from({ length: BELT_ROWS }, () => readonly grid: (BeltPotion | null)[][] = Array.from({ length: BELT_ROWS }, () =>
@ -218,18 +175,6 @@ export class BeltModel {
/** Currently hovered slot `(row, col)`. */ /** Currently hovered slot `(row, col)`. */
hoveredSlot: { row: number; col: number } | null = null 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 { get isExpanded(): boolean {
return this.lockedOpen || this.hovered return this.lockedOpen || this.hovered
} }

View File

@ -183,45 +183,5 @@ export function isAuraSkill(skillId: number): boolean {
return false 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 } export type { CharacterClassCode }

View File

@ -12,21 +12,11 @@
* - Druid: Aldur's Watchtower (Hunter's Guise, Shadow Plate, Jagged Star, Battle Boots) * - 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) * - 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. * - 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 { CharacterClassCode } from './classes.ts'
import type { EquipSlotId, GridPlacement, UiInventoryItem } from '../items/inventory-model.ts' import type { EquipSlotId, GridPlacement, UiInventoryItem } from '../items/inventory-model.ts'
import type { CharacterAttributes } from './unit-types.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' import { getSocketableInfo } from '../items/gems-data.ts'
function buildSocketableStats(code: string): readonly { text: string; color: 'white' }[] { 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) // 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 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,
}
}

View File

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' 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 { ADDITIVE_MONSTER_TOKENS } from '../src/client/render/monster-art.ts'
import { import {
executeMonsterRenderLoop, executeMonsterRenderLoop,

View File

@ -8,8 +8,6 @@
* content twice over. * content twice over.
*/ */
import { describe, expect, it } from 'vitest' 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 * as demoData from '../src/common/data/demo-data.ts'
import { planLevelMonsters } from '../src/common/units/monsters-data.ts' import { planLevelMonsters } from '../src/common/units/monsters-data.ts'
import { parseTable } from '../src/common/world/acts.ts' import { parseTable } from '../src/common/world/acts.ts'
@ -20,38 +18,12 @@ function mockTable(tsv: string): D2Table {
return parseTable(encoder.encode(tsv.trim())) 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', () => { describe('no synthetic monster fallback', () => {
it('demo-data no longer exports a monster roster', () => { it('demo-data no longer exports a monster roster', () => {
expect(Object.keys(demoData)).not.toContain('DEMO_MONSTERS') 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') 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', () => { it('includes superunique boss and minion monster IDs in PlannedLevel.types', () => {
const levelsTxt = mockTable([ const levelsTxt = mockTable([
'Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3', 'Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3',

View File

@ -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])
})
})

View File

@ -5,21 +5,18 @@
import { describe, expect, it, beforeEach } from 'vitest' import { describe, expect, it, beforeEach } from 'vitest'
import { readFileSync, existsSync } from 'node:fs' import { readFileSync, existsSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { import { resolveSkillIcon, getAvailableSkillsForSide } from '../src/client/ui/hotkeys.ts'
DEFAULT_SORCERESS_SKILLS,
resolveSkillIcon,
getAvailableSkillsForSide,
} from '../src/client/ui/hotkeys.ts'
import { createDemoHotkeys } from './fixtures/hud-demo-state.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 { castSkill } from '../src/server/scene/skill-caster.ts'
import { GameEngine, type GameEngineOptions } from '../src/server/engine/engine.ts' import { GameEngine, type GameEngineOptions } from '../src/server/engine/engine.ts'
import type { Monster } from '../src/server/engine/combat.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('Issue #151: Normal Attack Icon Completion & Dual Slot Binding', () => {
describe('Skill 0 Definition & Dual-Palette Usability', () => { describe('Skill 0 Definition & Dual-Palette Usability', () => {
it('defines Skill 0 Attack with leftUsable: true and rightUsable: true', () => { 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).toBeDefined()
expect(attackSkill!.name).toBe('Attack') expect(attackSkill!.name).toBe('Attack')
expect(attackSkill!.nameZh).toBe('普通攻击') 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', () => { it('includes Skill 0 in both left and right available skill palettes', () => {
const leftSkills = getAvailableSkillsForSide('left', DEFAULT_SORCERESS_SKILLS) const leftSkills = getAvailableSkillsForSide('left', DEMO_SORCERESS_SKILLS)
const rightSkills = getAvailableSkillsForSide('right', DEFAULT_SORCERESS_SKILLS) const rightSkills = getAvailableSkillsForSide('right', DEMO_SORCERESS_SKILLS)
const leftAttack = leftSkills.find((s) => s.skillId === 0) const leftAttack = leftSkills.find((s) => s.skillId === 0)
const rightAttack = rightSkills.find((s) => s.skillId === 0) const rightAttack = rightSkills.find((s) => s.skillId === 0)

View File

@ -31,7 +31,7 @@ import { getSharedDataRegistry } from '../src/baker/mpq-archive-source.ts'
import { UnitStatList } from '../src/server/engine/stat-list.ts' import { UnitStatList } from '../src/server/engine/stat-list.ts'
import { StateBus } from '../src/server/engine/state-bus.ts' import { StateBus } from '../src/server/engine/state-bus.ts'
import type { CombatUnitContext } from '../src/server/engine/combat-pipeline.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', () => { describe('[Skill #059] Blizzard — Empirical Stress & Physics Challenger Suite', () => {
const openTerrain = { overlap: () => 0 } const openTerrain = { overlap: () => 0 }

View File

@ -26,7 +26,7 @@ import {
type OverlayTxtData, type OverlayTxtData,
castSkill, castSkill,
} from '../src/server/skills/skills.ts' } 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 { GameEngine } from '../src/server/engine/engine.ts'
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.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' import { SpriteRenderer, type AtlasHandle } from '../src/client/render/renderer.ts'

View File

@ -37,7 +37,7 @@ import {
computeEffectivePlayers, computeEffectivePlayers,
computeScaledNoDrop, computeScaledNoDrop,
} from '../src/common/items/treasure-engine.ts' } 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' import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts'
const dropTables = getEmbeddedDropTables() const dropTables = getEmbeddedDropTables()

View File

@ -4,12 +4,11 @@
*/ */
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { KeyboardInput } from '../src/client/sim/input.ts'
import { HudManager } from '../src/client/ui/hud-manager.ts' import { HudManager } from '../src/client/ui/hud-manager.ts'
import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/server/engine/engine.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 { 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 { class MockWindow extends EventTarget {
innerWidth = 1920 innerWidth = 1920
@ -78,220 +77,6 @@ globalThis.HTMLInputElement = class HTMLInputElement {}
// @ts-ignore // @ts-ignore
globalThis.HTMLSelectElement = class HTMLSelectElement {} 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('Challenger Stress Tests: Milestone 2 Left-Click & Shift Controls', () => {
describe('1. screenToWorld projection with varying camera zoom levels and DPRs', () => { 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]', () => { 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()
})
}) })
}) })

View File

@ -8,16 +8,7 @@
*/ */
import { describe, it, expect, vi } from 'vitest' 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 { 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 { import {
BeltHud, BeltHud,
BELT_COLS, 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 // 1. Issue #423: Flippy DC6 Coverage, Fail-Fast Throw & Frozen Death Anim
// ============================================================================ // ============================================================================
describe('Issue #423 — Exhaustive Flippy DC6 Coverage & Fail-Fast Rendering', () => { 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', () => { it('advances death animation (DT -> DD) even when monster has frozenTicks > 0', () => {
const animator = new ActorAnimator() 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 // 3. Issue #425: Pickup Bounds Fuzzing & 2-Pass Auto-Belt Stress
// ============================================================================ // ============================================================================

View File

@ -10,15 +10,12 @@ import { describe, expect, it } from 'vitest'
import { join } from 'node:path' import { join } from 'node:path'
import { existsSync } from 'node:fs' import { existsSync } from 'node:fs'
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
import { import { resolveItemSpriteRect } from '../src/client/ui/inventory.ts'
resolveItemSpriteRect,
STARTER_BAG_ITEMS,
STARTER_EQUIPPED_GEAR,
} from '../src/client/ui/inventory.ts'
import { MountedArchives } from '../src/common/mpq/mount.ts' import { MountedArchives } from '../src/common/mpq/mount.ts'
import { MpqArchive } from '../src/common/mpq/archive.ts' import { MpqArchive } from '../src/common/mpq/archive.ts'
import { fileSource } from '../src/baker/io/file-source.ts' import { fileSource } from '../src/baker/io/file-source.ts'
import { parseTable } from '../src/common/items/tables.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)', () => { describe('Challenger M4 Inventory & Item Atlas Audit (Issue #149)', () => {
const itemRects = BAKED_UI_MANIFEST.itemRects 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', () => { 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 if (!item) continue
const rect = resolveItemSpriteRect(item, itemRects) const rect = resolveItemSpriteRect(item, itemRects)
expect(rect, `Equipped item ${slot} (${item.name}) must resolve`).not.toBeNull() 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) 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) const rect = resolveItemSpriteRect(item, itemRects)
expect(rect, `Bag item at (${col}, ${row}) (${item.name}) must resolve`).not.toBeNull() expect(rect, `Bag item at (${col}, ${row}) (${item.name}) must resolve`).not.toBeNull()
expect(rect!.w).toBeGreaterThan(0) expect(rect!.w).toBeGreaterThan(0)

View File

@ -33,13 +33,6 @@ import {
type CombatOptions, type CombatOptions,
} from '../src/server/engine/combat.ts' } from '../src/server/engine/combat.ts'
import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.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 { import {
GroundItemManager, GroundItemManager,
getPickupEdgeDistance, 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)', () => { 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)', () => { 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 } const terrain = { widthPx: 10000, heightPx: 10000, overlap: () => 0 }

View File

@ -27,7 +27,7 @@ import {
BATCH1_SKILLS, BATCH1_SKILLS,
CANONICAL_113C_MISSILES, CANONICAL_113C_MISSILES,
} from '../src/server/skills/skills.ts' } 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)', () => { describe('Adversarial Stress Test: Milestone M4 (Skills 57, 58, 63)', () => {
// ========================================================================= // =========================================================================

View File

@ -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 { UnitStatList, FIXED_ONE } from '../src/server/engine/stat-list.ts'
import { StateBus } from '../src/server/engine/state-bus.ts' import { StateBus } from '../src/server/engine/state-bus.ts'
import { GameEngine, type WorldMapProvider, type GameEngineOptions } from '../src/server/engine/engine.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 { castSkill } from '../src/server/scene/skill-caster.ts'
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.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' import { OVERLAY_METAS } from '../src/client/render/overlays-meta.ts'

View File

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest' 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 { import {
WorldPanelsHud, WorldPanelsHud,
STASH_GOLD_CAP, STASH_GOLD_CAP,
@ -8,8 +8,6 @@ import {
import { HudManager } from '../src/client/ui/hud-manager.ts' import { HudManager } from '../src/client/ui/hud-manager.ts'
import { GameEngine } from '../src/server/engine/engine.ts' import { GameEngine } from '../src/server/engine/engine.ts'
import { createIsoTerrain } from '../src/common/world/iso-terrain.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' import { WaypointNetwork } from '../src/common/world/portal.ts'
class MockWindow extends EventTarget { 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', () => { describe('3. Gold Mechanics Stress Testing', () => {
let panels: WorldPanelsHud let panels: WorldPanelsHud
let inv: { gold: number } let inv: { gold: number }

View File

@ -8,15 +8,8 @@
import { describe, expect, it, beforeEach } from 'vitest' import { describe, expect, it, beforeEach } from 'vitest'
import { readFileSync, existsSync, statSync } from 'node:fs' import { readFileSync, existsSync, statSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { import { SkillHotkeysHud, resolveSkillIcon, getAvailableSkillsForSide, LEFT_SKILL_BOUNDS, RIGHT_SKILL_BOUNDS, type HotkeySkillEntry } from '../src/client/ui/hotkeys.ts'
DEFAULT_SORCERESS_SKILLS, import { DEMO_SORCERESS_SKILLS } from './fixtures/demo-profile-data.ts'
SkillHotkeysHud,
resolveSkillIcon,
getAvailableSkillsForSide,
LEFT_SKILL_BOUNDS,
RIGHT_SKILL_BOUNDS,
type HotkeySkillEntry,
} from '../src/client/ui/hotkeys.ts'
describe('Empirical Challenger Stress Suite: M6 Normal Attack & Dual Slot Bindings', () => { describe('Empirical Challenger Stress Suite: M6 Normal Attack & Dual Slot Bindings', () => {
const rootDir = process.cwd() const rootDir = process.cwd()
@ -131,12 +124,12 @@ describe('Empirical Challenger Stress Suite: M6 Normal Attack & Dual Slot Bindin
beforeEach(() => { beforeEach(() => {
hud = new SkillHotkeysHud() 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', () => { it('verifies getAvailableSkillsForSide includes Skill 0 (Attack) in both left and right palettes', () => {
const leftSkills = getAvailableSkillsForSide('left', DEFAULT_SORCERESS_SKILLS) const leftSkills = getAvailableSkillsForSide('left', DEMO_SORCERESS_SKILLS)
const rightSkills = getAvailableSkillsForSide('right', DEFAULT_SORCERESS_SKILLS) const rightSkills = getAvailableSkillsForSide('right', DEMO_SORCERESS_SKILLS)
const leftAttack = leftSkills.find((s) => s.skillId === 0) const leftAttack = leftSkills.find((s) => s.skillId === 0)
const rightAttack = rightSkills.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(() => { beforeEach(() => {
hud = new SkillHotkeysHud() 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', () => { 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', () => { 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).toBeDefined()
expect(attackDef!.leftUsable).toBe(true) expect(attackDef!.leftUsable).toBe(true)
expect(attackDef!.rightUsable).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') hud.handleKeyDown('F1')
} }
// Check DEFAULT_SORCERESS_SKILLS // Check DEMO_SORCERESS_SKILLS
const attackDefAfter = DEFAULT_SORCERESS_SKILLS.find((s) => s.skillId === 0) const attackDefAfter = DEMO_SORCERESS_SKILLS.find((s) => s.skillId === 0)
expect(attackDefAfter!.leftUsable).toBe(true) expect(attackDefAfter!.leftUsable).toBe(true)
expect(attackDefAfter!.rightUsable).toBe(true) expect(attackDefAfter!.rightUsable).toBe(true)

View File

@ -19,14 +19,13 @@
*/ */
import { describe, expect, it, beforeEach } from 'vitest' 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 { castSkill } from '../src/server/scene/skill-caster.ts'
import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/server/engine/engine.ts' import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/server/engine/engine.ts'
import type { Projectile } from '../src/server/skills/skills.ts' import type { Projectile } from '../src/server/skills/skills.ts'
import { damageMonster, type Monster } from '../src/server/engine/combat.ts' import { damageMonster, type Monster } from '../src/server/engine/combat.ts'
import { HudManager } from '../src/client/ui/hud-manager.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' import { WaypointNetwork } from '../src/common/world/portal.ts'
// DOM mocks for headless testing environment // DOM mocks for headless testing environment
@ -101,182 +100,6 @@ if (typeof globalThis.HTMLSelectElement === 'undefined') {
globalThis.HTMLSelectElement = class HTMLSelectElement {} 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', () => { describe('Challenger M6 Empirical Stress Test Suite: Attack Execution Mechanics', () => {
// ========================================================================= // =========================================================================
// 1. Unarmed vs Equipped Melee Weapon Combat Timing & Reach // 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) 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)
})
})
}) })

View File

@ -1,12 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { import { CLASS_EXCLUSIVE_SETS, STARTER_STASH_ITEMS, CLASS_STARTER_ATTRS, CLASS_STARTER_HARD_POINTS } from '../src/common/units/class-starter-profiles.ts'
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 { HudManager } from '../src/client/ui/hud-manager.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 { 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' 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.getSkillTree().length).toBe(30)
expect(panel.getVerticalTabs()[0].titleZh).toBe('防御') expect(panel.getVerticalTabs()[0].titleZh).toBe('防御')
// Load specific profile state // Load the Barbarian starter skill allocation
const profile = createStarterProfileForClass('bar') const barHardPoints = new Map(
panel.loadProfile(profile.classCode, profile.hardPoints, profile.unspentSkillPoints, profile.activeSkillTab) 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.characterClass).toBe('bar')
expect(panel.getHardPoints(151)).toBe(20) // Whirlwind 20 expect(panel.getHardPoints(151)).toBe(20) // Whirlwind 20
expect(panel.unspentSkillPoints).toBe(12) 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)
})
})
}) })

View File

@ -65,10 +65,6 @@ import {
applyLightingInputs, applyLightingInputs,
LIGHTING_PRESETS, LIGHTING_PRESETS,
} from '../../src/client/scene/lighting-presets.ts' } 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 { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts'
import { import {
computeDockingLayout, computeDockingLayout,
@ -370,133 +366,6 @@ describe('Dual Viewport Profiles, HudManager Anchoring & Coordinate Transforms (
}) })
describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F17)', () => { 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', () => { it('normalizes D2GS world sub-tiles by LevelView.subOriginX/Y and submits tile + unit quads to SpriteRenderer', () => {
const automap = new AutomapView({ initialMode: 'level' }) const automap = new AutomapView({ initialMode: 'level' })

View File

@ -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)
})
})

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' 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 type { CombatTerrain, CombatWorld, MonsterPack, MonsterStats } from '../src/server/engine/combat.ts'
import { MonsterStreamingManager } from '../src/common/units/monster-streaming.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', () => { describe('Town safe-zone invariant: strictly 0 density in town levels', () => {
it('identifies all 5 canonical town levels across Acts 1..5', () => { it('identifies all 5 canonical town levels across Acts 1..5', () => {
expect(TOWN_LEVEL_IDS.size).toBe(5) expect(TOWN_LEVEL_IDS.size).toBe(5)

View File

@ -1,7 +1,6 @@
import { describe, it, expect, beforeAll } from 'vitest' import { describe, it, expect, beforeAll } from 'vitest'
import { readFileSync, existsSync, readdirSync } from 'node:fs' import { readFileSync, existsSync, readdirSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { computeObjectPhaseOffsetMs, updateAnimatedObject, updateAnimatedObjects, PERSISTENT_ILLUMINATION_TOKENS, type ObjectDrawable, type AnimatedObjectFrame } from '../src/client/world/act-runtime.ts'
import { import {
updateAnimatableTiles, updateAnimatableTiles,
type AnimatableTile, type AnimatableTile,
@ -9,152 +8,6 @@ import {
} from '../src/common/world/animated-tiles.ts' } from '../src/common/world/animated-tiles.ts'
describe('Dungeon & Wilderness Illumination & Animated Tile Sequences (Issue #398)', () => { 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', () => { describe('DT1 animated tile serialization and unpacking parity', () => {
it('correctly unpacks extended animated tile rows [frameIndex, x, y, cx, cy, ...animFrames]', () => { 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)', () => { it('verifies Catacombs (Level 34) packs have animated candles (A1, A2) and fires (BF, BR)', () => {
const act1Dir = join(packsDir, 'act1') const act1Dir = join(packsDir, 'act1')
expect(existsSync(act1Dir)).toBe(true) expect(existsSync(act1Dir)).toBe(true)
@ -301,27 +130,6 @@ describe('Dungeon & Wilderness Illumination & Animated Tile Sequences (Issue #39
expect(totalCandlesOrFires).toBeGreaterThan(0) 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', () => { it('verifies DT1 animated tile sequences in scenes that contain animated tiles', () => {
const act4Dir = join(packsDir, 'act4') const act4Dir = join(packsDir, 'act4')
expect(existsSync(act4Dir)).toBe(true) expect(existsSync(act4Dir)).toBe(true)

View File

@ -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 { 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 { BAKED_UI_MANIFEST } from '../../src/common/items/baked-ui-meta.ts'
import { BeltHud } from '../../src/client/ui/belt.ts' import { BeltHud } from '../../src/client/ui/belt.ts'
import { seedDemoBeltPotions } from '../fixtures/demo-profile-data.ts'
let oracle: DropOracleStack let oracle: DropOracleStack
const dropTables = getEmbeddedDropTables() const dropTables = getEmbeddedDropTables()
@ -611,7 +612,7 @@ describe('Tier 2 — Boundary, Corner & Negative Cases (All 17 Features, Issues
const belt = new BeltHud() const belt = new BeltHud()
// The belt starts empty; seed the legacy full 16-potion layout explicitly // The belt starts empty; seed the legacy full 16-potion layout explicitly
expect(belt.countTotalPotions()).toBe(0) expect(belt.countTotalPotions()).toBe(0)
belt.seedDefaultPotions() seedDemoBeltPotions(belt)
expect(belt.countTotalPotions()).toBe(16) expect(belt.countTotalPotions()).toBe(16)
const extraPotion = { id: 'extra', name: 'Health Potion', col: 0 } as any const extraPotion = { id: 'extra', name: 'Health Potion', col: 0 } as any
// Trying to place in any column returns the unplaced potion // 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', () => { it('B14.3 picked potion targets column with matching potion type first', () => {
const belt = new BeltHud() 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) belt.useSlot(0) // empty top row in col 0 (Health Potion col)
expect(belt.grid[3][0]).toBeNull() expect(belt.grid[3][0]).toBeNull()
const newHealth = { id: 'new_hp', name: 'Health Potion' } as any const newHealth = { id: 'new_hp', name: 'Health Potion' } as any

View File

@ -379,7 +379,7 @@ describe('Tier 1 — Feature Coverage: Module Split & Single Entry Consolidation
for (const tool of requiredTools) { for (const tool of requiredTools) {
expect(existsInRepo(tool), `Expected CLI tool ${tool} to exist`).toBe(true) 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 () => { 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/frontend',
'src/client/scene', 'src/client/scene',
'src/client/audio', 'src/client/audio',
'src/client/sim',
'src/client/i18n', 'src/client/i18n',
'src/client/map', 'src/client/map',
'src/client/session', 'src/client/session',

View File

@ -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', () => { it('F7.B5: src/client/main.ts contains zero imports from src/server, src/baker, or legacy src/* directories', () => {
const source = readRepoFile('src/client/launcher/play-entry.ts') const source = readRepoFile('src/client/main.ts')
const specs = extractImportSpecifiers(source) 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) { for (const spec of specs) {
const target = resolveImportTargetRel(entryFileAbs, spec) const target = resolveImportTargetRel(entryFileAbs, spec)
expect(target.startsWith('src/server')).toBe(false) expect(target.startsWith('src/server')).toBe(false)
expect(target.startsWith('src/baker')).toBe(false) expect(target.startsWith('src/baker')).toBe(false)
for (const legacyDir of LEGACY_SRC_DIRS) { for (const legacyDir of LEGACY_SRC_DIRS) {
expect(target.startsWith(`src/${legacyDir}`)).toBe(false) expect(target.startsWith(`src/${legacyDir}/`)).toBe(false)
} }
} }
}) })

View File

@ -4,8 +4,8 @@
* Covers 16 pairwise & multi-feature integration tests across F1–F10: * Covers 16 pairwise & multi-feature integration tests across F1–F10:
* - F2 + F5: `common/data` (`D2DataRegistry`) + `server/engine` (`buildGameEngine` / `createPlayerState`) * - F2 + F5: `common/data` (`D2DataRegistry`) + `server/engine` (`buildGameEngine` / `createPlayerState`)
* - F3 + F5: `common/items` (`gems` + `runeword`) + `server/engine` item modifier application * - 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/items/item-bridge` + `client/ui/inventory` UI descriptor contract
* - F3 + F6: `common/units/monsters-data` + `common/units/monster-mapping` + `client/world/act-runtime` * - 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 * - 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 + F5 + F6: `netproto` + `server/loopback-adapter` + `client/session/online-session` + `client/world/client-world`
* - F4 + F6: `netproto` S2C packet stream + `OnlineSession` + `ClientWorld` * - 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/items/inventory-model` + `belt-model` + `server/engine`
* - F3 + F5: `common/world/d2map` collision & spawn grounding + `server/world/server-world` * - F3 + F5: `common/world/d2map` collision & spawn grounding + `server/world/server-world`
* - F2 + F8: `common/data/data-registry` `ArchiveSource` contract + `baker/mpq-archive-source` * - F2 + F8: `common/data/data-registry` `ArchiveSource` contract + `baker/mpq-archive-source`
* - F6 + F7: `play.html` + `vite.config.ts` + `client/launcher/play-entry.ts` * - F6 + F7: `play.html` + `vite.config.ts` + `client/main.ts`
* - F7 + F9: `tsconfig.client.json` + `src/client/launcher/play-entry.ts` full transitive import graph * - 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 * - 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 * - 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` * - 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') 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 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() expect(invPanelMod).toBeDefined()
const domainSword = { 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 () => { 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 monDataMod = await importRepoModule('src/common/units/monsters-data.ts')
const monMapMod = await importRepoModule('src/common/units/monster-mapping.ts') const monMapMod = await importRepoModule('src/common/units/monster-mapping.ts')
const actRuntimeMod = await importRepoModule( const entityArtMod = await importRepoModule('src/client/scene/entity-art-loader.ts')
'src/client/world/act-runtime.ts', expect(entityArtMod).toBeDefined()
'src/client/map/act-runtime.ts',
)
expect(actRuntimeMod).toBeDefined()
const corpsefire = monDataMod.CANONICAL_SUPERUNIQUES_TABLE.find( const corpsefire = monDataMod.CANONICAL_SUPERUNIQUES_TABLE.find(
(s: any) => s.superunique === 'Corpsefire', (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 langMod = await importRepoModule('src/common/i18n/lang.ts')
const monDataMod = await importRepoModule('src/common/units/monsters-data.ts') const monDataMod = await importRepoModule('src/common/units/monsters-data.ts')
const clientI18n = await importRepoModule( const clientI18n = await importRepoModule(
'src/client/i18n/labels.ts', 'src/client/i18n/lang.ts',
'src/client/i18n/index.ts',
'src/client/i18n/monster-names.ts',
) )
expect(clientI18n).toBeDefined() expect(clientI18n).toBeDefined()
@ -401,15 +396,15 @@ describe('Tier 3: Pairwise Cross-Feature Interactions (F1–F10)', () => {
expect(bakerSource).toContain('common') 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 playHtml = readRepoFile('play.html')
const viteCfg = readRepoFile('vite.config.ts') const viteCfg = readRepoFile('vite.config.ts')
expect(playHtml).toContain('/src/client/main.ts') expect(playHtml).toContain('/src/client/main.ts')
expect(viteCfg).toContain('play.html') 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', () => { it('CF13 (F7 + F9): transitive import closure of src/client/main.ts stays strictly within common, netproto, and client', () => {
const reachable = collectTransitiveTsImports('src/client/launcher/play-entry.ts') const reachable = collectTransitiveTsImports('src/client/main.ts')
expect(reachable.size).toBeGreaterThan(5) expect(reachable.size).toBeGreaterThan(5)
const violations: string[] = [] const violations: string[] = []
for (const rel of reachable) { for (const rel of reachable) {

View File

@ -3,7 +3,6 @@
* (`tests/e2e-module-split/tier5-adversarial-hardening.test.ts`). * (`tests/e2e-module-split/tier5-adversarial-hardening.test.ts`).
* *
* Covers white-box regression guards and stress probes from Iteration 1: * 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_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_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 * - adv_F1_01..06: `D2DataRegistry` & `getSharedDataRegistry` fail-fast & cache-poisoning guards
@ -15,10 +14,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { runBakerCli, runBakerVerify } from '../../src/baker/cli.ts' 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 { OnlineSession } from '../../src/client/session/online-session.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts' import { ClientWorld } from '../../src/client/world/client-world.ts'
import { import {
@ -53,19 +48,7 @@ import {
} from './helpers.ts' } from './helpers.ts'
describe('Tier 5 — Adversarial Hardening & Challenger Regression Suite', () => { describe('Tier 5 — Adversarial Hardening & Challenger Regression Suite', () => {
describe('1. White-Box Boundary & Architecture Regression Guards (adv_BUG_01..03)', () => { describe('1. White-Box Boundary & Architecture Regression Guards (adv_BUG_02..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,
)
})
it('adv_BUG_02: zero .test.ts or .spec.ts files exist under src/** and tests/frontend/flow-state.test.ts is present', () => { 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( const srcTestFiles = collectTsFiles('src').filter(

View File

@ -170,7 +170,6 @@ import {
applyLightingInputs, applyLightingInputs,
LIGHTING_PRESETS, LIGHTING_PRESETS,
} from '../../src/client/scene/lighting-presets.ts' } 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 { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts'
import { computeDockingLayout, computeHudLayout } from '../../src/client/ui/hud-manager.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') 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', () => { it('F17.5: renderSceneSourceFrame and applyLightingInputs configure torch vs fullbright lighting', () => {
const automap = new AutomapView({ initialMode: 'level' }) const automap = new AutomapView({ initialMode: 'level' })
const levelView = automap.setContext(0x12345678, 0, 1) const levelView = automap.setContext(0x12345678, 0, 1)

View File

@ -113,7 +113,6 @@ import {
} from '../../src/client/view/scene-source.ts' } from '../../src/client/view/scene-source.ts'
import { type RenderableUnitInput } from '../../src/client/view/unit-drawables.ts' import { type RenderableUnitInput } from '../../src/client/view/unit-drawables.ts'
import { applyLightingInputs } from '../../src/client/scene/lighting-presets.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 { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts'
import { computeDockingLayout } from '../../src/client/ui/hud-manager.ts' import { computeDockingLayout } from '../../src/client/ui/hud-manager.ts'
import { AutomapView } from '../../src/client/automap/automap-view.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() 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', () => { 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 store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
const toolbar = new Toolbar({ store }) const toolbar = new Toolbar({ store })

View File

@ -94,7 +94,6 @@ import {
import { type RenderableUnitInput } from '../../src/client/view/unit-drawables.ts' import { type RenderableUnitInput } from '../../src/client/view/unit-drawables.ts'
import { LightGrid } from '../../src/common/drlg/light-grid.ts' import { LightGrid } from '../../src/common/drlg/light-grid.ts'
import { applyLightingInputs } from '../../src/client/scene/lighting-presets.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 { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts'
import { computeDockingLayout, computeHudLayout } from '../../src/client/ui/hud-manager.ts' import { computeDockingLayout, computeHudLayout } from '../../src/client/ui/hud-manager.ts'
import { AutomapView } from '../../src/client/automap/automap-view.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 () => { it('S10: End-to-End Audio & Asset streaming workflow (SettingsStore <-> SoundService spatial SFX/BGM + OPFS HTTP Range 206 cache)', async () => {
const playedBgm: number[] = [] const playedBgm: number[] = []
const playedSfx: string[] = [] const playedSfx: string[] = []

View File

@ -31,7 +31,6 @@ import { AuraScanner } from '../../src/server/engine/aura-scanner.ts'
import { SummonManager } from '../../src/server/ai/summon-manager.ts' import { SummonManager } from '../../src/server/ai/summon-manager.ts'
import { WorldArena } from '../../src/server/engine/world-arena.ts' import { WorldArena } from '../../src/server/engine/world-arena.ts'
import { evaluateSkill113c } from '../../src/server/skills/registry.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' import { createAndCastSkillInArena, evaluateSkillCurve113c } from '../helpers/skill-test-harness.ts'
describe('R1 Universal 1.13c Skill Engine & 7-Class Sandbox Foundation', () => { 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) 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)
}) })

View File

@ -22,7 +22,8 @@ import {
tickProjectiles, tickProjectiles,
type Projectile, type Projectile,
} from '../src/server/skills/skills.ts' } 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 { GameEngine } from '../src/server/engine/engine.ts'
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.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' import { SpriteRenderer, type AtlasHandle } from '../src/client/render/renderer.ts'

525
tests/fixtures/demo-profile-data.ts vendored Normal file
View File

@ -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
}

View File

@ -9,16 +9,12 @@
import { CharacterSheetPanel } from '../../src/client/ui/character-sheet.ts' import { CharacterSheetPanel } from '../../src/client/ui/character-sheet.ts'
import type { HudManager } from '../../src/client/ui/hud-manager.ts' import type { HudManager } from '../../src/client/ui/hud-manager.ts'
import { DEFAULT_SORCERESS_SKILLS, SkillHotkeysHud } from '../../src/client/ui/hotkeys.ts' import { SkillHotkeysHud } from '../../src/client/ui/hotkeys.ts'
import { import { InventoryPanel, type EquipSlotId } from '../../src/client/ui/inventory.ts'
InventoryPanel,
STARTER_BAG_ITEMS,
STARTER_EQUIPPED_GEAR,
type EquipSlotId,
} from '../../src/client/ui/inventory.ts'
import { SkillTreePanel } from '../../src/client/ui/skill-tree-panel.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 { ACT_WAYPOINTS, ALL_QUESTS, WorldPanelsHud } from '../../src/client/ui/world-panels.ts'
import type { BeltModel } from '../../src/common/items/belt-model.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). */ /** Former demo Sorceress profile (level 85). */
export const DEMO_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. */ /** Seed Sorceress quickbar: all default skills, Fire Ball (47) left, Frozen Orb (64) right, F1..F8 bindings. */
export function seedDemoHotkeys(hotkeys: SkillHotkeysHud): SkillHotkeysHud { export function seedDemoHotkeys(hotkeys: SkillHotkeysHud): SkillHotkeysHud {
hotkeys.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS]) hotkeys.setAvailableSkills([...DEMO_SORCERESS_SKILLS])
hotkeys.leftSkillId = 47 hotkeys.leftSkillId = 47
hotkeys.rightSkillId = 64 hotkeys.rightSkillId = 64
hotkeys.bindings.clear() 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. */ /** Seed starter gear + bag items (deep-cloned), 850k gold and the level-85 Sorceress requirement context. */
export function seedDemoInventory(inv: InventoryPanel): InventoryPanel { export function seedDemoInventory(inv: InventoryPanel): InventoryPanel {
for (const k of Object.keys(inv.equipped) as EquipSlotId[]) delete inv.equipped[k] 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.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.gold = 850_000
inv.setPlayerContext({ level: DEMO_LEVEL, str: DEMO_STR, dex: DEMO_DEX, classCode: 'sor' }) inv.setPlayerContext({ level: DEMO_LEVEL, str: DEMO_STR, dex: DEMO_DEX, classCode: 'sor' })
return inv return inv
@ -129,7 +125,7 @@ export function seedDemoWorldPanels(wp: WorldPanelsHud): WorldPanelsHud {
/** Seed the 4x4 belt with the legacy default potions. */ /** Seed the 4x4 belt with the legacy default potions. */
export function seedDemoBelt<T extends BeltModel>(belt: T): T { export function seedDemoBelt<T extends BeltModel>(belt: T): T {
belt.seedDefaultPotions() seedDemoBeltPotions(belt)
return belt return belt
} }

View File

@ -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(/monsterKinds\s*:\s*new\s+Map\s*\(\s*\)/)
expect(embeddedSrc).not.toMatch(/superUniques\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')
})
}) })
}) })

View File

@ -1,9 +1,9 @@
import { describe, it, expect, beforeEach } from 'vitest' 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 { 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 { 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', () => { describe('Issue Fix: Glacial Spike (#55) and All Active Skills on Quickbar Speedbars', () => {
let hud: SkillHotkeysHud 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', () => { 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', () => { it('has Glacial Spike (55) in DEMO_SORCERESS_SKILLS with level 10 and authentic mana', () => {
const entry = DEFAULT_SORCERESS_SKILLS.find(s => s.skillId === 55) const entry = DEMO_SORCERESS_SKILLS.find(s => s.skillId === 55)
expect(entry).toBeDefined() expect(entry).toBeDefined()
expect(entry!.name).toBe('Glacial Spike') expect(entry!.name).toBe('Glacial Spike')
expect(entry!.nameZh).toBe('冰尖柱') 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', () => { describe('3. HudManager Skill Tree Allocation dynamically updates Available Skills', () => {
const mockCanvas = { const mockCanvas = {
width: 800, width: 800,
@ -118,15 +84,13 @@ describe('Issue Fix: Glacial Spike (#55) and All Active Skills on Quickbar Speed
onToggleAutomap: () => {}, onToggleAutomap: () => {},
onWaypointTeleport: () => {}, 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 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', () => { it('requests skill allocation through the command sink without local mutation', () => {
const hudManager = createMockHud() const hudManager = createMockHud()
// Open Skill tree panel // Open Skill tree panel

View File

@ -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 { DEMO_EXPERIENCE, DEMO_SKILLS } from '../src/common/data/demo-data.ts'
import { itemToUiInventoryItem } from '../src/common/items/item-bridge.ts' import { itemToUiInventoryItem } from '../src/common/items/item-bridge.ts'
import { resolveGroundItemSpriteRect } from '../src/client/ui/inventory.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 { 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' 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)', () => { 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', () => { it('scatters 8 sequentially dropped potions/items from player inventory onto distinct sub-tile slots with zero stacking', () => {
const engine = createEngine() const engine = createEngine()

View File

@ -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)
})
})
})

View File

@ -1,7 +1,6 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { resolveGroundItemSpriteRect } from '../src/client/ui/inventory.ts' import { resolveGroundItemSpriteRect } from '../src/client/ui/inventory.ts'
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.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 { GroundItemEntity } from '../src/common/items/ground-items.ts'
import type { SpriteRenderer } from '../src/client/render/renderer.ts' import type { SpriteRenderer } from '../src/client/render/renderer.ts'
import { GameEngine } from '../src/server/engine/engine.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)', () => { describe('3. Clean Pickup Lifecycle (Zero Remaining Dots / Artifacts)', () => {
it('completely cleans groundItems on engine pickup tick without leaving orphaned entities', () => { it('completely cleans groundItems on engine pickup tick without leaving orphaned entities', () => {
const engine = new GameEngine( const engine = new GameEngine(
@ -272,45 +170,6 @@ describe('Authentic Diablo II v1.13c Ground Items Flippy Parity', () => {
expect(sr!.h).toBeGreaterThan(0) 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)
})
}) })
}) })

View File

@ -24,7 +24,6 @@ import {
GroundItemManager, GroundItemManager,
type GroundItemEntity, type GroundItemEntity,
} from '../src/common/items/ground-items.ts' } 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' import type { SpriteRenderer } from '../src/client/render/renderer.ts'
function createSmallEngine() { function createSmallEngine() {
@ -156,162 +155,4 @@ describe('Issue #392 — Inventory Full Flippy Bounce & Feedback Ground Truth',
expect(item.bounceState).toBeNull() 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)
})
})
}) })

View File

@ -5,8 +5,8 @@
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { GameEngine } from '../src/server/engine/engine.ts' import { GameEngine } from '../src/server/engine/engine.ts'
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.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 type { GroundItemEntity } from '../src/common/items/ground-items.ts'
import { seedDemoBeltPotions } from './fixtures/demo-profile-data.ts'
function createTestEngine() { function createTestEngine() {
const terrain = { widthPx: 1000, heightPx: 1000, overlap: () => 0 } const terrain = { widthPx: 1000, heightPx: 1000, overlap: () => 0 }
@ -32,7 +32,7 @@ function createTestEngine() {
npcDefs: [], npcDefs: [],
}) })
// Explicit legacy full belt (16 potions) so picked-up potions route to the bag, not the belt. // 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 return engine
} }
@ -110,149 +110,4 @@ describe('Ground Items Pickup & Pathfinding (Issue #391)', () => {
expect(engine.metrics.inventoryRefusals).toBe(1) 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)
})
})
}) })

View File

@ -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)
})
})
})

View File

@ -30,7 +30,6 @@ import {
subTileAt, subTileAt,
} from '../src/common/world/d2map.ts' } from '../src/common/world/d2map.ts'
import type { CollisionGrid } 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 { resolveGroundItemSpriteRect } from '../src/client/ui/inventory.ts'
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
import type { SpriteRenderer } from '../src/client/render/renderer.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', () => { 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+)', () => { it('resolves gold pile sprite tier based on amount (1-49, 50-499, 500+)', () => {
const flippyRects = BAKED_UI_MANIFEST.flippyRects const flippyRects = BAKED_UI_MANIFEST.flippyRects

View File

@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { existsSync, readFileSync } from 'node:fs' import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { HERO_SPECS } from '../src/baker/packers/pack-characters.ts' 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', () => { describe('7 Playable Character Classes Artwork & Selection Parity', () => {
const packRoot = join(process.cwd(), 'samples', 'd2-packs') const packRoot = join(process.cwd(), 'samples', 'd2-packs')

View File

@ -9,19 +9,9 @@
*/ */
import { describe, expect, it, beforeEach, vi } from 'vitest' import { describe, expect, it, beforeEach, vi } from 'vitest'
import { 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'
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 { D2FontRenderer } from '../src/client/ui/font.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)', () => { describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)', () => {
let hud: SkillHotkeysHud let hud: SkillHotkeysHud

View File

@ -3,15 +3,9 @@
*/ */
import { describe, expect, it, beforeEach } from 'vitest' import { describe, expect, it, beforeEach } from 'vitest'
import { import { SkillHotkeysHud, LEFT_SKILL_BOUNDS, RIGHT_SKILL_BOUNDS, resolveSkillIcon, getAvailableSkillsForSide } from '../src/client/ui/hotkeys.ts'
SkillHotkeysHud,
LEFT_SKILL_BOUNDS,
RIGHT_SKILL_BOUNDS,
DEFAULT_SORCERESS_SKILLS,
resolveSkillIcon,
getAvailableSkillsForSide,
} from '../src/client/ui/hotkeys.ts'
import { createDemoHotkeys } from './fixtures/hud-demo-state.ts' import { createDemoHotkeys } from './fixtures/hud-demo-state.ts'
import { DEMO_SORCERESS_SKILLS } from './fixtures/demo-profile-data.ts'
describe('SkillHotkeysHud & Dual Slot Mechanics', () => { describe('SkillHotkeysHud & Dual Slot Mechanics', () => {
let hud: SkillHotkeysHud let hud: SkillHotkeysHud

View File

@ -37,7 +37,7 @@ import {
FIREBOLT_META, FIREBOLT_META,
MISSILE_METAS, MISSILE_METAS,
} from '../src/client/render/missiles-meta.ts' } 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 { castSkill } from '../src/server/scene/skill-caster.ts'
import { GameEngine } from '../src/server/engine/engine.ts' import { GameEngine } from '../src/server/engine/engine.ts'

File diff suppressed because it is too large Load Diff

View File

@ -8,19 +8,13 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { readFileSync, existsSync } from 'node:fs' import { readFileSync, existsSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { import { resolveItemSpriteRect, InventoryPanel, EQUIP_SLOTS_LAYOUT, INV_GRID_ORIGIN } from '../src/client/ui/inventory.ts'
resolveItemSpriteRect,
STARTER_BAG_ITEMS,
STARTER_EQUIPPED_GEAR,
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 { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
import { MountedArchives } from '../src/common/mpq/mount.ts' import { MountedArchives } from '../src/common/mpq/mount.ts'
import { MpqArchive } from '../src/common/mpq/archive.ts' import { MpqArchive } from '../src/common/mpq/archive.ts'
import { fileSource } from '../src/baker/io/file-source.ts' import { fileSource } from '../src/baker/io/file-source.ts'
import { parseTable } from '../src/common/items/tables.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', () => { describe('Milestone M4 (Issue #149): Base item invfile audit & charm icons', () => {
const itemRects = BAKED_UI_MANIFEST.itemRects 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', () => { 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 if (!item) continue
const rect = resolveItemSpriteRect(item, itemRects) const rect = resolveItemSpriteRect(item, itemRects)
expect(rect, `Starter equipped ${slot} (${item.name}) must resolve`).not.toBeNull() 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) 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) const rect = resolveItemSpriteRect(item, itemRects)
expect(rect, `Starter bag item at (${col}, ${row}) (${item.name}) must resolve`).not.toBeNull() expect(rect, `Starter bag item at (${col}, ${row}) (${item.name}) must resolve`).not.toBeNull()
expect(rect!.w).toBeGreaterThan(0) expect(rect!.w).toBeGreaterThan(0)

View File

@ -28,12 +28,12 @@ import {
import { createDroppedItem } from '../src/common/items/drop-tables.ts' import { createDroppedItem } from '../src/common/items/drop-tables.ts'
import { ItemQuality, type ItemBase } from '../src/common/items/items.ts' import { ItemQuality, type ItemBase } from '../src/common/items/items.ts'
import type { GroundItemEntity } from '../src/common/items/ground-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 { import {
CLASS_EXCLUSIVE_SETS, CLASS_EXCLUSIVE_SETS,
STARTER_STASH_ITEMS, STARTER_STASH_ITEMS,
} from '../src/common/units/class-starter-profiles.ts' } 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', () => { describe('Issue #480: Canonical 1.13c .tbl & ItemStatCost.txt Parity across Inventory, Stash, and Ground', () => {
beforeEach(() => { beforeEach(() => {
@ -140,57 +140,6 @@ describe('Issue #480: Canonical 1.13c .tbl & ItemStatCost.txt Parity across Inve
level: 1, 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', () => { 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 capDrop = createDroppedItem(capBase, 'rare', { ilvl: 20, dwInitSeed: 480 })
const rareItem = { 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', () => { describe('4. Inventory & Equipped Gear (背包与装备栏): 1-Stat-Per-Line .tbl Compliance', () => {
it('ensures STARTER_EQUIPPED_GEAR has no non-.tbl "/" merged multi-stat lines', () => { it('ensures DEMO_EQUIPPED_GEAR has no non-.tbl "/" merged multi-stat lines', () => {
for (const [slot, item] of Object.entries(STARTER_EQUIPPED_GEAR)) { for (const [slot, item] of Object.entries(DEMO_EQUIPPED_GEAR)) {
if (!item) continue if (!item) continue
for (const stat of item.stats) { for (const stat of item.stats) {
// "高速跑步/行走" is the single stat FRW name in Chinese, not a multi-stat merge // "高速跑步/行走" is the single stat FRW name in Chinese, not a multi-stat merge

View File

@ -37,7 +37,7 @@ import {
monsterDisplayName, monsterDisplayName,
} from '../src/common/units/monsters-data.ts' } from '../src/common/units/monsters-data.ts'
import { TOWN_NPC_DESCRIPTORS, getLocalizedNpcName } from '../src/common/units/npc-table.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 { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts'
import { CharacterSheetPanel } from '../src/client/ui/character-sheet.ts' import { CharacterSheetPanel } from '../src/client/ui/character-sheet.ts'
import { InventoryPanel, itemToUiInventoryItem } from '../src/client/ui/inventory.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 { ControlBarHud } from '../src/client/ui/control-bar.ts'
import { GlobesHud } from '../src/client/ui/globes.ts' import { GlobesHud } from '../src/client/ui/globes.ts'
import { SkillHotkeysHud } from '../src/client/ui/hotkeys.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' import type { D2FontRenderer } from '../src/client/ui/font.ts'
const CJK_REGEX = /[\u4e00-\u9fff]/ 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 const classCodes = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'] as const
expect(classCodes.length).toBe(7) expect(classCodes.length).toBe(7)
for (const code of classCodes) { for (const code of classCodes) {
const profile = createStarterProfileForClass(code)
const allItems = [ const allItems = [
...Object.values(profile.equipped).filter(Boolean), ...Object.values(CLASS_EXCLUSIVE_SETS[code]).filter(Boolean),
...profile.bagItems.map(b => b.item), ...createStarterBagForClass(code).map(b => b.item),
] ]
for (const item of allItems) { for (const item of allItems) {
if (!item) continue 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)) const cjkLines = textCalls.filter(line => CJK_REGEX.test(line))
expect(cjkLines, `Starter item ${item.id} (${code}) leaked CJK in ENG tooltip: ${cjkLines.join(' | ')}`).toEqual([]) 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)
} }
} }
}) })

View File

@ -18,11 +18,6 @@ import {
} from '../src/client/ui/font.ts' } from '../src/client/ui/font.ts'
import { CharacterSheetPanel } from '../src/client/ui/character-sheet.ts' import { CharacterSheetPanel } from '../src/client/ui/character-sheet.ts'
import { WorldPanelsHud } from '../src/client/ui/world-panels.ts' import { WorldPanelsHud } from '../src/client/ui/world-panels.ts'
import {
GroundLabelOverlay,
computeInitialGroundLabelLayouts,
resolveLadderCollisions,
} from '../src/client/ui/ground-labels.ts'
import { import {
computeHudLayout, computeHudLayout,
computeDockingLayout, computeDockingLayout,
@ -227,7 +222,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
expect(view.getUint32(0, false)).toBe(0x00010000) 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'] const htmlFiles = ['play.html']
for (const file of htmlFiles) { for (const file of htmlFiles) {
const content = readFileSync(join(process.cwd(), file), 'utf8') 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('https://')
expect(content).not.toContain('http://') 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', () => { 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'), 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)
})
}) })

View File

@ -17,7 +17,7 @@ import {
import { WorldPanelsHud } from '../src/client/ui/world-panels.ts' import { WorldPanelsHud } from '../src/client/ui/world-panels.ts'
import { D2FontRenderer } from '../src/client/ui/font.ts' import { D2FontRenderer } from '../src/client/ui/font.ts'
import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.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' import type { CharacterClassCode } from '../src/common/units/classes.ts'
function createMockCtx() { 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'] const classes: CharacterClassCode[] = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass']
for (const classCode of classes) { for (const classCode of classes) {
const attrs = CLASS_STARTER_ATTRS[classCode] const attrs = CLASS_STARTER_ATTRS[classCode]
const profile = createStarterProfileForClass(classCode)
const ctx = { const ctx = {
level: attrs.level, level: attrs.level,
str: attrs.str, str: attrs.str,
dex: attrs.dex, dex: attrs.dex,
classCode, classCode,
} }
for (const [slotId, item] of Object.entries(profile.equipped)) { for (const [slotId, item] of Object.entries(CLASS_EXCLUSIVE_SETS[classCode])) {
if (!item) continue if (!item) continue
expect( expect(
canPlayerUseUiItem(item, ctx), canPlayerUseUiItem(item, ctx),

View File

@ -28,7 +28,6 @@ import {
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
import { GameEngine } from '../src/server/engine/engine.ts' import { GameEngine } from '../src/server/engine/engine.ts'
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.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' import { castSkill } from '../src/server/scene/skill-caster.ts'
describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge & Pickup Loop', () => { 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 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', () => { it('converts raw ground item into UiInventoryItem upon Telekinesis pickup', () => {
const engine = createTestEngine() const engine = createTestEngine()
const invPanel = new InventoryPanel() 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', () => { it('accumulates gold and clamps to authentic inventory gold capacity on Telekinesis pickup', () => {
const engine = createTestEngine() const engine = createTestEngine()
engine.world.player.level = 10 engine.world.player.level = 10
@ -566,112 +489,4 @@ describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge &
expect(hudManager.syncPublishedState).toHaveBeenCalled() 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('包裹已满。')
})
})
}) })

View File

@ -12,8 +12,8 @@
* Test Architecture: * Test Architecture:
* - Tier 1: Feature Coverage (Happy Path) * - Tier 1: Feature Coverage (Happy Path)
* 1.1 resolveItemSpriteRect resolves dyed sprites: invcap_cgrn, invgth_dpur, invtgl_lgry, invvbl_blac * 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.2 DEMO_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.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.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.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)') * 1.6 Magic affixes in Blue, Sockets line in Blue ('凹槽 (5)')
@ -36,22 +36,12 @@
*/ */
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { import { InventoryPanel, EQUIP_SLOTS_LAYOUT, INV_GRID_ORIGIN, resolveItemSpriteRect, type EquipSlotId, type ResolvableItem, type SpriteRect, type UiInventoryItem } from '../src/client/ui/inventory.ts'
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 { WorldPanelsHud } from '../src/client/ui/world-panels.ts' import { WorldPanelsHud } from '../src/client/ui/world-panels.ts'
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.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 type { D2FontName, D2ColorCode, D2FontRenderer } from '../src/client/ui/font.ts'
import { createDemoInventory, createDemoWorldPanels } from './fixtures/hud-demo-state.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 --- // --- 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) 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) // Helm: Harlequin Crest (Shako)
const helm = STARTER_EQUIPPED_GEAR.helm as any const helm = DEMO_EQUIPPED_GEAR.helm as any
expect(helm, 'STARTER_EQUIPPED_GEAR.helm must exist').toBeDefined() expect(helm, 'DEMO_EQUIPPED_GEAR.helm must exist').toBeDefined()
expect(helm.invtransform, 'Harlequin Crest must have invtransform: cgrn').toBe('cgrn') expect(helm.invtransform, 'Harlequin Crest must have invtransform: cgrn').toBe('cgrn')
expect(helm.defense, 'Shako defense must be 141').toBe(141) expect(helm.defense, 'Shako defense must be 141').toBe(141)
expect(helm.durability, 'Shako durability must be 12/12').toEqual({ current: 12, max: 12 }) 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) expect(helm.stats.length, 'Shako must have exactly 6 canonical modifiers').toBe(6)
// Armor: Tal Rasha\'s Guardianship (Lacquered Plate) // Armor: Tal Rasha\'s Guardianship (Lacquered Plate)
const armor = STARTER_EQUIPPED_GEAR.armor as any const armor = DEMO_EQUIPPED_GEAR.armor as any
expect(armor, 'STARTER_EQUIPPED_GEAR.armor must exist').toBeDefined() expect(armor, 'DEMO_EQUIPPED_GEAR.armor must exist').toBeDefined()
expect(armor.invtransform, 'Tal Rasha Armor must have invtransform: dpur').toBe('dpur') 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.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 }) 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) expect(armor.setPieces.length, 'Tal Rasha set must contain 5 set pieces').toBeGreaterThanOrEqual(5)
// Gloves: Magefist (Light Gauntlets) // Gloves: Magefist (Light Gauntlets)
const gloves = STARTER_EQUIPPED_GEAR.gloves as any const gloves = DEMO_EQUIPPED_GEAR.gloves as any
expect(gloves, 'STARTER_EQUIPPED_GEAR.gloves must exist').toBeDefined() expect(gloves, 'DEMO_EQUIPPED_GEAR.gloves must exist').toBeDefined()
expect(gloves.invtransform, 'Magefist must have invtransform: lgry').toBe('lgry') expect(gloves.invtransform, 'Magefist must have invtransform: lgry').toBe('lgry')
expect(gloves.defense, 'Magefist defense must be 25').toBe(25) expect(gloves.defense, 'Magefist defense must be 25').toBe(25)
expect(gloves.durability, 'Magefist durability must be 14/14').toEqual({ current: 14, max: 14 }) 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) expect(gloves.reqLevel, 'Magefist reqLevel must be 23').toBe(23)
// Belt: Arachnid Mesh (Spiderweb Sash) // Belt: Arachnid Mesh (Spiderweb Sash)
const belt = STARTER_EQUIPPED_GEAR.belt as any const belt = DEMO_EQUIPPED_GEAR.belt as any
expect(belt, 'STARTER_EQUIPPED_GEAR.belt must exist').toBeDefined() expect(belt, 'DEMO_EQUIPPED_GEAR.belt must exist').toBeDefined()
expect(belt.invtransform, 'Arachnid Mesh must have invtransform: blac').toBe('blac') expect(belt.invtransform, 'Arachnid Mesh must have invtransform: blac').toBe('blac')
expect(belt.defense, 'Arachnid Mesh defense must be 138').toBe(138) 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 }) 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) expect(belt.reqLevel, 'Arachnid Mesh reqLevel must be 80').toBe(80)
// Weapon 1: The Oculus // Weapon 1: The Oculus
const oculus = STARTER_EQUIPPED_GEAR.weapon1 as any const oculus = DEMO_EQUIPPED_GEAR.weapon1 as any
expect(oculus, 'STARTER_EQUIPPED_GEAR.weapon1 must exist').toBeDefined() expect(oculus, 'DEMO_EQUIPPED_GEAR.weapon1 must exist').toBeDefined()
expect(oculus.damage, 'Oculus damage must be 18 - 42').toBe('18 - 42') 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.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.speedText, 'Oculus weapon speed class must be defined').toBe('法球类 - 普通攻击速度')
expect(oculus.reqLevel, 'Oculus reqLevel must be 42').toBe(42) expect(oculus.reqLevel, 'Oculus reqLevel must be 42').toBe(42)
// Weapon 2: Spirit Monarch // Weapon 2: Spirit Monarch
const spirit = STARTER_EQUIPPED_GEAR.weapon2 as any const spirit = DEMO_EQUIPPED_GEAR.weapon2 as any
expect(spirit, 'STARTER_EQUIPPED_GEAR.weapon2 must exist').toBeDefined() expect(spirit, 'DEMO_EQUIPPED_GEAR.weapon2 must exist').toBeDefined()
expect(spirit.runewordRunes, 'Spirit Monarch must declare runeword runes').toBe("'TalThulOrtAmn'") expect(spirit.runewordRunes, 'Spirit Monarch must declare runeword runes').toBe("'TalThulOrtAmn'")
expect(spirit.defense, 'Spirit Monarch defense must be 148').toBe(148) 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 }) 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) expect(spirit.sockets, 'Spirit Monarch must have 4 sockets').toBe(4)
// Boots: War Traveler // Boots: War Traveler
const boots = STARTER_EQUIPPED_GEAR.boots as any const boots = DEMO_EQUIPPED_GEAR.boots as any
expect(boots, 'STARTER_EQUIPPED_GEAR.boots must exist').toBeDefined() expect(boots, 'DEMO_EQUIPPED_GEAR.boots must exist').toBeDefined()
expect(boots.defense, 'War Traveler defense must be 139').toBe(139) 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.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.reqStr, 'War Traveler reqStr must be 95').toBe(95)
expect(boots.reqLevel, 'War Traveler reqLevel must be 42').toBe(42) 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', () => { it('1.3 defines Call to Arms Crystal Sword in DEMO_BAG_ITEMS with all 11 canonical runeword & rune stats', () => {
const ctaPlacement = STARTER_BAG_ITEMS.find(p => p.item.id === 'bag-cta') const ctaPlacement = DEMO_BAG_ITEMS.find(p => p.item.id === 'bag-cta')
expect(ctaPlacement, 'Call to Arms must be present in STARTER_BAG_ITEMS').toBeDefined() expect(ctaPlacement, 'Call to Arms must be present in DEMO_BAG_ITEMS').toBeDefined()
const cta = ctaPlacement!.item as any const cta = ctaPlacement!.item as any
expect(cta.runewordRunes, 'Call to Arms must have runewordRunes: AmnRalMalIstOhm').toBe("'AmnRalMalIstOhm'") 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'] 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 if (!targetBagIds.includes(placement.item.id)) continue
const hoverX = INV_GRID_ORIGIN.x + placement.col * INV_GRID_ORIGIN.cellPx + 14 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 mockFont = createMockFontRenderer()
const mockCtx = createMockCanvasContext() 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) worldPanels.drawItemTooltip(mockCtx, { item: ctaPlacement.item, x: 400, y: 350 }, mockFont)
// Verify fillRect and strokeRect // Verify fillRect and strokeRect

Some files were not shown because too many files have changed in this diff Show More