import { Vector2 } from 'three' import SAT from 'sat' import SATX from './satx.js' import Pathfind from './pathfind.js' import Ability from './ability.js' import Team from './team.js' export default class Entity { id = crypto.randomUUID() speed = 400 radius = 0 health = 1 maxHealth = 1 height = 40 abilities = [ Ability.rangedAttack, Ability.straightShot, Ability.shieldThrow, Ability.blink, ] casting = null team = Team.neutral cooldowns = {} #attack = false #dest = null #game = null #logic = null #path = [] #position = new Vector2() #scheduledPathfinding = null static collider(x, y, radius) { return new SAT.Circle(new SAT.Vector(x, y), radius) } constructor(options = {}) { Object.entries(options).forEach(([key, value]) => this[key] = value) } get destination() { return this.#dest } get logic() { return this.#logic } get game() { return this.#game } get position() { return this.#position } get scheduledPathfinding() { return this.#scheduledPathfinding } 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 position(value) { this.#position = value } set scheduledPathfinding(value) { this.#scheduledPathfinding = value } set x(value) { this.position.x = 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() { 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(x, y) { this.moveAction(x, y, true) } castAction(slot, x, y, clearDestination = true) { const ability = this.abilities[slot] if (this.casting != null) { const abilityBeingCasted = this.casting.ability if (abilityBeingCasted.id == ability.id) { return false } return false } if (clearDestination) { this.#dest = null } const cursor = new Vector2(x, y) 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 } return true } haltAction() { this.#dest = null } moveAction(x, y, attack = false) { this.#attack = attack if (this.casting != null && (!this.#attack || this.casting.ability.id != this.abilities[0].id)) { this.casting = null } this.#dest = SATX.fixCollisions(new Vector2(x, y), this.collidables(), this.radius, this.game?.width, this.game?.height) } stopAction() { this.casting = null this.#dest = null this.#attack = false } 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 } 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) } // DEPRECATED: hulls were a failed concept for position fixing collidableHulls() { const entityColliders = (this.game?.entities ?? []).filter((e) => e.id != this.id).map((e) => e.collider) const terrainColliders = (this.game?.terrains ?? []).map((t) => t.hull) return entityColliders.concat(terrainColliders) } cooldown(id) { this.cooldowns[id] = this.game?.currentTick ?? 0 } damage(amount) { this.health = Math.min(Math.max(0, this.health - amount), this.maxHealth) } despawn() { this.game?.despawn(this) } heal(amount) { this.health = Math.min(Math.max(0, this.health + amount), this.maxHealth) } fixPosition() { this.#position = SATX.fixCollisions(this.#position, this.collidables(), this.radius, this.game?.width, this.game?.height).clone() } isColliding(...colliders) { return SATX.collideObjects(this.collider, colliders) } state() { return { ...this, position: { x: this.x, y: this.y, }, } } teleport(position) { this.#position = position.clone() this.fixPosition() } takeStep(distanceTraveled = 0) { if (this.casting != null) { return false } if (this.#attack && this.game?.entities.some((e) => e.team != this.team && e.position.clone().sub(this.position).length() < this.abilities[0].range)) { const cooldown = this.game?.secToTick(this.abilities[0].cooldown) ?? 0 const lastCast = this.cooldowns[this.abilities[0].id] const timestamp = this.game?.currentTick ?? 0 if (lastCast != null && lastCast + cooldown > timestamp) { return false } const target = this.#dest ?? this.position this.castAction(0, target.x, target.y, false) return true } if (this.#dest == null) { return false } const collidables = this.collidables() 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 (!destinationInLineOfSight) { this.#path = [] } } if (this.#path.length < 1 || !this.#path.at(-1).equals(fixedDest)) { if (destinationInLineOfSight) { this.#path = [fixedDest] } } if ((this.#path.length < 1 || !this.#path.at(-1).equals(fixedDest)) && (!this.#scheduledPathfinding || this.game?.currentTick % this.game?.tickRate == this.#scheduledPathfinding)) { const start = SATX.vectorToFloat32Array(this.position) const goal = SATX.vectorToFloat32Array(fixedDest) const nonUniqueWaypoints = this.waypoints().map((w) => SATX.vectorToFloat32Array(w)).concat([start, goal]) const waypoints = Pathfind.uniqueWaypoints(nonUniqueWaypoints) const graph = Pathfind.buildGraph(waypoints, collidables, this.radius) this.#path = Pathfind.shortestPath(graph, start, goal).map((waypoint) => new Vector2(waypoint[0], waypoint[1])) } 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 collider = Entity.collider(position.x, position.y, this.radius) const isColliding = SATX.collideObjects(collider, this.collidables()) if (!isColliding) { this.position.copy(position) } if (this.position.equals(destination)) { this.#path = this.#path.slice(1) if (this.#path.length > 0) { this.takeStep(distance) } else { this.#dest = null } } } } update() { this.cast() this.takeStep() 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]) => { return SATX.clamp( waypoint.clone().add(direction.clone().multiplyScalar(this.radius)), this.game?.width, this.game?.height, this.radius, ) }) ?? [] } }