diablo2-web/src/client/input/command-mapper.ts

852 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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,
}
}
}