From fa2dbb5237ad40ac5dd2751f89e7adfe754df0bf Mon Sep 17 00:00:00 2001 From: Thayol Date: Tue, 21 Jan 2025 23:57:45 +0900 Subject: [PATCH] add bbox checks to pathfinding graphs --- src/entity.js | 98 +++++++++++++++++++++++++++++++++++++------------ src/index.js | 5 ++- src/level.js | 72 +++++++++++++++++++++--------------- src/pathfind.js | 48 ++++++++++++++++++------ src/template.js | 29 ++++++++++----- 5 files changed, 176 insertions(+), 76 deletions(-) diff --git a/src/entity.js b/src/entity.js index 66ed4b1..2327fc6 100644 --- a/src/entity.js +++ b/src/entity.js @@ -25,6 +25,7 @@ export default class Entity { maxHealth = 1 memory = {} pathfindingCooldown = 0 + pathfindingObstacleLimit = null position = null radius = 0 rotation = 0 @@ -266,16 +267,26 @@ export default class Entity { } closestTargetTo(cursor, range) { - const visibleEntityIds = this.visibleEntities() const entities = this.game?.entities if (entities == null) { return } + const targetsInRange = entities.filter((it) => this.team != it.team && it.distanceTo(cursor) <= range + this.radius + it.radius) + if (targetsInRange.length < 1) { return } - return entities - .filter((it) => visibleEntityIds.includes(it.id) && this.team != it.team && it.distanceTo(cursor) <= range + this.radius + it.radius) - .reduce((e1, e2) => (e1?.distanceTo(cursor) ?? Infinity) < e2.distanceTo(cursor) ? e1 : e2, null) + const absoluteClosestTarget = targetsInRange.reduce((e1, e2) => (e1?.distanceTo(cursor) ?? Infinity) < e2.distanceTo(cursor) ? e1 : e2, null) + const entityIdsInDirectVision = this.entitiesInVision() + if (entityIdsInDirectVision.includes(absoluteClosestTarget.id)) { + return absoluteClosestTarget + } + + const targetsInDirectVision = targetsInRange.filter((it) => entityIdsInDirectVision.includes(it.id)) + if (targetsInDirectVision.length < 1) { return } + + const visibleEntityIds = this.visibleEntities() + const visibleEntitiesInRange = targetsInRange.it((it) => visibleEntityIds.includes(it.id)) + + return visibleEntitiesInRange.filter((it) => visibleEntityIds.includes(it.id) && this.team != it.team && it.distanceTo(cursor) <= range + this.radius + it.radius) } - // TODO: collision and ghosting checks are duplicated customBboxCollidables(bbox) { const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? []) return entitiesAndTerrains.filter((it) => it.collision && !(this.ghosting && it.ghostable) && SATX.bboxCheck(bbox, it.bbox)) @@ -339,6 +350,7 @@ export default class Entity { this.health = Math.min(Math.max(0, this.health + amount), this.maxHealth) } + // TODO: something is pushing ghostables out of ghosted entities' way fixPosition() { const fixedPosition = this.fixFuturePosition(this.position) if (this.position.equals(fixedPosition)) { return } @@ -347,17 +359,17 @@ export default class Entity { } fixFuturePosition(futurePosition) { + const maxX = this.game?.width ?? Infinity + const maxY = this.game?.height ?? Infinity + const radius = this.radius if (!this.willCollide(futurePosition)) { - return futurePosition + return SATX.clamp(futurePosition, maxX, maxY, radius) } let direction = new Vector2(0, 5) let multiplier = 1 const rotationSlices = 16 const origin = new Vector2() - const maxX = this.game?.width ?? Infinity - const maxY = this.game?.height ?? Infinity - const radius = this.radius for (let limit = 1; limit <= 10000; limit++) { const rads = (limit % rotationSlices) * 2 * Math.PI / rotationSlices @@ -572,12 +584,19 @@ export default class Entity { const fixedDest = this.fixFuturePosition(this.#dest) const pathfinding = this.#noPathfindingUntil <= currentTick + const obstacles = new Map() + let pathGotObstructed = false if (pathfinding && this.#path.length > 0) { const sectionDest = this.#path.at(0) - const lineOfSight = this.isInLineOfSight(sectionDest) - if (!lineOfSight) { - this.#path = [] + const sectionObstacles = this.obstaclesInStraightPath(sectionDest) + if (sectionObstacles.length > 0) { + pathGotObstructed = true + for (const obstacle of sectionObstacles) { + if (!obstacles.has(obstacle.id)) { + obstacles.set(obstacle.id, obstacle) + } + } } } @@ -588,40 +607,71 @@ export default class Entity { } } - if (pathfinding && (this.#path.length < 1 || (this.#path.at(-1)?.distanceTo(fixedDest) ?? 0) > 0.01)) { + if (pathfinding && (pathGotObstructed || 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 obstacles = new Map() // TODO: limit number of obstacles for non-important entities (property on the class?) const obstacleWaypoints = new Map() const obstacleColliders = new Map() + const obstacleBboxes = new Map() - // TODO: pathfinding takes longer after bbox check implementation (maybe separate obstacleColliders into two, and index match) - for (let failsafe = 0; failsafe < 1000; failsafe++) { + const initialObstaclesMargin = this.radius + 20 + const initialObstacles = this.customBboxCollidables(new Float32Array([ + this.position.y + initialObstaclesMargin, + this.position.x + initialObstaclesMargin, + this.position.y - initialObstaclesMargin, + this.position.x - initialObstaclesMargin, + ])) + + for (const obstacle of initialObstacles) { + if (!obstacles.has(obstacle.id)) { + obstacles.set(obstacle.id, obstacle) + } + } + + for (let failsafe = 0; failsafe <= (this.pathfindingObstacleLimit ?? 1000); failsafe++) { + if (failsafe >= 10) { console.error('Failsafe is reached!!!'); process.exit(0) } const obstaclesArray = Array.from(obstacles.values()) for (const obstacle of obstaclesArray) { if (!obstacleWaypoints.has(obstacle.id)) { const waypoint = obstacle.unadjustedWaypoints().map(([w, d]) => SATX.vectorToFloat32Array(this.adjustWaypoint(w, d))) - obstacleWaypoints.set(obstacle.id, waypoint) - } - if (!obstacleColliders.has(obstacle.id)) { const bbox = obstacle.bbox const colliders = obstacle.colliders() - obstacleColliders.set(obstacle.id, [bbox, colliders]) + obstacleWaypoints.set(obstacle.id, waypoint) + obstacleColliders.set(obstacle.id, colliders) + obstacleBboxes.set(obstacle.id, bbox) } } const waypoints = [ start, goal, - ...obstaclesArray.map((it) => obstacleWaypoints.get(it.id)).flat() + ...Array.from(obstacleWaypoints.values()).flat() ] + const bboxesSize = obstacleBboxes.size * 5 + const bboxes = new Float32Array(bboxesSize) + let i = 0 + for (const obstacle of obstacleBboxes.values()) { + bboxes[i] = obstacle[0] + bboxes[i + 1] = obstacle[1] + bboxes[i + 2] = obstacle[2] + bboxes[i + 3] = obstacle[3] + bboxes[i + 4] = Math.floor(i / 5) + i += 5 + } + const colliders = Array.from(obstacleColliders.values()) - const graph = Pathfind.buildGraph(waypoints, colliders, this.radius) + const graph = Pathfind.buildGraph(waypoints, bboxes, 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 + if (path.length == 0) { + // WARNING: This unsets the destination because if an unreachable spot is clicked, + // pathfinding cycles all obstacles forever. A possible alternative could + // be setting a pathfinding timeout, but then moveAction must reset that! + this.#dest = null + break + } let obstacleInPath = false let lastSection = this.position @@ -639,8 +689,8 @@ export default class Entity { lastSection = section } + this.#path = path if (!obstacleInPath) { - this.#path = path break } } diff --git a/src/index.js b/src/index.js index 6262f87..42fe469 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,7 @@ import { WebSocketExpress } from 'websocket-express' import express from 'express' import Game from './game.js' -import { Dungeon } from './level.js' +import { Dungeon, Ravine } from './level.js' const app = new WebSocketExpress() const port = 1280 @@ -39,5 +39,6 @@ app.ws('/ws', async (req, res) => { app.listen(port, () => { console.info(`Server started! Visit http://localhost:${port}`) - Dungeon.scenario(game) + // Dungeon.scenario(game) + Ravine.scenario(game) }) diff --git a/src/level.js b/src/level.js index 63d2c81..8b7ca75 100644 --- a/src/level.js +++ b/src/level.js @@ -1,5 +1,4 @@ import { Vector2 } from 'three' -import Ability from './ability.js' import Entity from './entity.js' import Team from './team.js' import Template from './template.js' @@ -10,36 +9,51 @@ export class Dungeon { game.width = 3000 game.height = 3000 - const playerSpawn = new Vector2(game.width / 2, game.height / 2) - game.spawnEntity(new Entity(Template.player({ id: '1', spawnPosition: playerSpawn, position: new Vector2(playerSpawn.x - 1300, playerSpawn.y - 500), team: Team.blue }))) - const dummyLogic = function dummyLogic() { - const entity = this - if (entity.position.x > 1250) { - entity.moveAction(new Vector2(500, entity.position.y)) - } - else if (entity.position.x < 550 || entity.destination == null) { - entity.moveAction(new Vector2(1300, entity.position.y)) - } + // const playerSpawn = new Vector2(game.width / 2, game.height / 2) + // game.spawnEntity(new Entity(Template.player({ id: '1', spawnPosition: playerSpawn, position: new Vector2(playerSpawn.x - 1300, playerSpawn.y - 500), team: Team.blue }))) + // const dummyLogic = function dummyLogic() { + // const entity = this + // if (entity.position.x > 1250) { + // entity.moveAction(new Vector2(500, entity.position.y)) + // } + // else if (entity.position.x < 550 || entity.destination == null) { + // entity.moveAction(new Vector2(1300, entity.position.y)) + // } - if (game.currentTick > 0 && game.currentTick % (6 * game.tickRate) == 0) { - entity.castAction('q', playerSpawn) - } + // if (game.currentTick > 0 && game.currentTick % (6 * game.tickRate) == 0) { + // entity.castAction('q', playerSpawn) + // } + // } + // const dummy = { radius: 100, visualRadius: 50, abilities: { q: Ability.straightShot.id }, logic: dummyLogic } + // game.spawnEntity(new Entity({ ...dummy, position: new Vector2(1 * (game.width / 5), 1 * (game.height / 4)) })) + // game.spawnEntity(new Entity({ ...dummy, position: new Vector2(1 * (game.width / 5), 3 * (game.height / 4)) })) + // game.addTerrain(new Terrain([ + // new Vector2(3.5 * (game.width / 10), 1.6 * (game.height / 5)), + // new Vector2(3.5 * (game.width / 10), 1.4 * (game.height / 5)), + // new Vector2(4 * (game.width / 10), 1.4 * (game.height / 5)), + // new Vector2(4 * (game.width / 10), 1.6 * (game.height / 5)), + // ])) + // game.addTerrain(new Terrain([ + // new Vector2(3 * (game.width / 10), 2 * (game.height / 5)), + // new Vector2(3 * (game.width / 10), 1 * (game.height / 5)), + // new Vector2(4 * (game.width / 10), 1 * (game.height / 5)), + // new Vector2(4 * (game.width / 10), 2 * (game.height / 5)), + // ], false)) + + const from = new Vector2(100, 100) + game.height = 2000 + game.spawnEntity(new Entity(Template.player({ id: '1', spawnPosition: from, pathfindingObstacleLimit: 1, pathfindingCooldown: 0 }))) + for (let i = 100; i < game.width; i += 300) { + const highest = ((i - 100) % 600) == 0 ? 0 : 500 + const lowest = ((i - 100) % 600) == 0 ? 1500 : 2000 + game.addTerrain(new Terrain([ + new Vector2(i + 100, game.height - highest), + new Vector2(i, game.height - highest), + new Vector2(i, game.height - lowest), + new Vector2(i + 100, game.height - lowest), + ])) } - const dummy = { radius: 100, visualRadius: 50, abilities: { q: Ability.straightShot.id }, logic: dummyLogic } - game.spawnEntity(new Entity({ ...dummy, position: new Vector2(1 * (game.width / 5), 1 * (game.height / 4)) })) - game.spawnEntity(new Entity({ ...dummy, position: new Vector2(1 * (game.width / 5), 3 * (game.height / 4)) })) - game.addTerrain(new Terrain([ - new Vector2(3.5 * (game.width / 10), 1.6 * (game.height / 5)), - new Vector2(3.5 * (game.width / 10), 1.4 * (game.height / 5)), - new Vector2(4 * (game.width / 10), 1.4 * (game.height / 5)), - new Vector2(4 * (game.width / 10), 1.6 * (game.height / 5)), - ])) - game.addTerrain(new Terrain([ - new Vector2(3 * (game.width / 10), 2 * (game.height / 5)), - new Vector2(3 * (game.width / 10), 1 * (game.height / 5)), - new Vector2(4 * (game.width / 10), 1 * (game.height / 5)), - new Vector2(4 * (game.width / 10), 2 * (game.height / 5)), - ], false)) + game.start() } diff --git a/src/pathfind.js b/src/pathfind.js index 1902ff9..76b8fb5 100644 --- a/src/pathfind.js +++ b/src/pathfind.js @@ -77,17 +77,26 @@ export default class Pathfind { return [] } - static buildGraph(waypoints = [], colliders = [], radius = 0, mergeNodes = true) { + static buildGraph(waypoints, bboxes, obstacles, radius) { const filteredWaypoints = [] const checked = new Set() if (radius > 0) { for (const waypoint of waypoints) { - const bbox = Entity.bbox(waypoint[0], waypoint[1], radius) // TODO: duplicate bbox calculation logic for speed - const bboxCheckedObstacles = colliders.filter((it) => SATX.bboxCheck(bbox, it[0])) + const bbox = Entity.bbox(waypoint[0], waypoint[1], radius) + const bboxCheckedObstacles = [] + for (let i = 0; i < bboxes.length; i += 5) { + if (bbox[0] <= bboxes[i + 2]) { continue } + if (bbox[1] <= bboxes[i + 3]) { continue } + if (bbox[2] >= bboxes[i]) { continue } + if (bbox[3] >= bboxes[i + 1]) { continue } + + bboxCheckedObstacles.push(obstacles[bboxes[i + 4]]) + } + if (bboxCheckedObstacles.length > 0) { const collider = Entity.collider(waypoint[0], waypoint[1], radius) - const colliding = bboxCheckedObstacles.some((it) => it[1].some((c) => SATX.collideObject(collider, c))) + const colliding = bboxCheckedObstacles.flat().some((it) => SATX.collideObject(collider, it)) if (colliding) { continue } @@ -111,7 +120,7 @@ export default class Pathfind { if (i == j) { continue } - + if (Math.abs(mergedWaypoints[i] - mergedWaypoints[j]) < Pathfind.precision && Math.abs(mergedWaypoints[i + 1] - mergedWaypoints[j + 1]) < Pathfind.precision) { continue } @@ -124,11 +133,21 @@ export default class Pathfind { checked.add(key) checked.add(Pathfind.floatKey4(mergedWaypoints[j], mergedWaypoints[j + 1], mergedWaypoints[i], mergedWaypoints[i + 1])) - const bbox = Entity.tunnelBbox(mergedWaypoints[i], mergedWaypoints[i + 1], mergedWaypoints[j], mergedWaypoints[j + 1], radius) // TODO: duplicate bbox calculation logic for speed - const bboxCheckedObstacles = colliders.filter((it) => SATX.bboxCheck(bbox, it[0])) + const bbox = Entity.tunnelBbox(mergedWaypoints[i], mergedWaypoints[i + 1], mergedWaypoints[j], mergedWaypoints[j + 1], radius) + + const bboxCheckedObstacles = [] + for (let i = 0; i < bboxes.length; i += 5) { + if (bbox[0] <= bboxes[i + 2]) { continue } + if (bbox[1] <= bboxes[i + 3]) { continue } + if (bbox[2] >= bboxes[i]) { continue } + if (bbox[3] >= bboxes[i + 1]) { continue } + + bboxCheckedObstacles.push(obstacles[bboxes[i + 4]]) + } + if (bboxCheckedObstacles.length > 0) { const tunnel = Entity.tunnelCollider(mergedWaypoints[i], mergedWaypoints[i + 1], mergedWaypoints[j], mergedWaypoints[j + 1], radius) - const colliding = bboxCheckedObstacles.some((it) => it[1].some((c) => SATX.collideObject(tunnel, c))) + const colliding = bboxCheckedObstacles.some((it) => it.some((c) => SATX.collideObject(tunnel, c))) if (colliding) { continue } @@ -152,10 +171,6 @@ export default class Pathfind { } } - if (!mergeNodes) { - return nodes - } - const graph = new Float32Array(nodes.length * 5) let graphIndex = 0 for (const node of nodes) { @@ -167,6 +182,15 @@ export default class Pathfind { graphIndex += 5 } + // const niceGraph = [] + // for (let i = 0; i < graph.length / 5; i += 5) { + // niceGraph.push({ + // from: [graph[i], graph[i + 1]], + // to: [graph[i + 2], graph[i + 3]], + // distance: graph[i + 4], + // }) + // } + // console.log(niceGraph) return graph } diff --git a/src/template.js b/src/template.js index 12ac6b5..d7b2ff7 100644 --- a/src/template.js +++ b/src/template.js @@ -1,14 +1,16 @@ import { Vector2 } from 'three' import Ability from './ability.js' +import Team from './team.js' export default class Template { static minion(team, options = {}) { return { abilities: { a: options.ranged ? Ability.rangedAttack.id : Ability.meleeAttack.id }, height: options.ranged ? 40 : 38, - logic: this.#minionLogic(options.route), + logic: this.#minionLogic(options.route, (team != Team.blue)), maxHealth: options.ranged ? 300 : 450, pathfindingCooldown: 0.2, + pathfindingObstacleLimit: 0, position: options.route?.at(0) ?? options.position ?? new Vector2(0, 0), radius: 48, speed: 325, @@ -29,15 +31,16 @@ export default class Template { height: 80, logic: this.#playerLogic, maxHealth: 600, - spawnPosition: new Vector2(500, 150), + pathfindingObstacleLimit: 3, radius: 65, + spawnPosition: new Vector2(500, 150), visionRange: 1350, visualRadius: 40, ...overrides, } } - static #minionLogic(route = []) { + static #minionLogic(route = [], odd = false) { const checkpointSize = 300 const maxDestDistance = 100 const recalculateDestRadius = 50 @@ -47,8 +50,15 @@ export default class Template { const entity = this if (entity.dead) { entity.despawn() } + const currentTick = entity.game?.currentTick ?? 0 + const minionResponseTime = Math.floor(0.1 * (entity.game?.tickRate ?? 1)) + if (!(currentTick % minionResponseTime == 0 && Math.floor(currentTick / minionResponseTime) % 2 == (odd ? 1 : 0))) { + return + } + const target = entity.closestTargetTo(entity.position, aggroRadius) if (target != null) { + entity.ghosting = false entity.attackAction(target.position) } @@ -63,13 +73,14 @@ export default class Template { } if ((entity.destination?.distanceTo(entity.position) ?? 0) < recalculateDestRadius) { - const distanceToGoal = entity.distanceTo(goal) - if (distanceToGoal > maxDestDistance) { - const direction = goal.clone().sub(entity.position).normalize().multiplyScalar(maxDestDistance) - goal.copy(entity.position.clone().add(direction)) - } + // const distanceToGoal = entity.distanceTo(goal) + // if (distanceToGoal > maxDestDistance) { + // const direction = goal.clone().sub(entity.position).normalize().multiplyScalar(maxDestDistance) + // goal.copy(entity.position.clone().add(direction)) + // } - entity.attackAction(goal) + entity.ghosting = true + entity.moveAction(goal) } }