From 7d8629cd73cbb9daf31806321ccd2e0ac7c7ad5e Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Mon, 14 Sep 2026 13:07:37 +0000 Subject: [PATCH] =?UTF-8?q?feat(scene):=20=E8=A1=94=E6=8E=A5=20NPC=20?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E8=87=B3=E5=BC=95=E6=93=8E=E5=B1=82=E5=8F=8A?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E9=9D=A2=E6=9D=BF=E9=AA=8C=E8=AF=81=20(fixes?= =?UTF-8?q?=20#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 建立 NPC token 到独立显示名称的转译字典。将打包导出的 NPC 集合统一注水到 GameEngine 模型内以生效交互半径;同时增加 Canvas UI 外围的绝对定位 DOM 生成层来渲染 NPC 头顶铭牌名称,以及挂载对应的对话展示面板供 engine.dialog 同步刷新。更新 E2E 环境验证逻辑检测交互健壮性。 --- acts.html | 3 ++ scripts/browser/checks/act-pack-state.js | 9 +++++ scripts/browser/checks/act-state.js | 5 +++ src/game/engine.ts | 4 +- src/game/npc.ts | 30 ++++++++++++++ src/scene/act-scene.ts | 51 +++++++++++++++++++++++- src/scene/map-scene.ts | 7 ++-- 7 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 src/game/npc.ts diff --git a/acts.html b/acts.html index b4805a1..0a54457 100644 --- a/acts.html +++ b/acts.html @@ -27,6 +27,7 @@ option { background: #1c1813; } /* 状态面板放在控件行上方:贴在右下角会压住"细分场景"那个下拉框 */ #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; } @@ -48,6 +49,8 @@ WASD / 方向键移动 · 滚轮或 +/− 缩放 · 三个下拉框都会换图并更新地址栏
正在加载地图…
+ +
diff --git a/scripts/browser/checks/act-pack-state.js b/scripts/browser/checks/act-pack-state.js index a574d3b..c5fb731 100644 --- a/scripts/browser/checks/act-pack-state.js +++ b/scripts/browser/checks/act-pack-state.js @@ -22,6 +22,11 @@ press('ArrowRight', true); await sleep(900); press('ArrowRight', false) await sleep(200) 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 { source: atReady.source, ready: atReady.ready, error: atReady.error ?? null, base: atReady.base, act: atReady.act, level: atReady.level, quadrant: atReady.quadrant, @@ -46,6 +51,10 @@ facing: moved.facing, tick: after.tick, 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], viewport: [window.innerWidth, window.innerHeight], diff --git a/scripts/browser/checks/act-state.js b/scripts/browser/checks/act-state.js index 1fa2746..a302256 100644 --- a/scripts/browser/checks/act-state.js +++ b/scripts/browser/checks/act-state.js @@ -25,6 +25,10 @@ press('ArrowDown', true); await sleep(1200); press('ArrowDown', false) await sleep(300) 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 { ready: first.ready, error: first.error ?? null, 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)), 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, + dialogOpen: (afterTalk.dialog ?? []).length > 0, } })() diff --git a/src/game/engine.ts b/src/game/engine.ts index 7dc40bb..e61bd54 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -243,6 +243,8 @@ export class GameEngine { } } +export interface NpcEntity { def: NpcDef; x: number; y: number } + export interface EngineViewState { monstersSpawned?: number monstersAlive?: number @@ -271,7 +273,7 @@ export interface EngineViewState { castHits?: number projectilesAlive?: number npcs?: number - npcsNear?: unknown[] + npcsNear?: NpcEntity[] monstersNear?: unknown[] quests?: unknown[] dialog?: string[] diff --git a/src/game/npc.ts b/src/game/npc.ts new file mode 100644 index 0000000..3a28b3c --- /dev/null +++ b/src/game/npc.ts @@ -0,0 +1,30 @@ +import type { NpcDef } from './quests.ts' + +const NPC_NAME_FALLBACKS: Record = { + '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.`], + } +} diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 0afaafe..16f0e71 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -32,7 +32,10 @@ import { import type { CollisionGrid } from '../game/d2map.ts' import { depthInsertIndex } from '../game/map.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 { buildNpcDef } from '../game/npc.ts' +import type { NpcDef } from '../game/quests.ts' import { buildAtlas } from '../render/atlas.ts' import type { AtlasFrame, SpriteAtlas } from '../render/atlas.ts' import { SpriteRenderer } from '../render/renderer.ts' @@ -119,6 +122,9 @@ export interface ActSceneState { canvasWidth: number canvasHeight: number error: string | null + dialog: string[] + npcs: number + npcsNear: NpcEntity[] } declare global { @@ -136,6 +142,7 @@ const state: ActSceneState = { skippedDraws: 0, objects: 0, loadMs: 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, + dialog: [], npcs: 0, npcsNear: [], } window.__d2webAct = state @@ -188,6 +195,7 @@ interface MapRuntime { /** Object draws: interleaved with walls in painter's order. */ readonly objectDrawables: readonly ObjectDrawable[] readonly spawn: { x: number; y: number } + readonly npcs: readonly PackObject[] readonly objects: number readonly frames: number /** Whether the real character art is in use. */ @@ -294,6 +302,7 @@ interface PackSceneJson { /** Roof draws, painted after everything else (absent in older packs). */ readonly roofs?: readonly (readonly number[])[] readonly objects: readonly PackObject[] + readonly npcs?: readonly PackObject[] readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] } readonly spawn: readonly number[] | null readonly stats: { readonly missingTiles: number; readonly walkable: number } @@ -349,7 +358,8 @@ async function loadPackRuntime( const roofs = (scene.roofs ?? []).map(toDrawable) const objectDrawables: ObjectDrawable[] = [] - for (const obj of scene.objects) { + const packObjects = [...scene.objects, ...(scene.npcs ?? [])] + for (const obj of packObjects) { if (obj.frame) { objectDrawables.push({ frame: { @@ -420,6 +430,7 @@ async function loadPackRuntime( roofs, objectDrawables, spawn, + npcs: scene.npcs ?? [], objects: scene.objects.length, frames: scene.frames.length, notes: [`首屏页 ${priority.join(',')},共 ${String(pages.length)} 页`], @@ -578,6 +589,7 @@ async function loadLiveRuntime( roofs: scene.roofs.map(toDrawable), objectDrawables: [], spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 }, + npcs: [], objects: level.objects.length, frames: scene.frames.length, notes, @@ -715,6 +727,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number let playerMoving = false let playerWalkFrame = 0 + const npcDefs = runtime.npcs.map(n => buildNpcDef(n.token ?? '', n.id, n.name ?? '')) const engine = new GameEngine( { widthPx: runtime.widthPx, @@ -729,7 +742,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number prefixAffixes: DEMO_AFFIXES, suffixAffixes: DEMO_AFFIXES, skills: DEMO_SKILLS, - npcDefs: DEMO_NPCS, + npcDefs, questDefs: DEMO_QUESTS, combatOptions: { 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({ tickRate: 25, onTick: () => { @@ -775,6 +794,12 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number state.facing = player.facing syncEngineState(engine, state) + + const dialogPanel = document.querySelector('#dialog') + if (dialogPanel !== null) { + dialogPanel.hidden = state.dialog.length === 0 + dialogPanel.textContent = state.dialog.join('\n') + } }, onRender: () => { 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. drawTiles(runtime.roofs, 0, runtime.roofs.length) renderer.flush() + + const labelsContainer = document.querySelector('#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.tickRate = loop.tickRate state.zoom = Number(camera.zoom.toFixed(2)) diff --git a/src/scene/map-scene.ts b/src/scene/map-scene.ts index 0f2339c..caff6a7 100644 --- a/src/scene/map-scene.ts +++ b/src/scene/map-scene.ts @@ -29,6 +29,7 @@ import { Inventory, affixesFromTable, goldItem, itemBasesFromTable, rollDrop, totalStat, } from '../game/items.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 type { Affix, Item, ItemBase } from '../game/items.ts' import { Rng } from '../game/rng.ts' @@ -201,7 +202,7 @@ export interface MapSceneState { /** NPCs placed. */ npcs: number /** 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. */ monstersNear: { x: number; y: number; distance: number }[] /** 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) })) .sort((a, b) => a.distance - b.distance) .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 => ({ id: entry.def.id, status: entry.status, kills: entry.kills, needed: entry.def.killCount, }))