976 lines
31 KiB
TypeScript
976 lines
31 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 { 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 {
|
|
SceneMouseController,
|
|
screenToWorld,
|
|
facingOf,
|
|
PASSIVE_SKILL_IDS,
|
|
isPassiveSkill,
|
|
getSkillRange,
|
|
getSkillMana,
|
|
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' }
|
|
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 }>
|
|
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: [],
|
|
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,
|
|
})
|
|
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()
|
|
})
|
|
})
|