This repository has been archived on 2026-05-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
instructions-clear/src/entity.js
T
2025-01-18 12:00:12 +09:00

438 lines
12 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 = {}
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))
}
}