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 = {} buffs = [] casting = null cooldowns = {} dead = false health = null height = 40 maxHealth = 1 memory = {} // TODO: hide from reports but keep public position = null radius = 0 speed = 400 team = Team.neutral visualRadius = null #attacking = false #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) } 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 } } 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 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 = SATX.fixCollisions(cursor, this.collidables(), this.radius, this.game?.width, this.game?.height) } 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() { 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) } collider() { return new SAT.Circle(new SAT.Vector(this.position.x, this.position.y), this.radius) } colliders() { return [this.collider()] } 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) } 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() { 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) } removeBuff(id) { this.buffs = this.buffs.filter((it) => it.id != id) } respawn() { this.position = this.#spawnPosition.clone() this.health = this.maxHealth this.dead = false } teleport(cursor) { this.position = cursor.clone() this.fixPosition() } 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)) ?? [] } #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 collidables = this.collidables() const fixedDest = SATX.clamp(SATX.fixCollisions(this.#dest, collidables, this.radius), this.game?.width, this.game?.height, this.radius) 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) 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) 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 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) 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 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.#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)) } }