diablo2-web/tests/input-controls.test.ts

2317 lines
78 KiB
TypeScript

/**
* Unit & Parity Tests for Keyboard Conflict Resolution & Hotkey Harmonization (Requirement R1 / R5)
* Verifies ghost interaction decoupling, combat safety, and potion drinking invariants.
*/
import * as fs from 'node:fs'
import * as path from 'node:path'
import { describe, expect, it } from 'vitest'
import { KeyboardInput } from '../src/sim/input.ts'
import { HudManager } from '../src/ui/hud-manager.ts'
import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/game/engine.ts'
import { ViewportCamera } from '../src/sim/camera.ts'
import { WaypointNetwork } from '../src/game/portal.ts'
import type { SceneEntrance, SceneWarp } from '../src/game/level-links.ts'
import { subTileToScene } from '../src/scene/transition.ts'
import { getLevelNames } from '../src/game/level-names-zh.ts'
import {
SceneMouseController,
screenToWorld,
facingOf,
PASSIVE_SKILL_IDS,
isPassiveSkill,
getSkillRange,
getSkillMana,
TELEKINESIS_RANGE_PX,
getWarpLabelWorldPos,
getEntranceLabelWorldPos,
hitTestWarp,
hitTestEntrance,
isPlayerWithinLinkRange,
resolveObstacleAwareSteerTarget,
sceneToSubTile,
type MapRuntime,
} from '../src/scene/act-scene.ts'
class MockWindow extends EventTarget {
innerWidth = 1920
innerHeight = 1080
devicePixelRatio = 1
}
class MockCanvas extends EventTarget {
clientWidth = 1920
clientHeight = 1080
width = 1920
height = 1080
style = { pointerEvents: 'none', cursor: '' }
getBoundingClientRect() {
return { left: 0, top: 0, width: 1920, height: 1080 }
}
getContext() {
return null
}
}
class SyntheticKeyboardEvent extends Event {
readonly code: string
readonly key: string
constructor(type: string, init: { code: string; key: string }) {
super(type, { cancelable: true })
this.code = init.code
this.key = init.key
}
}
class SyntheticPointerEvent extends Event {
readonly clientX: number
readonly clientY: number
readonly button: number
readonly buttons: number
readonly shiftKey: boolean
private _defaultPrevented = false
constructor(type: string, init: { clientX: number; clientY: number; button?: number; buttons?: number; shiftKey?: boolean; cancelable?: boolean }) {
super(type, { cancelable: init.cancelable ?? true })
this.clientX = init.clientX
this.clientY = init.clientY
this.button = init.button ?? 0
this.buttons = init.buttons !== undefined ? init.buttons : (1 << (init.button ?? 0))
this.shiftKey = init.shiftKey ?? false
}
override get defaultPrevented(): boolean {
return this._defaultPrevented
}
override preventDefault() {
super.preventDefault()
this._defaultPrevented = true
}
}
// Global DOM shims for HudManager
const mockWin = new MockWindow()
// @ts-ignore
globalThis.window = mockWin
// @ts-ignore
globalThis.HTMLInputElement = class HTMLInputElement {}
// @ts-ignore
globalThis.HTMLSelectElement = class HTMLSelectElement {}
describe('Milestone 1: Keyboard Conflict Resolution & Hotkey Harmonization (R1)', () => {
it('decouples KeyT from input.talking to eliminate ghost NPC interactions', () => {
const win = new MockWindow()
const input = new KeyboardInput(win as unknown as Window)
input.attach()
expect(input.talking).toBe(false)
win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'KeyT', key: 't' }))
expect(input.talking).toBe(false)
// Held across multiple frames/ticks
for (let i = 0; i < 25; i++) {
expect(input.talking).toBe(false)
}
win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'KeyT', key: 't' }))
expect(input.talking).toBe(false)
})
it('decouples KeyB from input.takeWaypoint() to eliminate ghost waypoint travel', () => {
const win = new MockWindow()
const input = new KeyboardInput(win as unknown as Window)
input.attach()
expect(input.takeWaypoint()).toBe(false)
win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'KeyB', key: 'b' }))
expect(input.takeWaypoint()).toBe(false)
for (let i = 0; i < 25; i++) {
expect(input.takeWaypoint()).toBe(false)
}
win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'KeyB', key: 'b' }))
expect(input.takeWaypoint()).toBe(false)
})
it('removes Space from ATTACK_KEYS so Space never triggers combat swings or skill casts', () => {
const win = new MockWindow()
const input = new KeyboardInput(win as unknown as Window)
input.attach()
win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Space', key: ' ' }))
expect(input.attacking).toBe(false)
// Legitimate attack keys still work
win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'KeyJ', key: 'j' }))
expect(input.attacking).toBe(true)
win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'KeyJ', key: 'j' }))
expect(input.attacking).toBe(false)
win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Enter', key: 'Enter' }))
expect(input.attacking).toBe(true)
win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'Enter', key: 'Enter' }))
expect(input.attacking).toBe(false)
})
it('verifies Space in GameEngine does not damage monsters or trigger combat hits', () => {
const dummyTerrain: WorldMapProvider = { widthPx: 1000, heightPx: 1000, overlap: () => 0 }
const dummyOpts: GameEngineOptions = {
spawn: { x: 100, y: 100 },
stats: [{ id: 'mon1', name: 'Zombie', hp: 100, damage: 0, cooldownTicks: 100, reach: 20, aggroRadius: 100, speed: 0, xp: 5 }],
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: [],
questDefs: [],
combatOptions: { playerSpeed: 100, playerReach: 50, playerCooldownTicks: 1, playerDamage: 10, playerManaPerAttack: 0, respawnTicks: 100 },
lootSeed: 12345,
talkRadius: 50,
pickupRadius: 50,
inventoryCols: 10,
inventoryRows: 4,
}
const engine = new GameEngine(dummyTerrain, dummyOpts)
engine.world.monsters[0]!.x = 110
engine.world.monsters[0]!.y = 100
const startHp = engine.world.monsters[0]!.hp
const startHits = engine.metrics.playerHits
const win = new MockWindow()
const input = new KeyboardInput(win as unknown as Window)
input.attach()
win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Space', key: ' ' }))
for (let i = 0; i < 30; i++) {
engine.tick({
movement: input.movement(),
attacking: input.attacking,
pickingUp: input.pickingUp,
talking: input.talking,
digits: input.digits,
saving: input.saving,
loading: input.loading,
})
}
expect(engine.world.monsters[0]!.hp).toBe(startHp)
expect(engine.metrics.playerHits).toBe(startHits)
})
it('harmonizes Space with Escape to close open HUD panels', () => {
const canvas = new MockCanvas() as unknown as HTMLCanvasElement
const hud = new HudManager(canvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hud['bindEvents']()
// Character Sheet toggle and Space dismiss
hud.toggleLeftPanel('char')
expect(hud.leftPanel).toBe('char')
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Space', key: ' ' }))
expect(hud.leftPanel).toBe('none')
// Skill Tree toggle and Space dismiss
hud.toggleRightPanel('skill')
expect(hud.rightPanel).toBe('skill')
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Space', key: ' ' }))
expect(hud.rightPanel).toBe('none')
// Inventory toggle and Space dismiss
hud.toggleRightPanel('inv')
expect(hud.rightPanel).toBe('inv')
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Space', key: ' ' }))
expect(hud.rightPanel).toBe('none')
})
it('drinks belt potions from columns 0..3 via keys 1..4 without resetting active skills', () => {
const canvas = new MockCanvas() as unknown as HTMLCanvasElement
const hud = new HudManager(canvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hud['bindEvents']()
hud.hotkeys.leftSkillId = 47 // Fire Ball
hud.hotkeys.rightSkillId = 64 // Frozen Orb
hud.syncPublishedState()
hud.hp = 400
hud.mana = 200
expect(hud.belt.countTotalPotions()).toBe(16)
// Press '1' -> drinks column 0 (hp potion, healHp 180)
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit1', key: '1' }))
expect(hud.hp).toBe(400 + 180)
expect(hud.hotkeys.leftSkillId).toBe(47)
expect(hud.hotkeys.rightSkillId).toBe(64)
expect(hud.state.leftSkillId).toBe(47)
expect(hud.state.rightSkillId).toBe(64)
expect(hud.belt.countTotalPotions()).toBe(15)
// Press '2' -> drinks column 1 (hp potion, healHp 320)
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit2', key: '2' }))
expect(hud.hp).toBe(580 + 320)
expect(hud.hotkeys.leftSkillId).toBe(47)
expect(hud.hotkeys.rightSkillId).toBe(64)
expect(hud.belt.countTotalPotions()).toBe(14)
// Press '3' -> drinks column 2 (mana potion, healMana 150)
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit3', key: '3' }))
expect(hud.mana).toBe(200 + 150)
expect(hud.hotkeys.leftSkillId).toBe(47)
expect(hud.hotkeys.rightSkillId).toBe(64)
expect(hud.belt.countTotalPotions()).toBe(13)
// Press '4' -> drinks column 3 (rejuv potion, healHp 400, healMana 400)
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit4', key: '4' }))
expect(hud.hp).toBe(900 + 400 > hud.maxHp ? hud.maxHp : 1300)
expect(hud.mana).toBe(350 + 400)
expect(hud.hotkeys.leftSkillId).toBe(47)
expect(hud.hotkeys.rightSkillId).toBe(64)
expect(hud.belt.countTotalPotions()).toBe(12)
})
it('isolates WASD keys from movement Contribution', () => {
const win = new MockWindow()
const input = new KeyboardInput(win as unknown as Window)
input.attach()
for (const code of ['KeyW', 'KeyA', 'KeyS', 'KeyD']) {
win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code, key: code.slice(-1).toLowerCase() }))
expect(input.movement()).toEqual({ x: 0, y: 0 })
expect(input.active).toBe(false)
win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code, key: code.slice(-1).toLowerCase() }))
}
// Arrow keys still move
win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ArrowUp', key: 'ArrowUp' }))
expect(input.movement()).toEqual({ x: 0, y: -1 })
expect(input.active).toBe(true)
win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'ArrowUp', key: 'ArrowUp' }))
expect(input.active).toBe(false)
})
it('tracks shiftHeld state for force stand still attack', () => {
const win = new MockWindow()
const input = new KeyboardInput(win as unknown as Window)
input.attach()
expect(input.shiftHeld).toBe(false)
win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' }))
expect(input.shiftHeld).toBe(true)
win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'ShiftLeft', key: 'Shift' }))
expect(input.shiftHeld).toBe(false)
win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftRight', key: 'Shift' }))
expect(input.shiftHeld).toBe(true)
win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'ShiftRight', key: 'Shift' }))
expect(input.shiftHeld).toBe(false)
})
})
function createTestScene(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 }>
warps?: SceneWarp[]
entrances?: SceneEntrance[]
portalSlot?: { mouthInLevel: (levelId: number) => { x: number; y: number } | null }
onEnterPortal?: () => void
onEnterWarp?: (warp: SceneWarp) => void
onEnterEntrance?: (entrance: SceneEntrance) => void
isTravelling?: () => boolean
labelsContainer?: HTMLElement | null
monsters?: Array<{ id: string; name: string; x: number; y: number; hp: number }>
blockedOverlap?: (x: number, y: number) => 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: options?.blockedOverlap ?? (() => 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: [{ 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, 2000, 2000)
camera.zoom = 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: 2000,
heightPx: 2000,
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: options?.entrances ?? [],
warps: options?.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,
portalSlot: options?.portalSlot as any,
onEnterPortal: options?.onEnterPortal,
onEnterWarp: options?.onEnterWarp,
onEnterEntrance: options?.onEnterEntrance,
isTravelling: options?.isTravelling,
labelsContainer: options?.labelsContainer,
})
controller.attach()
return {
canvas,
win,
input,
engine,
camera,
hudManager,
waypointNetwork,
runtime,
status,
controller,
}
}
describe('Milestone 2: Mouse Interaction, Click-to-Move, and Combat Controls (R2, R3, R4)', () => {
it('projects screen coordinates to world coordinates through inverse camera transform', () => {
const canvas = new MockCanvas() as unknown as HTMLCanvasElement
const rect = canvas.getBoundingClientRect()
const camX = 500
const camY = 484 // player.y - 16 = 500 - 16
// Center of screen (960, 540) maps exactly to (camX, camY) = (500, 484)
const center = screenToWorld({
clientX: 960,
clientY: 540,
rect,
canvas,
camX,
camY,
cameraZoom: 1,
})
expect(center.x).toBe(500)
expect(center.y).toBe(484)
// Offset +100px X, +50px Y at zoom 1
const offset = screenToWorld({
clientX: 1060,
clientY: 590,
rect,
canvas,
camX,
camY,
cameraZoom: 1,
})
expect(offset.x).toBe(600)
expect(offset.y).toBe(534)
// Offset at zoom 2
const offsetZoom2 = screenToWorld({
clientX: 1060,
clientY: 590,
rect,
canvas,
camX,
camY,
cameraZoom: 2,
})
expect(offsetZoom2.x).toBe(550)
expect(offsetZoom2.y).toBe(509)
})
it('computes 8-direction facing index with 0=South turning clockwise (D2 convention)', () => {
// 0 = South (dx=0, dy>0)
expect(facingOf(0, 1)).toBe(0)
// 1 = South-West (dx<0, dy>0)
expect(facingOf(-1, 1)).toBe(1)
// 2 = West (dx<0, dy=0)
expect(facingOf(-1, 0)).toBe(2)
// 3 = North-West (dx<0, dy<0)
expect(facingOf(-1, -1)).toBe(3)
// 4 = North (dx=0, dy<0)
expect(facingOf(0, -1)).toBe(4)
// 5 = North-East (dx>0, dy<0)
expect(facingOf(1, -1)).toBe(5)
// 6 = East (dx>0, dy=0)
expect(facingOf(1, 0)).toBe(6)
// 7 = South-East (dx>0, dy>0)
expect(facingOf(1, 1)).toBe(7)
})
it('R3: left-click on ground sets navTarget and generates movement towards target, stopping within 8px', () => {
const scene = createTestScene({ playerPos: { x: 500, y: 500 } })
// Click ground at +100px X, +0px Y (clientX = 960 + 100, clientY = 540)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1060,
clientY: 540,
button: 0,
}))
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', {
clientX: 1060,
clientY: 540,
button: 0,
}))
expect(scene.controller.navTarget).toEqual({ x: 600, y: 484 })
// First tick produces movement vector pointing right towards (600, 484)
const tick1 = scene.controller.tick()
expect(tick1.movement.x).toBeGreaterThan(0)
// Move player closer to target (598, 484) - distance is 2px (< 8px arrival threshold)
scene.engine.world.player.x = 598
scene.engine.world.player.y = 484
const arrivalTick = scene.controller.tick()
expect(arrivalTick.movement).toEqual({ x: 0, y: 0 })
expect(scene.controller.navTarget).toBeNull()
})
it('R3: continuous left-click drag updates navTarget with cursor coordinates', () => {
const scene = createTestScene({ playerPos: { x: 500, y: 500 } })
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1060,
clientY: 540,
button: 0,
buttons: 1,
}))
expect(scene.controller.navTarget).toEqual({ x: 600, y: 484 })
// Drag pointer to (1060, 640) -> world (600, 584)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', {
clientX: 1060,
clientY: 640,
button: 0,
buttons: 1,
}))
expect(scene.controller.navTarget).toEqual({ x: 600, y: 584 })
// pointerup releases hold
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', {
clientX: 1060,
clientY: 640,
button: 0,
buttons: 0,
}))
expect(scene.controller.isLeftMouseDown).toBe(false)
})
it('R4: left-click with Shift held executes force stand still attack without pathing', () => {
const scene = createTestScene({
playerPos: { x: 500, y: 500 },
leftSkillId: 47, // Fire Ball
mana: 100,
})
// Hold Shift via window keydown
scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' }))
expect(scene.input.shiftHeld).toBe(true)
// Left-click towards (600, 484)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1060,
clientY: 540,
button: 0,
shiftKey: true,
}))
// Movement must be strictly 0 (no pathing / stand still)
const tickResult = scene.controller.tick()
expect(tickResult.movement).toEqual({ x: 0, y: 0 })
expect(scene.controller.navTarget).toBeNull()
// Player faces cursor (East = 6)
expect(scene.engine.world.player.facing).toBe(6)
// Projectile spawned towards cursor
expect(scene.engine.projectiles.length).toBe(1)
expect(scene.engine.projectiles[0]!.skillId).toBe('47')
expect(scene.engine.projectiles[0]!.vx).toBeGreaterThan(0)
// Mana deducted (Fire Ball lv 8 costs 8.5)
expect(scene.hudManager.mana).toBe(100 - 8.5)
})
it('R3: left-click on NPC talks immediately if <= 80px, or paths and talks on arrival if > 80px', () => {
// NPC 1 is nearby (530, 500), distance 30px <= 80px
// NPC 2 is far away (750, 500), distance 250px > 80px
const scene = createTestScene({
playerPos: { x: 500, y: 500 },
npcs: [
{ id: 1, name: 'Akara', x: 530, y: 500 },
{ id: 2, name: 'Kashya', x: 750, y: 500 },
],
})
// 1. Click nearby NPC Akara (world 530, 500 -> clientX = 960 + 30 = 990, clientY = 540)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 990,
clientY: 540,
button: 0,
}))
// Talks immediately without pathing
expect(scene.controller.navTarget).toBeNull()
expect(scene.controller.pendingInteraction).toBeNull()
expect(scene.status.textContent).toContain('Akara')
expect(scene.engine.world.player.facing).toBe(6) // Facing East towards Akara
// 2. Click far NPC Kashya (world 750, 500 -> clientX = 960 + 250 = 1210, clientY = 540)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1210,
clientY: 540,
button: 0,
}))
// Sets pending interaction and navTarget towards Kashya
expect(scene.controller.pendingInteraction).toEqual({
kind: 'npc',
npc: expect.objectContaining({ x: 750, y: 500 }),
})
expect(scene.controller.navTarget).toEqual({ x: 750, y: 500 })
// Walks towards Kashya
const walkTick = scene.controller.tick()
expect(walkTick.movement.x).toBeGreaterThan(0)
// Player arrives within 80px (e.g. x = 680, dist = 70px)
scene.engine.world.player.x = 680
const arriveTick = scene.controller.tick()
expect(arriveTick.movement).toEqual({ x: 0, y: 0 })
expect(scene.controller.pendingInteraction).toBeNull()
expect(scene.status.textContent).toContain('Kashya')
})
it('R3: left-click on Waypoint activates and opens menu if <= 80px, or paths and opens on arrival', () => {
// WP 1 nearby at (540, 500), dist 40px <= 80px
// WP 2 far away at (800, 500), dist 300px > 80px
// Note: waypoint subTile to scene is handled in test mock
const scene = createTestScene({
playerPos: { x: 500, y: 500 },
waypoints: [
{ waypointId: 1, x: 540, y: 500 },
{ waypointId: 2, x: 800, y: 500 },
],
})
// Click nearby WP 1 (clientX = 960 + 40 = 1000, clientY = 540)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1000,
clientY: 540,
button: 0,
}))
// Activated immediately and opened waypoint menu
expect(scene.waypointNetwork.destinations().some(d => d.waypointId === 1)).toBe(true)
expect(scene.hudManager.leftPanel).toBe('waypoint')
expect(scene.controller.navTarget).toBeNull()
// Close waypoint panel
scene.hudManager.toggleLeftPanel('waypoint')
expect(scene.hudManager.leftPanel).toBe('none')
// Click far WP 2 (clientX = 960 + 300 = 1260, clientY = 540)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1260,
clientY: 540,
button: 0,
}))
expect(scene.controller.pendingInteraction?.kind).toBe('waypoint')
expect(scene.controller.navTarget).toBeDefined()
// Player moves within 80px (e.g. x = 740, dist = 60px)
scene.engine.world.player.x = 740
const arriveTick = scene.controller.tick()
expect(arriveTick.movement).toEqual({ x: 0, y: 0 })
expect(scene.hudManager.leftPanel).toBe('waypoint')
expect(scene.controller.pendingInteraction).toBeNull()
})
it('R3: left-click on Monster attacks melee if <= 52px, or paths to monster and attacks on arrival', () => {
// Monster 1 nearby at (530, 500), dist 30px <= 52px
// Monster 2 far away at (700, 500), dist 200px > 52px
const scene = createTestScene({
playerPos: { x: 500, y: 500 },
leftSkillId: 0, // Melee attack
monsters: [
{ id: 'mon1', name: 'Fallen', x: 530, y: 500, hp: 50 },
{ id: 'mon2', name: 'Zombie', x: 700, y: 500, hp: 100 },
],
})
// Click nearby monster 1 (clientX = 960 + 30 = 990, clientY = 540)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 990,
clientY: 540,
button: 0,
}))
// Attacks immediately: deals damage, no pathing
expect(scene.engine.world.monsters[0]!.hp).toBeLessThan(50)
expect(scene.engine.metrics.playerHits).toBe(1)
expect(scene.controller.navTarget).toBeNull()
// Click far monster 2 (clientX = 960 + 200 = 1160, clientY = 540)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1160,
clientY: 540,
button: 0,
}))
expect(scene.controller.pendingInteraction?.kind).toBe('monster')
expect(scene.controller.navTarget).toEqual({ x: 700, y: 500 })
// Move player within reach (e.g. x = 660, dist = 40px <= 52px)
scene.engine.world.player.x = 660
scene.engine.world.player.cooldown = 0
const attackTick = scene.controller.tick()
expect(attackTick.movement).toEqual({ x: 0, y: 0 })
expect(scene.engine.world.monsters[1]!.hp).toBeLessThan(100)
expect(scene.engine.metrics.playerHits).toBe(2)
})
it('R2: right-click active skill checks mana, deducts mana, enforces cooldown, and casts', () => {
const scene = createTestScene({
playerPos: { x: 500, y: 500 },
rightSkillId: 47, // Fire Ball, cost 8.5
mana: 100,
})
// 1. Sufficient mana
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1060,
clientY: 540,
button: 2,
}))
expect(scene.hudManager.mana).toBe(100 - 8.5)
expect(scene.engine.projectiles.length).toBe(1)
expect(scene.engine.world.player.cooldown).toBeGreaterThan(0)
expect(scene.engine.world.player.facing).toBe(6) // Facing East
// 2. Insufficient mana: reduce mana to 5
scene.hudManager.mana = 5
scene.engine.world.player.cooldown = 0
scene.engine.projectiles.length = 0
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1060,
clientY: 540,
button: 2,
}))
// Must not move player, must display "法力不足!", must not spawn projectile
expect(scene.controller.navTarget).toBeNull()
expect(scene.controller.tick().movement).toEqual({ x: 0, y: 0 })
expect(scene.status.textContent).toBe('法力不足!')
expect(scene.engine.projectiles.length).toBe(0)
expect(scene.hudManager.mana).toBe(5) // Unducted
})
it('R2: right-click Teleport (54) verifies target walkability and relocates player', () => {
const scene = createTestScene({
playerPos: { x: 500, y: 500 },
rightSkillId: 54, // Teleport, cost 21
mana: 100,
blockedOverlap: (x, y) => (x > 1500 ? 1 : 0),
})
// Teleport to walkable location (600, 484)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1060,
clientY: 540,
button: 2,
}))
expect(scene.engine.world.player.x).toBe(600)
expect(scene.engine.world.player.y).toBe(484)
expect(scene.hudManager.mana).toBe(100 - 21)
expect(scene.status.textContent).toBe('已施展传送')
// Teleport to blocked location (1600, 484) -> clientX = 960 + 1000 = 1960
scene.engine.world.player.cooldown = 0
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1960,
clientY: 540,
button: 2,
}))
// Does not move player
expect(scene.engine.world.player.x).toBe(600)
expect(scene.status.textContent).toBe('无法传送到该位置')
})
it('R2: right-click unassigned (0) or passive skill (37, 61) routes to walk/run towards cursor', () => {
// 1. Unassigned skill 0
const scene1 = createTestScene({
playerPos: { x: 500, y: 500 },
rightSkillId: 0,
})
scene1.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1060,
clientY: 540,
button: 2,
}))
expect(scene1.controller.navTarget).toEqual({ x: 600, y: 484 })
expect(scene1.controller.tick().movement.x).toBeGreaterThan(0)
// 2. Passive skill Warmth (37)
const scene2 = createTestScene({
playerPos: { x: 500, y: 500 },
rightSkillId: 37,
})
scene2.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1060,
clientY: 540,
button: 2,
}))
expect(scene2.controller.navTarget).toEqual({ x: 600, y: 484 })
expect(scene2.controller.tick().movement.x).toBeGreaterThan(0)
// 3. Passive skill Fire Mastery (61)
const scene3 = createTestScene({
playerPos: { x: 500, y: 500 },
rightSkillId: 61,
})
scene3.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1060,
clientY: 540,
button: 2,
}))
expect(scene3.controller.navTarget).toEqual({ x: 600, y: 484 })
expect(scene3.controller.tick().movement.x).toBeGreaterThan(0)
})
it('prevents contextmenu default on canvas and respects HUD interception', () => {
const scene = createTestScene({ playerPos: { x: 500, y: 500 } })
// Test contextmenu preventDefault
const contextMenuEvent = new SyntheticPointerEvent('contextmenu', {
clientX: 500,
clientY: 500,
})
scene.canvas.dispatchEvent(contextMenuEvent)
expect(contextMenuEvent.defaultPrevented).toBe(true)
// Test HUD interception at bottom control bar (clientY = 1000 -> logical.y = 555.5 >= 546)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 960,
clientY: 1000,
button: 0,
}))
expect(scene.controller.navTarget).toBeNull()
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 960,
clientY: 1000,
button: 2,
}))
expect(scene.controller.navTarget).toBeNull()
})
it('clears navTarget on stuck detection after 12 ticks without moving', () => {
const scene = createTestScene({ playerPos: { x: 500, y: 500 } })
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1060,
clientY: 540,
button: 0,
}))
expect(scene.controller.navTarget).toEqual({ x: 600, y: 484 })
// Simulate player being stuck at (500, 500) for 12 ticks
for (let i = 0; i < 11; i++) {
scene.controller.tick()
expect(scene.controller.navTarget).not.toBeNull()
}
// 12th tick triggers stuck mitigation
scene.controller.tick()
expect(scene.controller.navTarget).toBeNull()
})
})
describe('Warp & Entrance Mouse-Click Transitions, KeyG Removal & Overhead Labels', () => {
it('disables KeyG so pressing KeyG no longer triggers takeUse()', () => {
const win = new MockWindow()
const input = new KeyboardInput(win as unknown as Window)
input.attach()
expect(input.takeUse()).toBe(false)
win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'KeyG', key: 'g' }))
expect(input.takeUse()).toBe(false)
for (let i = 0; i < 25; i++) {
expect(input.takeUse()).toBe(false)
}
win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'KeyG', key: 'g' }))
expect(input.takeUse()).toBe(false)
})
it('immediately triggers onEnterWarp without setting navTarget when left-clicking an in-range SceneWarp', () => {
const grid = { originX: 0, originY: 0 }
const warp: SceneWarp = {
source: 'tile',
direction: 'down',
label: 'Den of Evil',
toLevelId: 8, // Den of Evil
warpId: 4,
x: 30,
y: 30,
arriveX: 32,
arriveY: 30,
exitWalkX: 32,
exitWalkY: 30,
selectX: 0,
selectY: 0,
selectDX: 0,
selectDY: 0,
}
const anchorPos = subTileToScene(warp.x, warp.y, grid)
const playerPos = subTileToScene(31, 30, grid)
const enteredWarps: SceneWarp[] = []
const scene = createTestScene({
playerPos,
warps: [warp],
onEnterWarp: (w) => enteredWarps.push(w),
})
// Click directly on the warp anchor (relative to camera centered at playerPos.x, playerPos.y - 16)
const clientX = 960 + (anchorPos.x - playerPos.x)
const clientY = 540 + (anchorPos.y - (playerPos.y - 16))
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX,
clientY,
button: 0,
}))
expect(enteredWarps).toEqual([warp])
expect(scene.controller.navTarget).toBeNull()
expect(scene.controller.pendingInteraction).toBeNull()
expect(scene.status.textContent).toContain('邪恶洞窟')
})
it('sets navTarget to walkable arrivePos when left-clicking a distant SceneWarp and triggers onEnterWarp on tick() upon arrival', () => {
const grid = { originX: 0, originY: 0 }
// Warp where arriveX/arriveY is 7 sub-tiles from anchor (like Harem / Catacombs stairs)
const warp: SceneWarp = {
source: 'tile',
direction: 'down',
label: 'Catacombs Level 2',
toLevelId: 35, // Catacombs Level 2
warpId: 18,
x: 40,
y: 20,
arriveX: 47,
arriveY: 20,
exitWalkX: 47,
exitWalkY: 20,
selectX: -54,
selectY: -39,
selectDX: 121,
selectDY: 102,
}
const anchorPos = subTileToScene(warp.x, warp.y, grid)
const arrivePos = subTileToScene(warp.arriveX, warp.arriveY, grid)
const playerPos = subTileToScene(70, 50, grid)
const enteredWarps: SceneWarp[] = []
const scene = createTestScene({
playerPos: { ...playerPos },
warps: [warp],
onEnterWarp: (w) => enteredWarps.push(w),
})
const clientX = 960 + (anchorPos.x - playerPos.x)
const clientY = 540 + (anchorPos.y - (playerPos.y - 16))
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX,
clientY,
button: 0,
}))
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', {
clientX,
clientY,
button: 0,
}))
expect(enteredWarps).toHaveLength(0)
expect(scene.controller.navTarget).toEqual(arrivePos)
expect(scene.controller.pendingInteraction?.kind).toBe('warp')
// Move partway toward arrivePos: still out of range
scene.engine.world.player.x = (playerPos.x + arrivePos.x) * 0.5
scene.engine.world.player.y = (playerPos.y + arrivePos.y) * 0.5
const step = scene.controller.tick()
expect(enteredWarps).toHaveLength(0)
expect(step.movement.x !== 0 || step.movement.y !== 0).toBe(true)
// Arrive within trigger range of arrivePos
scene.engine.world.player.x = arrivePos.x
scene.engine.world.player.y = arrivePos.y
const arriveTick = scene.controller.tick()
expect(enteredWarps).toEqual([warp])
expect(scene.controller.navTarget).toBeNull()
expect(scene.controller.pendingInteraction).toBeNull()
expect(arriveTick.movement).toEqual({ x: 0, y: 0 })
})
it('resolves and activates a SceneWarp when left-clicking inside its LvlWarp.txt (selectX, selectY, selectDX, selectDY) bounding box', () => {
const grid = { originX: 0, originY: 0 }
const warp: SceneWarp = {
source: 'tile',
direction: 'down',
label: 'Harem Level 1',
toLevelId: 50, // Harem Level 1
warpId: 23,
x: 35,
y: 35,
arriveX: 37,
arriveY: 35,
exitWalkX: 37,
exitWalkY: 35,
selectX: -120,
selectY: -100,
selectDX: 180,
selectDY: 140,
}
const anchorPos = subTileToScene(warp.x, warp.y, grid)
const playerPos = subTileToScene(36, 35, grid)
const enteredWarps: SceneWarp[] = []
const scene = createTestScene({
playerPos: { ...playerPos },
warps: [warp],
onEnterWarp: (w) => enteredWarps.push(w),
})
// Click near the top-left corner of the LvlWarp.txt selection box:
// ~140px from anchorPos (well outside radial proximity 56px, but inside [-120..60, -100..40])
const clickWorldX = anchorPos.x - 110
const clickWorldY = anchorPos.y - 90
const clientX = 960 + (clickWorldX - playerPos.x)
const clientY = 540 + (clickWorldY - (playerPos.y - 16))
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX,
clientY,
button: 0,
}))
expect(enteredWarps).toEqual([warp])
expect(scene.controller.navTarget).toBeNull()
})
it('paths to arrivePos and triggers onEnterEntrance when left-clicking a SceneEntrance', () => {
const grid = { originX: 0, originY: 0 }
const entrance: SceneEntrance = {
toLevelId: 2, // Blood Moor
side: 'east',
label: 'Blood Moor Exit',
x: 25,
y: 25,
arriveX: 31,
arriveY: 25,
}
const anchorPos = subTileToScene(entrance.x, entrance.y, grid)
const arrivePos = subTileToScene(entrance.arriveX, entrance.arriveY, grid)
const playerPos = subTileToScene(60, 45, grid)
const enteredEntrances: SceneEntrance[] = []
const scene = createTestScene({
playerPos: { ...playerPos },
entrances: [entrance],
onEnterEntrance: (e) => enteredEntrances.push(e),
})
const clientX = 960 + (anchorPos.x - playerPos.x)
const clientY = 540 + (anchorPos.y - (playerPos.y - 16))
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX,
clientY,
button: 0,
}))
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', {
clientX,
clientY,
button: 0,
}))
expect(enteredEntrances).toHaveLength(0)
expect(scene.controller.navTarget).toEqual(arrivePos)
expect(scene.controller.pendingInteraction?.kind).toBe('entrance')
// Move player to arrivePos and tick
scene.engine.world.player.x = arrivePos.x
scene.engine.world.player.y = arrivePos.y
scene.controller.tick()
expect(enteredEntrances).toEqual([entrance])
expect(scene.controller.navTarget).toBeNull()
expect(scene.controller.pendingInteraction).toBeNull()
expect(scene.status.textContent).toContain('鲜血荒地')
})
it('allows Telekinesis (Skill 43) right-click casts to activate a clicked SceneWarp within TELEKINESIS_RANGE_PX (320px)', () => {
const grid = { originX: 0, originY: 0 }
const nearWarp: SceneWarp = {
source: 'tile',
direction: 'down',
label: 'Den of Evil',
toLevelId: 8,
warpId: 4,
x: 30,
y: 30,
arriveX: 32,
arriveY: 30,
exitWalkX: 32,
exitWalkY: 30,
selectX: -48,
selectY: -48,
selectDX: 96,
selectDY: 96,
}
const farWarp: SceneWarp = {
source: 'tile',
direction: 'down',
label: 'Cold Plains Cave',
toLevelId: 9,
warpId: 5,
x: 60,
y: 30,
arriveX: 62,
arriveY: 30,
exitWalkX: 62,
exitWalkY: 30,
selectX: -48,
selectY: -48,
selectDX: 96,
selectDY: 96,
}
const nearPos = subTileToScene(nearWarp.x, nearWarp.y, grid)
const farPos = subTileToScene(farWarp.x, farWarp.y, grid)
// Place player 220px away from nearWarp (< 320px) and > 320px from farWarp
const playerPos = { x: nearPos.x - 220, y: nearPos.y }
expect(Math.hypot(nearPos.x - playerPos.x, nearPos.y - playerPos.y)).toBeLessThanOrEqual(TELEKINESIS_RANGE_PX)
expect(Math.hypot(farPos.x - playerPos.x, farPos.y - playerPos.y)).toBeGreaterThan(TELEKINESIS_RANGE_PX)
const enteredWarps: SceneWarp[] = []
const scene = createTestScene({
playerPos,
rightSkillId: 43, // Telekinesis
warps: [nearWarp, farWarp],
onEnterWarp: (w) => enteredWarps.push(w),
})
// Right-click farWarp (> 320px) -> rejected without deducting mana
const startMana = scene.hudManager.mana
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 960 + (farPos.x - playerPos.x),
clientY: 540 + (farPos.y - (playerPos.y - 16)),
button: 2,
}))
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', {
clientX: 960 + (farPos.x - playerPos.x),
clientY: 540 + (farPos.y - (playerPos.y - 16)),
button: 2,
}))
expect(enteredWarps).toHaveLength(0)
expect(scene.hudManager.mana).toBe(startMana)
// Right-click nearWarp (<= 320px) -> deducts mana and activates warp
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 960 + (nearPos.x - playerPos.x),
clientY: 540 + (nearPos.y - (playerPos.y - 16)),
button: 2,
}))
expect(enteredWarps).toEqual([nearWarp])
expect(scene.hudManager.mana).toBeLessThan(startMana)
})
it('renders overhead destination labels in labelsContainer and updates pointer cursor + badge highlight on hover', () => {
const grid = { originX: 0, originY: 0 }
const warp: SceneWarp = {
source: 'tile',
direction: 'down',
label: 'Den of Evil',
toLevelId: 8, // 邪恶洞窟
warpId: 4,
x: 30,
y: 30,
arriveX: 32,
arriveY: 30,
exitWalkX: 32,
exitWalkY: 30,
selectX: -60,
selectY: -60,
selectDX: 120,
selectDY: 90,
}
const entrance: SceneEntrance = {
toLevelId: 2, // 鲜血荒地
side: 'east',
label: 'Blood Moor Exit',
x: 38,
y: 30,
arriveX: 40,
arriveY: 30,
}
const playerPos = subTileToScene(34, 30, grid)
const warpAnchor = subTileToScene(warp.x, warp.y, grid)
const entranceAnchor = subTileToScene(entrance.x, entrance.y, grid)
const children: HTMLDivElement[] = []
const mockLabelsContainer = {
children,
appendChild(el: HTMLDivElement) {
children.push(el)
return el
},
} as unknown as HTMLElement
const scene = createTestScene({
playerPos,
warps: [warp],
entrances: [entrance],
labelsContainer: mockLabelsContainer,
})
const badges = children
expect(badges.length).toBe(2)
expect(badges[0]!.textContent).toBe('通往 邪恶洞窟')
expect(badges[1]!.textContent).toBe('通往 鲜血荒地')
expect(badges[0]!.dataset.hovered).toBe('false')
expect(badges[1]!.dataset.hovered).toBe('false')
expect(scene.canvas.style.cursor).not.toBe('pointer')
// Hover over the SceneWarp bounding box
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', {
clientX: 960 + (warpAnchor.x - playerPos.x),
clientY: 540 + (warpAnchor.y - (playerPos.y - 16)),
}))
expect(scene.controller.hoveredWarp).toBe(warp)
expect(scene.controller.hoveredEntrance).toBeNull()
expect(scene.canvas.style.cursor).toBe('pointer')
expect(badges[0]!.dataset.hovered).toBe('true')
expect(badges[0]!.className).toContain('is-hovered')
expect(badges[1]!.dataset.hovered).toBe('false')
// Hover over the SceneEntrance
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', {
clientX: 960 + (entranceAnchor.x - playerPos.x),
clientY: 540 + (entranceAnchor.y - (playerPos.y - 16)),
}))
expect(scene.controller.hoveredWarp).toBeNull()
expect(scene.controller.hoveredEntrance).toBe(entrance)
expect(scene.canvas.style.cursor).toBe('pointer')
expect(badges[0]!.dataset.hovered).toBe('false')
expect(badges[1]!.dataset.hovered).toBe('true')
expect(badges[1]!.className).toContain('is-hovered')
// Move cursor to empty ground
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', {
clientX: 100,
clientY: 100,
}))
expect(scene.controller.hoveredWarp).toBeNull()
expect(scene.controller.hoveredEntrance).toBeNull()
expect(scene.canvas.style.cursor).toBe('')
expect(badges[0]!.dataset.hovered).toBe('false')
expect(badges[1]!.dataset.hovered).toBe('false')
})
it('paths around a concave dungeon wall obstacle when clicking a distant SceneWarp without false stuck cancellation', () => {
const grid = { originX: 0, originY: 0 }
const warp: SceneWarp = {
source: 'tile',
direction: 'down',
label: 'Catacombs Level 3',
toLevelId: 36,
warpId: 19,
x: 20,
y: 33,
arriveX: 20,
arriveY: 32,
exitWalkX: 20,
exitWalkY: 32,
selectX: -48,
selectY: -48,
selectDX: 96,
selectDY: 96,
}
const playerStart = subTileToScene(20, 20, grid)
const warpAnchor = subTileToScene(warp.x, warp.y, grid)
const enteredWarps: SceneWarp[] = []
const scene = createTestScene({
playerPos: { ...playerStart },
warps: [warp],
onEnterWarp: (w) => enteredWarps.push(w),
})
// Build a concave U-shaped dungeon wall between (20, 20) and (20, 32):
// Horizontal bar at y = 26, x = 16..24, plus side walls extending up toward player at x = 16 and x = 24, y = 22..26
const gridW = scene.runtime.grid.gridWidth
const blocked = scene.runtime.grid.blocked as Uint8Array
for (let x = 16; x <= 24; x++) {
blocked[26 * gridW + x] = 1
}
for (let y = 22; y <= 26; y++) {
blocked[y * gridW + 16] = 1
blocked[y * gridW + 24] = 1
}
// Left-click the distant warp behind the concave wall
const clientX = 960 + (warpAnchor.x - playerStart.x)
const clientY = 540 + (warpAnchor.y - (playerStart.y - 16))
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX,
clientY,
button: 0,
}))
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', {
clientX,
clientY,
button: 0,
}))
expect(scene.controller.pendingInteraction?.kind).toBe('warp')
expect(enteredWarps).toHaveLength(0)
// Step simulation ticks: player must route out of the concave pocket, around x < 16 or x > 24, and arrive at (20, 32)
let detouredOutsideWall = false
for (let stepIdx = 0; stepIdx < 160 && enteredWarps.length === 0; stepIdx++) {
const { movement } = scene.controller.tick()
if (enteredWarps.length > 0) break
expect(movement.x !== 0 || movement.y !== 0).toBe(true)
scene.engine.world.player.x += movement.x * 5
scene.engine.world.player.y += movement.y * 5
const sub = sceneToSubTile(scene.engine.world.player.x, scene.engine.world.player.y, grid)
expect(blocked[sub.y * gridW + sub.x]).toBe(0)
if (sub.x < 16 || sub.x > 24) {
detouredOutsideWall = true
}
}
expect(detouredOutsideWall).toBe(true)
expect(enteredWarps).toEqual([warp])
expect(scene.controller.navTarget).toBeNull()
expect(scene.controller.pendingInteraction).toBeNull()
})
it('hit-tests and activates overhead destination badges across their full rectangular footprint', () => {
const grid = { originX: 0, originY: 0 }
const warp: SceneWarp = {
source: 'tile',
direction: 'down',
label: 'Den of Evil',
toLevelId: 8,
warpId: 4,
x: 30,
y: 30,
arriveX: 31,
arriveY: 30,
exitWalkX: 31,
exitWalkY: 30,
selectX: -50,
selectY: -60,
selectDX: 100,
selectDY: 80,
}
const playerPos = subTileToScene(31, 30, grid)
const labelPos = getWarpLabelWorldPos(warp, grid)
const enteredWarps: SceneWarp[] = []
const scene = createTestScene({
playerPos: { ...playerPos },
warps: [warp],
onEnterWarp: (w) => enteredWarps.push(w),
})
// Point near the top-right of the rendered overhead badge:
// +58px right and -18px above labelPos (Euclidean distance = 60.73px > 56px radial threshold, and above selectY box)
const badgeClickX = labelPos.x + 58
const badgeClickY = labelPos.y - 18
const clientX = 960 + (badgeClickX - playerPos.x)
const clientY = 540 + (badgeClickY - (playerPos.y - 16))
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', {
clientX,
clientY,
}))
expect(scene.controller.hoveredWarp).toBe(warp)
expect(scene.canvas.style.cursor).toBe('pointer')
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX,
clientY,
button: 0,
}))
expect(enteredWarps).toEqual([warp])
})
it('prioritizes monsters and ground items over SceneWarp when overlapping inside a LvlWarp.txt box for both hover and click', () => {
const grid = { originX: 0, originY: 0 }
const warp: SceneWarp = {
source: 'tile',
direction: 'down',
label: 'Catacombs Level 1',
toLevelId: 34,
warpId: 17,
x: 35,
y: 35,
arriveX: 36,
arriveY: 35,
exitWalkX: 36,
exitWalkY: 35,
selectX: -120,
selectY: -100,
selectDX: 180,
selectDY: 140,
}
const anchorPos = subTileToScene(warp.x, warp.y, grid)
const playerPos = subTileToScene(36, 35, grid)
const monsterPos = { x: anchorPos.x - 20, y: anchorPos.y - 10 }
const enteredWarps: SceneWarp[] = []
const children: HTMLDivElement[] = []
const mockLabelsContainer = {
children,
appendChild(el: HTMLDivElement) {
children.push(el)
return el
},
} as unknown as HTMLElement
const scene = createTestScene({
playerPos: { ...playerPos },
leftSkillId: 0,
monsters: [
{ id: 'mon-overlap', name: 'Fallen', x: monsterPos.x, y: monsterPos.y, hp: 40 },
],
warps: [warp],
onEnterWarp: (w) => enteredWarps.push(w),
labelsContainer: mockLabelsContainer,
})
scene.engine.world.monsters.length = 1
// 1. Hovering over the living monster inside the warp's LvlWarp.txt box must NOT highlight the warp badge
const monClientX = 960 + (monsterPos.x - playerPos.x)
const monClientY = 540 + (monsterPos.y - (playerPos.y - 16))
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', {
clientX: monClientX,
clientY: monClientY,
}))
expect(scene.controller.hoveredWarp).toBeNull()
expect(children[0]!.dataset.hovered).toBe('false')
// 2. Left-clicking the living monster attacks the monster instead of triggering the warp
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: monClientX,
clientY: monClientY,
button: 0,
}))
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', {
clientX: monClientX,
clientY: monClientY,
button: 0,
}))
expect(enteredWarps).toHaveLength(0)
expect(scene.engine.world.monsters[0]!.hp).toBeLessThan(40)
// 3. Place a ground item at that spot and verify GroundItem > SceneWarp
scene.engine.world.monsters[0]!.hp = 0
scene.engine.world.monsters[0]!.state = 'dead'
scene.engine.groundItems.clear()
const groundItem = scene.engine.groundItems.add(
{ isGold: true, code: 'gld', name: 'Gold' },
0,
0,
monsterPos.x,
monsterPos.y,
{ amount: 25 },
)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', {
clientX: monClientX,
clientY: monClientY,
}))
expect(scene.controller.hoveredGroundItem?.id).toBe(groundItem.id)
expect(scene.controller.hoveredWarp).toBeNull()
expect(children[0]!.dataset.hovered).toBe('false')
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: monClientX,
clientY: monClientY,
button: 0,
}))
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', {
clientX: monClientX,
clientY: monClientY,
button: 0,
}))
expect(enteredWarps).toHaveLength(0)
expect(scene.engine.groundItems.count).toBe(0)
// 4. Once both the monster is dead and the ground item is picked up, hovering and clicking that exact spot inside the LvlWarp.txt box highlights and activates the warp
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', {
clientX: monClientX,
clientY: monClientY,
}))
expect(scene.controller.hoveredWarp).toBe(warp)
expect(children[0]!.dataset.hovered).toBe('true')
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: monClientX,
clientY: monClientY,
button: 0,
}))
expect(enteredWarps).toEqual([warp])
})
it('maintains exact overhead badge screen alignment across non-unit camera zooms, camera scrolling, and clearTarget()', () => {
const grid = { originX: 0, originY: 0 }
const warp: SceneWarp = {
source: 'tile',
direction: 'down',
label: 'Den of Evil',
toLevelId: 8,
warpId: 4,
x: 30,
y: 30,
arriveX: 31,
arriveY: 30,
exitWalkX: 31,
exitWalkY: 30,
selectX: -60,
selectY: -60,
selectDX: 120,
selectDY: 90,
}
const entrance: SceneEntrance = {
toLevelId: 2,
side: 'east',
label: 'Blood Moor',
x: 36,
y: 28,
arriveX: 38,
arriveY: 28,
}
const playerPos = subTileToScene(32, 30, grid)
const warpLabelWorld = getWarpLabelWorldPos(warp, grid)
const entranceLabelWorld = getEntranceLabelWorldPos(entrance, grid)
const children: HTMLDivElement[] = []
const mockLabelsContainer = {
children,
appendChild(el: HTMLDivElement) {
children.push(el)
return el
},
} as unknown as HTMLElement
const scene = createTestScene({
playerPos: { ...playerPos },
warps: [warp],
entrances: [entrance],
labelsContainer: mockLabelsContainer,
})
for (const zoom of [1.5, 0.75]) {
scene.camera.zoom = zoom
scene.controller.updateOverheadLabels(playerPos.x, playerPos.y - 16)
const warpBadge = children[0]!
const entranceBadge = children[1]!
const warpDomX = parseFloat(warpBadge.style.left)
const warpDomY = parseFloat(warpBadge.style.top) + 2
const entranceDomX = parseFloat(entranceBadge.style.left)
const entranceDomY = parseFloat(entranceBadge.style.top) + 2
const invWarp = screenToWorld({
clientX: warpDomX,
clientY: warpDomY,
rect: scene.canvas.getBoundingClientRect(),
canvas: scene.canvas,
camX: playerPos.x,
camY: playerPos.y - 16,
cameraZoom: zoom,
})
const invEntrance = screenToWorld({
clientX: entranceDomX,
clientY: entranceDomY,
rect: scene.canvas.getBoundingClientRect(),
canvas: scene.canvas,
camX: playerPos.x,
camY: playerPos.y - 16,
cameraZoom: zoom,
})
expect(Math.hypot(invWarp.x - warpLabelWorld.x, invWarp.y - warpLabelWorld.y)).toBeLessThan(1e-6)
expect(Math.hypot(invEntrance.x - entranceLabelWorld.x, invEntrance.y - entranceLabelWorld.y)).toBeLessThan(1e-6)
// Hover the projected badge position at this zoom
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', {
clientX: warpDomX,
clientY: warpDomY,
}))
expect(scene.controller.hoveredWarp).toBe(warp)
expect(warpBadge.dataset.hovered).toBe('true')
// If the camera scrolls away while the pointer stays stationary, updateOverheadLabels clears hover
scene.controller.updateOverheadLabels(playerPos.x + 600, playerPos.y + 600)
expect(scene.controller.hoveredWarp).toBeNull()
expect(warpBadge.dataset.hovered).toBe('false')
// Re-hover at original camera position, then verify clearTarget() immediately clears badge hover
scene.controller.updateOverheadLabels(playerPos.x, playerPos.y - 16)
expect(scene.controller.hoveredWarp).toBe(warp)
expect(warpBadge.dataset.hovered).toBe('true')
scene.controller.clearTarget()
expect(scene.controller.hoveredWarp).toBeNull()
expect(warpBadge.dataset.hovered).toBe('false')
expect(warpBadge.className).not.toContain('is-hovered')
}
})
it('verifies all 365 baked map packs (495 SceneWarp and 166 SceneEntrance entries) resolve valid Chinese destination badges, hitboxes, and arrival triggers', () => {
const baseDir = path.resolve(process.cwd(), 'samples/d2-packs')
const indexPath = path.join(baseDir, 'index.json')
const index = JSON.parse(fs.readFileSync(indexPath, 'utf8'))
const packs: Array<{ path: string }> = index.levels ?? []
expect(packs.length).toBe(365)
let warpCount = 0
let nonZeroBoxWarps = 0
let zeroBoxWarps = 0
let entranceCount = 0
for (const entry of packs) {
const pack = JSON.parse(fs.readFileSync(path.join(baseDir, entry.path, 'scene.json'), 'utf8'))
const cellsX: number = pack.cellsX ?? 40
const cellsY: number = pack.cellsY ?? 40
const grid = {
originX: Math.ceil((cellsX + cellsY) * 80 * 0.5),
originY: 64,
}
for (const warp of (pack.warps ?? []) as SceneWarp[]) {
warpCount += 1
if (warp.selectDX > 0 && warp.selectDY > 0) nonZeroBoxWarps += 1
else zeroBoxWarps += 1
expect(warp.toLevelId).toBeGreaterThan(0)
const nameZh = getLevelNames(warp.toLevelId).nameZh
expect(nameZh.length).toBeGreaterThan(0)
const labelPos = getWarpLabelWorldPos(warp, grid)
expect(Number.isFinite(labelPos.x)).toBe(true)
expect(Number.isFinite(labelPos.y)).toBe(true)
const hitLabel = hitTestWarp(warp, labelPos.x, labelPos.y - 12, grid)
expect(hitLabel.hit).toBe(true)
const hitArrive = hitTestWarp(warp, hitLabel.arrivePos.x, hitLabel.arrivePos.y, grid)
expect(hitArrive.hit).toBe(true)
expect(isPlayerWithinLinkRange(hitLabel.arrivePos.x, hitLabel.arrivePos.y, warp, grid)).toBe(true)
}
for (const entrance of (pack.entrances ?? []) as SceneEntrance[]) {
entranceCount += 1
expect(entrance.toLevelId).toBeGreaterThan(0)
const nameZh = getLevelNames(entrance.toLevelId).nameZh
expect(nameZh.length).toBeGreaterThan(0)
const labelPos = getEntranceLabelWorldPos(entrance, grid)
expect(Number.isFinite(labelPos.x)).toBe(true)
expect(Number.isFinite(labelPos.y)).toBe(true)
const hitLabel = hitTestEntrance(entrance, labelPos.x, labelPos.y - 12, grid)
expect(hitLabel.hit).toBe(true)
const hitArrive = hitTestEntrance(entrance, hitLabel.arrivePos.x, hitLabel.arrivePos.y, grid)
expect(hitArrive.hit).toBe(true)
expect(isPlayerWithinLinkRange(hitLabel.arrivePos.x, hitLabel.arrivePos.y, entrance, grid)).toBe(true)
}
}
expect(warpCount).toBe(495)
expect(nonZeroBoxWarps).toBe(464)
expect(zeroBoxWarps).toBe(31)
// 166 since the Act I outdoor levels come from the DRLG port: D2 has no
// Stony Field <-> Dark Wood seam (DrlgOutPlace.cpp gAct1WildernessDrlgLink /
// gAct1MonasteryDrlgLink put them in separate groups, joined only through
// the Underground Passage), so the 2 seam entrances the old generator
// invented in each of the 3 act copies are gone.
expect(entranceCount).toBe(166)
})
it('ignores unresolved warps and entrances (toLevelId <= 0) for overhead badges, hover, left-click, and Telekinesis', () => {
const grid = { originX: 0, originY: 0 }
const unresolvedWarp: SceneWarp = {
source: 'tile',
direction: 'down',
label: 'Unresolved Warp',
toLevelId: -1,
warpId: 4,
x: 30,
y: 30,
arriveX: 31,
arriveY: 30,
exitWalkX: 31,
exitWalkY: 30,
selectX: -60,
selectY: -60,
selectDX: 120,
selectDY: 90,
}
const unresolvedEntrance: SceneEntrance = {
toLevelId: -1,
side: 'east',
label: 'Unresolved Seam',
x: 34,
y: 30,
arriveX: 33,
arriveY: 30,
}
const playerPos = subTileToScene(31, 30, grid)
const warpAnchor = subTileToScene(unresolvedWarp.x, unresolvedWarp.y, grid)
const enteredWarps: SceneWarp[] = []
const enteredEntrances: SceneEntrance[] = []
expect(hitTestWarp(unresolvedWarp, warpAnchor.x, warpAnchor.y, grid).hit).toBe(false)
const entranceAnchor = subTileToScene(unresolvedEntrance.x, unresolvedEntrance.y, grid)
expect(hitTestEntrance(unresolvedEntrance, entranceAnchor.x, entranceAnchor.y, grid).hit).toBe(false)
const children: HTMLDivElement[] = []
const mockLabelsContainer = {
children,
appendChild(el: HTMLDivElement) {
children.push(el)
return el
},
} as unknown as HTMLElement
const scene = createTestScene({
playerPos: { ...playerPos },
rightSkillId: 43,
warps: [unresolvedWarp],
entrances: [unresolvedEntrance],
onEnterWarp: (w) => enteredWarps.push(w),
onEnterEntrance: (e) => enteredEntrances.push(e),
labelsContainer: mockLabelsContainer,
})
// No overhead badges should be rendered for unresolved links
expect(children.filter(el => !el.hidden)).toHaveLength(0)
const clientX = 960 + (warpAnchor.x - playerPos.x)
const clientY = 540 + (warpAnchor.y - (playerPos.y - 16))
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', {
clientX,
clientY,
}))
expect(scene.controller.hoveredWarp).toBeNull()
expect(scene.controller.hoveredEntrance).toBeNull()
expect(scene.canvas.style.cursor).toBe('')
// Left-click should walk on ground rather than activating unresolved warp
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX,
clientY,
button: 0,
}))
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', {
clientX,
clientY,
button: 0,
}))
expect(enteredWarps).toHaveLength(0)
expect(enteredEntrances).toHaveLength(0)
// Right-click Telekinesis should not trigger unresolved warp
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX,
clientY,
button: 2,
}))
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', {
clientX,
clientY,
button: 2,
}))
expect(enteredWarps).toHaveLength(0)
})
it('strictly enforces TELEKINESIS_RANGE_PX against warp anchorPos/arrivePos and does not spam Telekinesis while right mouse is held', () => {
const grid = { originX: 0, originY: 0 }
// Warp with a wide selection box: anchor and arrive are > 320px away from player,
// but the left edge of the selection box is < 320px from player.
const wideBoxWarp: SceneWarp = {
source: 'tile',
direction: 'down',
label: 'Harem Level 1',
toLevelId: 50,
warpId: 23,
x: 50,
y: 30,
arriveX: 52,
arriveY: 30,
exitWalkX: 52,
exitWalkY: 30,
selectX: -140,
selectY: -80,
selectDX: 200,
selectDY: 140,
}
const anchorPos = subTileToScene(wideBoxWarp.x, wideBoxWarp.y, grid)
const arrivePos = subTileToScene(wideBoxWarp.arriveX, wideBoxWarp.arriveY, grid)
// Place player 380px left of anchorPos (> 320px), so anchorPos-120px is 260px from player (<= 320px)
const playerPos = { x: anchorPos.x - 380, y: anchorPos.y }
expect(Math.hypot(anchorPos.x - playerPos.x, anchorPos.y - playerPos.y)).toBeGreaterThan(TELEKINESIS_RANGE_PX)
expect(Math.hypot(arrivePos.x - playerPos.x, arrivePos.y - playerPos.y)).toBeGreaterThan(TELEKINESIS_RANGE_PX)
const enteredWarps: SceneWarp[] = []
const scene = createTestScene({
playerPos: { ...playerPos },
rightSkillId: 43,
warps: [wideBoxWarp],
onEnterWarp: (w) => enteredWarps.push(w),
})
const nearCornerWorldX = anchorPos.x - 120
const nearCornerWorldY = anchorPos.y
expect(Math.hypot(nearCornerWorldX - playerPos.x, nearCornerWorldY - playerPos.y)).toBeLessThanOrEqual(TELEKINESIS_RANGE_PX)
const startMana = scene.hudManager.mana
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 960 + (nearCornerWorldX - playerPos.x),
clientY: 540 + (nearCornerWorldY - (playerPos.y - 16)),
button: 2,
}))
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', {
clientX: 960 + (nearCornerWorldX - playerPos.x),
clientY: 540 + (nearCornerWorldY - (playerPos.y - 16)),
button: 2,
}))
// Must reject activation and refund mana because warp anchor & arrive are > 320px away
expect(enteredWarps).toHaveLength(0)
expect(scene.hudManager.mana).toBe(startMana)
expect(scene.status.textContent).toBe('目标超出心灵传动范围!')
// Move player within range (200px left of anchorPos) and hold right mouse down across 5 ticks
scene.engine.world.player.x = anchorPos.x - 200
scene.engine.world.player.y = anchorPos.y
const inRangeClientX = 960 + 200
const inRangeClientY = 540 - 16
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: inRangeClientX,
clientY: inRangeClientY,
button: 2,
buttons: 2,
}))
expect(enteredWarps).toHaveLength(1)
const manaAfterSingleCast = scene.hudManager.mana
expect(manaAfterSingleCast).toBeLessThan(startMana)
// Holding right button across subsequent ticks must NOT re-cast Telekinesis 25 times/sec
for (let i = 0; i < 5; i++) {
scene.controller.tick()
}
expect(enteredWarps).toHaveLength(1)
expect(scene.hudManager.mana).toBe(manaAfterSingleCast)
})
it('handles non-unit collision bitmask bytes (e.g. 0x21) and routes around walls when target is inside a 3x3 blocked footprint', () => {
const gridW = 100
const blocked = new Uint8Array(gridW * gridW)
const grid = {
originX: 0,
originY: 0,
cellsX: 20,
cellsY: 20,
gridWidth: gridW,
blocked,
}
// Place a horizontal wall at y = 25, x = 15..25 using non-unit collision bitmask 0x21 (COLLIDE_BLANK | COLLIDE_WALL)
for (let x = 15; x <= 25; x++) {
blocked[25 * gridW + x] = 0x21
}
// Enclose the target (20, 30) in a 3x3 blocked object footprint (e.g. stash/chest/monster)
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
blocked[(30 + dy) * gridW + (20 + dx)] = 0x04
}
}
const startPos = subTileToScene(20, 20, grid)
const targetPos = subTileToScene(20, 30, grid)
const steer = resolveObstacleAwareSteerTarget(startPos.x, startPos.y, targetPos, grid)
// Direct line from (20,20) to (20,30) crosses the 0x21 wall at y=25;
// A* must route around the wall rather than returning targetPos directly.
expect(steer).not.toEqual(targetPos)
const steerSub = sceneToSubTile(steer.x, steer.y, grid)
expect(blocked[steerSub.y * gridW + steerSub.x]).toBe(0)
})
it('expands badge hit-testing width for long Chinese destination names and cleans up state on detach()', () => {
const grid = { originX: 0, originY: 0 }
// Level 66: 塔·拉夏古墓 (1) -> badgeText = '通往 塔·拉夏古墓 (1)' (13 chars, half-width = 99px > 64px)
const tombWarp: SceneWarp = {
source: 'tile',
direction: 'down',
label: 'Tal Rasha Tomb',
toLevelId: 66,
warpId: 49,
x: 30,
y: 30,
arriveX: 31,
arriveY: 30,
exitWalkX: 31,
exitWalkY: 30,
selectX: -40,
selectY: -40,
selectDX: 80,
selectDY: 80,
}
const labelPos = getWarpLabelWorldPos(tombWarp, grid)
// Click 85px to the right of badge center (> 64px default half-width, within 13-char badge half-width 99px)
const wideClick = hitTestWarp(tombWarp, labelPos.x + 85, labelPos.y - 12, grid)
expect(wideClick.hit).toBe(true)
const playerPos = subTileToScene(31, 30, grid)
const children: HTMLDivElement[] = []
const mockLabelsContainer = {
children,
appendChild(el: HTMLDivElement) {
children.push(el)
return el
},
} as unknown as HTMLElement
const scene = createTestScene({
playerPos: { ...playerPos },
warps: [tombWarp],
labelsContainer: mockLabelsContainer,
})
// Hover over the wide badge edge
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', {
clientX: 960 + ((labelPos.x + 85) - playerPos.x),
clientY: 540 + ((labelPos.y - 12) - (playerPos.y - 16)),
}))
expect(scene.controller.hoveredWarp).toBe(tombWarp)
expect(scene.canvas.style.cursor).toBe('pointer')
expect(children[0]!.hidden).toBe(false)
// Detach must clear hover, reset cursor, and hide pooled labels
scene.controller.detach()
expect(scene.controller.hoveredWarp).toBeNull()
expect(scene.canvas.style.cursor).toBe('')
expect(children[0]!.hidden).toBe(true)
})
it('resolves multi-warp and warp-vs-entrance overlaps by true Euclidean distance and prioritizes foreground overhead badges over background LvlWarp.txt boxes', () => {
const grid = { originX: 0, originY: 0 }
// Warp A has a huge LvlWarp.txt selection box centered at (30, 30)
const warpA: SceneWarp = {
source: 'tile',
direction: 'down',
label: 'Harem Level 1',
toLevelId: 50,
warpId: 23,
x: 30,
y: 30,
arriveX: 31,
arriveY: 30,
exitWalkX: 31,
exitWalkY: 30,
selectX: -120,
selectY: -120,
selectDX: 240,
selectDY: 240,
}
// Warp B is a zero-box warp at (33, 30) (48px right of Warp A)
const warpB: SceneWarp = {
source: 'tile',
direction: 'up',
label: 'Lut Gholein',
toLevelId: 40,
warpId: 22,
x: 33,
y: 30,
arriveX: 34,
arriveY: 30,
exitWalkX: 34,
exitWalkY: 30,
selectX: 0,
selectY: 0,
selectDX: 0,
selectDY: 0,
}
// Entrance C is at (27, 30) (48px left of Warp A)
const entranceC: SceneEntrance = {
toLevelId: 41,
side: 'west',
label: 'Rocky Waste',
x: 27,
y: 30,
arriveX: 28,
arriveY: 30,
}
const posB = subTileToScene(warpB.x, warpB.y, grid)
const posC = subTileToScene(entranceC.x, entranceC.y, grid)
// Clicking 16px from Warp B's anchor (still inside Warp A's [-120..120] box, 32px from A's center)
// must select Warp B because 16px < 32px.
const hitAatB = hitTestWarp(warpA, posB.x - 16, posB.y, grid)
const hitBatB = hitTestWarp(warpB, posB.x - 16, posB.y, grid)
expect(hitAatB.hit).toBe(true)
expect(hitBatB.hit).toBe(true)
expect(hitBatB.dist).toBeLessThan(hitAatB.dist)
// Clicking 16px from Entrance C's anchor (inside Warp A's box, 32px from A's center)
// must select Entrance C because 16px < 32px.
const hitAatC = hitTestWarp(warpA, posC.x + 16, posC.y, grid)
const hitCatC = hitTestEntrance(entranceC, posC.x + 16, posC.y, grid)
expect(hitAatC.hit).toBe(true)
expect(hitCatC.hit).toBe(true)
expect(hitCatC.dist).toBeLessThan(hitAatC.dist)
// Clicking directly on Warp B's overhead badge (which overlaps Warp A's LvlWarp.txt box)
// must prioritize the foreground overhead badge of Warp B over Warp A's background box.
const badgeB = getWarpLabelWorldPos(warpB, grid)
const hitAonBadgeB = hitTestWarp(warpA, badgeB.x + 20, badgeB.y - 12, grid)
const hitBonBadgeB = hitTestWarp(warpB, badgeB.x + 20, badgeB.y - 12, grid)
expect(hitAonBadgeB.hit).toBe(true)
expect(hitBonBadgeB.hit).toBe(true)
expect(hitBonBadgeB.dist).toBeLessThan(hitAonBadgeB.dist)
})
it('prioritizes an open Town Portal over an overlapping SceneWarp for hover, left-click, and Telekinesis across the full [portalPos.y - 80, portalPos.y] vertical oval', () => {
const grid = { originX: 0, originY: 0 }
const warp: SceneWarp = {
source: 'tile',
direction: 'down',
label: 'Den of Evil',
toLevelId: 8,
warpId: 4,
x: 30,
y: 30,
arriveX: 31,
arriveY: 30,
exitWalkX: 31,
exitWalkY: 30,
selectX: -100,
selectY: -120,
selectDX: 200,
selectDY: 160,
}
const portalPos = subTileToScene(30, 30, grid)
const playerPos = subTileToScene(31, 30, grid)
let enteredPortalCount = 0
const enteredWarps: SceneWarp[] = []
const scene = createTestScene({
playerPos: { ...playerPos },
rightSkillId: 43, // Telekinesis
warps: [warp],
portalSlot: {
mouthInLevel: () => ({ x: 30, y: 30 }),
},
onEnterPortal: () => {
enteredPortalCount += 1
},
onEnterWarp: (w) => enteredWarps.push(w),
})
// Upper part of the Town Portal oval: (portalPos.x + 24, portalPos.y - 75)
// Within 48px of [portalPos.y - 80, portalPos.y], and also inside warp's LvlWarp.txt box.
const targetWorldX = portalPos.x + 24
const targetWorldY = portalPos.y - 75
const clientX = 960 + (targetWorldX - playerPos.x)
const clientY = 540 + (targetWorldY - (playerPos.y - 16))
// 1. Hovering the portal oval suppresses hoveredWarp
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', {
clientX,
clientY,
}))
expect(scene.controller.hoveredWarp).toBeNull()
// 2. Left-clicking the portal oval enters the Town Portal instead of the SceneWarp
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX,
clientY,
button: 0,
}))
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', {
clientX,
clientY,
button: 0,
}))
expect(enteredPortalCount).toBe(1)
expect(enteredWarps).toHaveLength(0)
// 3. Right-clicking with Telekinesis (Skill 43) near the top of the portal oval (targetY = portalPos.y - 95,
// distance to clampedPortalY (portalPos.y - 80) ishypot(30, -15) = 33.5px <= 64px, whereas distance to
// portalPos.y - 40 is hypot(30, -55) = 62.6px and at (40, -95) is hypot(40, -15) = 42.7px <= 64px vs hypot(40, -55) = 68.0px > 64px)
// enters the Town Portal rather than the underlying SceneWarp.
const tkWorldX = portalPos.x + 40
const tkWorldY = portalPos.y - 95
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 960 + (tkWorldX - playerPos.x),
clientY: 540 + (tkWorldY - (playerPos.y - 16)),
button: 2,
}))
expect(enteredPortalCount).toBe(2)
expect(enteredWarps).toHaveLength(0)
})
it('clears isLeftMouseDown and activeClickType on warp/entrance/portal trigger and suppresses pointer/tick actions while isTravelling() is true', () => {
const grid = { originX: 0, originY: 0 }
const warp: SceneWarp = {
source: 'tile',
direction: 'down',
label: 'Den of Evil',
toLevelId: 8,
warpId: 4,
x: 30,
y: 30,
arriveX: 31,
arriveY: 30,
exitWalkX: 31,
exitWalkY: 30,
selectX: -48,
selectY: -48,
selectDX: 96,
selectDY: 96,
}
const anchorPos = subTileToScene(warp.x, warp.y, grid)
const arrivePos = subTileToScene(warp.arriveX, warp.arriveY, grid)
const distantPlayer = subTileToScene(45, 30, grid)
let travelling = false
const enteredWarps: SceneWarp[] = []
const scene = createTestScene({
playerPos: { ...distantPlayer },
warps: [warp],
isTravelling: () => travelling,
onEnterWarp: (w) => {
enteredWarps.push(w)
travelling = true
},
})
// Hold left mouse button down on distant warp (no pointerup)
const clientX = 960 + (anchorPos.x - distantPlayer.x)
const clientY = 540 + (anchorPos.y - (distantPlayer.y - 16))
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX,
clientY,
button: 0,
buttons: 1,
}))
expect(scene.controller.isLeftMouseDown).toBe(true)
expect(scene.controller.activeClickType).toBe('warp')
expect(scene.controller.pendingInteraction?.kind).toBe('warp')
// Arrive at warp while still holding left mouse button down
scene.engine.world.player.x = arrivePos.x
scene.engine.world.player.y = arrivePos.y
const arriveTick = scene.controller.tick()
expect(enteredWarps).toEqual([warp])
expect(arriveTick.movement).toEqual({ x: 0, y: 0 })
// Left mouse hold and activeClickType must be cleared immediately upon transition trigger
expect(scene.controller.isLeftMouseDown).toBe(false)
expect(scene.controller.activeClickType).toBe('none')
// While travelling is true, new pointerdown events and tick() calls must be suppressed
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX,
clientY,
button: 0,
buttons: 1,
}))
expect(scene.controller.isLeftMouseDown).toBe(false)
expect(enteredWarps).toHaveLength(1)
expect(scene.controller.tick()).toEqual({ movement: { x: 0, y: 0 }, attacking: false })
})
})