608 lines
18 KiB
JavaScript
608 lines
18 KiB
JavaScript
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
|
|
#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
|
|
}
|
|
}
|
|
|
|
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 Entity.collider(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)
|
|
}
|
|
|
|
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() {
|
|
this.position = this.fixFuturePosition(this.position.clone()).clone()
|
|
}
|
|
|
|
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.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()
|
|
}
|
|
|
|
this.#calculateBbox()
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
#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.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))
|
|
}
|
|
}
|