304 lines
8.8 KiB
JavaScript
304 lines
8.8 KiB
JavaScript
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,
|
|
)
|
|
}) ?? []
|
|
}
|
|
}
|