optimize reporting and serialization for clients

This commit is contained in:
2025-01-22 12:41:55 +09:00
parent 916bc31356
commit c4c7c921d7
6 changed files with 107 additions and 90 deletions
+39 -32
View File
@@ -12,20 +12,14 @@ export default class Entity {
static #nextUniqueId = 0 static #nextUniqueId = 0
abilities = {} abilities = {}
bbox = new Float32Array(4)
buffs = [] buffs = []
casting = null casting = null
collision = true
cooldowns = {} cooldowns = {}
dead = false dead = false
ghostable = true
ghosting = false ghosting = false
health = null health = null
height = 40 height = 40
maxHealth = 1 maxHealth = 1
memory = {}
pathfindingCooldown = 0
pathfindingObstacleLimit = null
position = null position = null
radius = 0 radius = 0
rotation = 0 rotation = 0
@@ -34,8 +28,15 @@ export default class Entity {
visionRange = 900 visionRange = 900
visualRadius = null visualRadius = null
#collision = true
#ghostable = true
#attacking = false #attacking = false
#bbox = new Float32Array(4)
#colliders = [] #colliders = []
#entitiesInVision = []
#projectilesInVision = []
#pathfindingCooldown = 0
#pathfindingObstacleLimit = null
#dest = null #dest = null
#game = null #game = null
#logic = null #logic = null
@@ -44,7 +45,6 @@ export default class Entity {
#noPathfindingUntil = 0 #noPathfindingUntil = 0
#spawnPosition = new Vector2() #spawnPosition = new Vector2()
static bbox(x, y, radius) { static bbox(x, y, radius) {
return new Float32Array([y + radius, x + radius, y - radius, x - radius]) return new Float32Array([y + radius, x + radius, y - radius, x - radius])
} }
@@ -145,16 +145,28 @@ export default class Entity {
} }
get attacking() { return this.#attacking } get attacking() { return this.#attacking }
get bbox() { return this.#bbox }
get collision() { return this.#collision }
get destination() { return this.#dest } get destination() { return this.#dest }
get logic() { return this.#logic } get entitiesInVision() { return this.#entitiesInVision }
get game() { return this.#game } get game() { return this.#game }
get ghostable() { return this.#ghostable }
get logic() { return this.#logic }
get pathfindingCooldown() { return this.#pathfindingCooldown }
get pathfindingObstacleLimit() { return this.#pathfindingObstacleLimit }
get projectilesInVision() { return this.#projectilesInVision }
get spawnPosition() { return this.#spawnPosition } get spawnPosition() { return this.#spawnPosition }
get x() { return this.position.x } get x() { return this.position.x }
get y() { return this.position.y } get y() { return this.position.y }
set bbox(value) { this.#bbox = value }
set collision(value) { this.#collision = value }
set destination(value) { this.#dest = value } set destination(value) { this.#dest = value }
set logic(value) { this.#logic = value }
set game(value) { this.#game = value } set game(value) { this.#game = value }
set ghostable(value) { this.#ghostable = value }
set logic(value) { this.#logic = value }
set pathfindingCooldown(value) { this.#pathfindingCooldown = value }
set pathfindingObstacleLimit(value) { this.#pathfindingObstacleLimit = value }
set spawnPosition(value) { this.#spawnPosition = value } set spawnPosition(value) { this.#spawnPosition = value }
set x(value) { this.position.x = value } set x(value) { this.position.x = value }
set y(value) { this.position.y = value } set y(value) { this.position.y = value }
@@ -273,7 +285,7 @@ export default class Entity {
if (targetsInRange.length < 1) { return } if (targetsInRange.length < 1) { return }
const absoluteClosestTarget = targetsInRange.reduce((e1, e2) => (e1?.distanceTo(cursor) ?? Infinity) < e2.distanceTo(cursor) ? e1 : e2, null) const absoluteClosestTarget = targetsInRange.reduce((e1, e2) => (e1?.distanceTo(cursor) ?? Infinity) < e2.distanceTo(cursor) ? e1 : e2, null)
const entityIdsInDirectVision = this.entitiesInVision() const entityIdsInDirectVision = this.#entitiesInVision
if (entityIdsInDirectVision.includes(absoluteClosestTarget.id)) { if (entityIdsInDirectVision.includes(absoluteClosestTarget.id)) {
return absoluteClosestTarget return absoluteClosestTarget
} }
@@ -313,17 +325,6 @@ export default class Entity {
return this.position.distanceTo(cursor) return this.position.distanceTo(cursor)
} }
// TODO: cache vision because linear per player connected is better than exponential
entitiesInVision() {
const entities = this.game?.entities
if (entities == null) { return }
const entitiesInVisionRange = entities.filter((it) => it.id != this.id && it.distanceTo(this.position) <= this.visionRange + it.radius)
const entitiesInLineOfSight = entitiesInVisionRange.filter((it) => this.isInLineOfVision(it.position))
return entitiesInLineOfSight.concat([this]).map((it) => it.id)
}
futureCollidables(futurePosition) { futureCollidables(futurePosition) {
return this.customBboxCollidables(new Float32Array([ return this.customBboxCollidables(new Float32Array([
futurePosition.y + this.radius, futurePosition.y + this.radius,
@@ -435,16 +436,6 @@ export default class Entity {
return bboxCheckedObstacles.filter((obstacle) => obstacle.colliders().some((it) => SATX.collideObject(collider, it))) return bboxCheckedObstacles.filter((obstacle) => obstacle.colliders().some((it) => SATX.collideObject(collider, it)))
} }
projectilesInVision() {
const projectiles = this.game?.projectiles
if (projectiles == null) { return }
const projectilesInVisionRange = projectiles.filter((it) => this.distanceTo(it.position) <= this.visionRange + it.radius)
const projectilesInLineOfSight = projectilesInVisionRange.filter((it) => it.visibleThroughTerrain || this.isInLineOfVision(it.position))
return projectilesInLineOfSight.map((it) => it.id)
}
removeBuff(id) { removeBuff(id) {
this.buffs = this.buffs.filter((it) => it.id != id) this.buffs = this.buffs.filter((it) => it.id != id)
} }
@@ -484,11 +475,13 @@ export default class Entity {
]) ])
} }
// TODO: make non-race-condition calculations multi-threaded
update() { update() {
if (this.dead) { if (this.dead) {
// TODO: do something while the entity is dead (and disallow casting, etc) // TODO: do something while the entity is dead (and disallow casting, vision, etc)
} }
else { else {
this.#calculateVision()
this.#cast() this.#cast()
this.#checkHealth() this.#checkHealth()
this.#move() this.#move()
@@ -529,6 +522,20 @@ export default class Entity {
this.#colliders = [Entity.collider(this.position.x, this.position.y, this.radius)] this.#colliders = [Entity.collider(this.position.x, this.position.y, this.radius)]
} }
#calculateVision() {
const entities = this.game?.entities ?? []
const projectiles = this.game?.projectiles ?? []
const entitiesInVisionRange = entities.filter((it) => it.id != this.id && it.distanceTo(this.position) <= this.visionRange + it.radius)
const entitiesInLineOfSight = entitiesInVisionRange.filter((it) => this.isInLineOfVision(it.position))
const projectilesInVisionRange = projectiles.filter((it) => this.position.distanceTo(it.position) <= this.visionRange + it.radius)
const projectilesInLineOfSight = projectilesInVisionRange.filter((it) => it.visibleThroughTerrain || this.isInLineOfVision(it.position))
this.#entitiesInVision = entitiesInLineOfSight.concat([this]).map((it) => it.id)
this.#projectilesInVision = projectilesInLineOfSight.map((it) => it.id)
}
#cast() { #cast() {
if (this.casting == null) { if (this.casting == null) {
return false return false
+36 -33
View File
@@ -5,31 +5,27 @@ import Buff from './buff.js'
import Entity from './entity.js' import Entity from './entity.js'
import Projectile from './projectile.js' import Projectile from './projectile.js'
import Terrain from './terrain.js' import Terrain from './terrain.js'
import { Worker } from 'node:worker_threads'
export default class Game { export default class Game {
id = crypto.randomUUID() id = crypto.randomUUID()
abilities = Object.values({...Ability}) abilities = Object.values({...Ability})
buffs = Object.values({...Buff}) buffs = Object.values({...Buff})
averageTick = 0
currentTick = 0 currentTick = 0
entities = [] entities = []
gameLoopIntervalId = null
height = 0 height = 0
projectiles = [] projectiles = []
secondToSlowestTick = 0
startTimestamp = 0
terrains = [] terrains = []
tickRate = 30 tickRate = 30
width = 0 width = 0
#behindMs = 0
#currentTiming = 0
#eventEmitter = new EventEmitter() #eventEmitter = new EventEmitter()
#gameLoopIntervalId = null
#logic = null #logic = null
#nextTickAt = 0 #nextTickAt = 0
#startTimestamp = 0
#tickBudget = 1000 / this.tickRate #tickBudget = 1000 / this.tickRate
#timings = new Float32Array(this.tickRate)
get logic() { return this.#logic } get logic() { return this.#logic }
get eventEmitter() { return this.#eventEmitter } get eventEmitter() { return this.#eventEmitter }
@@ -71,6 +67,19 @@ export default class Game {
projectile.game = null projectile.game = null
} }
joinReport() {
return {
id: this.id,
height: this.height,
width: this.width,
currentTick: this.currentTick,
abilities: this.abilities,
buffs: this.buffs,
terrains: this.terrains,
tickRate: this.tickRate,
}
}
removeTerrain(terrain) { removeTerrain(terrain) {
this.terrains = this.terrains.filter((t) => t.id != terrain.id) this.terrains = this.terrains.filter((t) => t.id != terrain.id)
} }
@@ -97,30 +106,37 @@ export default class Game {
} }
start() { start() {
if (this.gameLoopIntervalId != null) { return } if (this.#gameLoopIntervalId != null) { return }
this.startTimestamp = performance.now() + (this.currentTick * this.tickBudget) this.#startTimestamp = performance.now() + (this.currentTick * this.tickBudget)
console.info(`Started game ${this.id} with ${this.tickRate} tps. Starting on tick ${this.currentTick}.`) console.info(`Started game ${this.id} with ${this.tickRate} tps. Starting on tick ${this.currentTick}.`)
this.gameLoopIntervalId = setInterval(this.#gameLoopCall.bind(this), this.tickBudget / 5) this.#gameLoopIntervalId = setInterval(this.#gameLoopCall.bind(this), this.tickBudget / 5)
} }
stop() { stop() {
if (this.gameLoopIntervalId == null) { return } if (this.#gameLoopIntervalId == null) { return }
clearInterval(this.gameLoopIntervalId) clearInterval(this.#gameLoopIntervalId)
this.gameLoopIntervalId = null this.#gameLoopIntervalId = null
console.info(`Stopped game ${this.id}. Stopped on tick ${this.currentTick}.`) console.info(`Stopped game ${this.id}. Stopped on tick ${this.currentTick}.`)
} }
subscription(websocket, id) { subscription(websocket, id) {
const worker = new Worker('./src/worker/object-to-json.js')
const sendToWebSocket = function sendToWebSocket(message) { websocket.send(message) } // TODO: latency because workers wait for the main thread's next tick which is ~33ms
worker.on('message', sendToWebSocket)
return function builtSubscription() { return function builtSubscription() {
const game = this const game = this
const entity = game.entities.find((it) => it.id == id) const entity = game.entities.find((it) => it.id == id)
if (entity == null) { return } if (entity == null) { return }
const team = entity.team const team = entity.team
const message = game.visionByTeam(team) const state = game.visionByTeam(team)
websocket.send(JSON.stringify(message)) state.currentTick = game.currentTick
worker.postMessage(state)
} }
} }
@@ -132,7 +148,6 @@ export default class Game {
this.#logic() this.#logic()
} }
this.#calculateTickMetrics()
this.eventEmitter.emit('tick') this.eventEmitter.emit('tick')
this.currentTick++ this.currentTick++
@@ -140,12 +155,12 @@ export default class Game {
visibleEntities(team) { visibleEntities(team) {
const visionSources = this.visionSources(team) const visionSources = this.visionSources(team)
return Array.from(new Set(visionSources.map((it) => it.entitiesInVision()).flat())) return Array.from(new Set(visionSources.map((it) => it.entitiesInVision).flat()))
} }
visibleProjectiles(team) { visibleProjectiles(team) {
const visionSources = this.visionSources(team) const visionSources = this.visionSources(team)
return Array.from(new Set(visionSources.map((it) => it.projectilesInVision()).flat())) return Array.from(new Set(visionSources.map((it) => it.projectilesInVision).flat()))
} }
visionSources(team) { visionSources(team) {
@@ -156,20 +171,14 @@ export default class Game {
visionByTeam(team) { visionByTeam(team) {
const visionSources = this.visionSources(team) const visionSources = this.visionSources(team)
const visibleEntities = new Set(visionSources.map((it) => it.entitiesInVision()).flat()) const visibleEntities = new Set(visionSources.map((it) => it.entitiesInVision).flat())
const visibleProjectiles = new Set(visionSources.map((it) => it.projectilesInVision()).flat()) const visibleProjectiles = new Set(visionSources.map((it) => it.projectilesInVision).flat())
return { return {
...this,
entities: this.entities.filter((it) => visibleEntities.has(it.id)), entities: this.entities.filter((it) => visibleEntities.has(it.id)),
projectiles: this.projectiles.filter((it) => visibleProjectiles.has(it.id)), projectiles: this.projectiles.filter((it) => visibleProjectiles.has(it.id)),
} }
} }
#calculateTickMetrics() {
this.averageTick = Math.floor(10 * this.#timings.reduce((sum, t) => sum += t, 0) / this.#timings.length) / 10
this.secondToSlowestTick = Math.floor(10 * this.#timings.toSorted().at(-2)) / 10
}
#gameLoop() { #gameLoop() {
if (this.#nextTickAt != null) { if (this.#nextTickAt != null) {
const tickBudget = this.#tickBudget const tickBudget = this.#tickBudget
@@ -185,15 +194,9 @@ export default class Game {
const taken = (after - before) const taken = (after - before)
const useAbsoluteBehind = true const useAbsoluteBehind = true
const absoluteBehind = Math.max(0, (start - this.startTimestamp) - ((this.currentTick) * tickBudget)) const absoluteBehind = Math.max(0, (start - this.#startTimestamp) - ((this.currentTick) * tickBudget))
this.#behindMs = absoluteBehind
this.#nextTickAt = start + tickBudget - (useAbsoluteBehind ? absoluteBehind : prevBehind) this.#nextTickAt = start + tickBudget - (useAbsoluteBehind ? absoluteBehind : prevBehind)
this.#timings[this.#currentTiming] = taken
if (this.#currentTiming++ > this.#timings.length) {
this.#currentTiming = 0
}
if (after - before > tickBudget) { if (after - before > tickBudget) {
const behindNotice = absoluteBehind > 0.1 ? `(Was already behind ${absoluteBehind.toFixed(1)} ms)` : `` const behindNotice = absoluteBehind > 0.1 ? `(Was already behind ${absoluteBehind.toFixed(1)} ms)` : ``
console.warn(`Can't keep up! A tick took ${taken.toFixed(1)} ms / ${tickBudget.toFixed(1)} ms. ${behindNotice}`) console.warn(`Can't keep up! A tick took ${taken.toFixed(1)} ms / ${tickBudget.toFixed(1)} ms. ${behindNotice}`)
+1
View File
@@ -23,6 +23,7 @@ app.ws('/ws', async (req, res) => {
const message = JSON.parse(rawData) const message = JSON.parse(rawData)
console.log(message) console.log(message)
if (message.action == 'join') { if (message.action == 'join') {
websocket.send(JSON.stringify(game.joinReport()))
const subscription = game.subscription(websocket, message.id).bind(game) const subscription = game.subscription(websocket, message.id).bind(game)
game.eventEmitter.on('tick', subscription) game.eventEmitter.on('tick', subscription)
+23 -23
View File
@@ -8,9 +8,7 @@ export default class Projectile {
static nextId() { return this.#nextUniqueId++ } static nextId() { return this.#nextUniqueId++ }
static #nextUniqueId = 0 static #nextUniqueId = 0
bbox = new Float32Array(4)
height = 50 height = 50
memory = {}
owner = null owner = null
position = new Vector2() position = new Vector2()
radius = 0 radius = 0
@@ -21,19 +19,26 @@ export default class Projectile {
visualRadius = null visualRadius = null
#after = null #after = null
#bbox = new Float32Array(4)
#dest = null #dest = null
#entitiesInVision = []
#game = null
#homingTarget = null #homingTarget = null
#logic = null #logic = null
#onCollide = null #onCollide = null
#game = null #projectilesInVision = []
get after() { return this.#after } get after() { return this.#after }
get bbox() { return this.#bbox }
get entitiesInVision() { return this.#entitiesInVision }
get game() { return this.#game } get game() { return this.#game }
get homingTarget() { return this.#homingTarget } get homingTarget() { return this.#homingTarget }
get logic() { return this.#logic } get logic() { return this.#logic }
get onCollide() { return this.#onCollide } get onCollide() { return this.#onCollide }
get projectilesInVision() { return this.#projectilesInVision }
set after(value) { this.#after = value } set after(value) { this.#after = value }
set bbox(value) { this.#bbox = value }
set destination(value) { this.#dest = value } set destination(value) { this.#dest = value }
set game(value) { this.#game = value } set game(value) { this.#game = value }
set homingTarget(value) { this.#homingTarget = value } set homingTarget(value) { this.#homingTarget = value }
@@ -59,16 +64,6 @@ export default class Projectile {
this.game?.despawn(this) this.game?.despawn(this)
} }
entitiesInVision() {
const entities = this.game?.entities
if (entities == null) { return }
const entitiesInVisionRange = entities.filter((it) => it.id != this.id && it.distanceTo(this.position) <= this.visionRange + it.radius)
const entitiesInLineOfSight = entitiesInVisionRange.filter((it) => this.isInLineOfVision(it.position))
return entitiesInLineOfSight.concat([this]).map((it) => it.id)
}
isInLineOfVision(destination) { isInLineOfVision(destination) {
const bbox = Entity.tunnelBbox(this.position.x, this.position.y, destination.x, destination.y, 0) const bbox = Entity.tunnelBbox(this.position.x, this.position.y, destination.x, destination.y, 0)
const terrains = this.game?.terrains ?? [] const terrains = this.game?.terrains ?? []
@@ -90,6 +85,7 @@ export default class Projectile {
} }
update() { update() {
this.#calculateVision()
this.#move() this.#move()
this.#checkStationaryCollisions() this.#checkStationaryCollisions()
this.#checkIfArrived() this.#checkIfArrived()
@@ -98,16 +94,6 @@ export default class Projectile {
} }
} }
projectilesInVision() {
const projectiles = this.game?.projectiles
if (projectiles == null) { return }
const projectilesInVisionRange = projectiles.filter((it) => this.position.distanceTo(it.position) <= this.visionRange + it.radius)
const projectilesInLineOfSight = projectilesInVisionRange.filter((it) => it.visibleThroughTerrain || this.isInLineOfVision(it.position))
return projectilesInLineOfSight.concat([this]).map((it) => it.id)
}
#calculateBbox() { #calculateBbox() {
this.bbox[0] = this.position.y + this.radius this.bbox[0] = this.position.y + this.radius
this.bbox[1] = this.position.x + this.radius this.bbox[1] = this.position.x + this.radius
@@ -115,6 +101,20 @@ export default class Projectile {
this.bbox[3] = this.position.x - this.radius this.bbox[3] = this.position.x - this.radius
} }
#calculateVision() {
const entities = this.game?.entities ?? []
const projectiles = this.game?.projectiles ?? []
const entitiesInVisionRange = entities.filter((it) => it.id != this.id && it.distanceTo(this.position) <= this.visionRange + it.radius)
const entitiesInLineOfSight = entitiesInVisionRange.filter((it) => this.isInLineOfVision(it.position))
const projectilesInVisionRange = projectiles.filter((it) => this.position.distanceTo(it.position) <= this.visionRange + it.radius)
const projectilesInLineOfSight = projectilesInVisionRange.filter((it) => it.visibleThroughTerrain || this.isInLineOfVision(it.position))
this.#entitiesInVision = entitiesInLineOfSight.concat([this]).map((it) => it.id)
this.#projectilesInVision = projectilesInLineOfSight.map((it) => it.id)
}
#checkIfArrived() { #checkIfArrived() {
if (this.destination == null) { return } if (this.destination == null) { return }
if (!this.position.equals(this.destination)) { return } if (!this.position.equals(this.destination)) { return }
+3 -2
View File
@@ -44,6 +44,7 @@ export default class Template {
const checkpointSize = 300 const checkpointSize = 300
const recalculateDestRadius = 50 const recalculateDestRadius = 50
const aggroRadius = 500 const aggroRadius = 500
const memory = {}
return function builtMinionLogic() { return function builtMinionLogic() {
const entity = this const entity = this
@@ -62,12 +63,12 @@ export default class Template {
} }
if ((route.length > 0 || entity.attacking) && target == null) { if ((route.length > 0 || entity.attacking) && target == null) {
const routeIndex = entity.memory.routeCheckpoint ?? 0 const routeIndex = memory.routeCheckpoint ?? 0
const goal = route[routeIndex].clone() const goal = route[routeIndex].clone()
if (goal instanceof Vector2) { if (goal instanceof Vector2) {
if (entity.distanceTo(goal) < checkpointSize) { if (entity.distanceTo(goal) < checkpointSize) {
if (routeIndex + 1 < route.length) { if (routeIndex + 1 < route.length) {
entity.memory.routeCheckpoint = routeIndex + 1 memory.routeCheckpoint = routeIndex + 1
} }
} }
+5
View File
@@ -0,0 +1,5 @@
import { parentPort } from 'node:worker_threads'
parentPort.on('message', (message) => {
parentPort.postMessage(JSON.stringify(message))
})