2317 lines
78 KiB
TypeScript
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 })
|
|
})
|
|
})
|
|
|
|
|