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)