use obstacle-in-path pathfinding

This commit is contained in:
2025-01-17 13:01:47 +09:00
parent 597aa204de
commit 20f8a2f1fe
7 changed files with 138 additions and 70 deletions
+68 -32
View File
@@ -25,7 +25,6 @@ export default class Entity {
#logic = null #logic = null
#moving = false #moving = false
#path = [] #path = []
#scheduledPathfinding = null
#spawnPosition = new Vector2() #spawnPosition = new Vector2()
static collider(x, y, radius) { static collider(x, y, radius) {
@@ -45,7 +44,6 @@ export default class Entity {
get destination() { return this.#dest } get destination() { return this.#dest }
get logic() { return this.#logic } get logic() { return this.#logic }
get game() { return this.#game } get game() { return this.#game }
get scheduledPathfinding() { return this.#scheduledPathfinding }
get spawnPosition() { return this.#spawnPosition } get spawnPosition() { return this.#spawnPosition }
get x() { return this.position.x } get x() { return this.position.x }
get y() { return this.position.y } get y() { return this.position.y }
@@ -53,19 +51,10 @@ export default class Entity {
set destination(value) { this.#dest = value } set destination(value) { this.#dest = value }
set logic(value) { this.#logic = value } set logic(value) { this.#logic = value }
set game(value) { this.#game = value } set game(value) { this.#game = value }
set scheduledPathfinding(value) { this.#scheduledPathfinding = value }
set spawnPosition(value) { this.#spawnPosition = value } set spawnPosition(value) { this.#spawnPosition = value }
set x(value) { this.position.x = value } set x(value) { this.position.x = value }
set y(value) { this.position.y = 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() { get unadjustedWaypoints() {
const numberOfWaypoints = 8 const numberOfWaypoints = 8
const margin = 1 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) { attackAction(cursor) {
this.moveAction(cursor, true) this.moveAction(cursor, true)
} }
@@ -141,12 +139,20 @@ export default class Entity {
// --- Actions above --- // // --- Actions above --- //
collidables() { collidables() {
const entityColliders = (this.game?.entities ?? []).filter((e) => e.id != this.id).map((e) => e.collider) 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 terrainColliders = (this.game?.terrains ?? []).map((t) => t.colliders()).flat()
return entityColliders.concat(terrainColliders) return entityColliders.concat(terrainColliders)
} }
collider() {
return new SAT.Circle(new SAT.Vector(this.x, this.y), this.radius)
}
colliders() {
return [this.collider()]
}
cooldown(id) { cooldown(id) {
this.cooldowns[id] = this.game?.currentTick ?? 0 this.cooldowns[id] = this.game?.currentTick ?? 0
} }
@@ -180,7 +186,7 @@ export default class Entity {
} }
isColliding(...colliders) { isColliding(...colliders) {
return SATX.collideObjects(this.collider, colliders) return SATX.collideObjects(this.collider(), colliders)
} }
respawn() { respawn() {
@@ -221,14 +227,7 @@ export default class Entity {
const terrainColliders = (this.game?.terrains ?? []) const terrainColliders = (this.game?.terrains ?? [])
const unadjustedWaypoints = entityColliders.concat(terrainColliders).map((e) => e.unadjustedWaypoints).flat() const unadjustedWaypoints = entityColliders.concat(terrainColliders).map((e) => e.unadjustedWaypoints).flat()
return unadjustedWaypoints.map(([waypoint, direction]) => { return unadjustedWaypoints.map(([waypoint, direction]) => this.adjustWaypoint(waypoint, direction)) ?? []
return SATX.clamp(
waypoint.clone().add(direction.clone().multiplyScalar(this.radius)),
this.game?.width,
this.game?.height,
this.radius,
)
}) ?? []
} }
#cast() { #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) { #move(distanceTraveled = 0) {
if (this.casting != null) { return false } if (this.casting != null) { return false }
@@ -278,28 +276,66 @@ export default class Entity {
const collidables = this.collidables() const collidables = this.collidables()
const fixedDest = SATX.clamp(SATX.fixCollisions(this.#dest, collidables, this.radius), this.game?.width, this.game?.height, this.radius) 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 (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 = [] this.#path = []
} }
} }
if (this.#path.length < 1 || !this.#path.at(-1).equals(fixedDest)) { 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] 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 start = SATX.vectorToFloat32Array(this.position)
const goal = SATX.vectorToFloat32Array(fixedDest) const goal = SATX.vectorToFloat32Array(fixedDest)
const nonUniqueWaypoints = this.waypoints().map((w) => SATX.vectorToFloat32Array(w)).concat([start, goal]) const obstacles = []
const waypoints = Pathfind.uniqueWaypoints(nonUniqueWaypoints)
const graph = Pathfind.buildGraph(waypoints, collidables, this.radius) for (let failsafe = 0; failsafe < 1000; failsafe++) {
this.#path = Pathfind.shortestPath(graph, start, goal).map((waypoint) => new Vector2(waypoint[0], waypoint[1])) 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) { if (this.#path.length > 0) {
+44 -21
View File
@@ -4,18 +4,22 @@ import Projectile from './projectile.js'
import Terrain from './terrain.js' import Terrain from './terrain.js'
export default class Game { export default class Game {
tickRate = 30 averageTick = 0
currentTick = 0 currentTick = 0
width = 2000
height = 2000 height = 2000
nextTickAt = 0 secondToSlowestTick = 0
tickRate = 30
width = 2000
#logic = null #currentTiming = 0
#entities = [] #entities = []
#eventEmitter = new EventEmitter() #eventEmitter = new EventEmitter()
#logic = null
#nextTickAt = 0
#projectiles = [] #projectiles = []
#terrains = [] #terrains = []
#tickBudget = 1000 / this.tickRate #tickBudget = 1000 / this.tickRate
#timings = new Float32Array(this.tickRate)
get logic() { return this.#logic } get logic() { return this.#logic }
get entities() { return this.#entities } 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() { start() {
setInterval(() => this.gameLoop(), 1) setInterval(() => this.#gameLoop(), 1)
} }
update() { update() {
@@ -110,7 +99,41 @@ export default class Game {
this.#logic() this.#logic()
} }
this.currentTick++ this.#calculateTickMetrics()
this.eventEmitter.emit('tick') 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)`)
}
}
} }
} }
+10
View File
@@ -118,6 +118,16 @@ function laneScenario() {
} }
} }
game.logic = gameLogic 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, () => { app.listen(port, () => {
+1 -1
View File
@@ -33,7 +33,7 @@ export default class Projectile {
(this.game?.entities ?? []).filter((e) => e.id != this.id).forEach((e) => { (this.game?.entities ?? []).filter((e) => e.id != this.id).forEach((e) => {
if (e.id == this.owner?.id) { return } 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) this.onCollide(this, e)
} }
}) })
+6 -2
View File
@@ -41,8 +41,12 @@ export default class SATX {
return false return false
} }
static collideObjects(collider1, colliders) { static collideObjects(collider, colliders) {
return colliders.some((c) => this.collideObject(collider1, c)) 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) { static enclosingRegularPolygonRadius(numberOfVertices) {
+7 -12
View File
@@ -33,11 +33,12 @@ export default class Template {
} }
} }
// TODO: fix disabled incremental pathing causes lag spikes
// TODO: minion aggro // TODO: minion aggro
// TODO: incremental pathfinding stuck in thicker than recalculateDestRadius walls
static #minionLogic(route = []) { static #minionLogic(route = []) {
const checkpointSize = 300 const checkpointSize = 300
const incrementalPathing = 100 const maxDestDistance = 100
const recalculateDestRadius = 50
return function builtMinionLogic() { return function builtMinionLogic() {
const entity = this const entity = this
@@ -46,7 +47,6 @@ export default class Template {
if (route.length > 0) { if (route.length > 0) {
const routeIndex = entity.memory.routeCheckpoint ?? 0 const routeIndex = entity.memory.routeCheckpoint ?? 0
const goal = route[routeIndex].clone() const goal = route[routeIndex].clone()
const currentTick = entity.game?.currentTick ?? 0
if (goal instanceof Vector2) { if (goal instanceof Vector2) {
if (entity.distanceTo(goal) < checkpointSize) { if (entity.distanceTo(goal) < checkpointSize) {
if (routeIndex + 1 < route.length) { if (routeIndex + 1 < route.length) {
@@ -54,21 +54,16 @@ export default class Template {
} }
} }
if ((entity.memory.incrementalPathingTimeout ?? -Infinity) < currentTick) { if ((entity.destination?.distanceTo(entity.position) ?? 0) < recalculateDestRadius) {
const distanceToGoal = entity.distanceTo(goal) const distanceToGoal = entity.distanceTo(goal)
if (distanceToGoal > entity.memory.distanceToGoal ?? -Infinity) { if (distanceToGoal > maxDestDistance) {
entity.memory.incrementalPathingTimeout = currentTick + (1 * (entity.game.tickRate ?? 1)) const direction = goal.clone().sub(entity.position).normalize().multiplyScalar(maxDestDistance)
}
else if (distanceToGoal > incrementalPathing) {
const direction = goal.clone().sub(entity.position).normalize().multiplyScalar(incrementalPathing)
goal.copy(entity.position.clone().add(direction)) goal.copy(entity.position.clone().add(direction))
} }
entity.memory.distanceToGoal = distanceToGoal
}
entity.attackAction(goal) entity.attackAction(goal)
} }
}
if (entity.position.equals(route.at(-1))) { if (entity.position.equals(route.at(-1))) {
entity.despawn() entity.despawn()
+2 -2
View File
@@ -8,7 +8,6 @@ export default class Terrain {
relativeVertices = [] relativeVertices = []
#colliders = [] #colliders = []
#hull = null
#vertices = [] #vertices = []
#unadjustedWaypoints = [] #unadjustedWaypoints = []
@@ -24,7 +23,6 @@ export default class Terrain {
this.#calculateUnadjustedWaypoints() this.#calculateUnadjustedWaypoints()
} }
get colliders() { return this.#colliders }
get unadjustedWaypoints() { return this.#unadjustedWaypoints } get unadjustedWaypoints() { return this.#unadjustedWaypoints }
get vertices() { return this.#vertices } get vertices() { return this.#vertices }
@@ -45,6 +43,8 @@ export default class Terrain {
] ]
} }
colliders() { return this.#colliders }
state() { state() {
return { return {
...this, ...this,