diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 3ca6d94..2a1029f 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -183,6 +183,412 @@ export function projectNpcLabel(params: NpcLabelProjectionParams): NpcLabelProje return { domX, domY: domY - 2, fontSize, visible } } +/** Maximum range in scene pixels for Telekinesis (Skill 43) warp activation. */ +export const TELEKINESIS_RANGE_PX = 320 +/** Radial proximity click tolerance in scene pixels for a SceneWarp. */ +export const WARP_CLICK_RADIUS_PX = 56 +/** Radial proximity click tolerance in scene pixels for a SceneEntrance. */ +export const ENTRANCE_CLICK_RADIUS_PX = 56 + +/** + * Convert scene pixel coordinates back to isometric sub-tile coordinates. + */ +export function sceneToSubTile( + worldX: number, + worldY: number, + grid: { readonly originX: number; readonly originY: number }, +): { x: number; y: number } { + const dx = (worldX - grid.originX) / ORTHO_SUB_TILE_WIDTH + const dy = (worldY - grid.originY) / ORTHO_SUB_TILE_HEIGHT + return { x: Math.round((dy + dx) / 2), y: Math.round((dy - dx) / 2) } +} + +/** + * Compute the scene coordinate for a `SceneWarp` overhead destination badge. + */ +export function getWarpLabelWorldPos( + warp: SceneWarp, + grid: { readonly originX: number; readonly originY: number }, +): { x: number; y: number } { + const anchor = subTileToScene(warp.x, warp.y, grid) + if (warp.selectDX > 0 && warp.selectDY > 0) { + return { + x: anchor.x + warp.selectX + warp.selectDX * 0.5, + y: anchor.y + warp.selectY - 6, + } + } + return { + x: anchor.x, + y: anchor.y - 48, + } +} + +/** + * Compute the scene coordinate for a `SceneEntrance` overhead destination badge. + */ +export function getEntranceLabelWorldPos( + entrance: SceneEntrance, + grid: { readonly originX: number; readonly originY: number }, +): { x: number; y: number } { + const anchor = subTileToScene(entrance.x, entrance.y, grid) + return { + x: anchor.x, + y: anchor.y - 48, + } +} + +/** + * Hit-test a world-space pointer position against a `SceneWarp` (`LvlWarp.txt` + * selection bounding box when non-zero, overhead badge footprint, plus radial + * proximity to anchor, walkable arrival point, and overhead label). + */ +export function hitTestWarp( + warp: SceneWarp, + worldX: number, + worldY: number, + grid: { readonly originX: number; readonly originY: number }, +): { + hit: boolean + dist: number + anchorPos: { x: number; y: number } + arrivePos: { x: number; y: number } +} { + const anchorPos = subTileToScene(warp.x, warp.y, grid) + const arrivePos = subTileToScene(warp.arriveX, warp.arriveY, grid) + if (!(warp.toLevelId > 0)) { + return { + hit: false, + dist: Infinity, + anchorPos, + arrivePos, + } + } + const labelPos = getWarpLabelWorldPos(warp, grid) + + let boxDist = Infinity + let inBox = false + if (warp.selectDX > 0 && warp.selectDY > 0) { + const minX = anchorPos.x + warp.selectX + const maxX = minX + warp.selectDX + const minY = anchorPos.y + warp.selectY + const maxY = minY + warp.selectDY + if (worldX >= minX && worldX <= maxX && worldY >= minY && worldY <= maxY) { + inBox = true + const cx = (minX + maxX) * 0.5 + const cy = (minY + maxY) * 0.5 + boxDist = Math.hypot(worldX - cx, worldY - cy) + } + } + + const badgeText = `通往 ${getLevelNames(warp.toLevelId).nameZh}` + const badgeHalfWidth = Math.max(64, Math.ceil((badgeText.length * 14 + 16) * 0.5)) + const inBadge = + Math.abs(worldX - labelPos.x) <= badgeHalfWidth && + worldY >= labelPos.y - 28 && + worldY <= labelPos.y + 8 + const badgeDist = inBadge + ? Math.hypot((worldX - labelPos.x) * 0.5, worldY - (labelPos.y - 12)) * 0.1 + : Infinity + + const distAnchor = Math.hypot(worldX - anchorPos.x, worldY - anchorPos.y) + const distArrive = Math.hypot(worldX - arrivePos.x, worldY - arrivePos.y) + const distLabel = Math.hypot(worldX - labelPos.x, worldY - labelPos.y) + const distAnchorBody = Math.hypot(worldX - anchorPos.x, worldY - (anchorPos.y - 48)) + const radialDist = Math.min(distAnchor, distArrive, distLabel, distAnchorBody) + const inRadius = radialDist <= WARP_CLICK_RADIUS_PX + + return { + hit: inBox || inBadge || inRadius, + dist: Math.min(boxDist, badgeDist, radialDist), + anchorPos, + arrivePos, + } +} + +/** + * Hit-test a world-space pointer position against a `SceneEntrance` (overhead + * badge footprint plus radial proximity to entrance anchor, walkable arrival + * point, and overhead label). + */ +export function hitTestEntrance( + entrance: SceneEntrance, + worldX: number, + worldY: number, + grid: { readonly originX: number; readonly originY: number }, +): { + hit: boolean + dist: number + anchorPos: { x: number; y: number } + arrivePos: { x: number; y: number } +} { + const anchorPos = subTileToScene(entrance.x, entrance.y, grid) + const arrivePos = subTileToScene(entrance.arriveX, entrance.arriveY, grid) + if (!(entrance.toLevelId > 0)) { + return { + hit: false, + dist: Infinity, + anchorPos, + arrivePos, + } + } + const labelPos = getEntranceLabelWorldPos(entrance, grid) + + const badgeText = `通往 ${getLevelNames(entrance.toLevelId).nameZh}` + const badgeHalfWidth = Math.max(64, Math.ceil((badgeText.length * 14 + 16) * 0.5)) + const inBadge = + Math.abs(worldX - labelPos.x) <= badgeHalfWidth && + worldY >= labelPos.y - 28 && + worldY <= labelPos.y + 8 + const badgeDist = inBadge + ? Math.hypot((worldX - labelPos.x) * 0.5, worldY - (labelPos.y - 12)) * 0.1 + : Infinity + + const distAnchor = Math.hypot(worldX - anchorPos.x, worldY - anchorPos.y) + const distArrive = Math.hypot(worldX - arrivePos.x, worldY - arrivePos.y) + const distLabel = Math.hypot(worldX - labelPos.x, worldY - labelPos.y) + const distArriveBody = Math.hypot(worldX - arrivePos.x, worldY - (arrivePos.y - 48)) + const radialDist = Math.min(distAnchor, distArrive, distLabel, distArriveBody) + + return { + hit: inBadge || radialDist <= ENTRANCE_CLICK_RADIUS_PX, + dist: Math.min(badgeDist, radialDist), + anchorPos, + arrivePos, + } +} + +/** + * Check whether the player is within trigger range (`WARP_TRIGGER_SUBTILES` or + * 80px proximity radius) of a warp or entrance's anchor or walkable arrival point. + */ +export function isPlayerWithinLinkRange( + playerX: number, + playerY: number, + link: { readonly x: number; readonly y: number; readonly arriveX: number; readonly arriveY: number }, + grid: { readonly originX: number; readonly originY: number }, + triggerSubTiles = WARP_TRIGGER_SUBTILES, +): boolean { + const sub = sceneToSubTile(playerX, playerY, grid) + const nearAnchorSub = Math.abs(sub.x - link.x) <= triggerSubTiles && Math.abs(sub.y - link.y) <= triggerSubTiles + const nearArriveSub = Math.abs(sub.x - link.arriveX) <= triggerSubTiles && Math.abs(sub.y - link.arriveY) <= triggerSubTiles + if (nearAnchorSub || nearArriveSub) return true + + const anchorPos = subTileToScene(link.x, link.y, grid) + const arrivePos = subTileToScene(link.arriveX, link.arriveY, grid) + const distAnchor = Math.hypot(playerX - anchorPos.x, playerY - anchorPos.y) + const distArrive = Math.hypot(playerX - arrivePos.x, playerY - arrivePos.y) + return Math.min(distAnchor, distArrive) <= 80 +} + +/** + * Resolve an obstacle-aware immediate steering target toward `navTarget` on `grid`. + * When the direct sub-tile line between the player and `navTarget` is unobstructed, + * returns `navTarget` directly. When blocked by dungeon walls in `grid.blocked`, + * computes an 8-directional shortest walkable sub-tile path (A*) around corners and + * returns the furthest visible waypoint along that path. + */ +export function resolveObstacleAwareSteerTarget( + playerX: number, + playerY: number, + navTarget: { readonly x: number; readonly y: number }, + grid: CollisionGrid | undefined, +): { x: number; y: number } { + if (!grid || !grid.blocked || grid.blocked.length === 0 || grid.gridWidth <= 0 || grid.cellsY <= 0) { + return navTarget + } + + const gridW = grid.gridWidth + const gridH = grid.cellsY * SUB_TILES_PER_TILE + const startSub = sceneToSubTile(playerX, playerY, grid) + const rawEndSub = sceneToSubTile(navTarget.x, navTarget.y, grid) + + if (startSub.x < 0 || startSub.y < 0 || startSub.x >= gridW || startSub.y >= gridH) { + return navTarget + } + const endSub = { + x: Math.max(0, Math.min(gridW - 1, rawEndSub.x)), + y: Math.max(0, Math.min(gridH - 1, rawEndSub.y)), + } + if (startSub.x === endSub.x && startSub.y === endSub.y) { + return navTarget + } + + const isSubTileBlocked = (sx: number, sy: number): boolean => { + if (sx < 0 || sy < 0 || sx >= gridW || sy >= gridH) return true + return grid.blocked[sy * gridW + sx] !== 0 + } + + const hasWallNeighbor = (sx: number, sy: number): boolean => + isSubTileBlocked(sx - 1, sy) || + isSubTileBlocked(sx + 1, sy) || + isSubTileBlocked(sx, sy - 1) || + isSubTileBlocked(sx, sy + 1) + + const hasClearLine = (x0: number, y0: number, x1: number, y1: number, requireClearance: boolean): boolean => { + let cx = x0 + let cy = y0 + const dx = Math.abs(x1 - x0) + const dy = Math.abs(y1 - y0) + const sx = x0 < x1 ? 1 : -1 + const sy = y0 < y1 ? 1 : -1 + let err = dx - dy + let step = 0 + + while (true) { + if (step > 0) { + if (isSubTileBlocked(cx, cy)) return false + if (requireClearance && (cx !== x1 || cy !== y1) && hasWallNeighbor(cx, cy)) return false + } + if (cx === x1 && cy === y1) break + const prevX = cx + const prevY = cy + const e2 = 2 * err + if (e2 > -dy) { + err -= dy + cx += sx + } + if (e2 < dx) { + err += dx + cy += sy + } + if (cx !== prevX && cy !== prevY) { + if (isSubTileBlocked(cx, prevY) || isSubTileBlocked(prevX, cy)) return false + } + step += 1 + } + return true + } + + if (hasClearLine(startSub.x, startSub.y, endSub.x, endSub.y, false)) { + return navTarget + } + + // Bounded 8-directional A* search on sub-tile grid + const maxExpanded = 4096 + const gScore = new Map() + const parent = new Map() + const closed = new Set() + const open: Array<{ key: number; x: number; y: number; g: number; f: number }> = [] + + const startKey = startSub.y * gridW + startSub.x + const endKey = endSub.y * gridW + endSub.x + const heuristic = (x: number, y: number): number => Math.hypot(endSub.x - x, endSub.y - y) + + gScore.set(startKey, 0) + open.push({ key: startKey, x: startSub.x, y: startSub.y, g: 0, f: heuristic(startSub.x, startSub.y) }) + + const dirs: ReadonlyArray = [ + [1, 0, 1], + [-1, 0, 1], + [0, 1, 1], + [0, -1, 1], + [1, 1, Math.SQRT2], + [1, -1, Math.SQRT2], + [-1, 1, Math.SQRT2], + [-1, -1, Math.SQRT2], + ] + + let reachedKey: number | null = null + let bestFallbackKey: number | null = null + let bestFallbackDist = heuristic(startSub.x, startSub.y) + let bestFallbackG = Infinity + let expanded = 0 + + while (open.length > 0 && expanded < maxExpanded) { + let bestIdx = 0 + for (let i = 1; i < open.length; i += 1) { + if (open[i]!.f < open[bestIdx]!.f) bestIdx = i + } + const cur = open[bestIdx]! + open[bestIdx] = open[open.length - 1]! + open.pop() + + if (closed.has(cur.key)) continue + closed.add(cur.key) + expanded += 1 + + if (cur.key === endKey) { + reachedKey = cur.key + break + } + + if (cur.key !== startKey) { + const hCur = heuristic(cur.x, cur.y) + if ( + hCur < bestFallbackDist - 1e-6 || + (Math.abs(hCur - bestFallbackDist) <= 1e-6 && cur.g < bestFallbackG) + ) { + bestFallbackDist = hCur + bestFallbackG = cur.g + bestFallbackKey = cur.key + } + } + + for (const [dx, dy, baseCost] of dirs) { + const nx = cur.x + dx + const ny = cur.y + dy + if (nx < 0 || ny < 0 || nx >= gridW || ny >= gridH) continue + const nKey = ny * gridW + nx + if (closed.has(nKey)) continue + if (nKey !== endKey && isSubTileBlocked(nx, ny)) continue + if (dx !== 0 && dy !== 0) { + if (isSubTileBlocked(cur.x + dx, cur.y) || isSubTileBlocked(cur.x, cur.y + dy)) continue + } + + const wallPenalty = (nKey !== endKey && hasWallNeighbor(nx, ny)) ? 0.6 : 0 + const tentativeG = cur.g + baseCost + wallPenalty + const prevG = gScore.get(nKey) + if (prevG === undefined || tentativeG < prevG) { + gScore.set(nKey, tentativeG) + parent.set(nKey, cur.key) + open.push({ key: nKey, x: nx, y: ny, g: tentativeG, f: tentativeG + heuristic(nx, ny) }) + } + } + } + + const finalKey = reachedKey ?? bestFallbackKey + if (finalKey === null) { + return navTarget + } + + const path: Array<{ x: number; y: number }> = [] + let curr: number | undefined = finalKey + while (curr !== undefined) { + const py = Math.floor(curr / gridW) + const px = curr - py * gridW + path.push({ x: px, y: py }) + if (curr === startKey) break + curr = parent.get(curr) + } + path.reverse() + + if (path.length <= 1) { + return navTarget + } + + let targetIdx = 1 + for (let i = path.length - 1; i >= 2; i -= 1) { + const pt = path[i]! + if (hasClearLine(startSub.x, startSub.y, pt.x, pt.y, true)) { + targetIdx = i + break + } + } + + if (targetIdx === path.length - 1 && reachedKey !== null) { + return navTarget + } + + const wp = path[targetIdx]! + const wpScene = subTileToScene(wp.x, wp.y, grid) + if (Math.hypot(wpScene.x - playerX, wpScene.y - playerY) <= 10 && targetIdx + 1 < path.length) { + const nextWp = path[targetIdx + 1]! + return subTileToScene(nextWp.x, nextWp.y, grid) + } + return wpScene +} + + /** * Side of a streaming room, in cells. * @@ -1598,6 +2004,8 @@ export function castSkill( portalSlot?: TownPortalSlot | undefined onEnterPortal?: (() => void) | undefined onCastTownPortal?: (() => boolean) | undefined + onEnterWarp?: ((warp: SceneWarp) => void) | undefined + onEnterEntrance?: ((entrance: SceneEntrance) => void) | undefined }, ): boolean { const { engine, runtime, hudManager, status, playerAnimator, character } = context @@ -1865,18 +2273,27 @@ export function castSkill( // Combat: 1-2 lightning damage base (slvl to slvl + 1), 35% knockback, 35 stun ticks // Remote interaction: // - Items: pickup allowed ground items (potions, scrolls, gold, ammo, keys); reject equipment - // - Objects: triggers chests, waypoints, shrines, portals, doors, wells + // - Objects: triggers chests, waypoints, shrines, portals, doors, wells, warps const slvl = (hudManager as any)?.getSkillLevel?.(43) ?? 1 const dist = Math.hypot(targetX - player.x, targetY - player.y) const maxRange = 600 + const refundTelekinesisMana = (): void => { + const manaCost = getSkillMana(43, hudManager) + if (manaCost > 0) { + player.mana = Math.min(player.maxMana ?? player.mana + manaCost, player.mana + manaCost) + if (hudManager !== null) { + hudManager.mana = Math.min(hudManager.maxMana ?? hudManager.mana + manaCost, hudManager.mana + manaCost) + hudManager.syncPublishedState?.() + } + } + } if (dist > maxRange) { + refundTelekinesisMana() status.textContent = '目标超出心灵传动范围!' return false } - engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) - // 1. Monster combat check: living monster within 48 px of target point let targetMonster: Monster | null = null let bestMonDist = 48 @@ -1892,6 +2309,7 @@ export function castSkill( } if (targetMonster !== null) { + engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) const dmgRange = calculateTelekinesisDamage(slvl) const dmg = Math.floor(dmgRange.min + Math.random() * (dmgRange.max - dmgRange.min + 1)) damageMonster(engine.world, targetMonster.index, Math.max(1, dmg)) @@ -1919,6 +2337,7 @@ export function castSkill( engine.groundItems?.findAtPoint?.(targetX, targetY, 48) ?? engine.groundItems?.all?.find(g => Math.hypot(g.x - targetX, g.y - targetY) <= 48) if (nearbyGroundItem) { + engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) if (isTelekinesisItemAllowed(nearbyGroundItem)) { if (nearbyGroundItem.isGold) { const res = engine.pickupGold(nearbyGroundItem.id) @@ -1979,10 +2398,11 @@ export function castSkill( return true } - // 3. Interactive objects check (waypoints, stash, chests, portals) + // 3. Interactive objects check (waypoints, stash, chests, portals, warps) if (runtime.waypoints) { const clickedWp = runtime.waypoints.find(wp => Math.hypot(wp.arriveX - targetX, wp.arriveY - targetY) <= 80) if (clickedWp) { + engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) if (context.waypointNetwork?.activate(clickedWp.waypointId)) { status.textContent = `传送点已激活:${runtime.level}` } @@ -2002,6 +2422,7 @@ export function castSkill( if (runtime.stashes) { const clickedStash = runtime.stashes.find(s => Math.hypot(s.x - targetX, s.y - targetY) <= 64) if (clickedStash) { + engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) if (context.openStash) { context.openStash(clickedStash) } else if (hudManager && hudManager.leftPanel !== 'stash') { @@ -2018,10 +2439,9 @@ export function castSkill( const mouth = context.portalSlot.mouthInLevel(runtime.levelId) if (mouth) { const portalPos = subTileToScene(mouth.x, mouth.y, runtime.grid) - if ( - Math.hypot(portalPos.x - targetX, portalPos.y - targetY) <= 64 || - Math.hypot(portalPos.x - targetX, (portalPos.y - 40) - targetY) <= 64 - ) { + const clampedPortalY = Math.max(portalPos.y - 80, Math.min(portalPos.y, targetY)) + if (Math.hypot(portalPos.x - targetX, clampedPortalY - targetY) <= 64) { + engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) player.cooldown = 0 engine.metrics.casts += 1 status.textContent = '心灵传动进入传送门' @@ -2031,7 +2451,36 @@ export function castSkill( } } + if (runtime.warps && runtime.grid) { + let bestWarp: { warp: SceneWarp; dist: number; anchorPos: { x: number; y: number }; arrivePos: { x: number; y: number } } | null = null + for (const warp of runtime.warps) { + const res = hitTestWarp(warp, targetX, targetY, runtime.grid) + if (res.hit && (bestWarp === null || res.dist < bestWarp.dist)) { + bestWarp = { warp, dist: res.dist, anchorPos: res.anchorPos, arrivePos: res.arrivePos } + } + } + if (bestWarp !== null) { + const warpPlayerDist = Math.min( + Math.hypot(bestWarp.anchorPos.x - player.x, bestWarp.anchorPos.y - player.y), + Math.hypot(bestWarp.arrivePos.x - player.x, bestWarp.arrivePos.y - player.y), + ) + if (warpPlayerDist <= TELEKINESIS_RANGE_PX) { + engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) + player.cooldown = 0 + engine.metrics.casts += 1 + const destName = getLevelNames(bestWarp.warp.toLevelId).nameZh + status.textContent = `心灵传动触发入口:通往 ${destName}` + context.onEnterWarp?.(bestWarp.warp) + return true + } + refundTelekinesisMana() + status.textContent = '目标超出心灵传动范围!' + return false + } + } + // 4. Generic ground cast / feedback + engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) player.cooldown = 0 engine.metrics.casts += 1 status.textContent = '施展心灵传动' @@ -3319,6 +3768,8 @@ export type PendingInteraction = | { readonly kind: 'waypoint'; readonly waypoint: SceneWaypoint; readonly worldPos: { readonly x: number; readonly y: number } } | { readonly kind: 'stash'; readonly stash: TownStashEntity } | { readonly kind: 'portal'; readonly worldPos: { readonly x: number; readonly y: number } } + | { readonly kind: 'warp'; readonly warp: SceneWarp; readonly anchorPos: { readonly x: number; readonly y: number }; readonly arrivePos: { readonly x: number; readonly y: number } } + | { readonly kind: 'entrance'; readonly entrance: SceneEntrance; readonly anchorPos: { readonly x: number; readonly y: number }; readonly arrivePos: { readonly x: number; readonly y: number } } | { readonly kind: 'monster'; readonly monster: Monster; readonly skillId?: number } | { readonly kind: 'ground-item'; readonly item: GroundItemEntity } @@ -3343,6 +3794,10 @@ export interface SceneMouseControllerOptions { readonly portalSlot?: TownPortalSlot | undefined readonly onEnterPortal?: (() => void) | undefined readonly onCastTownPortal?: (() => boolean) | undefined + readonly onEnterWarp?: ((warp: SceneWarp) => void) | undefined + readonly onEnterEntrance?: ((entrance: SceneEntrance) => void) | undefined + readonly isTravelling?: (() => boolean) | undefined + readonly labelsContainer?: HTMLElement | null | undefined } /** @@ -3444,17 +3899,25 @@ export class SceneMouseController { readonly portalSlot?: TownPortalSlot | undefined readonly onEnterPortal?: (() => void) | undefined readonly onCastTownPortal?: (() => boolean) | undefined + readonly onEnterWarp?: ((warp: SceneWarp) => void) | undefined + readonly onEnterEntrance?: ((entrance: SceneEntrance) => void) | undefined + readonly isTravelling?: (() => boolean) | undefined + readonly labelsContainer?: HTMLElement | null | undefined navTarget: { x: number; y: number } | null = null pendingInteraction: PendingInteraction | null = null - activeClickType: 'none' | 'ground' | 'npc' | 'waypoint' | 'stash' | 'portal' | 'monster' = 'none' + activeClickType: 'none' | 'ground' | 'npc' | 'waypoint' | 'stash' | 'portal' | 'warp' | 'entrance' | 'monster' = 'none' isLeftMouseDown = false isRightMouseDown = false lastClientPos: { x: number; y: number } | null = null hoveredGroundItem: GroundItemEntity | null = null + hoveredWarp: SceneWarp | null = null + hoveredEntrance: SceneEntrance | null = null + readonly labelPool: HTMLDivElement[] = [] private stuckTicks = 0 private lastDist = -1 + private lastPlayerPos: { x: number; y: number } | null = null constructor(options: SceneMouseControllerOptions) { this.canvas = options.canvas @@ -3470,7 +3933,320 @@ export class SceneMouseController { this.portalSlot = options.portalSlot this.onEnterPortal = options.onEnterPortal this.onCastTownPortal = options.onCastTownPortal + this.onEnterWarp = options.onEnterWarp + this.onEnterEntrance = options.onEnterEntrance + this.isTravelling = options.isTravelling + this.labelsContainer = options.labelsContainer ?? null ;(this.engine as any).dialog = null + if (this.labelsContainer) { + this.updateOverheadLabels() + } + } + + private isHigherPriorityEntityAt(worldX: number, worldY: number): boolean { + for (const npc of this.engine.npcEntities ?? []) { + const distFeet = Math.hypot(npc.x - worldX, npc.y - worldY) + const distBody = Math.hypot(npc.x - worldX, (npc.y - 16) - worldY) + if (Math.min(distFeet, distBody) <= 36) return true + } + + const runtime = this.getRuntime() + if (runtime?.grid) { + for (const wp of runtime.waypoints ?? []) { + const pos = subTileToScene(wp.x, wp.y, runtime.grid) + if (Math.hypot(pos.x - worldX, pos.y - worldY) <= 48) return true + } + } + + if (this.findNearestStash(worldX, worldY, 48) !== null) return true + + if (runtime?.grid) { + const activePortalMouth = this.portalSlot?.mouthInLevel(runtime.levelId) ?? null + if (activePortalMouth !== null) { + const portalPos = subTileToScene(activePortalMouth.x, activePortalMouth.y, runtime.grid) + const clampedPortalY = Math.max(portalPos.y - 80, Math.min(portalPos.y, worldY)) + if (Math.hypot(portalPos.x - worldX, clampedPortalY - worldY) <= 48) return true + } + } + + for (const m of this.engine.world.monsters ?? []) { + if (m.state === 'dead' || m.hp <= 0) continue + const distFeet = Math.hypot(m.x - worldX, m.y - worldY) + const distBody = Math.hypot(m.x - worldX, (m.y - 16) - worldY) + if (Math.min(distFeet, distBody) <= 36) return true + } + + if (this.engine.groundItems?.findAtPoint(worldX, worldY, 32)) return true + + return false + } + + private syncLinkHoverAtWorld(worldX: number, worldY: number): void { + if (this.isHigherPriorityEntityAt(worldX, worldY)) { + this.hoveredWarp = null + this.hoveredEntrance = null + if (this.canvas?.style) { + this.canvas.style.cursor = '' + } + return + } + + const runtime = this.getRuntime() + let bestWarp: { warp: SceneWarp; dist: number } | null = null + let bestEntrance: { entrance: SceneEntrance; dist: number } | null = null + if (runtime?.grid) { + for (const warp of runtime.warps ?? []) { + const res = hitTestWarp(warp, worldX, worldY, runtime.grid) + if (res.hit && (bestWarp === null || res.dist < bestWarp.dist)) { + bestWarp = { warp, dist: res.dist } + } + } + for (const entrance of runtime.entrances ?? []) { + const res = hitTestEntrance(entrance, worldX, worldY, runtime.grid) + if (res.hit && (bestEntrance === null || res.dist < bestEntrance.dist)) { + bestEntrance = { entrance, dist: res.dist } + } + } + } + if (bestWarp !== null && (bestEntrance === null || bestWarp.dist <= bestEntrance.dist)) { + this.hoveredWarp = bestWarp.warp + this.hoveredEntrance = null + } else if (bestEntrance !== null) { + this.hoveredWarp = null + this.hoveredEntrance = bestEntrance.entrance + } else { + this.hoveredWarp = null + this.hoveredEntrance = null + } + if (this.canvas?.style) { + this.canvas.style.cursor = (this.hoveredWarp !== null || this.hoveredEntrance !== null) ? 'pointer' : '' + } + } + + private allocateLabelElement(container: HTMLElement): HTMLDivElement { + let label: HTMLDivElement + if (typeof document !== 'undefined' && typeof document.createElement === 'function') { + label = document.createElement('div') + } else { + label = { + tagName: 'DIV', + textContent: '', + hidden: false, + className: '', + dataset: {}, + style: {}, + } as unknown as HTMLDivElement + } + label.style.position = 'absolute' + label.style.transform = 'translate(-50%, -100%)' + label.style.textShadow = '1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000' + label.style.whiteSpace = 'nowrap' + label.style.pointerEvents = 'none' + this.labelPool.push(label) + if (typeof container.appendChild === 'function') { + container.appendChild(label) + } else if (Array.isArray((container as any).children)) { + ;(container as any).children.push(label) + } + return label + } + + /** + * Render recycled overhead labels for on-screen NPCs, Town Portals, + * `runtime.warps`, and `runtime.entrances` into `labelsContainer`. + */ + updateOverheadLabels( + camX = this.engine.world.player.x, + camY = this.engine.world.player.y - 16, + activePortalMouthOverride?: { readonly x: number; readonly y: number; readonly toLevelId: number } | null, + activePortalPosOverride?: { readonly x: number; readonly y: number } | null, + ): void { + const container = this.labelsContainer + if (!container) return + + const runtime = this.getRuntime() + let labelCount = 0 + const rect = this.canvas.getBoundingClientRect() + const scaleX = this.canvas.width > 0 ? rect.width / this.canvas.width : 1 + const scaleY = this.canvas.height > 0 ? rect.height / this.canvas.height : 1 + const zoom = this.camera?.zoom ?? 1 + const fontSizePx = `${Math.min(24, Math.max(11, Math.round(13 * Math.sqrt(Math.max(0.001, zoom)))))}px` + + if (this.lastClientPos !== null) { + let hudIntercepted = false + if (this.hudManager !== null) { + const logical = this.hudManager.clientToLogical(this.lastClientPos.x, this.lastClientPos.y) + hudIntercepted = this.hudManager.isPointInterceptedByHud(logical.x, logical.y) + } + if (hudIntercepted) { + this.hoveredGroundItem = null + this.hoveredWarp = null + this.hoveredEntrance = null + if (this.canvas?.style) { + this.canvas.style.cursor = '' + } + } else { + const worldAtPointer = screenToWorld({ + clientX: this.lastClientPos.x, + clientY: this.lastClientPos.y, + rect, + canvas: this.canvas, + camX, + camY, + cameraZoom: zoom, + }) + this.hoveredGroundItem = this.engine.groundItems?.findAtPoint(worldAtPointer.x, worldAtPointer.y, 32) ?? null + this.syncLinkHoverAtWorld(worldAtPointer.x, worldAtPointer.y) + } + } + + const activeNpcMenuName = this.hudManager?.worldPanels?.npcMenu?.npcName?.toLowerCase() + for (const npc of this.engine.npcEntities ?? []) { + if (activeNpcMenuName && npc.def.name.toLowerCase() === activeNpcMenuName) continue + const topX = npc.frame ? npc.x + (npc.frame.offsetX ?? 0) + (npc.frame.width ?? 0) / 2 : npc.x + const topY = npc.frame ? npc.y + (npc.frame.offsetY ?? 0) : npc.y - MARKER_HEIGHT + const domX = rect.left + rect.width * 0.5 + (topX - camX) * zoom * scaleX + const domY = rect.top + rect.height * 0.5 + (topY - camY) * zoom * scaleY + + if (domX <= -100 || domX >= rect.width + 100) continue + if (domY <= -100 || domY >= rect.height + 100) continue + const label = this.labelPool[labelCount] ?? this.allocateLabelElement(container) + if (label.textContent !== npc.def.name) label.textContent = npc.def.name + label.className = 'd2-overhead-label d2-npc-label' + if (label.dataset) { + label.dataset.kind = 'npc' + delete label.dataset.toLevelId + delete label.dataset.hovered + } + label.style.color = '#c8a15a' + label.style.background = 'transparent' + label.style.border = 'none' + label.style.padding = '0' + label.style.borderRadius = '0' + label.style.fontWeight = 'normal' + label.style.boxShadow = 'none' + label.style.left = `${domX}px` + label.style.top = `${domY - 2}px` + label.style.fontSize = fontSizePx + label.hidden = false + labelCount += 1 + } + + const portalMouth = activePortalMouthOverride !== undefined + ? activePortalMouthOverride + : (this.portalSlot && runtime ? this.portalSlot.mouthInLevel(runtime.levelId) : null) + const portalPos = activePortalPosOverride !== undefined + ? activePortalPosOverride + : (portalMouth && runtime?.grid ? subTileToScene(portalMouth.x, portalMouth.y, runtime.grid) : null) + + if (portalMouth !== null && portalPos !== null) { + const topX = portalPos.x + const topY = portalPos.y - 94 + const domX = rect.left + rect.width * 0.5 + (topX - camX) * zoom * scaleX + const domY = rect.top + rect.height * 0.5 + (topY - camY) * zoom * scaleY + if (domX > -100 && domX < rect.width + 100 && domY > -100 && domY < rect.height + 100) { + const label = this.labelPool[labelCount] ?? this.allocateLabelElement(container) + const portalLabelText = `传送门 -> ${getLevelNames(portalMouth.toLevelId).nameZh}` + if (label.textContent !== portalLabelText) label.textContent = portalLabelText + label.className = 'd2-overhead-label d2-portal-label' + if (label.dataset) { + label.dataset.kind = 'portal' + label.dataset.toLevelId = String(portalMouth.toLevelId) + delete label.dataset.hovered + } + label.style.color = '#6cb4ff' + label.style.background = 'transparent' + label.style.border = 'none' + label.style.padding = '0' + label.style.borderRadius = '0' + label.style.fontWeight = 'normal' + label.style.boxShadow = 'none' + label.style.left = `${domX}px` + label.style.top = `${domY - 2}px` + label.style.fontSize = fontSizePx + label.hidden = false + labelCount += 1 + } + } + + if (runtime?.grid) { + for (const warp of runtime.warps ?? []) { + if (!(warp.toLevelId > 0)) continue + const pos = getWarpLabelWorldPos(warp, runtime.grid) + const domX = rect.left + rect.width * 0.5 + (pos.x - camX) * zoom * scaleX + const domY = rect.top + rect.height * 0.5 + (pos.y - camY) * zoom * scaleY + if (domX <= -100 || domX >= rect.width + 100 || domY <= -100 || domY >= rect.height + 100) continue + const label = this.labelPool[labelCount] ?? this.allocateLabelElement(container) + const destName = getLevelNames(warp.toLevelId).nameZh + const badgeText = `通往 ${destName}` + if (label.textContent !== badgeText) label.textContent = badgeText + const isHovered = this.hoveredWarp === warp + label.className = isHovered + ? 'd2-overhead-label d2-exit-badge is-hovered' + : 'd2-overhead-label d2-exit-badge' + if (label.dataset) { + label.dataset.kind = 'warp' + label.dataset.toLevelId = String(warp.toLevelId) + label.dataset.hovered = isHovered ? 'true' : 'false' + } + label.style.color = isHovered ? '#fff3c4' : '#f0d080' + label.style.background = isHovered ? 'rgba(44, 32, 12, 0.92)' : 'rgba(12, 10, 8, 0.78)' + label.style.border = isHovered ? '1px solid #ffd86b' : '1px solid rgba(200, 161, 90, 0.65)' + label.style.padding = '2px 7px' + label.style.borderRadius = '3px' + label.style.fontWeight = isHovered ? 'bold' : 'normal' + label.style.boxShadow = isHovered ? '0 0 8px rgba(255, 210, 90, 0.65)' : 'none' + label.style.left = `${domX}px` + label.style.top = `${domY - 2}px` + label.style.fontSize = fontSizePx + label.hidden = false + labelCount += 1 + } + + for (const entrance of runtime.entrances ?? []) { + if (!(entrance.toLevelId > 0)) continue + const pos = getEntranceLabelWorldPos(entrance, runtime.grid) + const domX = rect.left + rect.width * 0.5 + (pos.x - camX) * zoom * scaleX + const domY = rect.top + rect.height * 0.5 + (pos.y - camY) * zoom * scaleY + if (domX <= -100 || domX >= rect.width + 100 || domY <= -100 || domY >= rect.height + 100) continue + const label = this.labelPool[labelCount] ?? this.allocateLabelElement(container) + const destName = getLevelNames(entrance.toLevelId).nameZh + const badgeText = `通往 ${destName}` + if (label.textContent !== badgeText) label.textContent = badgeText + const isHovered = this.hoveredEntrance === entrance + label.className = isHovered + ? 'd2-overhead-label d2-exit-badge is-hovered' + : 'd2-overhead-label d2-exit-badge' + if (label.dataset) { + label.dataset.kind = 'entrance' + label.dataset.toLevelId = String(entrance.toLevelId) + label.dataset.hovered = isHovered ? 'true' : 'false' + } + label.style.color = isHovered ? '#fff3c4' : '#f0d080' + label.style.background = isHovered ? 'rgba(44, 32, 12, 0.92)' : 'rgba(12, 10, 8, 0.78)' + label.style.border = isHovered ? '1px solid #ffd86b' : '1px solid rgba(200, 161, 90, 0.65)' + label.style.padding = '2px 7px' + label.style.borderRadius = '3px' + label.style.fontWeight = isHovered ? 'bold' : 'normal' + label.style.boxShadow = isHovered ? '0 0 8px rgba(255, 210, 90, 0.65)' : 'none' + label.style.left = `${domX}px` + label.style.top = `${domY - 2}px` + label.style.fontSize = fontSizePx + label.hidden = false + labelCount += 1 + } + } + + for (let index = labelCount; index < this.labelPool.length; index += 1) { + const unused = this.labelPool[index]! + unused.hidden = true + if (unused.dataset && unused.dataset.hovered === 'true') { + unused.dataset.hovered = 'false' + } + if (unused.className.includes('is-hovered')) { + unused.className = unused.className.replace(/\s*is-hovered\b/g, '') + } + } } findNearestStash(worldX: number, worldY: number, maxRadius = 48): TownStashEntity | null { @@ -3573,6 +4349,7 @@ export class SceneMouseController { this.navTarget = { x: item.x, y: item.y } this.lastDist = dist this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.y } } } @@ -3619,6 +4396,7 @@ export class SceneMouseController { readonly onPointerDown = (e: PointerEvent): void => { if (e.button !== 0 && e.button !== 2) return + if (this.isTravelling?.()) return // Check HUD interception if (this.hudManager !== null) { @@ -3673,12 +4451,24 @@ export class SceneMouseController { const logical = this.hudManager.clientToLogical(e.clientX, e.clientY) if (this.hudManager.isPointInterceptedByHud(logical.x, logical.y)) { this.hoveredGroundItem = null + this.hoveredWarp = null + this.hoveredEntrance = null + if (this.canvas.style) { + this.canvas.style.cursor = '' + } + if (this.labelsContainer) { + this.updateOverheadLabels() + } return } } const { x: worldX, y: worldY } = this.getPointerWorldCoords(e.clientX, e.clientY) this.hoveredGroundItem = this.engine.groundItems.findAtPoint(worldX, worldY, 32) ?? null + this.syncLinkHoverAtWorld(worldX, worldY) + if (this.labelsContainer) { + this.updateOverheadLabels() + } if (this.isLeftMouseDown && !this.input.shiftHeld && this.activeClickType === 'ground') { this.navTarget = { x: worldX, y: worldY } @@ -3695,7 +4485,16 @@ export class SceneMouseController { if (target && target.closest?.('.d2-ground-label')) { return } + this.lastClientPos = null this.hoveredGroundItem = null + this.hoveredWarp = null + this.hoveredEntrance = null + if (this.canvas.style) { + this.canvas.style.cursor = '' + } + if (this.labelsContainer) { + this.updateOverheadLabels() + } } readonly onPointerUp = (e: PointerEvent): void => { @@ -3715,17 +4514,18 @@ export class SceneMouseController { } handleRightClickWorld(worldX: number, worldY: number): void { + const player = this.engine.world.player const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64 if (isPassiveSkill(rightSkillId) || isAuraSkill(rightSkillId)) { // Walk/Run fallback (auras are active automatically in D2 1.13c) this.pendingInteraction = null this.navTarget = { x: worldX, y: worldY } - this.lastDist = Math.hypot(worldX - this.engine.world.player.x, worldY - this.engine.world.player.y) + this.lastDist = Math.hypot(worldX - player.x, worldY - player.y) this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.y } return } - const player = this.engine.world.player const range = getSkillRange(rightSkillId, this.hudManager) // 1. Check Monster: living monster within ~36 px @@ -3753,6 +4553,9 @@ export class SceneMouseController { } else { this.pendingInteraction = { kind: 'monster', monster: clickedMonster, skillId: rightSkillId } this.navTarget = { x: clickedMonster.x, y: clickedMonster.y } + this.lastDist = distToPlayer + this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.y } } return } @@ -3771,6 +4574,7 @@ export class SceneMouseController { this.navTarget = { x: worldX, y: worldY } this.lastDist = Math.hypot(worldX - player.x, worldY - player.y) this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.y } } } else { this.navTarget = null @@ -3867,6 +4671,9 @@ export class SceneMouseController { ;(this.engine as any).dialog = null this.pendingInteraction = { kind: 'npc', npc: clickedNpc } this.navTarget = { x: clickedNpc.x, y: clickedNpc.y } + this.lastDist = distToPlayer + this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.y } } return } @@ -3904,6 +4711,9 @@ export class SceneMouseController { } else { this.pendingInteraction = { kind: 'waypoint', waypoint: clickedWp.wp, worldPos: clickedWp.pos } this.navTarget = { x: clickedWp.pos.x, y: clickedWp.pos.y } + this.lastDist = distToPlayer + this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.y } } return } @@ -3920,6 +4730,9 @@ export class SceneMouseController { } else { this.pendingInteraction = { kind: 'stash', stash: clickedStash } this.navTarget = { x: clickedStash.x, y: clickedStash.y } + this.lastDist = distToPlayer + this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.y } } return } @@ -3936,6 +4749,8 @@ export class SceneMouseController { if (distToPlayer <= 80) { // OperateRange = 2 tiles (~80 px) this.navTarget = null this.pendingInteraction = null + this.isLeftMouseDown = false + this.activeClickType = 'none' player.facing = facingOf(portalPos.x - player.x, portalPos.y - player.y) this.onEnterPortal?.() } else { @@ -3943,6 +4758,7 @@ export class SceneMouseController { this.navTarget = { x: portalPos.x, y: portalPos.y } this.lastDist = distToPlayer this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.y } } return } @@ -3975,6 +4791,9 @@ export class SceneMouseController { } else { this.pendingInteraction = { kind: 'monster', monster: clickedMonster, skillId: leftSkillId } this.navTarget = { x: clickedMonster.x, y: clickedMonster.y } + this.lastDist = distToPlayer + this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.y } } return } @@ -3986,6 +4805,71 @@ export class SceneMouseController { return } + // 5b. Check SceneWarp & SceneEntrance (LvlWarp.txt bounding box + radial proximity) + if (runtime.grid) { + let bestWarp: { warp: SceneWarp; dist: number; anchorPos: { x: number; y: number }; arrivePos: { x: number; y: number } } | null = null + for (const warp of runtime.warps ?? []) { + const res = hitTestWarp(warp, worldX, worldY, runtime.grid) + if (res.hit && (bestWarp === null || res.dist < bestWarp.dist)) { + bestWarp = { warp, dist: res.dist, anchorPos: res.anchorPos, arrivePos: res.arrivePos } + } + } + + let bestEntrance: { entrance: SceneEntrance; dist: number; anchorPos: { x: number; y: number }; arrivePos: { x: number; y: number } } | null = null + for (const entrance of runtime.entrances ?? []) { + const res = hitTestEntrance(entrance, worldX, worldY, runtime.grid) + if (res.hit && (bestEntrance === null || res.dist < bestEntrance.dist)) { + bestEntrance = { entrance, dist: res.dist, anchorPos: res.anchorPos, arrivePos: res.arrivePos } + } + } + + if (bestWarp !== null && (bestEntrance === null || bestWarp.dist <= bestEntrance.dist)) { + this.activeClickType = 'warp' + ;(this.engine as any).dialog = null + const { warp, anchorPos, arrivePos } = bestWarp + if (isPlayerWithinLinkRange(player.x, player.y, warp, runtime.grid, WARP_TRIGGER_SUBTILES)) { + this.navTarget = null + this.pendingInteraction = null + this.isLeftMouseDown = false + this.activeClickType = 'none' + player.facing = facingOf(anchorPos.x - player.x, anchorPos.y - player.y) + const destName = getLevelNames(warp.toLevelId).nameZh + this.status.textContent = `进入:${destName}` + this.onEnterWarp?.(warp) + } else { + this.pendingInteraction = { kind: 'warp', warp, anchorPos, arrivePos } + this.navTarget = { x: arrivePos.x, y: arrivePos.y } + this.lastDist = Math.hypot(arrivePos.x - player.x, arrivePos.y - player.y) + this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.y } + } + return + } + + if (bestEntrance !== null) { + this.activeClickType = 'entrance' + ;(this.engine as any).dialog = null + const { entrance, anchorPos, arrivePos } = bestEntrance + if (isPlayerWithinLinkRange(player.x, player.y, entrance, runtime.grid, WARP_TRIGGER_SUBTILES)) { + this.navTarget = null + this.pendingInteraction = null + this.isLeftMouseDown = false + this.activeClickType = 'none' + player.facing = facingOf(anchorPos.x - player.x, anchorPos.y - player.y) + const destName = getLevelNames(entrance.toLevelId).nameZh + this.status.textContent = `进入:${destName}` + this.onEnterEntrance?.(entrance) + } else { + this.pendingInteraction = { kind: 'entrance', entrance, anchorPos, arrivePos } + this.navTarget = { x: arrivePos.x, y: arrivePos.y } + this.lastDist = Math.hypot(arrivePos.x - player.x, arrivePos.y - player.y) + this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.y } + } + return + } + } + // 6. Pure ground click to move: this.activeClickType = 'ground' ;(this.engine as any).dialog = null @@ -3993,6 +4877,7 @@ export class SceneMouseController { this.navTarget = { x: worldX, y: worldY } this.lastDist = Math.hypot(worldX - player.x, worldY - player.y) this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.y } } getPointerWorldCoords(clientX: number, clientY: number): { x: number; y: number } { @@ -4035,6 +4920,8 @@ export class SceneMouseController { portalSlot: this.portalSlot, onEnterPortal: this.onEnterPortal, onCastTownPortal: this.onCastTownPortal, + onEnterWarp: this.onEnterWarp, + onEnterEntrance: this.onEnterEntrance, }) } @@ -4046,21 +4933,38 @@ export class SceneMouseController { this.isRightMouseDown = false this.lastClientPos = null this.hoveredGroundItem = null + this.hoveredWarp = null + this.hoveredEntrance = null + if (this.canvas?.style) { + this.canvas.style.cursor = '' + } this.stuckTicks = 0 this.lastDist = -1 + this.lastPlayerPos = null ;(this.engine as any).dialog = null const player = this.engine.world.player player.infernoChanneling = false player.infernoManaRemainder256 = 0 + if (this.labelsContainer) { + this.updateOverheadLabels() + } } tick(): { movement: { x: number; y: number }; attacking: boolean } { + if (this.isTravelling?.()) { + this.navTarget = null + this.pendingInteraction = null + this.lastPlayerPos = null + return { movement: { x: 0, y: 0 }, attacking: false } + } + const keyMovement = this.input.movement() const isKeyMoving = keyMovement.x !== 0 || keyMovement.y !== 0 if (isKeyMoving) { this.navTarget = null this.pendingInteraction = null + this.lastPlayerPos = null const player = this.engine.world.player player.infernoChanneling = false player.infernoManaRemainder256 = 0 @@ -4079,6 +4983,7 @@ export class SceneMouseController { if (this.input.shiftHeld) { this.navTarget = null this.pendingInteraction = null + this.lastPlayerPos = null if (this.isLeftMouseDown && this.lastClientPos !== null && player.cooldown === 0) { const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y) const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47 @@ -4107,9 +5012,15 @@ export class SceneMouseController { } else if (rightSkillId === 0 && !this.input.shiftHeld && this.activeClickType === 'ground') { const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y) this.navTarget = { x: worldX, y: worldY } - } else if (player.cooldown === 0) { + } else if ( + player.cooldown === 0 && + rightSkillId !== 43 && + rightSkillId !== 219 && + rightSkillId !== 220 + ) { this.navTarget = null this.pendingInteraction = null + this.lastPlayerPos = null const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y) this.castSkill(rightSkillId, worldX, worldY) } @@ -4123,6 +5034,7 @@ export class SceneMouseController { if (dist <= 80) { this.navTarget = null this.pendingInteraction = null + this.lastPlayerPos = null this.interactWithNpc(npc) } else { this.navTarget = { x: npc.x, y: npc.y } @@ -4133,6 +5045,7 @@ export class SceneMouseController { if (dist <= 80) { this.navTarget = null this.pendingInteraction = null + this.lastPlayerPos = null player.facing = facingOf(worldPos.x - player.x, worldPos.y - player.y) if (this.waypointNetwork.activate(waypoint.waypointId)) { this.status.textContent = `传送点已激活:${this.getRuntime().level}` @@ -4153,6 +5066,7 @@ export class SceneMouseController { if (dist <= stash.interactionRangePx) { this.navTarget = null this.pendingInteraction = null + this.lastPlayerPos = null this.openStash(stash) } else { this.navTarget = { x: stash.x, y: stash.y } @@ -4163,16 +5077,52 @@ export class SceneMouseController { if (dist <= 80) { this.navTarget = null this.pendingInteraction = null + this.lastPlayerPos = null + this.isLeftMouseDown = false + this.activeClickType = 'none' player.facing = facingOf(worldPos.x - player.x, worldPos.y - player.y) this.onEnterPortal?.() } else { this.navTarget = { x: worldPos.x, y: worldPos.y } } + } else if (this.pendingInteraction.kind === 'warp') { + const { warp, anchorPos, arrivePos } = this.pendingInteraction + const runtime = this.getRuntime() + if (isPlayerWithinLinkRange(player.x, player.y, warp, runtime.grid, WARP_TRIGGER_SUBTILES)) { + this.navTarget = null + this.pendingInteraction = null + this.lastPlayerPos = null + this.isLeftMouseDown = false + this.activeClickType = 'none' + player.facing = facingOf(anchorPos.x - player.x, anchorPos.y - player.y) + const destName = getLevelNames(warp.toLevelId).nameZh + this.status.textContent = `进入:${destName}` + this.onEnterWarp?.(warp) + } else { + this.navTarget = { x: arrivePos.x, y: arrivePos.y } + } + } else if (this.pendingInteraction.kind === 'entrance') { + const { entrance, anchorPos, arrivePos } = this.pendingInteraction + const runtime = this.getRuntime() + if (isPlayerWithinLinkRange(player.x, player.y, entrance, runtime.grid, WARP_TRIGGER_SUBTILES)) { + this.navTarget = null + this.pendingInteraction = null + this.lastPlayerPos = null + this.isLeftMouseDown = false + this.activeClickType = 'none' + player.facing = facingOf(anchorPos.x - player.x, anchorPos.y - player.y) + const destName = getLevelNames(entrance.toLevelId).nameZh + this.status.textContent = `进入:${destName}` + this.onEnterEntrance?.(entrance) + } else { + this.navTarget = { x: arrivePos.x, y: arrivePos.y } + } } else if (this.pendingInteraction.kind === 'monster') { const monster = this.pendingInteraction.monster if (monster.state === 'dead' || monster.hp <= 0) { this.navTarget = null this.pendingInteraction = null + this.lastPlayerPos = null } else { const skillId = this.pendingInteraction.skillId ?? (this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47) const range = getSkillRange(skillId, this.hudManager) @@ -4180,6 +5130,7 @@ export class SceneMouseController { if (dist <= range) { this.navTarget = null this.pendingInteraction = null + this.lastPlayerPos = null player.facing = facingOf(monster.x - player.x, monster.y - player.y) this.castSkill(skillId, monster.x, monster.y) } else { @@ -4192,6 +5143,7 @@ export class SceneMouseController { if (isWithinPickupBounds(player.x, player.y, item.x, item.y, maxEdgeReach)) { this.navTarget = null this.pendingInteraction = null + this.lastPlayerPos = null this.pickupGroundItem(item) } else { this.navTarget = { x: item.x, y: item.y } @@ -4208,16 +5160,23 @@ export class SceneMouseController { this.navTarget = null this.stuckTicks = 0 this.lastDist = -1 + this.lastPlayerPos = null return { movement: { x: 0, y: 0 }, attacking: false } } - if (this.lastDist >= 0 && Math.abs(this.lastDist - dist) < 0.5) { + const playerStep = this.lastPlayerPos !== null + ? Math.hypot(player.x - this.lastPlayerPos.x, player.y - this.lastPlayerPos.y) + : Infinity + this.lastPlayerPos = { x: player.x, y: player.y } + + if (this.lastDist >= 0 && Math.abs(this.lastDist - dist) < 0.5 && playerStep < 0.5) { this.stuckTicks++ if (this.stuckTicks >= 12) { this.navTarget = null this.pendingInteraction = null this.stuckTicks = 0 this.lastDist = -1 + this.lastPlayerPos = null return { movement: { x: 0, y: 0 }, attacking: false } } } else { @@ -4225,16 +5184,30 @@ export class SceneMouseController { this.lastDist = dist } + const steerTarget = resolveObstacleAwareSteerTarget( + player.x, + player.y, + this.navTarget, + this.getRuntime()?.grid, + ) + const steerDx = steerTarget.x - player.x + const steerDy = steerTarget.y - player.y + const steerDist = Math.hypot(steerDx, steerDy) + const normDist = steerDist > 1e-4 ? steerDist : dist + const moveDx = steerDist > 1e-4 ? steerDx : dx + const moveDy = steerDist > 1e-4 ? steerDy : dy + const runFactor = (this.hudManager && this.hudManager.isRunning) ? 1.5 : 1.0 return { movement: { - x: (dx / dist) * runFactor, - y: (dy / dist) * runFactor, + x: (moveDx / normDist) * runFactor, + y: (moveDy / normDist) * runFactor, }, attacking: false, } } + this.lastPlayerPos = null return { movement: { x: 0, y: 0 }, attacking: false } } @@ -4264,6 +5237,14 @@ export class SceneMouseController { window.removeEventListener('pointerup', this.onPointerUp) window.removeEventListener('pointercancel', this.onPointerUp) } + + this.isLeftMouseDown = false + this.isRightMouseDown = false + this.lastClientPos = null + this.clearTarget() + for (const label of this.labelPool) { + label.hidden = true + } } } @@ -6431,10 +7412,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st // the latter allocates a throwaway array 60 times a second. const countPages = (): number => countLoadedPages(runtime.pages) - // NPC name plates. The container and the elements outlive a frame so the - // render loop only has to move them. + // NPC and exit name plates. The container and the elements outlive a frame so + // the render loop only has to move them. const labelsContainer = document.querySelector('#labels') - const labelPool: HTMLDivElement[] = [] // Ground item Alt floating labels container and overlay manager const groundLabelsContainer = document.querySelector('#ground-labels') ?? (() => { @@ -6839,12 +7819,18 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st return { x: Math.round((dy + dx) / 2), y: Math.round((dy - dx) / 2) } } + /** True while a transition is in flight, so nothing triggers a second one. */ + let travelling = false + let lastContactWaypointId: number | null = null + let minimapCache: { key: string; level: MinimapLevel } | null = null + /** * Cast a Town Portal from the current non-town level, placing the wilderness * mouth on a walkable sub-tile next to the player and the town mouth at the * authentic 1.13c DS1 Object #33 (`Objects.txt` ID 59) Town Portal Spot. */ const castTownPortal = (): boolean => { + if (travelling) return false if (isTownLevel(runtime.levelId)) { playInventoryFullFeedback() showNotification('无法在城镇中开启回城传送门', 2000, status) @@ -6939,11 +7925,6 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st } } - /** True while a transition is in flight, so nothing triggers a second one. */ - let travelling = false - let lastContactWaypointId: number | null = null - let minimapCache: { key: string; level: MinimapLevel } | null = null - /** * Enter the open Town Portal from the current level: * - Entering from wilderness transitions to town beside the Town Portal and keeps the portal open. @@ -6981,6 +7962,14 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st portalSlot, onEnterPortal: enterTownPortal, onCastTownPortal: castTownPortal, + onEnterWarp: (warp) => { + void travel(warp.toLevelId, WARP_FADE_MS) + }, + onEnterEntrance: (entrance) => { + void travel(entrance.toLevelId, SEAM_FADE_MS, undefined, entrance.side) + }, + isTravelling: () => travelling, + labelsContainer, }) mouseController.attach() if (groundLabelOverlay !== null) { @@ -7178,6 +8167,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st hudManager.worldPanels.setCurrentWaypoint(next.waypoints[0]?.waypointId ?? null, next.levelId ?? null) hudManager.worldPanels.selectedActTab = next.act } + mouseController?.clearTarget() } /** @@ -7200,6 +8190,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st ): Promise => { if (travelling || runtime.source !== 'pack') return travelling = true + mouseController.clearTarget() const fromLevelId = runtime.levelId try { await fade.out(fadeMs) @@ -7286,6 +8277,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st ): Promise => { if (travelling || runtime.source !== 'pack') return travelling = true + mouseController.clearTarget() try { await fade.out(WARP_FADE_MS) const index = await getPackIndex(runtime.base) @@ -7352,9 +8344,8 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st * Act on whatever the player is standing on. * * Seams fire on contact — the player walked off the edge of the map, and - * asking them to press a key for that would be strange. Warps, waypoints and - * portals need the use key, because they sit in the middle of walkable ground - * and stepping on one by accident is normal. + * asking them to press a key for that would be strange. Warps and portals are + * activated via mouse click (or Telekinesis) rather than keyboard polling. */ const checkLinks = (): void => { if (travelling || runtime.source !== 'pack') return @@ -7363,25 +8354,12 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st Math.abs(here.x - x) <= radius && Math.abs(here.y - y) <= radius for (const entrance of runtime.entrances) { + if (!(entrance.toLevelId > 0)) continue if (!near(entrance.x, entrance.y, SEAM_TRIGGER_SUBTILES)) continue void travel(entrance.toLevelId, SEAM_FADE_MS, undefined, entrance.side) return } - const wantsUse = input.takeUse() - if (wantsUse) { - for (const warp of runtime.warps) { - if (!near(warp.x, warp.y, WARP_TRIGGER_SUBTILES)) continue - void travel(warp.toLevelId, WARP_FADE_MS) - return - } - const activeMouth = portalSlot.mouthInLevel(runtime.levelId) - if (activeMouth !== null && near(activeMouth.x, activeMouth.y, WARP_TRIGGER_SUBTILES)) { - enterTownPortal() - return - } - } - let nearWaypoint = false for (const waypoint of runtime.waypoints) { if (!near(waypoint.x, waypoint.y, WARP_TRIGGER_SUBTILES)) continue @@ -8156,75 +9134,10 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st } // The labels are recycled rather than rebuilt: `innerHTML = ''` plus one - // `createElement` per NPC per frame forced a full style recalculation and - // made the DOM, not the GPU, the slowest part of some town frames. + // `createElement` per NPC/exit per frame forced a full style recalculation + // and made the DOM, not the GPU, the slowest part of some town frames. if (labelsContainer !== null) { - let labelCount = 0 - const rect = canvas.getBoundingClientRect() - const scaleX = canvas.width > 0 ? rect.width / canvas.width : 1 - const scaleY = canvas.height > 0 ? rect.height / canvas.height : 1 - const fontSizePx = `${Math.min(24, Math.max(11, Math.round(13 * Math.sqrt(camera.zoom))))}px` - - const activeNpcMenuName = hudManager?.worldPanels?.npcMenu?.npcName?.toLowerCase() - for (const npc of engine.npcEntities) { - if (activeNpcMenuName && npc.def.name.toLowerCase() === activeNpcMenuName) continue - const topX = npc.frame ? npc.x + (npc.frame.offsetX ?? 0) + (npc.frame.width ?? 0) / 2 : npc.x - const topY = npc.frame ? npc.y + (npc.frame.offsetY ?? 0) : npc.y - MARKER_HEIGHT - const domX = rect.left + rect.width * 0.5 + (topX - camX) * camera.zoom * scaleX - const domY = rect.top + rect.height * 0.5 + (topY - camY) * camera.zoom * scaleY - - if (domX <= -100 || domX >= rect.width + 100) continue - if (domY <= -100 || domY >= rect.height + 100) continue - let label = labelPool[labelCount] - if (label === undefined) { - label = document.createElement('div') - label.style.position = 'absolute' - label.style.transform = 'translate(-50%, -100%)' - label.style.color = '#c8a15a' - label.style.textShadow = '1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000' - label.style.whiteSpace = 'nowrap' - label.style.pointerEvents = 'none' - labelPool.push(label) - labelsContainer.appendChild(label) - } - if (label.textContent !== npc.def.name) label.textContent = npc.def.name - label.style.color = '#c8a15a' - label.style.left = `${domX}px` - label.style.top = `${domY - 2}px` - label.style.fontSize = fontSizePx - label.hidden = false - labelCount += 1 - } - - if (activePortalMouth !== null && activePortalPos !== null) { - const topX = activePortalPos.x - const topY = activePortalPos.y - 94 - const domX = rect.left + rect.width * 0.5 + (topX - camX) * camera.zoom * scaleX - const domY = rect.top + rect.height * 0.5 + (topY - camY) * camera.zoom * scaleY - if (domX > -100 && domX < rect.width + 100 && domY > -100 && domY < rect.height + 100) { - let label = labelPool[labelCount] - if (label === undefined) { - label = document.createElement('div') - label.style.position = 'absolute' - label.style.transform = 'translate(-50%, -100%)' - label.style.textShadow = '1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000' - label.style.whiteSpace = 'nowrap' - label.style.pointerEvents = 'none' - labelPool.push(label) - labelsContainer.appendChild(label) - } - const portalLabelText = `传送门 -> ${getLevelNames(activePortalMouth.toLevelId).nameZh}` - if (label.textContent !== portalLabelText) label.textContent = portalLabelText - label.style.color = '#6cb4ff' - label.style.left = `${domX}px` - label.style.top = `${domY - 2}px` - label.style.fontSize = fontSizePx - label.hidden = false - labelCount += 1 - } - } - - for (let index = labelCount; index < labelPool.length; index += 1) labelPool[index]!.hidden = true + mouseController.updateOverheadLabels(camX, camY, activePortalMouth, activePortalPos) } if (groundLabelOverlay !== null) { diff --git a/src/sim/input.ts b/src/sim/input.ts index f873879..d2ca4c0 100644 --- a/src/sim/input.ts +++ b/src/sim/input.ts @@ -47,7 +47,7 @@ const LOAD_KEYS = new Set(['KeyL']) /** Keys that toggle the automap. `Tab` is where Diablo II puts it. */ const MAP_KEYS = new Set(['Tab', 'KeyM']) /** Keys that take the stairs, cave mouth or portal the player is standing on. */ -const USE_KEYS = new Set(['KeyG']) +const USE_KEYS = new Set() /** Keys that cast a town portal. */ const PORTAL_KEYS = new Set(['KeyP']) /** Keys that open the waypoint destination list. */ diff --git a/tests/input-controls.test.ts b/tests/input-controls.test.ts index d306b21..b414716 100644 --- a/tests/input-controls.test.ts +++ b/tests/input-controls.test.ts @@ -3,12 +3,17 @@ * 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, @@ -17,6 +22,14 @@ import { isPassiveSkill, getSkillRange, getSkillMana, + TELEKINESIS_RANGE_PX, + getWarpLabelWorldPos, + getEntranceLabelWorldPos, + hitTestWarp, + hitTestEntrance, + isPlayerWithinLinkRange, + resolveObstacleAwareSteerTarget, + sceneToSubTile, type MapRuntime, } from '../src/scene/act-scene.ts' @@ -31,7 +44,7 @@ class MockCanvas extends EventTarget { clientHeight = 1080 width = 1920 height = 1080 - style = { pointerEvents: 'none' } + style = { pointerEvents: 'none', cursor: '' } getBoundingClientRect() { return { left: 0, top: 0, width: 1920, height: 1080 } } @@ -313,6 +326,14 @@ function createTestScene(options?: { 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 }) { @@ -452,8 +473,8 @@ function createTestScene(options?: { x: n.x, y: n.y, })), - entrances: [], - warps: [], + entrances: options?.entrances ?? [], + warps: options?.warps ?? [], waypoints: (options?.waypoints ?? []).map(wp => { const dx = wp.x / 16 const dy = wp.y / 8 @@ -490,6 +511,12 @@ function createTestScene(options?: { 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() @@ -973,3 +1000,1312 @@ describe('Milestone 2: Mouse Interaction, Click-to-Move, and Combat Controls (R2 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 172 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) + expect(entranceCount).toBe(172) + }) + + 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 }) + }) +}) + +