feat(scene): 衔接 NPC 数据至引擎层及交互面板验证 (fixes #6)

建立 NPC token 到独立显示名称的转译字典。将打包导出的 NPC 集合统一注水到 GameEngine 模型内以生效交互半径;同时增加 Canvas UI 外围的绝对定位 DOM 生成层来渲染 NPC 头顶铭牌名称,以及挂载对应的对话展示面板供 engine.dialog 同步刷新。更新 E2E 环境验证逻辑检测交互健壮性。
This commit is contained in:
troytt 2026-09-14 13:07:37 +00:00
parent 11439256bf
commit 7d8629cd73
7 changed files with 102 additions and 7 deletions

View File

@ -27,6 +27,7 @@
option { background: #1c1813; } option { background: #1c1813; }
/* 状态面板放在控件行上方:贴在右下角会压住"细分场景"那个下拉框 */ /* 状态面板放在控件行上方:贴在右下角会压住"细分场景"那个下拉框 */
#status { right: 12px; bottom: 58px; padding: 6px 10px; max-width: 60vw; color: #9c8d78; } #status { right: 12px; bottom: 58px; padding: 6px 10px; max-width: 60vw; color: #9c8d78; }
#dialog { right: 12px; top: 36px; padding: 8px 12px; min-width: 240px; color: #e8dcc8; white-space: pre-wrap; font-size: 14px; background: rgba(30, 26, 21, .9); border-color: #c8a15a; }
.hint { color: #6d5a34; } .hint { color: #6d5a34; }
</style> </style>
</head> </head>
@ -48,6 +49,8 @@
<span class="hint" style="margin:0">WASD / 方向键移动 · 滚轮或 +/− 缩放 · 三个下拉框都会换图并更新地址栏</span> <span class="hint" style="margin:0">WASD / 方向键移动 · 滚轮或 +/− 缩放 · 三个下拉框都会换图并更新地址栏</span>
</div> </div>
<div id="status" class="overlay">正在加载地图…</div> <div id="status" class="overlay">正在加载地图…</div>
<div id="dialog" class="overlay" hidden></div>
<div id="labels" style="position: fixed; inset: 0; pointer-events: none;"></div>
<canvas id="view" width="960" height="560"></canvas> <canvas id="view" width="960" height="560"></canvas>
<script type="module" src="/src/scene/act-scene.ts"></script> <script type="module" src="/src/scene/act-scene.ts"></script>
</body> </body>

View File

@ -22,6 +22,11 @@
press('ArrowRight', true); await sleep(900); press('ArrowRight', false) press('ArrowRight', true); await sleep(900); press('ArrowRight', false)
await sleep(200) await sleep(200)
const moved = { x: window.__d2webAct?.x ?? 0, y: window.__d2webAct?.y ?? 0, facing: window.__d2webAct?.facing ?? -1 } const moved = { x: window.__d2webAct?.x ?? 0, y: window.__d2webAct?.y ?? 0, facing: window.__d2webAct?.facing ?? -1 }
press('KeyT', true); await sleep(100); press('KeyT', false)
await sleep(200)
const afterTalk = { ...(window.__d2webAct ?? {}) }
return { return {
source: atReady.source, ready: atReady.ready, error: atReady.error ?? null, source: atReady.source, ready: atReady.ready, error: atReady.error ?? null,
base: atReady.base, act: atReady.act, level: atReady.level, quadrant: atReady.quadrant, base: atReady.base, act: atReady.act, level: atReady.level, quadrant: atReady.quadrant,
@ -46,6 +51,10 @@
facing: moved.facing, facing: moved.facing,
tick: after.tick, tick: after.tick,
walkable: Number((atReady.walkable ?? 0).toFixed(3)), walkable: Number((atReady.walkable ?? 0).toFixed(3)),
// NPC additions:
npcs: after.npcs,
nearestNpcDistance: after.npcsNear?.[0] ? Math.round(Math.hypot(after.npcsNear[0].x - after.x, after.npcsNear[0].y - after.y)) : null,
dialogOpen: (afterTalk.dialog ?? []).length > 0,
// 画布是否铺满窗口 + 缩放是否至少覆盖视口 // 画布是否铺满窗口 + 缩放是否至少覆盖视口
canvas: [after.canvasWidth, after.canvasHeight], canvas: [after.canvasWidth, after.canvasHeight],
viewport: [window.innerWidth, window.innerHeight], viewport: [window.innerWidth, window.innerHeight],

View File

@ -25,6 +25,10 @@
press('ArrowDown', true); await sleep(1200); press('ArrowDown', false) press('ArrowDown', true); await sleep(1200); press('ArrowDown', false)
await sleep(300) await sleep(300)
const south = { x: window.__d2webAct?.x ?? 0, y: window.__d2webAct?.y ?? 0, facing: window.__d2webAct?.facing ?? -1 } const south = { x: window.__d2webAct?.x ?? 0, y: window.__d2webAct?.y ?? 0, facing: window.__d2webAct?.facing ?? -1 }
press('KeyT', true); await sleep(100); press('KeyT', false)
await sleep(200)
const afterTalk = { ...(window.__d2webAct ?? {}) }
return { return {
ready: first.ready, error: first.error ?? null, ready: first.ready, error: first.error ?? null,
act: first.act, level: first.level, quadrant: first.quadrant, quadrants: first.quadrants, act: first.act, level: first.level, quadrant: first.quadrant, quadrants: first.quadrants,
@ -35,5 +39,6 @@
character: first.character, drawCalls: first.drawCalls, tickRate: Number((first.tickRate ?? 0).toFixed(1)), character: first.character, drawCalls: first.drawCalls, tickRate: Number((first.tickRate ?? 0).toFixed(1)),
moved: { east: [Math.round(east.x - before.x), Math.round(east.y - before.y)], south: [Math.round(south.x - east.x), Math.round(south.y - east.y)], tickDelta: (window.__d2webAct?.tick ?? 0) - before.tick }, moved: { east: [Math.round(east.x - before.x), Math.round(east.y - before.y)], south: [Math.round(south.x - east.x), Math.round(south.y - east.y)], tickDelta: (window.__d2webAct?.tick ?? 0) - before.tick },
facingEast: east.facing, facingSouth: south.facing, facingEast: east.facing, facingSouth: south.facing,
dialogOpen: (afterTalk.dialog ?? []).length > 0,
} }
})() })()

View File

@ -243,6 +243,8 @@ export class GameEngine {
} }
} }
export interface NpcEntity { def: NpcDef; x: number; y: number }
export interface EngineViewState { export interface EngineViewState {
monstersSpawned?: number monstersSpawned?: number
monstersAlive?: number monstersAlive?: number
@ -271,7 +273,7 @@ export interface EngineViewState {
castHits?: number castHits?: number
projectilesAlive?: number projectilesAlive?: number
npcs?: number npcs?: number
npcsNear?: unknown[] npcsNear?: NpcEntity[]
monstersNear?: unknown[] monstersNear?: unknown[]
quests?: unknown[] quests?: unknown[]
dialog?: string[] dialog?: string[]

30
src/game/npc.ts Normal file
View File

@ -0,0 +1,30 @@
import type { NpcDef } from './quests.ts'
const NPC_NAME_FALLBACKS: Record<string, string> = {
'DC': 'Deckard Cain',
'JE': 'Jerhyn',
'HR': 'Hratli',
'XR': 'Larzuk',
'XS': 'Drehya',
'0J': 'Nihlathak',
}
export function resolveNpcName(token: string, fallbackName: string): string {
const upper = token.trim().toUpperCase()
if (NPC_NAME_FALLBACKS[upper] !== undefined) {
return NPC_NAME_FALLBACKS[upper]
}
return fallbackName
}
export function buildNpcDef(token: string, id: number, fallbackName: string): NpcDef {
const name = resolveNpcName(token, fallbackName)
return {
id: `npc-${String(id)}`,
name,
questId: null,
offerLines: [],
progressLines: [],
doneLines: [`Hello, my name is ${name}. Welcome to town.`],
}
}

View File

@ -32,7 +32,10 @@ import {
import type { CollisionGrid } from '../game/d2map.ts' import type { CollisionGrid } from '../game/d2map.ts'
import { depthInsertIndex } from '../game/map.ts' import { depthInsertIndex } from '../game/map.ts'
import { GameEngine, syncEngineState } from "../game/engine.ts" import { GameEngine, syncEngineState } from "../game/engine.ts"
import type { NpcEntity } from "../game/engine.ts"
import { DEMO_MONSTERS, DEMO_EXPERIENCE, DEMO_BASES, DEMO_AFFIXES, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts" import { DEMO_MONSTERS, DEMO_EXPERIENCE, DEMO_BASES, DEMO_AFFIXES, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts"
import { buildNpcDef } from '../game/npc.ts'
import type { NpcDef } from '../game/quests.ts'
import { buildAtlas } from '../render/atlas.ts' import { buildAtlas } from '../render/atlas.ts'
import type { AtlasFrame, SpriteAtlas } from '../render/atlas.ts' import type { AtlasFrame, SpriteAtlas } from '../render/atlas.ts'
import { SpriteRenderer } from '../render/renderer.ts' import { SpriteRenderer } from '../render/renderer.ts'
@ -119,6 +122,9 @@ export interface ActSceneState {
canvasWidth: number canvasWidth: number
canvasHeight: number canvasHeight: number
error: string | null error: string | null
dialog: string[]
npcs: number
npcsNear: NpcEntity[]
} }
declare global { declare global {
@ -136,6 +142,7 @@ const state: ActSceneState = {
skippedDraws: 0, objects: 0, loadMs: 0, skippedDraws: 0, objects: 0, loadMs: 0,
missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0, missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0,
drawCalls: 0, zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null, drawCalls: 0, zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
dialog: [], npcs: 0, npcsNear: [],
} }
window.__d2webAct = state window.__d2webAct = state
@ -188,6 +195,7 @@ interface MapRuntime {
/** Object draws: interleaved with walls in painter's order. */ /** Object draws: interleaved with walls in painter's order. */
readonly objectDrawables: readonly ObjectDrawable[] readonly objectDrawables: readonly ObjectDrawable[]
readonly spawn: { x: number; y: number } readonly spawn: { x: number; y: number }
readonly npcs: readonly PackObject[]
readonly objects: number readonly objects: number
readonly frames: number readonly frames: number
/** Whether the real character art is in use. */ /** Whether the real character art is in use. */
@ -294,6 +302,7 @@ interface PackSceneJson {
/** Roof draws, painted after everything else (absent in older packs). */ /** Roof draws, painted after everything else (absent in older packs). */
readonly roofs?: readonly (readonly number[])[] readonly roofs?: readonly (readonly number[])[]
readonly objects: readonly PackObject[] readonly objects: readonly PackObject[]
readonly npcs?: readonly PackObject[]
readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] } readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] }
readonly spawn: readonly number[] | null readonly spawn: readonly number[] | null
readonly stats: { readonly missingTiles: number; readonly walkable: number } readonly stats: { readonly missingTiles: number; readonly walkable: number }
@ -349,7 +358,8 @@ async function loadPackRuntime(
const roofs = (scene.roofs ?? []).map(toDrawable) const roofs = (scene.roofs ?? []).map(toDrawable)
const objectDrawables: ObjectDrawable[] = [] const objectDrawables: ObjectDrawable[] = []
for (const obj of scene.objects) { const packObjects = [...scene.objects, ...(scene.npcs ?? [])]
for (const obj of packObjects) {
if (obj.frame) { if (obj.frame) {
objectDrawables.push({ objectDrawables.push({
frame: { frame: {
@ -420,6 +430,7 @@ async function loadPackRuntime(
roofs, roofs,
objectDrawables, objectDrawables,
spawn, spawn,
npcs: scene.npcs ?? [],
objects: scene.objects.length, objects: scene.objects.length,
frames: scene.frames.length, frames: scene.frames.length,
notes: [`首屏页 ${priority.join(',')},共 ${String(pages.length)} 页`], notes: [`首屏页 ${priority.join(',')},共 ${String(pages.length)} 页`],
@ -578,6 +589,7 @@ async function loadLiveRuntime(
roofs: scene.roofs.map(toDrawable), roofs: scene.roofs.map(toDrawable),
objectDrawables: [], objectDrawables: [],
spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 }, spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 },
npcs: [],
objects: level.objects.length, objects: level.objects.length,
frames: scene.frames.length, frames: scene.frames.length,
notes, notes,
@ -715,6 +727,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
let playerMoving = false let playerMoving = false
let playerWalkFrame = 0 let playerWalkFrame = 0
const npcDefs = runtime.npcs.map(n => buildNpcDef(n.token ?? '', n.id, n.name ?? ''))
const engine = new GameEngine( const engine = new GameEngine(
{ {
widthPx: runtime.widthPx, widthPx: runtime.widthPx,
@ -729,7 +742,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
prefixAffixes: DEMO_AFFIXES, prefixAffixes: DEMO_AFFIXES,
suffixAffixes: DEMO_AFFIXES, suffixAffixes: DEMO_AFFIXES,
skills: DEMO_SKILLS, skills: DEMO_SKILLS,
npcDefs: DEMO_NPCS, npcDefs,
questDefs: DEMO_QUESTS, questDefs: DEMO_QUESTS,
combatOptions: { combatOptions: {
playerSpeed: WALK_SPEED, playerSpeed: WALK_SPEED,
@ -747,6 +760,12 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
} }
) )
engine.npcEntities.length = 0
for (const n of runtime.npcs) {
const def = npcDefs.find(d => d.id === `npc-${String(n.id)}`)!
engine.npcEntities.push({ def, x: n.x, y: n.y })
}
const loop = new GameLoop({ const loop = new GameLoop({
tickRate: 25, tickRate: 25,
onTick: () => { onTick: () => {
@ -775,6 +794,12 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
state.facing = player.facing state.facing = player.facing
syncEngineState(engine, state) syncEngineState(engine, state)
const dialogPanel = document.querySelector<HTMLElement>('#dialog')
if (dialogPanel !== null) {
dialogPanel.hidden = state.dialog.length === 0
dialogPanel.textContent = state.dialog.join('\n')
}
}, },
onRender: () => { onRender: () => {
if (state.pagesAtFirstFrame < 0) { if (state.pagesAtFirstFrame < 0) {
@ -900,6 +925,28 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
// layer so they cover the floor, the walls and anything walking under them. // layer so they cover the floor, the walls and anything walking under them.
drawTiles(runtime.roofs, 0, runtime.roofs.length) drawTiles(runtime.roofs, 0, runtime.roofs.length)
renderer.flush() renderer.flush()
const labelsContainer = document.querySelector<HTMLElement>('#labels')
if (labelsContainer) {
labelsContainer.innerHTML = ''
for (const npc of engine.npcEntities) {
const screenX = (npc.x - engine.world.player.x) * camera.zoom + canvas.width / 2
const screenY = (npc.y - (engine.world.player.y - 16)) * camera.zoom + canvas.height / 2
if (screenX > -100 && screenX < canvas.width + 100 && screenY > -100 && screenY < canvas.height + 100) {
const div = document.createElement('div')
div.textContent = npc.def.name
div.style.position = 'absolute'
div.style.left = `${screenX}px`
div.style.top = `${screenY - 60 * camera.zoom}px`
div.style.transform = 'translate(-50%, -100%)'
div.style.color = '#c8a15a'
div.style.textShadow = '1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000'
div.style.fontSize = `${Math.max(10, 14 * camera.zoom)}px`
labelsContainer.appendChild(div)
}
}
}
state.drawCalls = 1 state.drawCalls = 1
state.tickRate = loop.tickRate state.tickRate = loop.tickRate
state.zoom = Number(camera.zoom.toFixed(2)) state.zoom = Number(camera.zoom.toFixed(2))

View File

@ -29,6 +29,7 @@ import {
Inventory, affixesFromTable, goldItem, itemBasesFromTable, rollDrop, totalStat, Inventory, affixesFromTable, goldItem, itemBasesFromTable, rollDrop, totalStat,
} from '../game/items.ts' } from '../game/items.ts'
import { GameEngine, syncEngineState } from "../game/engine.ts" import { GameEngine, syncEngineState } from "../game/engine.ts"
import type { NpcEntity } from "../game/engine.ts"
import { DEMO_MONSTERS, DEMO_EXPERIENCE, DEMO_BASES, DEMO_AFFIXES, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts" import { DEMO_MONSTERS, DEMO_EXPERIENCE, DEMO_BASES, DEMO_AFFIXES, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts"
import type { Affix, Item, ItemBase } from '../game/items.ts' import type { Affix, Item, ItemBase } from '../game/items.ts'
import { Rng } from '../game/rng.ts' import { Rng } from '../game/rng.ts'
@ -201,7 +202,7 @@ export interface MapSceneState {
/** NPCs placed. */ /** NPCs placed. */
npcs: number npcs: number
/** Placed NPCs with their distance to the player, for automation. */ /** Placed NPCs with their distance to the player, for automation. */
npcsNear: { x: number; y: number; distance: number }[] npcsNear: NpcEntity[]
/** Living monsters with their distance to the player, for automation. */ /** Living monsters with their distance to the player, for automation. */
monstersNear: { x: number; y: number; distance: number }[] monstersNear: { x: number; y: number; distance: number }[]
/** Current dialogue lines, empty when nobody is talking. */ /** Current dialogue lines, empty when nobody is talking. */
@ -525,9 +526,7 @@ function startScene(sources: MapSources, status: HTMLElement, hud: HTMLElement):
.map(monster => ({ x: monster.x, y: monster.y, distance: Math.hypot(monster.x - engine.world.player.x, monster.y - engine.world.player.y) })) .map(monster => ({ x: monster.x, y: monster.y, distance: Math.hypot(monster.x - engine.world.player.x, monster.y - engine.world.player.y) }))
.sort((a, b) => a.distance - b.distance) .sort((a, b) => a.distance - b.distance)
.slice(0, 4) .slice(0, 4)
state.npcsNear = engine.npcEntities.map(npc => ({
x: npc.x, y: npc.y, distance: Math.hypot(npc.x - engine.world.player.x, npc.y - engine.world.player.y),
}))
state.quests = engine.questLog.all.map(entry => ({ state.quests = engine.questLog.all.map(entry => ({
id: entry.def.id, status: entry.status, kills: entry.kills, needed: entry.def.killCount, id: entry.def.id, status: entry.status, kills: entry.kills, needed: entry.def.killCount,
})) }))