852 lines
26 KiB
TypeScript
852 lines
26 KiB
TypeScript
/**
|
||
* Input -> ClientCommand Mapper (Milestone M5 — F17)
|
||
*
|
||
* Translates browser mouse/pointer and keyboard events across 800×600 and 1024×768
|
||
* viewports into rate-limited `ClientCommand`s and local movement predictions:
|
||
* - Screen-to-world subtile conversion via `screenToSubTile` (isometric 2:1 projection).
|
||
* - HUD hit-test gating via `HudManager.isPointInterceptedByHud(x, y)` so clicks on
|
||
* status bar, orbs, belt, skill popups, or open panels never trigger ground movement.
|
||
* - Ground movement: `findPathWorld` -> `LocalMovementPredictor` +
|
||
* rate-limited `Move` (`0x01` walk / `0x03` run) via `OutboundRateLimiter`.
|
||
* - Unit interaction: `MoveToUnit` (`0x02`/`0x04`), `Interact` (`0x13`), `ItemPickup` (`0x16`).
|
||
* - Skill casting: `SkillAt` (`0x05` left / `0x08` right), `SkillOn` (`0x06` left / `0x0c` right).
|
||
* - Hotkeys: `1..4` belt use (`0x26`), `F1..F8` skill select (`0x3c`), `W` weapon swap (`0x60`),
|
||
* `R` run/walk toggle, `Tab` automap toggle, `C`/`I`/`T`/`Q`/`P`/`M` panel toggles, `Alt` item labels.
|
||
*/
|
||
|
||
import {
|
||
UNIT_TYPE_TO_ID,
|
||
type ClientCommand,
|
||
type Hand,
|
||
type UnitRef,
|
||
} from '../../netproto/index.ts'
|
||
import type { HudManager } from '../ui/hud-manager.ts'
|
||
import type { AutomapView } from '../automap/automap-view.ts'
|
||
import type { MapService } from '../map/map-service.ts'
|
||
import { findPathWorld, type SubTilePoint } from '../motion/pathfind.ts'
|
||
import type { LocalMovementPredictor } from '../motion/predictor.ts'
|
||
import { OutboundRateLimiter, type RateLimitAcquireResult } from '../session/rate-limiter.ts'
|
||
import type { HudModel } from '../ui-model/hud-model.ts'
|
||
import {
|
||
VIEWPORT_800x600,
|
||
getViewportProfile,
|
||
screenToSubTile,
|
||
subTileToScreen,
|
||
type ViewportProfile,
|
||
type ViewportResolution,
|
||
} from '../view/viewport-profile.ts'
|
||
import type { ClientUnit } from '../world/client-unit.ts'
|
||
import type { ClientWorld } from '../world/client-world.ts'
|
||
|
||
export interface CameraLike {
|
||
readonly subX: number
|
||
readonly subY: number
|
||
readonly viewportProfile: ViewportProfile
|
||
screenToSubtile(screenX: number, screenY: number): { readonly x: number; readonly y: number }
|
||
subtileToScreen(subX: number, subY: number): { readonly x: number; readonly y: number }
|
||
snapTo(subX: number, subY: number): void
|
||
update(subX: number, subY: number): void
|
||
resize(widthOrProfile: number | ViewportProfile | ViewportResolution, height?: number): void
|
||
}
|
||
|
||
/**
|
||
* Isometric sub-tile camera tracking the player center across `800×600` and `1024×768` viewports.
|
||
*/
|
||
export class ViewportCamera implements CameraLike {
|
||
subX = 0
|
||
subY = 0
|
||
viewportProfile: ViewportProfile
|
||
|
||
constructor(
|
||
widthOrProfile: number | ViewportProfile | ViewportResolution = VIEWPORT_800x600,
|
||
height?: number,
|
||
) {
|
||
if (typeof widthOrProfile === 'number') {
|
||
this.viewportProfile = getViewportProfile(
|
||
widthOrProfile === 1024 || height === 768 ? '1024x768' : '800x600',
|
||
)
|
||
} else {
|
||
this.viewportProfile = getViewportProfile(widthOrProfile)
|
||
}
|
||
}
|
||
|
||
screenToSubtile(screenX: number, screenY: number): { readonly x: number; readonly y: number } {
|
||
const raw = screenToSubTile(screenX, screenY, this.subX, this.subY, this.viewportProfile)
|
||
return {
|
||
x: Math.round(raw.x),
|
||
y: Math.round(raw.y),
|
||
}
|
||
}
|
||
|
||
subtileToScreen(subX: number, subY: number): { readonly x: number; readonly y: number } {
|
||
return subTileToScreen(subX, subY, this.subX, this.subY, this.viewportProfile)
|
||
}
|
||
|
||
snapTo(subX: number, subY: number): void {
|
||
this.subX = subX
|
||
this.subY = subY
|
||
}
|
||
|
||
update(subX: number, subY: number): void {
|
||
this.subX = subX
|
||
this.subY = subY
|
||
}
|
||
|
||
resize(widthOrProfile: number | ViewportProfile | ViewportResolution, height?: number): void {
|
||
if (typeof widthOrProfile === 'number') {
|
||
this.viewportProfile = getViewportProfile(
|
||
widthOrProfile === 1024 || height === 768 ? '1024x768' : '800x600',
|
||
)
|
||
} else {
|
||
this.viewportProfile = getViewportProfile(widthOrProfile)
|
||
}
|
||
}
|
||
}
|
||
|
||
export interface SkillHotkeyBinding {
|
||
readonly skillId: number
|
||
readonly hand: Hand
|
||
readonly itemId?: number | undefined
|
||
}
|
||
|
||
export interface CommandMapperDeps {
|
||
readonly world: ClientWorld
|
||
readonly camera?: CameraLike | undefined
|
||
readonly hudManager: HudManager
|
||
readonly hudModel?: HudModel | undefined
|
||
readonly predictor?: LocalMovementPredictor | undefined
|
||
readonly mapService?: MapService | undefined
|
||
readonly automapView?: AutomapView | undefined
|
||
readonly rateLimiter?: OutboundRateLimiter | undefined
|
||
readonly sendCommand: (cmd: ClientCommand) => void
|
||
/** Optional hook to check if an external UI overlay (e.g. top toolbar / chat input) intercepted the point. */
|
||
readonly isExternalUiIntercepted?: ((screenX: number, screenY: number) => boolean) | undefined
|
||
/** Optional callback when Enter key toggles chat input. */
|
||
readonly onToggleChatInput?: (() => void) | undefined
|
||
/** Optional callback when Escape is pressed with no open HUD panels. */
|
||
readonly onEscapeMenu?: (() => void) | undefined
|
||
}
|
||
|
||
export interface PointerWorldTarget {
|
||
readonly subtileX: number
|
||
readonly subtileY: number
|
||
readonly hoveredUnit: ClientUnit | null
|
||
}
|
||
|
||
export interface DispatchedCommandRecord {
|
||
readonly command: ClientCommand
|
||
readonly rateLimit: RateLimitAcquireResult
|
||
readonly path: readonly SubTilePoint[]
|
||
}
|
||
|
||
function unitToRef(unit: ClientUnit): UnitRef {
|
||
return {
|
||
unitType: UNIT_TYPE_TO_ID[unit.unitType],
|
||
id: unit.id,
|
||
}
|
||
}
|
||
|
||
export class CommandMapper {
|
||
private readonly world: ClientWorld
|
||
private readonly camera: CameraLike
|
||
private readonly hudManager: HudManager
|
||
private readonly hudModel: HudModel | null
|
||
private readonly predictor: LocalMovementPredictor | null
|
||
private readonly mapService: MapService | null
|
||
private readonly automapView: AutomapView | null
|
||
private readonly rateLimiter: OutboundRateLimiter
|
||
private readonly sendCommand: (cmd: ClientCommand) => void
|
||
private readonly isExternalUiIntercepted?: ((screenX: number, screenY: number) => boolean) | undefined
|
||
private readonly onToggleChatInput?: (() => void) | undefined
|
||
private readonly onEscapeMenu?: (() => void) | undefined
|
||
|
||
private shiftHeld = false
|
||
private altHeld = false
|
||
private leftMouseDown = false
|
||
private rightMouseDown = false
|
||
private lastPointerX = 0
|
||
private lastPointerY = 0
|
||
private hoveredUnit: ClientUnit | null = null
|
||
private lastPath: readonly SubTilePoint[] = []
|
||
|
||
/** Default F1..F8 skill hotkey bindings (`key.toUpperCase()` -> binding). */
|
||
private readonly skillHotkeys = new Map<string, SkillHotkeyBinding>()
|
||
|
||
constructor(deps: CommandMapperDeps) {
|
||
this.world = deps.world
|
||
this.camera = deps.camera ?? new ViewportCamera(deps.hudManager.screenWidth, deps.hudManager.screenHeight)
|
||
this.hudManager = deps.hudManager
|
||
this.hudModel = deps.hudModel ?? null
|
||
this.predictor = deps.predictor ?? null
|
||
this.mapService = deps.mapService ?? null
|
||
this.automapView = deps.automapView ?? null
|
||
this.rateLimiter = deps.rateLimiter ?? new OutboundRateLimiter()
|
||
this.sendCommand = deps.sendCommand
|
||
this.isExternalUiIntercepted = deps.isExternalUiIntercepted
|
||
this.onToggleChatInput = deps.onToggleChatInput
|
||
this.onEscapeMenu = deps.onEscapeMenu
|
||
}
|
||
|
||
// ─── State Queries & Configuration ───────────────────────────
|
||
|
||
get isShiftHeld(): boolean {
|
||
return this.shiftHeld
|
||
}
|
||
|
||
get isAltHeld(): boolean {
|
||
return this.altHeld
|
||
}
|
||
|
||
get currentHoveredUnit(): ClientUnit | null {
|
||
return this.hoveredUnit
|
||
}
|
||
|
||
get currentPath(): readonly SubTilePoint[] {
|
||
return this.lastPath
|
||
}
|
||
|
||
getCamera(): CameraLike {
|
||
return this.camera
|
||
}
|
||
|
||
getRateLimiter(): OutboundRateLimiter {
|
||
return this.rateLimiter
|
||
}
|
||
|
||
bindSkillHotkey(key: string, binding: SkillHotkeyBinding): void {
|
||
this.skillHotkeys.set(key.toUpperCase(), binding)
|
||
}
|
||
|
||
getSkillHotkey(key: string): SkillHotkeyBinding | undefined {
|
||
return this.skillHotkeys.get(key.toUpperCase())
|
||
}
|
||
|
||
// ─── Coordinate & Hit-Testing Helpers ────────────────────────
|
||
|
||
/**
|
||
* Convert screen coordinates `(screenX, screenY)` (in canvas pixels, supporting
|
||
* 800×600 and 1024×768 viewports) to world subtile coordinates and hit-test units.
|
||
*/
|
||
resolvePointerTarget(screenX: number, screenY: number, hitRadiusSubtiles = 2.5): PointerWorldTarget {
|
||
if (this.predictor) {
|
||
const snap = this.predictor.getSnapshot()
|
||
if (snap.x !== 0 || snap.y !== 0) {
|
||
this.camera.update(snap.x, snap.y)
|
||
}
|
||
} else if (this.world.self.x !== 0 || this.world.self.y !== 0) {
|
||
this.camera.update(this.world.self.x, this.world.self.y)
|
||
}
|
||
const subtile = this.camera.screenToSubtile(screenX, screenY)
|
||
const unit = this.pickUnitAtSubtile(subtile.x, subtile.y, hitRadiusSubtiles)
|
||
return {
|
||
subtileX: subtile.x,
|
||
subtileY: subtile.y,
|
||
hoveredUnit: unit,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Pick the closest interactive unit within `radiusSubtiles` of `(subtileX, subtileY)`.
|
||
* Excludes the local player unit and dead/corpse monsters unless interactable.
|
||
*/
|
||
pickUnitAtSubtile(subtileX: number, subtileY: number, radiusSubtiles = 2.5): ClientUnit | null {
|
||
const localPlayerId = this.world.selfId
|
||
const radiusSq = radiusSubtiles * radiusSubtiles
|
||
let bestUnit: ClientUnit | null = null
|
||
let bestDistSq = radiusSq
|
||
|
||
// 1. Check ground items first (unitType 4 = 'item')
|
||
for (const unit of this.world.getUnitsByType(4)) {
|
||
const dx = unit.x - subtileX
|
||
const dy = unit.y - subtileY
|
||
const d2 = dx * dx + dy * dy
|
||
if (d2 <= bestDistSq) {
|
||
bestDistSq = d2
|
||
bestUnit = unit
|
||
}
|
||
}
|
||
if (bestUnit) return bestUnit
|
||
|
||
// 2. Check monsters/NPCs (1), objects (2), warp tiles (5), and other players (0)
|
||
const candidateTypes = [1, 2, 5, 0] as const
|
||
for (const unitType of candidateTypes) {
|
||
for (const unit of this.world.getUnitsByType(unitType)) {
|
||
if (unitType === 0 && localPlayerId !== 0 && unit.id === localPlayerId) {
|
||
continue
|
||
}
|
||
// Skip dead monsters (mode 12 = Dead, or lifePct <= 0)
|
||
if (unitType === 1 && (unit.mode === 12 || unit.lifePct <= 0)) {
|
||
continue
|
||
}
|
||
const dx = unit.x - subtileX
|
||
const dy = unit.y - subtileY
|
||
const d2 = dx * dx + dy * dy
|
||
if (d2 <= bestDistSq) {
|
||
bestDistSq = d2
|
||
bestUnit = unit
|
||
}
|
||
}
|
||
}
|
||
|
||
return bestUnit
|
||
}
|
||
|
||
/**
|
||
* Returns true if `(screenX, screenY)` is intercepted by the HUD (`800×600` or `1024×768`)
|
||
* or an external UI overlay.
|
||
*/
|
||
isPointIntercepted(screenX: number, screenY: number): boolean {
|
||
if (this.isExternalUiIntercepted?.(screenX, screenY)) {
|
||
return true
|
||
}
|
||
return this.hudManager.isPointInterceptedByHud(screenX, screenY)
|
||
}
|
||
|
||
// ─── Mouse / Pointer Input Handlers ──────────────────────────
|
||
|
||
/**
|
||
* Handle pointer move at `(screenX, screenY)` in canvas coordinates.
|
||
*/
|
||
handlePointerMove(screenX: number, screenY: number, nowMs = performance.now()): void {
|
||
this.lastPointerX = screenX
|
||
this.lastPointerY = screenY
|
||
this.hudManager.handleMouseMove(screenX, screenY)
|
||
|
||
if (this.isPointIntercepted(screenX, screenY)) {
|
||
this.hoveredUnit = null
|
||
this.hudModel?.setHoveredMonster(null)
|
||
return
|
||
}
|
||
|
||
const target = this.resolvePointerTarget(screenX, screenY)
|
||
this.hoveredUnit = target.hoveredUnit
|
||
if (target.hoveredUnit && target.hoveredUnit.unitType === 'monster') {
|
||
this.hudModel?.setHoveredMonster(target.hoveredUnit.id)
|
||
} else {
|
||
this.hudModel?.setHoveredMonster(null)
|
||
}
|
||
|
||
// If holding left mouse button in world space, continuously steer toward cursor
|
||
if (this.leftMouseDown) {
|
||
this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
||
} else if (this.rightMouseDown) {
|
||
this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle mouse/pointer button down at `(screenX, screenY)`.
|
||
* @param button 0 = left button, 2 = right button
|
||
*/
|
||
handlePointerDown(
|
||
screenX: number,
|
||
screenY: number,
|
||
button: number,
|
||
nowMs = performance.now(),
|
||
): DispatchedCommandRecord | null {
|
||
this.lastPointerX = screenX
|
||
this.lastPointerY = screenY
|
||
this.hudManager.handleMouseMove(screenX, screenY)
|
||
|
||
// 1. Check external UI interception first
|
||
if (this.isExternalUiIntercepted?.(screenX, screenY)) {
|
||
return null
|
||
}
|
||
|
||
// 2. Route HUD clicks to HudManager (status bar, orbs, skill popup, belt, open panels)
|
||
if (this.hudManager.isPointInterceptedByHud(screenX, screenY)) {
|
||
if (button === 0) {
|
||
this.hudManager.handleClick(screenX, screenY)
|
||
}
|
||
return null
|
||
}
|
||
|
||
// 3. World-space action
|
||
const target = this.resolvePointerTarget(screenX, screenY)
|
||
this.hoveredUnit = target.hoveredUnit
|
||
|
||
if (button === 0) {
|
||
this.leftMouseDown = true
|
||
return this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
||
}
|
||
|
||
if (button === 2) {
|
||
this.rightMouseDown = true
|
||
return this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Handle mouse/pointer button release.
|
||
*/
|
||
handlePointerUp(button: number): void {
|
||
if (button === 0) {
|
||
this.leftMouseDown = false
|
||
} else if (button === 2) {
|
||
this.rightMouseDown = false
|
||
}
|
||
}
|
||
|
||
// ─── World Action Execution ──────────────────────────────────
|
||
|
||
/**
|
||
* Execute primary (left-click) action at world subtile `(subtileX, subtileY)`.
|
||
*/
|
||
triggerPrimaryWorldAction(
|
||
subtileX: number,
|
||
subtileY: number,
|
||
hoveredUnit: ClientUnit | null = null,
|
||
nowMs = performance.now(),
|
||
): DispatchedCommandRecord | null {
|
||
const isRunning = this.hudModel ? this.hudModel.isRunMode : this.hudManager.isRunning
|
||
const leftSkillId = this.hudModel ? this.hudModel.leftSkillId : this.hudManager.hotkeys.leftSkillId
|
||
|
||
// Stand-still modifier (Shift): cast left skill on location/target instead of moving
|
||
if (this.shiftHeld) {
|
||
if (hoveredUnit && hoveredUnit.unitType === 'monster') {
|
||
return this.dispatchRateLimited(
|
||
{
|
||
type: 'SkillOn',
|
||
hand: 'left',
|
||
target: unitToRef(hoveredUnit),
|
||
shift: true,
|
||
},
|
||
nowMs,
|
||
)
|
||
}
|
||
return this.dispatchRateLimited(
|
||
{
|
||
type: 'SkillAt',
|
||
hand: 'left',
|
||
at: { x: subtileX, y: subtileY },
|
||
shift: true,
|
||
},
|
||
nowMs,
|
||
)
|
||
}
|
||
|
||
// Unit interaction when clicking directly on an entity
|
||
if (hoveredUnit) {
|
||
if (hoveredUnit.unitType === 'item') {
|
||
const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning)
|
||
return this.dispatchRateLimited(
|
||
{
|
||
type: 'ItemPickup',
|
||
itemId: hoveredUnit.id,
|
||
toCursor: false,
|
||
},
|
||
nowMs,
|
||
path,
|
||
)
|
||
}
|
||
|
||
if (hoveredUnit.unitType === 'object' || hoveredUnit.unitType === 'tile') {
|
||
const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning)
|
||
return this.dispatchRateLimited(
|
||
{
|
||
type: 'Interact',
|
||
target: unitToRef(hoveredUnit),
|
||
},
|
||
nowMs,
|
||
path,
|
||
)
|
||
}
|
||
|
||
if (hoveredUnit.unitType === 'monster') {
|
||
const isTownArea =
|
||
this.world.areaId === 1 ||
|
||
this.world.areaId === 40 ||
|
||
this.world.areaId === 75 ||
|
||
this.world.areaId === 103 ||
|
||
this.world.areaId === 109
|
||
if (isTownArea) {
|
||
const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning)
|
||
const ref = unitToRef(hoveredUnit)
|
||
const moveRecord = this.dispatchRateLimited(
|
||
{
|
||
type: 'MoveToUnit',
|
||
run: isRunning,
|
||
target: ref,
|
||
},
|
||
nowMs,
|
||
path,
|
||
)
|
||
this.sendCommand({
|
||
type: 'NpcInit',
|
||
target: ref,
|
||
})
|
||
this.sendCommand({
|
||
type: 'Interact',
|
||
target: ref,
|
||
})
|
||
return moveRecord
|
||
}
|
||
// Non-zero left skill -> cast/attack on monster; otherwise MoveToUnit / attack
|
||
if (leftSkillId > 0) {
|
||
return this.dispatchRateLimited(
|
||
{
|
||
type: 'SkillOn',
|
||
hand: 'left',
|
||
target: unitToRef(hoveredUnit),
|
||
},
|
||
nowMs,
|
||
)
|
||
}
|
||
const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning)
|
||
return this.dispatchRateLimited(
|
||
{
|
||
type: 'MoveToUnit',
|
||
run: isRunning,
|
||
target: unitToRef(hoveredUnit),
|
||
},
|
||
nowMs,
|
||
path,
|
||
)
|
||
}
|
||
}
|
||
|
||
// Ground walk / run
|
||
const path = this.startLocalPathPrediction(subtileX, subtileY, isRunning)
|
||
const moveTarget = path.length > 0 ? path[path.length - 1]! : { x: subtileX, y: subtileY }
|
||
return this.dispatchRateLimited(
|
||
{
|
||
type: 'Move',
|
||
run: isRunning,
|
||
to: { x: moveTarget.x, y: moveTarget.y },
|
||
},
|
||
nowMs,
|
||
path,
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Execute secondary (right-click) skill action at world subtile `(subtileX, subtileY)`.
|
||
*/
|
||
triggerSecondaryWorldAction(
|
||
subtileX: number,
|
||
subtileY: number,
|
||
hoveredUnit: ClientUnit | null = null,
|
||
nowMs = performance.now(),
|
||
): DispatchedCommandRecord | null {
|
||
if (hoveredUnit) {
|
||
return this.dispatchRateLimited(
|
||
{
|
||
type: 'SkillOn',
|
||
hand: 'right',
|
||
target: unitToRef(hoveredUnit),
|
||
shift: this.shiftHeld,
|
||
},
|
||
nowMs,
|
||
)
|
||
}
|
||
|
||
return this.dispatchRateLimited(
|
||
{
|
||
type: 'SkillAt',
|
||
hand: 'right',
|
||
at: { x: subtileX, y: subtileY },
|
||
shift: this.shiftHeld,
|
||
},
|
||
nowMs,
|
||
)
|
||
}
|
||
|
||
// ─── Keyboard Input Handlers ─────────────────────────────────
|
||
|
||
/**
|
||
* Handle keydown events for D2 hotkeys.
|
||
* Returns true if the key was handled by the command mapper.
|
||
*/
|
||
handleKeyDown(
|
||
event: {
|
||
key: string
|
||
code?: string | undefined
|
||
shiftKey?: boolean | undefined
|
||
altKey?: boolean | undefined
|
||
ctrlKey?: boolean | undefined
|
||
metaKey?: boolean | undefined
|
||
preventDefault?: (() => void) | undefined
|
||
},
|
||
nowMs = performance.now(),
|
||
): boolean {
|
||
if (event.key === 'Shift' || event.shiftKey) {
|
||
this.shiftHeld = true
|
||
}
|
||
if (event.key === 'Alt' || event.altKey) {
|
||
this.altHeld = true
|
||
event.preventDefault?.()
|
||
if (event.key === 'Alt') return true
|
||
}
|
||
|
||
// Ignore hotkeys if modifier keys (Ctrl/Meta) are pressed
|
||
if (event.ctrlKey || event.metaKey) {
|
||
return false
|
||
}
|
||
|
||
const keyUpper = event.key.toUpperCase()
|
||
|
||
// Escape: close open HUD panels first, otherwise invoke escape menu callback / toggle
|
||
if (event.key === 'Escape') {
|
||
if (this.hudManager.leftPanel !== 'none' || this.hudManager.rightPanel !== 'none') {
|
||
this.hudManager.closeAllPanels()
|
||
event.preventDefault?.()
|
||
return true
|
||
}
|
||
if (this.onEscapeMenu) {
|
||
this.onEscapeMenu()
|
||
event.preventDefault?.()
|
||
return true
|
||
}
|
||
if (this.hudModel) {
|
||
this.hudModel.togglePanel('escapeMenu')
|
||
event.preventDefault?.()
|
||
return true
|
||
}
|
||
this.hudManager.toggleEscapeMenu()
|
||
event.preventDefault?.()
|
||
return true
|
||
}
|
||
|
||
// While Escape menu is open, route Up/Down/Enter to the Escape menu and block gameplay hotkeys
|
||
if (this.hudManager.escapeMenuOpen || this.hudModel?.getSnapshot().panels.escapeMenu) {
|
||
if (event.key === 'ArrowUp') {
|
||
event.preventDefault?.()
|
||
this.hudManager.moveEscapeMenuSelection(-1)
|
||
return true
|
||
}
|
||
if (event.key === 'ArrowDown') {
|
||
event.preventDefault?.()
|
||
this.hudManager.moveEscapeMenuSelection(1)
|
||
return true
|
||
}
|
||
if (event.key === 'Enter') {
|
||
event.preventDefault?.()
|
||
this.hudManager.activateEscapeMenuIndex()
|
||
return true
|
||
}
|
||
return true
|
||
}
|
||
|
||
// Enter: toggle chat input
|
||
if (event.key === 'Enter') {
|
||
if (this.onToggleChatInput) {
|
||
this.onToggleChatInput()
|
||
event.preventDefault?.()
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Tab: toggle automap overlay
|
||
if (event.key === 'Tab') {
|
||
event.preventDefault?.()
|
||
if (this.hudModel) {
|
||
this.hudModel.toggleAutomap()
|
||
} else if (this.automapView) {
|
||
const cur = this.automapView.getMode()
|
||
const next = cur === 'off' ? 'level' : cur === 'level' ? 'act' : 'off'
|
||
this.automapView.setMode(next)
|
||
}
|
||
return true
|
||
}
|
||
|
||
// Belt slots 1..4 -> BeltUse (0x26)
|
||
if (event.key === '1' || event.key === '2' || event.key === '3' || event.key === '4') {
|
||
const slotIndex = Number(event.key) - 1
|
||
event.preventDefault?.()
|
||
// BeltUse needs the real server item id of the potion, which only HudModel (ClientWorld) knows.
|
||
if (!this.hudModel) return false
|
||
this.hudModel.useBeltSlot(slotIndex, Boolean(event.shiftKey))
|
||
return true
|
||
}
|
||
|
||
// Function keys F1..F8 -> SelectSkill (0x3c)
|
||
if (/^F[1-8]$/.test(keyUpper)) {
|
||
event.preventDefault?.()
|
||
const binding = this.skillHotkeys.get(keyUpper)
|
||
if (binding) {
|
||
if (this.hudModel) {
|
||
this.hudModel.selectSkill(binding.hand, binding.skillId, binding.itemId ?? 0xffffffff)
|
||
} else {
|
||
this.dispatchRateLimited(
|
||
{
|
||
type: 'SelectSkill',
|
||
hand: binding.hand,
|
||
skill: binding.skillId,
|
||
itemId: binding.itemId,
|
||
},
|
||
nowMs,
|
||
)
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// Panel & action hotkeys
|
||
switch (keyUpper) {
|
||
case 'R': {
|
||
if (this.hudModel) {
|
||
this.hudModel.toggleRunWalk()
|
||
} else {
|
||
this.hudManager.isRunning = !this.hudManager.isRunning
|
||
this.hudManager.syncPublishedState()
|
||
}
|
||
return true
|
||
}
|
||
case 'W': {
|
||
if (this.hudModel) {
|
||
this.hudModel.swapWeapons()
|
||
} else {
|
||
this.dispatchRateLimited({ type: 'SwapWeapons' }, nowMs)
|
||
}
|
||
return true
|
||
}
|
||
case 'A':
|
||
case 'C': {
|
||
this.hudManager.toggleLeftPanel('char')
|
||
return true
|
||
}
|
||
case 'I':
|
||
case 'B': {
|
||
this.hudManager.toggleRightPanel('inv')
|
||
return true
|
||
}
|
||
case 'T':
|
||
case 'S': {
|
||
this.hudManager.toggleRightPanel('skill')
|
||
return true
|
||
}
|
||
case 'Q': {
|
||
this.hudManager.toggleLeftPanel('quest')
|
||
return true
|
||
}
|
||
case 'P':
|
||
case 'M': {
|
||
this.hudManager.toggleLeftPanel('waypoint')
|
||
return true
|
||
}
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle keyup events (releases Shift stand-still and Alt item-labels).
|
||
*/
|
||
handleKeyUp(event: {
|
||
key: string
|
||
shiftKey?: boolean | undefined
|
||
altKey?: boolean | undefined
|
||
}): void {
|
||
if (event.key === 'Shift' || event.shiftKey === false) {
|
||
this.shiftHeld = false
|
||
}
|
||
if (event.key === 'Alt' || event.altKey === false) {
|
||
this.altHeld = false
|
||
}
|
||
}
|
||
|
||
// ─── Frame Tick & Rate-Limiter Coalescing ────────────────────
|
||
|
||
/**
|
||
* Advance periodic input state:
|
||
* - Flushes any coalesced movement command when its 120ms cooldown expires.
|
||
* - Repeats held mouse actions at rate-limited intervals.
|
||
*/
|
||
tick(nowMs = performance.now()): ClientCommand | null {
|
||
const flushed = this.rateLimiter.flushPendingMove(nowMs)
|
||
if (flushed) {
|
||
this.sendCommand(flushed)
|
||
return flushed
|
||
}
|
||
|
||
if (this.leftMouseDown && !this.isPointIntercepted(this.lastPointerX, this.lastPointerY)) {
|
||
const target = this.resolvePointerTarget(this.lastPointerX, this.lastPointerY)
|
||
const record = this.triggerPrimaryWorldAction(
|
||
target.subtileX,
|
||
target.subtileY,
|
||
target.hoveredUnit,
|
||
nowMs,
|
||
)
|
||
return record?.rateLimit.allowed ? record.command : null
|
||
}
|
||
|
||
if (this.rightMouseDown && !this.isPointIntercepted(this.lastPointerX, this.lastPointerY)) {
|
||
const target = this.resolvePointerTarget(this.lastPointerX, this.lastPointerY)
|
||
const record = this.triggerSecondaryWorldAction(
|
||
target.subtileX,
|
||
target.subtileY,
|
||
target.hoveredUnit,
|
||
nowMs,
|
||
)
|
||
return record?.rateLimit.allowed ? record.command : null
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
// ─── Internal Helpers ────────────────────────────────────────
|
||
|
||
private startLocalPathPrediction(
|
||
targetX: number,
|
||
targetY: number,
|
||
isRunning: boolean,
|
||
): readonly SubTilePoint[] {
|
||
if (!this.predictor) {
|
||
this.lastPath = [{ x: targetX, y: targetY }]
|
||
return this.lastPath
|
||
}
|
||
|
||
const levelView =
|
||
this.mapService?.getActiveLevelView() ?? this.automapView?.getLevelView() ?? null
|
||
|
||
if (levelView && levelView.walkable.length > 0) {
|
||
const snap = this.predictor.getSnapshot()
|
||
const sx = Math.round(snap.logicalX)
|
||
const sy = Math.round(snap.logicalY)
|
||
const ox = levelView.subOriginX ?? 0
|
||
const oy = levelView.subOriginY ?? 0
|
||
const inBounds =
|
||
sx >= ox &&
|
||
sy >= oy &&
|
||
sx < ox + levelView.subWidth &&
|
||
sy < oy + levelView.subHeight &&
|
||
targetX >= ox &&
|
||
targetY >= oy &&
|
||
targetX < ox + levelView.subWidth &&
|
||
targetY < oy + levelView.subHeight
|
||
|
||
if (inBounds) {
|
||
const pathResult = findPathWorld(levelView, sx, sy, targetX, targetY)
|
||
if (pathResult.path.length > 0) {
|
||
this.lastPath = pathResult.path
|
||
this.predictor.setPath(pathResult.path, isRunning)
|
||
return this.lastPath
|
||
}
|
||
}
|
||
}
|
||
|
||
this.lastPath = [{ x: targetX, y: targetY }]
|
||
this.predictor.setPath(this.lastPath, isRunning)
|
||
return this.lastPath
|
||
}
|
||
|
||
dispatchRateLimited(
|
||
command: ClientCommand,
|
||
nowMs = performance.now(),
|
||
path: readonly SubTilePoint[] = [],
|
||
): DispatchedCommandRecord {
|
||
const rateLimit = this.rateLimiter.tryAcquire(command, nowMs)
|
||
if (rateLimit.allowed) {
|
||
this.sendCommand(command)
|
||
}
|
||
return {
|
||
command,
|
||
rateLimit,
|
||
path,
|
||
}
|
||
}
|
||
}
|