feat(scene): 衔接 NPC 数据至引擎层及交互面板验证 (fixes #6)
建立 NPC token 到独立显示名称的转译字典。将打包导出的 NPC 集合统一注水到 GameEngine 模型内以生效交互半径;同时增加 Canvas UI 外围的绝对定位 DOM 生成层来渲染 NPC 头顶铭牌名称,以及挂载对应的对话展示面板供 engine.dialog 同步刷新。更新 E2E 环境验证逻辑检测交互健壮性。
This commit is contained in:
parent
11439256bf
commit
7d8629cd73
|
|
@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
|
|
@ -48,6 +49,8 @@
|
|||
<span class="hint" style="margin:0">WASD / 方向键移动 · 滚轮或 +/− 缩放 · 三个下拉框都会换图并更新地址栏</span>
|
||||
</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>
|
||||
<script type="module" src="/src/scene/act-scene.ts"></script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})()
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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.`],
|
||||
}
|
||||
}
|
||||
|
|
@ -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<HTMLElement>('#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<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.tickRate = loop.tickRate
|
||||
state.zoom = Number(camera.zoom.toFixed(2))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
|
|
|
|||
Loading…
Reference in New Issue