use obstacle-in-path pathfinding
This commit is contained in:
+68
-32
@@ -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
@@ -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)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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,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)
|
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))) {
|
||||||
|
|||||||
+2
-2
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user