Merge branch 'refactor/remove-legacy-sp' into main
This commit is contained in:
commit
c96e7254cc
|
|
@ -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
137
play.html
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
/**
|
|
||||||
* Browser-only MPQ sources (`Blob`/`File` and HTTP `Range` `fetch`).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { MpqSource } from '../../common/mpq/source.ts'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a source over a browser `Blob`/`File` (drag & drop, `<input type=file>`,
|
|
||||||
* OPFS handles all expose a Blob).
|
|
||||||
*
|
|
||||||
* @param blob - the blob to read.
|
|
||||||
* @param label - identity shown in diagnostics.
|
|
||||||
* @returns the source.
|
|
||||||
*/
|
|
||||||
export function blobSource(blob: Blob, label = blob instanceof File ? blob.name : 'blob'): MpqSource {
|
|
||||||
return {
|
|
||||||
size: blob.size,
|
|
||||||
label,
|
|
||||||
read: async (offset, length) => {
|
|
||||||
if (offset < 0 || offset + length > blob.size) {
|
|
||||||
throw new RangeError(`${label}: read ${String(offset)}+${String(length)} beyond ${String(blob.size)}`)
|
|
||||||
}
|
|
||||||
return new Uint8Array(await blob.slice(offset, offset + length).arrayBuffer())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bytes to probe with when deciding whether a server honours `Range`. */
|
|
||||||
const RANGE_PROBE_BYTES = 32
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a source over HTTP, reading only the byte ranges asked for.
|
|
||||||
*
|
|
||||||
* @param url - archive URL.
|
|
||||||
* @param label - identity for diagnostics.
|
|
||||||
* @returns the source.
|
|
||||||
*/
|
|
||||||
export async function httpRangeSource(url: string, label = url): Promise<MpqSource> {
|
|
||||||
const probe = await fetch(url, { headers: { Range: `bytes=0-${String(RANGE_PROBE_BYTES - 1)}` } })
|
|
||||||
if (probe.status !== 206) {
|
|
||||||
const type = probe.headers.get('content-type') ?? 'unknown'
|
|
||||||
throw new Error(
|
|
||||||
`${label}: server answered ${String(probe.status)} (${type}) instead of 206 — `
|
|
||||||
+ 'range requests are required to read an archive this large',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const contentRange = probe.headers.get('content-range') ?? ''
|
|
||||||
const total = Number(/\/(\d+)$/.exec(contentRange)?.[1] ?? '0')
|
|
||||||
probe.body?.cancel().catch(() => {})
|
|
||||||
if (!Number.isFinite(total) || total <= 0) {
|
|
||||||
throw new Error(`${label}: server sent no usable Content-Range total (${contentRange})`)
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
size: total,
|
|
||||||
label,
|
|
||||||
read: async (offset, length) => {
|
|
||||||
if (offset < 0 || offset + length > total) {
|
|
||||||
throw new RangeError(`${label}: read ${String(offset)}+${String(length)} beyond ${String(total)}`)
|
|
||||||
}
|
|
||||||
const response = await fetch(url, { headers: { Range: `bytes=${String(offset)}-${String(offset + length - 1)}` } })
|
|
||||||
if (response.status !== 206 && response.status !== 200) {
|
|
||||||
throw new Error(`${label}: range read failed with HTTP ${String(response.status)}`)
|
|
||||||
}
|
|
||||||
const buffer = new Uint8Array(await response.arrayBuffer())
|
|
||||||
if (buffer.byteLength !== length) {
|
|
||||||
throw new Error(`${label}: asked for ${String(length)} bytes at ${String(offset)}, got ${String(buffer.byteLength)}`)
|
|
||||||
}
|
|
||||||
return buffer
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export * from './lang.ts'
|
|
||||||
export { resolveTblKey } from '../../common/data/canonical-tbl-strings.ts'
|
|
||||||
export { decodeTbl, tblLookup } from '../../common/formats/tbl.ts'
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
export {
|
|
||||||
CommandMapper,
|
|
||||||
ViewportCamera,
|
|
||||||
type CameraLike,
|
|
||||||
type CommandMapperDeps,
|
|
||||||
type DispatchedCommandRecord,
|
|
||||||
type PointerWorldTarget,
|
|
||||||
type SkillHotkeyBinding,
|
|
||||||
} from './command-mapper.ts'
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
export * from '../main.ts'
|
|
||||||
export * from '../world/act-runtime.ts'
|
|
||||||
export * from '../session/online-session.ts'
|
|
||||||
export * from '../world/client-world.ts'
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export * from "../../common/units/animation.ts"
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export * from "../../common/units/char-appearance.ts"
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
/**
|
|
||||||
* 视口剔除:把"这一帧到底要画哪些东西"从整张地图缩小到屏幕那一块。
|
|
||||||
*
|
|
||||||
* 起因是一次实测:把 `act-scene` 的绘制顺序在 365 张烘焙关卡上静态重放一遍,
|
|
||||||
* 平均每帧提交 2 848 个四边形,最差一帧(act4 迪亚波罗)14 400 个,而其中真正
|
|
||||||
* 落在视口里的只有 7%~11%。也就是说九成的顶点写入、九成的图集页切换,都是在
|
|
||||||
* 给屏幕外的瓦片做无用功——这正是主循环掉到 20 tps 的直接原因。
|
|
||||||
*
|
|
||||||
* 这里只做两件事:把绘制列表的包围盒预先摊平成 `Float32Array`,以及提供一个
|
|
||||||
* 矩形相交判断。之所以不用四叉树/网格索引,是因为绘制列表本身**不是**按 y 单调
|
|
||||||
* 的(实测地面 0/365 单调,最大回退 9 520 px),任何空间分桶都得重新合并回
|
|
||||||
* 画家顺序;而线性扫描 14 400 个条目只需要 4 次浮点比较 × 14 400 ≈ 15~30 µs,
|
|
||||||
* 占 16 ms 预算的 0.2%,却能原样保住顺序。
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** 一个可绘制物需要的最小信息:世界坐标 + 图集帧尺寸。 */
|
|
||||||
export interface BoundedDrawable {
|
|
||||||
readonly frame: { readonly width: number; readonly height: number }
|
|
||||||
readonly x: number
|
|
||||||
readonly y: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 视口矩形,世界像素。 */
|
|
||||||
export interface ViewRect {
|
|
||||||
readonly left: number
|
|
||||||
readonly top: number
|
|
||||||
readonly right: number
|
|
||||||
readonly bottom: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 视口四周额外放宽的余量,单位为场景像素。
|
|
||||||
*
|
|
||||||
* 包围盒已经算进了每帧的宽高,所以这点余量只是为了兜住 CPU 侧矩形与顶点着色器
|
|
||||||
* 变换之间的浮点舍入,不是用来遮掩边界算错的。
|
|
||||||
*/
|
|
||||||
export const CULL_MARGIN = 32
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把一条绘制列表的世界包围盒摊平成 `[x0, y0, x1, y1]` 四元组。
|
|
||||||
*
|
|
||||||
* 列表在关卡建好之后就不再变动,所以包围盒可以一次算完;用一整块连续内存而不是
|
|
||||||
* 每帧去点两层属性(`draw.frame.width`),是为了让每帧的拒绝测试只碰缓存友好的
|
|
||||||
* 相邻浮点。
|
|
||||||
*
|
|
||||||
* @param list - 带世界坐标和图集帧的可绘制物。
|
|
||||||
* @returns 包围盒,每个条目 4 个浮点。
|
|
||||||
*/
|
|
||||||
export function buildBounds(list: readonly BoundedDrawable[]): Float32Array {
|
|
||||||
const bounds = new Float32Array(list.length * 4)
|
|
||||||
for (let index = 0; index < list.length; index += 1) {
|
|
||||||
const draw = list[index]!
|
|
||||||
const at = index * 4
|
|
||||||
bounds[at] = draw.x
|
|
||||||
bounds[at + 1] = draw.y
|
|
||||||
bounds[at + 2] = draw.x + draw.frame.width
|
|
||||||
bounds[at + 3] = draw.y + draw.frame.height
|
|
||||||
}
|
|
||||||
return bounds
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 由相机和画布尺寸算出视口矩形。
|
|
||||||
*
|
|
||||||
* 顶点着色器保留一个四边形的条件是 `|x - camX| <= drawingBufferWidth / (2 * zoom)`,
|
|
||||||
* 而 `drawingBufferWidth` 就是 `canvas.width`,所以这里用同一个表达式复现它——
|
|
||||||
* 两边算法一旦不一致,靠近屏幕边缘的瓦片就会闪烁。
|
|
||||||
*
|
|
||||||
* @param camX - 相机中心 x,世界像素。
|
|
||||||
* @param camY - 相机中心 y,世界像素。
|
|
||||||
* @param canvasWidth - 画布后备存储宽度。
|
|
||||||
* @param canvasHeight - 画布后备存储高度。
|
|
||||||
* @param zoom - 当前缩放。
|
|
||||||
*/
|
|
||||||
export function viewportRect(
|
|
||||||
camX: number,
|
|
||||||
camY: number,
|
|
||||||
canvasWidth: number,
|
|
||||||
canvasHeight: number,
|
|
||||||
zoom: number,
|
|
||||||
): ViewRect {
|
|
||||||
const halfWidth = canvasWidth / (2 * zoom) + CULL_MARGIN
|
|
||||||
const halfHeight = canvasHeight / (2 * zoom) + CULL_MARGIN
|
|
||||||
return {
|
|
||||||
left: camX - halfWidth,
|
|
||||||
top: camY - halfHeight,
|
|
||||||
right: camX + halfWidth,
|
|
||||||
bottom: camY + halfHeight,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断 `bounds` 中第 `index` 个条目是否与视口相交。
|
|
||||||
*
|
|
||||||
* 这是整帧里调用次数最多的一个函数(最差 14 400 次/帧),所以它只做四次比较,
|
|
||||||
* 不建临时对象、不解构。
|
|
||||||
*
|
|
||||||
* @param bounds - `buildBounds` 的输出。
|
|
||||||
* @param index - 条目下标(不是浮点下标)。
|
|
||||||
* @param view - 视口矩形。
|
|
||||||
*/
|
|
||||||
export function intersects(bounds: Float32Array, index: number, view: ViewRect): boolean {
|
|
||||||
const at = index * 4
|
|
||||||
return bounds[at + 2]! >= view.left && bounds[at]! <= view.right
|
|
||||||
&& bounds[at + 3]! >= view.top && bounds[at + 1]! <= view.bottom
|
|
||||||
}
|
|
||||||
|
|
@ -1,611 +0,0 @@
|
||||||
/**
|
|
||||||
* Dynamic roof occlusion detection and opacity fade (Roof Fade).
|
|
||||||
*
|
|
||||||
* In Diablo II, roof tiles (`TileRoof = 15`) are placed over shelters like
|
|
||||||
* Akara's tent, Charsi's forge, and town houses. They are rendered in Pass 4
|
|
||||||
* (topmost layer) after floors, walls, and characters.
|
|
||||||
*
|
|
||||||
* When the player steps inside a shelter or under an overhanging roof, the roof
|
|
||||||
* tile(s) occlude the character. This module detects player occlusion
|
|
||||||
* (via bounding box overlap or cell/sub-tile coverage) and smoothly transitions
|
|
||||||
* the roof opacity between 1.0 (opaque) and a semi-transparent alpha (e.g. 0.25),
|
|
||||||
* restoring full opacity upon exit.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Minimal roof tile drawable interface. */
|
|
||||||
export interface RoofDrawable {
|
|
||||||
readonly frame: { readonly width: number; readonly height: number }
|
|
||||||
readonly x: number
|
|
||||||
readonly y: number
|
|
||||||
readonly cellX?: number
|
|
||||||
readonly cellY?: number
|
|
||||||
readonly roofHeight?: number
|
|
||||||
readonly wallType?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 2D axis-aligned bounding box in world/scene coordinates. */
|
|
||||||
export interface RoofBounds {
|
|
||||||
readonly minX: number
|
|
||||||
readonly minY: number
|
|
||||||
readonly maxX: number
|
|
||||||
readonly maxY: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Point position in world/scene pixels. */
|
|
||||||
export interface Point2D {
|
|
||||||
readonly x: number
|
|
||||||
readonly y: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Axis-aligned bounding box with left/top/right/bottom. */
|
|
||||||
export interface Box2D {
|
|
||||||
readonly left: number
|
|
||||||
readonly top: number
|
|
||||||
readonly right: number
|
|
||||||
readonly bottom: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Player target representation: a point { x, y }, a box { left, top, right, bottom }, or RoofBounds. */
|
|
||||||
export type PlayerTarget = Point2D | Box2D | RoofBounds
|
|
||||||
|
|
||||||
/** Configuration options for roof fade and occlusion detection. */
|
|
||||||
export interface RoofFadeConfig {
|
|
||||||
/**
|
|
||||||
* Minimum opacity when player is under roof (0.0 .. 1.0).
|
|
||||||
* Default: 0.0 (vanilla Diablo II completely fades occluding roofs).
|
|
||||||
*/
|
|
||||||
readonly minAlpha?: number
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maximum opacity when player is outside (0.0 .. 1.0).
|
|
||||||
* Default: 1.0.
|
|
||||||
*/
|
|
||||||
readonly maxAlpha?: number
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fade transition duration in milliseconds.
|
|
||||||
* Default: 200 ms.
|
|
||||||
*/
|
|
||||||
readonly fadeDurationMs?: number
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Player bounding box half-width (padding) for point-based player positions.
|
|
||||||
* Default: 16 pixels.
|
|
||||||
*/
|
|
||||||
readonly playerPaddingX?: number
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Player bounding box height for point-based player positions (feet to head).
|
|
||||||
* Default: 48 pixels.
|
|
||||||
*/
|
|
||||||
readonly playerHeight?: number
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maximum cell distance to cluster adjacent roof tiles into a single shelter.
|
|
||||||
* Default: 4 cells.
|
|
||||||
*/
|
|
||||||
readonly clusterCellDistance?: number
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pixel proximity margin to connect roof bounding boxes into a cluster.
|
|
||||||
* Default: 40 pixels.
|
|
||||||
*/
|
|
||||||
readonly clusterPixelMargin?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_MIN_ROOF_ALPHA = 0.0
|
|
||||||
export const DEFAULT_MAX_ROOF_ALPHA = 1.0
|
|
||||||
export const DEFAULT_ROOF_FADE_DURATION_MS = 200
|
|
||||||
export const DEFAULT_PLAYER_PADDING_X = 16
|
|
||||||
export const DEFAULT_PLAYER_HEIGHT = 48
|
|
||||||
export const DEFAULT_CLUSTER_CELL_DISTANCE = 4
|
|
||||||
export const DEFAULT_CLUSTER_PIXEL_MARGIN = 40
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute the world-space bounding box of a single roof tile.
|
|
||||||
*
|
|
||||||
* For elevated roofs (with `roofHeight > 0`), the bounding box is projected
|
|
||||||
* downward to the ground footprint so that a character standing on the ground
|
|
||||||
* beneath the elevated sprite is detected within the occlusion area.
|
|
||||||
*
|
|
||||||
* @param roof - Roof drawable with position and dimensions.
|
|
||||||
* @returns 2D axis-aligned bounding box.
|
|
||||||
*/
|
|
||||||
export function getRoofBounds(roof: RoofDrawable): RoofBounds {
|
|
||||||
let elevation = roof.roofHeight ?? 0
|
|
||||||
if (elevation === 0 && roof.cellX !== undefined && roof.cellY !== undefined) {
|
|
||||||
const orthoY = (roof.cellX + roof.cellY) * 40
|
|
||||||
if (orthoY > roof.y) {
|
|
||||||
elevation = orthoY - roof.y
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
minX: roof.x,
|
|
||||||
minY: roof.y,
|
|
||||||
maxX: roof.x + roof.frame.width,
|
|
||||||
maxY: roof.y + roof.frame.height + Math.max(0, elevation),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute the combined bounding box enclosing an array of roof tiles.
|
|
||||||
*
|
|
||||||
* @param roofs - Array of roof drawables.
|
|
||||||
* @returns Combined bounding box, or null if the array is empty.
|
|
||||||
*/
|
|
||||||
export function getCombinedRoofBounds(roofs: readonly RoofDrawable[]): RoofBounds | null {
|
|
||||||
if (roofs.length === 0) return null
|
|
||||||
let minX = Infinity
|
|
||||||
let minY = Infinity
|
|
||||||
let maxX = -Infinity
|
|
||||||
let maxY = -Infinity
|
|
||||||
|
|
||||||
for (let i = 0; i < roofs.length; i += 1) {
|
|
||||||
const b = getRoofBounds(roofs[i]!)
|
|
||||||
if (b.minX < minX) minX = b.minX
|
|
||||||
if (b.minY < minY) minY = b.minY
|
|
||||||
if (b.maxX > maxX) maxX = b.maxX
|
|
||||||
if (b.maxY > maxY) maxY = b.maxY
|
|
||||||
}
|
|
||||||
|
|
||||||
return { minX, minY, maxX, maxY }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a 2D point lies within a bounding box (inclusive).
|
|
||||||
*
|
|
||||||
* @param px - Point x coordinate.
|
|
||||||
* @param py - Point y coordinate.
|
|
||||||
* @param bounds - Bounding box.
|
|
||||||
*/
|
|
||||||
export function isPointInRoofBounds(px: number, py: number, bounds: RoofBounds): boolean {
|
|
||||||
return px >= bounds.minX && px <= bounds.maxX && py >= bounds.minY && py <= bounds.maxY
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if two axis-aligned bounding boxes overlap.
|
|
||||||
*
|
|
||||||
* @param a - First bounding box.
|
|
||||||
* @param b - Second bounding box.
|
|
||||||
*/
|
|
||||||
export function boundsOverlap(a: RoofBounds, b: RoofBounds): boolean {
|
|
||||||
return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalise a player target (point or box) into a world-space bounding box.
|
|
||||||
*
|
|
||||||
* @param player - Point { x, y }, Box { left, top, right, bottom }, or RoofBounds.
|
|
||||||
* @param paddingX - Half-width when expanding a point (default: 16).
|
|
||||||
* @param height - Height when expanding a point upward from feet (default: 48).
|
|
||||||
*/
|
|
||||||
export function getPlayerBounds(
|
|
||||||
player: PlayerTarget,
|
|
||||||
paddingX = DEFAULT_PLAYER_PADDING_X,
|
|
||||||
height = DEFAULT_PLAYER_HEIGHT,
|
|
||||||
): RoofBounds {
|
|
||||||
if ('minX' in player && 'minY' in player && 'maxX' in player && 'maxY' in player) {
|
|
||||||
return player
|
|
||||||
}
|
|
||||||
if ('left' in player && 'right' in player && 'top' in player && 'bottom' in player) {
|
|
||||||
return {
|
|
||||||
minX: Math.min(player.left, player.right),
|
|
||||||
minY: Math.min(player.top, player.bottom),
|
|
||||||
maxX: Math.max(player.left, player.right),
|
|
||||||
maxY: Math.max(player.top, player.bottom),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
minX: player.x - paddingX,
|
|
||||||
minY: player.y - height,
|
|
||||||
maxX: player.x + paddingX,
|
|
||||||
maxY: player.y,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a player's position or bounding box overlaps a roof's bounding box.
|
|
||||||
*
|
|
||||||
* @param player - Player position or box.
|
|
||||||
* @param roof - Roof drawable or precomputed bounds.
|
|
||||||
* @param paddingX - Optional player padding in x.
|
|
||||||
* @param playerHeight - Optional player height.
|
|
||||||
*/
|
|
||||||
export function isPlayerOverlappingRoofBounds(
|
|
||||||
player: PlayerTarget,
|
|
||||||
roof: RoofDrawable | RoofBounds,
|
|
||||||
paddingX = DEFAULT_PLAYER_PADDING_X,
|
|
||||||
playerHeight = DEFAULT_PLAYER_HEIGHT,
|
|
||||||
): boolean {
|
|
||||||
const pBounds = getPlayerBounds(player, paddingX, playerHeight)
|
|
||||||
const rBounds = 'frame' in roof ? getRoofBounds(roof) : roof
|
|
||||||
return boundsOverlap(pBounds, rBounds)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if player's cell matches any roof tile's cell.
|
|
||||||
*
|
|
||||||
* @param playerCell - Player cell coordinates { x, y }.
|
|
||||||
* @param roofs - Roof drawable(s) or array with cellX/cellY.
|
|
||||||
*/
|
|
||||||
export function isPlayerInRoofCell(
|
|
||||||
playerCell: Point2D,
|
|
||||||
roofs: { readonly cellX?: number; readonly cellY?: number } | readonly { readonly cellX?: number; readonly cellY?: number }[],
|
|
||||||
): boolean {
|
|
||||||
if (Array.isArray(roofs)) {
|
|
||||||
for (let i = 0; i < roofs.length; i += 1) {
|
|
||||||
const r = roofs[i]!
|
|
||||||
if (r.cellX !== undefined && r.cellY !== undefined && r.cellX === playerCell.x && r.cellY === playerCell.y) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const single = roofs as { readonly cellX?: number; readonly cellY?: number }
|
|
||||||
return single.cellX !== undefined && single.cellY !== undefined && single.cellX === playerCell.x && single.cellY === playerCell.y
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if player's sub-tile coordinates fall inside any cell covered by a roof.
|
|
||||||
* In Diablo II, 1 cell = 5x5 sub-tiles (subTilesPerTile = 5).
|
|
||||||
*
|
|
||||||
* @param playerSubTile - Player sub-tile coordinates { x, y }.
|
|
||||||
* @param roofs - Roof drawable(s) with cellX/cellY.
|
|
||||||
* @param subTilesPerTile - Sub-tiles per cell edge (default: 5).
|
|
||||||
*/
|
|
||||||
export function isPlayerInRoofSubTile(
|
|
||||||
playerSubTile: Point2D,
|
|
||||||
roofs: { readonly cellX?: number; readonly cellY?: number } | readonly { readonly cellX?: number; readonly cellY?: number }[],
|
|
||||||
subTilesPerTile = 5,
|
|
||||||
): boolean {
|
|
||||||
const cellX = Math.floor(playerSubTile.x / subTilesPerTile)
|
|
||||||
const cellY = Math.floor(playerSubTile.y / subTilesPerTile)
|
|
||||||
return isPlayerInRoofCell({ x: cellX, y: cellY }, roofs)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether player is under a specific roof tile, checking bounding box
|
|
||||||
* overlap OR cell/sub-tile match.
|
|
||||||
*
|
|
||||||
* @param player - Player coordinates or bounding box.
|
|
||||||
* @param roof - Roof drawable.
|
|
||||||
* @param playerCell - Optional player cell coordinates.
|
|
||||||
* @param config - Optional configuration.
|
|
||||||
*/
|
|
||||||
export function isPlayerUnderRoof(
|
|
||||||
player: PlayerTarget,
|
|
||||||
roof: RoofDrawable,
|
|
||||||
playerCell?: Point2D,
|
|
||||||
config?: RoofFadeConfig,
|
|
||||||
): boolean {
|
|
||||||
if (playerCell && roof.cellX !== undefined && roof.cellY !== undefined) {
|
|
||||||
if (roof.cellX === playerCell.x && roof.cellY === playerCell.y) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return isPlayerOverlappingRoofBounds(
|
|
||||||
player,
|
|
||||||
roof,
|
|
||||||
config?.playerPaddingX ?? DEFAULT_PLAYER_PADDING_X,
|
|
||||||
config?.playerHeight ?? DEFAULT_PLAYER_HEIGHT,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Step an opacity value towards targetAlpha given elapsed time deltaMs and duration.
|
|
||||||
*
|
|
||||||
* @param currentAlpha - Current alpha (0.0 .. 1.0).
|
|
||||||
* @param targetAlpha - Target alpha (0.0 .. 1.0).
|
|
||||||
* @param deltaMs - Elapsed wall-clock milliseconds.
|
|
||||||
* @param durationMs - Transition duration in ms (default: 200).
|
|
||||||
* @param minAlpha - Minimum allowed alpha (default: 0.25).
|
|
||||||
* @param maxAlpha - Maximum allowed alpha (default: 1.0).
|
|
||||||
*/
|
|
||||||
export function stepRoofAlpha(
|
|
||||||
currentAlpha: number,
|
|
||||||
targetAlpha: number,
|
|
||||||
deltaMs: number,
|
|
||||||
durationMs = DEFAULT_ROOF_FADE_DURATION_MS,
|
|
||||||
minAlpha = DEFAULT_MIN_ROOF_ALPHA,
|
|
||||||
maxAlpha = DEFAULT_MAX_ROOF_ALPHA,
|
|
||||||
): number {
|
|
||||||
if (durationMs <= 0) return targetAlpha
|
|
||||||
if (deltaMs <= 0) return currentAlpha
|
|
||||||
|
|
||||||
const speed = (maxAlpha - minAlpha) / durationMs
|
|
||||||
const change = speed * deltaMs
|
|
||||||
|
|
||||||
if (currentAlpha < targetAlpha) {
|
|
||||||
return Math.min(targetAlpha, currentAlpha + change)
|
|
||||||
}
|
|
||||||
if (currentAlpha > targetAlpha) {
|
|
||||||
return Math.max(targetAlpha, currentAlpha - change)
|
|
||||||
}
|
|
||||||
return currentAlpha
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Represents a clustered shelter composed of one or more adjacent roof tiles. */
|
|
||||||
export interface RoofCluster {
|
|
||||||
readonly id: number
|
|
||||||
readonly roofs: readonly RoofDrawable[]
|
|
||||||
readonly roofIndices: readonly number[]
|
|
||||||
readonly bounds: RoofBounds
|
|
||||||
readonly cells: ReadonlySet<string>
|
|
||||||
/** Current opacity alpha (0.0 .. 1.0). */
|
|
||||||
alpha: number
|
|
||||||
/** Target opacity alpha (0.0 .. 1.0). */
|
|
||||||
targetAlpha: number
|
|
||||||
/** Whether the player was detected under this cluster on the latest update. */
|
|
||||||
isPlayerUnder: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Group roof drawables into connected shelters (clusters).
|
|
||||||
*
|
|
||||||
* Roof tiles that share adjacent cells or have overlapping/nearby bounding boxes
|
|
||||||
* are grouped together so an entire building/tent roof fades in unison.
|
|
||||||
*
|
|
||||||
* @param roofs - Array of roof drawables.
|
|
||||||
* @param config - Clustering parameters.
|
|
||||||
*/
|
|
||||||
export function clusterRoofs(
|
|
||||||
roofs: readonly RoofDrawable[],
|
|
||||||
config: RoofFadeConfig = {},
|
|
||||||
): RoofCluster[] {
|
|
||||||
if (roofs.length === 0) return []
|
|
||||||
|
|
||||||
const cellDist = config.clusterCellDistance ?? DEFAULT_CLUSTER_CELL_DISTANCE
|
|
||||||
const pixelMargin = config.clusterPixelMargin ?? DEFAULT_CLUSTER_PIXEL_MARGIN
|
|
||||||
|
|
||||||
const n = roofs.length
|
|
||||||
const parent = new Int32Array(n)
|
|
||||||
for (let i = 0; i < n; i += 1) parent[i] = i
|
|
||||||
|
|
||||||
const find = (i: number): number => {
|
|
||||||
let root = i
|
|
||||||
while (parent[root] !== root) root = parent[root]!
|
|
||||||
let curr = i
|
|
||||||
while (curr !== root) {
|
|
||||||
const nxt = parent[curr]!
|
|
||||||
parent[curr] = root
|
|
||||||
curr = nxt
|
|
||||||
}
|
|
||||||
return root
|
|
||||||
}
|
|
||||||
|
|
||||||
const union = (i: number, j: number): void => {
|
|
||||||
const rootI = find(i)
|
|
||||||
const rootJ = find(j)
|
|
||||||
if (rootI !== rootJ) parent[rootI] = rootJ
|
|
||||||
}
|
|
||||||
|
|
||||||
const boundsList = roofs.map(getRoofBounds)
|
|
||||||
|
|
||||||
for (let i = 0; i < n; i += 1) {
|
|
||||||
const a = roofs[i]!
|
|
||||||
const bA = boundsList[i]!
|
|
||||||
const expandedA: RoofBounds = {
|
|
||||||
minX: bA.minX - pixelMargin,
|
|
||||||
minY: bA.minY - pixelMargin,
|
|
||||||
maxX: bA.maxX + pixelMargin,
|
|
||||||
maxY: bA.maxY + pixelMargin,
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let j = i + 1; j < n; j += 1) {
|
|
||||||
const b = roofs[j]!
|
|
||||||
let connected = false
|
|
||||||
|
|
||||||
// 1. Cell adjacency
|
|
||||||
if (
|
|
||||||
a.cellX !== undefined && a.cellY !== undefined &&
|
|
||||||
b.cellX !== undefined && b.cellY !== undefined
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
Math.abs(a.cellX - b.cellX) <= cellDist &&
|
|
||||||
Math.abs(a.cellY - b.cellY) <= cellDist
|
|
||||||
) {
|
|
||||||
connected = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Pixel bounding box proximity
|
|
||||||
if (!connected && boundsOverlap(expandedA, boundsList[j]!)) {
|
|
||||||
connected = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connected) union(i, j)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const groups = new Map<number, number[]>()
|
|
||||||
for (let i = 0; i < n; i += 1) {
|
|
||||||
const root = find(i)
|
|
||||||
const existing = groups.get(root)
|
|
||||||
if (existing !== undefined) {
|
|
||||||
existing.push(i)
|
|
||||||
} else {
|
|
||||||
groups.set(root, [i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxAlpha = config.maxAlpha ?? DEFAULT_MAX_ROOF_ALPHA
|
|
||||||
const clusters: RoofCluster[] = []
|
|
||||||
let clusterId = 0
|
|
||||||
|
|
||||||
for (const indices of groups.values()) {
|
|
||||||
const clusterRoofsList: RoofDrawable[] = []
|
|
||||||
const cells = new Set<string>()
|
|
||||||
|
|
||||||
for (const idx of indices) {
|
|
||||||
const r = roofs[idx]!
|
|
||||||
clusterRoofsList.push(r)
|
|
||||||
if (r.cellX !== undefined && r.cellY !== undefined) {
|
|
||||||
cells.add(`${String(r.cellX)},${String(r.cellY)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bridge passage / corridor cells between nearby roofs in the same cluster
|
|
||||||
const len = clusterRoofsList.length
|
|
||||||
for (let i = 0; i < len; i += 1) {
|
|
||||||
const a = clusterRoofsList[i]!
|
|
||||||
if (a.cellX === undefined || a.cellY === undefined) continue
|
|
||||||
for (let j = i + 1; j < len; j += 1) {
|
|
||||||
const b = clusterRoofsList[j]!
|
|
||||||
if (b.cellX === undefined || b.cellY === undefined) continue
|
|
||||||
if (
|
|
||||||
Math.abs(a.cellX - b.cellX) <= cellDist &&
|
|
||||||
Math.abs(a.cellY - b.cellY) <= cellDist
|
|
||||||
) {
|
|
||||||
const minCx = Math.min(a.cellX, b.cellX)
|
|
||||||
const maxCx = Math.max(a.cellX, b.cellX)
|
|
||||||
const minCy = Math.min(a.cellY, b.cellY)
|
|
||||||
const maxCy = Math.max(a.cellY, b.cellY)
|
|
||||||
for (let cx = minCx; cx <= maxCx; cx += 1) {
|
|
||||||
for (let cy = minCy; cy <= maxCy; cy += 1) {
|
|
||||||
cells.add(`${String(cx)},${String(cy)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bounds = getCombinedRoofBounds(clusterRoofsList)!
|
|
||||||
clusters.push({
|
|
||||||
id: clusterId,
|
|
||||||
roofs: clusterRoofsList,
|
|
||||||
roofIndices: indices,
|
|
||||||
bounds,
|
|
||||||
cells,
|
|
||||||
alpha: maxAlpha,
|
|
||||||
targetAlpha: maxAlpha,
|
|
||||||
isPlayerUnder: false,
|
|
||||||
})
|
|
||||||
clusterId += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return clusters
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Controller managing roof opacity and dynamic roof fading for a scene.
|
|
||||||
*/
|
|
||||||
export class RoofFadeController {
|
|
||||||
readonly clusters: RoofCluster[]
|
|
||||||
readonly roofClusterMap: Int32Array
|
|
||||||
readonly config: Required<RoofFadeConfig>
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
roofs: readonly RoofDrawable[],
|
|
||||||
config: RoofFadeConfig = {},
|
|
||||||
) {
|
|
||||||
this.config = {
|
|
||||||
minAlpha: config.minAlpha ?? DEFAULT_MIN_ROOF_ALPHA,
|
|
||||||
maxAlpha: config.maxAlpha ?? DEFAULT_MAX_ROOF_ALPHA,
|
|
||||||
fadeDurationMs: config.fadeDurationMs ?? DEFAULT_ROOF_FADE_DURATION_MS,
|
|
||||||
playerPaddingX: config.playerPaddingX ?? DEFAULT_PLAYER_PADDING_X,
|
|
||||||
playerHeight: config.playerHeight ?? DEFAULT_PLAYER_HEIGHT,
|
|
||||||
clusterCellDistance: config.clusterCellDistance ?? DEFAULT_CLUSTER_CELL_DISTANCE,
|
|
||||||
clusterPixelMargin: config.clusterPixelMargin ?? DEFAULT_CLUSTER_PIXEL_MARGIN,
|
|
||||||
}
|
|
||||||
|
|
||||||
this.clusters = clusterRoofs(roofs, this.config)
|
|
||||||
this.roofClusterMap = new Int32Array(roofs.length)
|
|
||||||
|
|
||||||
for (let cIdx = 0; cIdx < this.clusters.length; cIdx += 1) {
|
|
||||||
const cluster = this.clusters[cIdx]!
|
|
||||||
for (const rIdx of cluster.roofIndices) {
|
|
||||||
this.roofClusterMap[rIdx] = cIdx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update opacity for all roof clusters based on player position and elapsed time.
|
|
||||||
*
|
|
||||||
* @param player - Current player world position or bounding box.
|
|
||||||
* @param deltaMs - Elapsed wall-clock time in milliseconds.
|
|
||||||
* @param playerCell - Optional player cell { x, y } in tile grid.
|
|
||||||
*/
|
|
||||||
update(
|
|
||||||
player: PlayerTarget,
|
|
||||||
deltaMs: number,
|
|
||||||
playerCell?: Point2D,
|
|
||||||
): void {
|
|
||||||
const pBounds = getPlayerBounds(player, this.config.playerPaddingX, this.config.playerHeight)
|
|
||||||
|
|
||||||
for (let i = 0; i < this.clusters.length; i += 1) {
|
|
||||||
const cluster = this.clusters[i]!
|
|
||||||
let under = false
|
|
||||||
|
|
||||||
// 1. Check cell coverage
|
|
||||||
if (playerCell && cluster.cells.has(`${String(playerCell.x)},${String(playerCell.y)}`)) {
|
|
||||||
under = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check bounding box overlap if not already triggered by cell
|
|
||||||
if (!under && boundsOverlap(pBounds, cluster.bounds)) {
|
|
||||||
for (let r = 0; r < cluster.roofs.length; r += 1) {
|
|
||||||
if (isPlayerOverlappingRoofBounds(pBounds, cluster.roofs[r]!)) {
|
|
||||||
under = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cluster.isPlayerUnder = under
|
|
||||||
cluster.targetAlpha = under ? this.config.minAlpha : this.config.maxAlpha
|
|
||||||
cluster.alpha = stepRoofAlpha(
|
|
||||||
cluster.alpha,
|
|
||||||
cluster.targetAlpha,
|
|
||||||
deltaMs,
|
|
||||||
this.config.fadeDurationMs,
|
|
||||||
this.config.minAlpha,
|
|
||||||
this.config.maxAlpha,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current opacity alpha for a roof drawable by its index.
|
|
||||||
*
|
|
||||||
* @param roofIndex - Index of the roof in the original list passed to the constructor.
|
|
||||||
* @returns Opacity alpha in range [minAlpha, maxAlpha].
|
|
||||||
*/
|
|
||||||
getAlpha(roofIndex: number): number {
|
|
||||||
const clusterIdx = this.roofClusterMap[roofIndex]
|
|
||||||
if (clusterIdx === undefined || clusterIdx < 0) return this.config.maxAlpha
|
|
||||||
return this.clusters[clusterIdx]?.alpha ?? this.config.maxAlpha
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set alpha directly for all clusters (useful for testing or scene reset).
|
|
||||||
*/
|
|
||||||
setAlpha(alpha: number): void {
|
|
||||||
for (let i = 0; i < this.clusters.length; i += 1) {
|
|
||||||
this.clusters[i]!.alpha = alpha
|
|
||||||
this.clusters[i]!.targetAlpha = alpha
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the player is currently detected under any roof cluster.
|
|
||||||
*/
|
|
||||||
get isPlayerUnderAnyRoof(): boolean {
|
|
||||||
for (let i = 0; i < this.clusters.length; i += 1) {
|
|
||||||
if (this.clusters[i]!.isPlayerUnder) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lowest opacity alpha among all roof clusters (for global state/HUD reporting).
|
|
||||||
*/
|
|
||||||
get overallAlpha(): number {
|
|
||||||
if (this.clusters.length === 0) return this.config.maxAlpha
|
|
||||||
let min = 1.0
|
|
||||||
for (let i = 0; i < this.clusters.length; i += 1) {
|
|
||||||
if (this.clusters[i]!.alpha < min) min = this.clusters[i]!.alpha
|
|
||||||
}
|
|
||||||
return min
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
/**
|
|
||||||
* Diablo II: Lord of Destruction v1.13c Unified 7-Class, Missile, Overlay & Summon Sprite Loader (`UnitSpriteLoader`).
|
|
||||||
*
|
|
||||||
* - Maps all 7 player classes (`ama`->`am`, `sor`->`so`, `nec`->`ne`, `pal`->`pa`, `bar`->`ba`, `dru`->`dz`, `ass`->`ai`)
|
|
||||||
* across `d2char.mpq` and `d2exp.mpq` with 16 composite layers (`hd..s8`).
|
|
||||||
* - Decodes missile `.dcc` sprites (`data\global\missiles\<CelFile>.dcc`) via `decodeDcc`.
|
|
||||||
* - Decodes state/cast overlay `.dcc`/`.dc6` sprites (`data\global\overlays\<Filename>.dcc` or `.dc6`)
|
|
||||||
* and distinguishes `PreDraw === true` (ground aura rings) vs `PreDraw === false` (overhead curses/flashes).
|
|
||||||
* - Decodes summon/trap/morph monster sprites (`data\global\monsters\<Code>\`).
|
|
||||||
*/
|
|
||||||
import type { MountedArchives } from '../../common/mpq/mount.ts'
|
|
||||||
import { decodeDcc } from '../../common/formats/dcc.ts'
|
|
||||||
import { decodeDc6 } from '../../common/formats/dc6.ts'
|
|
||||||
import type { SpriteFrame, SpriteSheet } from '../../common/formats/sprite.ts'
|
|
||||||
import { loadCharacterSheet, type CharacterSheet } from '../../common/units/character.ts'
|
|
||||||
|
|
||||||
import { CLASS_CODE_TO_CHAR_TOKEN, resolvePlayerCharToken } from '../../common/units/classes.ts'
|
|
||||||
export { CLASS_CODE_TO_CHAR_TOKEN, resolvePlayerCharToken }
|
|
||||||
|
|
||||||
export class UnitSpriteLoader {
|
|
||||||
readonly archives: MountedArchives
|
|
||||||
private readonly charSheetCache = new Map<string, Promise<CharacterSheet>>()
|
|
||||||
private readonly missileSheetCache = new Map<string, Promise<SpriteSheet>>()
|
|
||||||
private readonly overlaySheetCache = new Map<string, Promise<SpriteSheet>>()
|
|
||||||
private readonly monsterSheetCache = new Map<string, Promise<CharacterSheet>>()
|
|
||||||
|
|
||||||
constructor(archives: MountedArchives) {
|
|
||||||
this.archives = archives
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load and cache a composite character sheet for any of the 7 hero classes (`am`, `so`, `ne`, `pa`, `ba`, `dz`, `ai`).
|
|
||||||
*/
|
|
||||||
loadPlayerClassSheet(
|
|
||||||
classOrToken: string,
|
|
||||||
mode = 'sc',
|
|
||||||
weaponClass = 'hth',
|
|
||||||
): Promise<CharacterSheet> {
|
|
||||||
const token = resolvePlayerCharToken(classOrToken)
|
|
||||||
const anim = mode.trim().toLowerCase()
|
|
||||||
const wclass = weaponClass.trim().toLowerCase()
|
|
||||||
const key = `${token}:${anim}:${wclass}`
|
|
||||||
|
|
||||||
const existing = this.charSheetCache.get(key)
|
|
||||||
if (existing) return existing
|
|
||||||
|
|
||||||
const promise = loadCharacterSheet(this.archives, token, anim, wclass)
|
|
||||||
this.charSheetCache.set(key, promise)
|
|
||||||
return promise
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load and cache a missile sprite (`data\global\missiles\<celFile>.dcc`).
|
|
||||||
*/
|
|
||||||
loadMissileSprite(celFile: string): Promise<SpriteSheet> {
|
|
||||||
const clean = celFile.trim().replace(/\.(dcc|dc6)$/i, '')
|
|
||||||
const key = clean.toLowerCase()
|
|
||||||
const existing = this.missileSheetCache.get(key)
|
|
||||||
if (existing) return existing
|
|
||||||
|
|
||||||
const promise = (async (): Promise<SpriteSheet> => {
|
|
||||||
const dccPath = `data\\global\\missiles\\${clean}.dcc`
|
|
||||||
const bytes = await this.archives.read(dccPath)
|
|
||||||
const dcc = decodeDcc(bytes)
|
|
||||||
const groups = dcc.directions.map(dir => ({
|
|
||||||
frames: dir.frames.map(
|
|
||||||
(f): SpriteFrame => ({
|
|
||||||
width: f.frame.width,
|
|
||||||
height: f.frame.height,
|
|
||||||
indices: f.frame.indices,
|
|
||||||
mask: f.frame.mask,
|
|
||||||
anchorX: dir.box.left,
|
|
||||||
anchorY: dir.box.top,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
return { groups, width: null }
|
|
||||||
})()
|
|
||||||
|
|
||||||
this.missileSheetCache.set(key, promise)
|
|
||||||
return promise
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load and cache a state/cast overlay sprite (`data\global\overlays\<filename>.dcc` with `.dc6` fallback).
|
|
||||||
* Accepts either an `Overlay.txt` `Filename` (`CurseAmplifyDamageEffect`) or `overlay` ID (`curseamplifydamage`).
|
|
||||||
*/
|
|
||||||
loadOverlaySprite(filenameOrOverlayId: string): Promise<SpriteSheet> {
|
|
||||||
const clean = filenameOrOverlayId.trim().replace(/\.(dcc|dc6)$/i, '')
|
|
||||||
const key = clean.toLowerCase()
|
|
||||||
const existing = this.overlaySheetCache.get(key)
|
|
||||||
if (existing) return existing
|
|
||||||
|
|
||||||
const promise = (async (): Promise<SpriteSheet> => {
|
|
||||||
const candidates = [
|
|
||||||
clean,
|
|
||||||
key === 'cursampdamage' || key === 'curseamplifydamage' ? 'CurseAmplifyDamageEffect' : '',
|
|
||||||
'AuraFanatic',
|
|
||||||
].filter(Boolean)
|
|
||||||
|
|
||||||
for (const cand of candidates) {
|
|
||||||
const dccPath = `data\\global\\overlays\\${cand}.dcc`
|
|
||||||
if (this.archives.find(dccPath)) {
|
|
||||||
const bytes = await this.archives.read(dccPath)
|
|
||||||
const dcc = decodeDcc(bytes)
|
|
||||||
const groups = dcc.directions.map(dir => ({
|
|
||||||
frames: dir.frames.map(
|
|
||||||
(f): SpriteFrame => ({
|
|
||||||
width: f.frame.width,
|
|
||||||
height: f.frame.height,
|
|
||||||
indices: f.frame.indices,
|
|
||||||
mask: f.frame.mask,
|
|
||||||
anchorX: dir.box.left,
|
|
||||||
anchorY: dir.box.top,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
return { groups, width: null }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dc6Path = `data\\global\\overlays\\${clean}.dc6`
|
|
||||||
const bytes = await this.archives.read(dc6Path)
|
|
||||||
return decodeDc6(bytes)
|
|
||||||
})()
|
|
||||||
|
|
||||||
this.overlaySheetCache.set(key, promise)
|
|
||||||
return promise
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load and cache a summoned pet, trap, or shapeshift monster sprite (`data\global\monsters\<code>\`).
|
|
||||||
*/
|
|
||||||
loadMonsterOrPetSheet(
|
|
||||||
monCode: string,
|
|
||||||
mode = 'nu',
|
|
||||||
weaponClass = 'hth',
|
|
||||||
): Promise<CharacterSheet> {
|
|
||||||
const code = monCode.trim().toLowerCase()
|
|
||||||
const anim = mode.trim().toLowerCase()
|
|
||||||
const wclass = weaponClass.trim().toLowerCase()
|
|
||||||
const key = `${code}:${anim}:${wclass}`
|
|
||||||
|
|
||||||
const existing = this.monsterSheetCache.get(key)
|
|
||||||
if (existing) return existing
|
|
||||||
|
|
||||||
const promise = loadCharacterSheet(
|
|
||||||
this.archives,
|
|
||||||
code,
|
|
||||||
anim,
|
|
||||||
wclass,
|
|
||||||
`data\\global\\monsters\\${code}\\`,
|
|
||||||
)
|
|
||||||
this.monsterSheetCache.set(key, promise)
|
|
||||||
return promise
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,293 +0,0 @@
|
||||||
import { getSharedDataRegistry } from '../../common/data/data-registry.ts'
|
|
||||||
import { MpqArchive } from '../../common/mpq/archive.ts'
|
|
||||||
import { MountedArchives } from '../../common/mpq/mount.ts'
|
|
||||||
import { memorySource } from '../../common/mpq/source.ts'
|
|
||||||
|
|
||||||
export type WorldArenaType = any
|
|
||||||
export type WorldArenaFactory = (opts: Record<string, unknown>) => WorldArenaType
|
|
||||||
|
|
||||||
let registeredWorldArenaFactory: WorldArenaFactory | null = null
|
|
||||||
|
|
||||||
export function registerWorldArenaFactory(factory: WorldArenaFactory | null): void {
|
|
||||||
registeredWorldArenaFactory = factory
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function initBrowserArenaMode(factory?: WorldArenaFactory): Promise<void> {
|
|
||||||
const createArena = factory ?? registeredWorldArenaFactory
|
|
||||||
if (!createArena) {
|
|
||||||
throw new Error(
|
|
||||||
'[Arena] No WorldArenaFactory registered; registerWorldArenaFactory must be called before initializing arena mode',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') return
|
|
||||||
const params = new URLSearchParams(location.search)
|
|
||||||
if (params.get('arena') !== '1') return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const classParam = params.get('class') || params.get('hero') || 'sor'
|
|
||||||
const skillId = Math.max(0, Number(params.get('skill') ?? '47'))
|
|
||||||
const slvl = Math.max(1, Number(params.get('slvl') ?? '20'))
|
|
||||||
const autocast = params.get('autocast') !== '0'
|
|
||||||
|
|
||||||
const [patchRes, animRes, hitClassRes] = await Promise.all([
|
|
||||||
fetch('/samples/d2/Patch_D2.mpq'),
|
|
||||||
fetch('/samples/d2-packs/anim/AnimData.d2'),
|
|
||||||
fetch('/samples/d2-packs/anim/HitClass.txt'),
|
|
||||||
])
|
|
||||||
if (!patchRes.ok || !animRes.ok || !hitClassRes.ok) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to fetch 1.13c registry assets (Patch_D2=${String(patchRes.status)}, AnimData=${String(animRes.status)}, HitClass=${String(hitClassRes.status)})`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const patchBytes = new Uint8Array(await patchRes.arrayBuffer())
|
|
||||||
const animBytes = new Uint8Array(await animRes.arrayBuffer())
|
|
||||||
const hitClassBytes = new Uint8Array(await hitClassRes.arrayBuffer())
|
|
||||||
const patchMpq = MpqArchive.openSync(memorySource(patchBytes, 'Patch_D2.mpq'))
|
|
||||||
const mounted = new MountedArchives()
|
|
||||||
mounted.add('Patch_D2.mpq', patchMpq)
|
|
||||||
const browserSource = {
|
|
||||||
readBytes(path: string): Uint8Array | null {
|
|
||||||
const lower = path.trim().replace(/\//g, '\\').toLowerCase()
|
|
||||||
if (lower.endsWith('animdata.d2')) return animBytes
|
|
||||||
if (lower.endsWith('hitclass.txt')) return hitClassBytes
|
|
||||||
return mounted.readFile(path)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const registry = getSharedDataRegistry(browserSource)
|
|
||||||
let arena: WorldArenaType = createArena({
|
|
||||||
registry,
|
|
||||||
classCode: classParam,
|
|
||||||
skillId,
|
|
||||||
slvl,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (autocast) {
|
|
||||||
arena.triggerCast()
|
|
||||||
arena.stepTicks(15)
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncWindowArena = () => {
|
|
||||||
const snap = arena.getDebugState() as ReturnType<typeof arena.getDebugState> & {
|
|
||||||
switchAndCastSkill?: (nextClassCode: string, nextSkillId: number, nextSlvl?: number) => unknown
|
|
||||||
}
|
|
||||||
const origTrigger = snap.triggerCast
|
|
||||||
const origStep = snap.stepTicks
|
|
||||||
snap.triggerCast = (tx?: number, ty?: number) => {
|
|
||||||
const out = origTrigger(tx, ty)
|
|
||||||
syncWindowArena()
|
|
||||||
renderArenaVisualOverlay(arena)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
snap.stepTicks = (cnt: number) => {
|
|
||||||
origStep(cnt)
|
|
||||||
syncWindowArena()
|
|
||||||
renderArenaVisualOverlay(arena)
|
|
||||||
}
|
|
||||||
snap.switchAndCastSkill = (nextClassCode: string, nextSkillId: number, nextSlvl = 20) => {
|
|
||||||
arena = createArena({
|
|
||||||
registry,
|
|
||||||
classCode: nextClassCode,
|
|
||||||
skillId: nextSkillId,
|
|
||||||
slvl: nextSlvl,
|
|
||||||
})
|
|
||||||
arena.triggerCast()
|
|
||||||
arena.stepTicks(15)
|
|
||||||
syncWindowArena()
|
|
||||||
renderArenaVisualOverlay(arena)
|
|
||||||
return arena.getDebugState()
|
|
||||||
}
|
|
||||||
;(window as unknown as { __d2webArena?: unknown }).__d2webArena = snap
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderArenaVisualOverlay = (wArena: WorldArenaType) => {
|
|
||||||
let overlayCanvas = document.querySelector<HTMLCanvasElement>('#arena-skill-overlay')
|
|
||||||
if (!overlayCanvas) {
|
|
||||||
overlayCanvas = document.createElement('canvas')
|
|
||||||
overlayCanvas.id = 'arena-skill-overlay'
|
|
||||||
overlayCanvas.width = window.innerWidth || 960
|
|
||||||
overlayCanvas.height = window.innerHeight || 600
|
|
||||||
overlayCanvas.style.cssText =
|
|
||||||
'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:7;'
|
|
||||||
document.body.appendChild(overlayCanvas)
|
|
||||||
}
|
|
||||||
const ctx = overlayCanvas.getContext('2d')
|
|
||||||
if (!ctx) return
|
|
||||||
const w = overlayCanvas.width
|
|
||||||
const h = overlayCanvas.height
|
|
||||||
ctx.clearRect(0, 0, w, h)
|
|
||||||
|
|
||||||
const dbg = wArena.getDebugState()
|
|
||||||
const cx = w * 0.5
|
|
||||||
const cy = h * 0.52
|
|
||||||
|
|
||||||
// 1. Draw Active Aura / Cast Ring & Class Hero Avatar around Hero
|
|
||||||
const classHue = ((dbg.skillId * 37) % 360)
|
|
||||||
ctx.save()
|
|
||||||
ctx.strokeStyle = dbg.activeStates.length > 0 ? 'rgba(255, 210, 80, 0.9)' : `hsla(${classHue}, 85%, 65%, 0.85)`
|
|
||||||
ctx.lineWidth = 3.5
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.ellipse(cx, cy + 16, 62 + (dbg.skillId % 18), 32 + (dbg.skillId % 9), 0, 0, Math.PI * 2)
|
|
||||||
ctx.stroke()
|
|
||||||
|
|
||||||
// Hero class figure + active weapon/spell glow
|
|
||||||
ctx.fillStyle = `hsla(${classHue}, 75%, 55%, 0.85)`
|
|
||||||
ctx.strokeStyle = '#fff3d1'
|
|
||||||
ctx.lineWidth = 2
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(cx, cy - 6, 18, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
ctx.stroke()
|
|
||||||
ctx.fillStyle = '#ffe9a8'
|
|
||||||
ctx.font = 'bold 11px monospace'
|
|
||||||
ctx.textAlign = 'center'
|
|
||||||
ctx.fillText(`${dbg.classCode.toUpperCase()}:${dbg.charToken.toUpperCase()} [${dbg.playerAnimMode}:${dbg.playerAnimFrame}]`, cx, cy + 35)
|
|
||||||
ctx.restore()
|
|
||||||
|
|
||||||
// 2. Draw Target Dummies (`dummy-normal`, `dummy-undead`, `dummy-demon`, `dummy-immune`)
|
|
||||||
const dummyOffsets: Record<string, { dx: number; dy: number; color: string }> = {
|
|
||||||
'dummy-normal': { dx: 0, dy: -115, color: '#e6644c' },
|
|
||||||
'dummy-undead': { dx: -145, dy: -95, color: '#b4c8e8' },
|
|
||||||
'dummy-demon': { dx: 145, dy: -95, color: '#ff8a3d' },
|
|
||||||
'dummy-immune': { dx: 255, dy: -65, color: '#c77dff' },
|
|
||||||
}
|
|
||||||
for (const tgt of dbg.targetLog) {
|
|
||||||
const off = dummyOffsets[tgt.targetId] ?? { dx: 0, dy: -115, color: '#e6644c' }
|
|
||||||
const tx = cx + off.dx
|
|
||||||
const ty = cy + off.dy
|
|
||||||
const hpRatio = Math.max(0, Math.min(1, tgt.hp / Math.max(1, tgt.maxHp)))
|
|
||||||
|
|
||||||
// Dummy shadow + body marker
|
|
||||||
ctx.fillStyle = 'rgba(0,0,0,0.55)'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.ellipse(tx, ty + 18, 22, 10, 0, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
ctx.fillStyle = off.color
|
|
||||||
ctx.strokeStyle = '#f5e6c8'
|
|
||||||
ctx.lineWidth = 2
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(tx, ty - 4, 16, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
ctx.stroke()
|
|
||||||
|
|
||||||
// HP bar
|
|
||||||
ctx.fillStyle = '#1f1914'
|
|
||||||
ctx.fillRect(tx - 44, ty - 38, 88, 9)
|
|
||||||
ctx.fillStyle = hpRatio > 0.5 ? '#c9342b' : '#f07822'
|
|
||||||
ctx.fillRect(tx - 43, ty - 37, Math.round(86 * hpRatio), 7)
|
|
||||||
|
|
||||||
ctx.font = 'bold 11px monospace'
|
|
||||||
ctx.textAlign = 'center'
|
|
||||||
ctx.fillStyle = '#f2e6d0'
|
|
||||||
ctx.fillText(`${tgt.targetId} (${tgt.hp}/${tgt.maxHp})`, tx, ty - 43)
|
|
||||||
|
|
||||||
if (tgt.lastDamage > 0) {
|
|
||||||
ctx.fillStyle = '#ffdf59'
|
|
||||||
ctx.font = 'bold 14px monospace'
|
|
||||||
ctx.fillText(`-${tgt.lastDamage}`, tx, ty - 58)
|
|
||||||
}
|
|
||||||
if (tgt.states.length > 0) {
|
|
||||||
ctx.fillStyle = '#7be3ff'
|
|
||||||
ctx.font = '10px monospace'
|
|
||||||
ctx.fillText(tgt.states.join(','), tx, ty + 32)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Draw Spawned Missiles / Projectiles / Effect Ray toward primary dummy
|
|
||||||
if (dbg.missilesSpawned > 0 || dbg.totalDamageDealt > 0) {
|
|
||||||
ctx.save()
|
|
||||||
const grad = ctx.createLinearGradient(cx, cy - 6, cx, cy - 110)
|
|
||||||
grad.addColorStop(0, 'rgba(255, 220, 110, 0.9)')
|
|
||||||
grad.addColorStop(1, 'rgba(255, 90, 40, 0.95)')
|
|
||||||
ctx.strokeStyle = grad
|
|
||||||
ctx.lineWidth = 5
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(cx, cy - 8)
|
|
||||||
ctx.lineTo(cx, cy - 108)
|
|
||||||
ctx.stroke()
|
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(255, 165, 50, 0.85)'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(cx, cy - 65, 14, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
ctx.restore()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Draw Active Summoned Pets / Traps
|
|
||||||
dbg.petsActive.forEach((pet: { monId: string; hp: number }, idx: number) => {
|
|
||||||
const px = cx - 95 + idx * 48
|
|
||||||
const py = cy + 44
|
|
||||||
ctx.fillStyle = '#5ce1e6'
|
|
||||||
ctx.strokeStyle = '#112a36'
|
|
||||||
ctx.lineWidth = 2
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(px, py, 12, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
ctx.stroke()
|
|
||||||
ctx.fillStyle = '#d7f9ff'
|
|
||||||
ctx.font = 'bold 10px monospace'
|
|
||||||
ctx.textAlign = 'center'
|
|
||||||
ctx.fillText(`${pet.monId} (${pet.hp}HP)`, px, py + 24)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 5. Top-left 1.13c Skill Verification Telemetry Panel
|
|
||||||
ctx.fillStyle = 'rgba(12, 10, 8, 0.88)'
|
|
||||||
ctx.strokeStyle = '#c8a15a'
|
|
||||||
ctx.lineWidth = 1.5
|
|
||||||
ctx.fillRect(14, 62, 470, 122)
|
|
||||||
ctx.strokeRect(14, 62, 470, 122)
|
|
||||||
|
|
||||||
ctx.textAlign = 'left'
|
|
||||||
ctx.fillStyle = '#f0c76e'
|
|
||||||
ctx.font = 'bold 13px monospace'
|
|
||||||
ctx.fillText(
|
|
||||||
`[1.13c ARENA] #${dbg.skillId} ${dbg.skillName} | Class: ${dbg.classCode.toUpperCase()} (${dbg.charToken}) | slvl ${dbg.slvl}`,
|
|
||||||
24,
|
|
||||||
82,
|
|
||||||
)
|
|
||||||
ctx.fillStyle = '#e8dcc8'
|
|
||||||
ctx.font = '12px monospace'
|
|
||||||
ctx.fillText(
|
|
||||||
`CastCount: ${dbg.castCount} | Mode: ${dbg.playerAnimMode} (Frame ${dbg.playerAnimFrame}) | ManaCost: ${dbg.tooltipStats.manaCost}`,
|
|
||||||
24,
|
|
||||||
102,
|
|
||||||
)
|
|
||||||
ctx.fillText(
|
|
||||||
`1.13c Dmg: ${dbg.tooltipStats.minDmg}-${dbg.tooltipStats.maxDmg} | Total Dealt: ${dbg.totalDamageDealt} | Missiles: ${dbg.missilesSpawned}`,
|
|
||||||
24,
|
|
||||||
122,
|
|
||||||
)
|
|
||||||
ctx.fillText(
|
|
||||||
`Pets: ${dbg.petsActive.length} | Corpses Left: ${dbg.corpsesRemaining}/3 | States: ${dbg.activeStates.slice(0, 3).join(', ') || 'NONE'}`,
|
|
||||||
24,
|
|
||||||
142,
|
|
||||||
)
|
|
||||||
ctx.fillStyle = '#8ee38e'
|
|
||||||
ctx.fillText(
|
|
||||||
`Overlays: ${dbg.activeOverlays.slice(0, 3).join(', ') || 'default'} | Status: READY`,
|
|
||||||
24,
|
|
||||||
162,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
syncWindowArena()
|
|
||||||
renderArenaVisualOverlay(arena)
|
|
||||||
} catch (err) {
|
|
||||||
;(window as unknown as { __d2webArenaError?: string }).__d2webArenaError = String(
|
|
||||||
(err as Error)?.stack || err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
|
||||||
;(window as unknown as { __initBrowserArenaMode?: (factory?: WorldArenaFactory) => Promise<void> }).__initBrowserArenaMode =
|
|
||||||
initBrowserArenaMode
|
|
||||||
if (typeof location !== 'undefined' && new URLSearchParams(location.search).get('arena') === '1') {
|
|
||||||
initBrowserArenaMode().catch((err) => {
|
|
||||||
;(window as unknown as { __d2webArenaError?: string }).__d2webArenaError = String(
|
|
||||||
(err as Error)?.stack || err,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +1,8 @@
|
||||||
import type { SceneEngineLike as GameEngine } from '../../common/units/unit-types.ts'
|
|
||||||
import {
|
|
||||||
calculateBounceHeight,
|
|
||||||
type GroundItemEntity,
|
|
||||||
} from '../../common/items/ground-items.ts'
|
|
||||||
import type { AtlasFrame } from '../render/atlas.ts'
|
|
||||||
import type {
|
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])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -1,136 +0,0 @@
|
||||||
import type { AnimatableTile, AnimatedFrame } from '../../common/world/animated-tiles.ts'
|
|
||||||
import { ADDITIVE_OBJECT_TOKENS } from '../../common/units/objects.ts'
|
|
||||||
import type { AtlasFrame } from '../render/atlas.ts'
|
|
||||||
|
|
||||||
/** One tile to draw. */
|
|
||||||
export interface Drawable extends AnimatableTile {
|
|
||||||
frame: AtlasFrame
|
|
||||||
readonly x: number
|
|
||||||
readonly y: number
|
|
||||||
readonly cellX: number
|
|
||||||
readonly cellY: number
|
|
||||||
/** Atlas page index this frame lives on. */
|
|
||||||
page: number
|
|
||||||
readonly animatedFrames?: readonly AnimatedFrame[] | undefined
|
|
||||||
readonly roofHeight?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One placed frame of an animated object. */
|
|
||||||
export interface AnimatedObjectFrame {
|
|
||||||
readonly frame: AtlasFrame
|
|
||||||
readonly page: number
|
|
||||||
readonly offsetX: number
|
|
||||||
readonly offsetY: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One object sprite to draw. */
|
|
||||||
export interface ObjectDrawable {
|
|
||||||
readonly id?: number | undefined
|
|
||||||
readonly objectsTxtId?: number | undefined
|
|
||||||
readonly token?: string | undefined
|
|
||||||
readonly name?: string | null | undefined
|
|
||||||
readonly kind?: 'object' | 'npc' | undefined
|
|
||||||
readonly blendMode?: 'normal' | 'additive' | undefined
|
|
||||||
readonly trans?: number | undefined
|
|
||||||
frame: AtlasFrame
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
readonly depth: number
|
|
||||||
/** Object atlas page index this frame lives on. */
|
|
||||||
page: number
|
|
||||||
readonly baseX?: number | undefined
|
|
||||||
readonly baseY?: number | undefined
|
|
||||||
readonly animatedFrames?: readonly AnimatedObjectFrame[] | undefined
|
|
||||||
readonly frameDurationMs?: number | undefined
|
|
||||||
readonly cycleAnim?: boolean | undefined
|
|
||||||
readonly phaseOffsetMs?: number | undefined
|
|
||||||
currentFrameIndex?: number | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tokens that represent environmental additive-blend illumination and magic objects.
|
|
||||||
* Ground truth parity (1.13c): Window light rays (11, 12 in Act 4), light beams
|
|
||||||
* (R1..R5, 1R..4R with Trans=7), arcane orifice (HA with Trans=1), and fire jets.
|
|
||||||
*/
|
|
||||||
export { ADDITIVE_OBJECT_TOKENS }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tokens that represent persistent environmental fire and illumination objects.
|
|
||||||
* Ground truth parity (1.13c): Torches (TO), candles (A1, A2, 3o/3O), and fires
|
|
||||||
* (FX, FY, FZ, BR, BF, FL) cycle continuously in ambient illumination without clamping.
|
|
||||||
*/
|
|
||||||
export const PERSISTENT_ILLUMINATION_TOKENS = new Set([
|
|
||||||
'TO', 'A1', 'A2', '3O', '3o', 'FX', 'FY', 'FZ', 'BR', 'BF', 'FL',
|
|
||||||
])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SplitMix64 based spatial phase offset for unsynchronized animated map objects.
|
|
||||||
* Prevents mechanical synchronization across multiple torches or campfires in a scene.
|
|
||||||
*
|
|
||||||
* @param x - object sub-tile or world x
|
|
||||||
* @param y - object sub-tile or world y
|
|
||||||
* @param totalDurationMs - total duration of one animation cycle in milliseconds
|
|
||||||
* @returns phase offset in milliseconds within [0, totalDurationMs)
|
|
||||||
*/
|
|
||||||
export function computeObjectPhaseOffsetMs(x: number, y: number, totalDurationMs: number): number {
|
|
||||||
if (totalDurationMs <= 0) return 0
|
|
||||||
let h = (Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) >>> 0
|
|
||||||
h = (h ^ (h >>> 16)) >>> 0
|
|
||||||
h = Math.imul(h, 0x45d9f3b) >>> 0
|
|
||||||
h = (h ^ (h >>> 16)) >>> 0
|
|
||||||
return ((h % 1000000) / 1000000) * totalDurationMs
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update one animated object's current frame based on time and spatial phase.
|
|
||||||
*
|
|
||||||
* @param obj - object drawable
|
|
||||||
* @param clockMs - current clock time in milliseconds
|
|
||||||
* @returns true if frame changed
|
|
||||||
*/
|
|
||||||
export function updateAnimatedObject(obj: ObjectDrawable, clockMs: number): boolean {
|
|
||||||
const frames = obj.animatedFrames
|
|
||||||
if (!frames || frames.length <= 1) return false
|
|
||||||
const duration = (obj.frameDurationMs && obj.frameDurationMs > 0) ? obj.frameDurationMs : 40
|
|
||||||
const phase = obj.phaseOffsetMs ?? 0
|
|
||||||
const elapsed = Math.max(0, clockMs + phase)
|
|
||||||
const isPersistent = obj.token !== undefined && PERSISTENT_ILLUMINATION_TOKENS.has(obj.token.trim().toUpperCase())
|
|
||||||
const cycleAnim = isPersistent || obj.cycleAnim !== false
|
|
||||||
const targetIndex = cycleAnim
|
|
||||||
? Math.floor(elapsed / duration) % frames.length
|
|
||||||
: Math.min(frames.length - 1, Math.floor(elapsed / duration))
|
|
||||||
|
|
||||||
if (targetIndex === obj.currentFrameIndex) return false
|
|
||||||
obj.currentFrameIndex = targetIndex
|
|
||||||
const targetFrame = frames[targetIndex]
|
|
||||||
if (targetFrame !== undefined) {
|
|
||||||
obj.frame = targetFrame.frame
|
|
||||||
obj.page = targetFrame.page
|
|
||||||
if (obj.baseX !== undefined && obj.baseY !== undefined) {
|
|
||||||
obj.x = obj.baseX + targetFrame.offsetX
|
|
||||||
obj.y = obj.baseY + targetFrame.offsetY
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an array of animated objects according to millisecond clock.
|
|
||||||
*
|
|
||||||
* @param objects - array of object drawables
|
|
||||||
* @param clockMs - current clock time in milliseconds
|
|
||||||
* @returns number of objects whose active frame changed
|
|
||||||
*/
|
|
||||||
export function updateAnimatedObjects(
|
|
||||||
objects: readonly ObjectDrawable[],
|
|
||||||
clockMs: number,
|
|
||||||
): number {
|
|
||||||
let updated = 0
|
|
||||||
for (let i = 0; i < objects.length; i += 1) {
|
|
||||||
if (updateAnimatedObject(objects[i]!, clockMs)) {
|
|
||||||
updated += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
@ -1,462 +0,0 @@
|
||||||
import { type AudioManager, sharedAudioManager } from '../audio/manager.ts'
|
|
||||||
import type { Monster, SceneEngineLike as GameEngine } from '../../common/units/unit-types.ts'
|
|
||||||
import type { GroundItemEntity } from '../../common/items/ground-items.ts'
|
|
||||||
import { getLevelNames, levelDisplayName } from '../../common/world/level-names-zh.ts'
|
|
||||||
import { getTownNpcDescriptorByName, npcDisplayName } from '../../common/units/npc-table.ts'
|
|
||||||
import type { TownPortalSlot } from '../../common/world/portal.ts'
|
|
||||||
import { getTblLang } from '../i18n/lang.ts'
|
|
||||||
import type { ViewportCamera } from '../sim/camera.ts'
|
|
||||||
import {
|
|
||||||
computeInitialGroundLabelLayouts,
|
|
||||||
resolveLadderCollisions,
|
|
||||||
type GroundLabelOverlay,
|
|
||||||
} from '../ui/ground-labels.ts'
|
|
||||||
import type { HudManager } from '../ui/hud-manager.ts'
|
|
||||||
import {
|
|
||||||
getEntranceLabelWorldPos,
|
|
||||||
getWarpLabelWorldPos,
|
|
||||||
screenToWorld,
|
|
||||||
subTileToScene,
|
|
||||||
type SceneEntrance,
|
|
||||||
type SceneWarp,
|
|
||||||
} from '../../common/world/scene-links-nav.ts'
|
|
||||||
import { MARKER_HEIGHT, type MapRuntime } from './pack-loader.ts'
|
|
||||||
import {
|
|
||||||
monsterArtStatusText,
|
|
||||||
monsterPlanStatusText,
|
|
||||||
} from './scene-state.ts'
|
|
||||||
|
|
||||||
export { monsterArtStatusText, monsterPlanStatusText }
|
|
||||||
|
|
||||||
export interface NpcLabelProjectionParams {
|
|
||||||
npc: {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
frame?: {
|
|
||||||
readonly width?: number
|
|
||||||
readonly height?: number
|
|
||||||
readonly offsetX?: number
|
|
||||||
readonly offsetY?: number
|
|
||||||
} | null | undefined
|
|
||||||
}
|
|
||||||
camX: number
|
|
||||||
camY: number
|
|
||||||
cameraZoom: number
|
|
||||||
canvas: { width: number; height: number }
|
|
||||||
rect: { left: number; top: number; width: number; height: number }
|
|
||||||
markerHeight?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NpcLabelProjectionResult {
|
|
||||||
domX: number
|
|
||||||
domY: number
|
|
||||||
fontSize: number
|
|
||||||
visible: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function projectNpcLabel(params: NpcLabelProjectionParams): NpcLabelProjectionResult {
|
|
||||||
const markerHeight = params.markerHeight ?? MARKER_HEIGHT
|
|
||||||
const scaleX = params.canvas.width > 0 ? params.rect.width / params.canvas.width : 1
|
|
||||||
const scaleY = params.canvas.height > 0 ? params.rect.height / params.canvas.height : 1
|
|
||||||
|
|
||||||
const frame = params.npc.frame
|
|
||||||
const topX = frame != null
|
|
||||||
? params.npc.x + (frame.offsetX ?? 0) + (frame.width ?? 0) / 2
|
|
||||||
: params.npc.x
|
|
||||||
const topY = frame != null
|
|
||||||
? params.npc.y + (frame.offsetY ?? 0)
|
|
||||||
: params.npc.y - markerHeight
|
|
||||||
|
|
||||||
const domX = params.rect.left + params.rect.width * 0.5 + (topX - params.camX) * params.cameraZoom * scaleX
|
|
||||||
const domY = params.rect.top + params.rect.height * 0.5 + (topY - params.camY) * params.cameraZoom * scaleY
|
|
||||||
|
|
||||||
const visible = domX > -100 && domX < params.rect.width + 100 && domY > -100 && domY < params.rect.height + 100
|
|
||||||
const safeZoom = Math.max(0.001, Number.isFinite(params.cameraZoom) ? params.cameraZoom : 1)
|
|
||||||
const fontSize = Math.min(24, Math.max(11, Math.round(13 * Math.sqrt(safeZoom))))
|
|
||||||
|
|
||||||
return { domX, domY: domY - 2, fontSize, visible }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Play synthesized Diablo II-style audio rejection feedback when inventory is full.
|
|
||||||
* Low descending refusal tone paired with delayed secondary touch landing thud.
|
|
||||||
*/
|
|
||||||
export function playInventoryFullFeedback(audioManager: AudioManager = sharedAudioManager): void {
|
|
||||||
if (typeof audioManager?.playSfx === 'function') {
|
|
||||||
audioManager.playSfx('inventory_full')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
audioManager?.playTone?.({
|
|
||||||
type: 'sawtooth',
|
|
||||||
from: 175,
|
|
||||||
to: 90,
|
|
||||||
gain: 0.3,
|
|
||||||
durationS: 0.3,
|
|
||||||
delayS: 0,
|
|
||||||
})
|
|
||||||
audioManager?.playTone?.({
|
|
||||||
type: 'triangle',
|
|
||||||
from: 110,
|
|
||||||
to: 45,
|
|
||||||
gain: 0.2,
|
|
||||||
durationS: 0.1,
|
|
||||||
delayS: 0.35,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display prominent Diablo II style on-screen notification banner with auto fade-out.
|
|
||||||
*/
|
|
||||||
export function showNotification(message: string, durationMs = 2000, status?: HTMLElement): void {
|
|
||||||
if (status) {
|
|
||||||
status.textContent = message
|
|
||||||
}
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
let banner = document.getElementById('d2-center-notification')
|
|
||||||
if (!banner && document.body) {
|
|
||||||
banner = document.createElement('div')
|
|
||||||
banner.id = 'd2-center-notification'
|
|
||||||
banner.style.position = 'fixed'
|
|
||||||
banner.style.top = '18%'
|
|
||||||
banner.style.left = '50%'
|
|
||||||
banner.style.transform = 'translateX(-50%)'
|
|
||||||
banner.style.color = '#e03030'
|
|
||||||
banner.style.fontFamily = 'var(--font-display, "D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif)'
|
|
||||||
banner.style.fontSize = '20px'
|
|
||||||
banner.style.fontWeight = 'bold'
|
|
||||||
banner.style.textShadow = '0 0 8px rgba(0,0,0,0.9), 1px 1px 2px #000'
|
|
||||||
banner.style.pointerEvents = 'none'
|
|
||||||
banner.style.zIndex = '99999'
|
|
||||||
banner.style.transition = 'opacity 0.4s ease-out'
|
|
||||||
document.body.appendChild(banner)
|
|
||||||
}
|
|
||||||
if (banner) {
|
|
||||||
banner.textContent = message
|
|
||||||
banner.style.opacity = '1'
|
|
||||||
clearTimeout((banner as any)._timeout)
|
|
||||||
;(banner as any)._timeout = setTimeout(() => {
|
|
||||||
if (banner) banner.style.opacity = '0'
|
|
||||||
}, durationMs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
;(globalThis as unknown as {
|
|
||||||
__d2PlayInventoryFullFeedback?: () => void
|
|
||||||
__d2ShowNotification?: (message: string, durationMs?: number, status?: unknown) => void
|
|
||||||
}).__d2PlayInventoryFullFeedback = playInventoryFullFeedback
|
|
||||||
;(globalThis as unknown as {
|
|
||||||
__d2ShowNotification?: (message: string, durationMs?: number, status?: unknown) => void
|
|
||||||
}).__d2ShowNotification = (message: string, durationMs = 2000, status?: unknown) => {
|
|
||||||
showNotification(message, durationMs, status as HTMLElement | undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function allocateLabelElement(
|
|
||||||
container: HTMLElement,
|
|
||||||
labelPool: HTMLDivElement[],
|
|
||||||
): HTMLDivElement {
|
|
||||||
let label: HTMLDivElement
|
|
||||||
if (typeof document !== 'undefined' && typeof document.createElement === 'function') {
|
|
||||||
label = document.createElement('div')
|
|
||||||
} else {
|
|
||||||
label = {
|
|
||||||
tagName: 'DIV',
|
|
||||||
textContent: '',
|
|
||||||
hidden: false,
|
|
||||||
className: '',
|
|
||||||
dataset: {},
|
|
||||||
style: {},
|
|
||||||
} as unknown as HTMLDivElement
|
|
||||||
}
|
|
||||||
label.style.position = 'absolute'
|
|
||||||
label.style.transform = 'translate(-50%, -100%)'
|
|
||||||
label.style.textShadow = '1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000'
|
|
||||||
label.style.whiteSpace = 'nowrap'
|
|
||||||
label.style.pointerEvents = 'none'
|
|
||||||
labelPool.push(label)
|
|
||||||
if (typeof container.appendChild === 'function') {
|
|
||||||
container.appendChild(label)
|
|
||||||
} else if (Array.isArray((container as any).children)) {
|
|
||||||
;(container as any).children.push(label)
|
|
||||||
}
|
|
||||||
return label
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverheadLabelsControllerHost {
|
|
||||||
readonly labelsContainer?: HTMLElement | null | undefined
|
|
||||||
readonly canvas: HTMLCanvasElement
|
|
||||||
readonly camera: ViewportCamera
|
|
||||||
readonly engine: GameEngine
|
|
||||||
readonly hudManager: HudManager | null
|
|
||||||
readonly portalSlot?: TownPortalSlot | undefined
|
|
||||||
readonly labelPool: HTMLDivElement[]
|
|
||||||
lastClientPos: { x: number; y: number } | null
|
|
||||||
hoveredMonster: Monster | null
|
|
||||||
hoveredGroundItem: GroundItemEntity | null
|
|
||||||
hoveredWarp: SceneWarp | null
|
|
||||||
hoveredEntrance: SceneEntrance | null
|
|
||||||
getRuntime(): MapRuntime
|
|
||||||
findMonsterAtWorld(worldX: number, worldY: number): Monster | null
|
|
||||||
syncLinkHoverAtWorld(worldX: number, worldY: number): void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render recycled overhead labels for on-screen NPCs, Town Portals,
|
|
||||||
* `runtime.warps`, and `runtime.entrances` into `labelsContainer`.
|
|
||||||
*/
|
|
||||||
export function updateOverheadLabels(
|
|
||||||
host: OverheadLabelsControllerHost,
|
|
||||||
camX = host.engine.world.player.x,
|
|
||||||
camY = host.engine.world.player.y - 16,
|
|
||||||
activePortalMouthOverride?: { readonly x: number; readonly y: number; readonly toLevelId: number } | null,
|
|
||||||
activePortalPosOverride?: { readonly x: number; readonly y: number } | null,
|
|
||||||
): void {
|
|
||||||
const container = host.labelsContainer
|
|
||||||
if (!container) return
|
|
||||||
|
|
||||||
const runtime = host.getRuntime()
|
|
||||||
let labelCount = 0
|
|
||||||
const rect = host.canvas.getBoundingClientRect()
|
|
||||||
const scaleX = host.canvas.width > 0 ? rect.width / host.canvas.width : 1
|
|
||||||
const scaleY = host.canvas.height > 0 ? rect.height / host.canvas.height : 1
|
|
||||||
const zoom = host.camera?.zoom ?? 1
|
|
||||||
const fontSizePx = `${Math.min(24, Math.max(11, Math.round(13 * Math.sqrt(Math.max(0.001, zoom)))))}px`
|
|
||||||
|
|
||||||
if (host.lastClientPos !== null) {
|
|
||||||
let hudIntercepted = false
|
|
||||||
if (host.hudManager !== null) {
|
|
||||||
const logical = host.hudManager.clientToLogical(host.lastClientPos.x, host.lastClientPos.y)
|
|
||||||
hudIntercepted = host.hudManager.isPointInterceptedByHud(logical.x, logical.y)
|
|
||||||
}
|
|
||||||
if (hudIntercepted) {
|
|
||||||
host.hoveredMonster = null
|
|
||||||
host.hoveredGroundItem = null
|
|
||||||
host.hoveredWarp = null
|
|
||||||
host.hoveredEntrance = null
|
|
||||||
if (host.canvas?.style) {
|
|
||||||
host.canvas.style.cursor = ''
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const worldAtPointer = screenToWorld({
|
|
||||||
clientX: host.lastClientPos.x,
|
|
||||||
clientY: host.lastClientPos.y,
|
|
||||||
rect,
|
|
||||||
canvas: host.canvas,
|
|
||||||
camX,
|
|
||||||
camY,
|
|
||||||
cameraZoom: zoom,
|
|
||||||
})
|
|
||||||
host.hoveredMonster = host.findMonsterAtWorld(worldAtPointer.x, worldAtPointer.y)
|
|
||||||
host.hoveredGroundItem = host.engine.groundItems?.findAtPoint(worldAtPointer.x, worldAtPointer.y, 32) ?? null
|
|
||||||
host.syncLinkHoverAtWorld(worldAtPointer.x, worldAtPointer.y)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeNpcMenuName = host.hudManager?.worldPanels?.npcMenu?.npcName?.toLowerCase()
|
|
||||||
const currentLang = getTblLang()
|
|
||||||
const actIndexForNpc = Math.max(0, (runtime?.act ?? 1) - 1)
|
|
||||||
for (const npc of host.engine.npcEntities ?? []) {
|
|
||||||
if (npc.def.name.toLowerCase() === 'dummy') continue
|
|
||||||
const npcDesc = getTownNpcDescriptorByName(npc.def.name, actIndexForNpc)
|
|
||||||
const npcLabelText = npcDesc ? npcDisplayName(npcDesc, currentLang) : npc.def.name
|
|
||||||
if (
|
|
||||||
activeNpcMenuName &&
|
|
||||||
(npc.def.name.toLowerCase() === activeNpcMenuName || npcLabelText.toLowerCase() === activeNpcMenuName)
|
|
||||||
) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const topX = npc.frame ? npc.x + (npc.frame.offsetX ?? 0) + (npc.frame.width ?? 0) / 2 : npc.x
|
|
||||||
const topY = npc.frame ? npc.y + (npc.frame.offsetY ?? 0) : npc.y - MARKER_HEIGHT
|
|
||||||
const domX = rect.left + rect.width * 0.5 + (topX - camX) * zoom * scaleX
|
|
||||||
const domY = rect.top + rect.height * 0.5 + (topY - camY) * zoom * scaleY
|
|
||||||
|
|
||||||
if (domX <= -100 || domX >= rect.width + 100) continue
|
|
||||||
if (domY <= -100 || domY >= rect.height + 100) continue
|
|
||||||
const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool)
|
|
||||||
if (label.textContent !== npcLabelText) label.textContent = npcLabelText
|
|
||||||
label.className = 'd2-overhead-label d2-npc-label'
|
|
||||||
if (label.dataset) {
|
|
||||||
label.dataset.kind = 'npc'
|
|
||||||
delete label.dataset.toLevelId
|
|
||||||
delete label.dataset.hovered
|
|
||||||
}
|
|
||||||
label.style.color = '#c8a15a'
|
|
||||||
label.style.background = 'transparent'
|
|
||||||
label.style.border = 'none'
|
|
||||||
label.style.padding = '0'
|
|
||||||
label.style.borderRadius = '0'
|
|
||||||
label.style.fontWeight = 'normal'
|
|
||||||
label.style.boxShadow = 'none'
|
|
||||||
label.style.left = `${domX}px`
|
|
||||||
label.style.top = `${domY - 2}px`
|
|
||||||
label.style.fontSize = fontSizePx
|
|
||||||
label.hidden = false
|
|
||||||
labelCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const portalMouth = activePortalMouthOverride !== undefined
|
|
||||||
? activePortalMouthOverride
|
|
||||||
: (host.portalSlot && runtime ? host.portalSlot.mouthInLevel(runtime.levelId) : null)
|
|
||||||
const portalPos = activePortalPosOverride !== undefined
|
|
||||||
? activePortalPosOverride
|
|
||||||
: (portalMouth && runtime?.grid ? subTileToScene(portalMouth.x, portalMouth.y, runtime.grid) : null)
|
|
||||||
|
|
||||||
if (portalMouth !== null && portalPos !== null) {
|
|
||||||
const topX = portalPos.x
|
|
||||||
const topY = portalPos.y - 94
|
|
||||||
const domX = rect.left + rect.width * 0.5 + (topX - camX) * zoom * scaleX
|
|
||||||
const domY = rect.top + rect.height * 0.5 + (topY - camY) * zoom * scaleY
|
|
||||||
if (domX > -100 && domX < rect.width + 100 && domY > -100 && domY < rect.height + 100) {
|
|
||||||
const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool)
|
|
||||||
const portalLabelText = currentLang === 'ENG'
|
|
||||||
? `Town Portal -> ${levelDisplayName(portalMouth.toLevelId, 'ENG')}`
|
|
||||||
: `传送门 -> ${getLevelNames(portalMouth.toLevelId).nameZh}`
|
|
||||||
if (label.textContent !== portalLabelText) label.textContent = portalLabelText
|
|
||||||
label.className = 'd2-overhead-label d2-portal-label'
|
|
||||||
if (label.dataset) {
|
|
||||||
label.dataset.kind = 'portal'
|
|
||||||
label.dataset.toLevelId = String(portalMouth.toLevelId)
|
|
||||||
delete label.dataset.hovered
|
|
||||||
}
|
|
||||||
label.style.color = '#6cb4ff'
|
|
||||||
label.style.background = 'transparent'
|
|
||||||
label.style.border = 'none'
|
|
||||||
label.style.padding = '0'
|
|
||||||
label.style.borderRadius = '0'
|
|
||||||
label.style.fontWeight = 'normal'
|
|
||||||
label.style.boxShadow = 'none'
|
|
||||||
label.style.left = `${domX}px`
|
|
||||||
label.style.top = `${domY - 2}px`
|
|
||||||
label.style.fontSize = fontSizePx
|
|
||||||
label.hidden = false
|
|
||||||
labelCount += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (runtime?.grid) {
|
|
||||||
for (const warp of runtime.warps ?? []) {
|
|
||||||
if (!(warp.toLevelId > 0)) continue
|
|
||||||
const pos = getWarpLabelWorldPos(warp, runtime.grid)
|
|
||||||
const domX = rect.left + rect.width * 0.5 + (pos.x - camX) * zoom * scaleX
|
|
||||||
const domY = rect.top + rect.height * 0.5 + (pos.y - camY) * zoom * scaleY
|
|
||||||
if (domX <= -100 || domX >= rect.width + 100 || domY <= -100 || domY >= rect.height + 100) continue
|
|
||||||
const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool)
|
|
||||||
const badgeText = currentLang === 'ENG'
|
|
||||||
? `To ${levelDisplayName(warp.toLevelId, 'ENG')}`
|
|
||||||
: `通往 ${getLevelNames(warp.toLevelId).nameZh}`
|
|
||||||
if (label.textContent !== badgeText) label.textContent = badgeText
|
|
||||||
const isHovered = host.hoveredWarp === warp
|
|
||||||
label.className = isHovered
|
|
||||||
? 'd2-overhead-label d2-exit-badge is-hovered'
|
|
||||||
: 'd2-overhead-label d2-exit-badge'
|
|
||||||
if (label.dataset) {
|
|
||||||
label.dataset.kind = 'warp'
|
|
||||||
label.dataset.toLevelId = String(warp.toLevelId)
|
|
||||||
label.dataset.hovered = isHovered ? 'true' : 'false'
|
|
||||||
}
|
|
||||||
label.style.color = isHovered ? '#fff3c4' : '#f0d080'
|
|
||||||
label.style.background = isHovered ? 'rgba(44, 32, 12, 0.92)' : 'rgba(12, 10, 8, 0.78)'
|
|
||||||
label.style.border = isHovered ? '1px solid #ffd86b' : '1px solid rgba(200, 161, 90, 0.65)'
|
|
||||||
label.style.padding = '2px 7px'
|
|
||||||
label.style.borderRadius = '3px'
|
|
||||||
label.style.fontWeight = isHovered ? 'bold' : 'normal'
|
|
||||||
label.style.boxShadow = isHovered ? '0 0 8px rgba(255, 210, 90, 0.65)' : 'none'
|
|
||||||
label.style.left = `${domX}px`
|
|
||||||
label.style.top = `${domY - 2}px`
|
|
||||||
label.style.fontSize = fontSizePx
|
|
||||||
label.hidden = false
|
|
||||||
labelCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entrance of runtime.entrances ?? []) {
|
|
||||||
if (!(entrance.toLevelId > 0)) continue
|
|
||||||
const pos = getEntranceLabelWorldPos(entrance, runtime.grid)
|
|
||||||
const domX = rect.left + rect.width * 0.5 + (pos.x - camX) * zoom * scaleX
|
|
||||||
const domY = rect.top + rect.height * 0.5 + (pos.y - camY) * zoom * scaleY
|
|
||||||
if (domX <= -100 || domX >= rect.width + 100 || domY <= -100 || domY >= rect.height + 100) continue
|
|
||||||
const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool)
|
|
||||||
const badgeText = currentLang === 'ENG'
|
|
||||||
? `To ${levelDisplayName(entrance.toLevelId, 'ENG')}`
|
|
||||||
: `通往 ${getLevelNames(entrance.toLevelId).nameZh}`
|
|
||||||
if (label.textContent !== badgeText) label.textContent = badgeText
|
|
||||||
const isHovered = host.hoveredEntrance === entrance
|
|
||||||
label.className = isHovered
|
|
||||||
? 'd2-overhead-label d2-exit-badge is-hovered'
|
|
||||||
: 'd2-overhead-label d2-exit-badge'
|
|
||||||
if (label.dataset) {
|
|
||||||
label.dataset.kind = 'entrance'
|
|
||||||
label.dataset.toLevelId = String(entrance.toLevelId)
|
|
||||||
label.dataset.hovered = isHovered ? 'true' : 'false'
|
|
||||||
}
|
|
||||||
label.style.color = isHovered ? '#fff3c4' : '#f0d080'
|
|
||||||
label.style.background = isHovered ? 'rgba(44, 32, 12, 0.92)' : 'rgba(12, 10, 8, 0.78)'
|
|
||||||
label.style.border = isHovered ? '1px solid #ffd86b' : '1px solid rgba(200, 161, 90, 0.65)'
|
|
||||||
label.style.padding = '2px 7px'
|
|
||||||
label.style.borderRadius = '3px'
|
|
||||||
label.style.fontWeight = isHovered ? 'bold' : 'normal'
|
|
||||||
label.style.boxShadow = isHovered ? '0 0 8px rgba(255, 210, 90, 0.65)' : 'none'
|
|
||||||
label.style.left = `${domX}px`
|
|
||||||
label.style.top = `${domY - 2}px`
|
|
||||||
label.style.fontSize = fontSizePx
|
|
||||||
label.hidden = false
|
|
||||||
labelCount += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let index = labelCount; index < host.labelPool.length; index += 1) {
|
|
||||||
const unused = host.labelPool[index]!
|
|
||||||
unused.hidden = true
|
|
||||||
if (unused.dataset && unused.dataset.hovered === 'true') {
|
|
||||||
unused.dataset.hovered = 'false'
|
|
||||||
}
|
|
||||||
if (unused.className.includes('is-hovered')) {
|
|
||||||
unused.className = unused.className.replace(/\s*is-hovered\b/g, '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Project and update floating ground item labels (Alt mode or single hovered item).
|
|
||||||
*/
|
|
||||||
export function updateGroundItemLabelsOverlay(
|
|
||||||
groundLabelOverlay: GroundLabelOverlay | null,
|
|
||||||
hudManager: HudManager | null,
|
|
||||||
mouseController: { hoveredGroundItem: GroundItemEntity | null } | null,
|
|
||||||
engine: GameEngine,
|
|
||||||
canvas: HTMLCanvasElement,
|
|
||||||
camera: ViewportCamera,
|
|
||||||
camX: number,
|
|
||||||
camY: number,
|
|
||||||
): void {
|
|
||||||
if (groundLabelOverlay === null) return
|
|
||||||
const showLabels = Boolean(hudManager?.showGroundLabels)
|
|
||||||
let hoveredItem = mouseController?.hoveredGroundItem ?? null
|
|
||||||
if (hoveredItem !== null && !engine.groundItems.get(hoveredItem.id)) {
|
|
||||||
if (mouseController) mouseController.hoveredGroundItem = null
|
|
||||||
hoveredItem = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (engine.groundItems.count > 0 && (showLabels || hoveredItem !== null)) {
|
|
||||||
const rect = canvas.getBoundingClientRect()
|
|
||||||
const scaleX = canvas.width > 0 ? rect.width / canvas.width : 1
|
|
||||||
const scaleY = canvas.height > 0 ? rect.height / canvas.height : 1
|
|
||||||
const projectToScreen = (worldX: number, worldY: number) => {
|
|
||||||
const domX = rect.left + rect.width * 0.5 + (worldX - camX) * camera.zoom * scaleX
|
|
||||||
const domY = rect.top + rect.height * 0.5 + (worldY - camY) * camera.zoom * scaleY
|
|
||||||
if (domX < -150 || domX > rect.width + 150 || domY < -150 || domY > rect.height + 150) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return { x: domX, y: domY }
|
|
||||||
}
|
|
||||||
if (showLabels) {
|
|
||||||
const initialLayouts = computeInitialGroundLabelLayouts(engine.groundItems.all, projectToScreen)
|
|
||||||
const resolvedLayouts = resolveLadderCollisions(initialLayouts)
|
|
||||||
groundLabelOverlay.update(true, resolvedLayouts)
|
|
||||||
} else if (hoveredItem !== null) {
|
|
||||||
const initialLayouts = computeInitialGroundLabelLayouts([hoveredItem], projectToScreen)
|
|
||||||
groundLabelOverlay.update(true, initialLayouts)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
groundLabelOverlay.update(false, [])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,406 +0,0 @@
|
||||||
import { DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS } from '../../common/world/animated-tiles.ts'
|
|
||||||
import type { SorceressAppearanceSpec } from '../../common/units/char-appearance.ts'
|
|
||||||
import type { Monster, MonsterStats, NpcEntity } from '../../common/units/unit-types.ts'
|
|
||||||
import { getEmbeddedDropTables } from '../../common/items/embedded-drop-tables.ts'
|
|
||||||
import { enrichMonsterStats } from '../../common/units/monsters-data.ts'
|
|
||||||
import type { OpenPortal } from '../../common/world/portal.ts'
|
|
||||||
import { getTblLang, type TblLang } from '../i18n/lang.ts'
|
|
||||||
import type {
|
|
||||||
AutomapOverlayLabel,
|
|
||||||
AutomapViewportMetrics,
|
|
||||||
Minimap,
|
|
||||||
} from '../ui/minimap.ts'
|
|
||||||
import type { MonsterTargetHudInfo } from '../ui/world-panels.ts'
|
|
||||||
import type { DensityMultiplier, EliteMultiplier } from '../../common/world/scene-population.ts'
|
|
||||||
import type { LightingPreset } from './lighting-presets.ts'
|
|
||||||
import type { MapRuntime } from './pack-loader.ts'
|
|
||||||
import { countLoadedPages } from './pack-loader.ts'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `localStorage` key holding the single save slot.
|
|
||||||
*
|
|
||||||
* Deliberately the same key the retired `map.html` sandbox used, so a save made
|
|
||||||
* there still loads here — the snapshot format never differed, only the scene
|
|
||||||
* that drove it.
|
|
||||||
*/
|
|
||||||
export const SAVE_KEY = 'd2web.save'
|
|
||||||
/**
|
|
||||||
* How often the HUD text is rewritten, in milliseconds.
|
|
||||||
*
|
|
||||||
* The overlay reports averages, so refreshing it at frame rate only buys string
|
|
||||||
* churn: four `toFixed` calls and ten concatenations that the reader cannot
|
|
||||||
* follow anyway.
|
|
||||||
*/
|
|
||||||
export const HUD_INTERVAL_MS = 250
|
|
||||||
|
|
||||||
/** Live scene state, also published for automated checks. */
|
|
||||||
export interface ActSceneState {
|
|
||||||
ready: boolean
|
|
||||||
/** Where the map came from: a prebaked pack or the archives. */
|
|
||||||
source: 'pack' | 'live' | ''
|
|
||||||
act: number
|
|
||||||
level: string
|
|
||||||
variant: string
|
|
||||||
variants: string[]
|
|
||||||
/** @deprecated Use `variant` instead. */
|
|
||||||
quadrant: string
|
|
||||||
/** @deprecated Use `variants` instead. */
|
|
||||||
quadrants: string[]
|
|
||||||
/** The archive/pack location actually used. */
|
|
||||||
base: string
|
|
||||||
cellsX: number
|
|
||||||
cellsY: number
|
|
||||||
widthPx: number
|
|
||||||
heightPx: number
|
|
||||||
floors: number
|
|
||||||
shadows?: number
|
|
||||||
walls: number
|
|
||||||
frames: number
|
|
||||||
/** Atlas pages loaded and total. */
|
|
||||||
pagesLoaded: number
|
|
||||||
pagesTotal: number
|
|
||||||
/** Pages that had arrived when the first frame was drawn. */
|
|
||||||
pagesAtFirstFrame: number
|
|
||||||
/** Milliseconds from boot to the first drawn frame. */
|
|
||||||
firstFrameMs: number
|
|
||||||
/** Page indices the spawn viewport needed, for diagnostics. */
|
|
||||||
priorityPages: number[]
|
|
||||||
/** Draws skipped because their page had not arrived yet. */
|
|
||||||
skippedDraws: number
|
|
||||||
/** Objects the pack places (their art still needs the DCC/COF decoder). */
|
|
||||||
objects: number
|
|
||||||
/** Roof draws, painted in their own pass after the character. */
|
|
||||||
roofs: number
|
|
||||||
/** Roof opacity alpha (0.0..1.0), faded dynamically when player enters shelter. */
|
|
||||||
roofAlpha: number
|
|
||||||
/** Whether the composited Sorceress sprite is in use (else the marker). */
|
|
||||||
character: boolean
|
|
||||||
/** DCC members that went into the character sprite. */
|
|
||||||
characterMembers: number
|
|
||||||
/** Frames the character path has actually drawn; 0 while the marker is in use. */
|
|
||||||
characterFrames: number
|
|
||||||
/** Last group the character path selected (walk or stand, times 8 directions). */
|
|
||||||
characterGroup: number
|
|
||||||
/** Active hero class token (e.g. 'so', 'am', 'ne', 'pa', 'ba', 'dz', 'ai'). */
|
|
||||||
heroToken?: string
|
|
||||||
/** Active hero class English name (e.g. 'Sorceress', 'Barbarian'). */
|
|
||||||
heroName?: string
|
|
||||||
/** Bounding box of the player sprite drawn this frame in scene coordinates. */
|
|
||||||
playerDrawRect?: { x: number; y: number; w: number; h: number } | undefined
|
|
||||||
/** Equipped or active weapon class code (e.g. 'hth', '1hs', '2hs', 'stf', 'orb', 'bow', 'xbw'). */
|
|
||||||
weaponClass?: string | null | undefined
|
|
||||||
/** Current animation clip mode name of the player (e.g. 'nu', 'wl', 'rn', 'a1', 'sc'). */
|
|
||||||
playerClip?: string | null | undefined
|
|
||||||
/** Canonical appearance cache key for dynamic modular paperdoll compositing. */
|
|
||||||
characterAppearanceKey?: string | undefined
|
|
||||||
/** Active Sorceress 16-component appearance and palette shift spec. */
|
|
||||||
characterAppearance?: SorceressAppearanceSpec | undefined
|
|
||||||
/** Milliseconds from boot to the first rendered frame. */
|
|
||||||
loadMs: number
|
|
||||||
missing: number
|
|
||||||
walkable: number
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
facing: number
|
|
||||||
tick: number
|
|
||||||
tickRate: number
|
|
||||||
simTps: number
|
|
||||||
renderFps: number
|
|
||||||
fps: number
|
|
||||||
/**
|
|
||||||
* GPU draw calls the last frame actually issued.
|
|
||||||
*/
|
|
||||||
drawCalls: number
|
|
||||||
/** Quads the last frame submitted, i.e. what survived culling. */
|
|
||||||
quadsDrawn: number
|
|
||||||
/** Draws rejected last frame because their bounds fell outside the viewport. */
|
|
||||||
culledDraws: number
|
|
||||||
/** Wall-clock milliseconds the last `onRender` took. */
|
|
||||||
renderMs: number
|
|
||||||
/** Active lighting preset id ('auto', 'noon', 'morning', 'sunset', 'night', 'cave', 'hell', 'eclipse', 'off'). */
|
|
||||||
lightingPreset?: LightingPreset | undefined
|
|
||||||
/** Active lighting mode ('Glide 1.13c' or undefined). */
|
|
||||||
lightingMode?: string | undefined
|
|
||||||
/** Ambient light intensity [0..255]. */
|
|
||||||
ambientIntensity?: number | undefined
|
|
||||||
/** Ambient color [R, G, B] in [0..255]. */
|
|
||||||
ambientColor?: readonly [number, number, number] | undefined
|
|
||||||
/** Current zoom factor and the canvas backing-store size. */
|
|
||||||
zoom: number
|
|
||||||
canvasWidth: number
|
|
||||||
canvasHeight: number
|
|
||||||
error: string | null
|
|
||||||
dialog: string[]
|
|
||||||
npcs: number
|
|
||||||
npcsNear: NpcEntity[]
|
|
||||||
/** Count of animated tiles in the active map. */
|
|
||||||
animatedTiles: number
|
|
||||||
/** Count of animated map objects in the active map. */
|
|
||||||
animatedObjects: number
|
|
||||||
/** Animation speed from LvlPrest.txt `Animate` column (or undefined if not specified). */
|
|
||||||
animSpeed?: number | undefined
|
|
||||||
/** Per-frame duration in ms computed from animSpeed. */
|
|
||||||
frameDurationMs?: number | undefined
|
|
||||||
/** Monster ids the art loader could not dress, lower case. */
|
|
||||||
missingMonsterArt: string[]
|
|
||||||
/** Monster-art loads that threw: a whole level, or one monster type. */
|
|
||||||
monsterArtErrors: number
|
|
||||||
/** COF layers dropped inside art that did load: monsters missing body parts. */
|
|
||||||
monsterArtLayerFailures: number
|
|
||||||
/** Monsters the level's own plan produced. */
|
|
||||||
monstersPlanned: number
|
|
||||||
/** Active monster density multiplier (0.5, 1, 2, 4, 8). */
|
|
||||||
densityMultiplier?: DensityMultiplier | undefined
|
|
||||||
/** Active elite chance multiplier (0.5, 1, 2, 4, 'all'). */
|
|
||||||
eliteMultiplier?: EliteMultiplier | undefined
|
|
||||||
/** Dynamic monster re-budget and re-stream method without page reload. */
|
|
||||||
reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise<void> | void
|
|
||||||
/** Active player Town Portal (if open). */
|
|
||||||
portal?: OpenPortal | null | undefined
|
|
||||||
/** Whether the automap/minimap is visible. */
|
|
||||||
minimapVisible?: boolean | undefined
|
|
||||||
/** Active automap mode ('minimap' | 'full'). */
|
|
||||||
automapMode?: 'minimap' | 'full' | undefined
|
|
||||||
/** Active automap projection divisor (20 for minimap, 10 for full). */
|
|
||||||
automapDivisor?: 10 | 20 | undefined
|
|
||||||
/** Number of loaded automap sprite atlases (0..8). */
|
|
||||||
automapAtlasesLoaded?: number | undefined
|
|
||||||
/** Pre-baked/active automap cell counts per layer. */
|
|
||||||
automapCellCounts?: {
|
|
||||||
floors: number
|
|
||||||
walls: number
|
|
||||||
objects: number
|
|
||||||
extras: number
|
|
||||||
total: number
|
|
||||||
} | undefined
|
|
||||||
/** Revealed DRLG room count for the active level. */
|
|
||||||
automapRevealedRooms?: number | undefined
|
|
||||||
/** Automap sprites drawn last frame per layer. */
|
|
||||||
automapSpritesDrawn?: {
|
|
||||||
floors: number
|
|
||||||
walls: number
|
|
||||||
objects: number
|
|
||||||
extras: number
|
|
||||||
portals: number
|
|
||||||
total: number
|
|
||||||
} | undefined
|
|
||||||
/** Automap 12-segment crosshairs drawn last frame. */
|
|
||||||
automapCrosshairsDrawn?: {
|
|
||||||
player: number
|
|
||||||
party: number
|
|
||||||
npcs: number
|
|
||||||
mercenaries: number
|
|
||||||
bosses: number
|
|
||||||
total: number
|
|
||||||
} | undefined
|
|
||||||
/** Revealed warp/stash Font6 labels on the automap. */
|
|
||||||
automapWarpLabels?: AutomapOverlayLabel[] | undefined
|
|
||||||
/** Top-right gold Font16 Level Name + Difficulty HUD readout. */
|
|
||||||
automapHudText?: {
|
|
||||||
levelName: string
|
|
||||||
difficulty: string
|
|
||||||
x: number
|
|
||||||
y1: number
|
|
||||||
y2: number
|
|
||||||
} | null | undefined
|
|
||||||
/** Active automap viewport metrics. */
|
|
||||||
automapViewport?: AutomapViewportMetrics | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
/** Act scene state for automation and debugging. */
|
|
||||||
__d2webAct?: ActSceneState
|
|
||||||
__d2webActs?: ActSceneState
|
|
||||||
__d2webMinimap?: Minimap
|
|
||||||
__d2webActScene?: {
|
|
||||||
switchHero: (token: string) => Promise<boolean>
|
|
||||||
getHero: () => string
|
|
||||||
switchLighting?: (preset: string) => LightingPreset
|
|
||||||
getLighting?: () => LightingPreset
|
|
||||||
reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise<void> | void
|
|
||||||
setPlayers?: (count: number, partyCount?: number) => void
|
|
||||||
executeCommand?: (cmd: string) => boolean
|
|
||||||
castTownPortal?: () => boolean
|
|
||||||
enterTownPortal?: () => void
|
|
||||||
getPortal?: () => OpenPortal | null
|
|
||||||
getMinimap?: () => Minimap
|
|
||||||
syncAppearance?: () => Promise<boolean>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const state: ActSceneState = {
|
|
||||||
ready: false, source: '', act: 1, level: '', variant: '', variants: [], quadrant: '', quadrants: [], base: '', roofs: 0, roofAlpha: 1,
|
|
||||||
cellsX: 0, cellsY: 0, widthPx: 0, heightPx: 0, floors: 0, shadows: 0, walls: 0, frames: 0,
|
|
||||||
pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [],
|
|
||||||
character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1,
|
|
||||||
heroToken: 'so', heroName: 'Sorceress',
|
|
||||||
playerDrawRect: undefined, weaponClass: 'hth', playerClip: 'nu',
|
|
||||||
skippedDraws: 0, objects: 0, loadMs: 0,
|
|
||||||
missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0, simTps: 0, renderFps: 0, fps: 0,
|
|
||||||
drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0,
|
|
||||||
zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
|
|
||||||
dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0, animatedObjects: 0,
|
|
||||||
animSpeed: undefined, frameDurationMs: DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
|
|
||||||
missingMonsterArt: [], monsterArtErrors: 0, monsterArtLayerFailures: 0, monstersPlanned: 0,
|
|
||||||
densityMultiplier: 1, eliteMultiplier: 1,
|
|
||||||
}
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.__d2webAct = state
|
|
||||||
window.__d2webActs = state
|
|
||||||
;(window as unknown as { __d2Scene?: ActSceneState }).__d2Scene = state
|
|
||||||
}
|
|
||||||
|
|
||||||
export function monsterToTargetHudInfo(monster: Monster): MonsterTargetHudInfo {
|
|
||||||
const dropTables = getEmbeddedDropTables()
|
|
||||||
const stats =
|
|
||||||
monster.stats.isDemon === undefined &&
|
|
||||||
monster.stats.isUndead === undefined &&
|
|
||||||
monster.stats.nameKey === undefined
|
|
||||||
? enrichMonsterStats(monster.stats, dropTables.monsterKinds, dropTables.superUniques)
|
|
||||||
: monster.stats
|
|
||||||
const r = stats.rank
|
|
||||||
const statsAny = stats as MonsterStats & {
|
|
||||||
readonly descStr?: string
|
|
||||||
readonly subType?: string
|
|
||||||
readonly affixes?: readonly string[]
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
name: stats.name || stats.id,
|
|
||||||
...(stats.nameKey !== undefined ? { nameKey: stats.nameKey } : {}),
|
|
||||||
hp: monster.hp,
|
|
||||||
maxHp: stats.hp,
|
|
||||||
rank:
|
|
||||||
r === 'champion'
|
|
||||||
? 'champion'
|
|
||||||
: r === 'unique'
|
|
||||||
? 'unique'
|
|
||||||
: (r as string) === 'superunique'
|
|
||||||
? 'superunique'
|
|
||||||
: r === 'minion'
|
|
||||||
? 'minion'
|
|
||||||
: r === 'boss' || stats.isBoss === true
|
|
||||||
? 'boss'
|
|
||||||
: 'normal',
|
|
||||||
...(stats.isBoss !== undefined ? { isBoss: stats.isBoss } : {}),
|
|
||||||
...(stats.isUndead !== undefined ? { isUndead: stats.isUndead } : {}),
|
|
||||||
...(stats.isDemon !== undefined ? { isDemon: stats.isDemon } : {}),
|
|
||||||
...(stats.descKey !== undefined ? { descKey: stats.descKey } : {}),
|
|
||||||
...(statsAny.descStr !== undefined ? { descStr: statsAny.descStr } : {}),
|
|
||||||
...(statsAny.subType !== undefined ? { subType: statsAny.subType } : {}),
|
|
||||||
...(stats.modifiers !== undefined ? { modifiers: stats.modifiers } : {}),
|
|
||||||
...(statsAny.affixes !== undefined ? { affixes: statsAny.affixes } : {}),
|
|
||||||
...(stats.resists !== undefined ? { resists: stats.resists } : {}),
|
|
||||||
...(stats.resistances !== undefined ? { resistances: stats.resistances } : {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MonsterArtDiagnosticState {
|
|
||||||
readonly missing: readonly string[]
|
|
||||||
readonly errors: number
|
|
||||||
readonly layerFailures: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The HUD fragment describing a monster-art load, empty when it was clean.
|
|
||||||
*
|
|
||||||
* @param load - the counters, from {@link ActSceneState} or {@link MonsterArtLoad}.
|
|
||||||
* @returns the fragment, ready to append to the HUD line.
|
|
||||||
*/
|
|
||||||
export function monsterArtStatusText(
|
|
||||||
load: MonsterArtDiagnosticState,
|
|
||||||
lang: TblLang = getTblLang(),
|
|
||||||
): string {
|
|
||||||
const isEn = lang === 'ENG'
|
|
||||||
const parts: string[] = []
|
|
||||||
if (load.missing.length > 0) {
|
|
||||||
const shown = load.missing.slice(0, 3).join(isEn ? ', ' : '、')
|
|
||||||
const more = load.missing.length > 3 ? `…+${String(load.missing.length - 3)}` : ''
|
|
||||||
parts.push(
|
|
||||||
isEn
|
|
||||||
? `Monster art missing ${String(load.missing.length)} (red box: ${shown}${more})`
|
|
||||||
: `怪物美术缺失 ${String(load.missing.length)}(红方块:${shown}${more})`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (load.errors > 0) parts.push(isEn ? `Load errors ${String(load.errors)}` : `加载错误 ${String(load.errors)}`)
|
|
||||||
if (load.layerFailures > 0) parts.push(isEn ? `Missing layers ${String(load.layerFailures)}` : `缺层 ${String(load.layerFailures)}`)
|
|
||||||
return parts.length === 0 ? '' : ` · ${parts.join(' · ')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The HUD fragment describing a level's planned population.
|
|
||||||
*
|
|
||||||
* @param planned - how many monsters the plan produced.
|
|
||||||
* @returns the fragment, ready to append to the HUD line.
|
|
||||||
*/
|
|
||||||
export function monsterPlanStatusText(planned: number, lang: TblLang = getTblLang()): string {
|
|
||||||
if (lang === 'ENG') {
|
|
||||||
return planned === 0 ? ' · Monsters 0 (none planned for level)' : ` · Monsters ${String(planned)}`
|
|
||||||
}
|
|
||||||
return planned === 0 ? ' · 怪物 0(本关未规划到怪物)' : ` · 怪物 ${String(planned)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish a level's planned population into {@link ActSceneState}.
|
|
||||||
*/
|
|
||||||
export function reportMonsterPlan(runtime: MapRuntime): void {
|
|
||||||
const planned = runtime.monsterPacks.reduce((sum, pack) => sum + pack.members.length, 0)
|
|
||||||
state.monstersPlanned = planned
|
|
||||||
if (planned === 0) {
|
|
||||||
console.info(`level ${runtime.level}: no monsters planned; the level stays empty`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish a load's failures into {@link ActSceneState}.
|
|
||||||
*/
|
|
||||||
export function recordMonsterArtLoad(load: MonsterArtDiagnosticState): void {
|
|
||||||
state.missingMonsterArt = [...load.missing]
|
|
||||||
state.monsterArtErrors = load.errors
|
|
||||||
state.monsterArtLayerFailures = load.layerFailures
|
|
||||||
if (load.missing.length > 0 || load.errors > 0 || load.layerFailures > 0) {
|
|
||||||
console.error(
|
|
||||||
`monster art incomplete: ${String(load.missing.length)} type(s) without art `
|
|
||||||
+ `(${load.missing.join(', ')}), ${String(load.errors)} load error(s), `
|
|
||||||
+ `${String(load.layerFailures)} dropped layer(s)`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy a runtime's description into the published state.
|
|
||||||
*
|
|
||||||
* @param runtime - the runtime.
|
|
||||||
* @param base - location the data came from.
|
|
||||||
*/
|
|
||||||
export function publish(runtime: MapRuntime, base: string): void {
|
|
||||||
state.source = runtime.source
|
|
||||||
state.base = base
|
|
||||||
state.act = runtime.act
|
|
||||||
state.level = runtime.level
|
|
||||||
state.variant = runtime.variant
|
|
||||||
state.variants = [...runtime.variants]
|
|
||||||
state.quadrant = runtime.quadrant
|
|
||||||
state.quadrants = [...runtime.quadrants]
|
|
||||||
state.cellsX = runtime.cellsX
|
|
||||||
state.cellsY = runtime.cellsY
|
|
||||||
state.widthPx = runtime.widthPx
|
|
||||||
state.heightPx = runtime.heightPx
|
|
||||||
state.floors = runtime.floors.length
|
|
||||||
state.shadows = runtime.shadows?.length ?? 0
|
|
||||||
state.walls = runtime.walls.length
|
|
||||||
state.frames = runtime.frames
|
|
||||||
state.pagesTotal = runtime.pages.length
|
|
||||||
state.pagesLoaded = countLoadedPages(runtime.pages)
|
|
||||||
state.priorityPages = [...runtime.priorityPages]
|
|
||||||
state.objects = runtime.objects
|
|
||||||
state.roofs = runtime.roofs.length
|
|
||||||
state.animatedTiles = runtime.animatedDrawables?.length ?? 0
|
|
||||||
state.animatedObjects = runtime.animatedObjects?.length ?? 0
|
|
||||||
state.animSpeed = runtime.animSpeed
|
|
||||||
state.frameDurationMs = runtime.frameDurationMs
|
|
||||||
state.roofAlpha = 1
|
|
||||||
state.missing = 0
|
|
||||||
state.walkable = 1 - [...runtime.grid.blocked].reduce((sum, value) => sum + value, 0) / runtime.grid.blocked.length
|
|
||||||
}
|
|
||||||
|
|
@ -1,948 +0,0 @@
|
||||||
import type { Palette } from '../../common/formats/pal.ts'
|
|
||||||
import { WORLD_VARIANT_SEED } from '../../common/world/act-variants.ts'
|
|
||||||
import type { ActorAnimator } from '../render/actor-animator.ts'
|
|
||||||
import {
|
|
||||||
SorceressLayerCompositor,
|
|
||||||
appearanceKey,
|
|
||||||
resolveSorceressAppearance,
|
|
||||||
} from '../../common/units/char-appearance.ts'
|
|
||||||
import type { CharacterClassCode } from '../../common/units/classes.ts'
|
|
||||||
import { spawnMonsterPacks } from '../../common/units/monster-rooms.ts'
|
|
||||||
import type { MonsterPack, SafeZone, SceneEngineLike as GameEngine } from '../../common/units/unit-types.ts'
|
|
||||||
import { cellCentre } from '../../common/world/d2map.ts'
|
|
||||||
import type { DropTables } from '../../common/items/drop-tables.ts'
|
|
||||||
import {
|
|
||||||
ACT_NAMES_EN,
|
|
||||||
ACT_NAMES_ZH,
|
|
||||||
getLevelNames,
|
|
||||||
sceneDisplayName,
|
|
||||||
variantDisplayLabel,
|
|
||||||
} from '../../common/world/level-names-zh.ts'
|
|
||||||
import type { LoadedMonsterArt } from '../render/monster-art.ts'
|
|
||||||
import { MonsterStreamingManager } from '../../common/units/monster-streaming.ts'
|
|
||||||
import {
|
|
||||||
buildLangSwitchHref,
|
|
||||||
getTblLang,
|
|
||||||
type TblLang,
|
|
||||||
} from '../i18n/lang.ts'
|
|
||||||
import type { AtlasFrame } from '../render/atlas.ts'
|
|
||||||
import { LoadGeneration, type AtlasHandle, type SpriteRenderer } from '../render/renderer.ts'
|
|
||||||
import { cleanChiItemName } from '../ui/ground-labels.ts'
|
|
||||||
import { HudManager } from '../ui/hud-manager.ts'
|
|
||||||
import type { Minimap } from '../ui/minimap.ts'
|
|
||||||
import { handleActSceneChatCommand } from '../../common/world/chat-commands.ts'
|
|
||||||
import { subTileToScene } from '../../common/world/scene-links-nav.ts'
|
|
||||||
import {
|
|
||||||
HERO_CLASSES,
|
|
||||||
ROOM_ACTIVATION_RADIUS,
|
|
||||||
ROOM_DEACTIVATION_RADIUS,
|
|
||||||
buildStreamingRooms,
|
|
||||||
isTownLevel,
|
|
||||||
normalizeHeroToken,
|
|
||||||
parseDensityMultiplier,
|
|
||||||
parseEliteMultiplier,
|
|
||||||
rebudgetMonsterPacks,
|
|
||||||
type DensityMultiplier,
|
|
||||||
type EliteMultiplier,
|
|
||||||
} from '../../common/world/scene-population.ts'
|
|
||||||
import {
|
|
||||||
DEFAULT_BASES,
|
|
||||||
loadCharacterArt,
|
|
||||||
loadMonsterArtMap,
|
|
||||||
type LoadedCharacter,
|
|
||||||
} from './entity-art-loader.ts'
|
|
||||||
import {
|
|
||||||
LIGHTING_PRESETS,
|
|
||||||
normalizeLightingPreset,
|
|
||||||
type LightingPreset,
|
|
||||||
} from './lighting-presets.ts'
|
|
||||||
import {
|
|
||||||
ACT_COUNT,
|
|
||||||
fetchJson,
|
|
||||||
param,
|
|
||||||
type MapRuntime,
|
|
||||||
type PackIndex,
|
|
||||||
} from './pack-loader.ts'
|
|
||||||
import {
|
|
||||||
recordMonsterArtLoad,
|
|
||||||
reportMonsterPlan,
|
|
||||||
state,
|
|
||||||
} from './scene-state.ts'
|
|
||||||
|
|
||||||
export { handleActSceneChatCommand }
|
|
||||||
|
|
||||||
export function updateSelectorWarningStyle(select: HTMLSelectElement | null, defaultValue: string = '1'): void {
|
|
||||||
if (!select) return
|
|
||||||
const isNonDefault = select.value !== defaultValue
|
|
||||||
select.classList.toggle('non-default', isNonDefault)
|
|
||||||
if (isNonDefault) {
|
|
||||||
select.style.border = '1px solid #e74c3c'
|
|
||||||
} else {
|
|
||||||
select.style.border = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateToolbarUrl(
|
|
||||||
currentUrlOrSearch: string | URL,
|
|
||||||
options: { density?: number | string | undefined; elite?: number | string | undefined },
|
|
||||||
): string {
|
|
||||||
const isUrl = typeof currentUrlOrSearch === 'object' || currentUrlOrSearch.startsWith('http://') || currentUrlOrSearch.startsWith('https://')
|
|
||||||
const url = isUrl
|
|
||||||
? new URL(typeof currentUrlOrSearch === 'string' ? currentUrlOrSearch : currentUrlOrSearch.href)
|
|
||||||
: new URL(`http://localhost${currentUrlOrSearch.startsWith('?') ? currentUrlOrSearch : '?' + currentUrlOrSearch}`)
|
|
||||||
|
|
||||||
if (options.density !== undefined) {
|
|
||||||
const density = parseDensityMultiplier(options.density)
|
|
||||||
if (density === 1) {
|
|
||||||
url.searchParams.delete('density')
|
|
||||||
} else {
|
|
||||||
url.searchParams.set('density', String(density))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.elite !== undefined) {
|
|
||||||
const elite = parseEliteMultiplier(options.elite)
|
|
||||||
if (elite === 1) {
|
|
||||||
url.searchParams.delete('elite')
|
|
||||||
} else {
|
|
||||||
url.searchParams.set('elite', String(elite))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isUrl) {
|
|
||||||
return url.toString()
|
|
||||||
}
|
|
||||||
return url.search
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MountChatAndToolbarUiOptions {
|
|
||||||
readonly engine: GameEngine
|
|
||||||
readonly status: HTMLElement
|
|
||||||
readonly executeChatCommand: (rawCommand: string) => boolean
|
|
||||||
readonly densitySelect: HTMLSelectElement | null
|
|
||||||
readonly eliteSelect: HTMLSelectElement | null
|
|
||||||
readonly densityMultiplier: number
|
|
||||||
readonly eliteMultiplier: number | 'all'
|
|
||||||
readonly reloadMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mount the in-game chat/console input overlay (`#d2-chat-input`) and wire
|
|
||||||
* toolbar density/elite selectors.
|
|
||||||
*/
|
|
||||||
export function mountChatAndToolbarUi(options: MountChatAndToolbarUiOptions): void {
|
|
||||||
const {
|
|
||||||
executeChatCommand,
|
|
||||||
densitySelect,
|
|
||||||
eliteSelect,
|
|
||||||
densityMultiplier,
|
|
||||||
eliteMultiplier,
|
|
||||||
reloadMonsters,
|
|
||||||
} = options
|
|
||||||
|
|
||||||
// In-game chat/console input overlay supporting "/players <1-8>"
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
let chatInput = document.querySelector<HTMLInputElement>('#d2-chat-input')
|
|
||||||
if (!chatInput) {
|
|
||||||
chatInput = document.createElement('input')
|
|
||||||
chatInput.id = 'd2-chat-input'
|
|
||||||
chatInput.type = 'text'
|
|
||||||
chatInput.placeholder = getTblLang() === 'ENG'
|
|
||||||
? 'Type /players 1-8 or message, press Enter to send…'
|
|
||||||
: '输入 /players 1-8 或消息,按 Enter 发送…'
|
|
||||||
chatInput.className = 'overlay'
|
|
||||||
chatInput.style.position = 'fixed'
|
|
||||||
chatInput.style.left = '12px'
|
|
||||||
chatInput.style.bottom = '56px'
|
|
||||||
chatInput.style.width = '320px'
|
|
||||||
chatInput.style.padding = '4px 8px'
|
|
||||||
chatInput.style.background = 'rgba(11, 9, 6, 0.9)'
|
|
||||||
chatInput.style.color = '#e8dcc8'
|
|
||||||
chatInput.style.border = '1px solid #c8a15a'
|
|
||||||
chatInput.style.borderRadius = '4px'
|
|
||||||
chatInput.style.font = 'inherit'
|
|
||||||
chatInput.style.fontSize = '12px'
|
|
||||||
chatInput.style.zIndex = '10'
|
|
||||||
chatInput.style.display = 'none'
|
|
||||||
document.body.appendChild(chatInput)
|
|
||||||
}
|
|
||||||
|
|
||||||
const openChat = () => {
|
|
||||||
if (!chatInput) return
|
|
||||||
chatInput.placeholder = getTblLang() === 'ENG'
|
|
||||||
? 'Type /players 1-8 or message, press Enter to send…'
|
|
||||||
: '输入 /players 1-8 或消息,按 Enter 发送…'
|
|
||||||
chatInput.style.display = 'block'
|
|
||||||
chatInput.value = ''
|
|
||||||
chatInput.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeChat = () => {
|
|
||||||
if (!chatInput) return
|
|
||||||
chatInput.value = ''
|
|
||||||
chatInput.blur()
|
|
||||||
chatInput.style.display = 'none'
|
|
||||||
}
|
|
||||||
|
|
||||||
chatInput.addEventListener('keydown', (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
const text = chatInput!.value
|
|
||||||
closeChat()
|
|
||||||
if (text) {
|
|
||||||
executeChatCommand(text)
|
|
||||||
}
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault()
|
|
||||||
closeChat()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
window.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
const active = document.activeElement
|
|
||||||
if (
|
|
||||||
active instanceof HTMLInputElement ||
|
|
||||||
active instanceof HTMLSelectElement ||
|
|
||||||
active instanceof HTMLTextAreaElement
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.preventDefault()
|
|
||||||
openChat()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (densitySelect !== null) {
|
|
||||||
densitySelect.disabled = false
|
|
||||||
densitySelect.value = String(densityMultiplier)
|
|
||||||
updateSelectorWarningStyle(densitySelect, '1')
|
|
||||||
densitySelect.onchange = () => {
|
|
||||||
void reloadMonsters({ density: densitySelect.value })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (eliteSelect !== null) {
|
|
||||||
eliteSelect.disabled = false
|
|
||||||
eliteSelect.value = String(eliteMultiplier)
|
|
||||||
updateSelectorWarningStyle(eliteSelect, '1')
|
|
||||||
eliteSelect.onchange = () => {
|
|
||||||
void reloadMonsters({ elite: eliteSelect.value })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HeroLightingAndMonsterControlsContext {
|
|
||||||
readonly renderer: SpriteRenderer
|
|
||||||
readonly dropTables: DropTables
|
|
||||||
readonly playerAnimator: ActorAnimator
|
|
||||||
readonly status: HTMLElement
|
|
||||||
readonly heroSelect: HTMLSelectElement | null
|
|
||||||
readonly lightingSelect: HTMLSelectElement | null
|
|
||||||
readonly densitySelect: HTMLSelectElement | null
|
|
||||||
readonly eliteSelect: HTMLSelectElement | null
|
|
||||||
readonly getRuntime: () => MapRuntime
|
|
||||||
readonly getEngine: () => GameEngine
|
|
||||||
readonly getHudManager: () => HudManager | null
|
|
||||||
readonly getCurrentHeroToken: () => string
|
|
||||||
readonly setCurrentHeroToken: (token: string) => void
|
|
||||||
readonly getCharacter: () => LoadedCharacter | null
|
|
||||||
readonly setCharacter: (char: LoadedCharacter | null) => void
|
|
||||||
readonly getActivePalette: () => Palette | null
|
|
||||||
readonly getPackEntityBase: () => string | undefined
|
|
||||||
readonly getPackEntityAct: () => number | undefined
|
|
||||||
readonly getCurrentLightingPreset: () => LightingPreset
|
|
||||||
readonly setLightingState: (preset: LightingPreset, enabled: boolean) => void
|
|
||||||
readonly getMultipliers: () => { densityMultiplier: DensityMultiplier; eliteMultiplier: EliteMultiplier }
|
|
||||||
readonly setMultipliers: (density: DensityMultiplier, elite: EliteMultiplier) => void
|
|
||||||
readonly getMonsterArtMap?: (() => Map<string, LoadedMonsterArt>) | undefined
|
|
||||||
readonly setMonsterArtMap: (map: Map<string, LoadedMonsterArt>) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HeroLightingAndMonsterControls {
|
|
||||||
readonly heroGen: LoadGeneration
|
|
||||||
readonly monsterGen: LoadGeneration
|
|
||||||
readonly lightingGen: LoadGeneration
|
|
||||||
readonly invalidateMonsterLoads: () => void
|
|
||||||
readonly syncSorceressAppearance: () => Promise<boolean>
|
|
||||||
readonly switchLighting: (preset: string) => LightingPreset
|
|
||||||
readonly switchHero: (newHeroToken: string) => Promise<boolean>
|
|
||||||
readonly changeHeroClass: (newHeroToken: string) => Promise<boolean>
|
|
||||||
readonly reloadMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise<void>
|
|
||||||
readonly respawnMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createHeroLightingAndMonsterControls(
|
|
||||||
ctx: HeroLightingAndMonsterControlsContext,
|
|
||||||
): HeroLightingAndMonsterControls {
|
|
||||||
const soCompositor = new SorceressLayerCompositor(ctx.getPackEntityBase() ?? 'samples/d2-packs')
|
|
||||||
const heroGen = new LoadGeneration()
|
|
||||||
const monsterGen = new LoadGeneration()
|
|
||||||
const lightingGen = new LoadGeneration()
|
|
||||||
let activeAppearanceKey = ''
|
|
||||||
let appearanceSeq = 0
|
|
||||||
|
|
||||||
const invalidateMonsterLoads = (): void => {
|
|
||||||
monsterGen.invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncSorceressAppearance = async (): Promise<boolean> => {
|
|
||||||
const hudInst = ctx.getHudManager() ?? (
|
|
||||||
typeof window !== 'undefined'
|
|
||||||
? (window as unknown as { __d2webHudInstance?: HudManager }).__d2webHudInstance ?? null
|
|
||||||
: null
|
|
||||||
)
|
|
||||||
const currentHeroToken = ctx.getCurrentHeroToken()
|
|
||||||
if (currentHeroToken !== 'so' || hudInst === null) {
|
|
||||||
activeAppearanceKey = ''
|
|
||||||
state.characterAppearanceKey = undefined
|
|
||||||
state.characterAppearance = undefined
|
|
||||||
if (hudInst !== null) {
|
|
||||||
const wCode = hudInst.inventory.equipped.weapon1?.code
|
|
||||||
state.weaponClass = wCode === '7wa' ? '2hs' : (wCode ? '1hs' : 'hth')
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const spec = resolveSorceressAppearance(hudInst.inventory.equipped, ctx.dropTables)
|
|
||||||
const key = appearanceKey(spec)
|
|
||||||
state.weaponClass = spec.wclass
|
|
||||||
state.characterAppearance = spec
|
|
||||||
|
|
||||||
if (key === activeAppearanceKey) return false
|
|
||||||
activeAppearanceKey = key
|
|
||||||
state.characterAppearanceKey = key
|
|
||||||
const seq = ++appearanceSeq
|
|
||||||
|
|
||||||
try {
|
|
||||||
const composited = await soCompositor.composite(spec)
|
|
||||||
if (seq !== appearanceSeq || ctx.getCurrentHeroToken() !== 'so') return false
|
|
||||||
const newHandle = ctx.renderer.addIndexedAtlas(composited.r8, composited.width, composited.height)
|
|
||||||
const groups: AtlasFrame[][] = composited.groups.map(group =>
|
|
||||||
group.map(([x, y, width, height, anchorX, anchorY]) => ({
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
anchorX,
|
|
||||||
anchorY,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
const prevChar = ctx.getCharacter()
|
|
||||||
if (prevChar?.handle && prevChar.handle !== newHandle) ctx.renderer.deleteAtlas(prevChar.handle)
|
|
||||||
ctx.setCharacter({
|
|
||||||
handle: newHandle,
|
|
||||||
groups,
|
|
||||||
directions: composited.directions,
|
|
||||||
walk: composited.walk,
|
|
||||||
stand: composited.stand,
|
|
||||||
members: composited.members,
|
|
||||||
clips: composited.clips,
|
|
||||||
})
|
|
||||||
state.character = true
|
|
||||||
state.characterMembers = composited.members
|
|
||||||
state.characterAppearanceKey = key
|
|
||||||
state.characterAppearance = spec
|
|
||||||
if (ctx.playerAnimator.mode !== null) {
|
|
||||||
const nextClip = composited.clips[ctx.playerAnimator.mode]
|
|
||||||
if (nextClip !== undefined) ctx.playerAnimator.syncClip(nextClip)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Sorceress appearance composite failed:', err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const switchLighting = (preset: string): LightingPreset => {
|
|
||||||
const { gen } = lightingGen.next()
|
|
||||||
const nextPreset = normalizeLightingPreset(preset)
|
|
||||||
const cfg = LIGHTING_PRESETS[nextPreset]
|
|
||||||
ctx.setLightingState(nextPreset, cfg.enabled)
|
|
||||||
const isEn = getTblLang() === 'ENG'
|
|
||||||
const modeLabel = isEn ? cfg.labelEn : cfg.labelZh
|
|
||||||
state.lightingPreset = nextPreset
|
|
||||||
state.lightingMode = modeLabel
|
|
||||||
if (ctx.lightingSelect !== null && ctx.lightingSelect.value !== nextPreset) {
|
|
||||||
ctx.lightingSelect.value = nextPreset
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const url = new URL(location.href)
|
|
||||||
if (nextPreset === 'auto') url.searchParams.delete('lighting')
|
|
||||||
else url.searchParams.set('lighting', nextPreset)
|
|
||||||
history.replaceState(null, '', url.toString())
|
|
||||||
} catch {
|
|
||||||
// ignore in non-browser context
|
|
||||||
}
|
|
||||||
const msgPrefix = isEn ? 'Scene lighting switched to: ' : '已切换场景光照为:'
|
|
||||||
ctx.status.textContent = `${msgPrefix}${modeLabel}`
|
|
||||||
setTimeout(() => {
|
|
||||||
if (lightingGen.isCurrent(gen) && ctx.status.textContent.startsWith(msgPrefix)) {
|
|
||||||
ctx.status.textContent = ''
|
|
||||||
}
|
|
||||||
}, 2500)
|
|
||||||
return nextPreset
|
|
||||||
}
|
|
||||||
|
|
||||||
const switchHero = async (newHeroToken: string): Promise<boolean> => {
|
|
||||||
const { gen } = heroGen.next()
|
|
||||||
const nextToken = normalizeHeroToken(newHeroToken)
|
|
||||||
if (nextToken === ctx.getCurrentHeroToken() && ctx.getCharacter() !== null) return true
|
|
||||||
const nextInfo = HERO_CLASSES[nextToken] ?? HERO_CLASSES.so
|
|
||||||
const isEn = getTblLang() === 'ENG'
|
|
||||||
const heroDispName = isEn ? nextInfo.nameEn : nextInfo.nameZh
|
|
||||||
ctx.status.textContent = isEn ? `Switching hero to ${heroDispName} …` : `正在切换角色至 ${heroDispName} …`
|
|
||||||
const runtime = ctx.getRuntime()
|
|
||||||
const nextChar = await loadCharacterArt(
|
|
||||||
runtime.charBases,
|
|
||||||
ctx.getActivePalette(),
|
|
||||||
ctx.renderer,
|
|
||||||
ctx.getPackEntityBase(),
|
|
||||||
ctx.getPackEntityAct(),
|
|
||||||
nextToken,
|
|
||||||
)
|
|
||||||
if (!heroGen.isCurrent(gen)) {
|
|
||||||
if (nextChar !== null && nextChar.handle && nextChar.handle !== ctx.getCharacter()?.handle) {
|
|
||||||
ctx.renderer.deleteAtlas(nextChar.handle)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (nextChar !== null) {
|
|
||||||
const prevChar = ctx.getCharacter()
|
|
||||||
if (prevChar?.handle && prevChar.handle !== nextChar.handle) {
|
|
||||||
ctx.renderer.deleteAtlas(prevChar.handle)
|
|
||||||
}
|
|
||||||
ctx.setCharacter(nextChar)
|
|
||||||
ctx.setCurrentHeroToken(nextToken)
|
|
||||||
state.heroToken = nextToken
|
|
||||||
state.heroName = nextInfo.nameEn
|
|
||||||
state.character = true
|
|
||||||
state.characterMembers = nextChar.members
|
|
||||||
const hudManager = ctx.getHudManager()
|
|
||||||
if (hudManager !== null) hudManager.switchClass(nextInfo.code as CharacterClassCode)
|
|
||||||
activeAppearanceKey = ''
|
|
||||||
if (nextToken === 'so') {
|
|
||||||
await syncSorceressAppearance()
|
|
||||||
if (!heroGen.isCurrent(gen)) return false
|
|
||||||
} else {
|
|
||||||
state.characterAppearanceKey = undefined
|
|
||||||
state.characterAppearance = undefined
|
|
||||||
}
|
|
||||||
if (ctx.heroSelect !== null && ctx.heroSelect.value !== nextToken) ctx.heroSelect.value = nextToken
|
|
||||||
try {
|
|
||||||
const url = new URL(location.href)
|
|
||||||
url.searchParams.set('hero', nextToken)
|
|
||||||
history.replaceState(null, '', url.toString())
|
|
||||||
} catch {
|
|
||||||
// ignore in non-browser context
|
|
||||||
}
|
|
||||||
const curChar = ctx.getCharacter()
|
|
||||||
if (curChar?.clips?.nu) ctx.playerAnimator.play('nu', curChar.clips.nu, { restart: true })
|
|
||||||
const switchedPrefix = isEn ? 'Switched hero to ' : '已切换角色为 '
|
|
||||||
ctx.status.textContent = isEn
|
|
||||||
? `${switchedPrefix}${heroDispName} (${String(curChar?.members ?? nextChar.members)} layers).`
|
|
||||||
: `${switchedPrefix}${heroDispName}(${String(curChar?.members ?? nextChar.members)} 层素材)。`
|
|
||||||
setTimeout(() => {
|
|
||||||
if (heroGen.isCurrent(gen) && ctx.status.textContent.startsWith(switchedPrefix)) {
|
|
||||||
ctx.status.textContent = ''
|
|
||||||
}
|
|
||||||
}, 3000)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
ctx.status.textContent = isEn ? `Failed to switch hero to ${heroDispName}.` : `切换角色 ${heroDispName} 失败。`
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const reloadMonsters = async (options?: { density?: number | string; elite?: number | string }): Promise<void> => {
|
|
||||||
const { gen } = monsterGen.next()
|
|
||||||
let { densityMultiplier, eliteMultiplier } = ctx.getMultipliers()
|
|
||||||
if (options?.density !== undefined) densityMultiplier = parseDensityMultiplier(options.density)
|
|
||||||
if (options?.elite !== undefined) eliteMultiplier = parseEliteMultiplier(options.elite)
|
|
||||||
ctx.setMultipliers(densityMultiplier, eliteMultiplier)
|
|
||||||
state.densityMultiplier = densityMultiplier
|
|
||||||
state.eliteMultiplier = eliteMultiplier
|
|
||||||
|
|
||||||
if (ctx.densitySelect !== null) {
|
|
||||||
ctx.densitySelect.value = String(densityMultiplier)
|
|
||||||
updateSelectorWarningStyle(ctx.densitySelect, '1')
|
|
||||||
}
|
|
||||||
if (ctx.eliteSelect !== null) {
|
|
||||||
ctx.eliteSelect.value = String(eliteMultiplier)
|
|
||||||
updateSelectorWarningStyle(ctx.eliteSelect, '1')
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (typeof location !== 'undefined' && typeof history !== 'undefined') {
|
|
||||||
const nextSearch = updateToolbarUrl(location.search, { density: densityMultiplier, elite: eliteMultiplier })
|
|
||||||
const newUrl = `${location.pathname}${nextSearch}${location.hash}`
|
|
||||||
history.replaceState(null, '', newUrl)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore in non-browser context
|
|
||||||
}
|
|
||||||
|
|
||||||
const runtime = ctx.getRuntime()
|
|
||||||
const engine = ctx.getEngine()
|
|
||||||
if (isTownLevel(runtime.levelId)) {
|
|
||||||
runtime.monsterPacks = []
|
|
||||||
engine.world.monsters.length = 0
|
|
||||||
engine.streamingManager = undefined
|
|
||||||
for (const oldArt of ctx.getMonsterArtMap?.().values() ?? []) {
|
|
||||||
ctx.renderer.deleteAtlas(oldArt.handle)
|
|
||||||
}
|
|
||||||
ctx.setMonsterArtMap(new Map())
|
|
||||||
reportMonsterPlan(runtime)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const basePacks = runtime.baseMonsterPacks ?? runtime.monsterPacks
|
|
||||||
runtime.baseMonsterPacks = basePacks
|
|
||||||
runtime.monsterPacks = rebudgetMonsterPacks(basePacks, {
|
|
||||||
densityMultiplier,
|
|
||||||
eliteMultiplier,
|
|
||||||
levelId: runtime.levelId,
|
|
||||||
seed: WORLD_VARIANT_SEED ^ runtime.levelId,
|
|
||||||
})
|
|
||||||
|
|
||||||
engine.world.monsters.length = 0
|
|
||||||
|
|
||||||
const player = engine.world.player
|
|
||||||
const currentSafeZones: SafeZone[] = [{ x: player.x, y: player.y, radius: 180 }]
|
|
||||||
for (const wp of runtime.waypoints) {
|
|
||||||
const pos = subTileToScene(wp.x, wp.y, runtime.grid)
|
|
||||||
currentSafeZones.push({ x: pos.x, y: pos.y, radius: 160 })
|
|
||||||
}
|
|
||||||
for (const ent of runtime.entrances) {
|
|
||||||
const pos = subTileToScene(ent.x, ent.y, runtime.grid)
|
|
||||||
currentSafeZones.push({ x: pos.x, y: pos.y, radius: 160 })
|
|
||||||
}
|
|
||||||
for (const s of runtime.stashes ?? []) {
|
|
||||||
currentSafeZones.push({ x: s.x, y: s.y, radius: 160 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentResolvedPacks: MonsterPack[] = runtime.monsterPacks.map(pack => {
|
|
||||||
if (pack.landmarkTile !== undefined && pack.fixedPosition === undefined) {
|
|
||||||
return {
|
|
||||||
...pack,
|
|
||||||
fixedPosition: cellCentre(runtime.grid, pack.landmarkTile.tileX, pack.landmarkTile.tileY),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pack
|
|
||||||
})
|
|
||||||
|
|
||||||
const nextRooms = buildStreamingRooms(runtime, currentResolvedPacks, currentSafeZones)
|
|
||||||
if (nextRooms.length > 0) {
|
|
||||||
engine.streamingManager = new MonsterStreamingManager(
|
|
||||||
engine.world,
|
|
||||||
engine.terrain,
|
|
||||||
nextRooms,
|
|
||||||
{
|
|
||||||
activationRadius: ROOM_ACTIVATION_RADIUS,
|
|
||||||
deactivationRadius: ROOM_DEACTIVATION_RADIUS,
|
|
||||||
safeZones: currentSafeZones,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
engine.streamingManager = undefined
|
|
||||||
if (currentResolvedPacks.length > 0) {
|
|
||||||
spawnMonsterPacks(
|
|
||||||
engine.world,
|
|
||||||
currentResolvedPacks,
|
|
||||||
{ x: player.x, y: player.y },
|
|
||||||
Math.max(runtime.widthPx, runtime.heightPx) / 4,
|
|
||||||
engine.terrain,
|
|
||||||
currentSafeZones,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reportMonsterPlan(runtime)
|
|
||||||
|
|
||||||
if (runtime.palette !== null) {
|
|
||||||
const packMonsterIds = runtime.monsterPacks.flatMap(p => p.members.map(m => m.id))
|
|
||||||
const activeMonsterIds = Array.from(new Set([...runtime.monsterTypes, ...packMonsterIds]))
|
|
||||||
if (activeMonsterIds.length > 0) {
|
|
||||||
const loaded = await loadMonsterArtMap(
|
|
||||||
DEFAULT_BASES,
|
|
||||||
activeMonsterIds,
|
|
||||||
runtime.palette,
|
|
||||||
ctx.renderer,
|
|
||||||
ctx.getPackEntityBase(),
|
|
||||||
ctx.getPackEntityAct(),
|
|
||||||
)
|
|
||||||
if (!monsterGen.isCurrent(gen) || runtime.disposed || ctx.getRuntime() !== runtime) {
|
|
||||||
for (const art of loaded.map.values()) {
|
|
||||||
ctx.renderer.deleteAtlas(art.handle)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const keepHandles = new Set<AtlasHandle>()
|
|
||||||
for (const art of loaded.map.values()) {
|
|
||||||
keepHandles.add(art.handle)
|
|
||||||
}
|
|
||||||
for (const oldArt of ctx.getMonsterArtMap?.().values() ?? []) {
|
|
||||||
if (!keepHandles.has(oldArt.handle)) {
|
|
||||||
ctx.renderer.deleteAtlas(oldArt.handle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.setMonsterArtMap(loaded.map)
|
|
||||||
recordMonsterArtLoad(loaded)
|
|
||||||
} else {
|
|
||||||
for (const oldArt of ctx.getMonsterArtMap?.().values() ?? []) {
|
|
||||||
ctx.renderer.deleteAtlas(oldArt.handle)
|
|
||||||
}
|
|
||||||
ctx.setMonsterArtMap(new Map())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
heroGen,
|
|
||||||
monsterGen,
|
|
||||||
lightingGen,
|
|
||||||
invalidateMonsterLoads,
|
|
||||||
syncSorceressAppearance,
|
|
||||||
switchLighting,
|
|
||||||
switchHero,
|
|
||||||
changeHeroClass: switchHero,
|
|
||||||
reloadMonsters,
|
|
||||||
respawnMonsters: reloadMonsters,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function changeHeroClass(
|
|
||||||
controlsOrCtx: HeroLightingAndMonsterControls | HeroLightingAndMonsterControlsContext,
|
|
||||||
newHeroToken: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if ('switchHero' in controlsOrCtx) {
|
|
||||||
return await controlsOrCtx.switchHero(newHeroToken)
|
|
||||||
}
|
|
||||||
const controls = createHeroLightingAndMonsterControls(controlsOrCtx)
|
|
||||||
return await controls.switchHero(newHeroToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function respawnMonsters(
|
|
||||||
controlsOrCtx: HeroLightingAndMonsterControls | HeroLightingAndMonsterControlsContext,
|
|
||||||
options?: { density?: number | string; elite?: number | string },
|
|
||||||
): Promise<void> {
|
|
||||||
if ('reloadMonsters' in controlsOrCtx) {
|
|
||||||
await controlsOrCtx.reloadMonsters(options)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const controls = createHeroLightingAndMonsterControls(controlsOrCtx)
|
|
||||||
await controls.reloadMonsters(options)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createActSceneHudManager(options: {
|
|
||||||
readonly runtime: MapRuntime
|
|
||||||
readonly engine: GameEngine
|
|
||||||
readonly minimap: Minimap
|
|
||||||
readonly status: HTMLElement
|
|
||||||
readonly currentHeroToken: string
|
|
||||||
readonly onWaypointTeleport: (targetAct: number, slug: string, levelId?: number) => void
|
|
||||||
readonly onCastTownPortal: () => void
|
|
||||||
}): Promise<HudManager | null> {
|
|
||||||
const {
|
|
||||||
runtime,
|
|
||||||
engine,
|
|
||||||
minimap,
|
|
||||||
status,
|
|
||||||
currentHeroToken,
|
|
||||||
onWaypointTeleport,
|
|
||||||
onCastTownPortal,
|
|
||||||
} = options
|
|
||||||
const hudCanvas = document.querySelector<HTMLCanvasElement>('#d2-hud-canvas')
|
|
||||||
const hudManager = hudCanvas !== null
|
|
||||||
? new HudManager(hudCanvas, {
|
|
||||||
onToggleAutomap: () => {
|
|
||||||
minimap.toggleVisibility()
|
|
||||||
state.minimapVisible = minimap.visible
|
|
||||||
},
|
|
||||||
onToggleAutomapMode: () => {
|
|
||||||
minimap.toggleMinimapMode()
|
|
||||||
state.minimapVisible = minimap.visible
|
|
||||||
state.automapMode = minimap.mode
|
|
||||||
state.automapDivisor = minimap.divisor
|
|
||||||
},
|
|
||||||
onCycleAutomapFade: () => { minimap.cycleFade() },
|
|
||||||
onToggleAutomapNames: () => { minimap.toggleNames() },
|
|
||||||
onResetAutomapPan: () => { minimap.resetPan() },
|
|
||||||
onPanAutomap: (dx, dy) => minimap.pan(dx, dy),
|
|
||||||
onRenderAutomapOverlay: (ctx, font, logicalWidth, logicalHeight) => {
|
|
||||||
minimap.drawHudOverlay(ctx, font, logicalWidth, logicalHeight)
|
|
||||||
},
|
|
||||||
onWaypointTeleport: (targetAct, slug, levelId) => {
|
|
||||||
onWaypointTeleport(targetAct, slug, levelId)
|
|
||||||
},
|
|
||||||
onCastTownPortal: () => onCastTownPortal(),
|
|
||||||
onDropItemToGround: (item) => {
|
|
||||||
const player = engine.world.player
|
|
||||||
const groundItem = engine.dropItem(item, player.x, player.y)
|
|
||||||
const matchingGround = engine.ground.find((g: { item?: unknown }) => (g.item as unknown) === item)
|
|
||||||
if (matchingGround) {
|
|
||||||
;(matchingGround as { groundItemId?: string }).groundItemId = groundItem.id
|
|
||||||
}
|
|
||||||
const isEn = getTblLang() === 'ENG'
|
|
||||||
status.textContent = isEn
|
|
||||||
? `Dropped "${groundItem.name}" on ground`
|
|
||||||
: `已将「${cleanChiItemName(groundItem.nameZh)}」丢弃在地面`
|
|
||||||
},
|
|
||||||
onDropGoldToGround: (amount) => {
|
|
||||||
const player = engine.world.player
|
|
||||||
let groundGold: unknown = true
|
|
||||||
if (!hudManager?.engine) {
|
|
||||||
if (hudManager && engine.bag) {
|
|
||||||
engine.bag.gold = hudManager.inventory.gold
|
|
||||||
}
|
|
||||||
groundGold = engine.dropGold(amount, player.x, player.y)
|
|
||||||
}
|
|
||||||
if (groundGold) {
|
|
||||||
const isEn = getTblLang() === 'ENG'
|
|
||||||
status.textContent = isEn
|
|
||||||
? `Dropped ${amount.toLocaleString()} Gold on ground`
|
|
||||||
: `已将 ${amount.toLocaleString()} 金币丢弃在地面`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: null
|
|
||||||
if (hudManager !== null) {
|
|
||||||
hudManager.worldPanels.setCurrentLevel(runtime.levelId ?? null)
|
|
||||||
hudManager.worldPanels.setCurrentWaypoint(runtime.waypoints[0]?.waypointId ?? null, runtime.levelId ?? null)
|
|
||||||
hudManager.worldPanels.selectedActTab = runtime.act
|
|
||||||
try {
|
|
||||||
await hudManager.init('/ui')
|
|
||||||
const initialHeroInfo = HERO_CLASSES[currentHeroToken] ?? HERO_CLASSES.so
|
|
||||||
hudManager.switchClass(initialHeroInfo.code as CharacterClassCode)
|
|
||||||
const customCharName = param('charName', '').trim()
|
|
||||||
if (customCharName) hudManager.charSheet.attrs.name = customCharName
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to initialize HudManager:', err)
|
|
||||||
}
|
|
||||||
if (typeof window !== 'undefined' && window.__d2webHud) {
|
|
||||||
(window.__d2webHud as any).proceduralFallbackCount = hudManager.globes.proceduralFallbackCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hudManager
|
|
||||||
}
|
|
||||||
|
|
||||||
export function wireBootToolbarSelectors(options: {
|
|
||||||
readonly isEn: boolean
|
|
||||||
readonly heroSelect: HTMLSelectElement | null
|
|
||||||
readonly actSelect: HTMLSelectElement
|
|
||||||
readonly sceneSelect: HTMLSelectElement
|
|
||||||
readonly variantSelect: HTMLSelectElement
|
|
||||||
readonly lightingSelect: HTMLSelectElement | null
|
|
||||||
readonly langSwitch: HTMLAnchorElement | null
|
|
||||||
}): number {
|
|
||||||
const {
|
|
||||||
isEn,
|
|
||||||
heroSelect,
|
|
||||||
actSelect,
|
|
||||||
sceneSelect,
|
|
||||||
variantSelect,
|
|
||||||
lightingSelect,
|
|
||||||
langSwitch,
|
|
||||||
} = options
|
|
||||||
|
|
||||||
if (langSwitch !== null && typeof location !== 'undefined') {
|
|
||||||
const targetLang: TblLang = isEn ? 'CHI' : 'ENG'
|
|
||||||
langSwitch.textContent = isEn ? 'Lang: ZH' : '语言: EN'
|
|
||||||
langSwitch.href = buildLangSwitchHref(targetLang, location.pathname, location.search)
|
|
||||||
langSwitch.addEventListener('click', () => {
|
|
||||||
langSwitch.href = buildLangSwitchHref(targetLang, location.pathname, location.search)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lightingSelect !== null) {
|
|
||||||
lightingSelect.disabled = false
|
|
||||||
lightingSelect.value = normalizeLightingPreset(param('lighting', 'auto'))
|
|
||||||
lightingSelect.onchange = () => {
|
|
||||||
const params = new URLSearchParams(location.search)
|
|
||||||
if (lightingSelect.value === 'auto') params.delete('lighting')
|
|
||||||
else params.set('lighting', lightingSelect.value)
|
|
||||||
location.search = params.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (heroSelect !== null) {
|
|
||||||
heroSelect.disabled = false
|
|
||||||
heroSelect.value = normalizeHeroToken(param('hero', param('class', 'so')))
|
|
||||||
heroSelect.onchange = () => {
|
|
||||||
const params = new URLSearchParams(location.search)
|
|
||||||
params.set('hero', heroSelect.value)
|
|
||||||
location.search = params.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const densitySelect = document.querySelector<HTMLSelectElement>('#density')
|
|
||||||
const eliteSelect = document.querySelector<HTMLSelectElement>('#elite')
|
|
||||||
if (densitySelect !== null) {
|
|
||||||
densitySelect.disabled = false
|
|
||||||
densitySelect.value = String(parseDensityMultiplier(param('density', '1')))
|
|
||||||
updateSelectorWarningStyle(densitySelect, '1')
|
|
||||||
}
|
|
||||||
if (eliteSelect !== null) {
|
|
||||||
eliteSelect.disabled = false
|
|
||||||
eliteSelect.value = String(parseEliteMultiplier(param('elite', '1')))
|
|
||||||
updateSelectorWarningStyle(eliteSelect, '1')
|
|
||||||
}
|
|
||||||
actSelect.disabled = false
|
|
||||||
sceneSelect.disabled = true
|
|
||||||
variantSelect.disabled = true
|
|
||||||
|
|
||||||
if (typeof Image !== 'undefined') {
|
|
||||||
const criticalImages = [
|
|
||||||
'/ui/ctrlpnl-800.png',
|
|
||||||
'/ui/globe-life.png',
|
|
||||||
'/ui/globe-mana.png',
|
|
||||||
'/ui/globe-poison.png',
|
|
||||||
'/ui/overlap-left.png',
|
|
||||||
'/ui/overlap-right.png',
|
|
||||||
'/ui/minipanel.png',
|
|
||||||
'/ui/minipanel-btns.png',
|
|
||||||
'/ui/runbutton.png',
|
|
||||||
'/ui/menubutton.png',
|
|
||||||
'/ui/border-left.png',
|
|
||||||
'/ui/border-right.png',
|
|
||||||
]
|
|
||||||
for (const src of criticalImages) {
|
|
||||||
const img = new Image()
|
|
||||||
img.src = src
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const act = Math.min(ACT_COUNT, Math.max(1, Number(param('act', '1'))))
|
|
||||||
state.act = act
|
|
||||||
actSelect.value = String(act)
|
|
||||||
actSelect.addEventListener('change', () => {
|
|
||||||
const params = new URLSearchParams(location.search)
|
|
||||||
params.set('act', actSelect.value)
|
|
||||||
params.delete('level')
|
|
||||||
location.search = params.toString()
|
|
||||||
})
|
|
||||||
return act
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fill the level picker from a pack index and wire it to navigation.
|
|
||||||
*
|
|
||||||
* Every baked map is one entry, grouped by act. Picking one reloads the page with
|
|
||||||
* `?act=` and `?level=`, which is the same query the loader already understands,
|
|
||||||
* so the picker needs no second loading path.
|
|
||||||
*
|
|
||||||
* @param packBase - directory holding `index.json`.
|
|
||||||
* @param current - the act and the entry label currently displayed (`runtime.quadrant`),
|
|
||||||
* so the list marks exactly the map that is on screen.
|
|
||||||
*/
|
|
||||||
export async function populatePackSelectors(
|
|
||||||
packBase: string,
|
|
||||||
current: { act: number; label: string },
|
|
||||||
): Promise<void> {
|
|
||||||
const actSelect = document.querySelector<HTMLSelectElement>('#act')
|
|
||||||
const sceneSelect = document.querySelector<HTMLSelectElement>('#scene')
|
|
||||||
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')
|
|
||||||
if (actSelect === null || sceneSelect === null || variantSelect === null) return
|
|
||||||
let index: PackIndex
|
|
||||||
try {
|
|
||||||
index = await fetchJson<PackIndex>(`${packBase}/index.json`)
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const isEn = getTblLang() === 'ENG'
|
|
||||||
|
|
||||||
/** A scene type: one `Levels.txt` level, with every baked DS1 variant under it. */
|
|
||||||
interface Scene {
|
|
||||||
readonly slug: string
|
|
||||||
readonly name: string
|
|
||||||
readonly variants: readonly PackIndex['levels'][number][]
|
|
||||||
}
|
|
||||||
|
|
||||||
const scenesByAct = new Map<number, Scene[]>()
|
|
||||||
for (const entry of index.levels) {
|
|
||||||
let scenes = scenesByAct.get(entry.act)
|
|
||||||
if (scenes === undefined) { scenes = []; scenesByAct.set(entry.act, scenes) }
|
|
||||||
let scene = scenes.find(candidate => candidate.slug === entry.slug)
|
|
||||||
if (scene === undefined) {
|
|
||||||
scene = {
|
|
||||||
slug: entry.slug,
|
|
||||||
name: sceneDisplayName(entry.slug, entry.levelName, isEn ? 'ENG' : 'CHI'),
|
|
||||||
variants: [],
|
|
||||||
}
|
|
||||||
scenes.push(scene)
|
|
||||||
}
|
|
||||||
;(scene.variants as PackIndex['levels'][number][]).push(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The suffix after the level slug, which is the DS1's own name fragment. */
|
|
||||||
const suffixOf = (entry: PackIndex['levels'][number]): string =>
|
|
||||||
entry.label.startsWith(`${entry.slug}-`) ? entry.label.slice(entry.slug.length + 1) : entry.label
|
|
||||||
|
|
||||||
const optionFor = (value: string, text: string, selected: boolean): HTMLOptionElement => {
|
|
||||||
const option = document.createElement('option')
|
|
||||||
option.value = value
|
|
||||||
option.textContent = text
|
|
||||||
option.selected = selected
|
|
||||||
return option
|
|
||||||
}
|
|
||||||
|
|
||||||
const acts = [...scenesByAct.keys()].sort((left, right) => left - right)
|
|
||||||
actSelect.replaceChildren(...acts.map(act => optionFor(
|
|
||||||
String(act),
|
|
||||||
isEn
|
|
||||||
? `${ACT_NAMES_EN[act] ?? `Act ${String(act)}`}`
|
|
||||||
: `${ACT_NAMES_ZH[act] ?? `第 ${String(act)} 章`}`,
|
|
||||||
act === current.act,
|
|
||||||
)))
|
|
||||||
actSelect.disabled = acts.length === 0
|
|
||||||
|
|
||||||
const scenes = scenesByAct.get(current.act) ?? []
|
|
||||||
const currentEntry = index.levels.find(entry => entry.act === current.act && entry.label === current.label)
|
|
||||||
const currentSceneSlug = currentEntry?.slug ?? scenes[0]?.slug ?? ''
|
|
||||||
sceneSelect.replaceChildren(...scenes.map(scene => optionFor(
|
|
||||||
scene.slug,
|
|
||||||
scene.name,
|
|
||||||
scene.slug === currentSceneSlug,
|
|
||||||
)))
|
|
||||||
sceneSelect.disabled = scenes.length === 0
|
|
||||||
|
|
||||||
const populateVariants = (slug: string, selectedLabel: string): void => {
|
|
||||||
const scene = scenes.find(candidate => candidate.slug === slug)
|
|
||||||
const variants = scene?.variants ?? []
|
|
||||||
// A single-variant scene still shows its own DS1 so the third selector is never
|
|
||||||
// an empty box; the option text repeats the scene name only when it is the only one.
|
|
||||||
variantSelect.replaceChildren(...variants.map(entry => {
|
|
||||||
const suffix = suffixOf(entry)
|
|
||||||
const label = variants.length > 1
|
|
||||||
? variantDisplayLabel(suffix, isEn ? 'ENG' : 'CHI')
|
|
||||||
: isEn
|
|
||||||
? `${scene?.name ?? entry.levelName} (${suffix})`
|
|
||||||
: `${scene?.name ?? entry.levelName}(${suffix})`
|
|
||||||
return optionFor(entry.label, label, entry.label === selectedLabel)
|
|
||||||
}))
|
|
||||||
variantSelect.disabled = variants.length === 0
|
|
||||||
}
|
|
||||||
populateVariants(currentSceneSlug, current.label)
|
|
||||||
variantSelect.dataset.scene = currentSceneSlug
|
|
||||||
|
|
||||||
const go = (params: URLSearchParams): void => { location.search = params.toString() }
|
|
||||||
const navigateToLabel = (label: string, act: number): void => {
|
|
||||||
const params = new URLSearchParams(location.search)
|
|
||||||
params.set('act', String(act))
|
|
||||||
params.set('level', label)
|
|
||||||
go(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
actSelect.onchange = () => {
|
|
||||||
const act = Number(actSelect.value)
|
|
||||||
const first = scenesByAct.get(act)?.[0]
|
|
||||||
// Keep the address bar and the map in sync: an act with no baked scene yet (there
|
|
||||||
// are none today) simply leaves the current map alone.
|
|
||||||
if (first === undefined) return
|
|
||||||
navigateToLabel(first.variants[0]?.label ?? '', act)
|
|
||||||
}
|
|
||||||
sceneSelect.onchange = () => {
|
|
||||||
const scene = scenes.find(candidate => candidate.slug === sceneSelect.value)
|
|
||||||
if (scene === undefined) return
|
|
||||||
populateVariants(scene.slug, '')
|
|
||||||
navigateToLabel(scene.variants[0]?.label ?? '', current.act)
|
|
||||||
}
|
|
||||||
variantSelect.onchange = () => { navigateToLabel(variantSelect.value, current.act) }
|
|
||||||
}
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
/**
|
|
||||||
* Level transitions: the fade, and the arithmetic of arriving.
|
|
||||||
*
|
|
||||||
* Two kinds of crossing, one pipeline. Walking through a gap in the border
|
|
||||||
* between the Cold Plains and the Stony Field is, in the original game, not a
|
|
||||||
* transition at all — the two levels share one coordinate space and the engine
|
|
||||||
* streams the neighbour in as you approach. This engine does not have that
|
|
||||||
* shared space yet, so both kinds load a fresh level; the seam simply gets a
|
|
||||||
* much shorter fade, short enough to read as continuous motion rather than as
|
|
||||||
* a loading screen.
|
|
||||||
*
|
|
||||||
* The fade is presentation, not simulation. It runs on wall-clock time in a DOM
|
|
||||||
* overlay and never touches the fixed-rate tick, because the tick has to stay a
|
|
||||||
* pure function of the inputs for lockstep networking to work.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
SEAM_FADE_MS,
|
|
||||||
WARP_FADE_MS,
|
|
||||||
subTileToScene,
|
|
||||||
type ScenePoint,
|
|
||||||
} from '../../common/world/d2map.ts'
|
|
||||||
|
|
||||||
export { SEAM_FADE_MS, WARP_FADE_MS, subTileToScene, type ScenePoint }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The fade overlay.
|
|
||||||
*
|
|
||||||
* A plain absolutely-positioned div rather than a renderer pass: it has to
|
|
||||||
* cover the HUD and the NPC name labels, which are DOM, and a WebGL quad would
|
|
||||||
* sit underneath them.
|
|
||||||
*/
|
|
||||||
export class FadeOverlay {
|
|
||||||
private readonly element: HTMLElement
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param host - the element to cover; the overlay is appended to it.
|
|
||||||
*/
|
|
||||||
constructor(host: HTMLElement) {
|
|
||||||
const element = document.createElement('div')
|
|
||||||
element.style.position = 'fixed'
|
|
||||||
element.style.inset = '0'
|
|
||||||
element.style.background = '#000'
|
|
||||||
element.style.opacity = '0'
|
|
||||||
element.style.pointerEvents = 'none'
|
|
||||||
element.style.zIndex = '50'
|
|
||||||
element.style.transition = 'opacity 0ms linear'
|
|
||||||
host.appendChild(element)
|
|
||||||
this.element = element
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fade to black.
|
|
||||||
*
|
|
||||||
* @param durationMs - how long to take.
|
|
||||||
*/
|
|
||||||
async out(durationMs: number): Promise<void> {
|
|
||||||
await this.ramp('1', durationMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fade back in.
|
|
||||||
*
|
|
||||||
* @param durationMs - how long to take.
|
|
||||||
*/
|
|
||||||
async in(durationMs: number): Promise<void> {
|
|
||||||
await this.ramp('0', durationMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove the overlay from the page. */
|
|
||||||
dispose(): void {
|
|
||||||
this.element.remove()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run one leg of the fade and resolve when it is over.
|
|
||||||
*
|
|
||||||
* @param opacity - the target opacity.
|
|
||||||
* @param durationMs - how long to take.
|
|
||||||
*/
|
|
||||||
private async ramp(opacity: string, durationMs: number): Promise<void> {
|
|
||||||
this.element.style.transition = `opacity ${String(durationMs)}ms linear`
|
|
||||||
// Read back a layout property so the browser commits the starting opacity
|
|
||||||
// before the new one is set; without this the two assignments coalesce and
|
|
||||||
// the transition never runs.
|
|
||||||
void this.element.offsetHeight
|
|
||||||
this.element.style.opacity = opacity
|
|
||||||
await new Promise<void>(resolve => { setTimeout(resolve, durationMs) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,17 +0,0 @@
|
||||||
export {
|
|
||||||
OutboundRateLimiter,
|
|
||||||
DEFAULT_RATE_LIMITS,
|
|
||||||
classifyClientCommand,
|
|
||||||
type CommandRateCategory,
|
|
||||||
type RateLimitAcquireResult,
|
|
||||||
type RateLimiterConfig,
|
|
||||||
} from './rate-limiter.ts'
|
|
||||||
|
|
||||||
export {
|
|
||||||
OnlineSession,
|
|
||||||
decodeCapturedGameS2cPacket,
|
|
||||||
type OnlineSessionLobbySnapshot,
|
|
||||||
type OnlineSessionLoginParams,
|
|
||||||
type OnlineSessionOptions,
|
|
||||||
type OnlineSessionPhase,
|
|
||||||
} from './online-session.ts'
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
export class ViewportCamera {
|
|
||||||
zoom: number
|
|
||||||
manualZoom = false
|
|
||||||
|
|
||||||
private readonly onWheel: (event: WheelEvent) => void
|
|
||||||
private readonly onKeyZoom: (event: KeyboardEvent) => void
|
|
||||||
private readonly onResize: () => void
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private canvas: HTMLCanvasElement,
|
|
||||||
public mapWidthPx: number,
|
|
||||||
public mapHeightPx: number
|
|
||||||
) {
|
|
||||||
this.zoom = ViewportCamera.coverZoom(mapWidthPx, mapHeightPx, ViewportCamera.syncCanvasSize(canvas))
|
|
||||||
|
|
||||||
this.onWheel = (event: WheelEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
this.setZoom(this.zoom * (event.deltaY < 0 ? 1.12 : 1 / 1.12))
|
|
||||||
}
|
|
||||||
|
|
||||||
this.onKeyZoom = (event: KeyboardEvent) => {
|
|
||||||
if (event.code === 'Equal' || event.code === 'NumpadAdd') this.setZoom(this.zoom * 1.15)
|
|
||||||
if (event.code === 'Minus' || event.code === 'NumpadSubtract') this.setZoom(this.zoom / 1.15)
|
|
||||||
if (event.code === 'Digit0') {
|
|
||||||
this.manualZoom = false
|
|
||||||
this.zoom = ViewportCamera.coverZoom(this.mapWidthPx, this.mapHeightPx, ViewportCamera.syncCanvasSize(this.canvas))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.onResize = () => {
|
|
||||||
const size = ViewportCamera.syncCanvasSize(this.canvas)
|
|
||||||
if (!this.manualZoom) {
|
|
||||||
this.zoom = ViewportCamera.coverZoom(this.mapWidthPx, this.mapHeightPx, size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setZoom(value: number): void {
|
|
||||||
this.manualZoom = true
|
|
||||||
this.zoom = Math.min(8, Math.max(0.4, value))
|
|
||||||
}
|
|
||||||
|
|
||||||
attach(): void {
|
|
||||||
this.canvas.addEventListener('wheel', this.onWheel, { passive: false })
|
|
||||||
window.addEventListener('keydown', this.onKeyZoom)
|
|
||||||
window.addEventListener('resize', this.onResize)
|
|
||||||
}
|
|
||||||
|
|
||||||
detach(): void {
|
|
||||||
this.canvas.removeEventListener('wheel', this.onWheel)
|
|
||||||
window.removeEventListener('keydown', this.onKeyZoom)
|
|
||||||
window.removeEventListener('resize', this.onResize)
|
|
||||||
}
|
|
||||||
|
|
||||||
static coverZoom(mapWidth: number, mapHeight: number, viewport: { width: number; height: number }): number {
|
|
||||||
if (mapWidth <= 0 || mapHeight <= 0) return 1.6
|
|
||||||
const cover = Math.max(viewport.width / mapWidth, viewport.height / mapHeight)
|
|
||||||
return Math.min(6, Math.max(0.9, cover))
|
|
||||||
}
|
|
||||||
|
|
||||||
static syncCanvasSize(canvas: HTMLCanvasElement): { width: number; height: number } {
|
|
||||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
|
||||||
const cssWidth = Math.max(320, canvas.clientWidth || window.innerWidth)
|
|
||||||
const cssHeight = Math.max(240, canvas.clientHeight || window.innerHeight)
|
|
||||||
const maxPixels = 1920 * 1200
|
|
||||||
const scale = Math.min(dpr, Math.sqrt(maxPixels / (cssWidth * cssHeight)))
|
|
||||||
const width = Math.max(1, Math.round(cssWidth * scale))
|
|
||||||
const height = Math.max(1, Math.round(cssHeight * scale))
|
|
||||||
if (canvas.width !== width || canvas.height !== height) {
|
|
||||||
canvas.width = width
|
|
||||||
canvas.height = height
|
|
||||||
}
|
|
||||||
return { width, height }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,265 +0,0 @@
|
||||||
/**
|
|
||||||
* Keyboard input, resolved into the game's own vocabulary.
|
|
||||||
*
|
|
||||||
* Two things are game facts rather than conveniences:
|
|
||||||
*
|
|
||||||
* - Diablo's eight facings are ordered starting at **south and turning west**
|
|
||||||
* (`Direction::South, SouthWest, West, NorthWest, North, ...`), and that
|
|
||||||
* order is exactly the group order inside a direction-animated sprite file.
|
|
||||||
* So facing is computed from the movement vector, not from which key was
|
|
||||||
* pressed.
|
|
||||||
* - Diagonal movement is normalised: the original moves diagonally at the same
|
|
||||||
* speed as along an axis, not at √2 times it.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Diablo's facing order: the index of a direction inside a sprite file. */
|
|
||||||
export const DIRECTION = {
|
|
||||||
South: 0,
|
|
||||||
SouthWest: 1,
|
|
||||||
West: 2,
|
|
||||||
NorthWest: 3,
|
|
||||||
North: 4,
|
|
||||||
NorthEast: 5,
|
|
||||||
East: 6,
|
|
||||||
SouthEast: 7,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/** A direction index (0..7) or -1 for "no direction". */
|
|
||||||
export type Direction = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7
|
|
||||||
|
|
||||||
/** A normalized movement request in screen coordinates (y grows downward). */
|
|
||||||
export interface Movement {
|
|
||||||
/** Horizontal component in -1..1. */
|
|
||||||
readonly x: number
|
|
||||||
/** Vertical component in -1..1. */
|
|
||||||
readonly y: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keys that mean "attack". */
|
|
||||||
const ATTACK_KEYS = new Set(['KeyJ', 'Enter'])
|
|
||||||
/** Keys that mean "pick up". */
|
|
||||||
const PICKUP_KEYS = new Set(['KeyE', 'KeyF'])
|
|
||||||
/** Keys that mean "talk to whoever is in reach". */
|
|
||||||
const TALK_KEYS = new Set<string>()
|
|
||||||
/** Keys that save and load the game. */
|
|
||||||
const SAVE_KEYS = new Set(['KeyK'])
|
|
||||||
const LOAD_KEYS = new Set(['KeyL'])
|
|
||||||
/** Keys that toggle the automap. `Tab` is where Diablo II puts it. */
|
|
||||||
const MAP_KEYS = new Set(['Tab', 'KeyM'])
|
|
||||||
/** Keys that take the stairs, cave mouth or portal the player is standing on. */
|
|
||||||
const USE_KEYS = new Set<string>()
|
|
||||||
/** Keys that cast a town portal. */
|
|
||||||
const PORTAL_KEYS = new Set(['KeyP'])
|
|
||||||
/** Keys that open the waypoint destination list. */
|
|
||||||
const WAYPOINT_KEYS = new Set<string>()
|
|
||||||
/** Shift keys for force stand-still attack. */
|
|
||||||
const SHIFT_KEYS = new Set(['ShiftLeft', 'ShiftRight'])
|
|
||||||
|
|
||||||
/** Recognised keys, mapped to a movement contribution. */
|
|
||||||
const KEYS: Readonly<Record<string, readonly [number, number]>> = {
|
|
||||||
ArrowUp: [0, -1],
|
|
||||||
ArrowDown: [0, 1],
|
|
||||||
ArrowLeft: [-1, 0],
|
|
||||||
ArrowRight: [1, 0],
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Facing for a movement vector.
|
|
||||||
*
|
|
||||||
* @param movement - normalized movement.
|
|
||||||
* @returns the direction index, or null when there is no movement.
|
|
||||||
*/
|
|
||||||
export function directionOf(movement: Movement): Direction | null {
|
|
||||||
const sx = Math.sign(movement.x)
|
|
||||||
const sy = Math.sign(movement.y)
|
|
||||||
if (sx === 0 && sy === 0) return null
|
|
||||||
if (sx === 0) return sy > 0 ? DIRECTION.South : DIRECTION.North
|
|
||||||
if (sy === 0) return sx > 0 ? DIRECTION.East : DIRECTION.West
|
|
||||||
if (sx < 0) return sy > 0 ? DIRECTION.SouthWest : DIRECTION.NorthWest
|
|
||||||
return sy > 0 ? DIRECTION.SouthEast : DIRECTION.NorthEast
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Key state for one window, sampled once per tick.
|
|
||||||
*/
|
|
||||||
export class KeyboardInput {
|
|
||||||
private readonly held = new Set<string>()
|
|
||||||
/**
|
|
||||||
* Keys pressed since the last time anyone looked.
|
|
||||||
*
|
|
||||||
* Movement and attack are held states: the question is "is the key down this
|
|
||||||
* tick". Taking a staircase is not — holding `G` on a cave mouth must not
|
|
||||||
* re-trigger the transition sixty times a second — so those keys are edge
|
|
||||||
* triggered, recorded here on the way down and drained by the reader.
|
|
||||||
*/
|
|
||||||
private readonly pressed = new Set<string>()
|
|
||||||
|
|
||||||
private readonly onKeyDown = (event: KeyboardEvent): void => {
|
|
||||||
const oneShot = MAP_KEYS.has(event.code)
|
|
||||||
|| USE_KEYS.has(event.code)
|
|
||||||
|| PORTAL_KEYS.has(event.code)
|
|
||||||
|| WAYPOINT_KEYS.has(event.code)
|
|
||||||
const recognised = KEYS[event.code] !== undefined
|
|
||||||
|| ATTACK_KEYS.has(event.code)
|
|
||||||
|| PICKUP_KEYS.has(event.code)
|
|
||||||
|| TALK_KEYS.has(event.code)
|
|
||||||
|| SAVE_KEYS.has(event.code)
|
|
||||||
|| LOAD_KEYS.has(event.code)
|
|
||||||
|| SHIFT_KEYS.has(event.code)
|
|
||||||
|| oneShot
|
|
||||||
if (!recognised) return
|
|
||||||
// Arrows scroll the page otherwise, and held keys would repeat.
|
|
||||||
event.preventDefault()
|
|
||||||
// Auto-repeat re-fires keydown without an intervening keyup; ignoring it
|
|
||||||
// is what makes these presses rather than a stream.
|
|
||||||
if (oneShot && !this.held.has(event.code)) this.pressed.add(event.code)
|
|
||||||
this.held.add(event.code)
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly onKeyUp = (event: KeyboardEvent): void => {
|
|
||||||
this.held.delete(event.code)
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly onBlur = (): void => {
|
|
||||||
this.held.clear()
|
|
||||||
this.pressed.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly target: Window
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param target - the window to listen on.
|
|
||||||
*/
|
|
||||||
constructor(target: Window = window) {
|
|
||||||
this.target = target
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Start listening. */
|
|
||||||
attach(): void {
|
|
||||||
this.target.addEventListener('keydown', this.onKeyDown)
|
|
||||||
this.target.addEventListener('keyup', this.onKeyUp)
|
|
||||||
this.target.addEventListener('blur', this.onBlur)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stop listening and release every key. */
|
|
||||||
detach(): void {
|
|
||||||
this.target.removeEventListener('keydown', this.onKeyDown)
|
|
||||||
this.target.removeEventListener('keyup', this.onKeyUp)
|
|
||||||
this.target.removeEventListener('blur', this.onBlur)
|
|
||||||
this.held.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The movement requested this tick, normalised so diagonals are not faster.
|
|
||||||
*
|
|
||||||
* @returns the movement vector.
|
|
||||||
*/
|
|
||||||
movement(): Movement {
|
|
||||||
let x = 0
|
|
||||||
let y = 0
|
|
||||||
for (const code of this.held) {
|
|
||||||
const contribution = KEYS[code]
|
|
||||||
if (contribution === undefined) continue
|
|
||||||
x += contribution[0]
|
|
||||||
y += contribution[1]
|
|
||||||
}
|
|
||||||
const magnitude = Math.hypot(x, y)
|
|
||||||
if (magnitude === 0) return { x: 0, y: 0 }
|
|
||||||
return { x: x / magnitude, y: y / magnitude }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether any movement key is held. */
|
|
||||||
get active(): boolean {
|
|
||||||
for (const code of this.held) if (KEYS[code] !== undefined) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether an attack key is held this tick. */
|
|
||||||
get attacking(): boolean {
|
|
||||||
for (const code of this.held) if (ATTACK_KEYS.has(code)) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether a pickup key is held this tick. */
|
|
||||||
get pickingUp(): boolean {
|
|
||||||
for (const code of this.held) if (PICKUP_KEYS.has(code)) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether a talk key is held this tick. */
|
|
||||||
get talking(): boolean {
|
|
||||||
for (const code of this.held) if (TALK_KEYS.has(code)) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether a save key is held this tick. */
|
|
||||||
get saving(): boolean {
|
|
||||||
for (const code of this.held) if (SAVE_KEYS.has(code)) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether a load key is held this tick. */
|
|
||||||
get loading(): boolean {
|
|
||||||
for (const code of this.held) if (LOAD_KEYS.has(code)) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Skill slots requested by the number keys currently held. */
|
|
||||||
get digits(): readonly number[] {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether Shift (left or right) is held down. */
|
|
||||||
get shiftHeld(): boolean {
|
|
||||||
return this.held.has('ShiftLeft') || this.held.has('ShiftRight')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the automap key was pressed since this was last called.
|
|
||||||
*
|
|
||||||
* @returns true once per press.
|
|
||||||
*/
|
|
||||||
takeMapToggle(): boolean {
|
|
||||||
return this.takeOnce(MAP_KEYS)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the use key was pressed since this was last called.
|
|
||||||
*
|
|
||||||
* @returns true once per press.
|
|
||||||
*/
|
|
||||||
takeUse(): boolean {
|
|
||||||
return this.takeOnce(USE_KEYS)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the town portal key was pressed since this was last called.
|
|
||||||
*
|
|
||||||
* @returns true once per press.
|
|
||||||
*/
|
|
||||||
takePortal(): boolean {
|
|
||||||
return this.takeOnce(PORTAL_KEYS)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the waypoint key was pressed since this was last called.
|
|
||||||
*
|
|
||||||
* @returns true once per press.
|
|
||||||
*/
|
|
||||||
takeWaypoint(): boolean {
|
|
||||||
return this.takeOnce(WAYPOINT_KEYS)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Consume a pending press.
|
|
||||||
*
|
|
||||||
* @param keys - the codes that count.
|
|
||||||
* @returns true when one of them was pressed, clearing it.
|
|
||||||
*/
|
|
||||||
private takeOnce(keys: ReadonlySet<string>): boolean {
|
|
||||||
for (const code of keys) {
|
|
||||||
if (this.pressed.delete(code)) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,318 +0,0 @@
|
||||||
/**
|
|
||||||
* Fixed-timestep game loop.
|
|
||||||
*
|
|
||||||
* Diablo's simulation runs at 25 ticks per second, and that rate is not
|
|
||||||
* cosmetic: animation frames, movement, and monster AI all advance per tick,
|
|
||||||
* so anything derived from wall-clock time would diverge from the original
|
|
||||||
* behaviour.
|
|
||||||
*
|
|
||||||
* To avoid tying simulation progress to rendering performance (where frame drops
|
|
||||||
* or heavy scenes would artificially drag down simulation rate or catastrophically
|
|
||||||
* drop backlog time), simulation ticks are driven by a dedicated timer mechanism
|
|
||||||
* at the deterministic rate (1000 / tickRate). Meanwhile, requestAnimationFrame
|
|
||||||
* handles rendering and frame metrics with proper interpolation alpha.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Loop wiring. */
|
|
||||||
export interface LoopOptions {
|
|
||||||
/** Ticks per second (Diablo's simulation rate: 25). */
|
|
||||||
readonly tickRate: number
|
|
||||||
/** Advance the simulation by one tick. */
|
|
||||||
readonly onTick: (tick: number) => void
|
|
||||||
/**
|
|
||||||
* Draw one frame.
|
|
||||||
*
|
|
||||||
* @param alpha - how far the next tick is into the future, in `0..1`, for
|
|
||||||
* interpolation between ticks.
|
|
||||||
* @param frameMs - wall-clock duration of the previous frame.
|
|
||||||
*/
|
|
||||||
readonly onRender: (alpha: number, frameMs: number) => void
|
|
||||||
/** Most ticks to run in one timer/frame step before clamping the backlog. */
|
|
||||||
readonly maxCatchUp?: number
|
|
||||||
/** Optional custom clock source for testing. Defaults to performance.now. */
|
|
||||||
readonly clock?: () => number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A fixed-rate simulation loop decoupled from rendering.
|
|
||||||
*/
|
|
||||||
export class GameLoop {
|
|
||||||
private readonly options: LoopOptions
|
|
||||||
readonly tickMs: number
|
|
||||||
readonly maxCatchUp: number
|
|
||||||
|
|
||||||
private rawAccumulator = 0
|
|
||||||
private consumed = 0
|
|
||||||
private running = false
|
|
||||||
|
|
||||||
// Simulation timer & metrics
|
|
||||||
private simTimerHandle: ReturnType<typeof setTimeout> | null = null
|
|
||||||
private lastSimTime = 0
|
|
||||||
private simWindowStart = 0
|
|
||||||
private simTicksInWindow = 0
|
|
||||||
private simVirtualElapsed = 0
|
|
||||||
private measuredSimRate = 0
|
|
||||||
|
|
||||||
// Render loop & metrics
|
|
||||||
private renderHandle = 0
|
|
||||||
private lastRenderTime = 0
|
|
||||||
private lastFrameMs = 0
|
|
||||||
private renderWindowStart = 0
|
|
||||||
private renderFramesInWindow = 0
|
|
||||||
private measuredFps = 0
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param options - loop wiring.
|
|
||||||
*/
|
|
||||||
constructor(options: LoopOptions) {
|
|
||||||
this.options = options
|
|
||||||
this.tickMs = 1000 / options.tickRate
|
|
||||||
this.maxCatchUp = options.maxCatchUp ?? 10
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Current time in ms from options.clock or performance.now. */
|
|
||||||
private now(): number {
|
|
||||||
return this.options.clock ? this.options.clock() : performance.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ticks simulated since the loop started. */
|
|
||||||
get tick(): number {
|
|
||||||
return this.consumed
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Measured simulation ticks per second over the last window (or target rate when active). */
|
|
||||||
get tickRate(): number {
|
|
||||||
return this.simTps
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Simulation ticks per second. */
|
|
||||||
get simTps(): number {
|
|
||||||
if (this.measuredSimRate > 0) return this.measuredSimRate
|
|
||||||
const elapsed = this.now() - this.simWindowStart
|
|
||||||
if (elapsed >= 200 && this.simTicksInWindow > 0) {
|
|
||||||
return (this.simTicksInWindow * 1000) / elapsed
|
|
||||||
}
|
|
||||||
if (this.simVirtualElapsed >= 200 && this.simTicksInWindow > 0) {
|
|
||||||
return (this.simTicksInWindow * 1000) / this.simVirtualElapsed
|
|
||||||
}
|
|
||||||
return this.running ? this.options.tickRate : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Measured render frames per second. */
|
|
||||||
get fps(): number {
|
|
||||||
return this.renderFps
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render frames per second. */
|
|
||||||
get renderFps(): number {
|
|
||||||
if (this.measuredFps > 0) return this.measuredFps
|
|
||||||
const elapsed = this.now() - this.renderWindowStart
|
|
||||||
if (elapsed >= 200 && this.renderFramesInWindow > 0) {
|
|
||||||
return (this.renderFramesInWindow * 1000) / elapsed
|
|
||||||
}
|
|
||||||
return this.lastFrameMs > 0 ? Math.min(120, 1000 / this.lastFrameMs) : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Duration of the last render frame in milliseconds. */
|
|
||||||
get frameMs(): number {
|
|
||||||
return this.lastFrameMs
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the loop is currently running. */
|
|
||||||
get isRunning(): boolean {
|
|
||||||
return this.running
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Current simulation accumulator in milliseconds. */
|
|
||||||
get accumulator(): number {
|
|
||||||
const elapsedSinceLastSim = this.running ? Math.max(0, this.now() - this.lastSimTime) : 0
|
|
||||||
return this.rawAccumulator + elapsedSinceLastSim
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Interpolation factor in 0..1 between the current tick and the next tick. */
|
|
||||||
get alpha(): number {
|
|
||||||
return Math.min(1, Math.max(0, this.accumulator / this.tickMs))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manually advance the simulation by deltaMs milliseconds.
|
|
||||||
* Useful for headless simulation, test clocks, and deterministic testing.
|
|
||||||
*
|
|
||||||
* @param deltaMs - Milliseconds to advance.
|
|
||||||
* @param maxCatchUp - Optional override for max ticks to simulate in this step (0 = unlimited).
|
|
||||||
*/
|
|
||||||
step(deltaMs: number, maxCatchUp?: number): void {
|
|
||||||
if (deltaMs <= 0) return
|
|
||||||
this.rawAccumulator += deltaMs
|
|
||||||
this.lastSimTime = this.now()
|
|
||||||
|
|
||||||
const maxSteps = maxCatchUp !== undefined ? maxCatchUp : (this.options.maxCatchUp !== undefined ? this.maxCatchUp : 0)
|
|
||||||
let steps = 0
|
|
||||||
while (this.rawAccumulator >= this.tickMs && (maxSteps <= 0 || steps < maxSteps)) {
|
|
||||||
this.rawAccumulator -= this.tickMs
|
|
||||||
this.consumed += 1
|
|
||||||
this.simTicksInWindow += 1
|
|
||||||
this.options.onTick(this.consumed)
|
|
||||||
steps += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (maxSteps > 0) {
|
|
||||||
const maxBacklog = this.tickMs * maxSteps
|
|
||||||
if (this.rawAccumulator > maxBacklog) {
|
|
||||||
this.rawAccumulator = maxBacklog
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.simVirtualElapsed += deltaMs
|
|
||||||
if (this.simVirtualElapsed >= 1000) {
|
|
||||||
this.measuredSimRate = (this.simTicksInWindow * 1000) / this.simVirtualElapsed
|
|
||||||
this.simVirtualElapsed = 0
|
|
||||||
this.simTicksInWindow = 0
|
|
||||||
} else if (this.simVirtualElapsed >= 200) {
|
|
||||||
this.measuredSimRate = (this.simTicksInWindow * 1000) / this.simVirtualElapsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manually trigger one render call.
|
|
||||||
*/
|
|
||||||
render(frameMs?: number): void {
|
|
||||||
const elapsed = frameMs ?? (this.lastFrameMs > 0 ? this.lastFrameMs : 16.6)
|
|
||||||
this.lastFrameMs = elapsed
|
|
||||||
this.options.onRender(this.alpha, elapsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Advance simulation according to elapsed wall-clock time. */
|
|
||||||
private advanceSimulation(now: number): void {
|
|
||||||
const elapsed = now - this.lastSimTime
|
|
||||||
if (elapsed <= 0) return
|
|
||||||
this.lastSimTime = now
|
|
||||||
this.rawAccumulator += elapsed
|
|
||||||
|
|
||||||
let steps = 0
|
|
||||||
while (this.rawAccumulator >= this.tickMs && (this.maxCatchUp <= 0 || steps < this.maxCatchUp)) {
|
|
||||||
this.rawAccumulator -= this.tickMs
|
|
||||||
this.consumed += 1
|
|
||||||
this.simTicksInWindow += 1
|
|
||||||
this.options.onTick(this.consumed)
|
|
||||||
steps += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent catastrophic discard: clamp to max backlog instead of wiping to 0
|
|
||||||
const maxBacklog = this.tickMs * this.maxCatchUp
|
|
||||||
if (this.rawAccumulator > maxBacklog) {
|
|
||||||
this.rawAccumulator = maxBacklog
|
|
||||||
}
|
|
||||||
|
|
||||||
const windowElapsed = now - this.simWindowStart
|
|
||||||
if (windowElapsed >= 1000) {
|
|
||||||
this.measuredSimRate = (this.simTicksInWindow * 1000) / windowElapsed
|
|
||||||
this.simWindowStart = now
|
|
||||||
this.simTicksInWindow = 0
|
|
||||||
} else if (this.measuredSimRate === 0 && windowElapsed >= 200) {
|
|
||||||
this.measuredSimRate = (this.simTicksInWindow * 1000) / windowElapsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Begin ticking and rendering. Idempotent. */
|
|
||||||
start(): void {
|
|
||||||
if (this.running) return
|
|
||||||
this.running = true
|
|
||||||
|
|
||||||
const now = this.now()
|
|
||||||
this.lastSimTime = now
|
|
||||||
this.lastRenderTime = now
|
|
||||||
this.simWindowStart = now
|
|
||||||
this.renderWindowStart = now
|
|
||||||
this.simTicksInWindow = 0
|
|
||||||
this.renderFramesInWindow = 0
|
|
||||||
|
|
||||||
this.startSimTimer()
|
|
||||||
this.startRenderLoop()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Start the self-correcting timer loop for simulation ticks. */
|
|
||||||
private startSimTimer(): void {
|
|
||||||
if (this.simTimerHandle !== null) return
|
|
||||||
let nextTick = this.now() + this.tickMs
|
|
||||||
|
|
||||||
const simStep = (): void => {
|
|
||||||
if (!this.running) return
|
|
||||||
const now = this.now()
|
|
||||||
this.advanceSimulation(now)
|
|
||||||
|
|
||||||
// Self-correct next tick timestamp to eliminate drift
|
|
||||||
nextTick += this.tickMs
|
|
||||||
if (nextTick < now - this.tickMs * this.maxCatchUp) {
|
|
||||||
nextTick = now + this.tickMs
|
|
||||||
} else {
|
|
||||||
while (nextTick <= now) {
|
|
||||||
nextTick += this.tickMs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const delay = Math.max(0, nextTick - this.now())
|
|
||||||
this.simTimerHandle = setTimeout(simStep, delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.simTimerHandle = setTimeout(simStep, this.tickMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Start requestAnimationFrame render loop. */
|
|
||||||
private startRenderLoop(): void {
|
|
||||||
if (this.renderHandle !== 0) return
|
|
||||||
|
|
||||||
const renderStep = (): void => {
|
|
||||||
if (!this.running) return
|
|
||||||
const now = this.now()
|
|
||||||
const frameMs = this.lastRenderTime > 0 ? now - this.lastRenderTime : 16.6
|
|
||||||
this.lastRenderTime = now
|
|
||||||
this.lastFrameMs = frameMs
|
|
||||||
this.renderFramesInWindow += 1
|
|
||||||
|
|
||||||
const renderWindowElapsed = now - this.renderWindowStart
|
|
||||||
if (renderWindowElapsed >= 1000) {
|
|
||||||
this.measuredFps = (this.renderFramesInWindow * 1000) / renderWindowElapsed
|
|
||||||
this.renderWindowStart = now
|
|
||||||
this.renderFramesInWindow = 0
|
|
||||||
} else if (this.measuredFps === 0 && renderWindowElapsed >= 200) {
|
|
||||||
this.measuredFps = (this.renderFramesInWindow * 1000) / renderWindowElapsed
|
|
||||||
}
|
|
||||||
|
|
||||||
this.options.onRender(this.alpha, frameMs)
|
|
||||||
|
|
||||||
if (this.running) {
|
|
||||||
this.renderHandle = this.requestFrame(renderStep)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.renderHandle = this.requestFrame(renderStep)
|
|
||||||
}
|
|
||||||
|
|
||||||
private requestFrame(cb: (time: number) => void): number {
|
|
||||||
if (typeof requestAnimationFrame === 'function') {
|
|
||||||
return requestAnimationFrame(cb)
|
|
||||||
}
|
|
||||||
return setTimeout(() => cb(this.now()), 16) as unknown as number
|
|
||||||
}
|
|
||||||
|
|
||||||
private cancelFrame(handle: number): void {
|
|
||||||
if (typeof cancelAnimationFrame === 'function') {
|
|
||||||
cancelAnimationFrame(handle)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
clearTimeout(handle as unknown as ReturnType<typeof setTimeout>)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stop ticking and rendering. Idempotent. */
|
|
||||||
stop(): void {
|
|
||||||
this.running = false
|
|
||||||
if (this.simTimerHandle !== null) {
|
|
||||||
clearTimeout(this.simTimerHandle)
|
|
||||||
this.simTimerHandle = null
|
|
||||||
}
|
|
||||||
if (this.renderHandle !== 0) {
|
|
||||||
this.cancelFrame(this.renderHandle)
|
|
||||||
this.renderHandle = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
export {
|
|
||||||
BODY_LOC_TO_EQUIP_SLOT,
|
|
||||||
CLASS_ID_TO_CODE,
|
|
||||||
CLASS_ID_TO_NAMES,
|
|
||||||
EQUIP_SLOT_TO_BODY_LOC,
|
|
||||||
HudModel,
|
|
||||||
STAT_KEY_TO_D2_STAT_ID,
|
|
||||||
clientUnitToMonsterTargetInfo,
|
|
||||||
itemDataToUiInventoryItem,
|
|
||||||
type HudModelOptions,
|
|
||||||
type HudModelSnapshot,
|
|
||||||
} from './hud-model.ts'
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export * from '../../common/items/baked-ui-meta.ts'
|
|
||||||
|
|
@ -22,7 +22,6 @@ import {
|
||||||
areBeltPotionsCompatible,
|
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,
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export * from '../../common/items/cursor-meta.ts'
|
|
||||||
|
|
@ -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')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,495 +0,0 @@
|
||||||
/**
|
|
||||||
* Diablo II v1.13c Authentic Ground Item Alt Labels (`src/ui/ground-labels.ts`).
|
|
||||||
*
|
|
||||||
* Implements:
|
|
||||||
* - Holding the `Alt` key displays floating text labels for all ground items on screen.
|
|
||||||
* - Text color and border styling strictly following Diablo II 1.13c item quality colors:
|
|
||||||
* Normal (white), Low (gray), Superior (white), Magic (blue), Rare (yellow),
|
|
||||||
* Set (green), Unique (gold/tan), Craft/Rune (orange), Gold (yellow/gold).
|
|
||||||
* - Vertical ladder collision avoidance (anti-overlap):
|
|
||||||
* When multiple items drop in close proximity (e.g. boss drops, packed loot),
|
|
||||||
* labels are neatly stacked vertically into a non-overlapping ladder.
|
|
||||||
* - Clickable labels to trigger pathfinding and item pickup.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { isGroundSourceUnidentified, type GroundItemEntity } from '../../common/items/ground-items.ts'
|
|
||||||
import {
|
|
||||||
resolveBaseItemNameEn,
|
|
||||||
translateItemName,
|
|
||||||
translateRareName,
|
|
||||||
translateMagicName,
|
|
||||||
} from '../../common/i18n/tooltip-i18n.ts'
|
|
||||||
import { isEn } from '../i18n/lang.ts'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authentic 1.13c Diablo II quality color codes for ground item labels.
|
|
||||||
* Matching D2 palette tables & D2WinFont values.
|
|
||||||
*/
|
|
||||||
export const GROUND_LABEL_QUALITY_COLORS: Record<string, string> = {
|
|
||||||
normal: '#d8d8d8',
|
|
||||||
low: '#808080',
|
|
||||||
superior: '#ffffff',
|
|
||||||
magic: '#6868ff',
|
|
||||||
rare: '#ffff64',
|
|
||||||
set: '#00fc00',
|
|
||||||
unique: '#c8a15a',
|
|
||||||
craft: '#ff9c18',
|
|
||||||
rune: '#ff9c18',
|
|
||||||
gold: '#d8b420',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether a ground item entity or its underlying item source is ethereal.
|
|
||||||
*/
|
|
||||||
export function isGroundItemEthereal(item: GroundItemEntity): boolean {
|
|
||||||
const src = item.item
|
|
||||||
return Boolean(
|
|
||||||
item.ethereal === true ||
|
|
||||||
src?.ethereal === true ||
|
|
||||||
src?.isEthereal === true ||
|
|
||||||
src?.item?.ethereal === true ||
|
|
||||||
src?.item?.isEthereal === true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether a ground item entity or its underlying item source has sockets (> 0).
|
|
||||||
*/
|
|
||||||
export function isGroundItemSocketed(item: GroundItemEntity): boolean {
|
|
||||||
const src = item.item
|
|
||||||
const sockets =
|
|
||||||
item.sockets ??
|
|
||||||
src?.sockets ??
|
|
||||||
src?.totalSockets ??
|
|
||||||
src?.socketedCount ??
|
|
||||||
src?.item?.sockets ??
|
|
||||||
src?.item?.totalSockets ??
|
|
||||||
src?.item?.socketedCount ??
|
|
||||||
0
|
|
||||||
return typeof sockets === 'number' && sockets > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether a ground item entity is ethereal or socketed.
|
|
||||||
*/
|
|
||||||
export function isGroundItemEtherealOrSocketed(item: GroundItemEntity): boolean {
|
|
||||||
return isGroundItemEthereal(item) || isGroundItemSocketed(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the quality color string for a ground item.
|
|
||||||
*
|
|
||||||
* Authentic Diablo II v1.13c rule (`D2Client.dll` ground item label color lookup):
|
|
||||||
* - Gold -> `#d8b420` (Gold)
|
|
||||||
* - Low Quality (`low` / `inferior` / `crude` / `damaged` / `cracked`) -> `#808080` (Gray)
|
|
||||||
* - Normal or Superior (`normal` / `superior` / `high` / `hi-quality`) items that are
|
|
||||||
* Ethereal OR have Sockets (`sockets > 0`, excluding completed runewords) -> `#808080` (Gray)
|
|
||||||
* - Magic, Rare, Set, Unique, Craft, Rune -> retain their respective quality colors even when ethereal/socketed.
|
|
||||||
*/
|
|
||||||
export function getGroundItemQualityColor(item: GroundItemEntity): string {
|
|
||||||
if (item.isGold) {
|
|
||||||
return GROUND_LABEL_QUALITY_COLORS.gold!
|
|
||||||
}
|
|
||||||
const q = item.quality?.toLowerCase() ?? 'normal'
|
|
||||||
if (q === 'low' || q === 'inferior' || q === 'crude' || q === 'damaged' || q === 'cracked') {
|
|
||||||
return GROUND_LABEL_QUALITY_COLORS.low!
|
|
||||||
}
|
|
||||||
const isNormalOrSuperior = q === 'normal' || q === 'superior' || q === 'high' || q === 'hi-quality'
|
|
||||||
const isRuneword = Boolean(
|
|
||||||
(item as any).isRuneword ||
|
|
||||||
item.item?.isRuneword ||
|
|
||||||
item.item?.runewordRunes ||
|
|
||||||
item.item?.item?.isRuneword ||
|
|
||||||
item.item?.item?.runewordRunes,
|
|
||||||
)
|
|
||||||
if (isRuneword) {
|
|
||||||
return GROUND_LABEL_QUALITY_COLORS.unique!
|
|
||||||
}
|
|
||||||
if (isNormalOrSuperior && isGroundItemEtherealOrSocketed(item)) {
|
|
||||||
return GROUND_LABEL_QUALITY_COLORS.low!
|
|
||||||
}
|
|
||||||
return GROUND_LABEL_QUALITY_COLORS[q] ?? GROUND_LABEL_QUALITY_COLORS.normal!
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Strips mixed English parentheticals from Chinese item names in CHI mode
|
|
||||||
* while preserving authentic Chinese annotations like `(5/5 火升)`.
|
|
||||||
*/
|
|
||||||
export function cleanChiItemName(nameZh: string): string {
|
|
||||||
return nameZh
|
|
||||||
.replace(/^精神\s*\(Spirit\)\s*统盾$/, '精神统盾')
|
|
||||||
.replace(/\s*\(([A-Za-z]+)\s+符文\)$/, '符文')
|
|
||||||
.replace(/\s*\(Rainbow Facet\s*-\s*([^)]+)\)$/, ' ($1)')
|
|
||||||
.replace(/\s*\([A-Za-z0-9'+\-% /×·,.#:]+\)/g, '')
|
|
||||||
.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format the user-facing display text for a ground item label.
|
|
||||||
*/
|
|
||||||
export function formatGroundItemLabelText(item: GroundItemEntity): string {
|
|
||||||
const en = isEn()
|
|
||||||
if (item.isGold) {
|
|
||||||
return en ? `${item.amount} Gold` : `${item.amount} 金币`
|
|
||||||
}
|
|
||||||
const src = item.item as any
|
|
||||||
if (isGroundSourceUnidentified(src)) {
|
|
||||||
const baseObj = src?.base ?? src?.rawItem?.base ?? src?.item?.base
|
|
||||||
const rawCode =
|
|
||||||
typeof src?.code === 'string'
|
|
||||||
? src.code.trim().toLowerCase()
|
|
||||||
: typeof baseObj?.id === 'string'
|
|
||||||
? baseObj.id.trim().toLowerCase()
|
|
||||||
: ''
|
|
||||||
const rawBaseName =
|
|
||||||
typeof baseObj?.name === 'string' && baseObj.name.trim().length > 0
|
|
||||||
? baseObj.name
|
|
||||||
: item.name
|
|
||||||
if (rawBaseName) {
|
|
||||||
const baseNameEn = resolveBaseItemNameEn(rawBaseName, rawCode)
|
|
||||||
if (en) {
|
|
||||||
return baseNameEn
|
|
||||||
}
|
|
||||||
if (item.nameZh && item.name === baseNameEn) {
|
|
||||||
return cleanChiItemName(item.nameZh)
|
|
||||||
}
|
|
||||||
const translatedBase =
|
|
||||||
translateItemName(rawBaseName) !== rawBaseName
|
|
||||||
? translateItemName(rawBaseName)
|
|
||||||
: translateItemName(baseNameEn)
|
|
||||||
return translatedBase || baseNameEn
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (en) {
|
|
||||||
return item.name || src?.name || src?.item?.name || 'Item'
|
|
||||||
}
|
|
||||||
if (item.nameZh) {
|
|
||||||
return cleanChiItemName(item.nameZh)
|
|
||||||
}
|
|
||||||
const srcNameZh = src?.nameZh ?? src?.item?.nameZh
|
|
||||||
if (typeof srcNameZh === 'string' && srcNameZh.trim() !== '') {
|
|
||||||
return cleanChiItemName(srcNameZh)
|
|
||||||
}
|
|
||||||
if (item.name) {
|
|
||||||
const q = (item.quality || 'normal').toLowerCase()
|
|
||||||
if (q === 'rare') {
|
|
||||||
return translateRareName(item.name)
|
|
||||||
}
|
|
||||||
if (q === 'magic') {
|
|
||||||
const baseName = src?.base?.name ?? src?.item?.base?.name
|
|
||||||
return translateMagicName(item.name, baseName)
|
|
||||||
}
|
|
||||||
if (src?.base || src?.code || src?.item?.base || src?.item?.code) {
|
|
||||||
return translateItemName(item.name)
|
|
||||||
}
|
|
||||||
return cleanChiItemName(item.name)
|
|
||||||
}
|
|
||||||
return '物品'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Estimates the pixel width of a ground item label based on CJK and Latin character counts.
|
|
||||||
*/
|
|
||||||
export function estimateLabelWidth(text: string, fontSizePx = 13): number {
|
|
||||||
let charWidths = 0
|
|
||||||
for (let i = 0; i < text.length; i += 1) {
|
|
||||||
const code = text.charCodeAt(i)
|
|
||||||
// CJK ideographs / punctuation roughly full-width
|
|
||||||
if (code > 0x2e80) {
|
|
||||||
charWidths += fontSizePx * 1.05
|
|
||||||
} else {
|
|
||||||
// Latin characters / numbers / spaces roughly half-width
|
|
||||||
charWidths += fontSizePx * 0.58
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Base padding: 12px (6px left + 6px right) + 2px border
|
|
||||||
return Math.ceil(charWidths + 14)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GroundLabelLayout {
|
|
||||||
readonly id: string
|
|
||||||
readonly itemId: string
|
|
||||||
readonly item: GroundItemEntity
|
|
||||||
readonly text: string
|
|
||||||
readonly color: string
|
|
||||||
readonly borderColor: string
|
|
||||||
readonly width: number
|
|
||||||
readonly height: number
|
|
||||||
readonly screenX: number
|
|
||||||
screenY: number
|
|
||||||
readonly naturalY: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes initial screen layouts for a list of ground items.
|
|
||||||
*/
|
|
||||||
export function computeInitialGroundLabelLayouts(
|
|
||||||
items: readonly GroundItemEntity[],
|
|
||||||
projectToScreen: (worldX: number, worldY: number) => { x: number; y: number } | null,
|
|
||||||
fontSizePx = 13,
|
|
||||||
labelHeightPx = 18,
|
|
||||||
): GroundLabelLayout[] {
|
|
||||||
const layouts: GroundLabelLayout[] = []
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const pt = projectToScreen(item.x, item.y)
|
|
||||||
if (pt === null) continue
|
|
||||||
|
|
||||||
const text = formatGroundItemLabelText(item)
|
|
||||||
const color = getGroundItemQualityColor(item)
|
|
||||||
const width = estimateLabelWidth(text, fontSizePx)
|
|
||||||
const naturalY = pt.y - 12 // Positioned 12px above ground contact point
|
|
||||||
|
|
||||||
layouts.push({
|
|
||||||
id: `label-${item.id}`,
|
|
||||||
itemId: item.id,
|
|
||||||
item,
|
|
||||||
text,
|
|
||||||
color,
|
|
||||||
borderColor: color,
|
|
||||||
width,
|
|
||||||
height: labelHeightPx,
|
|
||||||
screenX: pt.x,
|
|
||||||
screenY: naturalY,
|
|
||||||
naturalY,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return layouts
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if two rectangular labels overlap within specified padding tolerances.
|
|
||||||
*/
|
|
||||||
export function doLabelsOverlap(
|
|
||||||
a: GroundLabelLayout,
|
|
||||||
b: GroundLabelLayout,
|
|
||||||
padX = 4,
|
|
||||||
padY = 2,
|
|
||||||
): boolean {
|
|
||||||
const aLeft = a.screenX - a.width / 2
|
|
||||||
const aRight = a.screenX + a.width / 2
|
|
||||||
const aTop = a.screenY - a.height / 2
|
|
||||||
const aBottom = a.screenY + a.height / 2
|
|
||||||
|
|
||||||
const bLeft = b.screenX - b.width / 2
|
|
||||||
const bRight = b.screenX + b.width / 2
|
|
||||||
const bTop = b.screenY - b.height / 2
|
|
||||||
const bBottom = b.screenY + b.height / 2
|
|
||||||
|
|
||||||
const xOverlap = aLeft - padX < bRight && aRight + padX > bLeft
|
|
||||||
const yOverlap = aTop - padY < bBottom && aBottom + padY > bTop
|
|
||||||
|
|
||||||
return xOverlap && yOverlap
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authentic Diablo II 1.13c Vertical Ladder Collision Avoidance Algorithm.
|
|
||||||
*
|
|
||||||
* Stacks overlapping ground item labels vertically so they remain readable and clickable.
|
|
||||||
* - Groups labels into overlapping horizontal clusters.
|
|
||||||
* - Stacks each cluster from bottom (closest to the ground item) upwards.
|
|
||||||
* - Preserves natural anchor proximity while eliminating 100% of bounding box overlaps.
|
|
||||||
*/
|
|
||||||
export function resolveLadderCollisions(
|
|
||||||
labels: GroundLabelLayout[],
|
|
||||||
padX = 4,
|
|
||||||
padY = 2,
|
|
||||||
): GroundLabelLayout[] {
|
|
||||||
if (labels.length <= 1) return labels
|
|
||||||
|
|
||||||
// Work on a copy with mutable screenY
|
|
||||||
const result: GroundLabelLayout[] = labels.map(l => ({ ...l }))
|
|
||||||
|
|
||||||
// Partition into connected overlapping components (clusters)
|
|
||||||
const n = result.length
|
|
||||||
const adj: number[][] = Array.from({ length: n }, () => [])
|
|
||||||
|
|
||||||
for (let i = 0; i < n; i += 1) {
|
|
||||||
for (let j = i + 1; j < n; j += 1) {
|
|
||||||
// Horizontal overlap check: do their X spans overlap?
|
|
||||||
const a = result[i]!
|
|
||||||
const b = result[j]!
|
|
||||||
const aLeft = a.screenX - a.width / 2
|
|
||||||
const aRight = a.screenX + a.width / 2
|
|
||||||
const bLeft = b.screenX - b.width / 2
|
|
||||||
const bRight = b.screenX + b.width / 2
|
|
||||||
|
|
||||||
if (aLeft - padX < bRight && aRight + padX > bLeft) {
|
|
||||||
adj[i]!.push(j)
|
|
||||||
adj[j]!.push(i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find connected clusters via BFS
|
|
||||||
const visited = new Uint8Array(n)
|
|
||||||
for (let i = 0; i < n; i += 1) {
|
|
||||||
if (visited[i]) continue
|
|
||||||
const cluster: number[] = []
|
|
||||||
const queue = [i]
|
|
||||||
visited[i] = 1
|
|
||||||
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const curr = queue.shift()!
|
|
||||||
cluster.push(curr)
|
|
||||||
for (const neighbor of adj[curr]!) {
|
|
||||||
if (!visited[neighbor]) {
|
|
||||||
visited[neighbor] = 1
|
|
||||||
queue.push(neighbor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cluster.length <= 1) continue
|
|
||||||
|
|
||||||
// Sort cluster items by their natural Y ascending
|
|
||||||
cluster.sort((idxA, idxB) => {
|
|
||||||
const a = result[idxA]!
|
|
||||||
const b = result[idxB]!
|
|
||||||
if (a.naturalY !== b.naturalY) {
|
|
||||||
return a.naturalY - b.naturalY
|
|
||||||
}
|
|
||||||
return a.screenX - b.screenX
|
|
||||||
})
|
|
||||||
|
|
||||||
// Ladder layout: Bottom-most label is placed closest to the ground item,
|
|
||||||
// and subsequent labels stack upward neatly.
|
|
||||||
// Base Y is the natural Y of the lowest (highest Y coordinate) item in the cluster.
|
|
||||||
const bottomItem = result[cluster[cluster.length - 1]!]!
|
|
||||||
let currentBottomY = bottomItem.naturalY
|
|
||||||
|
|
||||||
// Stack upward from bottom to top
|
|
||||||
for (let k = cluster.length - 1; k >= 0; k -= 1) {
|
|
||||||
const itemIdx = cluster[k]!
|
|
||||||
const item = result[itemIdx]!
|
|
||||||
item.screenY = currentBottomY
|
|
||||||
currentBottomY -= item.height + padY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DOM Manager for ground item Alt labels overlay.
|
|
||||||
*/
|
|
||||||
export class GroundLabelOverlay {
|
|
||||||
private readonly container: HTMLElement
|
|
||||||
private readonly labelPool: HTMLElement[] = []
|
|
||||||
private onPickupCallback: ((item: GroundItemEntity) => void) | null = null
|
|
||||||
private onHoverCallback: ((item: GroundItemEntity | null) => void) | null = null
|
|
||||||
|
|
||||||
constructor(container: HTMLElement) {
|
|
||||||
this.container = container
|
|
||||||
this.container.style.position = 'fixed'
|
|
||||||
this.container.style.inset = '0'
|
|
||||||
this.container.style.pointerEvents = 'none'
|
|
||||||
this.container.style.zIndex = '5'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a callback when a ground item label is clicked.
|
|
||||||
*/
|
|
||||||
setOnPickupCallback(callback: (item: GroundItemEntity) => void): void {
|
|
||||||
this.onPickupCallback = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a callback when a ground item label is hovered or unhovered.
|
|
||||||
*/
|
|
||||||
setOnHoverCallback(callback: (item: GroundItemEntity | null) => void): void {
|
|
||||||
this.onHoverCallback = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update and render ground item labels based on current visible items and Alt key state.
|
|
||||||
*/
|
|
||||||
update(
|
|
||||||
visible: boolean,
|
|
||||||
layouts: readonly GroundLabelLayout[],
|
|
||||||
): void {
|
|
||||||
if (!visible || layouts.length === 0) {
|
|
||||||
this.hideAll()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let count = 0
|
|
||||||
for (const layout of layouts) {
|
|
||||||
let el = this.labelPool[count]
|
|
||||||
if (el === undefined) {
|
|
||||||
el = document.createElement('div')
|
|
||||||
el.className = 'd2-ground-label ground-label'
|
|
||||||
el.style.position = 'absolute'
|
|
||||||
el.style.transform = 'translate(-50%, -50%)'
|
|
||||||
el.style.background = 'rgba(0, 0, 0, 0.88)'
|
|
||||||
el.style.padding = '2px 6px'
|
|
||||||
el.style.fontFamily = '"D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif'
|
|
||||||
el.style.fontSize = '13px'
|
|
||||||
el.style.lineHeight = '14px'
|
|
||||||
el.style.whiteSpace = 'nowrap'
|
|
||||||
el.style.pointerEvents = 'auto'
|
|
||||||
el.style.cursor = 'pointer'
|
|
||||||
el.style.userSelect = 'none'
|
|
||||||
el.style.boxShadow = '0 1px 4px rgba(0, 0, 0, 0.8)'
|
|
||||||
el.style.textShadow = '1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000'
|
|
||||||
el.style.transition = 'border-color 0.1s, background-color 0.1s'
|
|
||||||
|
|
||||||
el.addEventListener('mouseenter', () => {
|
|
||||||
el.style.backgroundColor = 'rgba(28, 28, 48, 0.96)'
|
|
||||||
el.style.borderColor = '#ffffff'
|
|
||||||
const currentItem = (el as any).__groundItem as GroundItemEntity | undefined
|
|
||||||
if (currentItem && this.onHoverCallback) {
|
|
||||||
this.onHoverCallback(currentItem)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
el.addEventListener('mouseleave', () => {
|
|
||||||
el.style.backgroundColor = 'rgba(0, 0, 0, 0.88)'
|
|
||||||
el.style.borderColor = el.dataset.qualityColor || '#404040'
|
|
||||||
if (this.onHoverCallback) {
|
|
||||||
this.onHoverCallback(null)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
el.addEventListener('pointerdown', (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
e.preventDefault()
|
|
||||||
const currentItem = (el as any).__groundItem as GroundItemEntity | undefined
|
|
||||||
if (currentItem && this.onPickupCallback) {
|
|
||||||
this.onPickupCallback(currentItem)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
this.labelPool.push(el)
|
|
||||||
this.container.appendChild(el)
|
|
||||||
}
|
|
||||||
|
|
||||||
;(el as any).__groundItem = layout.item
|
|
||||||
el.dataset.itemId = layout.itemId
|
|
||||||
el.dataset.qualityColor = layout.borderColor
|
|
||||||
if (el.textContent !== layout.text) el.textContent = layout.text
|
|
||||||
el.style.color = layout.color
|
|
||||||
el.style.border = `1px solid ${layout.borderColor}`
|
|
||||||
el.style.left = `${Math.round(layout.screenX)}px`
|
|
||||||
el.style.top = `${Math.round(layout.screenY)}px`
|
|
||||||
el.style.display = 'block'
|
|
||||||
el.hidden = false
|
|
||||||
count += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide surplus pooled elements
|
|
||||||
for (let i = count; i < this.labelPool.length; i += 1) {
|
|
||||||
this.labelPool[i]!.style.display = 'none'
|
|
||||||
this.labelPool[i]!.hidden = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hide all active labels.
|
|
||||||
*/
|
|
||||||
hideAll(): void {
|
|
||||||
for (const el of this.labelPool) {
|
|
||||||
el.style.display = 'none'
|
|
||||||
el.hidden = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -22,8 +22,6 @@ import {
|
||||||
isLeftUsableSkill,
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export * from "./inventory.ts"
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export * from '../../common/items/item-bridge.ts'
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
import { AudioManager, SoundManager, sharedAudioManager } from '../audio/manager.ts'
|
|
||||||
import { getTblLang, initLangFromBrowser, type TblLang } from '../i18n/lang.ts'
|
|
||||||
import { MapService } from '../map/map-service.ts'
|
|
||||||
import { ActorAnimator } from '../render/actor-animator.ts'
|
|
||||||
import { buildBounds } from '../render/cull.ts'
|
|
||||||
import { LightningTrailManager, drawElectricBoltFallback } from '../render/missile-renderer.ts'
|
|
||||||
import { MISSILE_METAS, velocityToDccDirection, type MissileFrameTuple, type MissileMeta } from '../render/missiles-meta.ts'
|
|
||||||
import { OVERLAY_METAS, type OverlayMeta } from '../render/overlays-meta.ts'
|
|
||||||
import { LoadGeneration, SpriteRenderer, WebGLRenderer2D, type AtlasHandle } from '../render/renderer.ts'
|
|
||||||
import { RoofFadeController } from '../render/roofs.ts'
|
|
||||||
import { initBrowserArenaMode } from '../scene/arena-overlay.ts'
|
|
||||||
import {
|
|
||||||
ADDITIVE_MONSTER_TOKENS, DEFAULT_BASES, loadCharacterArt, loadMonsterArtMap,
|
|
||||||
missingMonsterArtIds, type LoadedCharacter, type MonsterArtLoad,
|
|
||||||
} from '../scene/entity-art-loader.ts'
|
|
||||||
import {
|
|
||||||
GROUND_ITEM_QUALITY_COLORS, drawGroundItem, globalItemsAtlasHandle,
|
|
||||||
loadItemsAtlas, resolveGroundItemFlippyFrame, syncHudGroundDrops,
|
|
||||||
} from '../scene/ground-item-renderer.ts'
|
|
||||||
import {
|
|
||||||
LIGHTING_PRESETS, lightingLabel, normalizeLightingPreset,
|
|
||||||
type LightingPreset, type LightingPresetConfig,
|
|
||||||
} from '../scene/lighting-presets.ts'
|
|
||||||
import {
|
|
||||||
drawCastOverlay, drawExplosion, drawFireExplosion, drawFireboltProjectile,
|
|
||||||
drawMissileProjectile, drawTownPortal, loadMissileArtMap, loadOverlayArtMap,
|
|
||||||
type LoadedMissileArt, type LoadedOverlayArt,
|
|
||||||
} from '../scene/missile-overlay-renderer.ts'
|
|
||||||
import {
|
|
||||||
SceneMouseController, playStashOpenSound, setupMouseControls, type MouseControllerContext,
|
|
||||||
type PendingInteraction, type SceneMouseControllerOptions,
|
|
||||||
} from '../scene/mouse-controller.ts'
|
|
||||||
import {
|
|
||||||
ADDITIVE_OBJECT_TOKENS, PERSISTENT_ILLUMINATION_TOKENS, computeObjectPhaseOffsetMs,
|
|
||||||
updateAnimatedObject, updateAnimatedObjects, type AnimatedObjectFrame,
|
|
||||||
type Drawable, type ObjectDrawable,
|
|
||||||
} from '../scene/object-animation.ts'
|
|
||||||
import {
|
|
||||||
playInventoryFullFeedback, projectNpcLabel, showNotification,
|
|
||||||
updateGroundItemLabelsOverlay, updateOverheadLabels,
|
|
||||||
type NpcLabelProjectionParams, type NpcLabelProjectionResult,
|
|
||||||
} from '../scene/overhead-labels.ts'
|
|
||||||
import {
|
|
||||||
DEFAULT_PACKS, FEET_HEIGHT, FEET_WIDTH, WALK_SPEED, buildPackRuntime,
|
|
||||||
countLoadedPages, createLevelTransitionController, disposeRuntimeAtlases,
|
|
||||||
fetchJson, getPackIndex, loadPackRuntime, loadRuntimeForLevel,
|
|
||||||
pagesNeededForViewport, param, preloadRemaining, switchArea, worldVariantsFor,
|
|
||||||
type MapRuntime, type PackIndex, type PackIndexEntry, type PackObject,
|
|
||||||
type PackObjectFrame, type PackSceneJson,
|
|
||||||
} from '../scene/pack-loader.ts'
|
|
||||||
import {
|
|
||||||
SAVE_KEY, monsterArtStatusText, monsterPlanStatusText,
|
|
||||||
monsterToTargetHudInfo, publish, recordMonsterArtLoad, reportMonsterPlan,
|
|
||||||
state, type ActSceneState,
|
|
||||||
} from '../scene/scene-state.ts'
|
|
||||||
import {
|
|
||||||
changeHeroClass, createActSceneHudManager, createHeroLightingAndMonsterControls,
|
|
||||||
mountChatAndToolbarUi, populatePackSelectors, respawnMonsters,
|
|
||||||
updateSelectorWarningStyle, updateToolbarUrl, wireBootToolbarSelectors,
|
|
||||||
} from '../scene/toolbar-chat-ui.ts'
|
|
||||||
import { FadeOverlay } from '../scene/transition.ts'
|
|
||||||
import {
|
|
||||||
applyDynamicSceneLighting, buildMinimapLevel, cellOf, playerSubTile,
|
|
||||||
renderWorldLayers, stepPlayerAndMonsterAnimations,
|
|
||||||
} from '../scene/world-renderer.ts'
|
|
||||||
import { ViewportCamera } from '../sim/camera.ts'
|
|
||||||
import { KeyboardInput } from '../sim/input.ts'
|
|
||||||
import { GroundLabelOverlay } from '../ui/ground-labels.ts'
|
|
||||||
import { resolveGroundItemSpriteRect } from '../ui/inventory.ts'
|
|
||||||
import { Minimap, type MinimapLevel } from '../ui/minimap.ts'
|
|
||||||
import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts'
|
|
||||||
import { getMissileTxtData, isAuraSkill } from '../../common/skills/missiles-data.ts'
|
|
||||||
import {
|
|
||||||
PASSIVE_SKILL_IDS, checkAmmunition, getAttackWeaponInfo, getSkillMana,
|
|
||||||
getSkillRange, isPassiveSkill, type AttackWeaponInfo, type AttackWeaponInputItem,
|
|
||||||
type AttackWeaponKind, type AttackWeaponSubtype, type SkillCastOutcome,
|
|
||||||
} from '../../common/skills/weapon-combat-meta.ts'
|
|
||||||
import { executeSceneChatCommand, handleActSceneChatCommand } from '../../common/world/chat-commands.ts'
|
|
||||||
import { resolveTownPortalSubTile } from '../../common/world/portal.ts'
|
|
||||||
import {
|
|
||||||
ENTRANCE_CLICK_RADIUS_PX, SEAM_FADE_MS, SEAM_TRIGGER_SUBTILES, TELEKINESIS_RANGE_PX,
|
|
||||||
WARP_CLICK_RADIUS_PX, WARP_FADE_MS, WARP_TRIGGER_SUBTILES, facingOf,
|
|
||||||
getEntranceLabelWorldPos, getWarpLabelWorldPos, hitTestEntrance, hitTestWarp,
|
|
||||||
isPlayerWithinLinkRange, resolveObstacleAwareSteerTarget, sceneToSubTile,
|
|
||||||
screenToWorld, subTileToScene, type SceneEntrance, type SceneWarp,
|
|
||||||
type SceneWaypoint, type ScreenToWorldParams,
|
|
||||||
} from '../../common/world/scene-links-nav.ts'
|
|
||||||
import {
|
|
||||||
CANONICAL_TOWN_STASHES, HERO_CLASSES, ROOM_ACTIVATION_RADIUS, ROOM_DEACTIVATION_RADIUS,
|
|
||||||
TOWN_LEVEL_IDS, buildStreamingRooms, demoteToNormal, getSharedDropTables,
|
|
||||||
isTownLevel, normalizeHeroToken, parseDensityMultiplier, parseEliteMultiplier,
|
|
||||||
promoteToChampion, promoteToUnique, rebudgetMonsterPacks, roomHasWalkableGround,
|
|
||||||
type DensityMultiplier, type EliteMultiplier, type HeroClassInfo,
|
|
||||||
type RebudgetOptions, type TownStashEntity,
|
|
||||||
} from '../../common/world/scene-population.ts'
|
|
||||||
import { syncEngineState } from '../../common/units/unit-types.ts'
|
|
||||||
import { ClientWorld } from './client-world.ts'
|
|
||||||
|
|
||||||
// GroundItem render failure: missing flippy sprite rect in BAKED_UI_MANIFEST (validated via resolveGroundItemSpriteRect in ../scene/ground-item-renderer.ts)
|
|
||||||
export {
|
|
||||||
ADDITIVE_MONSTER_TOKENS, ADDITIVE_OBJECT_TOKENS, ActorAnimator, AudioManager,
|
|
||||||
CANONICAL_TOWN_STASHES, ClientWorld, D2_EXPERIENCE_TABLE_99, DEFAULT_BASES,
|
|
||||||
DEFAULT_PACKS, ENTRANCE_CLICK_RADIUS_PX, FEET_HEIGHT, FEET_WIDTH, FadeOverlay,
|
|
||||||
GROUND_ITEM_QUALITY_COLORS, GroundLabelOverlay, HERO_CLASSES, KeyboardInput,
|
|
||||||
LIGHTING_PRESETS, LightningTrailManager, LoadGeneration, MISSILE_METAS,
|
|
||||||
MapService, Minimap, OVERLAY_METAS, PASSIVE_SKILL_IDS,
|
|
||||||
PERSISTENT_ILLUMINATION_TOKENS, ROOM_ACTIVATION_RADIUS, ROOM_DEACTIVATION_RADIUS,
|
|
||||||
RoofFadeController, SAVE_KEY, SEAM_FADE_MS, SEAM_TRIGGER_SUBTILES,
|
|
||||||
SceneMouseController, SoundManager, SpriteRenderer, TELEKINESIS_RANGE_PX,
|
|
||||||
TOWN_LEVEL_IDS, ViewportCamera, WALK_SPEED, WARP_CLICK_RADIUS_PX,
|
|
||||||
WARP_FADE_MS, WARP_TRIGGER_SUBTILES, WebGLRenderer2D,
|
|
||||||
applyDynamicSceneLighting, buildBounds, buildMinimapLevel, buildPackRuntime,
|
|
||||||
buildStreamingRooms, cellOf, changeHeroClass, checkAmmunition,
|
|
||||||
computeObjectPhaseOffsetMs,
|
|
||||||
countLoadedPages, createActSceneHudManager, createHeroLightingAndMonsterControls,
|
|
||||||
createLevelTransitionController, demoteToNormal,
|
|
||||||
disposeRuntimeAtlases, drawCastOverlay, drawElectricBoltFallback, drawExplosion,
|
|
||||||
drawFireExplosion, drawFireboltProjectile, drawGroundItem, drawMissileProjectile,
|
|
||||||
drawTownPortal, executeSceneChatCommand, facingOf, fetchJson, getAttackWeaponInfo,
|
|
||||||
getEntranceLabelWorldPos, getMissileTxtData, getPackIndex, getSharedDropTables,
|
|
||||||
getSkillMana, getSkillRange, getTblLang, getWarpLabelWorldPos,
|
|
||||||
globalItemsAtlasHandle, handleActSceneChatCommand, hitTestEntrance, hitTestWarp,
|
|
||||||
initBrowserArenaMode, initLangFromBrowser, isAuraSkill, isPassiveSkill,
|
|
||||||
isPlayerWithinLinkRange, isTownLevel, lightingLabel, loadCharacterArt,
|
|
||||||
loadItemsAtlas, loadMissileArtMap, loadMonsterArtMap, loadOverlayArtMap,
|
|
||||||
loadPackRuntime, loadRuntimeForLevel, missingMonsterArtIds, monsterArtStatusText,
|
|
||||||
monsterPlanStatusText, monsterToTargetHudInfo, mountChatAndToolbarUi,
|
|
||||||
normalizeHeroToken, normalizeLightingPreset, pagesNeededForViewport, param,
|
|
||||||
parseDensityMultiplier, parseEliteMultiplier, playInventoryFullFeedback,
|
|
||||||
playStashOpenSound, playerSubTile, populatePackSelectors, preloadRemaining,
|
|
||||||
projectNpcLabel, promoteToChampion, promoteToUnique, publish, rebudgetMonsterPacks,
|
|
||||||
recordMonsterArtLoad, renderWorldLayers, reportMonsterPlan,
|
|
||||||
resolveGroundItemFlippyFrame,
|
|
||||||
resolveGroundItemSpriteRect, resolveObstacleAwareSteerTarget, resolveTownPortalSubTile,
|
|
||||||
respawnMonsters, roomHasWalkableGround, sceneToSubTile, screenToWorld,
|
|
||||||
setupMouseControls, sharedAudioManager, showNotification, state,
|
|
||||||
stepPlayerAndMonsterAnimations, subTileToScene, switchArea, syncEngineState,
|
|
||||||
syncHudGroundDrops, updateAnimatedObject, updateAnimatedObjects,
|
|
||||||
updateGroundItemLabelsOverlay, updateOverheadLabels, updateSelectorWarningStyle,
|
|
||||||
updateToolbarUrl, velocityToDccDirection, wireBootToolbarSelectors, worldVariantsFor,
|
|
||||||
type ActSceneState, type AnimatedObjectFrame, type AtlasHandle,
|
|
||||||
type AttackWeaponInfo, type AttackWeaponInputItem, type AttackWeaponKind,
|
|
||||||
type AttackWeaponSubtype, type DensityMultiplier, type Drawable,
|
|
||||||
type EliteMultiplier, type HeroClassInfo, type LightingPreset,
|
|
||||||
type LightingPresetConfig, type LoadedCharacter, type LoadedMissileArt,
|
|
||||||
type LoadedOverlayArt, type MapRuntime, type MinimapLevel, type MissileFrameTuple,
|
|
||||||
type MissileMeta, type MonsterArtLoad, type MouseControllerContext,
|
|
||||||
type NpcLabelProjectionParams, type NpcLabelProjectionResult, type ObjectDrawable,
|
|
||||||
type OverlayMeta, type PackIndex, type PackIndexEntry, type PackObject,
|
|
||||||
type PackObjectFrame, type PackSceneJson, type PendingInteraction,
|
|
||||||
type RebudgetOptions, type SceneEntrance, type SceneMouseControllerOptions,
|
|
||||||
type SceneWarp, type SceneWaypoint, type ScreenToWorldParams,
|
|
||||||
type SkillCastOutcome, type TblLang, type TownStashEntity,
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
export * from './client-unit.ts'
|
|
||||||
export * from './client-world.ts'
|
|
||||||
export * from './inventory.ts'
|
|
||||||
export * from './item-tables-provider.ts'
|
|
||||||
export * from './self.ts'
|
|
||||||
export * from './stat-list.ts'
|
|
||||||
export * from './states.ts'
|
|
||||||
|
|
@ -163,49 +163,6 @@ export function isAutoBeltablePotion(item: unknown): boolean {
|
||||||
return itemToBeltPotion(item) !== null
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { ADDITIVE_OBJECT_TOKENS, type ObjectDrawable } from '../src/client/world/act-runtime.ts'
|
|
||||||
import {
|
|
||||||
buildObjectDrawables,
|
|
||||||
executeDepthDrawDispatch,
|
|
||||||
MockRenderer,
|
|
||||||
type PackObjectInput,
|
|
||||||
type SimulatedWall,
|
|
||||||
} from '../tools/verify-scene-object-dispatch.ts'
|
|
||||||
|
|
||||||
describe('Scene Object Rendering Dispatch (Milestone 1 / R1)', () => {
|
|
||||||
it('correctly maps all 1.13c additive tokens to blendMode: "additive"', () => {
|
|
||||||
const tokens = ['11', '12', 'R1', 'R2', 'R3', 'R4', 'R5', '1R', '2R', '3R', '4R', 'HA']
|
|
||||||
for (const token of tokens) {
|
|
||||||
expect(ADDITIVE_OBJECT_TOKENS.has(token)).toBe(true)
|
|
||||||
|
|
||||||
const drawables = buildObjectDrawables([
|
|
||||||
{
|
|
||||||
x: 100,
|
|
||||||
y: 200,
|
|
||||||
token,
|
|
||||||
frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(drawables).toHaveLength(1)
|
|
||||||
expect(drawables[0]!.blendMode).toBe('additive')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('correctly identifies trans=7 and trans=1 objects as additive', () => {
|
|
||||||
const drawables = buildObjectDrawables([
|
|
||||||
{
|
|
||||||
x: 10,
|
|
||||||
y: 20,
|
|
||||||
token: 'UNKNOWN',
|
|
||||||
trans: 7,
|
|
||||||
frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
x: 30,
|
|
||||||
y: 40,
|
|
||||||
token: 'OTHER',
|
|
||||||
trans: 1,
|
|
||||||
frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(drawables).toHaveLength(2)
|
|
||||||
expect(drawables[0]!.blendMode).toBe('additive')
|
|
||||||
expect(drawables[1]!.blendMode).toBe('additive')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('preserves blendMode: "normal" for regular objects and torches', () => {
|
|
||||||
const drawables = buildObjectDrawables([
|
|
||||||
{
|
|
||||||
x: 10,
|
|
||||||
y: 20,
|
|
||||||
token: 'CHEST',
|
|
||||||
trans: 0,
|
|
||||||
frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
x: 30,
|
|
||||||
y: 40,
|
|
||||||
token: 'TO',
|
|
||||||
trans: 0,
|
|
||||||
frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(drawables).toHaveLength(2)
|
|
||||||
expect(drawables[0]!.blendMode).toBe('normal')
|
|
||||||
expect(drawables[1]!.blendMode).toBe('normal')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('dispatches additive objects with blendMode: "additive" and regular objects with "normal"', () => {
|
|
||||||
const rawObjects: PackObjectInput[] = [
|
|
||||||
{ id: 1, token: '11', depth: 2, frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 10, y: 0 },
|
|
||||||
{ id: 2, token: 'BOX', depth: 6, frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 30, y: 0 },
|
|
||||||
]
|
|
||||||
const objs = buildObjectDrawables(rawObjects)
|
|
||||||
|
|
||||||
const walls: SimulatedWall[] = [
|
|
||||||
{ frame: { x: 0, y: 0, width: 32, height: 32, _depth: 4 } as any, x: 0, y: 0, cellX: 2, cellY: 2, page: 0 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const renderer = new MockRenderer()
|
|
||||||
executeDepthDrawDispatch(renderer, {
|
|
||||||
walls,
|
|
||||||
objs,
|
|
||||||
maxDepthSteps: [Infinity],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(renderer.draws).toHaveLength(3)
|
|
||||||
|
|
||||||
// Draw 0: Object 1 (Token 11, depth 2) -> additive
|
|
||||||
expect(renderer.draws[0]!.target).toBe('object')
|
|
||||||
expect(renderer.draws[0]!.depth).toBe(2)
|
|
||||||
expect(renderer.draws[0]!.blendMode).toBe('additive')
|
|
||||||
|
|
||||||
// Draw 1: Wall (depth 4) -> blendMode undefined (options has { atlas })
|
|
||||||
expect(renderer.draws[1]!.target).toBe('wall')
|
|
||||||
expect(renderer.draws[1]!.depth).toBe(4)
|
|
||||||
expect(renderer.draws[1]!.blendMode).toBeUndefined()
|
|
||||||
|
|
||||||
// Draw 2: Object 2 (BOX, depth 6) -> normal
|
|
||||||
expect(renderer.draws[2]!.target).toBe('object')
|
|
||||||
expect(renderer.draws[2]!.depth).toBe(6)
|
|
||||||
expect(renderer.draws[2]!.blendMode).toBe('normal')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('strictly preserves depth ordering across mixed walls, objects, and incremental depth steps', () => {
|
|
||||||
const rawObjects: PackObjectInput[] = [
|
|
||||||
{ id: 1, depth: 3, token: '11', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 15, y: 0 },
|
|
||||||
{ id: 2, depth: 8, token: '12', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 40, y: 0 },
|
|
||||||
{ id: 3, depth: 15, token: 'CHEST', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 75, y: 0 },
|
|
||||||
]
|
|
||||||
const objs = buildObjectDrawables(rawObjects)
|
|
||||||
|
|
||||||
const walls: SimulatedWall[] = [
|
|
||||||
{ frame: { x: 0, y: 0, width: 32, height: 32, _depth: 1 } as any, x: 0, y: 0, cellX: 0, cellY: 1, page: 0 },
|
|
||||||
{ frame: { x: 0, y: 0, width: 32, height: 32, _depth: 5 } as any, x: 0, y: 0, cellX: 2, cellY: 3, page: 0 },
|
|
||||||
{ frame: { x: 0, y: 0, width: 32, height: 32, _depth: 10 } as any, x: 0, y: 0, cellX: 5, cellY: 5, page: 0 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const renderer = new MockRenderer()
|
|
||||||
executeDepthDrawDispatch(renderer, {
|
|
||||||
walls,
|
|
||||||
objs,
|
|
||||||
maxDepthSteps: [4, 9, Infinity],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(renderer.draws).toHaveLength(6)
|
|
||||||
|
|
||||||
// Verify non-decreasing order
|
|
||||||
for (let i = 1; i < renderer.draws.length; i++) {
|
|
||||||
expect(renderer.draws[i]!.depth).toBeGreaterThanOrEqual(renderer.draws[i - 1]!.depth)
|
|
||||||
}
|
|
||||||
|
|
||||||
const depths = renderer.draws.map(d => d.depth)
|
|
||||||
expect(depths).toEqual([1, 3, 5, 8, 10, 15])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -5,21 +5,18 @@
|
||||||
import { describe, expect, it, beforeEach } from 'vitest'
|
import { 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)
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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)', () => {
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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' })
|
||||||
|
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
import { describe, test, expect } from 'vitest'
|
|
||||||
import * as fs from 'node:fs'
|
|
||||||
import * as path from 'node:path'
|
|
||||||
import { buildBounds, intersects, viewportRect, CULL_MARGIN } from '../src/client/render/cull.ts'
|
|
||||||
|
|
||||||
// 视口剔除的回归护栏。
|
|
||||||
//
|
|
||||||
// 背景(见 issue #16):主循环原本把整张地图无条件提交给渲染器,实测 365 张烘焙
|
|
||||||
// 关卡平均每帧 2 848 个四边形、最差 14 400 个,而真正落在视口里的只有 7%~11%。
|
|
||||||
// 剔除本身不难,难的是**别把画家顺序弄坏**:墙和对象是按深度两路归并出来的,
|
|
||||||
// 一旦剔除时让某一路的游标停住,后面所有东西的前后遮挡关系就全乱了。
|
|
||||||
//
|
|
||||||
// 所以这里除了边界条件,还用真实关卡数据做一次全量重放,断言"开剔除后画出来的
|
|
||||||
// 序列,必须是不开剔除时那个序列的子序列"——顺序一致、只少不多、不多不错。
|
|
||||||
|
|
||||||
/** 一个最小可绘制物。 */
|
|
||||||
function drawable(x: number, y: number, width: number, height: number) {
|
|
||||||
return { x, y, frame: { width, height } }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('视口剔除', () => {
|
|
||||||
test('包围盒摊平成 [x0, y0, x1, y1]', () => {
|
|
||||||
const bounds = buildBounds([drawable(10, 20, 80, 40), drawable(-5, -6, 1, 2)])
|
|
||||||
expect(Array.from(bounds)).toEqual([10, 20, 90, 60, -5, -6, -4, -4])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('空列表得到空包围盒', () => {
|
|
||||||
expect(buildBounds([]).length).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('视口矩形与顶点着色器用同一个半宽公式', () => {
|
|
||||||
const view = viewportRect(1000, 500, 1280, 720, 2)
|
|
||||||
expect(view.left).toBe(1000 - 1280 / 4 - CULL_MARGIN)
|
|
||||||
expect(view.right).toBe(1000 + 1280 / 4 + CULL_MARGIN)
|
|
||||||
expect(view.top).toBe(500 - 720 / 4 - CULL_MARGIN)
|
|
||||||
expect(view.bottom).toBe(500 + 720 / 4 + CULL_MARGIN)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('缩小时视口变大,放大时视口变小', () => {
|
|
||||||
const wide = viewportRect(0, 0, 1280, 720, 0.5)
|
|
||||||
const tight = viewportRect(0, 0, 1280, 720, 4)
|
|
||||||
expect(wide.right - wide.left).toBeGreaterThan(tight.right - tight.left)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('视口内的留下,视口外的剔掉', () => {
|
|
||||||
const view = viewportRect(0, 0, 200, 200, 1)
|
|
||||||
// 半宽 100 + 余量 32 = 132。
|
|
||||||
const bounds = buildBounds([
|
|
||||||
drawable(0, 0, 10, 10), // 正中
|
|
||||||
drawable(1000, 0, 10, 10), // 右边远处
|
|
||||||
drawable(0, -5000, 10, 10), // 上方远处
|
|
||||||
])
|
|
||||||
expect(intersects(bounds, 0, view)).toBe(true)
|
|
||||||
expect(intersects(bounds, 1, view)).toBe(false)
|
|
||||||
expect(intersects(bounds, 2, view)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('只有一角搭进视口也算可见', () => {
|
|
||||||
const view = viewportRect(0, 0, 200, 200, 1)
|
|
||||||
// 左上角在视口右下角外,但自身右下角刚好压线。
|
|
||||||
const bounds = buildBounds([drawable(132, 132, 10, 10)])
|
|
||||||
expect(intersects(bounds, 0, view)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('大精灵即使锚点在屏外也不能被剔掉', () => {
|
|
||||||
const view = viewportRect(0, 0, 200, 200, 1)
|
|
||||||
// 资源包里最大的帧是 160×960:锚点远在视口上方,画出来的部分却盖住整屏。
|
|
||||||
const bounds = buildBounds([drawable(0, -900, 160, 960)])
|
|
||||||
expect(intersects(bounds, 0, view)).toBe(true)
|
|
||||||
// 高度换成 10 之后就真的看不见了,说明上面那条靠的是帧尺寸而不是坐标。
|
|
||||||
expect(intersects(buildBounds([drawable(0, -900, 160, 10)]), 0, view)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('余量让刚出界的一圈继续留下', () => {
|
|
||||||
const view = viewportRect(0, 0, 200, 200, 1)
|
|
||||||
// 半宽 100,落在 100~132 之间的只有靠余量才留得住。
|
|
||||||
expect(intersects(buildBounds([drawable(120, 0, 1, 1)]), 0, view)).toBe(true)
|
|
||||||
expect(intersects(buildBounds([drawable(140, 0, 1, 1)]), 0, view)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('换关卡时重建包围盒避免越界误剔除', () => {
|
|
||||||
// 模拟从 57x41 关卡(2337 个图元)切换到 80x80 关卡(6400 个图元)
|
|
||||||
const smallLevelDrawables = Array.from({ length: 2337 }, (_, i) => drawable(i * 10, 0, 80, 40))
|
|
||||||
const largeLevelDrawables = Array.from({ length: 6400 }, () => drawable(0, 0, 80, 40))
|
|
||||||
|
|
||||||
const view = viewportRect(0, 0, 1280, 720, 1)
|
|
||||||
|
|
||||||
// 旧实现未重建 bounds,largeLevel 使用 smallLevel 的 stale bounds
|
|
||||||
const staleBounds = buildBounds(smallLevelDrawables)
|
|
||||||
let visibleWithStaleBounds = 0
|
|
||||||
for (let i = 0; i < largeLevelDrawables.length; i += 1) {
|
|
||||||
if (intersects(staleBounds, i, view)) visibleWithStaleBounds += 1
|
|
||||||
}
|
|
||||||
expect(visibleWithStaleBounds).toBeLessThanOrEqual(2337)
|
|
||||||
|
|
||||||
// 修复后重建 bounds,所有落在视口内的图元正确通过剔除
|
|
||||||
const freshBounds = buildBounds(largeLevelDrawables)
|
|
||||||
let visibleWithFreshBounds = 0
|
|
||||||
for (let i = 0; i < largeLevelDrawables.length; i += 1) {
|
|
||||||
if (intersects(freshBounds, i, view)) visibleWithFreshBounds += 1
|
|
||||||
}
|
|
||||||
expect(visibleWithFreshBounds).toBe(6400)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 资源包位置;缺资源时整组测试跳过而不是报红。 */
|
|
||||||
const PACKS = path.resolve(__dirname, '../samples/d2-packs')
|
|
||||||
|
|
||||||
/** 所有含 scene.json 的关卡目录。 */
|
|
||||||
function levelDirs(): { name: string; scene: string }[] {
|
|
||||||
if (!fs.existsSync(PACKS)) return []
|
|
||||||
const out: { name: string; scene: string }[] = []
|
|
||||||
for (const act of fs.readdirSync(PACKS)) {
|
|
||||||
const actPath = path.join(PACKS, act)
|
|
||||||
if (!fs.statSync(actPath).isDirectory()) continue
|
|
||||||
for (const level of fs.readdirSync(actPath)) {
|
|
||||||
const scene = path.join(actPath, level, 'scene.json')
|
|
||||||
if (fs.existsSync(scene)) out.push({ name: `${act}/${level}`, scene })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Item { x: number; y: number; frame: { width: number; height: number }; depth: number }
|
|
||||||
|
|
||||||
/** 把一张 scene.json 摊成场景实际持有的四条绘制列表。 */
|
|
||||||
function loadLevel(scenePath: string) {
|
|
||||||
const json = JSON.parse(fs.readFileSync(scenePath, 'utf8')) as Record<string, any>
|
|
||||||
const place = (json.framePlacement ?? []) as number[][]
|
|
||||||
const tiles = (rows: number[][] | undefined): Item[] => (rows ?? []).map(row => {
|
|
||||||
const [frameIndex, x, y, cellX, cellY] = row
|
|
||||||
const p = place[frameIndex!] ?? [0, 0, 0, 0, 0]
|
|
||||||
return { x: x!, y: y!, frame: { width: p[3]!, height: p[4]! }, depth: cellX! + cellY! }
|
|
||||||
})
|
|
||||||
const objs: Item[] = (json.objects ?? [])
|
|
||||||
.filter((o: any) => o.frame)
|
|
||||||
.map((o: any) => ({ x: o.x, y: o.y, frame: { width: o.frame.width, height: o.frame.height }, depth: o.depth ?? 0 }))
|
|
||||||
.sort((a: Item, b: Item) => a.depth - b.depth)
|
|
||||||
const spawn = Array.isArray(json.spawn) ? { x: json.spawn[0] as number, y: json.spawn[1] as number } : { x: 0, y: 0 }
|
|
||||||
return { floors: tiles(json.floors), walls: tiles(json.walls), roofs: tiles(json.roofs), objs, spawn }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 重放一张关卡的绘制顺序。
|
|
||||||
*
|
|
||||||
* 这里刻意复刻 `act-scene` 的两路深度归并:剔除只跳过"画"这一步,游标照常前进。
|
|
||||||
*
|
|
||||||
* @param cull - 是否启用视口剔除。
|
|
||||||
* @returns 依次画出的标记,以及剔除数量。
|
|
||||||
*/
|
|
||||||
function replay(level: ReturnType<typeof loadLevel>, cull: boolean) {
|
|
||||||
const view = viewportRect(level.spawn.x, level.spawn.y - 16, 1280, 720, 1)
|
|
||||||
const floorBounds = buildBounds(level.floors)
|
|
||||||
const wallBounds = buildBounds(level.walls)
|
|
||||||
const roofBounds = buildBounds(level.roofs)
|
|
||||||
const objBounds = buildBounds(level.objs)
|
|
||||||
|
|
||||||
const order: string[] = []
|
|
||||||
let culled = 0
|
|
||||||
const emit = (bounds: Float32Array, index: number, item: Item, tag: string): void => {
|
|
||||||
if (cull && !intersects(bounds, index, view)) { culled += 1; return }
|
|
||||||
order.push(`${tag}:${String(item.x)},${String(item.y)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < level.floors.length; i += 1) emit(floorBounds, i, level.floors[i]!, 'f')
|
|
||||||
let wi = 0
|
|
||||||
let oi = 0
|
|
||||||
while (wi < level.walls.length || oi < level.objs.length) {
|
|
||||||
const wd = wi < level.walls.length ? level.walls[wi]!.depth : Infinity
|
|
||||||
const od = oi < level.objs.length ? level.objs[oi]!.depth : Infinity
|
|
||||||
if (wd <= od) { emit(wallBounds, wi, level.walls[wi]!, 'w'); wi += 1 }
|
|
||||||
else { emit(objBounds, oi, level.objs[oi]!, 'o'); oi += 1 }
|
|
||||||
}
|
|
||||||
for (let i = 0; i < level.roofs.length; i += 1) emit(roofBounds, i, level.roofs[i]!, 'r')
|
|
||||||
return { order, culled }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `sub` 是否按相同相对顺序出现在 `full` 里。 */
|
|
||||||
function isSubsequence(sub: string[], full: string[]): boolean {
|
|
||||||
let i = 0
|
|
||||||
for (const item of full) if (i < sub.length && sub[i] === item) i += 1
|
|
||||||
return i === sub.length
|
|
||||||
}
|
|
||||||
|
|
||||||
const levels = levelDirs()
|
|
||||||
|
|
||||||
describe.runIf(levels.length > 0)('剔除后的画家顺序(全量资源包重放)', () => {
|
|
||||||
test('每张关卡剔除后的绘制序列都是原序列的子序列', () => {
|
|
||||||
const broken: string[] = []
|
|
||||||
let before = 0
|
|
||||||
let after = 0
|
|
||||||
for (const { name, scene } of levels) {
|
|
||||||
const level = loadLevel(scene)
|
|
||||||
const full = replay(level, false)
|
|
||||||
const culled = replay(level, true)
|
|
||||||
if (!isSubsequence(culled.order, full.order)) broken.push(name)
|
|
||||||
before += full.order.length
|
|
||||||
after += culled.order.length
|
|
||||||
}
|
|
||||||
// 具名收集,失败时能直接看出是哪几张关卡乱了序。
|
|
||||||
expect(broken).toEqual([])
|
|
||||||
// 顺带守住收益:真剔掉了绝大多数,而不是悄悄退化成"全画"。
|
|
||||||
expect(after).toBeGreaterThan(0)
|
|
||||||
expect(after).toBeLessThan(before * 0.25)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, it } from 'vitest'
|
import { 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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -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[] = []
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,525 @@
|
||||||
|
/**
|
||||||
|
* Test-only legacy demo data (former single-player Sorceress profile).
|
||||||
|
*
|
||||||
|
* These literals used to live in `src/` (`DEFAULT_*_SKILLS`, starter gear/bag, default belt potions) and
|
||||||
|
* seeded the retired single-player HUD. The online client starts from a neutral server-not-ready state, so the
|
||||||
|
* data now exists only as fixtures for widget/tooltip/layout tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { HotkeySkillEntry } from '../../src/common/skills/skills-meta.ts'
|
||||||
|
import { BELT_COLS, BELT_ROWS, type BeltModel, type BeltPotion } from '../../src/common/items/belt-model.ts'
|
||||||
|
import type { EquipSlotId, GridPlacement, UiInventoryItem } from '../../src/common/items/inventory-model.ts'
|
||||||
|
|
||||||
|
export const DEMO_UNIVERSAL_SKILLS: readonly HotkeySkillEntry[] = [
|
||||||
|
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
|
||||||
|
{ skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
|
||||||
|
{ skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEMO_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [
|
||||||
|
// Universal Skills
|
||||||
|
...DEMO_UNIVERSAL_SKILLS,
|
||||||
|
// Fire Spells (8 active)
|
||||||
|
{ skillId: 36, name: 'Fire Bolt', nameZh: '火弹', level: 5, manaCost: 2.5, leftUsable: true, rightUsable: true },
|
||||||
|
{ skillId: 41, name: 'Inferno', nameZh: '地狱之火', level: 1, manaCost: 0.6, leftUsable: true, rightUsable: true },
|
||||||
|
{ skillId: 46, name: 'Blaze', nameZh: '炽烈之径', level: 1, manaCost: 11, leftUsable: false, rightUsable: true },
|
||||||
|
{ skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 8, manaCost: 8.5, leftUsable: true, rightUsable: true },
|
||||||
|
{ skillId: 51, name: 'Fire Wall', nameZh: '火墙', level: 1, manaCost: 22, leftUsable: false, rightUsable: true },
|
||||||
|
{ skillId: 52, name: 'Enchant', nameZh: '强化', level: 1, manaCost: 25, leftUsable: false, rightUsable: true },
|
||||||
|
{ skillId: 56, name: 'Meteor', nameZh: '陨石', level: 6, manaCost: 22, leftUsable: false, rightUsable: true },
|
||||||
|
{ skillId: 62, name: 'Hydra', nameZh: '九头海蛇', level: 1, manaCost: 20, leftUsable: false, rightUsable: true },
|
||||||
|
// Lightning Spells (9 active)
|
||||||
|
{ skillId: 38, name: 'Charged Bolt', nameZh: '充能弹', level: 4, manaCost: 4, leftUsable: true, rightUsable: true },
|
||||||
|
{ skillId: 42, name: 'Static Field', nameZh: '静态力场', level: 5, manaCost: 9, leftUsable: false, rightUsable: true },
|
||||||
|
{ skillId: 43, name: 'Telekinesis', nameZh: '心灵传动', level: 1, manaCost: 7, leftUsable: false, rightUsable: true },
|
||||||
|
{ skillId: 48, name: 'Nova', nameZh: '新星', level: 1, manaCost: 15, leftUsable: false, rightUsable: true },
|
||||||
|
{ skillId: 49, name: 'Lightning', nameZh: '闪电', level: 7, manaCost: 11, leftUsable: true, rightUsable: true },
|
||||||
|
{ skillId: 53, name: 'Chain Lightning', nameZh: '连锁闪电', level: 1, manaCost: 9, leftUsable: true, rightUsable: true },
|
||||||
|
{ skillId: 54, name: 'Teleport', nameZh: '传送', level: 4, manaCost: 21, leftUsable: false, rightUsable: true },
|
||||||
|
{ skillId: 57, name: 'Thunder Storm', nameZh: '雷云风暴', level: 1, manaCost: 19, leftUsable: false, rightUsable: true },
|
||||||
|
{ skillId: 58, name: 'Energy Shield', nameZh: '能量护盾', level: 1, manaCost: 5, leftUsable: false, rightUsable: true },
|
||||||
|
// Cold Spells (9 active)
|
||||||
|
{ skillId: 39, name: 'Ice Bolt', nameZh: '冰弹', level: 1, manaCost: 3, leftUsable: true, rightUsable: true },
|
||||||
|
{ skillId: 40, name: 'Frozen Armor', nameZh: '冰封装甲', level: 3, manaCost: 7, leftUsable: false, rightUsable: true },
|
||||||
|
{ skillId: 44, name: 'Frost Nova', nameZh: '霜之新星', level: 3, manaCost: 11, leftUsable: false, rightUsable: true },
|
||||||
|
{ skillId: 45, name: 'Ice Blast', nameZh: '冰风暴', level: 5, manaCost: 8, leftUsable: true, rightUsable: true },
|
||||||
|
{ skillId: 50, name: 'Shiver Armor', nameZh: '碎冰甲', level: 1, manaCost: 11, leftUsable: false, rightUsable: true },
|
||||||
|
{ skillId: 55, name: 'Glacial Spike', nameZh: '冰尖柱', level: 10, manaCost: 14.5, leftUsable: true, rightUsable: true },
|
||||||
|
{ skillId: 59, name: 'Blizzard', nameZh: '暴风雪', level: 10, manaCost: 27, leftUsable: false, rightUsable: true },
|
||||||
|
{ skillId: 60, name: 'Chilling Armor', nameZh: '寒冰装甲', level: 1, manaCost: 17, leftUsable: false, rightUsable: true },
|
||||||
|
{ skillId: 64, name: 'Frozen Orb', nameZh: '冰封球', level: 12, manaCost: 30, leftUsable: true, rightUsable: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEMO_EQUIPPED_GEAR: Readonly<Partial<Record<EquipSlotId, UiInventoryItem>>> = {
|
||||||
|
helm: {
|
||||||
|
id: 'eq-shako',
|
||||||
|
code: 'uap',
|
||||||
|
invFile: 'invcap',
|
||||||
|
invtransform: 'cgrn',
|
||||||
|
chrtransform: 'cgrn',
|
||||||
|
name: 'Harlequin Crest',
|
||||||
|
nameZh: '谐角之冠 (Harlequin Crest)',
|
||||||
|
baseNameZh: '军帽 (Shako)',
|
||||||
|
quality: 'unique',
|
||||||
|
invWidth: 2,
|
||||||
|
invHeight: 2,
|
||||||
|
allowedSlots: ['helm'],
|
||||||
|
defense: 141,
|
||||||
|
durability: { current: 12, max: 12 },
|
||||||
|
reqLevel: 62,
|
||||||
|
reqStr: 50,
|
||||||
|
stats: [
|
||||||
|
{ text: '+2 所有技能 (+2 To All Skills)', color: 'blue' },
|
||||||
|
{ text: '+1.5 生命 (依角色等级而定)', color: 'blue' },
|
||||||
|
{ text: '+1.5 法力 (依角色等级而定)', color: 'blue' },
|
||||||
|
{ text: '物理伤害减少 10%', color: 'blue' },
|
||||||
|
{ text: '50% 更佳机会取得魔法装备 (MF)', color: 'blue' },
|
||||||
|
{ text: '+2 所有属性', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
amulet: {
|
||||||
|
id: 'eq-maras',
|
||||||
|
code: 'amu',
|
||||||
|
invFile: 'invamu',
|
||||||
|
name: "Mara's Kaleidoscope",
|
||||||
|
nameZh: "马拉的万花筒 (Mara's Kaleidoscope)",
|
||||||
|
baseNameZh: '项链 (Amulet)',
|
||||||
|
quality: 'unique',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
allowedSlots: ['amulet'],
|
||||||
|
reqLevel: 67,
|
||||||
|
stats: [
|
||||||
|
{ text: '+2 所有技能 (+2 To All Skills)', color: 'blue' },
|
||||||
|
{ text: '+5 所有属性', color: 'blue' },
|
||||||
|
{ text: '所有抗性 +30 (All Resistances +30)', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
weapon1: {
|
||||||
|
id: 'eq-oculus',
|
||||||
|
code: 'oba',
|
||||||
|
invFile: 'invob5',
|
||||||
|
invtransform: 'whit',
|
||||||
|
chrtransform: 'whit',
|
||||||
|
name: 'The Oculus',
|
||||||
|
nameZh: '眼球 (The Oculus)',
|
||||||
|
baseNameZh: '涡流水晶 (Swirling Crystal)',
|
||||||
|
quality: 'unique',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 3,
|
||||||
|
allowedSlots: ['weapon1'],
|
||||||
|
damage: '18 - 42',
|
||||||
|
durability: { current: 50, max: 50 },
|
||||||
|
speedText: '法球类 - 普通攻击速度',
|
||||||
|
reqLevel: 42,
|
||||||
|
stats: [
|
||||||
|
{ text: '被击中时有 25% 几率施放等级 1 传送', color: 'blue' },
|
||||||
|
{ text: '+3 法师技能等级', color: 'blue' },
|
||||||
|
{ text: '+30% 快速施法速度 (FCR)', color: 'blue' },
|
||||||
|
{ text: '+20% 增强防御', color: 'blue' },
|
||||||
|
{ text: '+20 体力', color: 'blue' },
|
||||||
|
{ text: '+20 精力', color: 'blue' },
|
||||||
|
{ text: '所有抗性 +20', color: 'blue' },
|
||||||
|
{ text: '+5 每次击杀获得法力', color: 'blue' },
|
||||||
|
{ text: '50% 更佳机会取得魔法装备', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
armor: {
|
||||||
|
id: 'eq-tal-armor',
|
||||||
|
code: 'uth',
|
||||||
|
invFile: 'invgth',
|
||||||
|
invtransform: 'dpur',
|
||||||
|
chrtransform: 'dpur',
|
||||||
|
name: "Tal Rasha's Guardianship",
|
||||||
|
nameZh: '塔·拉夏的守护',
|
||||||
|
baseNameZh: '漆甲 (Lacquered Plate)',
|
||||||
|
quality: 'set',
|
||||||
|
invWidth: 2,
|
||||||
|
invHeight: 3,
|
||||||
|
allowedSlots: ['armor'],
|
||||||
|
defense: 941,
|
||||||
|
durability: { current: 55, max: 55 },
|
||||||
|
reqLevel: 71,
|
||||||
|
reqStr: 84,
|
||||||
|
stats: [
|
||||||
|
{ text: '+400 防御', color: 'blue' },
|
||||||
|
{ text: '抗寒 +40%', color: 'blue' },
|
||||||
|
{ text: '抗电 +40%', color: 'blue' },
|
||||||
|
{ text: '抗火 +40%', color: 'blue' },
|
||||||
|
{ text: '魔法伤害减少 15', color: 'blue' },
|
||||||
|
{ text: '88% 更佳机会取得魔法装备', color: 'blue' },
|
||||||
|
],
|
||||||
|
setPieces: [
|
||||||
|
'塔·拉夏的守护 (Tal Rasha\'s Guardianship)',
|
||||||
|
'塔·拉夏的赫拉迪克纹章 (Tal Rasha\'s Horadric Crest)',
|
||||||
|
'塔·拉夏的判决 (Tal Rasha\'s Adjudication)',
|
||||||
|
'塔·拉夏的无睑之眼 (Tal Rasha\'s Lidless Eye)',
|
||||||
|
'塔·拉夏的织细衣服 (Tal Rasha\'s Fine-Spun Cloth)',
|
||||||
|
],
|
||||||
|
setBonuses: [
|
||||||
|
{ text: '+10% 快速再度攻击 (2 件装备)', color: 'green' },
|
||||||
|
{ text: '+3 法师技能等级 (完整套装)', color: 'green' },
|
||||||
|
{ text: '所有抗性 +50 (完整套装)', color: 'green' },
|
||||||
|
{ text: '+150 生命 (完整套装)', color: 'green' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
weapon2: {
|
||||||
|
id: 'eq-spirit',
|
||||||
|
code: 'uit',
|
||||||
|
invFile: 'invtow',
|
||||||
|
name: 'Spirit Monarch',
|
||||||
|
nameZh: '精神 (Spirit) 统盾',
|
||||||
|
baseNameZh: '统治者大盾 (Monarch)',
|
||||||
|
quality: 'rune',
|
||||||
|
runewordRunes: "'TalThulOrtAmn'",
|
||||||
|
invWidth: 2,
|
||||||
|
invHeight: 3,
|
||||||
|
allowedSlots: ['weapon2'],
|
||||||
|
defense: 148,
|
||||||
|
durability: { current: 148, max: 148 },
|
||||||
|
reqLevel: 54,
|
||||||
|
reqStr: 156,
|
||||||
|
sockets: 4,
|
||||||
|
stats: [
|
||||||
|
{ text: '+2 所有技能 (+2 To All Skills)', color: 'blue' },
|
||||||
|
{ text: '+35% 快速施法速度 (FCR)', color: 'blue' },
|
||||||
|
{ text: '+55% 快速再度攻击 (FHR)', color: 'blue' },
|
||||||
|
{ text: '+250 对远距离攻击的防御', color: 'blue' },
|
||||||
|
{ text: '+22 体力', color: 'blue' },
|
||||||
|
{ text: '+112 法力', color: 'blue' },
|
||||||
|
{ text: '抗寒 +35%', color: 'blue' },
|
||||||
|
{ text: '抗电 +35%', color: 'blue' },
|
||||||
|
{ text: '抗毒 +35%', color: 'blue' },
|
||||||
|
{ text: '+8 魔法吸收', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
gloves: {
|
||||||
|
id: 'eq-magefist',
|
||||||
|
code: 'tgl',
|
||||||
|
invFile: 'invtgl',
|
||||||
|
invtransform: 'lgry',
|
||||||
|
name: 'Magefist',
|
||||||
|
nameZh: '法师之拳 (Magefist)',
|
||||||
|
baseNameZh: '轻型铁手套',
|
||||||
|
quality: 'unique',
|
||||||
|
invWidth: 2,
|
||||||
|
invHeight: 2,
|
||||||
|
allowedSlots: ['gloves'],
|
||||||
|
defense: 25,
|
||||||
|
durability: { current: 14, max: 14 },
|
||||||
|
reqLevel: 23,
|
||||||
|
reqStr: 45,
|
||||||
|
stats: [
|
||||||
|
{ text: '+1 火焰技能', color: 'blue' },
|
||||||
|
{ text: '+20% 快速施法速度 (FCR)', color: 'blue' },
|
||||||
|
{ text: '法力再生速度提高 25%', color: 'blue' },
|
||||||
|
{ text: '增加 1-6 火焰伤害', color: 'blue' },
|
||||||
|
{ text: '+30% 增强防御', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ring1: {
|
||||||
|
id: 'eq-soj-1',
|
||||||
|
code: 'rin',
|
||||||
|
invFile: 'invrin',
|
||||||
|
name: 'The Stone of Jordan',
|
||||||
|
nameZh: '乔丹之石 (Stone of Jordan)',
|
||||||
|
baseNameZh: '戒指 (Ring)',
|
||||||
|
quality: 'unique',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
allowedSlots: ['ring1', 'ring2'],
|
||||||
|
reqLevel: 29,
|
||||||
|
stats: [
|
||||||
|
{ text: '+1 所有技能', color: 'blue' },
|
||||||
|
{ text: '增加法力上限 25%', color: 'blue' },
|
||||||
|
{ text: '+20 法力', color: 'blue' },
|
||||||
|
{ text: '增加 1-12 闪电伤害', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
belt: {
|
||||||
|
id: 'eq-arach',
|
||||||
|
code: 'ulc',
|
||||||
|
invFile: 'invvbl',
|
||||||
|
invtransform: 'blac',
|
||||||
|
name: 'Arachnid Mesh',
|
||||||
|
nameZh: '蜘蛛之网 (Arachnid Mesh)',
|
||||||
|
baseNameZh: '蛛网腰带 (Spiderweb Sash)',
|
||||||
|
quality: 'unique',
|
||||||
|
invWidth: 2,
|
||||||
|
invHeight: 1,
|
||||||
|
allowedSlots: ['belt'],
|
||||||
|
defense: 138,
|
||||||
|
durability: { current: 12, max: 12 },
|
||||||
|
reqLevel: 80,
|
||||||
|
reqStr: 50,
|
||||||
|
stats: [
|
||||||
|
{ text: '+1 所有技能', color: 'blue' },
|
||||||
|
{ text: '+20% 快速施法速度 (FCR)', color: 'blue' },
|
||||||
|
{ text: '减慢目标速度 10%', color: 'blue' },
|
||||||
|
{ text: '增加法力上限 5%', color: 'blue' },
|
||||||
|
{ text: '+120% 增强防御', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ring2: {
|
||||||
|
id: 'eq-bk-2',
|
||||||
|
code: 'rin',
|
||||||
|
invFile: 'invrin',
|
||||||
|
name: "Bul-Kathos' Wedding Band",
|
||||||
|
nameZh: '布尔凯索之婚戒',
|
||||||
|
baseNameZh: '戒指 (Ring)',
|
||||||
|
quality: 'unique',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
allowedSlots: ['ring1', 'ring2'],
|
||||||
|
reqLevel: 58,
|
||||||
|
stats: [
|
||||||
|
{ text: '+1 所有技能', color: 'blue' },
|
||||||
|
{ text: '+45 生命 (依角色等级而定)', color: 'blue' },
|
||||||
|
{ text: '5% 每次命中偷取生命', color: 'blue' },
|
||||||
|
{ text: '+50 最大耐力', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
boots: {
|
||||||
|
id: 'eq-wartrav',
|
||||||
|
code: 'xtb',
|
||||||
|
invFile: 'invtbt',
|
||||||
|
name: 'War Traveler',
|
||||||
|
nameZh: '战争旅者 (War Traveler)',
|
||||||
|
baseNameZh: '战场之靴 (Battle Boots)',
|
||||||
|
quality: 'unique',
|
||||||
|
invWidth: 2,
|
||||||
|
invHeight: 2,
|
||||||
|
allowedSlots: ['boots'],
|
||||||
|
defense: 139,
|
||||||
|
durability: { current: 48, max: 48 },
|
||||||
|
reqLevel: 42,
|
||||||
|
reqStr: 95,
|
||||||
|
stats: [
|
||||||
|
{ text: '+25% 高速跑步/行走', color: 'blue' },
|
||||||
|
{ text: '+10 力量', color: 'blue' },
|
||||||
|
{ text: '+10 体力', color: 'blue' },
|
||||||
|
{ text: '增加 15-25 伤害', color: 'blue' },
|
||||||
|
{ text: '+190% 增强防御', color: 'blue' },
|
||||||
|
{ text: '减慢耐力消耗 40%', color: 'blue' },
|
||||||
|
{ text: '攻击者受到伤害 10', color: 'blue' },
|
||||||
|
{ text: '50% 更佳机会取得魔法装备 (MF)', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEMO_BAG_ITEMS: readonly GridPlacement[] = [
|
||||||
|
{
|
||||||
|
col: 0,
|
||||||
|
row: 0,
|
||||||
|
item: {
|
||||||
|
id: 'bag-cube',
|
||||||
|
code: 'box',
|
||||||
|
invFile: 'invbox',
|
||||||
|
name: 'Horadric Cube',
|
||||||
|
nameZh: '赫拉迪克方块 (Horadric Cube)',
|
||||||
|
baseNameZh: '任务宝盒',
|
||||||
|
quality: 'unique',
|
||||||
|
invWidth: 2,
|
||||||
|
invHeight: 2,
|
||||||
|
allowedSlots: [],
|
||||||
|
stats: [{ text: '右键点击打开合成界面 (3×4 网格)', color: 'gold' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
col: 2,
|
||||||
|
row: 0,
|
||||||
|
item: {
|
||||||
|
id: 'bag-tbk',
|
||||||
|
code: 'tbk',
|
||||||
|
invFile: 'invbbk',
|
||||||
|
name: 'Tome of Town Portal',
|
||||||
|
nameZh: '回城之书 (Tome of Town Portal)',
|
||||||
|
baseNameZh: '法术书 (数量: 20)',
|
||||||
|
quality: 'normal',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 2,
|
||||||
|
allowedSlots: [],
|
||||||
|
quantity: 20,
|
||||||
|
maxQuantity: 20,
|
||||||
|
stats: [{ text: '右键施放城镇传送门', color: 'white' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
col: 2,
|
||||||
|
row: 2,
|
||||||
|
item: {
|
||||||
|
id: 'bag-ibk',
|
||||||
|
code: 'ibk',
|
||||||
|
invFile: 'invrbk',
|
||||||
|
name: 'Tome of Identify',
|
||||||
|
nameZh: '鉴定之书 (Tome of Identify)',
|
||||||
|
baseNameZh: '法术书 (数量: 20)',
|
||||||
|
quality: 'normal',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 2,
|
||||||
|
allowedSlots: [],
|
||||||
|
quantity: 20,
|
||||||
|
maxQuantity: 20,
|
||||||
|
stats: [{ text: '鉴定未辨识的魔法/暗金装备', color: 'white' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
col: 3,
|
||||||
|
row: 0,
|
||||||
|
item: {
|
||||||
|
id: 'bag-torch',
|
||||||
|
code: 'cm2',
|
||||||
|
invFile: 'invtrch',
|
||||||
|
name: 'Hellfire Torch',
|
||||||
|
nameZh: '地狱火炬 (Hellfire Torch)',
|
||||||
|
baseNameZh: '大型护身符 (Large Charm)',
|
||||||
|
quality: 'unique',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 2,
|
||||||
|
allowedSlots: [],
|
||||||
|
reqLevel: 75,
|
||||||
|
stats: [
|
||||||
|
{ text: '击中时有 5% 几率施放等级 10 火风暴', color: 'blue' },
|
||||||
|
{ text: '+3 法师技能等级', color: 'blue' },
|
||||||
|
{ text: '+20 所有属性', color: 'blue' },
|
||||||
|
{ text: '所有抗性 +20', color: 'blue' },
|
||||||
|
{ text: '+8 照亮范围', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
col: 3,
|
||||||
|
row: 2,
|
||||||
|
item: {
|
||||||
|
id: 'bag-anni',
|
||||||
|
code: 'cm1',
|
||||||
|
invFile: 'invmss',
|
||||||
|
name: 'Annihilus',
|
||||||
|
nameZh: '毁灭 (Annihilus)',
|
||||||
|
baseNameZh: '小型护身符 (Small Charm)',
|
||||||
|
quality: 'unique',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
allowedSlots: [],
|
||||||
|
reqLevel: 70,
|
||||||
|
stats: [
|
||||||
|
{ text: '+1 所有技能 (+1 To All Skills)', color: 'blue' },
|
||||||
|
{ text: '+20 所有属性', color: 'blue' },
|
||||||
|
{ text: '所有抗性 +20', color: 'blue' },
|
||||||
|
{ text: '+10% 获得经验值', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
col: 4,
|
||||||
|
row: 0,
|
||||||
|
item: {
|
||||||
|
id: 'bag-gheeds',
|
||||||
|
code: 'cm3',
|
||||||
|
invFile: 'invch3',
|
||||||
|
name: "Gheed's Fortune",
|
||||||
|
nameZh: '基德的运气 (Gheed\'s Fortune)',
|
||||||
|
baseNameZh: '超大型护身符 (Grand Charm)',
|
||||||
|
quality: 'unique',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 3,
|
||||||
|
allowedSlots: [],
|
||||||
|
reqLevel: 62,
|
||||||
|
stats: [
|
||||||
|
{ text: '160% 额外金币从怪物身上取得', color: 'blue' },
|
||||||
|
{ text: '40% 更佳机会取得魔法装备 (MF)', color: 'blue' },
|
||||||
|
{ text: '降低所有商店价格 15%', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
col: 8,
|
||||||
|
row: 0,
|
||||||
|
item: {
|
||||||
|
id: 'bag-cta',
|
||||||
|
code: 'crs',
|
||||||
|
invFile: 'invcrs',
|
||||||
|
name: 'Call to Arms',
|
||||||
|
nameZh: '战争召唤 (Call to Arms)',
|
||||||
|
baseNameZh: '水晶剑 (Crystal Sword)',
|
||||||
|
quality: 'rune',
|
||||||
|
runewordRunes: "'AmnRalMalIstOhm'",
|
||||||
|
invWidth: 2,
|
||||||
|
invHeight: 3,
|
||||||
|
allowedSlots: ['weapon1'],
|
||||||
|
damage: '17 - 52',
|
||||||
|
durability: { current: 20, max: 20 },
|
||||||
|
reqStr: 43,
|
||||||
|
reqLevel: 57,
|
||||||
|
sockets: 5,
|
||||||
|
speedText: '剑类 - 极快的攻击速度',
|
||||||
|
stats: [
|
||||||
|
{ text: '+1 所有技能 (+1 To All Skills)', color: 'blue' },
|
||||||
|
{ text: '+40% 增加攻击速度 (+40% Increased Attack Speed)', color: 'blue' },
|
||||||
|
{ text: '+255% 增强伤害 (+255% Enhanced Damage)', color: 'blue' },
|
||||||
|
{ text: '增加 5-30 火焰伤害 (Adds 5-30 Fire Damage)', color: 'blue' },
|
||||||
|
{ text: '7% 生命偷取 / 每次命中偷取生命 (7% Life Stolen)', color: 'blue' },
|
||||||
|
{ text: '防止怪物自愈 / 防止怪物自疗 (Prevent Monster Heal)', color: 'blue' },
|
||||||
|
{ text: '+6 战斗体制 (Battle Orders)', color: 'blue' },
|
||||||
|
{ text: '+6 战斗指挥 (Battle Command)', color: 'blue' },
|
||||||
|
{ text: '+4 战斗狂嗥 / 战嚎 (Battle Cry)', color: 'blue' },
|
||||||
|
{ text: '生命回复 +12 / 生命补满 +12 (Replenish Life +12)', color: 'blue' },
|
||||||
|
{ text: '30% 更佳机会取得魔法装备 (30% Better Chance of Getting Magic Items)', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEMO_BELT_POTIONS: readonly BeltPotion[] = [
|
||||||
|
{
|
||||||
|
id: 'pot-hp-1',
|
||||||
|
code: 'hp4',
|
||||||
|
invFile: 'invhp4',
|
||||||
|
name: 'Greater Healing Potion',
|
||||||
|
nameZh: '强效治疗药剂',
|
||||||
|
kind: 'hp',
|
||||||
|
healHp: 180,
|
||||||
|
healMana: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pot-hp-2',
|
||||||
|
code: 'hp5',
|
||||||
|
invFile: 'invhp5',
|
||||||
|
name: 'Super Healing Potion',
|
||||||
|
nameZh: '超级治疗药剂',
|
||||||
|
kind: 'hp',
|
||||||
|
healHp: 320,
|
||||||
|
healMana: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pot-mp-1',
|
||||||
|
code: 'mp4',
|
||||||
|
invFile: 'invmp4',
|
||||||
|
name: 'Greater Mana Potion',
|
||||||
|
nameZh: '强效法力药剂',
|
||||||
|
kind: 'mana',
|
||||||
|
healHp: 0,
|
||||||
|
healMana: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pot-rv-1',
|
||||||
|
code: 'rvl',
|
||||||
|
invFile: 'invvpl',
|
||||||
|
name: 'Full Rejuvenation Potion',
|
||||||
|
nameZh: '全面回复活力药剂',
|
||||||
|
kind: 'rejuv',
|
||||||
|
healHp: 400,
|
||||||
|
healMana: 400,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Fill every 4x4 belt slot with the demo potion of its column. */
|
||||||
|
export function seedDemoBeltPotions<T extends BeltModel>(belt: T): T {
|
||||||
|
for (let row = 0; row < BELT_ROWS; row++) {
|
||||||
|
for (let col = 0; col < BELT_COLS; col++) {
|
||||||
|
const template = DEMO_BELT_POTIONS[col]!
|
||||||
|
belt.grid[row]![col] = { ...template, id: `${template.id}-r${row}-c${col}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return belt
|
||||||
|
}
|
||||||
|
|
@ -9,16 +9,12 @@
|
||||||
|
|
||||||
import { CharacterSheetPanel } from '../../src/client/ui/character-sheet.ts'
|
import { 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -1,568 +0,0 @@
|
||||||
/**
|
|
||||||
* Diablo II v1.13c Ground Items Alt Labels & Ladder Collision Tests (Issue #390).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'
|
|
||||||
import type { GroundItemEntity } from '../src/common/items/ground-items.ts'
|
|
||||||
import {
|
|
||||||
GROUND_LABEL_QUALITY_COLORS,
|
|
||||||
getGroundItemQualityColor,
|
|
||||||
formatGroundItemLabelText,
|
|
||||||
estimateLabelWidth,
|
|
||||||
doLabelsOverlap,
|
|
||||||
resolveLadderCollisions,
|
|
||||||
computeInitialGroundLabelLayouts,
|
|
||||||
GroundLabelOverlay,
|
|
||||||
type GroundLabelLayout,
|
|
||||||
} from '../src/client/ui/ground-labels.ts'
|
|
||||||
|
|
||||||
function createMockItem(partial: Partial<GroundItemEntity> = {}): GroundItemEntity {
|
|
||||||
return {
|
|
||||||
id: partial.id ?? 'item-1',
|
|
||||||
item: partial.item ?? {},
|
|
||||||
name: partial.name ?? 'Short Sword',
|
|
||||||
nameZh: partial.nameZh ?? '短剑',
|
|
||||||
quality: partial.quality ?? 'normal',
|
|
||||||
isGold: partial.isGold ?? false,
|
|
||||||
amount: partial.amount ?? 1,
|
|
||||||
invWidth: partial.invWidth ?? 1,
|
|
||||||
invHeight: partial.invHeight ?? 2,
|
|
||||||
dropTime: 1000,
|
|
||||||
x: partial.x ?? 400,
|
|
||||||
y: partial.y ?? 300,
|
|
||||||
cellX: 20,
|
|
||||||
cellY: 15,
|
|
||||||
sparklePhase: 0,
|
|
||||||
ethereal: partial.ethereal,
|
|
||||||
sockets: partial.sockets,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Ground Items Alt Labels (Issue #390)', () => {
|
|
||||||
describe('Quality Colors & Text Formatting', () => {
|
|
||||||
it('formats gold amounts accurately as authentic Chinese localized text', () => {
|
|
||||||
const gold1 = createMockItem({ isGold: true, amount: 150 })
|
|
||||||
expect(formatGroundItemLabelText(gold1)).toBe('150 金币')
|
|
||||||
|
|
||||||
const gold2 = createMockItem({ isGold: true, amount: 25000 })
|
|
||||||
expect(formatGroundItemLabelText(gold2)).toBe('25000 金币')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('formats item display names preferring nameZh with fallback to name', () => {
|
|
||||||
const item1 = createMockItem({ name: 'Short Sword', nameZh: '短剑' })
|
|
||||||
expect(formatGroundItemLabelText(item1)).toBe('短剑')
|
|
||||||
|
|
||||||
const item2 = createMockItem({ name: 'Grand Charm', nameZh: '' })
|
|
||||||
expect(formatGroundItemLabelText(item2)).toBe('Grand Charm')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps all Diablo II 1.13c item qualities to authentic palette colors', () => {
|
|
||||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'normal' }))).toBe(
|
|
||||||
GROUND_LABEL_QUALITY_COLORS.normal,
|
|
||||||
)
|
|
||||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'low' }))).toBe(
|
|
||||||
GROUND_LABEL_QUALITY_COLORS.low,
|
|
||||||
)
|
|
||||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'superior' }))).toBe(
|
|
||||||
GROUND_LABEL_QUALITY_COLORS.superior,
|
|
||||||
)
|
|
||||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'magic' }))).toBe(
|
|
||||||
GROUND_LABEL_QUALITY_COLORS.magic,
|
|
||||||
)
|
|
||||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'rare' }))).toBe(
|
|
||||||
GROUND_LABEL_QUALITY_COLORS.rare,
|
|
||||||
)
|
|
||||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'set' }))).toBe(
|
|
||||||
GROUND_LABEL_QUALITY_COLORS.set,
|
|
||||||
)
|
|
||||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'unique' }))).toBe(
|
|
||||||
GROUND_LABEL_QUALITY_COLORS.unique,
|
|
||||||
)
|
|
||||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'craft' }))).toBe(
|
|
||||||
GROUND_LABEL_QUALITY_COLORS.craft,
|
|
||||||
)
|
|
||||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'rune' }))).toBe(
|
|
||||||
GROUND_LABEL_QUALITY_COLORS.rune,
|
|
||||||
)
|
|
||||||
expect(getGroundItemQualityColor(createMockItem({ isGold: true }))).toBe(
|
|
||||||
GROUND_LABEL_QUALITY_COLORS.gold,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('colors ethereal or socketed normal and superior items gray (#808080) while preserving magic/rare/set/unique/runeword colors (Issue #424)', () => {
|
|
||||||
// Ethereal normal -> gray (#808080)
|
|
||||||
expect(
|
|
||||||
getGroundItemQualityColor(createMockItem({ quality: 'normal', ethereal: true })),
|
|
||||||
).toBe('#808080')
|
|
||||||
expect(
|
|
||||||
getGroundItemQualityColor(createMockItem({ quality: 'normal', item: { ethereal: true } })),
|
|
||||||
).toBe('#808080')
|
|
||||||
|
|
||||||
// Socketed normal -> gray (#808080)
|
|
||||||
expect(
|
|
||||||
getGroundItemQualityColor(createMockItem({ quality: 'normal', sockets: 3 })),
|
|
||||||
).toBe('#808080')
|
|
||||||
expect(
|
|
||||||
getGroundItemQualityColor(createMockItem({ quality: 'normal', item: { sockets: 4 } })),
|
|
||||||
).toBe('#808080')
|
|
||||||
|
|
||||||
// Socketed or ethereal superior / high -> gray (#808080)
|
|
||||||
expect(
|
|
||||||
getGroundItemQualityColor(createMockItem({ quality: 'superior', sockets: 2 })),
|
|
||||||
).toBe('#808080')
|
|
||||||
expect(
|
|
||||||
getGroundItemQualityColor(createMockItem({ quality: 'high', ethereal: true })),
|
|
||||||
).toBe('#808080')
|
|
||||||
|
|
||||||
// Non-ethereal 0-socket normal/superior remains normal/superior white
|
|
||||||
expect(
|
|
||||||
getGroundItemQualityColor(createMockItem({ quality: 'normal', ethereal: false, sockets: 0 })),
|
|
||||||
).toBe(GROUND_LABEL_QUALITY_COLORS.normal)
|
|
||||||
expect(
|
|
||||||
getGroundItemQualityColor(createMockItem({ quality: 'superior', ethereal: false, sockets: 0 })),
|
|
||||||
).toBe(GROUND_LABEL_QUALITY_COLORS.superior)
|
|
||||||
|
|
||||||
// Completed runewords keep unique gold (#908858)
|
|
||||||
expect(
|
|
||||||
getGroundItemQualityColor(createMockItem({ quality: 'normal', sockets: 4, item: { isRuneword: true, sockets: 4 } })),
|
|
||||||
).toBe(GROUND_LABEL_QUALITY_COLORS.unique)
|
|
||||||
|
|
||||||
// Magic, rare, set, unique keep their quality colors even when ethereal or socketed
|
|
||||||
expect(
|
|
||||||
getGroundItemQualityColor(createMockItem({ quality: 'magic', ethereal: true, sockets: 2 })),
|
|
||||||
).toBe(GROUND_LABEL_QUALITY_COLORS.magic)
|
|
||||||
expect(
|
|
||||||
getGroundItemQualityColor(createMockItem({ quality: 'rare', ethereal: true, sockets: 2 })),
|
|
||||||
).toBe(GROUND_LABEL_QUALITY_COLORS.rare)
|
|
||||||
expect(
|
|
||||||
getGroundItemQualityColor(createMockItem({ quality: 'set', sockets: 3 })),
|
|
||||||
).toBe(GROUND_LABEL_QUALITY_COLORS.set)
|
|
||||||
expect(
|
|
||||||
getGroundItemQualityColor(createMockItem({ quality: 'unique', ethereal: true, sockets: 1 })),
|
|
||||||
).toBe(GROUND_LABEL_QUALITY_COLORS.unique)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Label Width Estimation', () => {
|
|
||||||
it('correctly accounts for both ASCII and CJK wide characters', () => {
|
|
||||||
const asciiW = estimateLabelWidth('OK', 13)
|
|
||||||
const cjkW = estimateLabelWidth('戒指', 13)
|
|
||||||
// CJK characters have greater pixel width than Latin characters for same character count
|
|
||||||
expect(cjkW).toBeGreaterThan(asciiW)
|
|
||||||
expect(asciiW).toBeGreaterThan(20)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Overlap Detection & Layout Calculation', () => {
|
|
||||||
it('projects ground items to initial layouts with natural Y offset', () => {
|
|
||||||
const items = [
|
|
||||||
createMockItem({ id: 'i1', x: 100, y: 200 }),
|
|
||||||
createMockItem({ id: 'i2', x: 300, y: 400 }),
|
|
||||||
]
|
|
||||||
const project = (wx: number, wy: number) => ({ x: wx * 2, y: wy * 2 })
|
|
||||||
const layouts = computeInitialGroundLabelLayouts(items, project, 13, 18)
|
|
||||||
|
|
||||||
expect(layouts.length).toBe(2)
|
|
||||||
expect(layouts[0]!.screenX).toBe(200)
|
|
||||||
expect(layouts[0]!.naturalY).toBe(388) // 200 * 2 - 12
|
|
||||||
expect(layouts[0]!.screenY).toBe(388)
|
|
||||||
expect(layouts[1]!.screenX).toBe(600)
|
|
||||||
expect(layouts[1]!.naturalY).toBe(788)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('filters out items outside viewport when project returns null', () => {
|
|
||||||
const items = [createMockItem({ id: 'i1' })]
|
|
||||||
const layouts = computeInitialGroundLabelLayouts(items, () => null)
|
|
||||||
expect(layouts.length).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accurately identifies overlapping and non-overlapping label rectangles', () => {
|
|
||||||
const l1: GroundLabelLayout = {
|
|
||||||
id: '1',
|
|
||||||
itemId: '1',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'A',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 60,
|
|
||||||
height: 18,
|
|
||||||
screenX: 100,
|
|
||||||
screenY: 100,
|
|
||||||
naturalY: 100,
|
|
||||||
}
|
|
||||||
const l2Overlapping: GroundLabelLayout = {
|
|
||||||
id: '2',
|
|
||||||
itemId: '2',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'B',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 60,
|
|
||||||
height: 18,
|
|
||||||
screenX: 110,
|
|
||||||
screenY: 105,
|
|
||||||
naturalY: 105,
|
|
||||||
}
|
|
||||||
const l3Distant: GroundLabelLayout = {
|
|
||||||
id: '3',
|
|
||||||
itemId: '3',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'C',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 60,
|
|
||||||
height: 18,
|
|
||||||
screenX: 300,
|
|
||||||
screenY: 100,
|
|
||||||
naturalY: 100,
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(doLabelsOverlap(l1, l2Overlapping)).toBe(true)
|
|
||||||
expect(doLabelsOverlap(l1, l3Distant)).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Diablo II 1.13c Ladder Collision Avoidance Algorithm', () => {
|
|
||||||
it('returns empty array or single label unchanged', () => {
|
|
||||||
expect(resolveLadderCollisions([])).toEqual([])
|
|
||||||
const single: GroundLabelLayout[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
itemId: '1',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'A',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 50,
|
|
||||||
height: 18,
|
|
||||||
screenX: 100,
|
|
||||||
screenY: 100,
|
|
||||||
naturalY: 100,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
expect(resolveLadderCollisions(single)).toEqual(single)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('retains natural positions when labels do not overlap horizontally', () => {
|
|
||||||
const nonOverlapping: GroundLabelLayout[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
itemId: '1',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'Left Item',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 60,
|
|
||||||
height: 18,
|
|
||||||
screenX: 100,
|
|
||||||
screenY: 200,
|
|
||||||
naturalY: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
itemId: '2',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'Right Item',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 60,
|
|
||||||
height: 18,
|
|
||||||
screenX: 300,
|
|
||||||
screenY: 200,
|
|
||||||
naturalY: 200,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const resolved = resolveLadderCollisions(nonOverlapping)
|
|
||||||
expect(resolved[0]!.screenY).toBe(200)
|
|
||||||
expect(resolved[1]!.screenY).toBe(200)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('stacks 2 overlapping labels vertically into a clean ladder with zero overlap', () => {
|
|
||||||
const overlapping: GroundLabelLayout[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
itemId: '1',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'Item A',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 60,
|
|
||||||
height: 18,
|
|
||||||
screenX: 100,
|
|
||||||
screenY: 200,
|
|
||||||
naturalY: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
itemId: '2',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'Item B',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 60,
|
|
||||||
height: 18,
|
|
||||||
screenX: 105,
|
|
||||||
screenY: 202,
|
|
||||||
naturalY: 202,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const resolved = resolveLadderCollisions(overlapping, 4, 2)
|
|
||||||
expect(resolved.length).toBe(2)
|
|
||||||
|
|
||||||
// Zero overlap verification
|
|
||||||
expect(doLabelsOverlap(resolved[0]!, resolved[1]!, 4, 2)).toBe(false)
|
|
||||||
|
|
||||||
// Vertical distance between centers must be at least height + padY = 20px
|
|
||||||
const distY = Math.abs(resolved[0]!.screenY - resolved[1]!.screenY)
|
|
||||||
expect(distY).toBeGreaterThanOrEqual(20)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resolves dense 5-item loot explosion cluster into an orderly 5-step ladder', () => {
|
|
||||||
const cluster: GroundLabelLayout[] = []
|
|
||||||
for (let i = 0; i < 5; i += 1) {
|
|
||||||
cluster.push({
|
|
||||||
id: `item-${i}`,
|
|
||||||
itemId: `item-${i}`,
|
|
||||||
item: createMockItem({ id: `item-${i}` }),
|
|
||||||
text: `Drop ${i}`,
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 70,
|
|
||||||
height: 18,
|
|
||||||
screenX: 200 + (i % 2) * 5, // all clustered within 5px horizontally
|
|
||||||
screenY: 300,
|
|
||||||
naturalY: 300,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = resolveLadderCollisions(cluster, 4, 2)
|
|
||||||
expect(resolved.length).toBe(5)
|
|
||||||
|
|
||||||
// Assert pairwise zero-overlap across all 5 labels
|
|
||||||
for (let i = 0; i < resolved.length; i += 1) {
|
|
||||||
for (let j = i + 1; j < resolved.length; j += 1) {
|
|
||||||
expect(doLabelsOverlap(resolved[i]!, resolved[j]!, 4, 2)).toBe(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check ladder sorting: positions strictly decreasing (stacking upward)
|
|
||||||
const sortedByY = [...resolved].sort((a, b) => a.screenY - b.screenY)
|
|
||||||
for (let k = 0; k < sortedByY.length - 1; k += 1) {
|
|
||||||
expect(sortedByY[k + 1]!.screenY - sortedByY[k]!.screenY).toBeGreaterThanOrEqual(20)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles multiple independent clusters simultaneously', () => {
|
|
||||||
const items: GroundLabelLayout[] = [
|
|
||||||
// Cluster 1 around X=100
|
|
||||||
{
|
|
||||||
id: 'c1-1',
|
|
||||||
itemId: 'c1-1',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'Loot A',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 50,
|
|
||||||
height: 18,
|
|
||||||
screenX: 100,
|
|
||||||
screenY: 200,
|
|
||||||
naturalY: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c1-2',
|
|
||||||
itemId: 'c1-2',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'Loot B',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 50,
|
|
||||||
height: 18,
|
|
||||||
screenX: 102,
|
|
||||||
screenY: 200,
|
|
||||||
naturalY: 200,
|
|
||||||
},
|
|
||||||
// Cluster 2 around X=500
|
|
||||||
{
|
|
||||||
id: 'c2-1',
|
|
||||||
itemId: 'c2-1',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'Loot C',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 50,
|
|
||||||
height: 18,
|
|
||||||
screenX: 500,
|
|
||||||
screenY: 200,
|
|
||||||
naturalY: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c2-2',
|
|
||||||
itemId: 'c2-2',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'Loot D',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 50,
|
|
||||||
height: 18,
|
|
||||||
screenX: 503,
|
|
||||||
screenY: 200,
|
|
||||||
naturalY: 200,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const resolved = resolveLadderCollisions(items)
|
|
||||||
// Both clusters have their own pairwise overlap resolved
|
|
||||||
expect(doLabelsOverlap(resolved[0]!, resolved[1]!)).toBe(false)
|
|
||||||
expect(doLabelsOverlap(resolved[2]!, resolved[3]!)).toBe(false)
|
|
||||||
// Cluster 1 and Cluster 2 remain at their distinct X
|
|
||||||
expect(resolved[0]!.screenX).toBeLessThan(200)
|
|
||||||
expect(resolved[2]!.screenX).toBeGreaterThan(400)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('GroundLabelOverlay DOM Integration', () => {
|
|
||||||
function createMockDomElement(tag = 'div'): any {
|
|
||||||
const listeners: Record<string, ((e: any) => void)[]> = {}
|
|
||||||
const children: any[] = []
|
|
||||||
const el: any = {
|
|
||||||
tagName: tag.toUpperCase(),
|
|
||||||
className: '',
|
|
||||||
textContent: '',
|
|
||||||
style: {},
|
|
||||||
dataset: {},
|
|
||||||
hidden: false,
|
|
||||||
children,
|
|
||||||
appendChild(child: any) {
|
|
||||||
children.push(child)
|
|
||||||
return child
|
|
||||||
},
|
|
||||||
addEventListener(type: string, fn: any) {
|
|
||||||
listeners[type] = listeners[type] ?? []
|
|
||||||
listeners[type].push(fn)
|
|
||||||
},
|
|
||||||
dispatchEvent(event: any) {
|
|
||||||
for (const fn of listeners[event.type] ?? []) {
|
|
||||||
fn(event)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return el
|
|
||||||
}
|
|
||||||
|
|
||||||
const origDocument = (globalThis as any).document
|
|
||||||
beforeAll(() => {
|
|
||||||
;(globalThis as any).document = {
|
|
||||||
createElement: (tag: string) => createMockDomElement(tag),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
afterAll(() => {
|
|
||||||
;(globalThis as any).document = origDocument
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hides all pooled label elements when visible is false or layouts empty', () => {
|
|
||||||
const container = createMockDomElement('div')
|
|
||||||
const overlay = new GroundLabelOverlay(container)
|
|
||||||
|
|
||||||
const layout: GroundLabelLayout = {
|
|
||||||
id: '1',
|
|
||||||
itemId: '1',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'Short Sword',
|
|
||||||
color: '#d8d8d8',
|
|
||||||
borderColor: '#d8d8d8',
|
|
||||||
width: 60,
|
|
||||||
height: 18,
|
|
||||||
screenX: 100,
|
|
||||||
screenY: 100,
|
|
||||||
naturalY: 100,
|
|
||||||
}
|
|
||||||
|
|
||||||
overlay.update(true, [layout])
|
|
||||||
expect(container.children.length).toBe(1)
|
|
||||||
expect(container.children[0].style.display).toBe('block')
|
|
||||||
|
|
||||||
overlay.update(false, [])
|
|
||||||
expect(container.children[0].style.display).toBe('none')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders labels with authentic quality colors and triggers pickup callback on click', () => {
|
|
||||||
const container = createMockDomElement('div')
|
|
||||||
const overlay = new GroundLabelOverlay(container)
|
|
||||||
const onPickup = vi.fn()
|
|
||||||
overlay.setOnPickupCallback(onPickup)
|
|
||||||
|
|
||||||
const uniqueItem = createMockItem({
|
|
||||||
id: 'soj',
|
|
||||||
name: 'The Stone of Jordan',
|
|
||||||
nameZh: '乔丹之石',
|
|
||||||
quality: 'unique',
|
|
||||||
})
|
|
||||||
|
|
||||||
const layout: GroundLabelLayout = {
|
|
||||||
id: 'soj',
|
|
||||||
itemId: 'soj',
|
|
||||||
item: uniqueItem,
|
|
||||||
text: '乔丹之石',
|
|
||||||
color: GROUND_LABEL_QUALITY_COLORS.unique!,
|
|
||||||
borderColor: GROUND_LABEL_QUALITY_COLORS.unique!,
|
|
||||||
width: 80,
|
|
||||||
height: 18,
|
|
||||||
screenX: 250,
|
|
||||||
screenY: 150,
|
|
||||||
naturalY: 150,
|
|
||||||
}
|
|
||||||
|
|
||||||
overlay.update(true, [layout])
|
|
||||||
const el = container.children[0]
|
|
||||||
expect(el.textContent).toBe('乔丹之石')
|
|
||||||
expect(el.style.color).toBe(GROUND_LABEL_QUALITY_COLORS.unique)
|
|
||||||
expect(el.style.left).toBe('250px')
|
|
||||||
expect(el.style.top).toBe('150px')
|
|
||||||
|
|
||||||
// Simulate pointerdown
|
|
||||||
el.dispatchEvent({ type: 'pointerdown', stopPropagation: vi.fn(), preventDefault: vi.fn() })
|
|
||||||
expect(onPickup).toHaveBeenCalledWith(uniqueItem)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('triggers onHoverCallback on mouseenter and mouseleave for hovered labels (Issue #447)', () => {
|
|
||||||
const container = createMockDomElement('div')
|
|
||||||
const overlay = new GroundLabelOverlay(container)
|
|
||||||
const onHover = vi.fn()
|
|
||||||
overlay.setOnHoverCallback(onHover)
|
|
||||||
|
|
||||||
const magicItem = createMockItem({
|
|
||||||
id: 'ring-1',
|
|
||||||
name: 'Ring of the Apprentice',
|
|
||||||
nameZh: '学徒之戒',
|
|
||||||
quality: 'magic',
|
|
||||||
})
|
|
||||||
|
|
||||||
const layout: GroundLabelLayout = {
|
|
||||||
id: 'ring-1',
|
|
||||||
itemId: 'ring-1',
|
|
||||||
item: magicItem,
|
|
||||||
text: '学徒之戒',
|
|
||||||
color: GROUND_LABEL_QUALITY_COLORS.magic!,
|
|
||||||
borderColor: GROUND_LABEL_QUALITY_COLORS.magic!,
|
|
||||||
width: 70,
|
|
||||||
height: 18,
|
|
||||||
screenX: 200,
|
|
||||||
screenY: 100,
|
|
||||||
naturalY: 100,
|
|
||||||
}
|
|
||||||
|
|
||||||
overlay.update(true, [layout])
|
|
||||||
const el = container.children[0]
|
|
||||||
|
|
||||||
// Mouseenter
|
|
||||||
el.dispatchEvent({ type: 'mouseenter' })
|
|
||||||
expect(onHover).toHaveBeenCalledWith(magicItem)
|
|
||||||
expect(el.style.borderColor).toBe('#ffffff')
|
|
||||||
|
|
||||||
// Mouseleave
|
|
||||||
el.dispatchEvent({ type: 'mouseleave' })
|
|
||||||
expect(onHover).toHaveBeenCalledWith(null)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
import { 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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,376 +0,0 @@
|
||||||
/**
|
|
||||||
* Diablo II: Lord of Destruction v1.13c — Ground Item Rendering & Glint Sparkle Test Suite
|
|
||||||
*
|
|
||||||
* Requirements (Issue #389):
|
|
||||||
* 1. Isometric depth sorting & entity queue integration.
|
|
||||||
* 2. Adaptive item & gold icon silhouettes with bottom-center anchor and shadows.
|
|
||||||
* 3. Authentic quality colors: normal, magic, rare, set, unique, craft, rune, gold.
|
|
||||||
* 4. Periodic 4-point star glint & sparkle animation with sine easing.
|
|
||||||
* 5. Parabolic bounce vertical translation.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { drawGroundItem, GROUND_ITEM_QUALITY_COLORS } from '../src/client/world/act-runtime.ts'
|
|
||||||
import type { GroundItemEntity } from '../src/common/items/ground-items.ts'
|
|
||||||
import type { SpriteRenderer } from '../src/client/render/renderer.ts'
|
|
||||||
|
|
||||||
describe('Issue #389 — Ground Item Rendering & Glint Sparkle Ground Truth', () => {
|
|
||||||
const createMockRenderer = () => {
|
|
||||||
const drawnQuads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
|
|
||||||
const drawnSprites: { frame: any; x: number; y: number; options: any }[] = []
|
|
||||||
const renderer = {
|
|
||||||
drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) {
|
|
||||||
drawnQuads.push({ x, y, w, h, color })
|
|
||||||
},
|
|
||||||
draw(frame: any, x: number, y: number, options: any) {
|
|
||||||
drawnSprites.push({ frame, x, y, options })
|
|
||||||
},
|
|
||||||
} as unknown as SpriteRenderer
|
|
||||||
|
|
||||||
return { renderer, drawnQuads, drawnSprites }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('1. GROUND_ITEM_QUALITY_COLORS Palette Parity', () => {
|
|
||||||
it('provides distinct, authentic RGBA color tuples for all D2 quality tiers', () => {
|
|
||||||
const requiredQualities = ['normal', 'low', 'superior', 'magic', 'rare', 'set', 'unique', 'craft', 'rune', 'gold']
|
|
||||||
for (const q of requiredQualities) {
|
|
||||||
const col = GROUND_ITEM_QUALITY_COLORS[q]
|
|
||||||
expect(col).toBeDefined()
|
|
||||||
expect(col!.length).toBe(4)
|
|
||||||
for (const c of col!) {
|
|
||||||
expect(c).toBeGreaterThanOrEqual(0)
|
|
||||||
expect(c).toBeGreaterThanOrEqual(0)
|
|
||||||
expect(c).toBeLessThanOrEqual(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Magic is blue
|
|
||||||
expect(GROUND_ITEM_QUALITY_COLORS.magic![2]).toBeGreaterThan(GROUND_ITEM_QUALITY_COLORS.magic![0])
|
|
||||||
// Rare is yellow (red & green high, blue low)
|
|
||||||
expect(GROUND_ITEM_QUALITY_COLORS.rare![0]).toBeGreaterThan(0.8)
|
|
||||||
expect(GROUND_ITEM_QUALITY_COLORS.rare![1]).toBeGreaterThan(0.8)
|
|
||||||
// Set is green
|
|
||||||
expect(GROUND_ITEM_QUALITY_COLORS.set![1]).toBeGreaterThan(GROUND_ITEM_QUALITY_COLORS.set![0])
|
|
||||||
// Unique is gold/tan
|
|
||||||
expect(GROUND_ITEM_QUALITY_COLORS.unique![0]).toBeGreaterThan(0.7)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('2. Gold Pile Rendering & Scaling', () => {
|
|
||||||
it('draws medium gold pile (50 gold) using authentic flpgld_1 sprite and ground shadow', () => {
|
|
||||||
const { renderer, drawnQuads, drawnSprites } = createMockRenderer()
|
|
||||||
const goldItem: GroundItemEntity = {
|
|
||||||
id: 'gold_small',
|
|
||||||
item: { isGold: true },
|
|
||||||
name: '50 Gold',
|
|
||||||
nameZh: '50 金币',
|
|
||||||
quality: 'normal',
|
|
||||||
isGold: true,
|
|
||||||
amount: 50,
|
|
||||||
invWidth: 1,
|
|
||||||
invHeight: 1,
|
|
||||||
dropTime: 0,
|
|
||||||
x: 200,
|
|
||||||
y: 150,
|
|
||||||
cellX: 0,
|
|
||||||
cellY: 0,
|
|
||||||
sparklePhase: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a timestamp outside the sparkle window (e.g. 1000ms in a 2000ms cycle)
|
|
||||||
drawGroundItem(renderer, goldItem, 1000)
|
|
||||||
|
|
||||||
// Exactly 1 ground contact shadow quad and 1 authentic flippy sprite
|
|
||||||
expect(drawnQuads.length).toBe(1)
|
|
||||||
expect(drawnSprites.length).toBe(1)
|
|
||||||
|
|
||||||
const shadow = drawnQuads[0]!
|
|
||||||
expect(shadow.y).toBe(150 - 2)
|
|
||||||
expect(shadow.color[3]).toBeLessThan(0.5)
|
|
||||||
|
|
||||||
const sprite = drawnSprites[0]!
|
|
||||||
expect(sprite.options.width).toBe(26)
|
|
||||||
expect(sprite.options.height).toBe(13)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('scales gold pile sprite dimensions for large piles (>= 500 gold -> flpgld_2 32x20)', () => {
|
|
||||||
const { renderer, drawnSprites } = createMockRenderer()
|
|
||||||
const largeGold: GroundItemEntity = {
|
|
||||||
id: 'gold_large',
|
|
||||||
item: { isGold: true },
|
|
||||||
name: '10000 Gold',
|
|
||||||
nameZh: '10000 金币',
|
|
||||||
quality: 'normal',
|
|
||||||
isGold: true,
|
|
||||||
amount: 10000,
|
|
||||||
invWidth: 1,
|
|
||||||
invHeight: 1,
|
|
||||||
dropTime: 0,
|
|
||||||
x: 300,
|
|
||||||
y: 250,
|
|
||||||
cellX: 0,
|
|
||||||
cellY: 0,
|
|
||||||
sparklePhase: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
drawGroundItem(renderer, largeGold, 1000)
|
|
||||||
expect(drawnSprites.length).toBe(1)
|
|
||||||
const sprite = drawnSprites[0]!
|
|
||||||
expect(sprite.options.width).toBe(32)
|
|
||||||
expect(sprite.options.height).toBe(20)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('3. Equipment Item Rendering with Authentic Flippy Sprite & Bounce Offset', () => {
|
|
||||||
it('draws equipment item with ground shadow and authentic flippy sprite (zero procedural border quads)', () => {
|
|
||||||
const { renderer, drawnQuads, drawnSprites } = createMockRenderer()
|
|
||||||
const uniqueSword: GroundItemEntity = {
|
|
||||||
id: 'unique_sword',
|
|
||||||
item: { name: 'The Patriarch', code: 'gis' },
|
|
||||||
name: 'The Patriarch',
|
|
||||||
nameZh: '族长',
|
|
||||||
quality: 'unique',
|
|
||||||
isGold: false,
|
|
||||||
amount: 1,
|
|
||||||
invWidth: 1,
|
|
||||||
invHeight: 3,
|
|
||||||
dropTime: 0,
|
|
||||||
x: 400,
|
|
||||||
y: 350,
|
|
||||||
cellX: 0,
|
|
||||||
cellY: 0,
|
|
||||||
sparklePhase: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
drawGroundItem(renderer, uniqueSword, 1000)
|
|
||||||
|
|
||||||
// Only 1 shadow quad (outside sparkle window) + 1 flippy sprite
|
|
||||||
expect(drawnQuads.length).toBe(1)
|
|
||||||
expect(drawnSprites.length).toBe(1)
|
|
||||||
expect(drawnQuads[0]!.y).toBe(350 - 2)
|
|
||||||
expect(drawnSprites[0]!.options.width).toBeGreaterThan(0)
|
|
||||||
expect(drawnSprites[0]!.options.height).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('translates vertical sprite rendering position upwards during parabolic bounce', () => {
|
|
||||||
const { renderer: rGround, drawnQuads: quadsGround, drawnSprites: spritesGround } = createMockRenderer()
|
|
||||||
const { renderer: rAir, drawnQuads: quadsAir, drawnSprites: spritesAir } = createMockRenderer()
|
|
||||||
|
|
||||||
const restingItem: GroundItemEntity = {
|
|
||||||
id: 'item_rest',
|
|
||||||
item: { code: 'hlm', name: 'Helm' },
|
|
||||||
name: 'Helm',
|
|
||||||
nameZh: '头盔',
|
|
||||||
quality: 'magic',
|
|
||||||
isGold: false,
|
|
||||||
amount: 1,
|
|
||||||
invWidth: 2,
|
|
||||||
invHeight: 2,
|
|
||||||
dropTime: 0,
|
|
||||||
x: 100,
|
|
||||||
y: 200,
|
|
||||||
cellX: 0,
|
|
||||||
cellY: 0,
|
|
||||||
sparklePhase: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
const bouncingItem: GroundItemEntity = {
|
|
||||||
...restingItem,
|
|
||||||
id: 'item_bounce',
|
|
||||||
bounceState: {
|
|
||||||
startTime: 1000,
|
|
||||||
durationMs: 400,
|
|
||||||
peakHeightPx: 30,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw resting item at t = 1200
|
|
||||||
drawGroundItem(rGround, restingItem, 1200)
|
|
||||||
// Draw bouncing item at apex (t = 1200, apex = 30px)
|
|
||||||
drawGroundItem(rAir, bouncingItem, 1200)
|
|
||||||
|
|
||||||
const groundSprite = spritesGround[0]!
|
|
||||||
const airSprite = spritesAir[0]!
|
|
||||||
|
|
||||||
// Shadow stays anchored on ground plane (item.y - 2 = 198)
|
|
||||||
expect(quadsGround[0]!.y).toBe(198)
|
|
||||||
expect(quadsAir[0]!.y).toBe(198)
|
|
||||||
|
|
||||||
// Air sprite y should be exactly ground sprite y - 30
|
|
||||||
expect(airSprite.y).toBe(groundSprite.y - 30)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('4. Glint & Sparkle 4-Point Star Animation', () => {
|
|
||||||
it('spawns 4-point star rays, core, and quality aura during the active flash window', () => {
|
|
||||||
const { renderer, drawnQuads, drawnSprites } = createMockRenderer()
|
|
||||||
const gem: GroundItemEntity = {
|
|
||||||
id: 'gem_ruby',
|
|
||||||
item: { code: 'gcr', name: 'Perfect Ruby' },
|
|
||||||
name: 'Perfect Ruby',
|
|
||||||
nameZh: '完美红宝石',
|
|
||||||
quality: 'rare',
|
|
||||||
isGold: false,
|
|
||||||
amount: 1,
|
|
||||||
invWidth: 1,
|
|
||||||
invHeight: 1,
|
|
||||||
dropTime: 0,
|
|
||||||
x: 500,
|
|
||||||
y: 400,
|
|
||||||
cellX: 0,
|
|
||||||
cellY: 0,
|
|
||||||
sparklePhase: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
// At t = 160ms (middle of flash window 0..320ms, p = 0.5, intensity = 1.0)
|
|
||||||
drawGroundItem(renderer, gem, 160)
|
|
||||||
|
|
||||||
// Total quads: shadow (1) + sparkle (horizontal ray, vertical ray, core, aura = 4) = 5
|
|
||||||
expect(drawnSprites.length).toBe(1)
|
|
||||||
expect(drawnQuads.length).toBe(5)
|
|
||||||
|
|
||||||
const horizontalRay = drawnQuads[1]!
|
|
||||||
const verticalRay = drawnQuads[2]!
|
|
||||||
const core = drawnQuads[3]!
|
|
||||||
const aura = drawnQuads[4]!
|
|
||||||
|
|
||||||
// Brilliant core color (near-white)
|
|
||||||
expect(core.color[0]).toBeGreaterThan(0.9)
|
|
||||||
expect(core.color[1]).toBeGreaterThan(0.9)
|
|
||||||
expect(core.color[3]).toBeCloseTo(1.0, 1)
|
|
||||||
|
|
||||||
// Aura matches rare quality color
|
|
||||||
expect(aura.color[0]).toBeCloseTo(GROUND_ITEM_QUALITY_COLORS.rare![0], 2)
|
|
||||||
|
|
||||||
// Rays are centered on sparkle point
|
|
||||||
expect(horizontalRay.h).toBe(2)
|
|
||||||
expect(verticalRay.w).toBe(2)
|
|
||||||
expect(horizontalRay.w).toBeGreaterThan(10)
|
|
||||||
expect(verticalRay.h).toBeGreaterThan(10)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('suppresses sparkle rendering during the dormant period of the cycle', () => {
|
|
||||||
const { renderer, drawnQuads, drawnSprites } = createMockRenderer()
|
|
||||||
const item: GroundItemEntity = {
|
|
||||||
id: 'item_dormant',
|
|
||||||
item: { code: 'rin', name: 'Ring' },
|
|
||||||
name: 'Ring',
|
|
||||||
nameZh: '戒指',
|
|
||||||
quality: 'unique',
|
|
||||||
isGold: false,
|
|
||||||
amount: 1,
|
|
||||||
invWidth: 1,
|
|
||||||
invHeight: 1,
|
|
||||||
dropTime: 0,
|
|
||||||
x: 200,
|
|
||||||
y: 200,
|
|
||||||
cellX: 0,
|
|
||||||
cellY: 0,
|
|
||||||
sparklePhase: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
// At t = 1000ms (far outside 0..320ms window)
|
|
||||||
drawGroundItem(renderer, item, 1000)
|
|
||||||
|
|
||||||
// Only ground shadow quad (1), no sparkle quads
|
|
||||||
expect(drawnSprites.length).toBe(1)
|
|
||||||
expect(drawnQuads.length).toBe(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('5. Authentic Ground Item Sprite Parity (Anti-Blue-Box & Clean Pickup)', () => {
|
|
||||||
it('renders authentic item sprite using itemsAtlas instead of solid color box', () => {
|
|
||||||
const drawnQuads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
|
|
||||||
const drawnSprites: { frame: any; x: number; y: number; options: any }[] = []
|
|
||||||
const renderer = {
|
|
||||||
drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) {
|
|
||||||
drawnQuads.push({ x, y, w, h, color })
|
|
||||||
},
|
|
||||||
draw(frame: any, x: number, y: number, options: any) {
|
|
||||||
drawnSprites.push({ frame, x, y, options })
|
|
||||||
},
|
|
||||||
} as unknown as SpriteRenderer
|
|
||||||
|
|
||||||
const mockAtlas = { texture: {} as WebGLTexture, width: 1024, height: 1024 } as any
|
|
||||||
const magicSword: GroundItemEntity = {
|
|
||||||
id: 'magic_sword',
|
|
||||||
item: { name: 'Short Sword', code: 'ssd' },
|
|
||||||
name: 'Short Sword',
|
|
||||||
nameZh: '短剑',
|
|
||||||
quality: 'magic',
|
|
||||||
isGold: false,
|
|
||||||
amount: 1,
|
|
||||||
invWidth: 1,
|
|
||||||
invHeight: 3,
|
|
||||||
dropTime: 0,
|
|
||||||
x: 100,
|
|
||||||
y: 200,
|
|
||||||
cellX: 0,
|
|
||||||
cellY: 0,
|
|
||||||
sparklePhase: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
drawGroundItem(renderer, magicSword, 1000, mockAtlas)
|
|
||||||
|
|
||||||
// Verified: Sprite is drawn using itemsAtlas
|
|
||||||
expect(drawnSprites.length).toBe(1)
|
|
||||||
const sprite = drawnSprites[0]!
|
|
||||||
expect(sprite.options.atlas).toBe(mockAtlas)
|
|
||||||
expect(sprite.options.width).toBeGreaterThan(0)
|
|
||||||
expect(sprite.options.height).toBeGreaterThan(0)
|
|
||||||
// Drop shadow is drawn on ground
|
|
||||||
expect(drawnQuads.some(q => q.color[3] < 0.5 && q.y >= 198)).toBe(true)
|
|
||||||
// Crucial: No opaque blue body box drawn in quads
|
|
||||||
const blueBody = drawnQuads.find(q => q.color[2] > 0.8 && q.w > 10 && q.h > 10)
|
|
||||||
expect(blueBody).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('6. Authentic Ground Slanted Quad Projection (D2 1.13c Ground Truth)', () => {
|
|
||||||
it('calls renderer.drawQuad with 2:1 isometric ground foreshortened height and shear angle', () => {
|
|
||||||
const drawnQuads: any[] = []
|
|
||||||
const renderer = {
|
|
||||||
drawSolid() {},
|
|
||||||
drawQuad(frame: any, x0: number, y0: number, x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, options: any) {
|
|
||||||
drawnQuads.push({ frame, x0, y0, x1, y1, x2, y2, x3, y3, options })
|
|
||||||
},
|
|
||||||
} as unknown as SpriteRenderer
|
|
||||||
|
|
||||||
const mockAtlas = { texture: {} as WebGLTexture, width: 1024, height: 1024 } as any
|
|
||||||
const groundSword: GroundItemEntity = {
|
|
||||||
id: 'ground_sword',
|
|
||||||
item: { name: 'Broad Sword', code: 'bsd' },
|
|
||||||
name: 'Broad Sword',
|
|
||||||
nameZh: '阔剑',
|
|
||||||
quality: 'normal',
|
|
||||||
isGold: false,
|
|
||||||
amount: 1,
|
|
||||||
invWidth: 1,
|
|
||||||
invHeight: 3,
|
|
||||||
dropTime: 0,
|
|
||||||
x: 300,
|
|
||||||
y: 250,
|
|
||||||
cellX: 0,
|
|
||||||
cellY: 0,
|
|
||||||
sparklePhase: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
drawGroundItem(renderer, groundSword, 1000, mockAtlas)
|
|
||||||
|
|
||||||
expect(drawnQuads.length).toBe(1)
|
|
||||||
const q = drawnQuads[0]!
|
|
||||||
// Bottom edge rests at baseY (y = 250)
|
|
||||||
expect(q.y2).toBe(250)
|
|
||||||
expect(q.y3).toBe(250)
|
|
||||||
// Top edge is above ground (y0 < 250)
|
|
||||||
expect(q.y0).toBeLessThan(250)
|
|
||||||
expect(q.y1).toBeLessThan(250)
|
|
||||||
// Top edge is sheared horizontally along 2:1 isometric diamond (x0 < x2)
|
|
||||||
expect(q.x0).toBeLessThan(q.x2)
|
|
||||||
expect(q.x1).toBeLessThan(q.x3)
|
|
||||||
// Slanted parallelogram: width of top edge equals width of bottom edge
|
|
||||||
expect(q.x1 - q.x0).toBe(q.x3 - q.x2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
@ -30,7 +30,6 @@ import {
|
||||||
subTileAt,
|
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
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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('包裹已满。')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in New Issue