import { Vector2 } from 'three' import Pathfind from './pathfind.js' import SAT from 'sat' import SATX from './satx.js' import Team from './team.js' import Buff from './buff.js' export default class Entity { id = crypto.randomUUID() abilities = {} bbox = new Float32Array(4) buffs = [] casting = null cooldowns = {} dead = false health = null height = 40 maxHealth = 1 memory = {} // TODO: hide from reports but keep public position = null radius = 0 rotation = 0 speed = 400 team = Team.neutral visualRadius = null #attacking = false #colliders = [] #dest = null #game = null #logic = null #moving = false #path = [] #spawnPosition = new Vector2() static collider(x, y, radius) { 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) { this.position = this.#spawnPosition.clone() } if (this.health == null) { this.health = this.maxHealth } if (this.visualRadius == null) { this.visualRadius = this.radius } this.#calculateCollider() } get attacking() { return this.#attacking } get destination() { return this.#dest } get logic() { return this.#logic } get game() { return this.#game } get spawnPosition() { return this.#spawnPosition } get x() { return this.position.x } get y() { return this.position.y } set destination(value) { this.#dest = value } set logic(value) { this.#logic = value } set game(value) { this.#game = value } set spawnPosition(value) { this.#spawnPosition = value } set x(value) { this.position.x = value } set y(value) { this.position.y = value } get unadjustedWaypoints() { const numberOfWaypoints = 8 const margin = 1 const enclosingRegularPolygonRadius = SATX.enclosingRegularPolygonRadius(numberOfWaypoints) const radius = this.radius * enclosingRegularPolygonRadius + margin const baseWaypoint = new Vector2(radius, 0) const waypoints = [] const origin = new Vector2 const unitOfRotation = (Math.PI * 2 / numberOfWaypoints) for (let i = 0; i < numberOfWaypoints; i++) { waypoints.push(baseWaypoint.clone().rotateAround(origin, unitOfRotation * i)) } return waypoints.map((w) => [ w.clone().add(this.position), w.clone().normalize().multiplyScalar(enclosingRegularPolygonRadius), ]) } attackAction(cursor) { this.moveAction(cursor, true) } castAction(slot, cursor, halt = false) { const ability = this.ability(slot) if (ability == null) { return } if (this.casting != null) { const abilityBeingCasted = this.casting.ability if (abilityBeingCasted.id == ability.id) { return false } return false } if (halt) { this.#moving = false } const targetPosition = (cursor instanceof Vector2) ? cursor : this.game?.entities.find((it) => it.id == cursor)?.position if (targetPosition instanceof Vector2) { this.rotation = targetPosition.clone().sub(this.position).angle() } const cooldown = this.game?.secToTick(ability.cooldown) ?? 0 const lastCast = this.cooldowns[ability.id] const timestamp = this.game?.currentTick ?? 0 if (lastCast != null && lastCast + cooldown > timestamp) { return false } this.casting = { ability, cursor, timestamp } // TODO: use ID only for ability return true } haltAction() { this.#moving = false } moveAction(cursor, attack = false) { if (this.casting != null && this.casting.ability.moveCancelable) { if (!attack && !(this.casting != null && this.casting.ability.id == this.abilities[0])) { this.casting = null } } this.#attacking = attack this.#moving = true this.#dest = cursor.clone() } stopAction() { this.casting = null this.#moving = true this.#attacking = false } // --- Actions above --- // ability(slot) { if (this.abilities[slot] != null) { return this.game?.abilities.find((it) => it.id == this.abilities[slot]) } } adjustWaypoint(waypoint, direction) { return SATX.clamp( waypoint.clone().add(direction.clone().multiplyScalar(this.radius)), this.game?.width, this.game?.height, this.radius, ) } applyBuff(id, sourceId = null) { const index = this.buffs.findIndex((it) => it.id == id) const source = sourceId ?? this.id const timestamp = this.game?.currentTick ?? 0 if (index > -1) { this.buffs[index].timestamp = timestamp this.buffs[index].source = source } else { this.buffs.push({ id, source, timestamp }) } } collidables() { return this.customBboxCollidables(this.bbox) } collider() { return this.#colliders.at(0) } colliders() { return this.#colliders } cooldown(id) { this.cooldowns[id] = this.game?.currentTick ?? 0 } closestTargetTo(cursor, range) { return this .game ?.entities .filter((e) => this.team != e.team && e.distanceTo(cursor) <= range + this.radius + e.radius) .reduce((e1, e2) => (e1?.distanceTo(cursor) ?? Infinity) < e2.distanceTo(cursor) ? e1 : e2, null) } damage(amount, source = null) { let damage = amount if (this.hasBuff(Buff.exposed.id)) { const buff = this.getBuff(Buff.exposed.id) if (buff.source == source.id) { damage *= 3 // TODO: move to Buff class to make generic this.removeBuff(Buff.exposed.id) } } this.health = Math.min(Math.max(0, this.health - damage), this.maxHealth) } despawn() { this.game?.despawn(this) } distanceTo(cursor) { 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 } const buffDefinition = this.game?.buffs.find((it) => it.id == entityBuff.id) if (buffDefinition == null) { return } return { ...buffDefinition, ...entityBuff } } hasBuff(id) { return this.buffs.some((it) => it.id == id) } heal(amount) { this.health = Math.min(Math.max(0, this.health + amount), this.maxHealth) } fixPosition() { const fixedPosition = this.fixFuturePosition(this.position) if (this.position.equals(fixedPosition)) { return } this.setPosition(fixedPosition) } 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) { this.buffs = this.buffs.filter((it) => it.id != id) } respawn() { this.setPosition(this.#spawnPosition) this.health = this.maxHealth this.dead = false } setPosition(vector) { this.position.copy(vector) this.#calculateCollider() } teleport(cursor) { this.setPosition(this.fixFuturePosition(cursor)) } update() { if (this.dead) { // TODO: do something while the entity is dead } else { this.#cast() this.#checkHealth() this.#move() this.#tickBuffs() this.fixPosition() } if (this.#logic != null) { this.#logic() } } waypoints() { const entityColliders = (this.game?.entities ?? []).filter((e) => e.id != this.id) const terrainColliders = (this.game?.terrains ?? []) const unadjustedWaypoints = entityColliders.concat(terrainColliders).map((e) => e.unadjustedWaypoints).flat() 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 } #calculateCollider() { this.#calculateBbox() this.#colliders = [Entity.collider(this.position.x, this.position.y, this.radius)] } #cast() { if (this.casting == null) { return false } const castTime = this.game?.secToTick(this.casting.ability.castTime) ?? 0 const castStart = this.casting.timestamp const timestamp = this.game?.currentTick ?? 0 if (castStart + castTime > timestamp) { return false } this.casting.ability.effect(this, this.casting.cursor) this.casting = null return true } #checkHealth() { if (this.health <= 0) { this.dead = true } } #move(distanceTraveled = 0) { if (this.casting != null) { return false } if (this.#attacking) { const cursor = this.#dest ?? this.position const basicAttack = this.ability('a') if (basicAttack != null) { const target = this.closestTargetTo(cursor, 500) if (target != null && this.distanceTo(target.position) < basicAttack.range + this.radius + target.radius) { const cooldown = this.game?.secToTick(basicAttack.cooldown) ?? 0 const lastCast = this.cooldowns[basicAttack.id] const timestamp = this.game?.currentTick ?? 0 if (lastCast != null && lastCast + cooldown > timestamp) { return false } this.castAction('a', target.id, false) return true } } } if (!this.#moving || this.#dest == null) { return false } const fixedDest = this.fixFuturePosition(this.#dest) if (this.#path.length > 0) { const sectionDest = this.#path.at(0) const lineOfSight = this.isInLineOfSight(sectionDest) if (!lineOfSight) { this.#path = [] } } if (this.#path.length < 1 || !this.#path.at(-1).equals(fixedDest)) { const lineOfSight = this.isInLineOfSight(fixedDest) if (lineOfSight) { this.#path = [fixedDest] } } if ((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 = [] for (let failsafe = 0; failsafe < 1000; failsafe++) { 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 sectionObstacles = this.obstaclesInStraightPath(section, lastSection) 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) { const speed = (this.speed / (this.game?.tickBudget ?? 1000)) - distanceTraveled const destination = this.#path.at(0) const difference = destination.clone().sub(this.position) const distance = difference.length() const direction = difference.clone().normalize() const stepTaken = this.position.clone().add(direction.multiplyScalar(speed)) const position = distance <= speed ? destination : stepTaken const rotation = direction.angle() this.rotation = rotation if (!this.willCollide(position)) { this.setPosition(position) } if (this.position.equals(destination)) { this.#path = this.#path.slice(1) if (this.#path.length > 0) { this.#move(distance) } else { this.#dest = null this.#moving = false } } } } #tickBuff(index) { if (this.buffs[index] == null) { return } const buff = this.getBuff(this.buffs[index].id) const duration = this.game?.secToTick(buff.duration) ?? 0 const currentTick = this.game?.currentTick ?? 0 if (buff.timestamp + duration < currentTick) { this.removeBuff(buff.id) } } #tickBuffs() { this.buffs.forEach((_v, i) => this.#tickBuff(i)) } }