diff --git a/src/datastruct/DoublyLinkList.ts b/src/datastruct/DoublyLinkList.ts index 4987245ac..9aabed5bb 100644 --- a/src/datastruct/DoublyLinkList.ts +++ b/src/datastruct/DoublyLinkList.ts @@ -9,6 +9,10 @@ export default class DoublyLinkList { this.sentinel.prev2 = this.sentinel; } + isEmpty(): boolean { + return this.sentinel.next2 === this.sentinel; + } + addTail(node: T): void { if (node.prev2) { node.unlink2(); diff --git a/src/engine/GameMap.ts b/src/engine/GameMap.ts index 5fc561aa6..faa846699 100644 --- a/src/engine/GameMap.ts +++ b/src/engine/GameMap.ts @@ -1,7 +1,6 @@ import fs from 'fs'; -import { CollisionFlag, CollisionType, LocAngle, LocLayer } from '@2004scape/rsmod-pathfinder'; -import * as rsmod from '@2004scape/rsmod-pathfinder'; +import rsmod, { CollisionFlag, CollisionType, LocAngle, LocLayer } from '#/engine/routefinder/index.js'; import LocType from '#/cache/config/LocType.js'; import NpcType from '#/cache/config/NpcType.js'; @@ -50,32 +49,40 @@ export default class GameMap { return; } - printDebug('Loading game map'); + // Allow zones to be auto-created during map loading + this.zonemap.beginInitialization(); - if (fs.existsSync(`${Environment.BUILD_SRC_DIR}/maps/multiway.csv`)) { - this.loadCsvMap(this.multimap, fs.readFileSync(`${Environment.BUILD_SRC_DIR}/maps/multiway.csv`, 'ascii').split(/\r?\n/)); - } + try { + printDebug('Loading game map'); - if (fs.existsSync(`${Environment.BUILD_SRC_DIR}/maps/free2play.csv`)) { - this.loadCsvMap(this.freemap, fs.readFileSync(`${Environment.BUILD_SRC_DIR}/maps/free2play.csv`, 'ascii').split(/\r?\n/)); - } + if (fs.existsSync(`${Environment.BUILD_SRC_DIR}/maps/multiway.csv`)) { + this.loadCsvMap(this.multimap, fs.readFileSync(`${Environment.BUILD_SRC_DIR}/maps/multiway.csv`, 'ascii').split(/\r?\n/)); + } - const path: string = 'data/pack/server/maps/'; - const maps: string[] = fs.readdirSync(path).filter(x => x[0] === 'm'); - for (let index: number = 0; index < maps.length; index++) { - const [mx, mz] = maps[index].substring(1).split('_').map(Number); - const mapsquareX: number = mx << 6; - const mapsquareZ: number = mz << 6; - - this.loadNpcs(Packet.load(`${path}n${mx}_${mz}`), mapsquareX, mapsquareZ); - this.loadObjs(Packet.load(`${path}o${mx}_${mz}`), mapsquareX, mapsquareZ); - // collision - const lands: Int8Array = new Int8Array(GameMap.MAPSQUARE); // 4 * 64 * 64 size is guaranteed for lands - this.loadGround(lands, Packet.load(`${path}m${mx}_${mz}`), mapsquareX, mapsquareZ); - this.loadLocations(lands, Packet.load(`${path}l${mx}_${mz}`), mapsquareX, mapsquareZ); - } + if (fs.existsSync(`${Environment.BUILD_SRC_DIR}/maps/free2play.csv`)) { + this.loadCsvMap(this.freemap, fs.readFileSync(`${Environment.BUILD_SRC_DIR}/maps/free2play.csv`, 'ascii').split(/\r?\n/)); + } + + const path: string = 'data/pack/server/maps/'; + const maps: string[] = fs.readdirSync(path).filter(x => x[0] === 'm'); + for (let index: number = 0; index < maps.length; index++) { + const [mx, mz] = maps[index].substring(1).split('_').map(Number); + const mapsquareX: number = mx << 6; + const mapsquareZ: number = mz << 6; + + this.loadNpcs(Packet.load(`${path}n${mx}_${mz}`), mapsquareX, mapsquareZ); + this.loadObjs(Packet.load(`${path}o${mx}_${mz}`), mapsquareX, mapsquareZ); + // collision + const lands: Int8Array = new Int8Array(GameMap.MAPSQUARE); // 4 * 64 * 64 size is guaranteed for lands + this.loadGround(lands, Packet.load(`${path}m${mx}_${mz}`), mapsquareX, mapsquareZ); + this.loadLocations(lands, Packet.load(`${path}l${mx}_${mz}`), mapsquareX, mapsquareZ); + } - printDebug(`${World.getTotalNpcs()}/${Environment.NODE_MAX_NPCS} static NPCs added`); + printDebug(`${World.getTotalNpcs()}/${Environment.NODE_MAX_NPCS} static NPCs added`); + } finally { + // Lock down zone creation after map is fully loaded + this.zonemap.endInitialization(); + } } isMulti(coord: number): boolean { @@ -88,11 +95,52 @@ export default class GameMap { } getZone(x: number, z: number, level: number): Zone { - return this.zonemap.zone(x, z, level); + return this.zonemap.getZone(x, z, level); } getZoneIndex(zoneIndex: number): Zone { - return this.zonemap.zoneByIndex(zoneIndex); + return this.zonemap.getZoneByIndex(zoneIndex); + } + + getZoneIndexIfExists(zoneIndex: number): Zone | null { + return this.zonemap.getZoneByIndexIfExists(zoneIndex); + } + + /** + * Get a zone if it exists, or null if it hasn't been created. + */ + getZoneIfExists(x: number, z: number, level: number): Zone | null { + return this.zonemap.getZoneIfExists(x, z, level); + } + + /** + * Create a new zone during world startup. Should only be called from init(). + */ + createZone(x: number, z: number, level: number): Zone { + return this.zonemap.createZone(x, z, level); + } + + /** + * Create a new InstanceZone. Should only be called during instance creation. + */ + createInstanceZone(zoneIndex: number): Zone { + return this.zonemap.createInstanceZone(zoneIndex); + } + + hasZone(x: number, z: number, level: number): boolean { + return this.zonemap.hasZone(x, z, level); + } + + isInitializing(): boolean { + return this.zonemap.isInitializingMap(); + } + + addZone(zone: Zone): Zone { + return this.zonemap.addZone(zone); + } + + removeZone(index: number): boolean { + return this.zonemap.removeZone(index); } getZoneGrid(level: number): ZoneGrid { @@ -260,7 +308,9 @@ export default class GameMap { changeLocCollision(shape, angle, type.blockrange, length, width, type.active, absoluteX, absoluteZ, actualLevel, true); } - this.getZone(absoluteX, absoluteZ, actualLevel).addStaticLoc(new Loc(actualLevel, absoluteX, absoluteZ, width, length, EntityLifeCycle.RESPAWN, locId, shape, angle)); + if (type.active === 1) { + this.getZone(absoluteX, absoluteZ, actualLevel).addStaticLoc(new Loc(actualLevel, absoluteX, absoluteZ, width, length, EntityLifeCycle.RESPAWN, locId, shape, angle)); + } } locIdOffset = packet.gsmarts(); } diff --git a/src/engine/InstanceController.ts b/src/engine/InstanceController.ts new file mode 100644 index 000000000..a32e2c1b0 --- /dev/null +++ b/src/engine/InstanceController.ts @@ -0,0 +1,333 @@ +import { CoordGrid } from '#/engine/CoordGrid.js'; +import { isZoneAllocated } from '#/engine/GameMap.js'; +import routeFinder from '#/engine/routefinder/index.js'; +import World from '#/engine/World.js'; +import InstanceZone from '#/engine/zone/InstanceZone.js'; +import ZoneMap from '#/engine/zone/ZoneMap.js'; +import { printDebug } from '#/util/Logger.js'; + +type InstanceRecord = { + uid: number; + sw: CoordGrid; + floors: number; + zonesEast: number; + zonesNorth: number; + exitCoord: CoordGrid | null; +}; + +export default class InstanceController { + static readonly FIRST_INSTANCE_SW_MAPSQUARE: number = 25857; // m101_1 + + static readonly INSTANCES_PER_ROW: number = 32; + static readonly INSTANCE_ROWS: number = 64; + static readonly TOTAL_INSTANCES: number = InstanceController.INSTANCES_PER_ROW * InstanceController.INSTANCE_ROWS; + + static readonly INSTANCE_SIZE_TILES: number = 128; + static readonly INSTANCE_GAP_TILES: number = 64; + static readonly INSTANCE_SW_STRIDE_TILES: number = InstanceController.INSTANCE_SIZE_TILES + InstanceController.INSTANCE_GAP_TILES; + static readonly MAX_MISSING_SOURCE_ZONE_LOGS: number = 5; + static readonly DEBUG_INSTANCE_COPY_VERBOSE: boolean = false; + + nextInstancePointer: number = 0; + readonly instances: InstanceRecord[] = []; + private missingSourceZoneLogCount: number = 0; + + // --- + // Public methods + // --- + + /** + * Create a new instance record and reserve a slot in the global instance grid. + * This exists to give scripts a stable instance identity/footprint first, + * while zones are materialized lazily as each chunk is copied in. + */ + createInstance(floors: number, zonesEast: number, zonesNorth: number): CoordGrid { + this.missingSourceZoneLogCount = 0; + + // Reclaim all stale instance slots, then find the next available slot pointer. + if (InstanceController.DEBUG_INSTANCE_COPY_VERBOSE) { + printDebug(`[Instance] createInstance request floors=${floors}, zonesEast=${zonesEast}, zonesNorth=${zonesNorth}, nextPointer=${this.nextInstancePointer}`); + } + this.clearStaleInstances(); + this.findNextSlot(); + + // Instances are laid out in a fixed grid of 128x128 tiles, with a 64-tile buffer between them. + const slotX: number = this.nextInstancePointer % InstanceController.INSTANCES_PER_ROW; + const slotZ: number = Math.trunc(this.nextInstancePointer / InstanceController.INSTANCES_PER_ROW); + const firstMapsquareX: number = InstanceController.FIRST_INSTANCE_SW_MAPSQUARE >> 8; + const firstMapsquareZ: number = InstanceController.FIRST_INSTANCE_SW_MAPSQUARE & 0xff; + const baseTileX: number = (firstMapsquareX << 6) + slotX * InstanceController.INSTANCE_SW_STRIDE_TILES; + const baseTileZ: number = (firstMapsquareZ << 6) + slotZ * InstanceController.INSTANCE_SW_STRIDE_TILES; + const uid: number = this.nextInstancePointer; + const sw: CoordGrid = { level: 0, x: baseTileX, z: baseTileZ }; + if (InstanceController.DEBUG_INSTANCE_COPY_VERBOSE) { + printDebug(`[Instance] selected slot pointer=${uid}, slot=(${slotX},${slotZ}), sw=(${sw.x},${sw.z},L0)`); + } + + // Keep only the instance metadata here; zones are materialized lazily when copied from the overworld. + this.instances.push({ + uid, + sw, + floors, + zonesEast, + zonesNorth, + exitCoord: null + }); + + this.incrementSlotPointer(); + return sw; + } + + /** + * Copy one source zone into a destination chunk of an existing instance. + * This is purposed to be the single controlled path for lazy instance chunk + * creation, bounds enforcement, and source->instance template assignment. + */ + copyZone(instanceSw: CoordGrid, instanceOffset: CoordGrid, source: CoordGrid, rotation: 0 | 1 | 2 | 3): void { + const instance = this.instances.find(candidate => candidate.sw.level === instanceSw.level && candidate.sw.x === instanceSw.x && candidate.sw.z === instanceSw.z); + if (!instance) { + throw new Error(`copyZone failed: instance not found at sw=(${instanceSw.x}, ${instanceSw.z}, L${instanceSw.level})`); + } + + if (instanceOffset.level < 0 || instanceOffset.level >= instance.floors || instanceOffset.x < 0 || instanceOffset.x >= instance.zonesEast || instanceOffset.z < 0 || instanceOffset.z >= instance.zonesNorth) { + throw new Error( + `copyZone out of bounds: offset=(${instanceOffset.x}, ${instanceOffset.z}, L${instanceOffset.level}) size=(${instance.zonesEast}, ${instance.zonesNorth}, floors=${instance.floors}) sw=(${instanceSw.x}, ${instanceSw.z}, L${instanceSw.level})` + ); + } + + const target: CoordGrid = { + level: instanceSw.level + instanceOffset.level, + x: instanceSw.x + (instanceOffset.x << 3), + z: instanceSw.z + (instanceOffset.z << 3) + }; + + const targetZone = this.ensureInstanceZone(target.x, target.z, target.level); + const sourceZone = World.gameMap.getZoneIfExists(source.x, source.z, source.level); + + if (!sourceZone) { + // Keep template metadata so client rebuild can still draw this chunk, + // even when the source zone is not materialized server-side. + targetZone.assignTemplate(source, rotation); + + if (InstanceController.DEBUG_INSTANCE_COPY_VERBOSE) { + if (this.missingSourceZoneLogCount < InstanceController.MAX_MISSING_SOURCE_ZONE_LOGS) { + printDebug(`[Instance] copyZone skipped: source zone missing src=(${source.x},${source.z},L${source.level}) -> dst=(${target.x},${target.z},L${target.level})`); + } else if (this.missingSourceZoneLogCount === InstanceController.MAX_MISSING_SOURCE_ZONE_LOGS) { + printDebug('[Instance] copyZone: additional missing source-zone logs suppressed for this instance creation'); + } + } + this.missingSourceZoneLogCount++; + return; + } + + targetZone.copyFromZone(sourceZone, rotation); + if (InstanceController.DEBUG_INSTANCE_COPY_VERBOSE) { + printDebug(`[Instance] Zone copy complete: src=(${source.x},${source.z},L${source.level}) -> dst=(${target.x},${target.z},L${target.level}) rot=${rotation} locs=${targetZone.totalLocs}`); + for (const loc of targetZone.getAllLocsSafe()) { + printDebug(` [Loc] type=${loc.type} at (${loc.x},${loc.z},L${loc.level}) shape=${loc.shape} angle=${loc.angle} active=${loc.isActive}`); + } + } + } + + /** + * Check whether an instance has any players in currently materialized zones. + * This exists to support reclaiming empty instances and avoid accumulating + * stale zones/collision state. + */ + isInstanceEmpty(instance: InstanceRecord): boolean { + // Any player in any zone covered by this instance means the instance is still in use. + for (let level: number = 0; level < instance.floors; level++) { + const actualLevel: number = instance.sw.level + level; + for (let east: number = 0; east < instance.zonesEast; east++) { + for (let north: number = 0; north < instance.zonesNorth; north++) { + const x: number = instance.sw.x + (east << 3); + const z: number = instance.sw.z + (north << 3); + const zone = World.gameMap.getZoneIfExists(x, z, actualLevel); + if (zone && zone.hasPlayers()) { + return false; + } + } + } + } + + return true; + } + + /** + * Resolve an instance by absolute coordinate. + * This exists for callers that start from packed/world coordinates and need + * to recover instance context. + */ + findInstanceByCoord(coord: CoordGrid): InstanceRecord | null { + return this.findInstanceByTile(coord.level, coord.x, coord.z); + } + + /** + * Resolve an instance by raw level/x/z tile values. + * This is the core containment test used by teleport/login/instance checks. + */ + findInstanceByTile(level: number, x: number, z: number): InstanceRecord | null { + for (const instance of this.instances) { + // Check if tile falls within this instance's footprint + if (level >= instance.sw.level && level < instance.sw.level + instance.floors && x >= instance.sw.x && x < instance.sw.x + (instance.zonesEast << 3) && z >= instance.sw.z && z < instance.sw.z + (instance.zonesNorth << 3)) { + return instance; + } + } + return null; + } + + /** + * Resolve an instance by its stable uid. + * This exists because script/runtime flows store uid handles and need + * deterministic lookup. + */ + findInstanceByUid(uid: number): InstanceRecord | null { + for (const instance of this.instances) { + if (instance.uid === uid) { + return instance; + } + } + return null; + } + + // --- + // Private methods + // --- + + /** + * Reclaim all stale instance records that no longer contain players. + * This is purposed to keep slot reuse and cleanup opportunistic during + * instance creation. + */ + private clearStaleInstances(): void { + // Walk backward so removals do not disturb the remaining indices. + for (let index: number = this.instances.length - 1; index >= 0; index--) { + const instance: InstanceRecord = this.instances[index]; + if (!this.isInstanceEmpty(instance)) { + continue; + } + + this.deleteInstance(instance); + this.instances.splice(index, 1); + } + } + + /** + * Select the next free slot pointer in the instance grid. + * This exists to prevent slot collisions and keep instance placement + * deterministic. + */ + private findNextSlot(): void { + // Track occupied slots from active instance records to skip them during probing. + const occupiedSlots: Set = new Set(); + for (const instance of this.instances) { + occupiedSlots.add(instance.uid); + } + + if (InstanceController.DEBUG_INSTANCE_COPY_VERBOSE) { + printDebug(`[Instance] findNextSlot start: nextPointer=${this.nextInstancePointer}, activeInstances=${this.instances.length}, occupiedSlots=${occupiedSlots.size}`); + } + + for (let attempts: number = 0; attempts < InstanceController.TOTAL_INSTANCES; attempts++) { + const pointer: number = (this.nextInstancePointer + attempts) % InstanceController.TOTAL_INSTANCES; + if (occupiedSlots.has(pointer)) { + continue; + } + + const slotX: number = pointer % InstanceController.INSTANCES_PER_ROW; + const slotZ: number = Math.trunc(pointer / InstanceController.INSTANCES_PER_ROW); + const firstMapsquareX: number = InstanceController.FIRST_INSTANCE_SW_MAPSQUARE >> 8; + const firstMapsquareZ: number = InstanceController.FIRST_INSTANCE_SW_MAPSQUARE & 0xff; + const swX: number = (firstMapsquareX << 6) + slotX * InstanceController.INSTANCE_SW_STRIDE_TILES; + const swZ: number = (firstMapsquareZ << 6) + slotZ * InstanceController.INSTANCE_SW_STRIDE_TILES; + const allocated: boolean = isZoneAllocated(0, swX, swZ); + const hasZone: boolean = World.gameMap.hasZone(swX, swZ, 0); + + // If this slot's SW zone is occupied, the slot is considered in use. + if (allocated || hasZone) { + if (InstanceController.DEBUG_INSTANCE_COPY_VERBOSE && attempts < 10) { + printDebug(`[Instance] slot ${pointer} blocked: sw=(${swX},${swZ},L0) allocated=${allocated} hasZone=${hasZone}`); + } + continue; + } + + if (InstanceController.DEBUG_INSTANCE_COPY_VERBOSE) { + printDebug(`[Instance] slot ${pointer} available: sw=(${swX},${swZ},L0)`); + } + this.nextInstancePointer = pointer; + return; + } + + throw new Error('[InstanceController] No available instance slots found.'); + } + + /** + * Advance the slot pointer with wrap-around. + * This is purposed to keep scanning fair across the fixed-capacity + * instance grid. + */ + private incrementSlotPointer(): void { + this.nextInstancePointer = (this.nextInstancePointer + 1) % InstanceController.TOTAL_INSTANCES; + } + + /** + * Tear down all materialized zones and collision in the instance footprint. + * This exists so slot reuse cannot leak world-state from prior instances. + */ + private deleteInstance(instance: InstanceRecord): void { + printDebug(`[Instance] deleting instance uid=${instance.uid} sw=(${instance.sw.x},${instance.sw.z},L${instance.sw.level}) floors=${instance.floors} size=${instance.zonesEast}x${instance.zonesNorth}`); + + // Remove each zone in the instance footprint from the live zone map and collision data. + for (let level: number = 0; level < instance.floors; level++) { + const actualLevel: number = instance.sw.level + level; + for (let east: number = 0; east < instance.zonesEast; east++) { + for (let north: number = 0; north < instance.zonesNorth; north++) { + const x: number = instance.sw.x + (east << 3); + const z: number = instance.sw.z + (north << 3); + const zone = World.gameMap.getZoneIfExists(x, z, actualLevel); + if (zone) { + for (const npc of Array.from(zone.getAllNpcsUnsafe(true))) { + World.removeNpc(npc, -1); + } + + for (const loc of Array.from(zone.getAllLocsUnsafe(true))) { + World.removeLoc(loc, 0); + } + + for (const obj of Array.from(zone.getAllObjsUnsafe(true))) { + World.removeObj(obj, 0); + } + } + + World.gameMap.removeZone(ZoneMap.zoneIndex(x, z, actualLevel)); + // Clean up collision data for this zone + routeFinder.deallocateIfPresent(x, z, actualLevel); + } + } + } + + printDebug(`[Instance] deleted instance uid=${instance.uid}`); + } + + /** + * Ensure a destination instance zone exists and has collision storage allocated. + * This exists because chunks are created lazily and copy operations need a + * safe one-stop materializer. + */ + private ensureInstanceZone(x: number, z: number, level: number): InstanceZone { + const zoneIndex: number = ZoneMap.zoneIndex(x, z, level); + const existingZone = World.gameMap.getZoneIfExists(x, z, level); + + if (!existingZone) { + const zone = World.gameMap.createInstanceZone(zoneIndex) as InstanceZone; + routeFinder.allocateIfAbsent(x, z, level); + return zone; + } + + if (!(existingZone instanceof InstanceZone)) { + throw new Error(`Instance zone collision at (${x}, ${z}, L${level})`); + } + + return existingZone; + } +} diff --git a/src/engine/World.ts b/src/engine/World.ts index 1a90cfb99..a1feec80d 100644 --- a/src/engine/World.ts +++ b/src/engine/World.ts @@ -52,6 +52,7 @@ import { PlayerStat } from '#/engine/entity/PlayerStat.js'; import { SessionLog } from '#/engine/entity/tracking/SessionLog.js'; import { WealthTransactionEvent, WealthEvent } from '#/engine/entity/tracking/WealthEvent.js'; import GameMap, { changeLocCollision, changeNpcCollision, changePlayerCollision } from '#/engine/GameMap.js'; +import InstanceController from '#/engine/InstanceController.js'; import { Inventory } from '#/engine/Inventory.js'; import ScriptPointer from '#/engine/script/ScriptPointer.js'; import ScriptProvider from '#/engine/script/ScriptProvider.js'; @@ -92,7 +93,7 @@ import { import Environment from '#/util/Environment.js'; import { fromBase37, toBase37, toSafeName } from '#/util/JString.js'; import LinkList from '#/datastruct/LinkList.js'; -import { printDebug, printError, printInfo } from '#/util/Logger.js'; +import { printDebug, printError, printInfo, printWarning } from '#/util/Logger.js'; import { WalkTriggerSetting } from '#/engine/entity/WalkTriggerSetting.js'; import OnDemand from './OnDemand.js'; @@ -135,6 +136,9 @@ class World { // the game/zones map readonly gameMap: GameMap = new GameMap(Environment.NODE_MEMBERS); + // instance management + readonly instances: InstanceController = new InstanceController(); + // shared inventories (shops) readonly invs: Set = new Set(); @@ -664,6 +668,11 @@ class World { private processNpcEventQueue(): void { for (const request of this.npcEventQueue.all()) { const npc = request.npc; + if (request.type === NpcEventType.SPAWN && (!npc.isActive || npc.nid === -1 || this.getNpc(npc.nid) !== npc)) { + request.unlink(); + continue; + } + if (!npc.delayed) { request.unlink(); const state = ScriptRunner.init(request.script, npc); @@ -908,11 +917,13 @@ class World { player.client.state = 1; - player.client.send(Uint8Array.from([ - 2, - Math.min(player.staffModLevel, 2), - 1 // mouse tracking can only be enabled on login - ])); + player.client.send( + Uint8Array.from([ + 2, + Math.min(player.staffModLevel, 2), + 1 // mouse tracking can only be enabled on login + ]) + ); const remote = player.client.remoteAddress; if (remote.indexOf('.') !== -1) { @@ -938,7 +949,35 @@ class World { player.tele = true; player.moveClickRequest = false; - this.gameMap.getZone(player.x, player.z, player.level).enter(player); + const loginInstance = this.instances.findInstanceByTile(player.level, player.x, player.z); + if (loginInstance) { + const exitCoord = loginInstance.exitCoord; + const hasValidExit = exitCoord && this.gameMap.hasZone(exitCoord.x, exitCoord.z, exitCoord.level); + + if (hasValidExit && exitCoord) { + printWarning(`[World] Player login: player ${player.username} was in an instance, moving to exit at (${exitCoord.x}, ${exitCoord.z}, L${exitCoord.level})`); + player.x = exitCoord.x; + player.z = exitCoord.z; + player.level = exitCoord.level; + } else { + printWarning(`[World] Player login: player ${player.username} was in an instance with invalid exit, teleporting to Lumbridge failsafe`); + player.x = 3222; + player.z = 3222; + player.level = 0; + } + } + + const zone = this.gameMap.getZoneIfExists(player.x, player.z, player.level); + if (zone) { + zone.enter(player); + } else { + printWarning(`[World] Player login: zone does not exist at (${player.x}, ${player.z}, L${player.level}), teleporting to default spawn`); + player.x = 3222; + player.z = 3222; + player.level = 0; + const defaultZone = this.gameMap.getZone(player.x, player.z, player.level); + defaultZone.enter(player); + } player.onLogin(); if (this.shutdownTick != -1) { @@ -1270,18 +1309,15 @@ class World { npc.z = npc.startZ; npc.isActive = true; - const zone = this.gameMap.getZone(npc.x, npc.z, npc.level); - zone.enter(npc); + // During initialization, zones can auto-create. After init, zones must be pre-created. + const zone = this.gameMap.isInitializing() ? this.gameMap.getZone(npc.x, npc.z, npc.level) : this.gameMap.getZoneIfExists(npc.x, npc.z, npc.level); - switch (npc.blockWalk) { - case BlockWalk.NPC: - changeNpcCollision(npc.width, npc.x, npc.z, npc.level, true); - break; - case BlockWalk.ALL: - changeNpcCollision(npc.width, npc.x, npc.z, npc.level, true); - changePlayerCollision(npc.width, npc.x, npc.z, npc.level, true); - break; + if (!zone) { + printWarning(`[World] addNpc: zone does not exist at (${npc.x}, ${npc.z}, L${npc.level}), NPC spawn failed`); + npc.isActive = false; + return; } + zone.enter(npc); npc.resetEntity(true); npc.playAnimation(-1, 0); @@ -1299,9 +1335,11 @@ class World { } removeNpc(npc: Npc, duration: number): void { - const zone = this.gameMap.getZone(npc.x, npc.z, npc.level); + const zone = this.gameMap.getZoneIfExists(npc.x, npc.z, npc.level); const adjustedDuration = this.scaleByPlayerCount(duration); - zone.leave(npc); + if (zone) { + zone.leave(npc); + } npc.isActive = false; switch (npc.blockWalk) { @@ -1324,15 +1362,18 @@ class World { } getLoc(x: number, z: number, level: number, locId: number): Loc | null { - return this.gameMap.getZone(x, z, level).getLoc(x, z, locId); + const zone = this.gameMap.getZoneIfExists(x, z, level); + return zone ? zone.getLoc(x, z, locId) : null; } getObj(x: number, z: number, level: number, objId: number, receiver64: bigint): Obj | null { - return this.gameMap.getZone(x, z, level).getObj(x, z, objId, receiver64); + const zone = this.gameMap.getZoneIfExists(x, z, level); + return zone ? zone.getObj(x, z, objId, receiver64) : null; } getObjOfReceiver(x: number, z: number, level: number, objId: number, receiver64: bigint): Obj | null { - return this.gameMap.getZone(x, z, level).getObjOfReceiver(x, z, objId, receiver64); + const zone = this.gameMap.getZoneIfExists(x, z, level); + return zone ? zone.getObjOfReceiver(x, z, objId, receiver64) : null; } trackZone(zone: Zone): void { @@ -1346,7 +1387,11 @@ class World { changeLocCollision(loc.shape, loc.angle, type.blockrange, type.length, type.width, type.active, loc.x, loc.z, loc.level, true); } - const zone: Zone = this.gameMap.getZone(loc.x, loc.z, loc.level); + const zone: Zone | null = this.gameMap.getZoneIfExists(loc.x, loc.z, loc.level); + if (!zone) { + printWarning(`[World] addLoc: zone does not exist at (${loc.x}, ${loc.z}, L${loc.level})`); + return; + } zone.addLoc(loc); this.trackZone(zone); loc.setLifeCycle(duration); @@ -1376,7 +1421,11 @@ class World { } // Notify zone that loc has been changed - const zone: Zone = this.gameMap.getZone(loc.x, loc.z, loc.level); + const zone: Zone | null = this.gameMap.getZoneIfExists(loc.x, loc.z, loc.level); + if (!zone) { + printWarning(`[World] changeLoc: zone does not exist at (${loc.x}, ${loc.z}, L${loc.level})`); + return; + } zone.changeLoc(loc); this.trackZone(zone); @@ -1392,14 +1441,22 @@ class World { mergeLoc(loc: Loc, player: Player, startCycle: number, endCycle: number, south: number, east: number, north: number, west: number): void { // printDebug(`[World] mergeLoc => name: ${LocType.get(loc.type).name}`); - const zone: Zone = this.gameMap.getZone(loc.x, loc.z, loc.level); + const zone: Zone | null = this.gameMap.getZoneIfExists(loc.x, loc.z, loc.level); + if (!zone) { + printWarning(`[World] mergeLoc: zone does not exist at (${loc.x}, ${loc.z}, L${loc.level})`); + return; + } zone.mergeLoc(loc, player, startCycle, endCycle, south, east, north, west); this.trackZone(zone); } animLoc(loc: Loc, seq: number): void { // printDebug(`[World] animLoc => name: ${LocType.get(loc.type).name}, seq: ${seq}`); - const zone: Zone = this.gameMap.getZone(loc.x, loc.z, loc.level); + const zone: Zone | null = this.gameMap.getZoneIfExists(loc.x, loc.z, loc.level); + if (!zone) { + printWarning(`[World] animLoc: zone does not exist at (${loc.x}, ${loc.z}, L${loc.level})`); + return; + } zone.animLoc(loc, seq); this.trackZone(zone); } @@ -1415,7 +1472,11 @@ class World { changeLocCollision(loc.shape, loc.angle, type.blockrange, type.length, type.width, type.active, loc.x, loc.z, loc.level, false); } - const zone: Zone = this.gameMap.getZone(loc.x, loc.z, loc.level); + const zone: Zone | null = this.gameMap.getZoneIfExists(loc.x, loc.z, loc.level); + if (!zone) { + printWarning(`[World] removeLoc: zone does not exist at (${loc.x}, ${loc.z}, L${loc.level})`); + return; + } zone.removeLoc(loc); this.trackZone(zone); @@ -1446,7 +1507,11 @@ class World { } // Notify zone that loc has been changed - const zone: Zone = this.gameMap.getZone(loc.x, loc.z, loc.level); + const zone: Zone | null = this.gameMap.getZoneIfExists(loc.x, loc.z, loc.level); + if (!zone) { + printWarning(`[World] revertLoc: zone does not exist at (${loc.x}, ${loc.z}, L${loc.level})`); + return; + } zone.changeLoc(loc); loc.setLifeCycle(-1); this.trackZone(zone); @@ -1469,7 +1534,11 @@ class World { } } - const zone: Zone = this.gameMap.getZone(obj.x, obj.z, obj.level); + const zone: Zone | null = this.gameMap.getZoneIfExists(obj.x, obj.z, obj.level); + if (!zone) { + printWarning(`[World] addObj: zone does not exist at (${obj.x}, ${obj.z}, L${obj.level})`); + return; + } zone.addObj(obj, receiver64); this.trackZone(zone); // If the obj is dropped to a specific person @@ -1490,14 +1559,22 @@ class World { } revealObj(obj: Obj): void { - const zone: Zone = this.gameMap.getZone(obj.x, obj.z, obj.level); + const zone: Zone | null = this.gameMap.getZoneIfExists(obj.x, obj.z, obj.level); + if (!zone) { + printWarning(`[World] revealObj: zone does not exist at (${obj.x}, ${obj.z}, L${obj.level})`); + return; + } zone.revealObj(obj); this.trackZone(zone); } changeObj(obj: Obj, newCount: number): void { // printDebug(`[World] changeObj => name: ${ObjType.get(obj.type).name}, receiverId: ${receiverId}, newCount: ${newCount}`); - const zone: Zone = this.gameMap.getZone(obj.x, obj.z, obj.level); + const zone: Zone | null = this.gameMap.getZoneIfExists(obj.x, obj.z, obj.level); + if (!zone) { + printWarning(`[World] changeObj: zone does not exist at (${obj.x}, ${obj.z}, L${obj.level})`); + return; + } zone.changeObj(obj, obj.count, newCount); this.trackZone(zone); } @@ -1509,7 +1586,11 @@ class World { return; } // printDebug(`[World] removeObj => name: ${ObjType.get(obj.type).name}, duration: ${duration}`); - const zone: Zone = this.gameMap.getZone(obj.x, obj.z, obj.level); + const zone: Zone | null = this.gameMap.getZoneIfExists(obj.x, obj.z, obj.level); + if (!zone) { + printWarning(`[World] removeObj: zone does not exist at (${obj.x}, ${obj.z}, L${obj.level})`); + return; + } const adjustedDuration = this.scaleByPlayerCount(duration); zone.removeObj(obj); this.trackZone(zone); @@ -1523,13 +1604,21 @@ class World { } animMap(level: number, x: number, z: number, spotanim: number, height: number, delay: number): void { - const zone: Zone = this.gameMap.getZone(x, z, level); + const zone: Zone | null = this.gameMap.getZoneIfExists(x, z, level); + if (!zone) { + printWarning(`[World] animMap: zone does not exist at (${x}, ${z}, L${level})`); + return; + } zone.animMap(x, z, spotanim, height, delay); this.trackZone(zone); } mapProjAnim(level: number, x: number, z: number, dstX: number, dstZ: number, target: number, spotanim: number, srcHeight: number, dstHeight: number, startDelay: number, endDelay: number, peak: number, arc: number): void { - const zone: Zone = this.gameMap.getZone(x, z, level); + const zone: Zone | null = this.gameMap.getZoneIfExists(x, z, level); + if (!zone) { + printWarning(`[World] mapProjAnim: zone does not exist at (${x}, ${z}, L${level})`); + return; + } zone.mapProjAnim(x, z, dstX, dstZ, target, spotanim, srcHeight, dstHeight, startDelay, endDelay, peak, arc); this.trackZone(zone); } @@ -1600,7 +1689,10 @@ class World { } rsbuf.removePlayer(player.slot); - this.gameMap.getZone(player.x, player.z, player.level).leave(player); + const zone = this.gameMap.getZoneIfExists(player.x, player.z, player.level); + if (zone) { + zone.leave(player); + } delete this.players[player.slot]; player.unlink(); changeNpcCollision(player.width, player.x, player.z, player.level, false); @@ -1877,10 +1969,7 @@ class World { } else if (reply === 10) { // hop timer const { remaining } = msg; - client.send(Uint8Array.from([ - 21, - Math.min(255, remaining! / 1000) - ])); + client.send(Uint8Array.from([21, Math.min(255, remaining! / 1000)])); client.close(); return; } @@ -2145,7 +2234,7 @@ class World { client.send(seed.data); } else if (client.opcode === 16 || client.opcode === 18) { let rev = World.loginBuf.g1(); - if (rev === 0xFF) { + if (rev === 0xff) { rev = World.loginBuf.g2(); } if (rev !== Environment.ENGINE_REVISION) { diff --git a/src/engine/entity/BuildArea.ts b/src/engine/entity/BuildArea.ts index 9ee1afd30..5e30e113d 100644 --- a/src/engine/entity/BuildArea.ts +++ b/src/engine/entity/BuildArea.ts @@ -1,16 +1,21 @@ import { CoordGrid } from '#/engine/CoordGrid.js'; import Player from '#/engine/entity/Player.js'; import World from '#/engine/World.js'; +import InstanceZone from '#/engine/zone/InstanceZone.js'; import ZoneMap from '#/engine/zone/ZoneMap.js'; import RebuildNormal from '#/network/game/server/model/RebuildNormal.js'; +import RebuildRegion, { type RegionTemplate } from '#/network/game/server/model/RebuildRegion.js'; export default class BuildArea { + // Dynamic rebuild currently enabled for instance zones. + private static readonly ENABLE_REGION_REBUILD_SKELETON: boolean = true; + // constructor readonly player: Player; readonly loadedZones: Set; readonly activeZones: Set; readonly mapsquares: Set; - + lastBuild: number = -1; constructor(player: Player) { @@ -83,7 +88,11 @@ export default class BuildArea { } } - this.player.write(new RebuildNormal(zoneX, zoneZ, this.mapsquares)); + if (BuildArea.ENABLE_REGION_REBUILD_SKELETON && this.isInstanceBuildArea()) { + this.player.write(this.buildRegionSkeletonMessage(zoneX, zoneZ)); + } else { + this.player.write(new RebuildNormal(zoneX, zoneZ, this.mapsquares)); + } this.player.originX = this.player.x; this.player.originZ = this.player.z; @@ -91,4 +100,49 @@ export default class BuildArea { this.lastBuild = World.currentTick; // DO NOT DELETE THIS NO MATTER WHAT ?? } } + + private isInstanceBuildArea(): boolean { + const zoneX: number = CoordGrid.zone(this.player.x) << 3; + const zoneZ: number = CoordGrid.zone(this.player.z) << 3; + const zone = World.gameMap.getZoneIfExists(zoneX, zoneZ, this.player.level); + return zone instanceof InstanceZone; + } + + private buildRegionSkeletonMessage(zoneX: number, zoneZ: number): RebuildRegion { + const templates: RegionTemplate[] = []; + + const minZoneX: number = zoneX - 6; + const maxZoneX: number = zoneX + 6; + const minZoneZ: number = zoneZ - 6; + const maxZoneZ: number = zoneZ + 6; + + for (let level = 0; level < 4; level++) { + for (let currentZoneX: number = minZoneX; currentZoneX <= maxZoneX; currentZoneX++) { + for (let currentZoneZ: number = minZoneZ; currentZoneZ <= maxZoneZ; currentZoneZ++) { + const currentX: number = currentZoneX << 3; + const currentZ: number = currentZoneZ << 3; + if (!World.gameMap.hasZone(currentX, currentZ, level)) { + continue; + } + + const zone = World.gameMap.getZone(currentX, currentZ, level); + if (!(zone instanceof InstanceZone) || !zone.hasAssignedTemplate) { + continue; + } + + templates.push({ + level: zone.level, + zoneX: zone.x, + zoneZ: zone.z, + sourceLevel: zone.source.level, + sourceZoneX: zone.source.x, + sourceZoneZ: zone.source.z, + rotation: zone.rotation + }); + } + } + } + + return new RebuildRegion(zoneX, zoneZ, templates, this.player.x, this.player.z, this.player.level); + } } diff --git a/src/engine/entity/Loc.ts b/src/engine/entity/Loc.ts index 6e2f0cb56..8c1a44f00 100644 --- a/src/engine/entity/Loc.ts +++ b/src/engine/entity/Loc.ts @@ -1,4 +1,4 @@ -import { locShapeLayer } from '@2004scape/rsmod-pathfinder'; +import { locShapeLayer } from '#/engine/routefinder/index.js'; import { EntityLifeCycle } from '#/engine/entity/EntityLifeCycle.js'; import NonPathingEntity from '#/engine/entity/NonPathingEntity.js'; @@ -31,14 +31,26 @@ export default class Loc extends NonPathingEntity { return this.currentInfo & 0x3fff; } + get baseType(): number { + return this.baseInfo & 0x3fff; + } + get shape(): number { return (this.currentInfo >> 14) & 0x1f; } + get baseShape(): number { + return (this.baseInfo >> 14) & 0x1f; + } + get angle(): number { return (this.currentInfo >> 19) & 0x3; } + get baseAngle(): number { + return (this.baseInfo >> 19) & 0x3; + } + get layer(): number { return (this.baseInfo >> 21) & 0x3; } diff --git a/src/engine/entity/NetworkPlayer.ts b/src/engine/entity/NetworkPlayer.ts index 5d82f1709..9f22c2448 100644 --- a/src/engine/entity/NetworkPlayer.ts +++ b/src/engine/entity/NetworkPlayer.ts @@ -23,7 +23,6 @@ import IfOpenSide from '#/network/game/server/model/IfOpenSide.js'; import Logout from '#/network/game/server/model/Logout.js'; import NpcInfo from '#/network/game/server/model/NpcInfo.js'; import PlayerInfo from '#/network/game/server/model/PlayerInfo.js'; -import SetMultiway from '#/network/game/server/model/SetMultiway.js'; import UpdateInvFull from '#/network/game/server/model/UpdateInvFull.js'; import UpdateRunEnergy from '#/network/game/server/model/UpdateRunEnergy.js'; import UpdateRunWeight from '#/network/game/server/model/UpdateRunWeight.js'; @@ -67,12 +66,7 @@ export class NetworkPlayer extends Player { this.restrictedLimit = 0; const bytesStart = this.client.in.pos; - while ( - this.userLimit < ClientGameProtCategory.USER_EVENT.limit && - this.clientLimit < ClientGameProtCategory.CLIENT_EVENT.limit && - this.restrictedLimit < ClientGameProtCategory.RESTRICTED_EVENT.limit && - this.read() - ) { + while (this.userLimit < ClientGameProtCategory.USER_EVENT.limit && this.clientLimit < ClientGameProtCategory.CLIENT_EVENT.limit && this.restrictedLimit < ClientGameProtCategory.RESTRICTED_EVENT.limit && this.read()) { // empty } const bytesRead = bytesStart - this.client.in.pos; @@ -258,39 +252,8 @@ export class NetworkPlayer extends Player { info.unlink(); } - // map zone changed - const mapZone = CoordGrid.packCoord(0, (this.x >> 6) << 6, (this.z >> 6) << 6); - if (this.lastMapZone !== mapZone) { - // map zone triggers - if (this.lastMapZone !== -1) { - const { x, z } = CoordGrid.unpackCoord(this.lastMapZone); - this.triggerMapzoneExit(x, z); - } - - this.triggerMapzone((this.x >> 6) << 6, (this.z >> 6) << 6); - this.lastMapZone = mapZone; - } - - // zone changed - const zone = CoordGrid.packCoord(this.level, (this.x >> 3) << 3, (this.z >> 3) << 3); - if (this.lastZone !== zone) { - this.buildArea.rebuildZones(); - - // zone triggers - const lastWasMulti = World.gameMap.isMulti(this.lastZone); - const nowIsMulti = World.gameMap.isMulti(zone); - if (lastWasMulti != nowIsMulti) { - this.write(new SetMultiway(nowIsMulti)); - } - - if (this.lastZone !== -1) { - const { level, x, z } = CoordGrid.unpackCoord(this.lastZone); - this.triggerZoneExit(level, x, z); - } - - this.triggerZone(this.level, (this.x >> 3) << 3, (this.z >> 3) << 3); - this.lastZone = zone; - } + // map/zone transition triggers are now queued from tile updates + // (movement/teleport/login path), not from client-out update. } updatePlayers() { @@ -314,7 +277,10 @@ export class NetworkPlayer extends Player { // update active zones for (const zoneIndex of activeZones) { - const zone: Zone = World.gameMap.getZoneIndex(zoneIndex); + const zone: Zone | null = World.gameMap.getZoneIndexIfExists(zoneIndex); + if (!zone) { + continue; + } if (!loadedZones.has(zone.index)) { zone.writeFullFollows(this); } diff --git a/src/engine/entity/Npc.ts b/src/engine/entity/Npc.ts index 9b6b69270..5378d33dd 100644 --- a/src/engine/entity/Npc.ts +++ b/src/engine/entity/Npc.ts @@ -1,6 +1,6 @@ import { NpcInfoProt } from '@2004scape/rsbuf'; import * as rsbuf from '@2004scape/rsbuf'; -import { CollisionFlag, CollisionType } from '@2004scape/rsmod-pathfinder'; +import { CollisionFlag, CollisionType } from '#/engine/routefinder/index.js'; import HuntType from '#/cache/config/HuntType.js'; import NpcType from '#/cache/config/NpcType.js'; @@ -433,8 +433,9 @@ export default class Npc extends PathingEntity { this.uid = (type << 16) | this.nid; this.resetOnRevert = reset; + const npcType = NpcType.get(type); + if (reset) { - const npcType = NpcType.get(type); for (let index = 0; index < npcType.stats.length; index++) { const level = npcType.stats[index]; this.levels[index] = Math.max(level - (this.baseLevels[index] - this.levels[index]), 0); diff --git a/src/engine/entity/PathingEntity.ts b/src/engine/entity/PathingEntity.ts index 391ac4524..2466df669 100644 --- a/src/engine/entity/PathingEntity.ts +++ b/src/engine/entity/PathingEntity.ts @@ -1,6 +1,7 @@ -import { CollisionFlag, CollisionType } from '@2004scape/rsmod-pathfinder'; +import { CollisionFlag, CollisionType } from '#/engine/routefinder/index.js'; import LocType from '#/cache/config/LocType.js'; +import NpcType from '#/cache/config/NpcType.js'; import { CoordGrid } from '#/engine/CoordGrid.js'; import { BlockWalk } from '#/engine/entity/BlockWalk.js'; import Entity from '#/engine/entity/Entity.js'; @@ -123,6 +124,11 @@ export default abstract class PathingEntity extends Entity { abstract blockWalkFlag(): CollisionFlag; abstract defaultMoveSpeed(): MoveSpeed; + /** + * Hook for entity-specific logic when tile/level changes are applied. + */ + protected onTileUpdated(_previousX: number, _previousZ: number, _previousLevel: number): void {} + /** * Process movement function for a PathingEntity to use. * Checks for if this PathingEntity has any waypoints to move towards. @@ -161,8 +167,10 @@ export default abstract class PathingEntity extends Entity { * @param previousLevel Their previous recorded level position before movement. This one is important for teleport. */ private refreshZonePresence(previousX: number, previousZ: number, previousLevel: number): void { + const moved: boolean = this.x != previousX || this.z !== previousZ || this.level !== previousLevel; + // only update collision map when the entity moves. - if (this.x != previousX || this.z !== previousZ || this.level !== previousLevel) { + if (moved) { // update collision map // players and npcs both can change this collision switch (this.blockWalk) { @@ -182,8 +190,15 @@ export default abstract class PathingEntity extends Entity { } if (CoordGrid.zone(previousX) !== CoordGrid.zone(this.x) || CoordGrid.zone(previousZ) !== CoordGrid.zone(this.z) || previousLevel != this.level) { - World.gameMap.getZone(previousX, previousZ, previousLevel).leave(this); - World.gameMap.getZone(this.x, this.z, this.level).enter(this); + const previousZone = World.gameMap.getZoneIfExists(previousX, previousZ, previousLevel); + const currentZone = World.gameMap.getZoneIfExists(this.x, this.z, this.level); + + if (previousZone && previousZone !== currentZone) { + previousZone.leave(this); + } + if (currentZone && previousZone !== currentZone) { + currentZone.enter(this); + } } } @@ -216,13 +231,22 @@ export default abstract class PathingEntity extends Entity { } const previousX: number = this.x; const previousZ: number = this.z; - this.x = CoordGrid.moveX(this.x, dir); - this.z = CoordGrid.moveZ(this.z, dir); + const nextX: number = CoordGrid.moveX(this.x, dir); + const nextZ: number = CoordGrid.moveZ(this.z, dir); + + // After map initialization, entities must not move into zones that were never allocated. + if (!World.gameMap.getZoneIfExists(nextX, nextZ, this.level)) { + return -1; + } + + this.x = nextX; + this.z = nextZ; const moveX: number = CoordGrid.moveX(this.x, dir); const moveZ: number = CoordGrid.moveZ(this.z, dir); this.focus(CoordGrid.fine(moveX, this.width), CoordGrid.fine(moveZ, this.length), false); this.stepsTaken++; this.refreshZonePresence(previousX, previousZ, this.level); + this.onTileUpdated(previousX, previousZ, this.level); if (this.waypointIndex !== -1) { const coord: CoordGrid = CoordGrid.unpackCoord(this.waypoints[this.waypointIndex]); @@ -262,27 +286,50 @@ export default abstract class PathingEntity extends Entity { } teleJump(x: number, z: number, level: number): void { - this.teleport(x, z, level); + if (!this.teleport(x, z, level)) { + return; + } this.moveSpeed = MoveSpeed.INSTANT; this.jump = true; } - teleport(x: number, z: number, level: number): void { + teleport(x: number, z: number, level: number): boolean { if (isNaN(level)) { level = 0; } level = Math.max(0, Math.min(level, 3)); - if (!isZoneAllocated(level, x, z) && (!(this instanceof Player) || this.staffModLevel < 3)) { + const previousX: number = this.x; + const previousZ: number = this.z; + const previousLevel: number = this.level; + + if (this instanceof Player) { + const movingToInstance: boolean = Player.isInstanceX(x); + if (movingToInstance) { + const targetInstance = World.instances.findInstanceByTile(level, x, z); + if (targetInstance?.exitCoord) { + this.previousOverworldX = targetInstance.exitCoord.x; + this.previousOverworldZ = targetInstance.exitCoord.z; + this.previousOverworldLevel = targetInstance.exitCoord.level; + } else { + this.previousOverworldX = previousX; + this.previousOverworldZ = previousZ; + this.previousOverworldLevel = previousLevel; + } + this.hasPreviousOverworldTile = true; + } + } + + const allocated: boolean = isZoneAllocated(level, x, z); + const initialized: boolean = World.gameMap.hasZone(x, z, level); + if (!allocated || !initialized) { + console.error(`[Teleport] Invalid teleport for ${this.constructor.name} from (${this.x}, ${this.z}, L${this.level}) to (${x}, ${z}, L${level}) allocated=${allocated} initialized=${initialized}`); if (this instanceof Player) { this.messageGame('Invalid teleport!'); } - return; + return false; } - const previousX: number = this.x; - const previousZ: number = this.z; - const previousLevel: number = this.level; this.x = x; this.z = z; this.level = level; @@ -291,6 +338,7 @@ export default abstract class PathingEntity extends Entity { const moveZ: number = CoordGrid.moveZ(this.z, dir); this.focus(CoordGrid.fine(moveX, this.width), CoordGrid.fine(moveZ, this.length), false); this.refreshZonePresence(previousX, previousZ, previousLevel); + this.onTileUpdated(previousX, previousZ, previousLevel); this.lastStepX = this.x - 1; this.lastStepZ = this.z; this.tele = true; @@ -299,6 +347,8 @@ export default abstract class PathingEntity extends Entity { this.moveSpeed = MoveSpeed.INSTANT; this.jump = true; } + + return true; } /** @@ -559,22 +609,25 @@ export default abstract class PathingEntity extends Entity { } protected getCollisionStrategy(): CollisionType | null { - if (this.moveRestrict === MoveRestrict.NORMAL) { - return CollisionType.NORMAL; - } else if (this.moveRestrict === MoveRestrict.BLOCKED) { - return CollisionType.BLOCKED; - } else if (this.moveRestrict === MoveRestrict.BLOCKED_NORMAL) { - return CollisionType.LINE_OF_SIGHT; - } else if (this.moveRestrict === MoveRestrict.INDOORS) { - return CollisionType.INDOORS; - } else if (this.moveRestrict === MoveRestrict.OUTDOORS) { - return CollisionType.OUTDOORS; - } else if (this.moveRestrict === MoveRestrict.NOMOVE) { - return null; - } else if (this.moveRestrict === MoveRestrict.PASSTHRU) { - return CollisionType.NORMAL; + if (this instanceof Npc) { + const type: NpcType = NpcType.get(this.type); + if (type.moverestrict === MoveRestrict.NORMAL) { + return CollisionType.NORMAL; + } else if (type.moverestrict === MoveRestrict.BLOCKED) { + return CollisionType.BLOCKED; + } else if (type.moverestrict === MoveRestrict.BLOCKED_NORMAL) { + return CollisionType.LINE_OF_SIGHT; + } else if (type.moverestrict === MoveRestrict.INDOORS) { + return CollisionType.INDOORS; + } else if (type.moverestrict === MoveRestrict.OUTDOORS) { + return CollisionType.OUTDOORS; + } else if (type.moverestrict === MoveRestrict.NOMOVE) { + return null; + } else if (type.moverestrict === MoveRestrict.PASSTHRU) { + return CollisionType.NORMAL; + } } - return null; + return CollisionType.NORMAL; } protected resetPathingEntity(): void { diff --git a/src/engine/entity/Player.ts b/src/engine/entity/Player.ts index f84db52f6..9a451272a 100644 --- a/src/engine/entity/Player.ts +++ b/src/engine/entity/Player.ts @@ -1,7 +1,7 @@ import 'dotenv/config'; import { PlayerInfoProt, Visibility } from '@2004scape/rsbuf'; -import { CollisionType, CollisionFlag } from '@2004scape/rsmod-pathfinder'; +import { CollisionFlag, CollisionType } from '#/engine/routefinder/index.js'; import Component from '#/cache/config/Component.js'; import FontType from '#/cache/config/FontType.js'; @@ -56,6 +56,7 @@ import MidiJingle from '#/network/game/server/model/MidiJingle.js'; import MidiSong from '#/network/game/server/model/MidiSong.js'; import ResetAnims from '#/network/game/server/model/ResetAnims.js'; import ResetClientVarCache from '#/network/game/server/model/ResetClientVarCache.js'; +import SetMultiway from '#/network/game/server/model/SetMultiway.js'; import TutOpen from '#/network/game/server/model/TutOpen.js'; import UnsetMapFlag from '#/network/game/server/model/UnsetMapFlag.js'; import UpdateInvStopTransmit from '#/network/game/server/model/UpdateInvStopTransmit.js'; @@ -100,6 +101,13 @@ export function getExpByLevel(level: number) { } export default class Player extends PathingEntity { + // Instance tiles are hosted in a low-x reserved band; overworld tiles are outside this threshold. + static readonly INSTANCE_X_THRESHOLD: number = 2048; + + static isInstanceX(x: number): boolean { + return x < Player.INSTANCE_X_THRESHOLD; + } + static readonly DESIGN_BODY_COLORS: number[][] = [ [6798, 107, 10283, 16, 4797, 7744, 5799, 4634, 33697, 22433, 2983, 54193], [8741, 12, 64030, 43162, 7735, 8404, 1701, 38430, 24094, 10153, 56621, 4783, 1341, 16578, 35003, 25239], @@ -271,6 +279,12 @@ export default class Player extends PathingEntity { // last login info sav.p8(this.lastLoginTime); + // persistent overworld fallback tile used when logging back in from instance coords + sav.p2(this.previousOverworldX); + sav.p2(this.previousOverworldZ); + sav.p1(this.previousOverworldLevel); + sav.p1(this.hasPreviousOverworldTile ? 1 : 0); + sav.p4(Packet.getcrc(sav.data, 0, sav.pos)); return sav.data.subarray(0, sav.pos); } @@ -323,6 +337,13 @@ export default class Player extends PathingEntity { lastLevels: Uint8Array = new Uint8Array(21); // we track this so we know to flush stats only once a tick on changes originX: number = -1; originZ: number = -1; + + // Last known overworld tile; updated when transitioning from overworld -> instance. + previousOverworldX: number = 0; + previousOverworldZ: number = 0; + previousOverworldLevel: number = 0; + hasPreviousOverworldTile: boolean = false; + buildArea: BuildArea = new BuildArea(this); animProtect: number = 0; invListeners: InventoryListener[] = []; @@ -415,9 +436,17 @@ export default class Player extends PathingEntity { constructor(username: string, username37: bigint, hash64: bigint) { super( - 0, 3094, 3106, // tutorial island - 1, 1, - EntityLifeCycle.FOREVER, MoveRestrict.NORMAL, BlockWalk.NPC, MoveStrategy.SMART, PlayerInfoProt.FACE_COORD, PlayerInfoProt.FACE_ENTITY + 0, + 3094, + 3106, // tutorial island + 1, + 1, + EntityLifeCycle.FOREVER, + MoveRestrict.NORMAL, + BlockWalk.NPC, + MoveStrategy.SMART, + PlayerInfoProt.FACE_COORD, + PlayerInfoProt.FACE_ENTITY ); this.username = username; @@ -492,8 +521,15 @@ export default class Player extends PathingEntity { // - runenergy // - reset anims // - social + if (!Player.isInstanceX(this.x)) { + this.previousOverworldX = this.x; + this.previousOverworldZ = this.z; + this.previousOverworldLevel = this.level; + this.hasPreviousOverworldTile = true; + } this.buildArea.rebuildNormal(); + this.queueZoneTransitionTriggers(this.x, this.z, this.level, true); this.write(new ChatFilterSettings(this.publicChat, this.privateChat, this.tradeDuel)); // todo: exact order @@ -526,6 +562,52 @@ export default class Player extends PathingEntity { this.isActive = true; } + protected override onTileUpdated(previousX: number, previousZ: number, previousLevel: number): void { + this.queueZoneTransitionTriggers(previousX, previousZ, previousLevel, false); + } + + private queueZoneTransitionTriggers(previousX: number, previousZ: number, previousLevel: number, initialLogin: boolean): void { + const previousZoneX = (previousX >> 3) << 3; + const previousZoneZ = (previousZ >> 3) << 3; + const currentZoneX = (this.x >> 3) << 3; + const currentZoneZ = (this.z >> 3) << 3; + + const previousMapZoneX = (previousZoneX >> 6) << 6; + const previousMapZoneZ = (previousZoneZ >> 6) << 6; + const currentMapZoneX = (currentZoneX >> 6) << 6; + const currentMapZoneZ = (currentZoneZ >> 6) << 6; + + const mapZoneBoundaryChanged: boolean = initialLogin || (previousX >> 6) << 6 !== (this.x >> 6) << 6 || (previousZ >> 6) << 6 !== (this.z >> 6) << 6; + const mapZoneChanged: boolean = initialLogin || (mapZoneBoundaryChanged && (previousMapZoneX !== currentMapZoneX || previousMapZoneZ !== currentMapZoneZ)); + if (mapZoneChanged) { + if (!initialLogin) { + this.triggerMapzoneExit(previousMapZoneX, previousMapZoneZ); + } + this.triggerMapzone(currentMapZoneX, currentMapZoneZ); + this.lastMapZone = CoordGrid.packCoord(0, currentMapZoneX, currentMapZoneZ); + } + + const zoneChanged: boolean = initialLogin || previousLevel !== this.level || previousZoneX !== currentZoneX || previousZoneZ !== currentZoneZ; + if (zoneChanged) { + this.buildArea.rebuildZones(); + + if (!initialLogin) { + const previousZoneCoord = CoordGrid.packCoord(previousLevel, previousZoneX, previousZoneZ); + const currentZoneCoord = CoordGrid.packCoord(this.level, currentZoneX, currentZoneZ); + const lastWasMulti = World.gameMap.isMulti(previousZoneCoord); + const nowIsMulti = World.gameMap.isMulti(currentZoneCoord); + if (lastWasMulti !== nowIsMulti) { + this.write(new SetMultiway(nowIsMulti)); + } + + this.triggerZoneExit(previousLevel, previousZoneX, previousZoneZ); + } + + this.triggerZone(this.level, currentZoneX, currentZoneZ); + this.lastZone = CoordGrid.packCoord(this.level, currentZoneX, currentZoneZ); + } + } + onReconnect() { // - varp_reset // - varps @@ -1324,8 +1406,8 @@ export default class Player extends PathingEntity { const stream = Packet.alloc(0); stream.p1(this.gender); - stream.p1(0xFF); // prayer icon? - stream.p1(0xFF); // skull icon? + stream.p1(0xff); // prayer icon? + stream.p1(0xff); // skull icon? const skippedSlots = []; @@ -1356,7 +1438,7 @@ export default class Player extends PathingEntity { } for (let slot = 0; slot < 12; slot++) { - if(this.npcId != -1) { + if (this.npcId != -1) { stream.p2(-1); stream.p2(this.npcId); break; @@ -1759,7 +1841,7 @@ export default class Player extends PathingEntity { const { basevar, startbit, endbit } = varbit; const mask = Packet.bitmask[endbit - startbit + 1]; - return this.vars[basevar] >> startbit & mask; + return (this.vars[basevar] >> startbit) & mask; } setVarBit(id: number, value: number) { @@ -1776,7 +1858,7 @@ export default class Player extends PathingEntity { } mask <<= startbit; - this.setVar(basevar, mask & value << startbit | this.vars[basevar] & ~mask); + this.setVar(basevar, (mask & (value << startbit)) | (this.vars[basevar] & ~mask)); } private writeVarp(id: number, value: number): void { @@ -2245,19 +2327,11 @@ export default class Player extends PathingEntity { const daysSinceLogin: number = (Number(lastDate) / (1000 * 60 * 60 * 24)) | 0; const daysSincePasswordChanged = 201; // hide :) const daysSinceRecoveriesChanged = 201; // hide :) - const currentDay: number = Number(nextDate) / (1000 * 60 * 60 * 24) | 0; + const currentDay: number = (Number(nextDate) / (1000 * 60 * 60 * 24)) | 0; const unreadMessageCount = 0; const membersCreditDays = 365; - this.write(new LastLoginInfo( - lastIp, - currentDay, - daysSinceLogin, - daysSincePasswordChanged, - daysSinceRecoveriesChanged, - unreadMessageCount, - membersCreditDays - )); + this.write(new LastLoginInfo(lastIp, currentDay, daysSinceLogin, daysSincePasswordChanged, daysSinceRecoveriesChanged, unreadMessageCount, membersCreditDays)); this.lastLoginTime = nextDate; } diff --git a/src/engine/entity/PlayerLoading.ts b/src/engine/entity/PlayerLoading.ts index 5f3252ca2..da6aec76b 100644 --- a/src/engine/entity/PlayerLoading.ts +++ b/src/engine/entity/PlayerLoading.ts @@ -11,7 +11,7 @@ import { fromBase37, toBase37 } from '#/util/JString.js'; export class PlayerLoading { public static readonly SAV_MAGIC: number = 0x2004; - public static readonly SAV_VERSION: number = 7; + public static readonly SAV_VERSION: number = 9; static verify(sav: Packet) { if (sav.g2() !== PlayerLoading.SAV_MAGIC) { @@ -160,6 +160,32 @@ export class PlayerLoading { player.lastLoginTime = sav.g8(); } + // persistent overworld fallback tile for instance logout/login recovery + if (version >= 9) { + player.previousOverworldX = sav.g2(); + player.previousOverworldZ = sav.g2(); + player.previousOverworldLevel = sav.g1(); + player.hasPreviousOverworldTile = sav.g1() === 1; + } else if (version >= 8) { + player.previousOverworldX = sav.g2(); + player.previousOverworldZ = sav.g2(); + player.previousOverworldLevel = sav.g1(); + const legacyDefaultFallback = player.previousOverworldX === 3094 && player.previousOverworldZ === 3106 && player.previousOverworldLevel === 0; + player.hasPreviousOverworldTile = !legacyDefaultFallback && !Player.isInstanceX(player.previousOverworldX); + } else if (!Player.isInstanceX(player.x)) { + player.previousOverworldX = player.x; + player.previousOverworldZ = player.z; + player.previousOverworldLevel = player.level; + player.hasPreviousOverworldTile = true; + } + + // Only relocate on login: if saved in an instance, return to last known overworld tile. + if (Player.isInstanceX(player.x) && player.hasPreviousOverworldTile && !Player.isInstanceX(player.previousOverworldX)) { + player.x = player.previousOverworldX; + player.z = player.previousOverworldZ; + player.level = player.previousOverworldLevel; + } + player.combatLevel = player.getCombatLevel(); return player; diff --git a/src/engine/routefinder/CollisionEngine.ts b/src/engine/routefinder/CollisionEngine.ts new file mode 100644 index 000000000..4b8b3db95 --- /dev/null +++ b/src/engine/routefinder/CollisionEngine.ts @@ -0,0 +1,261 @@ +import { CollisionFlag, LocAngle, LocShape } from '#/engine/routefinder/flags.js'; + +export default class CollisionEngine { + private static readonly ZONE_SIZE = 8; + private static readonly ZONE_TILE_COUNT = CollisionEngine.ZONE_SIZE * CollisionEngine.ZONE_SIZE; + + private readonly zones = new Map(); + + static zoneIndex(x: number, z: number, y: number): number { + return ((x >> 3) & 0x7ff) | (((z >> 3) & 0x7ff) << 11) | ((y & 0x3) << 22); + } + + private static tileIndex(x: number, z: number): number { + return (x & 0x7) | ((z & 0x7) << 3); + } + + private allocateIfAbsentByIndex(zoneIndex: number): Uint32Array { + let zone = this.zones.get(zoneIndex); + if (!zone) { + zone = new Uint32Array(CollisionEngine.ZONE_TILE_COUNT); + this.zones.set(zoneIndex, zone); + } + return zone; + } + + allocateIfAbsent(x: number, z: number, y: number): void { + this.allocateIfAbsentByIndex(CollisionEngine.zoneIndex(x, z, y)); + } + + deallocateIfPresent(x: number, z: number, y: number): void { + this.zones.delete(CollisionEngine.zoneIndex(x, z, y)); + } + + isZoneAllocated(x: number, z: number, y: number): boolean { + return this.zones.has(CollisionEngine.zoneIndex(x, z, y)); + } + + get(x: number, z: number, y: number): number { + const zone = this.zones.get(CollisionEngine.zoneIndex(x, z, y)); + return zone ? zone[CollisionEngine.tileIndex(x, z)] : CollisionFlag.NULL; + } + + isFlagged(x: number, z: number, y: number, masks: number): boolean { + const zone = this.zones.get(CollisionEngine.zoneIndex(x, z, y)); + return !!zone && (zone[CollisionEngine.tileIndex(x, z)] & masks) !== CollisionFlag.OPEN; + } + + set(x: number, z: number, y: number, mask: number): void { + const zone = this.allocateIfAbsentByIndex(CollisionEngine.zoneIndex(x, z, y)); + zone[CollisionEngine.tileIndex(x, z)] = mask >>> 0; + } + + add(x: number, z: number, y: number, mask: number): void { + const zone = this.allocateIfAbsentByIndex(CollisionEngine.zoneIndex(x, z, y)); + const tile = CollisionEngine.tileIndex(x, z); + zone[tile] = (zone[tile] | mask) >>> 0; + } + + remove(x: number, z: number, y: number, mask: number): void { + const zone = this.allocateIfAbsentByIndex(CollisionEngine.zoneIndex(x, z, y)); + const tile = CollisionEngine.tileIndex(x, z); + zone[tile] = (zone[tile] & ~mask) >>> 0; + } + + changeFloor(x: number, z: number, y: number, add: boolean): void { + if (add) { + this.add(x, z, y, CollisionFlag.FLOOR); + } else { + this.remove(x, z, y, CollisionFlag.FLOOR); + } + } + + changeRoof(x: number, z: number, y: number, add: boolean): void { + if (add) { + this.add(x, z, y, CollisionFlag.ROOF); + } else { + this.remove(x, z, y, CollisionFlag.ROOF); + } + } + + changeNpc(x: number, z: number, y: number, size: number, add: boolean): void { + this.changeSquare(x, z, y, size, CollisionFlag.NPC, add); + } + + changePlayer(x: number, z: number, y: number, size: number, add: boolean): void { + this.changeSquare(x, z, y, size, CollisionFlag.PLAYER, add); + } + + changeLoc(x: number, z: number, y: number, width: number, length: number, blockrange: boolean, breakroutefinding: boolean, add: boolean): void { + let mask = CollisionFlag.LOC; + if (blockrange) { + mask |= CollisionFlag.LOC_PROJ_BLOCKER; + } + if (breakroutefinding) { + mask |= CollisionFlag.LOC_ROUTE_BLOCKER; + } + + const area = width * length; + for (let index = 0; index < area; index++) { + const dx = x + (index % width); + const dz = z + ((index / width) | 0); + if (add) { + this.add(dx, dz, y, mask); + } else { + this.remove(dx, dz, y, mask); + } + } + } + + changeWall(x: number, z: number, y: number, angle: number, shape: number, blockrange: boolean, breakroutefinding: boolean, add: boolean): void { + if (shape === LocShape.WALL_STRAIGHT) { + this.changeWallStraight(x, z, y, angle, blockrange, breakroutefinding, add); + } else if (shape === LocShape.WALL_DIAGONAL_CORNER || shape === LocShape.WALL_SQUARE_CORNER) { + this.changeWallCorner(x, z, y, angle, blockrange, breakroutefinding, add); + } else if (shape === LocShape.WALL_L) { + this.changeWallL(x, z, y, angle, blockrange, breakroutefinding, add); + } + } + + zoneCount(): number { + return this.zones.size; + } + + private changeSquare(x: number, z: number, y: number, size: number, mask: number, add: boolean): void { + const area = size * size; + for (let index = 0; index < area; index++) { + const dx = x + (index % size); + const dz = z + ((index / size) | 0); + if (add) { + this.add(dx, dz, y, mask); + } else { + this.remove(dx, dz, y, mask); + } + } + } + + private changeWallStraight(x: number, z: number, y: number, angle: number, blockrange: boolean, breakroutefinding: boolean, add: boolean): void { + const west = this.wallMask(CollisionFlag.WALL_WEST, CollisionFlag.WALL_WEST_PROJ_BLOCKER, CollisionFlag.WALL_WEST_ROUTE_BLOCKER, blockrange, breakroutefinding); + const east = this.wallMask(CollisionFlag.WALL_EAST, CollisionFlag.WALL_EAST_PROJ_BLOCKER, CollisionFlag.WALL_EAST_ROUTE_BLOCKER, blockrange, breakroutefinding); + const north = this.wallMask(CollisionFlag.WALL_NORTH, CollisionFlag.WALL_NORTH_PROJ_BLOCKER, CollisionFlag.WALL_NORTH_ROUTE_BLOCKER, blockrange, breakroutefinding); + const south = this.wallMask(CollisionFlag.WALL_SOUTH, CollisionFlag.WALL_SOUTH_PROJ_BLOCKER, CollisionFlag.WALL_SOUTH_ROUTE_BLOCKER, blockrange, breakroutefinding); + + if (angle === LocAngle.WEST) { + this.applyWallPair(x, z, y, west, x - 1, z, y, east, add); + } else if (angle === LocAngle.NORTH) { + this.applyWallPair(x, z, y, north, x, z + 1, y, south, add); + } else if (angle === LocAngle.EAST) { + this.applyWallPair(x, z, y, east, x + 1, z, y, west, add); + } else if (angle === LocAngle.SOUTH) { + this.applyWallPair(x, z, y, south, x, z - 1, y, north, add); + } + + if (breakroutefinding) { + this.changeWallStraight(x, z, y, angle, blockrange, false, add); + } else if (blockrange) { + this.changeWallStraight(x, z, y, angle, false, false, add); + } + } + + private changeWallCorner(x: number, z: number, y: number, angle: number, blockrange: boolean, breakroutefinding: boolean, add: boolean): void { + const northWest = this.wallMask(CollisionFlag.WALL_NORTH_WEST, CollisionFlag.WALL_NORTH_WEST_PROJ_BLOCKER, CollisionFlag.WALL_NORTH_WEST_ROUTE_BLOCKER, blockrange, breakroutefinding); + const southEast = this.wallMask(CollisionFlag.WALL_SOUTH_EAST, CollisionFlag.WALL_SOUTH_EAST_PROJ_BLOCKER, CollisionFlag.WALL_SOUTH_EAST_ROUTE_BLOCKER, blockrange, breakroutefinding); + const northEast = this.wallMask(CollisionFlag.WALL_NORTH_EAST, CollisionFlag.WALL_NORTH_EAST_PROJ_BLOCKER, CollisionFlag.WALL_NORTH_EAST_ROUTE_BLOCKER, blockrange, breakroutefinding); + const southWest = this.wallMask(CollisionFlag.WALL_SOUTH_WEST, CollisionFlag.WALL_SOUTH_WEST_PROJ_BLOCKER, CollisionFlag.WALL_SOUTH_WEST_ROUTE_BLOCKER, blockrange, breakroutefinding); + + if (angle === LocAngle.WEST) { + this.applyWallPair(x, z, y, northWest, x - 1, z + 1, y, southEast, add); + } else if (angle === LocAngle.NORTH) { + this.applyWallPair(x, z, y, northEast, x + 1, z + 1, y, southWest, add); + } else if (angle === LocAngle.EAST) { + this.applyWallPair(x, z, y, southEast, x + 1, z - 1, y, northWest, add); + } else if (angle === LocAngle.SOUTH) { + this.applyWallPair(x, z, y, southWest, x - 1, z - 1, y, northEast, add); + } + + if (breakroutefinding) { + this.changeWallCorner(x, z, y, angle, blockrange, false, add); + } else if (blockrange) { + this.changeWallCorner(x, z, y, angle, false, false, add); + } + } + + private changeWallL(x: number, z: number, y: number, angle: number, blockrange: boolean, breakroutefinding: boolean, add: boolean): void { + const west = this.wallMask(CollisionFlag.WALL_WEST, CollisionFlag.WALL_WEST_PROJ_BLOCKER, CollisionFlag.WALL_WEST_ROUTE_BLOCKER, blockrange, breakroutefinding); + const east = this.wallMask(CollisionFlag.WALL_EAST, CollisionFlag.WALL_EAST_PROJ_BLOCKER, CollisionFlag.WALL_EAST_ROUTE_BLOCKER, blockrange, breakroutefinding); + const north = this.wallMask(CollisionFlag.WALL_NORTH, CollisionFlag.WALL_NORTH_PROJ_BLOCKER, CollisionFlag.WALL_NORTH_ROUTE_BLOCKER, blockrange, breakroutefinding); + const south = this.wallMask(CollisionFlag.WALL_SOUTH, CollisionFlag.WALL_SOUTH_PROJ_BLOCKER, CollisionFlag.WALL_SOUTH_ROUTE_BLOCKER, blockrange, breakroutefinding); + + if (angle === LocAngle.WEST) { + this.applyWallSingle(x, z, y, north | west, add); + this.applyWallSingle(x - 1, z, y, east, add); + this.applyWallSingle(x, z + 1, y, south, add); + } else if (angle === LocAngle.NORTH) { + this.applyWallSingle(x, z, y, north | east, add); + this.applyWallSingle(x, z + 1, y, south, add); + this.applyWallSingle(x + 1, z, y, west, add); + } else if (angle === LocAngle.EAST) { + this.applyWallSingle(x, z, y, south | east, add); + this.applyWallSingle(x + 1, z, y, west, add); + this.applyWallSingle(x, z - 1, y, north, add); + } else if (angle === LocAngle.SOUTH) { + this.applyWallSingle(x, z, y, south | west, add); + this.applyWallSingle(x, z - 1, y, north, add); + this.applyWallSingle(x - 1, z, y, east, add); + } + + if (breakroutefinding) { + this.changeWallL(x, z, y, angle, blockrange, false, add); + } else if (blockrange) { + this.changeWallL(x, z, y, angle, false, false, add); + } + } + + private wallMask(normal: number, projectile: number, route: number, blockrange: boolean, breakroutefinding: boolean): number { + if (breakroutefinding) { + return route; + } + if (blockrange) { + return projectile; + } + return normal; + } + + private applyWallPair(srcX: number, srcZ: number, srcY: number, srcMask: number, dstX: number, dstZ: number, dstY: number, dstMask: number, add: boolean): void { + this.applyWallSingle(srcX, srcZ, srcY, srcMask, add); + this.applyWallSingle(dstX, dstZ, dstY, dstMask, add); + } + + private applyWallSingle(x: number, z: number, y: number, mask: number, add: boolean): void { + if (add) { + this.add(x, z, y, mask); + } else { + this.remove(x, z, y, mask); + } + } + + /** + * Get the collision flags for an entire zone. + * Returns null if the zone is not allocated. + * @param x The x coordinate (absolute world position). + * @param z The z coordinate (absolute world position). + * @param y The level (0-3). + * @returns A copy of the zone's collision flags, or null if not allocated. + */ + getZone(x: number, z: number, y: number): Uint32Array | null { + const zone = this.zones.get(CollisionEngine.zoneIndex(x, z, y)); + return zone ? new Uint32Array(zone) : null; + } + + /** + * Set collision flags for an entire zone. + * @param x The x coordinate (absolute world position). + * @param z The z coordinate (absolute world position). + * @param y The level (0-3). + * @param flags The 64-element collision flag array (8x8 zone). + */ + setZone(x: number, z: number, y: number, flags: Uint32Array): void { + const zone = this.allocateIfAbsentByIndex(CollisionEngine.zoneIndex(x, z, y)); + zone.set(flags); + } +} diff --git a/src/engine/routefinder/CollisionStrategy.ts b/src/engine/routefinder/CollisionStrategy.ts new file mode 100644 index 000000000..098f3c2fe --- /dev/null +++ b/src/engine/routefinder/CollisionStrategy.ts @@ -0,0 +1,45 @@ +import { CollisionFlag, CollisionType } from '#/engine/routefinder/flags.js'; + +const LINE_OF_SIGHT_MOVEMENT = + CollisionFlag.WALL_NORTH_WEST | + CollisionFlag.WALL_NORTH | + CollisionFlag.WALL_NORTH_EAST | + CollisionFlag.WALL_EAST | + CollisionFlag.WALL_SOUTH_EAST | + CollisionFlag.WALL_SOUTH | + CollisionFlag.WALL_SOUTH_WEST | + CollisionFlag.WALL_WEST | + CollisionFlag.LOC; + +const LINE_OF_SIGHT_ROUTE = + CollisionFlag.WALL_NORTH_WEST_ROUTE_BLOCKER | + CollisionFlag.WALL_NORTH_ROUTE_BLOCKER | + CollisionFlag.WALL_NORTH_EAST_ROUTE_BLOCKER | + CollisionFlag.WALL_EAST_ROUTE_BLOCKER | + CollisionFlag.WALL_SOUTH_EAST_ROUTE_BLOCKER | + CollisionFlag.WALL_SOUTH_ROUTE_BLOCKER | + CollisionFlag.WALL_SOUTH_WEST_ROUTE_BLOCKER | + CollisionFlag.WALL_WEST_ROUTE_BLOCKER | + CollisionFlag.LOC_ROUTE_BLOCKER; + +export function canMove(collision: CollisionType, tileFlag: number, blockFlag: number): boolean { + switch (collision) { + case CollisionType.NORMAL: + return (tileFlag & blockFlag) === CollisionFlag.OPEN; + case CollisionType.BLOCKED: { + const flag = blockFlag & ~CollisionFlag.FLOOR; + return (tileFlag & flag) === CollisionFlag.OPEN && (tileFlag & CollisionFlag.FLOOR) !== CollisionFlag.OPEN; + } + case CollisionType.INDOORS: + return (tileFlag & blockFlag) === CollisionFlag.OPEN && (tileFlag & CollisionFlag.ROOF) !== CollisionFlag.OPEN; + case CollisionType.OUTDOORS: + return (tileFlag & (blockFlag | CollisionFlag.ROOF)) === CollisionFlag.OPEN; + case CollisionType.LINE_OF_SIGHT: { + const movementFlags = (blockFlag & LINE_OF_SIGHT_MOVEMENT) << 9; + const routeFlags = (blockFlag & LINE_OF_SIGHT_ROUTE) >>> 13; + return (tileFlag & (movementFlags | routeFlags)) === CollisionFlag.OPEN; + } + default: + return false; + } +} diff --git a/src/engine/routefinder/Line.ts b/src/engine/routefinder/Line.ts new file mode 100644 index 000000000..f92af9547 --- /dev/null +++ b/src/engine/routefinder/Line.ts @@ -0,0 +1,33 @@ +import { CollisionFlag } from '#/engine/routefinder/flags.js'; + +export default class Line { + static readonly SIGHT_BLOCKED_NORTH = CollisionFlag.LOC_PROJ_BLOCKER | CollisionFlag.WALL_NORTH_PROJ_BLOCKER; + static readonly SIGHT_BLOCKED_EAST = CollisionFlag.LOC_PROJ_BLOCKER | CollisionFlag.WALL_EAST_PROJ_BLOCKER; + static readonly SIGHT_BLOCKED_SOUTH = CollisionFlag.LOC_PROJ_BLOCKER | CollisionFlag.WALL_SOUTH_PROJ_BLOCKER; + static readonly SIGHT_BLOCKED_WEST = CollisionFlag.LOC_PROJ_BLOCKER | CollisionFlag.WALL_WEST_PROJ_BLOCKER; + + static readonly WALK_BLOCKED_NORTH = CollisionFlag.WALL_NORTH | CollisionFlag.WALK_BLOCKED; + static readonly WALK_BLOCKED_EAST = CollisionFlag.WALL_EAST | CollisionFlag.WALK_BLOCKED; + static readonly WALK_BLOCKED_SOUTH = CollisionFlag.WALL_SOUTH | CollisionFlag.WALK_BLOCKED; + static readonly WALK_BLOCKED_WEST = CollisionFlag.WALL_WEST | CollisionFlag.WALK_BLOCKED; + + static readonly HALF_TILE = (1 << 16) / 2; + + static scaleUp(tiles: number): number { + return tiles << 16; + } + + static scaleDown(tiles: number): number { + return tiles >> 16; + } + + static coordinate(a: number, b: number, size: number): number { + if (a >= b) { + return a; + } + if (a + size - 1 <= b) { + return a + size - 1; + } + return b; + } +} diff --git a/src/engine/routefinder/LinePathFinder.ts b/src/engine/routefinder/LinePathFinder.ts new file mode 100644 index 000000000..0218a4058 --- /dev/null +++ b/src/engine/routefinder/LinePathFinder.ts @@ -0,0 +1,155 @@ +import CollisionEngine from '#/engine/routefinder/CollisionEngine.js'; +import { CollisionFlag } from '#/engine/routefinder/flags.js'; +import Line from '#/engine/routefinder/Line.js'; +import PackedCoord from '#/engine/routefinder/PackedCoord.js'; + +export function lineOfSight(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number, extraFlag: number): Uint32Array { + return rayCastPath( + flags, + y, + srcX, + srcZ, + destX, + destZ, + srcWidth, + srcHeight, + destWidth, + destHeight, + Line.SIGHT_BLOCKED_WEST | extraFlag, + Line.SIGHT_BLOCKED_EAST | extraFlag, + Line.SIGHT_BLOCKED_SOUTH | extraFlag, + Line.SIGHT_BLOCKED_NORTH | extraFlag, + CollisionFlag.LOC | extraFlag, + CollisionFlag.LOC_PROJ_BLOCKER | extraFlag, + true + ); +} + +export function lineOfWalk(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number, extraFlag: number): Uint32Array { + return rayCastPath( + flags, + y, + srcX, + srcZ, + destX, + destZ, + srcWidth, + srcHeight, + destWidth, + destHeight, + Line.WALK_BLOCKED_WEST | extraFlag, + Line.WALK_BLOCKED_EAST | extraFlag, + Line.WALK_BLOCKED_SOUTH | extraFlag, + Line.WALK_BLOCKED_NORTH | extraFlag, + CollisionFlag.LOC | extraFlag, + CollisionFlag.LOC_PROJ_BLOCKER | extraFlag, + false + ); +} + +function rayCastPath( + flags: CollisionEngine, + y: number, + srcX: number, + srcZ: number, + destX: number, + destZ: number, + srcWidth: number, + srcHeight: number, + destWidth: number, + destHeight: number, + flagWest: number, + flagEast: number, + flagSouth: number, + flagNorth: number, + flagLoc: number, + flagProj: number, + los: boolean +): Uint32Array { + const startX = Line.coordinate(srcX, destX, srcWidth); + const startZ = Line.coordinate(srcZ, destZ, srcHeight); + const endX = Line.coordinate(destX, srcX, destWidth); + const endZ = Line.coordinate(destZ, srcZ, destHeight); + + if (startX === endX && startZ === endZ) { + return new Uint32Array(); + } + + if (los && flags.isFlagged(startX, startZ, y, flagLoc)) { + return new Uint32Array(); + } + + const deltaX = endX - startX; + const deltaZ = endZ - startZ; + const absoluteDeltaX = Math.abs(deltaX); + const absoluteDeltaZ = Math.abs(deltaZ); + const travelEast = deltaX >= 0; + const travelNorth = deltaZ >= 0; + let xFlags = travelEast ? flagWest : flagEast; + let zFlags = travelNorth ? flagSouth : flagNorth; + const coordinates: number[] = []; + + if (absoluteDeltaX > absoluteDeltaZ) { + const offsetX = travelEast ? 1 : -1; + const offsetZ = travelNorth ? 0 : -1; + let scaledZ = Line.scaleUp(startZ) + Line.HALF_TILE + offsetZ; + const tangent = (Line.scaleUp(deltaZ) / absoluteDeltaX) | 0; + let currX = startX; + + while (currX !== endX) { + currX += offsetX; + const currZ = Line.scaleDown(scaledZ); + if (los && currX === endX && currZ === endZ) { + xFlags &= ~flagProj; + } + if (flags.isFlagged(currX, currZ, y, xFlags)) { + return new Uint32Array(); + } + coordinates.push(new PackedCoord(y, currX, currZ).packed); + + scaledZ += tangent; + const nextZ = Line.scaleDown(scaledZ); + if (nextZ !== currZ) { + if (los && currX === endX && nextZ === endZ) { + zFlags &= ~flagProj; + } + if (flags.isFlagged(currX, nextZ, y, zFlags)) { + return new Uint32Array(); + } + coordinates.push(new PackedCoord(y, currX, nextZ).packed); + } + } + } else { + const offsetX = travelEast ? 0 : -1; + const offsetZ = travelNorth ? 1 : -1; + let scaledX = Line.scaleUp(startX) + Line.HALF_TILE + offsetX; + const tangent = (Line.scaleUp(deltaX) / absoluteDeltaZ) | 0; + let currZ = startZ; + + while (currZ !== endZ) { + currZ += offsetZ; + const currX = Line.scaleDown(scaledX); + if (los && currX === endX && currZ === endZ) { + zFlags &= ~flagProj; + } + if (flags.isFlagged(currX, currZ, y, zFlags)) { + return new Uint32Array(); + } + coordinates.push(new PackedCoord(y, currX, currZ).packed); + + scaledX += tangent; + const nextX = Line.scaleDown(scaledX); + if (nextX !== currX) { + if (los && nextX === endX && currZ === endZ) { + xFlags &= ~flagProj; + } + if (flags.isFlagged(nextX, currZ, y, xFlags)) { + return new Uint32Array(); + } + coordinates.push(new PackedCoord(y, nextX, currZ).packed); + } + } + } + + return Uint32Array.from(coordinates); +} diff --git a/src/engine/routefinder/LineValidator.ts b/src/engine/routefinder/LineValidator.ts new file mode 100644 index 000000000..0a0d98760 --- /dev/null +++ b/src/engine/routefinder/LineValidator.ts @@ -0,0 +1,145 @@ +import CollisionEngine from '#/engine/routefinder/CollisionEngine.js'; +import { CollisionFlag } from '#/engine/routefinder/flags.js'; +import Line from '#/engine/routefinder/Line.js'; + +export function hasLineOfSight(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number, extraFlag: number): boolean { + return rayCastLine( + flags, + y, + srcX, + srcZ, + destX, + destZ, + srcWidth, + srcHeight, + destWidth, + destHeight, + Line.SIGHT_BLOCKED_WEST | extraFlag, + Line.SIGHT_BLOCKED_EAST | extraFlag, + Line.SIGHT_BLOCKED_SOUTH | extraFlag, + Line.SIGHT_BLOCKED_NORTH | extraFlag, + CollisionFlag.LOC | extraFlag, + CollisionFlag.LOC_PROJ_BLOCKER | extraFlag, + true + ); +} + +export function hasLineOfWalk(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number, extraFlag: number): boolean { + return rayCastLine( + flags, + y, + srcX, + srcZ, + destX, + destZ, + srcWidth, + srcHeight, + destWidth, + destHeight, + Line.WALK_BLOCKED_WEST | extraFlag, + Line.WALK_BLOCKED_EAST | extraFlag, + Line.WALK_BLOCKED_SOUTH | extraFlag, + Line.WALK_BLOCKED_NORTH | extraFlag, + CollisionFlag.LOC | extraFlag, + CollisionFlag.LOC_PROJ_BLOCKER | extraFlag, + false + ); +} + +function rayCastLine( + flags: CollisionEngine, + y: number, + srcX: number, + srcZ: number, + destX: number, + destZ: number, + srcWidth: number, + srcHeight: number, + destWidth: number, + destHeight: number, + flagWest: number, + flagEast: number, + flagSouth: number, + flagNorth: number, + flagLoc: number, + flagProj: number, + los: boolean +): boolean { + const startX = Line.coordinate(srcX, destX, srcWidth); + const startZ = Line.coordinate(srcZ, destZ, srcHeight); + const endX = Line.coordinate(destX, srcX, destWidth); + const endZ = Line.coordinate(destZ, srcZ, destHeight); + + if (startX === endX && startZ === endZ) { + return true; + } + + if (los && flags.isFlagged(startX, startZ, y, flagLoc)) { + return false; + } + + const deltaX = endX - startX; + const deltaZ = endZ - startZ; + const absoluteDeltaX = Math.abs(deltaX); + const absoluteDeltaZ = Math.abs(deltaZ); + const travelEast = deltaX >= 0; + const travelNorth = deltaZ >= 0; + let xFlags = travelEast ? flagWest : flagEast; + let zFlags = travelNorth ? flagSouth : flagNorth; + + if (absoluteDeltaX > absoluteDeltaZ) { + const offsetX = travelEast ? 1 : -1; + const offsetZ = travelNorth ? 0 : -1; + let scaledZ = Line.scaleUp(startZ) + Line.HALF_TILE + offsetZ; + const tangent = (Line.scaleUp(deltaZ) / absoluteDeltaX) | 0; + let currX = startX; + + while (currX !== endX) { + currX += offsetX; + const currZ = Line.scaleDown(scaledZ); + if (los && currX === endX && currZ === endZ) { + xFlags &= ~flagProj; + } + if (flags.isFlagged(currX, currZ, y, xFlags)) { + return false; + } + + scaledZ += tangent; + const nextZ = Line.scaleDown(scaledZ); + if (los && currX === endX && nextZ === endZ) { + zFlags &= ~flagProj; + } + if (nextZ !== currZ && flags.isFlagged(currX, nextZ, y, zFlags)) { + return false; + } + } + } else { + const offsetX = travelEast ? 0 : -1; + const offsetZ = travelNorth ? 1 : -1; + let scaledX = Line.scaleUp(startX) + Line.HALF_TILE + offsetX; + const tangent = (Line.scaleUp(deltaX) / absoluteDeltaZ) | 0; + let currZ = startZ; + + while (currZ !== endZ) { + currZ += offsetZ; + const currX = Line.scaleDown(scaledX); + if (los && currX === endX && currZ === endZ) { + zFlags &= ~flagProj; + } + if (flags.isFlagged(currX, currZ, y, zFlags)) { + return false; + } + + scaledX += tangent; + const nextX = Line.scaleDown(scaledX); + if (los && nextX === endX && currZ === endZ) { + xFlags &= ~flagProj; + } + if (nextX !== currX && flags.isFlagged(nextX, currZ, y, xFlags)) { + return false; + } + } + } + + return true; +} diff --git a/src/engine/routefinder/NaivePathFinder.ts b/src/engine/routefinder/NaivePathFinder.ts new file mode 100644 index 000000000..f036ff915 --- /dev/null +++ b/src/engine/routefinder/NaivePathFinder.ts @@ -0,0 +1,147 @@ +import CollisionEngine from '#/engine/routefinder/CollisionEngine.js'; +import PackedCoord from '#/engine/routefinder/PackedCoord.js'; +import { CollisionType } from '#/engine/routefinder/flags.js'; +import { canTravel } from '#/engine/routefinder/StepValidator.js'; + +const DIRECTIONS = [ + [-1, 0], + [1, 0], + [0, 1], + [0, -1] +] as const; + +export function findNaivePath( + flags: CollisionEngine, + y: number, + srcX: number, + srcZ: number, + destX: number, + destZ: number, + srcWidth: number, + srcHeight: number, + destWidth: number, + destHeight: number, + extraFlag: number, + collision: CollisionType +): Uint32Array { + if (intersects(srcX, srcZ, srcWidth, srcHeight, destX, destZ, destWidth, destHeight)) { + return cardinalDestination(y, srcX, srcZ); + } + + const dest = naiveDestination(y, srcX, srcZ, srcWidth, srcHeight, destX, destZ, 1, 1); + const coord = PackedCoord.from(dest[0]); + const dx = coord.x; + const dz = coord.z; + + if (isDiagonal(dx, dz, srcWidth, srcHeight, destX, destZ, destWidth, destHeight)) { + return dest; + } + + if (intersects(dx, dz, srcWidth, srcHeight, destX, destZ, destWidth, destHeight)) { + return dest; + } + + let currX = dx; + let currZ = dz; + while (currX !== destX && currZ !== destZ) { + const stepX = Math.sign(destX - currX); + const stepZ = Math.sign(destZ - currZ); + if (canTravel(flags, y, currX, currZ, stepX, stepZ, srcWidth, extraFlag, collision)) { + currX += stepX; + currZ += stepZ; + } else if (stepX !== 0 && canTravel(flags, y, currX, currZ, stepX, 0, srcWidth, extraFlag, collision)) { + currX += stepX; + } else if (stepZ !== 0 && canTravel(flags, y, currX, currZ, 0, stepZ, srcWidth, extraFlag, collision)) { + currZ += stepZ; + } else { + break; + } + } + + return new Uint32Array([new PackedCoord(y, currX, currZ).packed]); +} + +function intersects(srcX: number, srcZ: number, srcWidth: number, srcHeight: number, destX: number, destZ: number, destWidth: number, destHeight: number): boolean { + const srcHorizontal = srcX + srcWidth; + const srcVertical = srcZ + srcHeight; + const destHorizontal = destX + destWidth; + const destVertical = destZ + destHeight; + return !(destX >= srcHorizontal || destHorizontal <= srcX || destZ >= srcVertical || destVertical <= srcZ); +} + +function isDiagonal(srcX: number, srcZ: number, srcWidth: number, srcHeight: number, destX: number, destZ: number, destWidth: number, destHeight: number): boolean { + if (srcX + srcWidth === destX && srcZ + srcHeight === destZ) { + return true; + } + if (srcX - 1 === destX + destWidth - 1 && srcZ - 1 === destZ + destHeight - 1) { + return true; + } + if (srcX + srcWidth === destX && srcZ - 1 === destZ + destHeight - 1) { + return true; + } + return srcX - 1 === destX + destWidth - 1 && srcZ + srcHeight === destZ; +} + +function cardinalDestination(y: number, srcX: number, srcZ: number): Uint32Array { + const direction = DIRECTIONS[(Math.random() * DIRECTIONS.length) | 0]; + return new Uint32Array([new PackedCoord(y, srcX + direction[0], srcZ + direction[1]).packed]); +} + +function naiveDestination(y: number, srcX: number, srcZ: number, srcWidth: number, srcHeight: number, destX: number, destZ: number, destWidth: number, destHeight: number): Uint32Array { + const diagonal = srcX - destX + (srcZ - destZ); + const anti = srcX - destX - (srcZ - destZ); + const southWestClockwise = anti < 0; + const northWestClockwise = diagonal >= destHeight - 1 - (srcWidth - 1); + const northEastClockwise = anti > srcWidth - srcHeight; + const southEastClockwise = diagonal <= destWidth - 1 - (srcHeight - 1); + + if (southWestClockwise && !northWestClockwise) { + let offZ = 0; + if (diagonal >= -srcWidth) { + offZ = coerceAtMost(diagonal + srcWidth, destHeight - 1); + } else if (anti > -srcWidth) { + offZ = -(srcWidth + anti); + } + return new Uint32Array([new PackedCoord(y, -srcWidth + destX, offZ + destZ).packed]); + } + + if (northWestClockwise && !northEastClockwise) { + let offX = 0; + if (anti >= -destHeight) { + offX = coerceAtMost(anti + destHeight, destWidth - 1); + } else if (diagonal < destHeight) { + offX = coerceAtLeast(diagonal - destHeight, -(srcWidth - 1)); + } + return new Uint32Array([new PackedCoord(y, offX + destX, destHeight + destZ).packed]); + } + + if (northEastClockwise && !southEastClockwise) { + let offZ = 0; + if (anti <= destWidth) { + offZ = destHeight - anti; + } else if (diagonal < destWidth) { + offZ = coerceAtLeast(diagonal - destWidth, -(srcHeight - 1)); + } + return new Uint32Array([new PackedCoord(y, destWidth + destX, offZ + destZ).packed]); + } + + if (!(southEastClockwise && !southWestClockwise)) { + return new Uint32Array(); + } + + let offX = 0; + if (diagonal > -srcHeight) { + offX = coerceAtMost(diagonal + srcHeight, destWidth - 1); + } else if (anti < srcHeight) { + offX = coerceAtLeast(anti - srcHeight, -(srcHeight - 1)); + } + return new Uint32Array([new PackedCoord(y, offX + destX, -srcHeight + destZ).packed]); +} + +function coerceAtMost(value: number, max: number): number { + return value > max ? max : value; +} + +function coerceAtLeast(value: number, min: number): number { + return value < min ? min : value; +} diff --git a/src/engine/routefinder/PackedCoord.ts b/src/engine/routefinder/PackedCoord.ts new file mode 100644 index 000000000..edd945346 --- /dev/null +++ b/src/engine/routefinder/PackedCoord.ts @@ -0,0 +1,25 @@ +export default class PackedCoord { + packed: number; + + constructor(level: number, x: number, z: number) { + this.packed = ((z & 0x3fff) | ((x & 0x3fff) << 14) | ((level & 0x3) << 28)) >>> 0; + } + + static from(packed: number): PackedCoord { + const coord = Object.create(PackedCoord.prototype) as PackedCoord; + coord.packed = packed >>> 0; + return coord; + } + + get level(): number { + return (this.packed >>> 28) & 0x3; + } + + get x(): number { + return (this.packed >>> 14) & 0x3fff; + } + + get z(): number { + return this.packed & 0x3fff; + } +} diff --git a/src/engine/routefinder/PathFinder.ts b/src/engine/routefinder/PathFinder.ts new file mode 100644 index 000000000..1aeb931c0 --- /dev/null +++ b/src/engine/routefinder/PathFinder.ts @@ -0,0 +1,625 @@ +import { canMove } from '#/engine/routefinder/CollisionStrategy.js'; +import CollisionEngine from '#/engine/routefinder/CollisionEngine.js'; +import { CollisionFlag, CollisionType, DirectionFlag } from '#/engine/routefinder/flags.js'; +import PackedCoord from '#/engine/routefinder/PackedCoord.js'; +import ReachStrategy from '#/engine/routefinder/ReachStrategy.js'; +import { rotate } from '#/engine/routefinder/Rotation.js'; + +export default class PathFinder { + private static readonly DEFAULT_SEARCH_MAP_SIZE = 128; + private static readonly DEFAULT_RING_BUFFER_SIZE = 4096; + private static readonly DEFAULT_DISTANCE_VALUE = 99_999_999; + private static readonly DEFAULT_SRC_DIRECTION_VALUE = 99; + private static readonly MAX_ALTERNATIVE_ROUTE_LOWEST_COST = 1000; + private static readonly MAX_ALTERNATIVE_ROUTE_SEEK_RANGE = 100; + private static readonly MAX_ALTERNATIVE_ROUTE_DISTANCE_FROM_DESTINATION = 10; + + private readonly searchMapSize: number; + private readonly ringBufferSize: number; + private readonly searchHalfMapSize: number; + private readonly directions: Int8Array; + private readonly distances: Int32Array; + private readonly validLocalX: Int32Array; + private readonly validLocalZ: Int32Array; + + private currLocalX = 0; + private currLocalZ = 0; + private bufReaderIndex = 0; + private bufWriterIndex = 0; + + constructor() { + this.searchMapSize = PathFinder.DEFAULT_SEARCH_MAP_SIZE; + this.ringBufferSize = PathFinder.DEFAULT_RING_BUFFER_SIZE; + this.searchHalfMapSize = this.searchMapSize / 2; + this.directions = new Int8Array(this.searchMapSize * this.searchMapSize); + this.distances = new Int32Array(this.searchMapSize * this.searchMapSize); + this.validLocalX = new Int32Array(this.ringBufferSize); + this.validLocalZ = new Int32Array(this.ringBufferSize); + this.distances.fill(PathFinder.DEFAULT_DISTANCE_VALUE); + } + + findPath( + flags: CollisionEngine, + y: number, + srcX: number, + srcZ: number, + destX: number, + destZ: number, + srcSize: number, + destWidth: number, + destHeight: number, + angle: number, + shape: number, + moveNear: boolean, + blockAccessFlags: number, + maxWaypoints: number, + collision: CollisionType + ): Uint32Array { + this.reset(); + + const baseX = srcX - this.searchHalfMapSize; + const baseZ = srcZ - this.searchHalfMapSize; + const localSrcX = srcX - baseX; + const localSrcZ = srcZ - baseZ; + const localDestX = destX - baseX; + const localDestZ = destZ - baseZ; + + this.appendDirection(localSrcX, localSrcZ, PathFinder.DEFAULT_SRC_DIRECTION_VALUE, 0); + + let pathFound: boolean; + switch (srcSize) { + case 1: + pathFound = this.findPath1(flags, baseX, baseZ, y, localDestX, localDestZ, destWidth, destHeight, srcSize, angle, shape, blockAccessFlags, collision); + break; + case 2: + pathFound = this.findPath2(flags, baseX, baseZ, y, localDestX, localDestZ, destWidth, destHeight, srcSize, angle, shape, blockAccessFlags, collision); + break; + default: + pathFound = this.findPathN(flags, baseX, baseZ, y, localDestX, localDestZ, destWidth, destHeight, srcSize, angle, shape, blockAccessFlags, collision); + break; + } + + if (!pathFound) { + if (!moveNear) { + return new Uint32Array(); + } + + const foundApproachPoint = this.findClosestApproachPoint(localDestX, localDestZ, rotate(angle, destWidth, destHeight), rotate(angle, destHeight, destWidth)); + if (!foundApproachPoint) { + return new Uint32Array(); + } + } + + const limit = maxWaypoints; + const waypoints: number[] = []; + let next = this.directions[this.localIndex(this.currLocalX, this.currLocalZ)]; + let curr = -1; + + for (let index = 0; index < this.directions.length; index++) { + if (this.currLocalX === localSrcX && this.currLocalZ === localSrcZ) { + break; + } + + if (curr !== next) { + curr = next; + if (waypoints.length >= limit) { + waypoints.pop(); + } + waypoints.unshift(new PackedCoord(y, baseX + this.currLocalX, baseZ + this.currLocalZ).packed); + } + + if ((curr & DirectionFlag.East) !== 0) { + this.currLocalX += 1; + } else if ((curr & DirectionFlag.West) !== 0) { + this.currLocalX -= 1; + } + + if ((curr & DirectionFlag.North) !== 0) { + this.currLocalZ += 1; + } else if ((curr & DirectionFlag.South) !== 0) { + this.currLocalZ -= 1; + } + + next = this.directions[this.localIndex(this.currLocalX, this.currLocalZ)]; + } + + return Uint32Array.from(waypoints); + } + + private findPath1( + flags: CollisionEngine, + baseX: number, + baseZ: number, + y: number, + localDestX: number, + localDestZ: number, + destWidth: number, + destHeight: number, + srcSize: number, + angle: number, + shape: number, + blockAccessFlags: number, + collision: CollisionType + ): boolean { + const relativeSearchSize = this.searchMapSize - 1; + + while (this.bufWriterIndex !== this.bufReaderIndex) { + this.currLocalX = this.validLocalX[this.bufReaderIndex]; + this.currLocalZ = this.validLocalZ[this.bufReaderIndex]; + this.bufReaderIndex = (this.bufReaderIndex + 1) & (this.ringBufferSize - 1); + + if (ReachStrategy.reached(flags, y, this.currLocalX + baseX, this.currLocalZ + baseZ, localDestX + baseX, localDestZ + baseZ, destWidth, destHeight, srcSize, angle, shape, blockAccessFlags)) { + return true; + } + + const nextDistance = this.distances[this.localIndex(this.currLocalX, this.currLocalZ)] + 1; + + let x = this.currLocalX - 1; + let z = this.currLocalZ; + if (this.currLocalX > 0 && this.directions[this.localIndex(x, z)] === 0 && canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_WEST)) { + this.appendDirection(x, z, DirectionFlag.East, nextDistance); + } + + x = this.currLocalX + 1; + z = this.currLocalZ; + if (this.currLocalX < relativeSearchSize && this.directions[this.localIndex(x, z)] === 0 && canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_EAST)) { + this.appendDirection(x, z, DirectionFlag.West, nextDistance); + } + + x = this.currLocalX; + z = this.currLocalZ - 1; + if (this.currLocalZ > 0 && this.directions[this.localIndex(x, z)] === 0 && canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_SOUTH)) { + this.appendDirection(x, z, DirectionFlag.North, nextDistance); + } + + x = this.currLocalX; + z = this.currLocalZ + 1; + if (this.currLocalZ < relativeSearchSize && this.directions[this.localIndex(x, z)] === 0 && canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_NORTH)) { + this.appendDirection(x, z, DirectionFlag.South, nextDistance); + } + + x = this.currLocalX - 1; + z = this.currLocalZ - 1; + if ( + this.currLocalX > 0 && + this.currLocalZ > 0 && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_SOUTH_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ, y), CollisionFlag.BLOCK_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX, z, y), CollisionFlag.BLOCK_SOUTH) + ) { + this.appendDirection(x, z, DirectionFlag.NorthEast, nextDistance); + } + + x = this.currLocalX + 1; + z = this.currLocalZ - 1; + if ( + this.currLocalX < relativeSearchSize && + this.currLocalZ > 0 && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_SOUTH_EAST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ, y), CollisionFlag.BLOCK_EAST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX, z, y), CollisionFlag.BLOCK_SOUTH) + ) { + this.appendDirection(x, z, DirectionFlag.NorthWest, nextDistance); + } + + x = this.currLocalX - 1; + z = this.currLocalZ + 1; + if ( + this.currLocalX > 0 && + this.currLocalZ < relativeSearchSize && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_NORTH_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ, y), CollisionFlag.BLOCK_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX, z, y), CollisionFlag.BLOCK_NORTH) + ) { + this.appendDirection(x, z, DirectionFlag.SouthEast, nextDistance); + } + + x = this.currLocalX + 1; + z = this.currLocalZ + 1; + if ( + this.currLocalX < relativeSearchSize && + this.currLocalZ < relativeSearchSize && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_NORTH_EAST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ, y), CollisionFlag.BLOCK_EAST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX, z, y), CollisionFlag.BLOCK_NORTH) + ) { + this.appendDirection(x, z, DirectionFlag.SouthWest, nextDistance); + } + } + + return false; + } + + private findPath2( + flags: CollisionEngine, + baseX: number, + baseZ: number, + y: number, + localDestX: number, + localDestZ: number, + destWidth: number, + destHeight: number, + srcSize: number, + angle: number, + shape: number, + blockAccessFlags: number, + collision: CollisionType + ): boolean { + const relativeSearchSize = this.searchMapSize - 2; + + while (this.bufWriterIndex !== this.bufReaderIndex) { + this.currLocalX = this.validLocalX[this.bufReaderIndex]; + this.currLocalZ = this.validLocalZ[this.bufReaderIndex]; + this.bufReaderIndex = (this.bufReaderIndex + 1) & (this.ringBufferSize - 1); + + if (ReachStrategy.reached(flags, y, this.currLocalX + baseX, this.currLocalZ + baseZ, localDestX + baseX, localDestZ + baseZ, destWidth, destHeight, srcSize, angle, shape, blockAccessFlags)) { + return true; + } + + const nextDistance = this.distances[this.localIndex(this.currLocalX, this.currLocalZ)] + 1; + + let x = this.currLocalX - 1; + let z = this.currLocalZ; + if ( + this.currLocalX > 0 && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_SOUTH_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ + 1, y), CollisionFlag.BLOCK_NORTH_WEST) + ) { + this.appendDirection(x, z, DirectionFlag.East, nextDistance); + } + + x = this.currLocalX + 1; + z = this.currLocalZ; + if ( + this.currLocalX < relativeSearchSize && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + 2, z, y), CollisionFlag.BLOCK_SOUTH_EAST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + 2, this.currLocalZ + 1, y), CollisionFlag.BLOCK_NORTH_EAST) + ) { + this.appendDirection(x, z, DirectionFlag.West, nextDistance); + } + + x = this.currLocalX; + z = this.currLocalZ - 1; + if ( + this.currLocalZ > 0 && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_SOUTH_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + 1, z, y), CollisionFlag.BLOCK_SOUTH_EAST) + ) { + this.appendDirection(x, z, DirectionFlag.North, nextDistance); + } + + x = this.currLocalX; + z = this.currLocalZ + 1; + if ( + this.currLocalZ < relativeSearchSize && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ + 2, y), CollisionFlag.BLOCK_NORTH_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + 1, this.currLocalZ + 2, y), CollisionFlag.BLOCK_NORTH_EAST) + ) { + this.appendDirection(x, z, DirectionFlag.South, nextDistance); + } + + x = this.currLocalX - 1; + z = this.currLocalZ - 1; + if ( + this.currLocalX > 0 && + this.currLocalZ > 0 && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_EAST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_SOUTH_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX, z, y), CollisionFlag.BLOCK_NORTH_EAST_AND_WEST) + ) { + this.appendDirection(x, z, DirectionFlag.NorthEast, nextDistance); + } + + x = this.currLocalX + 1; + z = this.currLocalZ - 1; + if ( + this.currLocalX < relativeSearchSize && + this.currLocalZ > 0 && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_NORTH_EAST_AND_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + 2, z, y), CollisionFlag.BLOCK_SOUTH_EAST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + 2, this.currLocalZ, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_WEST) + ) { + this.appendDirection(x, z, DirectionFlag.NorthWest, nextDistance); + } + + x = this.currLocalX - 1; + z = this.currLocalZ + 1; + if ( + this.currLocalX > 0 && + this.currLocalZ < relativeSearchSize && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_EAST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ + 2, y), CollisionFlag.BLOCK_NORTH_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX, this.currLocalZ + 2, y), CollisionFlag.BLOCK_SOUTH_EAST_AND_WEST) + ) { + this.appendDirection(x, z, DirectionFlag.SouthEast, nextDistance); + } + + x = this.currLocalX + 1; + z = this.currLocalZ + 1; + if ( + this.currLocalX < relativeSearchSize && + this.currLocalZ < relativeSearchSize && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ + 2, y), CollisionFlag.BLOCK_SOUTH_EAST_AND_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + 2, this.currLocalZ + 2, y), CollisionFlag.BLOCK_NORTH_EAST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + 2, z, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_WEST) + ) { + this.appendDirection(x, z, DirectionFlag.SouthWest, nextDistance); + } + } + + return false; + } + + private findPathN( + flags: CollisionEngine, + baseX: number, + baseZ: number, + y: number, + localDestX: number, + localDestZ: number, + destWidth: number, + destHeight: number, + srcSize: number, + angle: number, + shape: number, + blockAccessFlags: number, + collision: CollisionType + ): boolean { + const relativeSearchSize = this.searchMapSize - srcSize; + + while (this.bufWriterIndex !== this.bufReaderIndex) { + this.currLocalX = this.validLocalX[this.bufReaderIndex]; + this.currLocalZ = this.validLocalZ[this.bufReaderIndex]; + this.bufReaderIndex = (this.bufReaderIndex + 1) & (this.ringBufferSize - 1); + + if (ReachStrategy.reached(flags, y, this.currLocalX + baseX, this.currLocalZ + baseZ, localDestX + baseX, localDestZ + baseZ, destWidth, destHeight, srcSize, angle, shape, blockAccessFlags)) { + return true; + } + + const nextDistance = this.distances[this.localIndex(this.currLocalX, this.currLocalZ)] + 1; + + let x = this.currLocalX - 1; + let z = this.currLocalZ; + if ( + this.currLocalX > 0 && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_SOUTH_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ + srcSize - 1, y), CollisionFlag.BLOCK_NORTH_WEST) + ) { + let blocked = false; + for (let index = 1; index < srcSize - 1; index++) { + if (!canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ + index, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_EAST)) { + blocked = true; + break; + } + } + if (!blocked) { + this.appendDirection(x, z, DirectionFlag.East, nextDistance); + } + } + + x = this.currLocalX + 1; + z = this.currLocalZ; + if ( + this.currLocalX < relativeSearchSize && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + srcSize, z, y), CollisionFlag.BLOCK_SOUTH_EAST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + srcSize, this.currLocalZ + srcSize - 1, y), CollisionFlag.BLOCK_NORTH_EAST) + ) { + let blocked = false; + for (let index = 1; index < srcSize - 1; index++) { + if (!canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + srcSize, this.currLocalZ + index, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_WEST)) { + blocked = true; + break; + } + } + if (!blocked) { + this.appendDirection(x, z, DirectionFlag.West, nextDistance); + } + } + + x = this.currLocalX; + z = this.currLocalZ - 1; + if ( + this.currLocalZ > 0 && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_SOUTH_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + srcSize - 1, z, y), CollisionFlag.BLOCK_SOUTH_EAST) + ) { + let blocked = false; + for (let index = 1; index < srcSize - 1; index++) { + if (!canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + index, z, y), CollisionFlag.BLOCK_NORTH_EAST_AND_WEST)) { + blocked = true; + break; + } + } + if (!blocked) { + this.appendDirection(x, z, DirectionFlag.North, nextDistance); + } + } + + x = this.currLocalX; + z = this.currLocalZ + 1; + if ( + this.currLocalZ < relativeSearchSize && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ + srcSize, y), CollisionFlag.BLOCK_NORTH_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + srcSize - 1, this.currLocalZ + srcSize, y), CollisionFlag.BLOCK_NORTH_EAST) + ) { + let blocked = false; + for (let index = 1; index < srcSize - 1; index++) { + if (!canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x + index, this.currLocalZ + srcSize, y), CollisionFlag.BLOCK_SOUTH_EAST_AND_WEST)) { + blocked = true; + break; + } + } + if (!blocked) { + this.appendDirection(x, z, DirectionFlag.South, nextDistance); + } + } + + x = this.currLocalX - 1; + z = this.currLocalZ - 1; + if (this.currLocalX > 0 && this.currLocalZ > 0 && this.directions[this.localIndex(x, z)] === 0 && canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_SOUTH_WEST)) { + let blocked = false; + for (let index = 1; index < srcSize; index++) { + if ( + !canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ + index - 1, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_EAST) || + !canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + index - 1, z, y), CollisionFlag.BLOCK_NORTH_EAST_AND_WEST) + ) { + blocked = true; + break; + } + } + if (!blocked) { + this.appendDirection(x, z, DirectionFlag.NorthEast, nextDistance); + } + } + + x = this.currLocalX + 1; + z = this.currLocalZ - 1; + if ( + this.currLocalX < relativeSearchSize && + this.currLocalZ > 0 && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + srcSize, z, y), CollisionFlag.BLOCK_SOUTH_EAST) + ) { + let blocked = false; + for (let index = 1; index < srcSize; index++) { + if ( + !canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + srcSize, this.currLocalZ + index - 1, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_WEST) || + !canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + index, z, y), CollisionFlag.BLOCK_NORTH_EAST_AND_WEST) + ) { + blocked = true; + break; + } + } + if (!blocked) { + this.appendDirection(x, z, DirectionFlag.NorthWest, nextDistance); + } + } + + x = this.currLocalX - 1; + z = this.currLocalZ + 1; + if ( + this.currLocalX > 0 && + this.currLocalZ < relativeSearchSize && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ + srcSize, y), CollisionFlag.BLOCK_NORTH_WEST) + ) { + let blocked = false; + for (let index = 1; index < srcSize; index++) { + if ( + !canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ + index, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_EAST) || + !canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + index - 1, this.currLocalZ + srcSize, y), CollisionFlag.BLOCK_SOUTH_EAST_AND_WEST) + ) { + blocked = true; + break; + } + } + if (!blocked) { + this.appendDirection(x, z, DirectionFlag.SouthEast, nextDistance); + } + } + + x = this.currLocalX + 1; + z = this.currLocalZ + 1; + if ( + this.currLocalX < relativeSearchSize && + this.currLocalZ < relativeSearchSize && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + srcSize, this.currLocalZ + srcSize, y), CollisionFlag.BLOCK_NORTH_EAST) + ) { + let blocked = false; + for (let index = 1; index < srcSize; index++) { + if ( + !canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + index, this.currLocalZ + srcSize, y), CollisionFlag.BLOCK_SOUTH_EAST_AND_WEST) || + !canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + srcSize, this.currLocalZ + index, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_WEST) + ) { + blocked = true; + break; + } + } + if (!blocked) { + this.appendDirection(x, z, DirectionFlag.SouthWest, nextDistance); + } + } + } + + return false; + } + + private findClosestApproachPoint(localDestX: number, localDestZ: number, width: number, height: number): boolean { + let lowestCost = PathFinder.MAX_ALTERNATIVE_ROUTE_LOWEST_COST; + let maxAlternativePath = PathFinder.MAX_ALTERNATIVE_ROUTE_SEEK_RANGE; + const alternativeRouteRange = PathFinder.MAX_ALTERNATIVE_ROUTE_DISTANCE_FROM_DESTINATION; + + for (let x = localDestX - alternativeRouteRange; x <= localDestX + alternativeRouteRange; x++) { + for (let z = localDestZ - alternativeRouteRange; z <= localDestZ + alternativeRouteRange; z++) { + if (!(x >= 0 && x < this.searchMapSize) || !(z >= 0 && z < this.searchMapSize) || this.distances[this.localIndex(x, z)] >= PathFinder.MAX_ALTERNATIVE_ROUTE_SEEK_RANGE) { + continue; + } + + let dx = 0; + if (x < localDestX) { + dx = localDestX - x; + } else if (x > localDestX + width - 1) { + dx = x - (width + localDestX - 1); + } + + let dz = 0; + if (z < localDestZ) { + dz = localDestZ - z; + } else if (z > localDestZ + height - 1) { + dz = z - (height + localDestZ - 1); + } + + const cost = dx * dx + dz * dz; + if (cost < lowestCost || (cost === lowestCost && maxAlternativePath > this.distances[this.localIndex(x, z)])) { + this.currLocalX = x; + this.currLocalZ = z; + lowestCost = cost; + maxAlternativePath = this.distances[this.localIndex(x, z)]; + } + } + } + + return lowestCost !== PathFinder.MAX_ALTERNATIVE_ROUTE_LOWEST_COST; + } + + private localIndex(x: number, z: number): number { + return x * this.searchMapSize + z; + } + + private static collisionFlag(flags: CollisionEngine, baseX: number, baseZ: number, localX: number, localZ: number, y: number): number { + return flags.get(baseX + localX, baseZ + localZ, y); + } + + private appendDirection(x: number, z: number, direction: number, distance: number): void { + const index = this.localIndex(x, z); + this.directions[index] = direction; + this.distances[index] = distance; + this.validLocalX[this.bufWriterIndex] = x; + this.validLocalZ[this.bufWriterIndex] = z; + this.bufWriterIndex = (this.bufWriterIndex + 1) & (this.ringBufferSize - 1); + } + + private reset(): void { + this.directions.fill(0); + this.distances.fill(PathFinder.DEFAULT_DISTANCE_VALUE); + this.bufReaderIndex = 0; + this.bufWriterIndex = 0; + } +} diff --git a/src/engine/routefinder/ReachStrategy.ts b/src/engine/routefinder/ReachStrategy.ts new file mode 100644 index 000000000..bb3a51099 --- /dev/null +++ b/src/engine/routefinder/ReachStrategy.ts @@ -0,0 +1,324 @@ +import CollisionEngine from '#/engine/routefinder/CollisionEngine.js'; +import { CollisionFlag, LocAngle, LocShape } from '#/engine/routefinder/flags.js'; +import { collides, reachRectangle1, reachRectangleN } from '#/engine/routefinder/RectangleBoundary.js'; +import { rotate, rotateFlags } from '#/engine/routefinder/Rotation.js'; + +export default class ReachStrategy { + private static readonly WALL_STRATEGY = 0; + private static readonly WALL_DECOR_STRATEGY = 1; + private static readonly RECTANGLE_STRATEGY = 2; + private static readonly NO_STRATEGY = 3; + private static readonly RECTANGLE_EXCLUSIVE_STRATEGY = 4; + + static alteredRotation(angle: number, shape: number): number { + return shape === 7 ? (angle + 2) & 0x3 : angle; + } + + static reached(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, destWidth: number, destHeight: number, srcSize: number, angle: number, shape: number, blockAccessFlags: number): boolean { + const exitStrategy = ReachStrategy.exitStrategy(shape); + if (exitStrategy !== ReachStrategy.RECTANGLE_EXCLUSIVE_STRATEGY && srcX === destX && srcZ === destZ) { + return true; + } + + switch (exitStrategy) { + case ReachStrategy.WALL_STRATEGY: + return ReachStrategy.reachWall(flags, y, srcX, srcZ, destX, destZ, srcSize, shape, angle); + case ReachStrategy.WALL_DECOR_STRATEGY: + return ReachStrategy.reachWallDecor(flags, y, srcX, srcZ, destX, destZ, srcSize, shape, angle); + case ReachStrategy.RECTANGLE_STRATEGY: + return ReachStrategy.reachRectangle(flags, y, srcX, srcZ, destX, destZ, srcSize, destWidth, destHeight, angle, blockAccessFlags); + case ReachStrategy.RECTANGLE_EXCLUSIVE_STRATEGY: + return ReachStrategy.reachExclusiveRectangle(flags, y, srcX, srcZ, destX, destZ, srcSize, destWidth, destHeight, angle, blockAccessFlags); + default: + return false; + } + } + + static reachRectangle(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcSize: number, destWidth: number, destHeight: number, angle: number, blockAccessFlags: number): boolean { + const rotatedWidth = rotate(angle, destWidth, destHeight); + const rotatedHeight = rotate(angle, destHeight, destWidth); + const rotatedBlockAccess = rotateFlags(angle, blockAccessFlags); + const intersects = collides(srcX, srcZ, destX, destZ, srcSize, srcSize, rotatedWidth, rotatedHeight); + + return srcSize === 1 + ? intersects || reachRectangle1(flags, y, srcX, srcZ, destX, destZ, rotatedWidth, rotatedHeight, rotatedBlockAccess) + : intersects || reachRectangleN(flags, y, srcX, srcZ, destX, destZ, srcSize, srcSize, rotatedWidth, rotatedHeight, rotatedBlockAccess); + } + + static reachExclusiveRectangle(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcSize: number, destWidth: number, destHeight: number, angle: number, blockAccessFlags: number): boolean { + const rotatedWidth = rotate(angle, destWidth, destHeight); + const rotatedHeight = rotate(angle, destHeight, destWidth); + const rotatedBlockAccess = rotateFlags(angle, blockAccessFlags); + const intersects = collides(srcX, srcZ, destX, destZ, srcSize, srcSize, rotatedWidth, rotatedHeight); + + return srcSize === 1 + ? !intersects && reachRectangle1(flags, y, srcX, srcZ, destX, destZ, rotatedWidth, rotatedHeight, rotatedBlockAccess) + : !intersects && reachRectangleN(flags, y, srcX, srcZ, destX, destZ, srcSize, srcSize, rotatedWidth, rotatedHeight, rotatedBlockAccess); + } + + private static exitStrategy(shape: number): number { + if (shape === -2) { + return ReachStrategy.RECTANGLE_EXCLUSIVE_STRATEGY; + } + if (shape === -1) { + return ReachStrategy.NO_STRATEGY; + } + if ((shape >= 0 && shape <= 3) || shape === 9) { + return ReachStrategy.WALL_STRATEGY; + } + if (shape < 9) { + return ReachStrategy.WALL_DECOR_STRATEGY; + } + if ((shape >= 10 && shape <= 11) || shape === 22) { + return ReachStrategy.RECTANGLE_STRATEGY; + } + return ReachStrategy.NO_STRATEGY; + } + + private static reachWall(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcSize: number, shape: number, angle: number): boolean { + if (srcSize === 1 && srcX === destX && srcZ === destZ) { + return true; + } + if (srcSize !== 1 && destX >= srcX && srcX + srcSize - 1 >= destX && destZ >= srcZ && srcZ + srcSize - 1 >= destZ) { + return true; + } + return srcSize === 1 ? ReachStrategy.reachWall1(flags, y, srcX, srcZ, destX, destZ, shape, angle) : ReachStrategy.reachWallN(flags, y, srcX, srcZ, destX, destZ, srcSize, shape, angle); + } + + private static reachWallDecor(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcSize: number, shape: number, angle: number): boolean { + if (srcSize === 1 && srcX === destX && srcZ === destZ) { + return true; + } + if (srcSize !== 1 && destX >= srcX && srcX + srcSize - 1 >= destX && destZ >= srcZ && srcZ + srcSize - 1 >= destZ) { + return true; + } + return srcSize === 1 ? ReachStrategy.reachWallDecor1(flags, y, srcX, srcZ, destX, destZ, shape, angle) : ReachStrategy.reachWallDecorN(flags, y, srcX, srcZ, destX, destZ, srcSize, shape, angle); + } + + private static reachWall1(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, shape: number, angle: number): boolean { + const collisionFlags = flags.get(srcX, srcZ, y); + + if (shape === LocShape.WALL_STRAIGHT) { + if (angle === LocAngle.WEST) { + return ( + (srcX === destX - 1 && srcZ === destZ) || + (srcX === destX && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.BLOCK_NORTH) === CollisionFlag.OPEN) || + (srcX === destX && srcZ === destZ - 1 && (collisionFlags & CollisionFlag.BLOCK_SOUTH) === CollisionFlag.OPEN) + ); + } + if (angle === LocAngle.NORTH) { + return ( + (srcX === destX && srcZ === destZ + 1) || + (srcX === destX - 1 && srcZ === destZ && (collisionFlags & CollisionFlag.BLOCK_WEST) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ === destZ && (collisionFlags & CollisionFlag.BLOCK_EAST) === CollisionFlag.OPEN) + ); + } + if (angle === LocAngle.EAST) { + return ( + (srcX === destX + 1 && srcZ === destZ) || + (srcX === destX && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.BLOCK_NORTH) === CollisionFlag.OPEN) || + (srcX === destX && srcZ === destZ - 1 && (collisionFlags & CollisionFlag.BLOCK_SOUTH) === CollisionFlag.OPEN) + ); + } + return ( + (srcX === destX && srcZ === destZ - 1) || + (srcX === destX - 1 && srcZ === destZ && (collisionFlags & CollisionFlag.BLOCK_WEST) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ === destZ && (collisionFlags & CollisionFlag.BLOCK_EAST) === CollisionFlag.OPEN) + ); + } + + if (shape === LocShape.WALL_L) { + if (angle === LocAngle.WEST) { + return ( + (srcX === destX - 1 && srcZ === destZ) || + (srcX === destX && srcZ === destZ + 1) || + (srcX === destX + 1 && srcZ === destZ && (collisionFlags & CollisionFlag.BLOCK_EAST) === CollisionFlag.OPEN) || + (srcX === destX && srcZ === destZ - 1 && (collisionFlags & CollisionFlag.BLOCK_SOUTH) === CollisionFlag.OPEN) + ); + } + if (angle === LocAngle.NORTH) { + return ( + (srcX === destX - 1 && srcZ === destZ && (collisionFlags & CollisionFlag.BLOCK_WEST) === CollisionFlag.OPEN) || + (srcX === destX && srcZ === destZ + 1) || + (srcX === destX + 1 && srcZ === destZ) || + (srcX === destX && srcZ === destZ - 1 && (collisionFlags & CollisionFlag.BLOCK_SOUTH) === CollisionFlag.OPEN) + ); + } + if (angle === LocAngle.EAST) { + return ( + (srcX === destX - 1 && srcZ === destZ && (collisionFlags & CollisionFlag.BLOCK_WEST) === CollisionFlag.OPEN) || + (srcX === destX && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.BLOCK_NORTH) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ === destZ) || + (srcX === destX && srcZ === destZ - 1) + ); + } + return ( + (srcX === destX - 1 && srcZ === destZ) || + (srcX === destX && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.BLOCK_NORTH) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ === destZ && (collisionFlags & CollisionFlag.BLOCK_EAST) === CollisionFlag.OPEN) || + (srcX === destX && srcZ === destZ - 1) + ); + } + + if (shape === LocShape.WALL_DIAGONAL) { + return ( + (srcX === destX && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.WALL_SOUTH) === CollisionFlag.OPEN) || + (srcX === destX && srcZ === destZ - 1 && (collisionFlags & CollisionFlag.WALL_NORTH) === CollisionFlag.OPEN) || + (srcX === destX - 1 && srcZ === destZ && (collisionFlags & CollisionFlag.WALL_EAST) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ === destZ && (collisionFlags & CollisionFlag.WALL_WEST) === CollisionFlag.OPEN) + ); + } + + return false; + } + + private static reachWallN(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcSize: number, shape: number, angle: number): boolean { + const collisionFlags = flags.get(srcX, srcZ, y); + const east = srcX + srcSize - 1; + const north = srcZ + srcSize - 1; + + if (shape === LocShape.WALL_STRAIGHT) { + if (angle === LocAngle.WEST) { + return ( + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ) || + (destX >= srcX && destX <= east && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.BLOCK_NORTH) === CollisionFlag.OPEN) || + (destX >= srcX && destX <= east && srcZ === destZ - srcSize && (collisionFlags & CollisionFlag.BLOCK_SOUTH) === CollisionFlag.OPEN) + ); + } + if (angle === LocAngle.NORTH) { + return ( + (destX >= srcX && destX <= east && srcZ === destZ + 1) || + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.BLOCK_WEST) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.BLOCK_EAST) === CollisionFlag.OPEN) + ); + } + if (angle === LocAngle.EAST) { + return ( + (srcX === destX + 1 && srcZ <= destZ && north >= destZ) || + (destX >= srcX && destX <= east && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.BLOCK_NORTH) === CollisionFlag.OPEN) || + (destX >= srcX && destX <= east && srcZ === destZ - srcSize && (collisionFlags & CollisionFlag.BLOCK_SOUTH) === CollisionFlag.OPEN) + ); + } + return ( + (destX >= srcX && destX <= east && srcZ === destZ - srcSize) || + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.BLOCK_WEST) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.BLOCK_EAST) === CollisionFlag.OPEN) + ); + } + + if (shape === LocShape.WALL_L) { + if (angle === LocAngle.WEST) { + return ( + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ) || + (destX >= srcX && destX <= east && srcZ === destZ + 1) || + (srcX === destX + 1 && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.BLOCK_EAST) === CollisionFlag.OPEN) || + (destX >= srcX && destX <= east && srcZ === destZ - srcSize && (collisionFlags & CollisionFlag.BLOCK_SOUTH) === CollisionFlag.OPEN) + ); + } + if (angle === LocAngle.NORTH) { + return ( + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.BLOCK_WEST) === CollisionFlag.OPEN) || + (destX >= srcX && destX <= east && srcZ === destZ + 1) || + (srcX === destX + 1 && srcZ <= destZ && north >= destZ) || + (destX >= srcX && destX <= east && srcZ === destZ - srcSize && (collisionFlags & CollisionFlag.BLOCK_SOUTH) === CollisionFlag.OPEN) + ); + } + if (angle === LocAngle.EAST) { + return ( + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.BLOCK_WEST) === CollisionFlag.OPEN) || + (destX >= srcX && destX <= east && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.BLOCK_NORTH) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ <= destZ && north >= destZ) || + (destX >= srcX && destX <= east && srcZ === destZ - srcSize) + ); + } + return ( + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ) || + (destX >= srcX && destX <= east && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.BLOCK_NORTH) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.BLOCK_EAST) === CollisionFlag.OPEN) || + (destX >= srcX && destX <= east && srcZ === destZ - srcSize) + ); + } + + if (shape === LocShape.WALL_DIAGONAL) { + return ( + (destX >= srcX && destX <= east && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.BLOCK_NORTH) === CollisionFlag.OPEN) || + (destX >= srcX && destX <= east && srcZ === destZ - srcSize && (collisionFlags & CollisionFlag.BLOCK_SOUTH) === CollisionFlag.OPEN) || + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.BLOCK_WEST) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.BLOCK_EAST) === CollisionFlag.OPEN) + ); + } + + return false; + } + + private static reachWallDecor1(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, shape: number, angle: number): boolean { + const collisionFlags = flags.get(srcX, srcZ, y); + if (shape === LocShape.WALLDECOR_DIAGONAL_OFFSET || shape === LocShape.WALLDECOR_DIAGONAL_NOOFFSET) { + const rotation = ReachStrategy.alteredRotation(angle, shape); + if (rotation === LocAngle.WEST) { + return (srcX === destX + 1 && srcZ === destZ && (collisionFlags & CollisionFlag.WALL_WEST) === CollisionFlag.OPEN) || (srcX === destX && srcZ === destZ - 1 && (collisionFlags & CollisionFlag.WALL_NORTH) === CollisionFlag.OPEN); + } + if (rotation === LocAngle.NORTH) { + return (srcX === destX - 1 && srcZ === destZ && (collisionFlags & CollisionFlag.WALL_EAST) === CollisionFlag.OPEN) || (srcX === destX && srcZ === destZ - 1 && (collisionFlags & CollisionFlag.WALL_NORTH) === CollisionFlag.OPEN); + } + if (rotation === LocAngle.EAST) { + return (srcX === destX - 1 && srcZ === destZ && (collisionFlags & CollisionFlag.WALL_EAST) === CollisionFlag.OPEN) || (srcX === destX && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.WALL_SOUTH) === CollisionFlag.OPEN); + } + return (srcX === destX + 1 && srcZ === destZ && (collisionFlags & CollisionFlag.WALL_WEST) === CollisionFlag.OPEN) || (srcX === destX && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.WALL_SOUTH) === CollisionFlag.OPEN); + } + + if (shape === LocShape.WALLDECOR_DIAGONAL_BOTH) { + return ( + (srcX === destX && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.WALL_SOUTH) === CollisionFlag.OPEN) || + (srcX === destX && srcZ === destZ - 1 && (collisionFlags & CollisionFlag.WALL_NORTH) === CollisionFlag.OPEN) || + (srcX === destX - 1 && srcZ === destZ && (collisionFlags & CollisionFlag.WALL_EAST) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ === destZ && (collisionFlags & CollisionFlag.WALL_WEST) === CollisionFlag.OPEN) + ); + } + + return false; + } + + private static reachWallDecorN(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcSize: number, shape: number, angle: number): boolean { + const collisionFlags = flags.get(srcX, srcZ, y); + const east = srcX + srcSize - 1; + const north = srcZ + srcSize - 1; + + if (shape === LocShape.WALLDECOR_DIAGONAL_OFFSET || shape === LocShape.WALLDECOR_DIAGONAL_NOOFFSET) { + const rotation = ReachStrategy.alteredRotation(angle, shape); + if (rotation === LocAngle.WEST) { + return ( + (srcX === destX + 1 && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.WALL_WEST) === CollisionFlag.OPEN) || + (srcX <= destX && srcZ === destZ - srcSize && east >= destX && (collisionFlags & CollisionFlag.WALL_NORTH) === CollisionFlag.OPEN) + ); + } + if (rotation === LocAngle.NORTH) { + return ( + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.WALL_EAST) === CollisionFlag.OPEN) || + (srcX <= destX && srcZ === destZ - srcSize && east >= destX && (collisionFlags & CollisionFlag.WALL_NORTH) === CollisionFlag.OPEN) + ); + } + if (rotation === LocAngle.EAST) { + return ( + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.WALL_EAST) === CollisionFlag.OPEN) || + (srcX <= destX && srcZ === destZ + 1 && east >= destX && (collisionFlags & CollisionFlag.WALL_SOUTH) === CollisionFlag.OPEN) + ); + } + return ( + (srcX === destX + 1 && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.WALL_WEST) === CollisionFlag.OPEN) || + (srcX <= destX && srcZ === destZ + 1 && east >= destX && (collisionFlags & CollisionFlag.WALL_SOUTH) === CollisionFlag.OPEN) + ); + } + + if (shape === LocShape.WALLDECOR_DIAGONAL_BOTH) { + return ( + (srcX <= destX && srcZ === destZ + 1 && east >= destX && (collisionFlags & CollisionFlag.WALL_SOUTH) === CollisionFlag.OPEN) || + (srcX <= destX && srcZ === destZ - srcSize && east >= destX && (collisionFlags & CollisionFlag.WALL_NORTH) === CollisionFlag.OPEN) || + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.WALL_EAST) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.WALL_WEST) === CollisionFlag.OPEN) + ); + } + + return false; + } +} diff --git a/src/engine/routefinder/RectangleBoundary.ts b/src/engine/routefinder/RectangleBoundary.ts new file mode 100644 index 000000000..69e5c20ce --- /dev/null +++ b/src/engine/routefinder/RectangleBoundary.ts @@ -0,0 +1,70 @@ +import CollisionEngine from '#/engine/routefinder/CollisionEngine.js'; +import { BlockAccessFlag, CollisionFlag } from '#/engine/routefinder/flags.js'; + +export function collides(srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number): boolean { + if (srcX >= destX + destWidth || srcX + srcWidth <= destX) { + return false; + } + return srcZ < destZ + destHeight && destZ < srcHeight + srcZ; +} + +export function reachRectangle1(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, destWidth: number, destHeight: number, blockAccessFlags: number): boolean { + const east = destX + destWidth - 1; + const north = destZ + destHeight - 1; + + if (srcX === destX - 1 && srcZ >= destZ && srcZ <= north && (flags.get(srcX, srcZ, y) & CollisionFlag.WALL_EAST) === CollisionFlag.OPEN && (blockAccessFlags & BlockAccessFlag.BLOCK_WEST) === 0) { + return true; + } + + if (srcX === east + 1 && srcZ >= destZ && srcZ <= north && (flags.get(srcX, srcZ, y) & CollisionFlag.WALL_WEST) === CollisionFlag.OPEN && (blockAccessFlags & BlockAccessFlag.BLOCK_EAST) === 0) { + return true; + } + + if (srcZ + 1 === destZ && srcX >= destX && srcX <= east && (flags.get(srcX, srcZ, y) & CollisionFlag.WALL_NORTH) === CollisionFlag.OPEN && (blockAccessFlags & BlockAccessFlag.BLOCK_SOUTH) === 0) { + return true; + } + + return srcZ === north + 1 && srcX >= destX && srcX <= east && (flags.get(srcX, srcZ, y) & CollisionFlag.WALL_SOUTH) === CollisionFlag.OPEN && (blockAccessFlags & BlockAccessFlag.BLOCK_NORTH) === 0; +} + +export function reachRectangleN(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number, blockAccessFlags: number): boolean { + const srcEast = srcX + srcWidth; + const srcNorth = srcZ + srcHeight; + const destEast = destX + destWidth; + const destNorth = destZ + destHeight; + + if (destEast === srcX && (blockAccessFlags & BlockAccessFlag.BLOCK_EAST) === 0) { + const fromZ = Math.max(srcZ, destZ); + const toZ = Math.min(srcNorth, destNorth); + for (let sideZ = fromZ; sideZ < toZ; sideZ++) { + if ((flags.get(destEast - 1, sideZ, y) & CollisionFlag.WALL_EAST) === CollisionFlag.OPEN) { + return true; + } + } + } else if (srcEast === destX && (blockAccessFlags & BlockAccessFlag.BLOCK_WEST) === 0) { + const fromZ = Math.max(srcZ, destZ); + const toZ = Math.min(srcNorth, destNorth); + for (let sideZ = fromZ; sideZ < toZ; sideZ++) { + if ((flags.get(destX, sideZ, y) & CollisionFlag.WALL_WEST) === CollisionFlag.OPEN) { + return true; + } + } + } else if (srcZ === destNorth && (blockAccessFlags & BlockAccessFlag.BLOCK_NORTH) === 0) { + const fromX = Math.max(srcX, destX); + const toX = Math.min(srcEast, destEast); + for (let sideX = fromX; sideX < toX; sideX++) { + if ((flags.get(sideX, destNorth - 1, y) & CollisionFlag.WALL_NORTH) === CollisionFlag.OPEN) { + return true; + } + } + } else if (destZ === srcNorth && (blockAccessFlags & BlockAccessFlag.BLOCK_SOUTH) === 0) { + const fromX = Math.max(srcX, destX); + const toX = Math.min(srcEast, destEast); + for (let sideX = fromX; sideX < toX; sideX++) { + if ((flags.get(sideX, destZ, y) & CollisionFlag.WALL_SOUTH) === CollisionFlag.OPEN) { + return true; + } + } + } + return false; +} diff --git a/src/engine/routefinder/Rotation.ts b/src/engine/routefinder/Rotation.ts new file mode 100644 index 000000000..58c7c1cc5 --- /dev/null +++ b/src/engine/routefinder/Rotation.ts @@ -0,0 +1,10 @@ +export function rotate(angle: number, a: number, b: number): number { + return (angle & 0x1) !== 0 ? b : a; +} + +export function rotateFlags(angle: number, blockAccessFlags: number): number { + if (angle === 0) { + return blockAccessFlags; + } + return (((blockAccessFlags << angle) & 0xf) | (blockAccessFlags >>> (4 - angle))) >>> 0; +} diff --git a/src/engine/routefinder/StepValidator.ts b/src/engine/routefinder/StepValidator.ts new file mode 100644 index 000000000..439b5bbb6 --- /dev/null +++ b/src/engine/routefinder/StepValidator.ts @@ -0,0 +1,239 @@ +import { canMove } from '#/engine/routefinder/CollisionStrategy.js'; +import CollisionEngine from '#/engine/routefinder/CollisionEngine.js'; +import { CollisionFlag, CollisionType } from '#/engine/routefinder/flags.js'; + +export function canTravel(flags: CollisionEngine, y: number, x: number, z: number, offsetX: number, offsetZ: number, size: number, extraFlag: number, collision: CollisionType): boolean { + if (offsetX === 0 && offsetZ === -1) { + return !isBlockedSouth(flags, y, x, z, size, extraFlag, collision); + } + if (offsetX === 0 && offsetZ === 1) { + return !isBlockedNorth(flags, y, x, z, size, extraFlag, collision); + } + if (offsetX === -1 && offsetZ === 0) { + return !isBlockedWest(flags, y, x, z, size, extraFlag, collision); + } + if (offsetX === 1 && offsetZ === 0) { + return !isBlockedEast(flags, y, x, z, size, extraFlag, collision); + } + if (offsetX === -1 && offsetZ === -1) { + return !isBlockedSouthWest(flags, y, x, z, size, extraFlag, collision); + } + if (offsetX === -1 && offsetZ === 1) { + return !isBlockedNorthWest(flags, y, x, z, size, extraFlag, collision); + } + if (offsetX === 1 && offsetZ === -1) { + return !isBlockedSouthEast(flags, y, x, z, size, extraFlag, collision); + } + if (offsetX === 1 && offsetZ === 1) { + return !isBlockedNorthEast(flags, y, x, z, size, extraFlag, collision); + } + return false; +} + +function isBlockedSouth(flags: CollisionEngine, y: number, x: number, z: number, size: number, extraFlag: number, collision: CollisionType): boolean { + switch (size) { + case 1: + return !canMove(collision, flags.get(x, z - 1, y), CollisionFlag.BLOCK_SOUTH | extraFlag); + case 2: + return !canMove(collision, flags.get(x, z - 1, y), CollisionFlag.BLOCK_SOUTH_WEST | extraFlag) || !canMove(collision, flags.get(x + 1, z - 1, y), CollisionFlag.BLOCK_SOUTH_EAST | extraFlag); + default: + if (!canMove(collision, flags.get(x, z - 1, y), CollisionFlag.BLOCK_SOUTH_WEST | extraFlag)) { + return true; + } + if (!canMove(collision, flags.get(x + size - 1, z - 1, y), CollisionFlag.BLOCK_SOUTH_EAST | extraFlag)) { + return true; + } + for (let midX = x + 1; midX < x + size - 1; midX++) { + if (!canMove(collision, flags.get(midX, z - 1, y), CollisionFlag.BLOCK_NORTH_EAST_AND_WEST | extraFlag)) { + return true; + } + } + return false; + } +} + +function isBlockedNorth(flags: CollisionEngine, y: number, x: number, z: number, size: number, extraFlag: number, collision: CollisionType): boolean { + switch (size) { + case 1: + return !canMove(collision, flags.get(x, z + 1, y), CollisionFlag.BLOCK_NORTH | extraFlag); + case 2: + return !canMove(collision, flags.get(x, z + 2, y), CollisionFlag.BLOCK_NORTH_WEST | extraFlag) || !canMove(collision, flags.get(x + 1, z + 2, y), CollisionFlag.BLOCK_NORTH_EAST | extraFlag); + default: + if (!canMove(collision, flags.get(x, z + size, y), CollisionFlag.BLOCK_NORTH_WEST | extraFlag)) { + return true; + } + if (!canMove(collision, flags.get(x + size - 1, z + size, y), CollisionFlag.BLOCK_NORTH_EAST | extraFlag)) { + return true; + } + for (let midX = x + 1; midX < x + size - 1; midX++) { + if (!canMove(collision, flags.get(midX, z + size, y), CollisionFlag.BLOCK_SOUTH_EAST_AND_WEST | extraFlag)) { + return true; + } + } + return false; + } +} + +function isBlockedWest(flags: CollisionEngine, y: number, x: number, z: number, size: number, extraFlag: number, collision: CollisionType): boolean { + switch (size) { + case 1: + return !canMove(collision, flags.get(x - 1, z, y), CollisionFlag.BLOCK_WEST | extraFlag); + case 2: + return !canMove(collision, flags.get(x - 1, z, y), CollisionFlag.BLOCK_SOUTH_WEST | extraFlag) || !canMove(collision, flags.get(x - 1, z + 1, y), CollisionFlag.BLOCK_NORTH_WEST | extraFlag); + default: + if (!canMove(collision, flags.get(x - 1, z, y), CollisionFlag.BLOCK_SOUTH_WEST | extraFlag)) { + return true; + } + if (!canMove(collision, flags.get(x - 1, z + size - 1, y), CollisionFlag.BLOCK_NORTH_WEST | extraFlag)) { + return true; + } + for (let midZ = z + 1; midZ < z + size - 1; midZ++) { + if (!canMove(collision, flags.get(x - 1, midZ, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_EAST | extraFlag)) { + return true; + } + } + return false; + } +} + +function isBlockedEast(flags: CollisionEngine, y: number, x: number, z: number, size: number, extraFlag: number, collision: CollisionType): boolean { + switch (size) { + case 1: + return !canMove(collision, flags.get(x + 1, z, y), CollisionFlag.BLOCK_EAST | extraFlag); + case 2: + return !canMove(collision, flags.get(x + 2, z, y), CollisionFlag.BLOCK_SOUTH_EAST | extraFlag) || !canMove(collision, flags.get(x + 2, z + 1, y), CollisionFlag.BLOCK_NORTH_EAST | extraFlag); + default: + if (!canMove(collision, flags.get(x + size, z, y), CollisionFlag.BLOCK_SOUTH_EAST | extraFlag)) { + return true; + } + if (!canMove(collision, flags.get(x + size, z + size - 1, y), CollisionFlag.BLOCK_NORTH_EAST | extraFlag)) { + return true; + } + for (let midZ = z + 1; midZ < z + size - 1; midZ++) { + if (!canMove(collision, flags.get(x + size, midZ, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_WEST | extraFlag)) { + return true; + } + } + return false; + } +} + +function isBlockedSouthWest(flags: CollisionEngine, y: number, x: number, z: number, size: number, extraFlag: number, collision: CollisionType): boolean { + switch (size) { + case 1: + return ( + !canMove(collision, flags.get(x - 1, z - 1, y), CollisionFlag.BLOCK_SOUTH_WEST | extraFlag) || + !canMove(collision, flags.get(x - 1, z, y), CollisionFlag.BLOCK_WEST | extraFlag) || + !canMove(collision, flags.get(x, z - 1, y), CollisionFlag.BLOCK_SOUTH | extraFlag) + ); + case 2: + return ( + !canMove(collision, flags.get(x - 1, z, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_EAST | extraFlag) || + !canMove(collision, flags.get(x - 1, z - 1, y), CollisionFlag.BLOCK_SOUTH_WEST | extraFlag) || + !canMove(collision, flags.get(x, z - 1, y), CollisionFlag.BLOCK_NORTH_EAST_AND_WEST | extraFlag) + ); + default: + if (!canMove(collision, flags.get(x - 1, z - 1, y), CollisionFlag.BLOCK_SOUTH_WEST | extraFlag)) { + return true; + } + for (let mid = 1; mid < size; mid++) { + if (!canMove(collision, flags.get(x - 1, z + mid - 1, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_EAST | extraFlag)) { + return true; + } + if (!canMove(collision, flags.get(x + mid - 1, z - 1, y), CollisionFlag.BLOCK_NORTH_EAST_AND_WEST | extraFlag)) { + return true; + } + } + return false; + } +} + +function isBlockedNorthWest(flags: CollisionEngine, y: number, x: number, z: number, size: number, extraFlag: number, collision: CollisionType): boolean { + switch (size) { + case 1: + return ( + !canMove(collision, flags.get(x - 1, z + 1, y), CollisionFlag.BLOCK_NORTH_WEST | extraFlag) || + !canMove(collision, flags.get(x - 1, z, y), CollisionFlag.BLOCK_WEST | extraFlag) || + !canMove(collision, flags.get(x, z + 1, y), CollisionFlag.BLOCK_NORTH | extraFlag) + ); + case 2: + return ( + !canMove(collision, flags.get(x - 1, z + 1, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_EAST | extraFlag) || + !canMove(collision, flags.get(x - 1, z + 2, y), CollisionFlag.BLOCK_NORTH_WEST | extraFlag) || + !canMove(collision, flags.get(x, z + 2, y), CollisionFlag.BLOCK_SOUTH_EAST_AND_WEST | extraFlag) + ); + default: + if (!canMove(collision, flags.get(x - 1, z + size, y), CollisionFlag.BLOCK_NORTH_WEST | extraFlag)) { + return true; + } + for (let mid = 1; mid < size; mid++) { + if (!canMove(collision, flags.get(x - 1, z + mid, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_EAST | extraFlag)) { + return true; + } + if (!canMove(collision, flags.get(x + mid - 1, z + size, y), CollisionFlag.BLOCK_SOUTH_EAST_AND_WEST | extraFlag)) { + return true; + } + } + return false; + } +} + +function isBlockedSouthEast(flags: CollisionEngine, y: number, x: number, z: number, size: number, extraFlag: number, collision: CollisionType): boolean { + switch (size) { + case 1: + return ( + !canMove(collision, flags.get(x + 1, z - 1, y), CollisionFlag.BLOCK_SOUTH_EAST | extraFlag) || + !canMove(collision, flags.get(x + 1, z, y), CollisionFlag.BLOCK_EAST | extraFlag) || + !canMove(collision, flags.get(x, z - 1, y), CollisionFlag.BLOCK_SOUTH | extraFlag) + ); + case 2: + return ( + !canMove(collision, flags.get(x + 1, z - 1, y), CollisionFlag.BLOCK_NORTH_EAST_AND_WEST | extraFlag) || + !canMove(collision, flags.get(x + 2, z - 1, y), CollisionFlag.BLOCK_SOUTH_EAST | extraFlag) || + !canMove(collision, flags.get(x + 2, z, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_WEST | extraFlag) + ); + default: + if (!canMove(collision, flags.get(x + size, z - 1, y), CollisionFlag.BLOCK_SOUTH_EAST | extraFlag)) { + return true; + } + for (let mid = 1; mid < size; mid++) { + if (!canMove(collision, flags.get(x + size, z + mid - 1, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_WEST | extraFlag)) { + return true; + } + if (!canMove(collision, flags.get(x + mid, z - 1, y), CollisionFlag.BLOCK_NORTH_EAST_AND_WEST | extraFlag)) { + return true; + } + } + return false; + } +} + +function isBlockedNorthEast(flags: CollisionEngine, y: number, x: number, z: number, size: number, extraFlag: number, collision: CollisionType): boolean { + switch (size) { + case 1: + return ( + !canMove(collision, flags.get(x + 1, z + 1, y), CollisionFlag.BLOCK_NORTH_EAST | extraFlag) || + !canMove(collision, flags.get(x + 1, z, y), CollisionFlag.BLOCK_EAST | extraFlag) || + !canMove(collision, flags.get(x, z + 1, y), CollisionFlag.BLOCK_NORTH | extraFlag) + ); + case 2: + return ( + !canMove(collision, flags.get(x + 1, z + 2, y), CollisionFlag.BLOCK_SOUTH_EAST_AND_WEST | extraFlag) || + !canMove(collision, flags.get(x + 2, z + 2, y), CollisionFlag.BLOCK_NORTH_EAST | extraFlag) || + !canMove(collision, flags.get(x + 2, z + 1, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_WEST | extraFlag) + ); + default: + if (!canMove(collision, flags.get(x + size, z + size, y), CollisionFlag.BLOCK_NORTH_EAST | extraFlag)) { + return true; + } + for (let mid = 1; mid < size; mid++) { + if (!canMove(collision, flags.get(x + mid, z + size, y), CollisionFlag.BLOCK_SOUTH_EAST_AND_WEST | extraFlag)) { + return true; + } + if (!canMove(collision, flags.get(x + size, z + mid, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_WEST | extraFlag)) { + return true; + } + } + return false; + } +} diff --git a/src/engine/routefinder/flags.ts b/src/engine/routefinder/flags.ts new file mode 100644 index 000000000..2a2eea2ed --- /dev/null +++ b/src/engine/routefinder/flags.ts @@ -0,0 +1,169 @@ +export const CollisionFlag = { + OPEN: 0x0, + WALL_NORTH_WEST: 0x1, + WALL_NORTH: 0x2, + WALL_NORTH_EAST: 0x4, + WALL_EAST: 0x8, + WALL_SOUTH_EAST: 0x10, + WALL_SOUTH: 0x20, + WALL_SOUTH_WEST: 0x40, + WALL_WEST: 0x80, + LOC: 0x100, + WALL_NORTH_WEST_PROJ_BLOCKER: 0x200, + WALL_NORTH_PROJ_BLOCKER: 0x400, + WALL_NORTH_EAST_PROJ_BLOCKER: 0x800, + WALL_EAST_PROJ_BLOCKER: 0x1000, + WALL_SOUTH_EAST_PROJ_BLOCKER: 0x2000, + WALL_SOUTH_PROJ_BLOCKER: 0x4000, + WALL_SOUTH_WEST_PROJ_BLOCKER: 0x8000, + WALL_WEST_PROJ_BLOCKER: 0x10000, + LOC_PROJ_BLOCKER: 0x20000, + FLOOR_DECORATION: 0x40000, + NPC: 0x80000, + PLAYER: 0x100000, + FLOOR: 0x200000, + WALL_NORTH_WEST_ROUTE_BLOCKER: 0x400000, + WALL_NORTH_ROUTE_BLOCKER: 0x800000, + WALL_NORTH_EAST_ROUTE_BLOCKER: 0x1000000, + WALL_EAST_ROUTE_BLOCKER: 0x2000000, + WALL_SOUTH_EAST_ROUTE_BLOCKER: 0x4000000, + WALL_SOUTH_ROUTE_BLOCKER: 0x8000000, + WALL_SOUTH_WEST_ROUTE_BLOCKER: 0x10000000, + WALL_WEST_ROUTE_BLOCKER: 0x20000000, + LOC_ROUTE_BLOCKER: 0x40000000, + ROOF: 0x80000000, + FLOOR_BLOCKED: 0x240000, + WALK_BLOCKED: 0x240100, + BLOCK_WEST: 0x240108, + BLOCK_EAST: 0x240180, + BLOCK_SOUTH: 0x240102, + BLOCK_NORTH: 0x240120, + BLOCK_SOUTH_WEST: 0x24010e, + BLOCK_SOUTH_EAST: 0x240183, + BLOCK_NORTH_WEST: 0x240138, + BLOCK_NORTH_EAST: 0x2401e0, + BLOCK_NORTH_AND_SOUTH_EAST: 0x24013e, + BLOCK_NORTH_AND_SOUTH_WEST: 0x2401e3, + BLOCK_NORTH_EAST_AND_WEST: 0x24018f, + BLOCK_SOUTH_EAST_AND_WEST: 0x2401f8, + BLOCK_WEST_ROUTE_BLOCKER: 0x2260000, + BLOCK_EAST_ROUTE_BLOCKER: 0x20260000, + BLOCK_SOUTH_ROUTE_BLOCKER: 0x10878976, + BLOCK_NORTH_ROUTE_BLOCKER: 0x8260000, + BLOCK_SOUTH_WEST_ROUTE_BLOCKER: 0x43a40000, + BLOCK_SOUTH_EAST_ROUTE_BLOCKER: 0x60e40000, + BLOCK_NORTH_WEST_ROUTE_BLOCKER: 0x4e240000, + BLOCK_NORTH_EAST_ROUTE_BLOCKER: 0x78240000, + BLOCK_NORTH_AND_SOUTH_EAST_ROUTE_BLOCKER: 0x4fa40000, + BLOCK_NORTH_AND_SOUTH_WEST_ROUTE_BLOCKER: 0x78e40000, + BLOCK_NORTH_EAST_AND_WEST_ROUTE_BLOCKER: 0x63e40000, + BLOCK_SOUTH_EAST_AND_WEST_ROUTE_BLOCKER: 0x7e240000, + NULL: 0x7fffffff +} as const; + +export const BlockAccessFlag = { + BLOCK_NORTH: 0x1, + BLOCK_EAST: 0x2, + BLOCK_SOUTH: 0x4, + BLOCK_WEST: 0x8 +} as const; + +export const DirectionFlag = { + North: 0x1, + East: 0x2, + South: 0x4, + West: 0x8, + SouthWest: 0xc, + NorthWest: 0x9, + SouthEast: 0x6, + NorthEast: 0x3 +} as const; + +export const CollisionType = { + NORMAL: 0, + BLOCKED: 1, + INDOORS: 2, + OUTDOORS: 3, + LINE_OF_SIGHT: 4 +} as const; + +export const LocAngle = { + WEST: 0, + NORTH: 1, + EAST: 2, + SOUTH: 3 +} as const; + +export const LocLayer = { + WALL: 0, + WALL_DECOR: 1, + GROUND: 2, + GROUND_DECOR: 3 +} as const; + +export const LocShape = { + WALL_STRAIGHT: 0, + WALL_DIAGONAL_CORNER: 1, + WALL_L: 2, + WALL_SQUARE_CORNER: 3, + WALLDECOR_STRAIGHT_NOOFFSET: 4, + WALLDECOR_STRAIGHT_OFFSET: 5, + WALLDECOR_DIAGONAL_OFFSET: 6, + WALLDECOR_DIAGONAL_NOOFFSET: 7, + WALLDECOR_DIAGONAL_BOTH: 8, + WALL_DIAGONAL: 9, + CENTREPIECE_STRAIGHT: 10, + CENTREPIECE_DIAGONAL: 11, + ROOF_STRAIGHT: 12, + ROOF_DIAGONAL_WITH_ROOFEDGE: 13, + ROOF_DIAGONAL: 14, + ROOF_L_CONCAVE: 15, + ROOF_L_CONVEX: 16, + ROOF_FLAT: 17, + ROOFEDGE_STRAIGHT: 18, + ROOFEDGE_DIAGONAL_CORNER: 19, + ROOFEDGE_L: 20, + ROOFEDGE_SQUARE_CORNER: 21, + GROUND_DECOR: 22 +} as const; + +export type CollisionType = (typeof CollisionType)[keyof typeof CollisionType]; +export type CollisionFlag = (typeof CollisionFlag)[keyof typeof CollisionFlag]; +export type BlockAccessFlag = (typeof BlockAccessFlag)[keyof typeof BlockAccessFlag]; +export type LocAngle = (typeof LocAngle)[keyof typeof LocAngle]; +export type LocLayer = (typeof LocLayer)[keyof typeof LocLayer]; +export type LocShape = (typeof LocShape)[keyof typeof LocShape]; + +export function locShapeLayer(shape: number): LocLayer { + switch (shape) { + case LocShape.WALL_STRAIGHT: + case LocShape.WALL_DIAGONAL_CORNER: + case LocShape.WALL_L: + case LocShape.WALL_SQUARE_CORNER: + return LocLayer.WALL; + case LocShape.WALLDECOR_STRAIGHT_NOOFFSET: + case LocShape.WALLDECOR_STRAIGHT_OFFSET: + case LocShape.WALLDECOR_DIAGONAL_OFFSET: + case LocShape.WALLDECOR_DIAGONAL_NOOFFSET: + case LocShape.WALLDECOR_DIAGONAL_BOTH: + return LocLayer.WALL_DECOR; + case LocShape.WALL_DIAGONAL: + case LocShape.CENTREPIECE_STRAIGHT: + case LocShape.CENTREPIECE_DIAGONAL: + case LocShape.ROOF_STRAIGHT: + case LocShape.ROOF_DIAGONAL_WITH_ROOFEDGE: + case LocShape.ROOF_DIAGONAL: + case LocShape.ROOF_L_CONCAVE: + case LocShape.ROOF_L_CONVEX: + case LocShape.ROOF_FLAT: + case LocShape.ROOFEDGE_STRAIGHT: + case LocShape.ROOFEDGE_DIAGONAL_CORNER: + case LocShape.ROOFEDGE_L: + case LocShape.ROOFEDGE_SQUARE_CORNER: + return LocLayer.GROUND; + case LocShape.GROUND_DECOR: + return LocLayer.GROUND_DECOR; + default: + throw new Error(`Unknown loc shape ${shape}`); + } +} diff --git a/src/engine/routefinder/index.ts b/src/engine/routefinder/index.ts new file mode 100644 index 000000000..d55f6738a --- /dev/null +++ b/src/engine/routefinder/index.ts @@ -0,0 +1,134 @@ +import CollisionEngine from '#/engine/routefinder/CollisionEngine.js'; +import { canTravel as canTravelStep } from '#/engine/routefinder/StepValidator.js'; +import { findNaivePath as findNaivePathImpl } from '#/engine/routefinder/NaivePathFinder.js'; +import { hasLineOfSight as hasLineOfSightImpl, hasLineOfWalk as hasLineOfWalkImpl } from '#/engine/routefinder/LineValidator.js'; +import { lineOfSight as lineOfSightImpl, lineOfWalk as lineOfWalkImpl } from '#/engine/routefinder/LinePathFinder.js'; +import PathFinder from '#/engine/routefinder/PathFinder.js'; +import ReachStrategy from '#/engine/routefinder/ReachStrategy.js'; +import { CollisionFlag, CollisionType, LocAngle, LocLayer, LocShape, locShapeLayer } from '#/engine/routefinder/flags.js'; + +export { CollisionFlag, CollisionType, LocAngle, LocLayer, LocShape, locShapeLayer }; + +export class RouteFinder { + readonly collisionFlags = new CollisionEngine(); + readonly pathfinder = new PathFinder(); + + findPath( + y: number, + srcX: number, + srcZ: number, + destX: number, + destZ: number, + srcSize: number, + destWidth: number, + destHeight: number, + angle: number, + shape: number, + moveNear: boolean, + blockAccessFlags: number, + maxWaypoints: number, + collision: CollisionType + ): Uint32Array { + return this.pathfinder.findPath(this.collisionFlags, y, srcX, srcZ, destX, destZ, srcSize, destWidth, destHeight, angle, shape, moveNear, blockAccessFlags, maxWaypoints, collision); + } + + findNaivePath(y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number, extraFlag: number, collision: CollisionType): Uint32Array { + return findNaivePathImpl(this.collisionFlags, y, srcX, srcZ, destX, destZ, srcWidth, srcHeight, destWidth, destHeight, extraFlag, collision); + } + + changeFloor(x: number, z: number, y: number, add: boolean): void { + this.collisionFlags.changeFloor(x, z, y, add); + } + + changeLoc(x: number, z: number, y: number, width: number, length: number, blockrange: boolean, breakroutefinding: boolean, add: boolean): void { + this.collisionFlags.changeLoc(x, z, y, width, length, blockrange, breakroutefinding, add); + } + + changeNpc(x: number, z: number, y: number, size: number, add: boolean): void { + this.collisionFlags.changeNpc(x, z, y, size, add); + } + + changePlayer(x: number, z: number, y: number, size: number, add: boolean): void { + this.collisionFlags.changePlayer(x, z, y, size, add); + } + + changeRoof(x: number, z: number, y: number, add: boolean): void { + this.collisionFlags.changeRoof(x, z, y, add); + } + + changeWall(x: number, z: number, y: number, angle: number, shape: number, blockrange: boolean, breakroutefinding: boolean, add: boolean): void { + this.collisionFlags.changeWall(x, z, y, angle, shape, blockrange, breakroutefinding, add); + } + + allocateIfAbsent(x: number, z: number, y: number): void { + this.collisionFlags.allocateIfAbsent(x, z, y); + } + + deallocateIfPresent(x: number, z: number, y: number): void { + this.collisionFlags.deallocateIfPresent(x, z, y); + } + + isZoneAllocated(x: number, z: number, y: number): boolean { + return this.collisionFlags.isZoneAllocated(x, z, y); + } + + isFlagged(x: number, z: number, y: number, masks: number): boolean { + return this.collisionFlags.isFlagged(x, z, y, masks); + } + + canTravel(y: number, x: number, z: number, offsetX: number, offsetZ: number, size: number, extraFlag: number, collision: CollisionType): boolean { + return canTravelStep(this.collisionFlags, y, x, z, offsetX, offsetZ, size, extraFlag, collision); + } + + hasLineOfSight(y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number, extraFlag: number): boolean { + return hasLineOfSightImpl(this.collisionFlags, y, srcX, srcZ, destX, destZ, srcWidth, srcHeight, destWidth, destHeight, extraFlag); + } + + hasLineOfWalk(y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number, extraFlag: number): boolean { + return hasLineOfWalkImpl(this.collisionFlags, y, srcX, srcZ, destX, destZ, srcWidth, srcHeight, destWidth, destHeight, extraFlag); + } + + lineOfSight(y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number, extraFlag: number): Uint32Array { + return lineOfSightImpl(this.collisionFlags, y, srcX, srcZ, destX, destZ, srcWidth, srcHeight, destWidth, destHeight, extraFlag); + } + + lineOfWalk(y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number, extraFlag: number): Uint32Array { + return lineOfWalkImpl(this.collisionFlags, y, srcX, srcZ, destX, destZ, srcWidth, srcHeight, destWidth, destHeight, extraFlag); + } + + reached(y: number, srcX: number, srcZ: number, destX: number, destZ: number, destWidth: number, destHeight: number, srcSize: number, angle: number, shape: number, blockAccessFlags: number): boolean { + return ReachStrategy.reached(this.collisionFlags, y, srcX, srcZ, destX, destZ, destWidth, destHeight, srcSize, angle, shape, blockAccessFlags); + } + + locShapeLayer(shape: number): LocLayer { + return locShapeLayer(shape); + } + + __set(x: number, z: number, y: number, mask: number): void { + this.collisionFlags.set(x, z, y, mask); + } +} + +const routefinder = new RouteFinder(); + +export default routefinder; + +export const findPath = routefinder.findPath.bind(routefinder); +export const findNaivePath = routefinder.findNaivePath.bind(routefinder); +export const changeFloor = routefinder.changeFloor.bind(routefinder); +export const changeLoc = routefinder.changeLoc.bind(routefinder); +export const changeNpc = routefinder.changeNpc.bind(routefinder); +export const changePlayer = routefinder.changePlayer.bind(routefinder); +export const changeRoof = routefinder.changeRoof.bind(routefinder); +export const changeWall = routefinder.changeWall.bind(routefinder); +export const allocateIfAbsent = routefinder.allocateIfAbsent.bind(routefinder); +export const deallocateIfPresent = routefinder.deallocateIfPresent.bind(routefinder); +export const isZoneAllocated = routefinder.isZoneAllocated.bind(routefinder); +export const isFlagged = routefinder.isFlagged.bind(routefinder); +export const canTravel = routefinder.canTravel.bind(routefinder); +export const hasLineOfSight = routefinder.hasLineOfSight.bind(routefinder); +export const hasLineOfWalk = routefinder.hasLineOfWalk.bind(routefinder); +export const lineOfSight = routefinder.lineOfSight.bind(routefinder); +export const lineOfWalk = routefinder.lineOfWalk.bind(routefinder); +export const reached = routefinder.reached.bind(routefinder); +export const __set = routefinder.__set.bind(routefinder); diff --git a/src/engine/script/ScriptIterators.ts b/src/engine/script/ScriptIterators.ts index 97b6ffb7c..7b913702e 100644 --- a/src/engine/script/ScriptIterators.ts +++ b/src/engine/script/ScriptIterators.ts @@ -74,9 +74,11 @@ export class HuntIterator extends ScriptIterator { const zoneX: number = x << 3; for (let z: number = this.maxZ; z >= this.minZ; z--) { const zoneZ: number = z << 3; + const zone = World.gameMap.getZoneIfExists(zoneX, zoneZ, this.level); + if (!zone) continue; if (this.type === HuntModeType.PLAYER) { - for (const player of World.gameMap.getZone(zoneX, zoneZ, this.level).getAllPlayersSafe(true)) { + for (const player of zone.getAllPlayersSafe(true)) { if (World.currentTick > this.tick) { throw new Error('[HuntIterator] tried to use an old iterator. Create a new iterator instead.'); } @@ -96,7 +98,7 @@ export class HuntIterator extends ScriptIterator { yield player; } } else if (this.type === HuntModeType.NPC) { - for (const npc of World.gameMap.getZone(zoneX, zoneZ, this.level).getAllNpcsSafe(true)) { + for (const npc of zone.getAllNpcsSafe(true)) { if (World.currentTick > this.tick) { throw new Error('[HuntIterator] tried to use an old iterator. Create a new iterator instead.'); } @@ -120,7 +122,7 @@ export class HuntIterator extends ScriptIterator { } } else if (this.type === HuntModeType.OBJ) { // scripting only cares about dynamic objs?? - for (const obj of World.gameMap.getZone(zoneX, zoneZ, this.level).getAllObjsSafe(true)) { + for (const obj of zone.getAllObjsSafe(true)) { if (World.currentTick > this.tick) { throw new Error('[HuntIterator] tried to use an old iterator. Create a new iterator instead.'); } @@ -143,7 +145,7 @@ export class HuntIterator extends ScriptIterator { yield obj; } } else if (this.type === HuntModeType.SCENERY) { - for (const loc of World.gameMap.getZone(zoneX, zoneZ, this.level).getAllLocsSafe(true)) { + for (const loc of zone.getAllLocsSafe(true)) { if (World.currentTick > this.tick) { throw new Error('[HuntIterator] tried to use an old iterator. Create a new iterator instead.'); } @@ -203,8 +205,10 @@ export class PlayerHuntAllCommandIterator extends ScriptIterator { const zoneX: number = x << 3; for (let z: number = this.maxZ; z >= this.minZ; z--) { const zoneZ: number = z << 3; + const zone = World.gameMap.getZoneIfExists(zoneX, zoneZ, this.level); + if (!zone) continue; - for (const player of World.gameMap.getZone(zoneX, zoneZ, this.level).getAllPlayersSafe(true)) { + for (const player of zone.getAllPlayersSafe(true)) { if (World.currentTick > this.tick) { throw new Error('[HuntIterator] tried to use an old iterator. Create a new iterator instead.'); } @@ -266,8 +270,10 @@ export class NpcHuntAllCommandIterator extends ScriptIterator { const zoneX: number = x << 3; for (let z: number = this.maxZ; z >= this.minZ; z--) { const zoneZ: number = z << 3; + const zone = World.gameMap.getZoneIfExists(zoneX, zoneZ, this.level); + if (!zone) continue; - for (const npc of World.gameMap.getZone(zoneX, zoneZ, this.level).getAllNpcsSafe(true)) { + for (const npc of zone.getAllNpcsSafe(true)) { if (World.currentTick > this.tick) { throw new Error('[HuntIterator] tried to use an old iterator. Create a new iterator instead.'); } @@ -327,18 +333,23 @@ export class NpcIterator extends ScriptIterator { protected *generator(): IterableIterator { if (this.type === NpcIteratorType.ZONE) { - for (const npc of World.gameMap.getZone(this.x, this.z, this.level).getAllNpcsSafe(true)) { - if (World.currentTick > this.tick) { - throw new Error('[NpcIterator] tried to use an old iterator. Create a new iterator instead.'); + const zone = World.gameMap.getZoneIfExists(this.x, this.z, this.level); + if (zone) { + for (const npc of zone.getAllNpcsSafe(true)) { + if (World.currentTick > this.tick) { + throw new Error('[NpcIterator] tried to use an old iterator. Create a new iterator instead.'); + } + yield npc; } - yield npc; } } else if (this.type === NpcIteratorType.DISTANCE) { for (let x: number = this.maxX; x >= this.minX; x--) { const zoneX: number = x << 3; for (let z: number = this.maxZ; z >= this.minZ; z--) { const zoneZ: number = z << 3; - for (const npc of World.gameMap.getZone(zoneX, zoneZ, this.level).getAllNpcsSafe(true)) { + const zone = World.gameMap.getZoneIfExists(zoneX, zoneZ, this.level); + if (!zone) continue; + for (const npc of zone.getAllNpcsSafe(true)) { if (World.currentTick > this.tick) { throw new Error('[NpcIterator] tried to use an old iterator. Create a new iterator instead.'); } @@ -375,11 +386,14 @@ export class LocIterator extends ScriptIterator { } protected *generator(): IterableIterator { - for (const loc of World.gameMap.getZone(this.x, this.z, this.level).getAllLocsSafe(true)) { - if (World.currentTick > this.tick) { - throw new Error('[LocIterator] tried to use an old iterator. Create a new iterator instead.'); + const zone = World.gameMap.getZoneIfExists(this.x, this.z, this.level); + if (zone) { + for (const loc of zone.getAllLocsSafe(true)) { + if (World.currentTick > this.tick) { + throw new Error('[LocIterator] tried to use an old iterator. Create a new iterator instead.'); + } + yield loc; } - yield loc; } } } @@ -397,11 +411,14 @@ export class ObjIterator extends ScriptIterator { } protected *generator(): IterableIterator { - for (const Obj of World.gameMap.getZone(this.x, this.z, this.level).getAllObjsSafe(true)) { - if (World.currentTick > this.tick) { - throw new Error('[ObjIterator] tried to use an old iterator. Create a new iterator instead.'); + const zone = World.gameMap.getZoneIfExists(this.x, this.z, this.level); + if (zone) { + for (const Obj of zone.getAllObjsSafe(true)) { + if (World.currentTick > this.tick) { + throw new Error('[ObjIterator] tried to use an old iterator. Create a new iterator instead.'); + } + yield Obj; } - yield Obj; } } } diff --git a/src/engine/script/ScriptOpcode.ts b/src/engine/script/ScriptOpcode.ts index c490d756d..553026d6b 100644 --- a/src/engine/script/ScriptOpcode.ts +++ b/src/engine/script/ScriptOpcode.ts @@ -61,6 +61,13 @@ export const enum ScriptOpcode { SEQLENGTH, // official SPOTANIM_MAP, WORLD_DELAY, // official + REGION_CREATE = 1022, + REGION_SET, + REGION_GETCOORD, + REGION_FINDBYCOORD, + REGION_UID, + REGION_FINDBYUID, + REGION_SETEXITCOORD, // Player ops (2000-2499) AFK_EVENT = 2000, @@ -448,7 +455,7 @@ export const enum ScriptOpcode { CONSOLE = 10000, ERROR, GETTIMESPENT, // custom: used to profile script execution (current duration) - TIMESPENT, // custom: used to profile script execution (record start time) + TIMESPENT // custom: used to profile script execution (record start time) } export const ScriptOpcodeMap: Map = new Map([ @@ -512,6 +519,13 @@ export const ScriptOpcodeMap: Map = new Map([ ['SEQLENGTH', ScriptOpcode.SEQLENGTH], ['SPOTANIM_MAP', ScriptOpcode.SPOTANIM_MAP], ['WORLD_DELAY', ScriptOpcode.WORLD_DELAY], + ['REGION_CREATE', ScriptOpcode.REGION_CREATE], + ['REGION_SET', ScriptOpcode.REGION_SET], + ['REGION_GETCOORD', ScriptOpcode.REGION_GETCOORD], + ['REGION_FINDBYCOORD', ScriptOpcode.REGION_FINDBYCOORD], + ['REGION_UID', ScriptOpcode.REGION_UID], + ['REGION_FINDBYUID', ScriptOpcode.REGION_FINDBYUID], + ['REGION_SETEXITCOORD', ScriptOpcode.REGION_SETEXITCOORD], ['AFK_EVENT', ScriptOpcode.AFK_EVENT], ['ALLOWDESIGN', ScriptOpcode.ALLOWDESIGN], @@ -871,9 +885,7 @@ export const ScriptOpcodeMap: Map = new Map([ ['CONSOLE', ScriptOpcode.CONSOLE], ['ERROR', ScriptOpcode.ERROR], ['GETTIMESPENT', ScriptOpcode.GETTIMESPENT], - ['TIMESPENT', ScriptOpcode.TIMESPENT], + ['TIMESPENT', ScriptOpcode.TIMESPENT] ]); -export const ScriptOpcodeNameMap: Map = new Map( - Array.from(ScriptOpcodeMap.entries()).map(([key, value]) => [value, key]) -); +export const ScriptOpcodeNameMap: Map = new Map(Array.from(ScriptOpcodeMap.entries()).map(([key, value]) => [value, key])); diff --git a/src/engine/script/ScriptPointer.ts b/src/engine/script/ScriptPointer.ts index dfc7c3971..be8a8287d 100644 --- a/src/engine/script/ScriptPointer.ts +++ b/src/engine/script/ScriptPointer.ts @@ -15,6 +15,8 @@ const enum ScriptPointer { ActiveLoc2, ActiveObj, ActiveObj2, + ActiveRegion, + ActiveRegion2, _LAST } @@ -29,13 +31,16 @@ export const ScriptPointerNameMap: Map = new Map([ [ScriptPointer.ActiveLoc2, 'ActiveLoc2'], [ScriptPointer.ActiveObj, 'ActiveObj'], [ScriptPointer.ActiveObj2, 'ActiveObj2'], - [ScriptPointer._LAST, '_LAST'], + [ScriptPointer.ActiveRegion, 'ActiveRegion'], + [ScriptPointer.ActiveRegion2, 'ActiveRegion2'], + [ScriptPointer._LAST, '_LAST'] ]); export const ActiveNpc: ScriptPointer[] = [ScriptPointer.ActiveNpc, ScriptPointer.ActiveNpc2]; export const ActiveLoc: ScriptPointer[] = [ScriptPointer.ActiveLoc, ScriptPointer.ActiveLoc2]; export const ActiveObj: ScriptPointer[] = [ScriptPointer.ActiveObj, ScriptPointer.ActiveObj2]; export const ActivePlayer: ScriptPointer[] = [ScriptPointer.ActivePlayer, ScriptPointer.ActivePlayer2]; +export const ActiveRegion: ScriptPointer[] = [ScriptPointer.ActiveRegion, ScriptPointer.ActiveRegion2]; export const ProtectedActivePlayer: ScriptPointer[] = [ScriptPointer.ProtectedActivePlayer, ScriptPointer.ProtectedActivePlayer2]; /** diff --git a/src/engine/script/ScriptRunner.ts b/src/engine/script/ScriptRunner.ts index 3de757d20..be97f0b39 100644 --- a/src/engine/script/ScriptRunner.ts +++ b/src/engine/script/ScriptRunner.ts @@ -17,6 +17,7 @@ import NumberOps from '#/engine/script/handlers/NumberOps.js'; import ObjConfigOps from '#/engine/script/handlers/ObjConfigOps.js'; import ObjOps from '#/engine/script/handlers/ObjOps.js'; import PlayerOps from '#/engine/script/handlers/PlayerOps.js'; +import RegionOps from '#/engine/script/handlers/RegionOps.js'; import ServerOps from '#/engine/script/handlers/ServerOps.js'; import StringOps from '#/engine/script/handlers/StringOps.js'; import StructOps from '#/engine/script/handlers/StructOps.js'; @@ -39,6 +40,7 @@ export default class ScriptRunner { // Language required opcodes ...CoreOps, ...ServerOps, + ...RegionOps, ...PlayerOps, ...NpcOps, ...LocOps, diff --git a/src/engine/script/ScriptState.ts b/src/engine/script/ScriptState.ts index 6f333b417..964122bba 100644 --- a/src/engine/script/ScriptState.ts +++ b/src/engine/script/ScriptState.ts @@ -1,4 +1,5 @@ import DbTableType from '#/cache/config/DbTableType.js'; +import { CoordGrid } from '#/engine/CoordGrid.js'; import Entity from '#/engine/entity/Entity.js'; import Loc from '#/engine/entity/Loc.js'; import Npc from '#/engine/entity/Npc.js'; @@ -102,6 +103,27 @@ export default class ScriptState { _activeObj: Obj | null = null; _activeObj2: Obj | null = null; + /** + * The primary active region, represented by instance uid. + */ + _activeRegionUid: number = -1; + + /** + * The secondary active region, represented by instance uid. + */ + _activeRegionUid2: number = -1; + + /** + * Cached southwest coord for the primary active region. + * Kept for compatibility with region operations that use tile offsets. + */ + _activeRegion: CoordGrid | null = null; + + /** + * Cached southwest coord for the secondary active region. + */ + _activeRegion2: CoordGrid | null = null; + /** * Used for string splitting operations with split_init and related commands. */ @@ -306,6 +328,60 @@ export default class ScriptState { } } + /** + * Gets the active region. Automatically checks the operand to determine primary and secondary. + */ + get activeRegion() { + const region = this.intOperand === 0 ? this._activeRegion : this._activeRegion2; + if (region === null) { + throw new Error('Attempt to access null active_region'); + } + return region; + } + + // gets the secondary region from the perspective of the command (.command returns region1) + get activeRegion2() { + const region = this.intOperand === 0 ? this._activeRegion2 : this._activeRegion; + if (region === null) { + throw new Error('Attempt to access null active_region'); + } + return region; + } + + /** + * Sets the active region. Automatically checks the operand to determine primary and secondary. + * @param region The region southwest coord to set. + */ + set activeRegion(region: CoordGrid) { + if (this.intOperand === 0) { + this._activeRegion = region; + } else { + this._activeRegion2 = region; + } + } + + /** + * Gets the active region uid. Automatically checks the operand to determine primary and secondary. + */ + get activeRegionUid() { + const uid = this.intOperand === 0 ? this._activeRegionUid : this._activeRegionUid2; + if (uid < 0) { + throw new Error('Attempt to access null active_region_uid'); + } + return uid; + } + + /** + * Sets the active region uid. Automatically checks the operand to determine primary and secondary. + */ + set activeRegionUid(uid: number) { + if (this.intOperand === 0) { + this._activeRegionUid = uid; + } else { + this._activeRegionUid2 = uid; + } + } + get intOperand(): number { return this.script.intOperands[this.pc]; } diff --git a/src/engine/script/ScriptValidators.ts b/src/engine/script/ScriptValidators.ts index eeb6a0f50..05986e19b 100644 --- a/src/engine/script/ScriptValidators.ts +++ b/src/engine/script/ScriptValidators.ts @@ -1,4 +1,4 @@ -import { LocAngle, LocShape } from '@2004scape/rsmod-pathfinder'; +import { LocAngle, LocShape } from '#/engine/routefinder/index.js'; import CategoryType from '#/cache/config/CategoryType.js'; import { ConfigType } from '#/cache/config/ConfigType.js'; diff --git a/src/engine/script/handlers/LocOps.ts b/src/engine/script/handlers/LocOps.ts index efac8acab..4c78e2282 100644 --- a/src/engine/script/handlers/LocOps.ts +++ b/src/engine/script/handlers/LocOps.ts @@ -1,4 +1,4 @@ -import { LocAngle, LocShape, locShapeLayer } from '@2004scape/rsmod-pathfinder'; +import { LocAngle, LocShape, locShapeLayer } from '#/engine/routefinder/index.js'; import LocType from '#/cache/config/LocType.js'; import { ParamHelper } from '#/cache/config/ParamHelper.js'; diff --git a/src/engine/script/handlers/RegionOps.ts b/src/engine/script/handlers/RegionOps.ts new file mode 100644 index 000000000..ec069d839 --- /dev/null +++ b/src/engine/script/handlers/RegionOps.ts @@ -0,0 +1,138 @@ +import { CoordGrid } from '#/engine/CoordGrid.js'; +import { ScriptOpcode } from '#/engine/script/ScriptOpcode.js'; +import { ActiveRegion, checkedHandler } from '#/engine/script/ScriptPointer.js'; +import { CommandHandlers } from '#/engine/script/ScriptRunner.js'; +import { check, CoordValid, NumberPositive } from '#/engine/script/ScriptValidators.js'; +import World from '#/engine/World.js'; + +const RegionOps: CommandHandlers = { + [ScriptOpcode.REGION_CREATE]: state => { + const secondary: number = state.intOperand; + const [levels, zonesEast, zonesNorth] = state.popInts(3); + + check(levels, NumberPositive); + check(zonesEast, NumberPositive); + check(zonesNorth, NumberPositive); + + if (levels < 1 || levels > 4) { + throw new Error(`region_create levels out of range: ${levels}. Expected 1..4.`); + } + + // Instance slots are 16x16 zones inside a 128x128 footprint. + if (zonesEast < 1 || zonesEast > 16) { + throw new Error(`region_create zonesEast out of range: ${zonesEast}. Expected 1..16.`); + } + + if (zonesNorth < 1 || zonesNorth > 16) { + throw new Error(`region_create zonesNorth out of range: ${zonesNorth}. Expected 1..16.`); + } + + const sw = World.instances.createInstance(levels, zonesEast, zonesNorth); + const instance = World.instances.findInstanceByCoord(sw); + if (!instance) { + throw new Error('region_create failed to resolve created instance uid'); + } + + state.activeRegion = sw; + state.activeRegionUid = instance.uid; + state.pointerAdd(ActiveRegion[secondary]); + state.pushInt(CoordGrid.packCoord(sw.level, sw.x, sw.z)); + }, + + [ScriptOpcode.REGION_SET]: checkedHandler(ActiveRegion, state => { + const [destLevel, destEast, destNorth, sourceCoord, rotation] = state.popInts(5); + + check(destLevel, NumberPositive); + check(destEast, NumberPositive); + check(destNorth, NumberPositive); + const source: CoordGrid = check(sourceCoord, CoordValid); + check(rotation, NumberPositive); + + if (destLevel < 0 || destLevel > 3) { + throw new Error(`region_set destLevel out of range: ${destLevel}. Expected 0..3.`); + } + + if (destEast < 0 || destEast > 15) { + throw new Error(`region_set destEast out of range: ${destEast}. Expected 0..15.`); + } + + if (destNorth < 0 || destNorth > 15) { + throw new Error(`region_set destNorth out of range: ${destNorth}. Expected 0..15.`); + } + + if (rotation < 0 || rotation > 3) { + throw new Error(`region_set rotation out of range: ${rotation}. Expected 0..3.`); + } + + World.instances.copyZone(state.activeRegion, { level: destLevel, x: destEast, z: destNorth }, source, rotation as 0 | 1 | 2 | 3); + }), + + [ScriptOpcode.REGION_GETCOORD]: checkedHandler(ActiveRegion, state => { + const [levelOffset, xOffset, zOffset] = state.popInts(3); + + check(levelOffset, NumberPositive); + check(xOffset, NumberPositive); + check(zOffset, NumberPositive); + + if (levelOffset < 0 || levelOffset > 3) { + throw new Error(`region_getcoord levelOffset out of range: ${levelOffset}. Expected 0..3.`); + } + + if (xOffset < 0 || xOffset > 127) { + throw new Error(`region_getcoord xOffset out of range: ${xOffset}. Expected 0..127.`); + } + + if (zOffset < 0 || zOffset > 127) { + throw new Error(`region_getcoord zOffset out of range: ${zOffset}. Expected 0..127.`); + } + + const coord = CoordGrid.packCoord(state.activeRegion.level + levelOffset, state.activeRegion.x + xOffset, state.activeRegion.z + zOffset); + state.pushInt(coord); + }), + + [ScriptOpcode.REGION_FINDBYCOORD]: state => { + const coord: CoordGrid = check(state.popInt(), CoordValid); + const secondary: number = state.intOperand; + const instance = World.instances.findInstanceByCoord(coord); + + if (instance) { + state.activeRegion = instance.sw; + state.activeRegionUid = instance.uid; + state.pointerAdd(ActiveRegion[secondary]); + state.pushInt(1); + } else { + state.pushInt(0); + } + }, + + [ScriptOpcode.REGION_UID]: checkedHandler(ActiveRegion, state => { + state.pushInt(state.activeRegionUid); + }), + + [ScriptOpcode.REGION_FINDBYUID]: state => { + const uid: number = state.popInt(); + const secondary: number = state.intOperand; + const instance = World.instances.findInstanceByUid(uid); + + if (instance) { + state.activeRegion = instance.sw; + state.activeRegionUid = instance.uid; + state.pointerAdd(ActiveRegion[secondary]); + state.pushInt(1); + } else { + state.pushInt(0); + } + }, + + [ScriptOpcode.REGION_SETEXITCOORD]: checkedHandler(ActiveRegion, state => { + const exitCoord: CoordGrid = check(state.popInt(), CoordValid); + const instance = World.instances.findInstanceByCoord(state.activeRegion); + if (!instance) { + throw new Error('region_setexitcoord requires active_region to reference a valid instance'); + } + + instance.exitCoord = { level: exitCoord.level, x: exitCoord.x, z: exitCoord.z }; + }) +}; + +export default RegionOps; diff --git a/src/engine/script/handlers/ServerOps.ts b/src/engine/script/handlers/ServerOps.ts index 665daf9a2..5d9ab4a41 100644 --- a/src/engine/script/handlers/ServerOps.ts +++ b/src/engine/script/handlers/ServerOps.ts @@ -1,4 +1,4 @@ -import { LocLayer, LocAngle } from '@2004scape/rsmod-pathfinder'; +import { LocLayer, LocAngle } from '#/engine/routefinder/index.js'; import SpotanimType from '#/cache/config/SpotanimType.js'; import { CoordGrid } from '#/engine/CoordGrid.js'; @@ -33,7 +33,9 @@ const ServerOps: CommandHandlers = { let count = 0; for (let x = Math.floor(from.x / 8); x <= Math.ceil(to.x / 8); x++) { for (let z = Math.floor(from.z / 8); z <= Math.ceil(to.z / 8); z++) { - for (const player of World.gameMap.getZone(x << 3, z << 3, from.level).getAllPlayersSafe()) { + const zone = World.gameMap.getZoneIfExists(x << 3, z << 3, from.level); + if (!zone) continue; + for (const player of zone.getAllPlayersSafe()) { if (player.x >= from.x && player.x <= to.x && player.z >= from.z && player.z <= to.z) { count++; } @@ -212,7 +214,12 @@ const ServerOps: CommandHandlers = { [ScriptOpcode.MAP_LOCADDUNSAFE]: state => { const coord: CoordGrid = check(state.popInt(), CoordValid); - for (const loc of World.gameMap.getZone(coord.x, coord.z, coord.level).getAllLocsUnsafe()) { + const zone = World.gameMap.getZoneIfExists(coord.x, coord.z, coord.level); + if (!zone) { + return; + } + + for (const loc of zone.getAllLocsUnsafe()) { const type = check(loc.type, LocTypeValid); if (type.active !== 1) { diff --git a/src/engine/zone/InstanceZone.ts b/src/engine/zone/InstanceZone.ts new file mode 100644 index 000000000..fce35f5cc --- /dev/null +++ b/src/engine/zone/InstanceZone.ts @@ -0,0 +1,370 @@ +import { CoordGrid } from '#/engine/CoordGrid.js'; +import { CollisionFlag } from '#/engine/routefinder/flags.js'; +import routeFinder from '#/engine/routefinder/index.js'; +import { EntityLifeCycle } from '#/engine/entity/EntityLifeCycle.js'; +import Loc from '#/engine/entity/Loc.js'; +import World from '#/engine/World.js'; +import Zone from '#/engine/zone/Zone.js'; +import ZoneMap from '#/engine/zone/ZoneMap.js'; + +export default class InstanceZone extends Zone { + source: CoordGrid; + rotation: 0 | 1 | 2 | 3; + private copiedFrom: boolean = false; + + constructor(index: number) { + super(index); + this.source = { level: 0, x: 0, z: 0 }; + this.rotation = 0; + } + + get hasAssignedTemplate(): boolean { + return this.copiedFrom; + } + + /** + * Copy entities (locations, objects, collision) from the source zone into this instance zone, + * applying the specified rotation transformation. + * + * @param sourceZone The overworld zone to copy from. + * @param rotation The rotation to apply (0, 1, 2, or 3). + */ + copyFromZone(sourceZone: Zone, rotation: 0 | 1 | 2 | 3): void { + this.assignSource(sourceZone.level, sourceZone.x, sourceZone.z, rotation); + + // Copy collision data with rotation applied + this.copyCollisionWithRotation(sourceZone, rotation); + + // Copy locs with rotation applied + this.copyLocsWithRotation(sourceZone, rotation); + } + + /** + * Record template metadata without copying locs or collision. + * This exists for missing-source copies, which still consume the single + * allowed assignment for the destination instance zone. + */ + assignTemplate(source: CoordGrid, rotation: 0 | 1 | 2 | 3): void { + this.assignSource(source.level, source.x >> 3, source.z >> 3, rotation); + } + + private assignSource(sourceLevel: number, sourceZoneX: number, sourceZoneZ: number, rotation: 0 | 1 | 2 | 3): void { + if (this.copiedFrom) { + throw new Error('InstanceZone has already been copied from a source'); + } + + this.source = { level: sourceLevel, x: sourceZoneX, z: sourceZoneZ }; + this.rotation = rotation; + this.copiedFrom = true; + } + + private copyLocsWithRotation(sourceZone: Zone, rotation: 0 | 1 | 2 | 3): void { + for (const sourceLoc of sourceZone.getAllLocsSafe()) { + // Extract base properties (before any runtime changes) + const baseType = sourceLoc.baseType; + const baseShape = sourceLoc.baseShape; + const baseAngle = sourceLoc.baseAngle; + const sourceWidth = sourceLoc.width; + const sourceLength = sourceLoc.length; + let width = sourceWidth; + let length = sourceLength; + + // Get zone-relative tile coordinates (0-7 range). + // sourceZone.x is already in zone-index units (tile >> 3), so shift left to + // get the absolute tile base of the zone's SW corner. + const locX = sourceLoc.x - (sourceZone.x << 3); + const locZ = sourceLoc.z - (sourceZone.z << 3); + + // Rotate position and dimensions + let rotatedX = locX; + let rotatedZ = locZ; + if (rotation === 1) { + // 90° CW + rotatedX = 8 - locZ - sourceLength; + rotatedZ = locX; + [width, length] = [length, width]; + } else if (rotation === 2) { + // 180° + rotatedX = 8 - locX - sourceWidth; + rotatedZ = 8 - locZ - sourceLength; + } else if (rotation === 3) { + // 270° CW + rotatedX = locZ; + rotatedZ = 8 - locX - sourceWidth; + [width, length] = [length, width]; + } + + // Rotate angle + const rotatedAngle = ((baseAngle + rotation) & 0x3) as 0 | 1 | 2 | 3; + + // Compute absolute coordinates in instance zone. + // this.x is zone-index (tile >> 3), so shift left to get the tile base. + const absoluteX = (this.x << 3) + rotatedX; + const absoluteZ = (this.z << 3) + rotatedZ; + + if (!World.gameMap.hasZone(absoluteX, absoluteZ, this.level)) { + throw new Error(`Instance loc out of bounds: source=(${sourceLoc.x},${sourceLoc.z},L${sourceLoc.level}) rotated=(${absoluteX},${absoluteZ},L${this.level}) has no destination zone`); + } + + const destinationZone = World.gameMap.getZone(absoluteX, absoluteZ, this.level); + + // Create new Loc with rotated properties + const newLoc = new Loc(this.level, absoluteX, absoluteZ, width, length, sourceLoc.lifecycle, baseType, baseShape, rotatedAngle); + + if (sourceLoc.lifecycle === EntityLifeCycle.DESPAWN) { + // Preserve dynamic loc semantics when the source loc is runtime-spawned. + World.addLoc(newLoc, 0); + } else { + // Static locs stay attached to the zone that owns their rotated base tile. + destinationZone.addStaticLoc(newLoc); + } + } + } + + private copyCollisionWithRotation(sourceZone: Zone, rotation: 0 | 1 | 2 | 3): void { + const sourceBaseX = sourceZone.x << 3; + const sourceBaseZ = sourceZone.z << 3; + const sourceCollision = routeFinder.collisionFlags.getZone(sourceBaseX, sourceBaseZ, sourceZone.level); + if (!sourceCollision) { + return; // No collision to copy + } + + const destBaseX = this.x << 3; + const destBaseZ = this.z << 3; + const destCollision = routeFinder.collisionFlags.getZone(destBaseX, destBaseZ, this.level) ?? new Uint32Array(64); + + // Iterate through the 8x8 zone and apply rotation transformations + for (let srcIdx = 0; srcIdx < 64; srcIdx++) { + const srcFlags = sourceCollision[srcIdx]; + if (srcFlags === CollisionFlag.OPEN) { + continue; // Skip empty tiles + } + + // Unpack source tile coordinates from linear index + const srcX = srcIdx & 0x7; + const srcZ = (srcIdx >> 3) & 0x7; + + // Rotate coordinates + let dstX = srcX; + let dstZ = srcZ; + if (rotation === 1) { + // 90° CW + dstX = 7 - srcZ; + dstZ = srcX; + } else if (rotation === 2) { + // 180° + dstX = 7 - srcX; + dstZ = 7 - srcZ; + } else if (rotation === 3) { + // 270° CW + dstX = srcZ; + dstZ = 7 - srcX; + } + + const dstIdx = dstX | (dstZ << 3); + const rotatedFlags = this.rotateCollisionFlags(srcFlags, rotation); + destCollision[dstIdx] |= rotatedFlags; + } + + // Write rotated collision data to destination zone + routeFinder.collisionFlags.setZone(destBaseX, destBaseZ, this.level, destCollision); + + // Copying a single 8x8 can miss the mirrored half of edge walls. + // Emit outward-facing mirrors onto adjacent tiles so crossing the zone boundary + // is blocked consistently even when neighboring zones are not copied. + this.mirrorOutboundEdgeWalls(destCollision, destBaseX, destBaseZ, this.level); + } + + private mirrorOutboundEdgeWalls(destCollision: Uint32Array, destBaseX: number, destBaseZ: number, level: number): void { + const mirror = (flags: number, sourceMask: number, dstX: number, dstZ: number, mirrorMask: number): void => { + if (flags & sourceMask && this.ensureMirrorDestinationZone(dstX, dstZ, level)) { + routeFinder.collisionFlags.add(dstX, dstZ, level, mirrorMask); + } + }; + + for (let localZ = 0; localZ < 8; localZ++) { + const westFlags = destCollision[localZ << 3]; + mirror(westFlags, CollisionFlag.WALL_WEST, destBaseX - 1, destBaseZ + localZ, CollisionFlag.WALL_EAST); + mirror(westFlags, CollisionFlag.WALL_WEST_PROJ_BLOCKER, destBaseX - 1, destBaseZ + localZ, CollisionFlag.WALL_EAST_PROJ_BLOCKER); + mirror(westFlags, CollisionFlag.WALL_WEST_ROUTE_BLOCKER, destBaseX - 1, destBaseZ + localZ, CollisionFlag.WALL_EAST_ROUTE_BLOCKER); + + const eastFlags = destCollision[7 | (localZ << 3)]; + mirror(eastFlags, CollisionFlag.WALL_EAST, destBaseX + 8, destBaseZ + localZ, CollisionFlag.WALL_WEST); + mirror(eastFlags, CollisionFlag.WALL_EAST_PROJ_BLOCKER, destBaseX + 8, destBaseZ + localZ, CollisionFlag.WALL_WEST_PROJ_BLOCKER); + mirror(eastFlags, CollisionFlag.WALL_EAST_ROUTE_BLOCKER, destBaseX + 8, destBaseZ + localZ, CollisionFlag.WALL_WEST_ROUTE_BLOCKER); + } + + for (let localX = 0; localX < 8; localX++) { + const southFlags = destCollision[localX]; + mirror(southFlags, CollisionFlag.WALL_SOUTH, destBaseX + localX, destBaseZ - 1, CollisionFlag.WALL_NORTH); + mirror(southFlags, CollisionFlag.WALL_SOUTH_PROJ_BLOCKER, destBaseX + localX, destBaseZ - 1, CollisionFlag.WALL_NORTH_PROJ_BLOCKER); + mirror(southFlags, CollisionFlag.WALL_SOUTH_ROUTE_BLOCKER, destBaseX + localX, destBaseZ - 1, CollisionFlag.WALL_NORTH_ROUTE_BLOCKER); + + const northFlags = destCollision[localX | (7 << 3)]; + mirror(northFlags, CollisionFlag.WALL_NORTH, destBaseX + localX, destBaseZ + 8, CollisionFlag.WALL_SOUTH); + mirror(northFlags, CollisionFlag.WALL_NORTH_PROJ_BLOCKER, destBaseX + localX, destBaseZ + 8, CollisionFlag.WALL_SOUTH_PROJ_BLOCKER); + mirror(northFlags, CollisionFlag.WALL_NORTH_ROUTE_BLOCKER, destBaseX + localX, destBaseZ + 8, CollisionFlag.WALL_SOUTH_ROUTE_BLOCKER); + } + + const southWestFlags = destCollision[0]; + mirror(southWestFlags, CollisionFlag.WALL_SOUTH_WEST, destBaseX - 1, destBaseZ - 1, CollisionFlag.WALL_NORTH_EAST); + mirror(southWestFlags, CollisionFlag.WALL_SOUTH_WEST_PROJ_BLOCKER, destBaseX - 1, destBaseZ - 1, CollisionFlag.WALL_NORTH_EAST_PROJ_BLOCKER); + mirror(southWestFlags, CollisionFlag.WALL_SOUTH_WEST_ROUTE_BLOCKER, destBaseX - 1, destBaseZ - 1, CollisionFlag.WALL_NORTH_EAST_ROUTE_BLOCKER); + + const southEastFlags = destCollision[7]; + mirror(southEastFlags, CollisionFlag.WALL_SOUTH_EAST, destBaseX + 8, destBaseZ - 1, CollisionFlag.WALL_NORTH_WEST); + mirror(southEastFlags, CollisionFlag.WALL_SOUTH_EAST_PROJ_BLOCKER, destBaseX + 8, destBaseZ - 1, CollisionFlag.WALL_NORTH_WEST_PROJ_BLOCKER); + mirror(southEastFlags, CollisionFlag.WALL_SOUTH_EAST_ROUTE_BLOCKER, destBaseX + 8, destBaseZ - 1, CollisionFlag.WALL_NORTH_WEST_ROUTE_BLOCKER); + + const northWestFlags = destCollision[7 << 3]; + mirror(northWestFlags, CollisionFlag.WALL_NORTH_WEST, destBaseX - 1, destBaseZ + 8, CollisionFlag.WALL_SOUTH_EAST); + mirror(northWestFlags, CollisionFlag.WALL_NORTH_WEST_PROJ_BLOCKER, destBaseX - 1, destBaseZ + 8, CollisionFlag.WALL_SOUTH_EAST_PROJ_BLOCKER); + mirror(northWestFlags, CollisionFlag.WALL_NORTH_WEST_ROUTE_BLOCKER, destBaseX - 1, destBaseZ + 8, CollisionFlag.WALL_SOUTH_EAST_ROUTE_BLOCKER); + + const northEastFlags = destCollision[7 | (7 << 3)]; + mirror(northEastFlags, CollisionFlag.WALL_NORTH_EAST, destBaseX + 8, destBaseZ + 8, CollisionFlag.WALL_SOUTH_WEST); + mirror(northEastFlags, CollisionFlag.WALL_NORTH_EAST_PROJ_BLOCKER, destBaseX + 8, destBaseZ + 8, CollisionFlag.WALL_SOUTH_WEST_PROJ_BLOCKER); + mirror(northEastFlags, CollisionFlag.WALL_NORTH_EAST_ROUTE_BLOCKER, destBaseX + 8, destBaseZ + 8, CollisionFlag.WALL_SOUTH_WEST_ROUTE_BLOCKER); + } + + private ensureMirrorDestinationZone(x: number, z: number, level: number): boolean { + if (World.gameMap.hasZone(x, z, level)) { + return true; + } + + // Only materialize mirror destinations that are within an active instance footprint. + if (!World.instances.findInstanceByTile(level, x, z)) { + return false; + } + + World.gameMap.createInstanceZone(ZoneMap.zoneIndex(x, z, level)); + routeFinder.allocateIfAbsent(x, z, level); + return true; + } + + private rotateCollisionFlags(flags: number, rotation: 0 | 1 | 2 | 3): number { + if (rotation === 0) { + return flags; + } + + let result = flags; + + // Extract directional wall flags + const hasWallNorth = !!(flags & CollisionFlag.WALL_NORTH); + const hasWallEast = !!(flags & CollisionFlag.WALL_EAST); + const hasWallSouth = !!(flags & CollisionFlag.WALL_SOUTH); + const hasWallWest = !!(flags & CollisionFlag.WALL_WEST); + const hasWallNW = !!(flags & CollisionFlag.WALL_NORTH_WEST); + const hasWallNE = !!(flags & CollisionFlag.WALL_NORTH_EAST); + const hasWallSE = !!(flags & CollisionFlag.WALL_SOUTH_EAST); + const hasWallSW = !!(flags & CollisionFlag.WALL_SOUTH_WEST); + + const hasWallNorthProj = !!(flags & CollisionFlag.WALL_NORTH_PROJ_BLOCKER); + const hasWallEastProj = !!(flags & CollisionFlag.WALL_EAST_PROJ_BLOCKER); + const hasWallSouthProj = !!(flags & CollisionFlag.WALL_SOUTH_PROJ_BLOCKER); + const hasWallWestProj = !!(flags & CollisionFlag.WALL_WEST_PROJ_BLOCKER); + const hasWallNWProj = !!(flags & CollisionFlag.WALL_NORTH_WEST_PROJ_BLOCKER); + const hasWallNEProj = !!(flags & CollisionFlag.WALL_NORTH_EAST_PROJ_BLOCKER); + const hasWallSEProj = !!(flags & CollisionFlag.WALL_SOUTH_EAST_PROJ_BLOCKER); + const hasWallSWProj = !!(flags & CollisionFlag.WALL_SOUTH_WEST_PROJ_BLOCKER); + + const hasWallNorthRoute = !!(flags & CollisionFlag.WALL_NORTH_ROUTE_BLOCKER); + const hasWallEastRoute = !!(flags & CollisionFlag.WALL_EAST_ROUTE_BLOCKER); + const hasWallSouthRoute = !!(flags & CollisionFlag.WALL_SOUTH_ROUTE_BLOCKER); + const hasWallWestRoute = !!(flags & CollisionFlag.WALL_WEST_ROUTE_BLOCKER); + const hasWallNWRoute = !!(flags & CollisionFlag.WALL_NORTH_WEST_ROUTE_BLOCKER); + const hasWallNERoute = !!(flags & CollisionFlag.WALL_NORTH_EAST_ROUTE_BLOCKER); + const hasWallSERoute = !!(flags & CollisionFlag.WALL_SOUTH_EAST_ROUTE_BLOCKER); + const hasWallSWRoute = !!(flags & CollisionFlag.WALL_SOUTH_WEST_ROUTE_BLOCKER); + + // Clear all directional flags + result &= ~(CollisionFlag.WALL_NORTH | CollisionFlag.WALL_EAST | CollisionFlag.WALL_SOUTH | CollisionFlag.WALL_WEST); + result &= ~(CollisionFlag.WALL_NORTH_WEST | CollisionFlag.WALL_NORTH_EAST | CollisionFlag.WALL_SOUTH_EAST | CollisionFlag.WALL_SOUTH_WEST); + result &= ~(CollisionFlag.WALL_NORTH_PROJ_BLOCKER | CollisionFlag.WALL_EAST_PROJ_BLOCKER | CollisionFlag.WALL_SOUTH_PROJ_BLOCKER | CollisionFlag.WALL_WEST_PROJ_BLOCKER); + result &= ~(CollisionFlag.WALL_NORTH_WEST_PROJ_BLOCKER | CollisionFlag.WALL_NORTH_EAST_PROJ_BLOCKER | CollisionFlag.WALL_SOUTH_EAST_PROJ_BLOCKER | CollisionFlag.WALL_SOUTH_WEST_PROJ_BLOCKER); + result &= ~(CollisionFlag.WALL_NORTH_ROUTE_BLOCKER | CollisionFlag.WALL_EAST_ROUTE_BLOCKER | CollisionFlag.WALL_SOUTH_ROUTE_BLOCKER | CollisionFlag.WALL_WEST_ROUTE_BLOCKER); + result &= ~(CollisionFlag.WALL_NORTH_WEST_ROUTE_BLOCKER | CollisionFlag.WALL_NORTH_EAST_ROUTE_BLOCKER | CollisionFlag.WALL_SOUTH_EAST_ROUTE_BLOCKER | CollisionFlag.WALL_SOUTH_WEST_ROUTE_BLOCKER); + + if (rotation === 1) { + // 90° CW: N→E, E→S, S→W, W→N + if (hasWallNorth) result |= CollisionFlag.WALL_EAST; + if (hasWallEast) result |= CollisionFlag.WALL_SOUTH; + if (hasWallSouth) result |= CollisionFlag.WALL_WEST; + if (hasWallWest) result |= CollisionFlag.WALL_NORTH; + if (hasWallNW) result |= CollisionFlag.WALL_NORTH_EAST; + if (hasWallNE) result |= CollisionFlag.WALL_SOUTH_EAST; + if (hasWallSE) result |= CollisionFlag.WALL_SOUTH_WEST; + if (hasWallSW) result |= CollisionFlag.WALL_NORTH_WEST; + if (hasWallNorthProj) result |= CollisionFlag.WALL_EAST_PROJ_BLOCKER; + if (hasWallEastProj) result |= CollisionFlag.WALL_SOUTH_PROJ_BLOCKER; + if (hasWallSouthProj) result |= CollisionFlag.WALL_WEST_PROJ_BLOCKER; + if (hasWallWestProj) result |= CollisionFlag.WALL_NORTH_PROJ_BLOCKER; + if (hasWallNWProj) result |= CollisionFlag.WALL_NORTH_EAST_PROJ_BLOCKER; + if (hasWallNEProj) result |= CollisionFlag.WALL_SOUTH_EAST_PROJ_BLOCKER; + if (hasWallSEProj) result |= CollisionFlag.WALL_SOUTH_WEST_PROJ_BLOCKER; + if (hasWallSWProj) result |= CollisionFlag.WALL_NORTH_WEST_PROJ_BLOCKER; + if (hasWallNorthRoute) result |= CollisionFlag.WALL_EAST_ROUTE_BLOCKER; + if (hasWallEastRoute) result |= CollisionFlag.WALL_SOUTH_ROUTE_BLOCKER; + if (hasWallSouthRoute) result |= CollisionFlag.WALL_WEST_ROUTE_BLOCKER; + if (hasWallWestRoute) result |= CollisionFlag.WALL_NORTH_ROUTE_BLOCKER; + if (hasWallNWRoute) result |= CollisionFlag.WALL_NORTH_EAST_ROUTE_BLOCKER; + if (hasWallNERoute) result |= CollisionFlag.WALL_SOUTH_EAST_ROUTE_BLOCKER; + if (hasWallSERoute) result |= CollisionFlag.WALL_SOUTH_WEST_ROUTE_BLOCKER; + if (hasWallSWRoute) result |= CollisionFlag.WALL_NORTH_WEST_ROUTE_BLOCKER; + } else if (rotation === 2) { + // 180°: N↔S, E↔W, NW↔SE, NE↔SW + if (hasWallNorth) result |= CollisionFlag.WALL_SOUTH; + if (hasWallEast) result |= CollisionFlag.WALL_WEST; + if (hasWallSouth) result |= CollisionFlag.WALL_NORTH; + if (hasWallWest) result |= CollisionFlag.WALL_EAST; + if (hasWallNW) result |= CollisionFlag.WALL_SOUTH_EAST; + if (hasWallNE) result |= CollisionFlag.WALL_SOUTH_WEST; + if (hasWallSE) result |= CollisionFlag.WALL_NORTH_WEST; + if (hasWallSW) result |= CollisionFlag.WALL_NORTH_EAST; + if (hasWallNorthProj) result |= CollisionFlag.WALL_SOUTH_PROJ_BLOCKER; + if (hasWallEastProj) result |= CollisionFlag.WALL_WEST_PROJ_BLOCKER; + if (hasWallSouthProj) result |= CollisionFlag.WALL_NORTH_PROJ_BLOCKER; + if (hasWallWestProj) result |= CollisionFlag.WALL_EAST_PROJ_BLOCKER; + if (hasWallNWProj) result |= CollisionFlag.WALL_SOUTH_EAST_PROJ_BLOCKER; + if (hasWallNEProj) result |= CollisionFlag.WALL_SOUTH_WEST_PROJ_BLOCKER; + if (hasWallSEProj) result |= CollisionFlag.WALL_NORTH_WEST_PROJ_BLOCKER; + if (hasWallSWProj) result |= CollisionFlag.WALL_NORTH_EAST_PROJ_BLOCKER; + if (hasWallNorthRoute) result |= CollisionFlag.WALL_SOUTH_ROUTE_BLOCKER; + if (hasWallEastRoute) result |= CollisionFlag.WALL_WEST_ROUTE_BLOCKER; + if (hasWallSouthRoute) result |= CollisionFlag.WALL_NORTH_ROUTE_BLOCKER; + if (hasWallWestRoute) result |= CollisionFlag.WALL_EAST_ROUTE_BLOCKER; + if (hasWallNWRoute) result |= CollisionFlag.WALL_SOUTH_EAST_ROUTE_BLOCKER; + if (hasWallNERoute) result |= CollisionFlag.WALL_SOUTH_WEST_ROUTE_BLOCKER; + if (hasWallSERoute) result |= CollisionFlag.WALL_NORTH_WEST_ROUTE_BLOCKER; + if (hasWallSWRoute) result |= CollisionFlag.WALL_NORTH_EAST_ROUTE_BLOCKER; + } else if (rotation === 3) { + // 270° CW: N→W, W→S, S→E, E→N + if (hasWallNorth) result |= CollisionFlag.WALL_WEST; + if (hasWallEast) result |= CollisionFlag.WALL_NORTH; + if (hasWallSouth) result |= CollisionFlag.WALL_EAST; + if (hasWallWest) result |= CollisionFlag.WALL_SOUTH; + if (hasWallNW) result |= CollisionFlag.WALL_SOUTH_WEST; + if (hasWallNE) result |= CollisionFlag.WALL_NORTH_WEST; + if (hasWallSE) result |= CollisionFlag.WALL_NORTH_EAST; + if (hasWallSW) result |= CollisionFlag.WALL_SOUTH_EAST; + if (hasWallNorthProj) result |= CollisionFlag.WALL_WEST_PROJ_BLOCKER; + if (hasWallEastProj) result |= CollisionFlag.WALL_NORTH_PROJ_BLOCKER; + if (hasWallSouthProj) result |= CollisionFlag.WALL_EAST_PROJ_BLOCKER; + if (hasWallWestProj) result |= CollisionFlag.WALL_SOUTH_PROJ_BLOCKER; + if (hasWallNWProj) result |= CollisionFlag.WALL_SOUTH_WEST_PROJ_BLOCKER; + if (hasWallNEProj) result |= CollisionFlag.WALL_NORTH_WEST_PROJ_BLOCKER; + if (hasWallSEProj) result |= CollisionFlag.WALL_NORTH_EAST_PROJ_BLOCKER; + if (hasWallSWProj) result |= CollisionFlag.WALL_SOUTH_EAST_PROJ_BLOCKER; + if (hasWallNorthRoute) result |= CollisionFlag.WALL_WEST_ROUTE_BLOCKER; + if (hasWallEastRoute) result |= CollisionFlag.WALL_NORTH_ROUTE_BLOCKER; + if (hasWallSouthRoute) result |= CollisionFlag.WALL_EAST_ROUTE_BLOCKER; + if (hasWallWestRoute) result |= CollisionFlag.WALL_SOUTH_ROUTE_BLOCKER; + if (hasWallNWRoute) result |= CollisionFlag.WALL_SOUTH_WEST_ROUTE_BLOCKER; + if (hasWallNERoute) result |= CollisionFlag.WALL_NORTH_WEST_ROUTE_BLOCKER; + if (hasWallSERoute) result |= CollisionFlag.WALL_NORTH_EAST_ROUTE_BLOCKER; + if (hasWallSWRoute) result |= CollisionFlag.WALL_SOUTH_EAST_ROUTE_BLOCKER; + } + + return result; + } +} diff --git a/src/engine/zone/Zone.ts b/src/engine/zone/Zone.ts index fc9822301..86540944f 100644 --- a/src/engine/zone/Zone.ts +++ b/src/engine/zone/Zone.ts @@ -32,7 +32,6 @@ import LinkList from '#/datastruct/LinkList.js'; import ServerGameProtRepository from '#/network/game/server/ServerGameProtRepository.js'; import DoublyLinkList from '#/datastruct/DoublyLinkList.js'; - export default class Zone { private static readonly SIZE: number = 8 * 8; private static readonly LOCS: number = this.SIZE << 2; @@ -76,6 +75,10 @@ export default class Zone { return this.objsCount; } + hasPlayers(): boolean { + return !this.players.isEmpty(); + } + enter(entity: PathingEntity): void { if (entity instanceof Player) { this.players.addTail(entity); diff --git a/src/engine/zone/ZoneMap.ts b/src/engine/zone/ZoneMap.ts index 17801b78b..bbe491617 100644 --- a/src/engine/zone/ZoneMap.ts +++ b/src/engine/zone/ZoneMap.ts @@ -1,6 +1,7 @@ import { CoordGrid } from '#/engine/CoordGrid.js'; import Zone from '#/engine/zone/Zone.js'; import ZoneGrid from '#/engine/zone/ZoneGrid.js'; +import InstanceZone from '#/engine/zone/InstanceZone.js'; export default class ZoneMap { static zoneIndex(x: number, z: number, level: number): number { @@ -16,31 +17,133 @@ export default class ZoneMap { private readonly zones: Map; private readonly grids: Map; + private isInitializing: boolean = false; constructor() { this.zones = new Map(); this.grids = new Map(); } - zone(x: number, z: number, level: number): Zone { + /** + * Call before loading map data to allow zone auto-creation. + */ + beginInitialization(): void { + this.isInitializing = true; + } + + /** + * Call after loading map data to lock down zone creation. + */ + endInitialization(): void { + this.isInitializing = false; + } + + /** + * Check if zone map is currently initializing (during map load). + */ + isInitializingMap(): boolean { + return this.isInitializing; + } + + /** + * Get an existing zone, or auto-create it during initialization. + * After initialization completes, zones must be pre-created or use getZoneIfExists(). + * @throws Error if zone doesn't exist and initialization is complete. + */ + getZone(x: number, z: number, level: number): Zone { const zoneIndex: number = ZoneMap.zoneIndex(x, z, level); let zone: Zone | undefined = this.zones.get(zoneIndex); - if (typeof zone == 'undefined') { - zone = new Zone(zoneIndex); - this.zones.set(zoneIndex, zone); + + if (typeof zone === 'undefined') { + if (this.isInitializing) { + // Auto-create zone only during initialization + zone = new Zone(zoneIndex); + this.zones.set(zoneIndex, zone); + } else { + // Enforce zone pre-creation after initialization + throw new Error(`Zone does not exist at (${x}, ${z}, L${level}). Zones must be pre-created during world startup.`); + } } return zone; } - zoneByIndex(index: number): Zone { + /** + * Get an existing zone by index, or auto-create it during initialization. + * After initialization completes, zones must be pre-created or use getZoneIfExists(). + * @throws Error if zone doesn't exist and initialization is complete. + */ + getZoneByIndex(index: number): Zone { let zone: Zone | undefined = this.zones.get(index); - if (typeof zone == 'undefined') { - zone = new Zone(index); - this.zones.set(index, zone); + + if (typeof zone === 'undefined') { + if (this.isInitializing) { + // Auto-create zone only during initialization + zone = new Zone(index); + this.zones.set(index, zone); + } else { + // Enforce zone pre-creation after initialization + const unpacked = ZoneMap.unpackIndex(index); + throw new Error(`Zone does not exist at (${unpacked.x}, ${unpacked.z}, L${unpacked.level}). Zones must be pre-created during world startup.`); + } } return zone; } + /** + * Get an existing zone by index, or null if it doesn't exist. + * Use this only for optional zone lookups where missing zones are expected. + */ + getZoneByIndexIfExists(index: number): Zone | null { + return this.zones.get(index) ?? null; + } + + /** + * Get an existing zone, or null if it doesn't exist. + * Use this only for optional zone lookups where missing zones are expected. + */ + getZoneIfExists(x: number, z: number, level: number): Zone | null { + const zoneIndex: number = ZoneMap.zoneIndex(x, z, level); + return this.zones.get(zoneIndex) ?? null; + } + + /** + * Create a new Zone and register it. Only called during world startup or instance creation. + */ + createZone(x: number, z: number, level: number): Zone { + const zoneIndex: number = ZoneMap.zoneIndex(x, z, level); + if (this.zones.has(zoneIndex)) { + throw new Error(`Zone already exists at (${x}, ${z}, L${level})`); + } + const zone = new Zone(zoneIndex); + this.zones.set(zoneIndex, zone); + return zone; + } + + /** + * Create a new InstanceZone and register it. Only called during instance creation. + */ + createInstanceZone(zoneIndex: number): Zone { + if (this.zones.has(zoneIndex)) { + throw new Error(`Zone already exists at index ${zoneIndex}`); + } + const zone = new InstanceZone(zoneIndex); + this.zones.set(zoneIndex, zone); + return zone; + } + + hasZone(x: number, z: number, level: number): boolean { + return this.zones.has(ZoneMap.zoneIndex(x, z, level)); + } + + addZone(zone: Zone): Zone { + this.zones.set(zone.index, zone); + return zone; + } + + removeZone(index: number): boolean { + return this.zones.delete(index); + } + grid(level: number): ZoneGrid { let grid: ZoneGrid | undefined = this.grids.get(level); if (typeof grid == 'undefined') { diff --git a/src/network/game/client/handler/ClientCheatHandler.ts b/src/network/game/client/handler/ClientCheatHandler.ts index a5f14969e..8d1f89a61 100644 --- a/src/network/game/client/handler/ClientCheatHandler.ts +++ b/src/network/game/client/handler/ClientCheatHandler.ts @@ -1,7 +1,7 @@ import v8 from 'node:v8'; import { Visibility } from '@2004scape/rsbuf'; -import { LocAngle, LocShape } from '@2004scape/rsmod-pathfinder'; +import { LocAngle, LocShape } from '#/engine/routefinder/index.js'; import Component from '#/cache/config/Component.js'; import IdkType from '#/cache/config/IdkType.js'; @@ -140,7 +140,6 @@ export default class ClientCheatHandler extends ClientGameMessageHandler { + prot = ServerGameProt.REBUILD_REGION; + + encode(buf: Packet, message: RebuildRegion): void { + // 377 packet 53 decode order: + // 1) zoneX (g2_alt2) + // 2) bit access: 4*13*13 flags + optional 26-bit templates + // 3) zoneZ (g2_alt2) after accessBytes + // No per-mapsquare key blocks are read by this client. + buf.p2_alt2(message.zoneX); + + const templateByZone = new Map(); + for (const template of message.templates) { + const key = (template.level << 22) | ((template.zoneX & 0x7ff) << 11) | (template.zoneZ & 0x7ff); + templateByZone.set(key, packRegionTemplate(template)); + } + + buf.bitStart(); + + // Emit 4*13*13 zone templates centered around the player's build-area center zone. + for (let level = 0; level < 4; level++) { + for (let zoneX = message.zoneX - 6; zoneX <= message.zoneX + 6; zoneX++) { + for (let zoneZ = message.zoneZ - 6; zoneZ <= message.zoneZ + 6; zoneZ++) { + const key = (level << 22) | ((zoneX & 0x7ff) << 11) | (zoneZ & 0x7ff); + const packed = templateByZone.get(key); + + if (packed === undefined) { + buf.pBit(1, 0); + } else { + buf.pBit(1, 1); + buf.pBit(26, packed); + } + } + } + } + + buf.bitEnd(); + + buf.p2_alt2(message.zoneZ); + } +} diff --git a/src/network/game/server/model/RebuildRegion.ts b/src/network/game/server/model/RebuildRegion.ts new file mode 100644 index 000000000..58e194270 --- /dev/null +++ b/src/network/game/server/model/RebuildRegion.ts @@ -0,0 +1,29 @@ +import ServerGameMessage from '#/network/game/server/ServerGameMessage.js'; + +export type RegionTemplate = { + level: number; + zoneX: number; + zoneZ: number; + sourceLevel: number; + sourceZoneX: number; + sourceZoneZ: number; + rotation: 0 | 1 | 2 | 3; +}; + +export function packRegionTemplate(template: RegionTemplate): number { + // Matches client sceneMapRegion entry read via gBit(26). + return ((template.sourceLevel & 0x3) << 24) | ((template.sourceZoneX & 0x3ff) << 14) | ((template.sourceZoneZ & 0x7ff) << 3) | ((template.rotation & 0x3) << 1); +} + +export default class RebuildRegion extends ServerGameMessage { + constructor( + readonly zoneX: number, + readonly zoneZ: number, + readonly templates: RegionTemplate[], + readonly localX: number, + readonly localZ: number, + readonly rebuildLevel: number + ) { + super(); + } +}