diff --git a/public/client.js b/public/client.js index 9deab32..eea3868 100644 --- a/public/client.js +++ b/public/client.js @@ -21,9 +21,9 @@ camera.updateProjectionMatrix() camera.layers.enable(1) camera.layers.enable(2) -// const entityMaterial = new THREE.MeshToonMaterial({ color: 0xffffff }) const projectileMaterial = new THREE.MeshToonMaterial({ color: 0xcccccc }) -const terrainMaterial = new THREE.MeshToonMaterial({ color: 0xffd700 }) +const terrainMaterial = new THREE.MeshToonMaterial({ color: 0x5c4033 }) +const bboxMaterial = new THREE.MeshToonMaterial({ color: 0xffd700, transparent: true, opacity: 0.2 }) const opacity = 0.3 const teamMaterials = { blue: new THREE.MeshToonMaterial({ color: 0x4444ff }), @@ -328,7 +328,7 @@ function connectWebSocket() { rangeMarker.scale.y = e.height / rangeMarkerSize rangeMarker.position.y = (e.height / (rangeMarkerSize * 2)) - (e.height / 100) rangeMarker.layers.set(1) - buffMarker.visible = false + rangeMarker.visible = false entity.add(rangeMarker) entities[e.id] = entity @@ -345,7 +345,7 @@ function connectWebSocket() { hp.scale.x = percentageHp hp.position.x = -(1 - percentageHp) / 2 - entity.children.at(4).visible = e.id == playerId + entity.children.at(4).visible = e.id == playerId // TODO: undo, just for clarity entity.children.at(3).children.at(0).visible = e.casting != null } @@ -414,6 +414,17 @@ function connectWebSocket() { terrain.userData.id = t.id scene.add(terrain) terrains[t.id] = terrain + + // TODO: bboxes aren't tracked and can leak memory + const bboxValues = Object.values(t.bbox) + if (bboxValues.length >= 4) { + const width = (bboxValues[1] - bboxValues[3]) / 100 + const height = (bboxValues[0] - bboxValues[2]) / 100 + + const bbox = new THREE.Mesh(new THREE.BoxGeometry(width, height, 0.2), bboxMaterial) + bbox.position.set((bboxValues[3] / 100) + (width / 2), (bboxValues[2] / 100) + (height / 2), 0) + scene.add(bbox) + } } terrain.position.set(t.position.x / 100, t.position.y / 100, 0) diff --git a/public/index.html b/public/index.html index 3b47c5a..12d3a6b 100644 --- a/public/index.html +++ b/public/index.html @@ -168,7 +168,6 @@
-Connection:
diff --git a/src/entity.js b/src/entity.js index b28cc77..b4fb90b 100644 --- a/src/entity.js +++ b/src/entity.js @@ -8,6 +8,7 @@ import Buff from './buff.js' export default class Entity { id = crypto.randomUUID() abilities = {} + bbox = new Float32Array(4) buffs = [] casting = null cooldowns = {} @@ -35,6 +36,82 @@ export default class Entity { return new SAT.Circle(new SAT.Vector(x, y), radius) } + // deliberate code duplication for performance + static tunnelCollider(fromX, fromY, toX, toY, radius) { + if (radius <= 0) { + return SATX.line(fromX, fromY, toX, toY) + } + + const sides = new Float32Array(5) + sides[0] = toX - fromX + sides[1] = toY - fromY + sides[4] = Math.hypot(sides[0], sides[1]) + sides[2] = (sides[1] / sides[4]) * -radius // optimization: negation and swapping rotates + sides[3] = (sides[0] / sides[4]) * radius + + return new SAT.Polygon(new SAT.Vector(fromX - sides[2], fromY - sides[3]), [ + new SAT.Vector(), + new SAT.Vector(sides[0], sides[1]), + new SAT.Vector(sides[0] + (2 * sides[2]), sides[1] + (2 * sides[3])), + new SAT.Vector(2 * sides[2], 2 * sides[3]), + ]) + } + + // deliberate code duplication for performance + static tunnelVertices(fromX, fromY, toX, toY, radius) { + const sides = new Float32Array(5) + sides[0] = toX - fromX + sides[1] = toY - fromY + sides[4] = Math.hypot(sides[0], sides[1]) + sides[2] = (sides[1] / sides[4]) * -radius // optimization: negation and swapping rotates + sides[3] = (sides[0] / sides[4]) * radius + + return [ + new Vector2(fromX - sides[2], fromY - sides[3]), + new Vector2(fromX - sides[2] + sides[0], fromY - sides[3] + sides[1]), + new Vector2(fromX + sides[2] + sides[0], fromY + sides[3] + sides[1]), + new Vector2(fromX + sides[2], fromY + sides[3]), + ] + } + + // deliberate code duplication for performance + static tunnelBbox(fromX, fromY, toX, toY, radius) { + if (radius <= 0) { + return new Float32Array([ + Math.max(fromY, toY), + Math.max(fromX, toX), + Math.min(fromY, toY), + Math.min(fromX, toX), + ]) + } + + const sides = new Float32Array(5) + sides[0] = toX - fromX + sides[1] = toY - fromY + sides[4] = Math.hypot(sides[0], sides[1]) + sides[2] = (sides[1] / sides[4]) * -radius // optimization: negation and swapping rotates + sides[3] = (sides[0] / sides[4]) * radius + + const offsetX = fromX + sides[0] + const x1 = fromX - sides[2] + const x2 = fromX + sides[2] + const x3 = offsetX - sides[2] + const x4 = offsetX + sides[2] + + const offsetY = fromY + sides[1] + const y1 = fromY - sides[3] + const y2 = fromY + sides[3] + const y3 = offsetY - sides[3] + const y4 = offsetY + sides[3] + + return new Float32Array([ + Math.max(y1, y2, y3, y4), + Math.max(x1, x2, x3, x4), + Math.min(y1, y2, y3, y4), + Math.min(x1, x2, x3, x4), + ]) + } + constructor(options = {}) { Object.entries(options).forEach(([key, value]) => this[key] = value) if (this.position == null) { @@ -48,6 +125,7 @@ export default class Entity { } } + get attacking() { return this.#attacking } get destination() { return this.#dest } get logic() { return this.#logic } get game() { return this.#game } @@ -133,7 +211,7 @@ export default class Entity { this.#attacking = attack this.#moving = true - this.#dest = SATX.fixCollisions(cursor, this.collidables(), this.radius, this.game?.width, this.game?.height) + this.#dest = cursor.clone() } stopAction() { @@ -174,14 +252,11 @@ export default class Entity { } 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() - - return entityColliders.concat(terrainColliders) + return this.customBboxCollidables(this.bbox) } collider() { - return new SAT.Circle(new SAT.Vector(this.position.x, this.position.y), this.radius) + return Entity.collider(this.position.x, this.position.y, this.radius) } colliders() { @@ -221,6 +296,20 @@ export default class Entity { return this.position.distanceTo(cursor) } + futureCollidables(futurePosition) { + return this.customBboxCollidables(new Float32Array([ + futurePosition.y + this.radius, + futurePosition.x + this.radius, + futurePosition.y - this.radius, + futurePosition.x - this.radius, + ])) + } + + customBboxCollidables(bbox) { + const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? []) + return entitiesAndTerrains.filter((it) => SATX.bboxCheck(bbox, it.bbox)) + } + getBuff(id) { const entityBuff = this.buffs.find((it) => it.id == id) if (entityBuff == null) { return } @@ -240,11 +329,70 @@ export default class Entity { } fixPosition() { - this.position = SATX.fixCollisions(this.position, this.collidables(), this.radius, this.game?.width, this.game?.height).clone() + this.position = this.fixFuturePosition(this.position.clone()).clone() } - isColliding(...colliders) { - return SATX.collideObjects(this.collider(), colliders) + fixFuturePosition(futurePosition) { + if (!this.willCollide(futurePosition)) { + return futurePosition + } + + 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 + const offset = direction.clone().rotateAround(origin, rads).multiplyScalar(multiplier) + const position = SATX.clamp(futurePosition.clone().add(offset), maxX, maxY, radius) + if (!this.willCollide(position)) { + return position + } + + if (limit % rotationSlices == 0) { + multiplier++ + } + } + + console.error(`Can't fix position ([${futurePosition.x}, ${futurePosition.y}]) of entity ID: ${this.id}`) + } + + isColliding() { + const collidables = this.collidables() + if (collidables.length < 1) { + return false + } + + const colliders = collidables.map((it) => it.colliders()).flat() + const collider = this.collider() + + return colliders.some((it) => SATX.collideObject(collider, it)) + } + + obstaclesInStraightPath(destination, position = this.position) { + const bbox = Entity.tunnelBbox(position.x, position.y, destination.x, destination.y, this.radius) + const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? []) + const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => SATX.bboxCheck(bbox, it.bbox)) + if (bboxCheckedObstacles.length < 1) { return [] } + + const collider = Entity.tunnelCollider(position.x, position.y, destination.x, destination.y, this.radius) + return bboxCheckedObstacles.filter((obstacle) => obstacle.colliders().some((it) => SATX.collideObject(collider, it))) + } + + isInLineOfSight(destination, position = this.position) { + const bbox = Entity.tunnelBbox(position.x, position.y, destination.x, destination.y, this.radius) + const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? []) + const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => SATX.bboxCheck(bbox, it.bbox)) + if (bboxCheckedObstacles.length < 1) { return true } + + + const colliders = bboxCheckedObstacles.map((it) => it.colliders()).flat() + const collider = Entity.tunnelCollider(position.x, position.y, destination.x, destination.y, this.radius) + return !colliders.some((it) => SATX.collideObject(collider, it)) } removeBuff(id) { @@ -277,6 +425,8 @@ export default class Entity { if (this.#logic != null) { this.#logic() } + + this.#calculateBbox() } waypoints() { @@ -287,6 +437,25 @@ export default class Entity { return unadjustedWaypoints.map(([waypoint, direction]) => this.adjustWaypoint(waypoint, direction)) ?? [] } + willCollide(futurePosition) { + const collidables = this.futureCollidables(futurePosition) + if (collidables.length < 1) { + return false + } + + const colliders = collidables.map((it) => it.colliders()).flat() + const collider = Entity.collider(futurePosition.x, futurePosition.y, this.radius) + + return colliders.some((it) => SATX.collideObject(collider, it)) + } + + #calculateBbox() { + this.bbox[0] = this.position.y + this.radius + this.bbox[1] = this.position.x + this.radius + this.bbox[2] = this.position.y - this.radius + this.bbox[3] = this.position.x - this.radius + } + #cast() { if (this.casting == null) { return false @@ -333,22 +502,18 @@ export default class Entity { if (!this.#moving || this.#dest == null) { return false } - // TODO: bounding boxes to early discard a lot of 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 = this.fixFuturePosition(this.#dest) if (this.#path.length > 0) { 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) + const lineOfSight = this.isInLineOfSight(sectionDest) if (!lineOfSight) { this.#path = [] } } if (this.#path.length < 1 || !this.#path.at(-1).equals(fixedDest)) { - const tunnel = SATX.entityTunnel(this.position.x, this.position.y, fixedDest.x, fixedDest.y, this.radius) - const lineOfSight = !SATX.collideObjects(tunnel, collidables) + const lineOfSight = this.isInLineOfSight(fixedDest) if (lineOfSight) { this.#path = [fixedDest] } @@ -375,9 +540,7 @@ export default class Entity { 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) + const sectionObstacles = this.obstaclesInStraightPath(section, lastSection) if (sectionObstacles.length > 0) { obstacleInPath = true const obstacleIds = obstacles.map((o) => o.id) @@ -408,12 +571,9 @@ export default class Entity { const position = distance <= speed ? destination : stepTaken const rotation = direction.angle() - const collider = Entity.collider(position.x, position.y, this.radius) - const isColliding = SATX.collideObjects(collider, this.collidables()) - this.rotation = rotation - if (!isColliding) { + if (!this.willCollide(position)) { this.position.copy(position) } diff --git a/src/game.js b/src/game.js index adb3286..6227ab7 100644 --- a/src/game.js +++ b/src/game.js @@ -81,7 +81,7 @@ export default class Game { } start() { - setInterval(() => this.#gameLoop(), 1) + setInterval(this.#gameLoopCall.bind(this), 1) } update() { @@ -128,4 +128,8 @@ export default class Game { } } } + + #gameLoopCall() { + this.#gameLoop() + } } diff --git a/src/level.js b/src/level.js index fefe00f..fa498c6 100644 --- a/src/level.js +++ b/src/level.js @@ -42,7 +42,7 @@ export default class Map { // new Vector2(3234, 1378), // ], - // top-left wall + // top-left wall (bottom part) [ new Vector2(0, 10000), new Vector2(0, 820), @@ -69,6 +69,13 @@ export default class Map { new Vector2(660, 8968), new Vector2(705, 9049), new Vector2(771, 9127), + new Vector2(760, 9104), + ], + + // top-left wall (top part) + [ + new Vector2(0, 10000), + new Vector2(760, 9104), new Vector2(849, 9193), new Vector2(930, 9220), new Vector2(1008, 9238), @@ -94,8 +101,9 @@ export default class Map { new Vector2(9186, 10000), ], - // bottom-right wall + // bottom-right wall (right part) [ + new Vector2(10000, 0), new Vector2(10000, 9127), new Vector2(9678, 9004), new Vector2(9684, 7003), @@ -122,6 +130,13 @@ export default class Map { new Vector2(9357, 1093), new Vector2(9324, 1006), new Vector2(9288, 943), + new Vector2(9268, 904), + ], + + // bottom-right wall (bottom part) + [ + new Vector2(10000, 0), + new Vector2(9268, 904), new Vector2(9246, 883), new Vector2(9186, 835), new Vector2(9105, 796), diff --git a/src/pathfind.js b/src/pathfind.js index 9c3f861..93bc1a5 100644 --- a/src/pathfind.js +++ b/src/pathfind.js @@ -84,7 +84,7 @@ export default class Pathfind { if (radius > 0) { for (const waypoint of waypoints) { const collider = Entity.collider(waypoint[0], waypoint[1], radius) - const waypointAvailable = !SATX.collideObjects(collider, colliders) + const waypointAvailable = !colliders.some((it) => SATX.collideObject(collider, it)) if (waypointAvailable) { filteredWaypoints.push(waypoint) } @@ -115,9 +115,10 @@ export default class Pathfind { checked.add(key) checked.add(Pathfind.floatKey4(mergedWaypoints[j], mergedWaypoints[j + 1], mergedWaypoints[i], mergedWaypoints[i + 1])) - const tunnel = SATX.entityTunnel(mergedWaypoints[i], mergedWaypoints[i + 1], mergedWaypoints[j], mergedWaypoints[j + 1], radius) - - if (!SATX.collideObjects(tunnel, colliders)) { + // TODO: optimize tunnelCollider using bounding boxes + const tunnel = Entity.tunnelCollider(mergedWaypoints[i], mergedWaypoints[i + 1], mergedWaypoints[j], mergedWaypoints[j + 1], radius) + + if (!colliders.some((it) => SATX.collideObject(tunnel, it))) { const node = new Float32Array(5) node[0] = mergedWaypoints[i] node[1] = mergedWaypoints[i + 1] diff --git a/src/projectile.js b/src/projectile.js index 33273b6..191d877 100644 --- a/src/projectile.js +++ b/src/projectile.js @@ -1,10 +1,12 @@ import { Vector2 } from 'three' import SAT from 'sat' import SATX from './satx.js' +import Entity from './entity.js' export default class Projectile { id = crypto.randomUUID() after = null // TODO: hide from reports but keep public + bbox = new Float32Array(4) height = 50 memory = {} // TODO: hide from reports but keep public onCollide = null // TODO: hide from reports but keep public @@ -36,6 +38,7 @@ export default class Projectile { checkCollisions(collider) { (this.game?.entities ?? []).filter((e) => e.id != this.id).forEach((e) => { + if (this.game == null) { return } if (e.id == this.owner?.id) { return } if (SATX.collideObject(collider, e.collider())) { @@ -54,10 +57,18 @@ export default class Projectile { update() { this.#move() + this.#calculateBbox() if (this.onCollide != null) { this.checkCollisions(this.collider()) } this.#checkIfArrived() } + #calculateBbox() { + this.bbox[0] = this.position.y + this.radius + this.bbox[1] = this.position.x + this.radius + this.bbox[2] = this.position.y - this.radius + this.bbox[3] = this.position.x - this.radius + } + #checkIfArrived() { if (this.destination == null) { return } if (!this.position.equals(this.destination)) { return } @@ -82,7 +93,8 @@ export default class Projectile { this.position.add(step) } - const tunnel = SATX.entityTunnel(prevPos.x, prevPos.y, this.position.x, this.position.y, this.radius) + // TODO: decouple from entity's tunnel collider + const tunnel = Entity.tunnelCollider(prevPos.x, prevPos.y, this.position.x, this.position.y, this.radius) if (this.onCollide != null) { this.checkCollisions(tunnel) } } } diff --git a/src/satx.js b/src/satx.js index 0daea89..0d45141 100644 --- a/src/satx.js +++ b/src/satx.js @@ -1,8 +1,16 @@ import { Vector2 } from 'three' -import Entity from './entity.js' import SAT from 'sat' export default class SATX { + static bboxCheck(bbox1, bbox2) { + if (bbox1[0] <= bbox2[2]) { return false } + if (bbox1[1] <= bbox2[3]) { return false } + if (bbox1[2] >= bbox2[0]) { return false } + if (bbox1[3] >= bbox2[1]) { return false } + + return true + } + static clamp(vectorOrObject, maxX = Infinity, maxY = Infinity, radius = 0) { let modified = null if (vectorOrObject instanceof Vector2) { @@ -41,66 +49,10 @@ export default class SATX { return false } - 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) { return 1 / Math.cos(Math.PI / numberOfVertices) } - static entityTunnel(fromX, fromY, toX, toY, radius = 0) { - if (radius <= 0) { - return this.line(fromX, fromY, toX, toY) - } - - const sides = new Float32Array(5) - sides[0] = toX - fromX - sides[1] = toY - fromY - sides[4] = Math.hypot(sides[0], sides[1]) - sides[2] = (sides[1] / sides[4]) * -radius // optimization: negation and swapping rotates - sides[3] = (sides[0] / sides[4]) * radius - - return new SAT.Polygon(new SAT.Vector(fromX - sides[2], fromY - sides[3]), [ - new SAT.Vector(), - new SAT.Vector(sides[0], sides[1]), - new SAT.Vector(sides[0] + (2 * sides[2]), sides[1] + (2 * sides[3])), - new SAT.Vector(2 * sides[2], 2 * sides[3]), - ]) - } - - static fixCollisions(entityPosition, colliders, radius = 0, maxX = Infinity, maxY = Infinity) { - if (!this.collideObjects(Entity.collider(entityPosition.x, entityPosition.y, radius), colliders)) { - return entityPosition - } - // console.time('fixCollisions') - - let direction = new Vector2(0, 5) - let multiplier = 1 - const rotationSlices = 16 - - for (let limit = 1; limit <= 10000; limit++) { - const rads = (limit % rotationSlices) * 2 * Math.PI / rotationSlices - const offset = direction.clone().rotateAround(new Vector2(), rads).multiplyScalar(multiplier) - const position = SATX.clamp(entityPosition.clone().add(offset), maxX, maxY, radius) - if (!this.collideObjects(Entity.collider(position.x, position.y, radius), colliders)) { - // console.timeEnd('fixCollisions') - return position - } - - if (limit % rotationSlices == 0) { - multiplier++ - } - } - - // console.timeEnd('fixCollisions') - console.error('ERROR: can\'t fix collision') - } - static line(fromX, fromY, toX, toY) { return new SAT.Polygon(new SAT.Vector(fromX, fromY), [new SAT.Vector(), new SAT.Vector(toX - fromX, toY - fromY)]) } diff --git a/src/template.js b/src/template.js index 215adb6..ba344db 100644 --- a/src/template.js +++ b/src/template.js @@ -1,6 +1,5 @@ import { Vector2 } from 'three' import Ability from './ability.js' -import Team from './team.js' export default class Template { static minion(team, options = {}) { @@ -37,6 +36,7 @@ export default class Template { // TODO: minion aggro // TODO: incremental pathfinding stuck in thicker than recalculateDestRadius walls + // TODO: minions despawn prematurely (too large checkpointSize?) static #minionLogic(route = []) { const checkpointSize = 300 const maxDestDistance = 100 diff --git a/src/terrain.js b/src/terrain.js index 3b1ca89..09f30b7 100644 --- a/src/terrain.js +++ b/src/terrain.js @@ -4,6 +4,7 @@ import SAT from 'sat' export default class Terrain { id = crypto.randomUUID() + bbox = new Float32Array(4) position = new Vector2() relativeVertices = [] @@ -21,6 +22,7 @@ export default class Terrain { this.#calculatePosition() this.#calculateRelativeVertices() this.#calculateUnadjustedWaypoints() + this.#calculateBbox() } get unadjustedWaypoints() { return this.#unadjustedWaypoints } @@ -54,6 +56,31 @@ export default class Terrain { return complexShape } + #calculateBbox() { + const firstVertex = this.vertices.at(0) + if (firstVertex != null) { + this.bbox[0] = firstVertex.y + this.bbox[1] = firstVertex.x + this.bbox[2] = firstVertex.y + this.bbox[3] = firstVertex.x + } + + this.vertices.forEach((v) => { + if (v.y > this.bbox[0]) { + this.bbox[0] = v.y + } + if (v.x > this.bbox[1]) { + this.bbox[1] = v.x + } + if (v.y < this.bbox[2]) { + this.bbox[2] = v.y + } + if (v.x < this.bbox[3]) { + this.bbox[3] = v.x + } + }) + } + #calculateColliders() { const points = this.#shape().extractPoints(16)