From 20f8a2f1fe129399a57cad00b7aa22070148ffa0 Mon Sep 17 00:00:00 2001 From: Thayol Date: Fri, 17 Jan 2025 13:01:47 +0900 Subject: [PATCH] use obstacle-in-path pathfinding --- src/entity.js | 100 +++++++++++++++++++++++++++++++--------------- src/game.js | 65 ++++++++++++++++++++---------- src/index.js | 10 +++++ src/projectile.js | 2 +- src/satx.js | 8 +++- src/template.js | 19 ++++----- src/terrain.js | 4 +- 7 files changed, 138 insertions(+), 70 deletions(-) diff --git a/src/entity.js b/src/entity.js index 50f13e5..7222857 100644 --- a/src/entity.js +++ b/src/entity.js @@ -25,7 +25,6 @@ export default class Entity { #logic = null #moving = false #path = [] - #scheduledPathfinding = null #spawnPosition = new Vector2() static collider(x, y, radius) { @@ -45,7 +44,6 @@ export default class Entity { get destination() { return this.#dest } get logic() { return this.#logic } get game() { return this.#game } - get scheduledPathfinding() { return this.#scheduledPathfinding } get spawnPosition() { return this.#spawnPosition } get x() { return this.position.x } get y() { return this.position.y } @@ -53,19 +51,10 @@ export default class Entity { set destination(value) { this.#dest = value } set logic(value) { this.#logic = value } set game(value) { this.#game = value } - set scheduledPathfinding(value) { this.#scheduledPathfinding = value } set spawnPosition(value) { this.#spawnPosition = value } set x(value) { this.position.x = value } set y(value) { this.position.y = value } - get collider() { - return new SAT.Circle(new SAT.Vector(this.x, this.y), this.radius) - } - - get colliders() { - return [this.collider] - } - get unadjustedWaypoints() { const numberOfWaypoints = 8 const margin = 1 @@ -86,6 +75,15 @@ export default class Entity { ]) } + adjustWaypoint(waypoint, direction) { + return SATX.clamp( + waypoint.clone().add(direction.clone().multiplyScalar(this.radius)), + this.game?.width, + this.game?.height, + this.radius, + ) + } + attackAction(cursor) { this.moveAction(cursor, true) } @@ -141,12 +139,20 @@ export default class Entity { // --- Actions above --- // collidables() { - const entityColliders = (this.game?.entities ?? []).filter((e) => e.id != this.id).map((e) => e.collider) - const terrainColliders = (this.game?.terrains ?? []).map((t) => t.colliders).flat() + const entityColliders = (this.game?.entities ?? []).filter((e) => e.id != this.id).map((e) => e.collider()) + const terrainColliders = (this.game?.terrains ?? []).map((t) => t.colliders()).flat() return entityColliders.concat(terrainColliders) } + collider() { + return new SAT.Circle(new SAT.Vector(this.x, this.y), this.radius) + } + + colliders() { + return [this.collider()] + } + cooldown(id) { this.cooldowns[id] = this.game?.currentTick ?? 0 } @@ -180,7 +186,7 @@ export default class Entity { } isColliding(...colliders) { - return SATX.collideObjects(this.collider, colliders) + return SATX.collideObjects(this.collider(), colliders) } respawn() { @@ -221,14 +227,7 @@ export default class Entity { const terrainColliders = (this.game?.terrains ?? []) const unadjustedWaypoints = entityColliders.concat(terrainColliders).map((e) => e.unadjustedWaypoints).flat() - return unadjustedWaypoints.map(([waypoint, direction]) => { - return SATX.clamp( - waypoint.clone().add(direction.clone().multiplyScalar(this.radius)), - this.game?.width, - this.game?.height, - this.radius, - ) - }) ?? [] + return unadjustedWaypoints.map(([waypoint, direction]) => this.adjustWaypoint(waypoint, direction)) ?? [] } #cast() { @@ -255,7 +254,6 @@ export default class Entity { } } - // TODO: make scheduled pathfinding continue until collision to make the entity more "alive" #move(distanceTraveled = 0) { if (this.casting != null) { return false } @@ -278,28 +276,66 @@ export default class Entity { const collidables = this.collidables() const fixedDest = SATX.clamp(SATX.fixCollisions(this.#dest, collidables, this.radius), this.game?.width, this.game?.height, this.radius) - const tunnel = SATX.entityTunnel(this.position.x, this.position.y, fixedDest.x, fixedDest.y, this.radius) - const destinationInLineOfSight = !SATX.collideObjects(tunnel, collidables) if (this.#path.length > 0) { - if (!destinationInLineOfSight) { + const sectionDest = this.#path.at(0) + const sectionTunnel = SATX.entityTunnel(this.position.x, this.position.y, sectionDest.x, sectionDest.y, this.radius) + const lineOfSight = !SATX.collideObjects(sectionTunnel, collidables) + if (!lineOfSight) { this.#path = [] } } if (this.#path.length < 1 || !this.#path.at(-1).equals(fixedDest)) { - if (destinationInLineOfSight) { + const tunnel = SATX.entityTunnel(this.position.x, this.position.y, fixedDest.x, fixedDest.y, this.radius) + const lineOfSight = !SATX.collideObjects(tunnel, collidables) + if (lineOfSight) { this.#path = [fixedDest] } } - if ((this.#path.length < 1 || !this.#path.at(-1).equals(fixedDest)) && (!this.#scheduledPathfinding || this.game?.currentTick % this.game?.tickRate == this.#scheduledPathfinding)) { + if ((this.#path.length < 1 || (this.#path.at(-1)?.distanceTo(fixedDest) ?? 0) > 0.01)) { const start = SATX.vectorToFloat32Array(this.position) const goal = SATX.vectorToFloat32Array(fixedDest) - const nonUniqueWaypoints = this.waypoints().map((w) => SATX.vectorToFloat32Array(w)).concat([start, goal]) - const waypoints = Pathfind.uniqueWaypoints(nonUniqueWaypoints) - const graph = Pathfind.buildGraph(waypoints, collidables, this.radius) - this.#path = Pathfind.shortestPath(graph, start, goal).map((waypoint) => new Vector2(waypoint[0], waypoint[1])) + const obstacles = [] + + for (let failsafe = 0; failsafe < 1000; failsafe++) { + const waypoints = [ + start, + goal, + ...obstacles.map((e) => e.unadjustedWaypoints.map(([w, d]) => SATX.vectorToFloat32Array(this.adjustWaypoint(w, d)))).flat() + ] + + const colliders = obstacles.map((e) => e.colliders()).flat() + const graph = Pathfind.buildGraph(waypoints, colliders, this.radius) + const path = Pathfind.shortestPath(graph, start, goal).map((waypoint) => new Vector2(waypoint[0], waypoint[1])) + + if (path.length == 0) { break } // goal unreachable + + let obstacleInPath = false + let lastSection = this.position + for (const section of path) { + const tunnel = SATX.entityTunnel(lastSection.x, lastSection.y, section.x, section.y, this.radius) + const globalObstacles = this.game.terrains.concat(this.game.entities.filter((e) => e.id != this.id)) + const sectionObstacles = SATX.collideObstacles(tunnel, globalObstacles) + if (sectionObstacles.length > 0) { + obstacleInPath = true + const obstacleIds = obstacles.map((o) => o.id) + for (const obstacle of sectionObstacles) { + if (!obstacleIds.includes(obstacle.id)) { + obstacles.push(obstacle) + } + } + } + + lastSection = section + } + + if (!obstacleInPath) { + this.#path = path + break + } + } } if (this.#path.length > 0) { diff --git a/src/game.js b/src/game.js index 1f47b0e..9abe224 100644 --- a/src/game.js +++ b/src/game.js @@ -4,18 +4,22 @@ import Projectile from './projectile.js' import Terrain from './terrain.js' export default class Game { - tickRate = 30 + averageTick = 0 currentTick = 0 - width = 2000 height = 2000 - nextTickAt = 0 + secondToSlowestTick = 0 + tickRate = 30 + width = 2000 - #logic = null + #currentTiming = 0 #entities = [] #eventEmitter = new EventEmitter() + #logic = null + #nextTickAt = 0 #projectiles = [] #terrains = [] #tickBudget = 1000 / this.tickRate + #timings = new Float32Array(this.tickRate) get logic() { return this.#logic } get entities() { return this.#entities } @@ -84,23 +88,8 @@ export default class Game { } } - gameLoop() { - const tickBudget = this.#tickBudget - - if (this.nextTickAt != null) { - const nextTickAt = this.nextTickAt - this.nextTickAt = null - - let start = performance.now() - while (start < nextTickAt) { start = performance.now() } - - this.update() - this.nextTickAt = start + tickBudget - } - } - start() { - setInterval(() => this.gameLoop(), 1) + setInterval(() => this.#gameLoop(), 1) } update() { @@ -110,7 +99,41 @@ export default class Game { this.#logic() } - this.currentTick++ + this.#calculateTickMetrics() this.eventEmitter.emit('tick') + + this.currentTick++ + } + + #calculateTickMetrics() { + this.averageTick = Math.floor(10 * this.#timings.reduce((sum, t) => sum += t, 0) / this.#timings.length) / 10 + this.secondToSlowestTick = Math.floor(10 * this.#timings.toSorted().at(-2)) / 10 + } + + #gameLoop() { + const tickBudget = this.#tickBudget + + if (this.#nextTickAt != null) { + const nextTickAt = this.#nextTickAt + this.#nextTickAt = null + + let start = 0 + while (start < nextTickAt) { start = performance.now() } + + const before = performance.now() + this.update() + this.#nextTickAt = start + tickBudget + const after = performance.now() + const taken = (after - before) + + this.#timings[this.#currentTiming] = taken + if (this.#currentTiming++ > this.#timings.length) { + this.#currentTiming = 0 + } + + if (after - before > tickBudget) { + console.warn(`Can't keep up! A tick took ${taken.toFixed(1)} ms (Budget: ${tickBudget.toFixed(1)} ms)`) + } + } } } diff --git a/src/index.js b/src/index.js index ed5c5fc..347dee0 100644 --- a/src/index.js +++ b/src/index.js @@ -118,6 +118,16 @@ function laneScenario() { } } game.logic = gameLogic + + // const uBottomPoints = [ + // midSouthWallPoints.at(0).clone().sub(midWallThickness), + // midSouthWallPoints.at(1).clone().sub(midWallThickness), + // midNorthWallPoints.at(-2).clone().add(midWallThickness), + // midNorthWallPoints.at(-1).clone().add(midWallThickness), + // ] + // const uBottom = new Terrain(uBottomPoints) + // uBottom.id = 'uBottom' + // game.addTerrain(uBottom) } app.listen(port, () => { diff --git a/src/projectile.js b/src/projectile.js index a51eedc..2355faa 100644 --- a/src/projectile.js +++ b/src/projectile.js @@ -33,7 +33,7 @@ export default class Projectile { (this.game?.entities ?? []).filter((e) => e.id != this.id).forEach((e) => { if (e.id == this.owner?.id) { return } - if (SATX.collideObject(this.collider(), e.collider)) { + if (SATX.collideObject(this.collider(), e.collider())) { this.onCollide(this, e) } }) diff --git a/src/satx.js b/src/satx.js index 2b75e5d..0daea89 100644 --- a/src/satx.js +++ b/src/satx.js @@ -41,8 +41,12 @@ export default class SATX { return false } - static collideObjects(collider1, colliders) { - return colliders.some((c) => this.collideObject(collider1, c)) + static collideObjects(collider, colliders) { + return colliders.some((c) => this.collideObject(collider, c)) + } + + static collideObstacles(collider, obstacles) { + return obstacles.filter((obstacle) => obstacle.colliders().some((c) => this.collideObject(collider, c))) } static enclosingRegularPolygonRadius(numberOfVertices) { diff --git a/src/template.js b/src/template.js index af5d880..d73719d 100644 --- a/src/template.js +++ b/src/template.js @@ -33,11 +33,12 @@ export default class Template { } } - // TODO: fix disabled incremental pathing causes lag spikes // TODO: minion aggro + // TODO: incremental pathfinding stuck in thicker than recalculateDestRadius walls static #minionLogic(route = []) { const checkpointSize = 300 - const incrementalPathing = 100 + const maxDestDistance = 100 + const recalculateDestRadius = 50 return function builtMinionLogic() { const entity = this @@ -46,7 +47,6 @@ export default class Template { if (route.length > 0) { const routeIndex = entity.memory.routeCheckpoint ?? 0 const goal = route[routeIndex].clone() - const currentTick = entity.game?.currentTick ?? 0 if (goal instanceof Vector2) { if (entity.distanceTo(goal) < checkpointSize) { if (routeIndex + 1 < route.length) { @@ -54,20 +54,15 @@ export default class Template { } } - if ((entity.memory.incrementalPathingTimeout ?? -Infinity) < currentTick) { + if ((entity.destination?.distanceTo(entity.position) ?? 0) < recalculateDestRadius) { const distanceToGoal = entity.distanceTo(goal) - if (distanceToGoal > entity.memory.distanceToGoal ?? -Infinity) { - entity.memory.incrementalPathingTimeout = currentTick + (1 * (entity.game.tickRate ?? 1)) - } - else if (distanceToGoal > incrementalPathing) { - const direction = goal.clone().sub(entity.position).normalize().multiplyScalar(incrementalPathing) + if (distanceToGoal > maxDestDistance) { + const direction = goal.clone().sub(entity.position).normalize().multiplyScalar(maxDestDistance) goal.copy(entity.position.clone().add(direction)) } - entity.memory.distanceToGoal = distanceToGoal + entity.attackAction(goal) } - - entity.attackAction(goal) } if (entity.position.equals(route.at(-1))) { diff --git a/src/terrain.js b/src/terrain.js index af97d0b..bcc109a 100644 --- a/src/terrain.js +++ b/src/terrain.js @@ -8,7 +8,6 @@ export default class Terrain { relativeVertices = [] #colliders = [] - #hull = null #vertices = [] #unadjustedWaypoints = [] @@ -24,7 +23,6 @@ export default class Terrain { this.#calculateUnadjustedWaypoints() } - get colliders() { return this.#colliders } get unadjustedWaypoints() { return this.#unadjustedWaypoints } get vertices() { return this.#vertices } @@ -45,6 +43,8 @@ export default class Terrain { ] } + colliders() { return this.#colliders } + state() { return { ...this,