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
+36 -33
View File
@@ -5,31 +5,27 @@ import Buff from './buff.js'
import Entity from './entity.js'
import Projectile from './projectile.js'
import Terrain from './terrain.js'
import { Worker } from 'node:worker_threads'
export default class Game {
id = crypto.randomUUID()
abilities = Object.values({...Ability})
buffs = Object.values({...Buff})
averageTick = 0
currentTick = 0
entities = []
gameLoopIntervalId = null
height = 0
projectiles = []
secondToSlowestTick = 0
startTimestamp = 0
terrains = []
tickRate = 30
width = 0
#behindMs = 0
#currentTiming = 0
#eventEmitter = new EventEmitter()
#gameLoopIntervalId = null
#logic = null
#nextTickAt = 0
#startTimestamp = 0
#tickBudget = 1000 / this.tickRate
#timings = new Float32Array(this.tickRate)
get logic() { return this.#logic }
get eventEmitter() { return this.#eventEmitter }
@@ -71,6 +67,19 @@ export default class Game {
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) {
this.terrains = this.terrains.filter((t) => t.id != terrain.id)
}
@@ -97,30 +106,37 @@ export default class Game {
}
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}.`)
this.gameLoopIntervalId = setInterval(this.#gameLoopCall.bind(this), this.tickBudget / 5)
this.#gameLoopIntervalId = setInterval(this.#gameLoopCall.bind(this), this.tickBudget / 5)
}
stop() {
if (this.gameLoopIntervalId == null) { return }
if (this.#gameLoopIntervalId == null) { return }
clearInterval(this.gameLoopIntervalId)
this.gameLoopIntervalId = null
clearInterval(this.#gameLoopIntervalId)
this.#gameLoopIntervalId = null
console.info(`Stopped game ${this.id}. Stopped on tick ${this.currentTick}.`)
}
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() {
const game = this
const entity = game.entities.find((it) => it.id == id)
if (entity == null) { return }
const team = entity.team
const message = game.visionByTeam(team)
websocket.send(JSON.stringify(message))
const state = game.visionByTeam(team)
state.currentTick = game.currentTick
worker.postMessage(state)
}
}
@@ -132,7 +148,6 @@ export default class Game {
this.#logic()
}
this.#calculateTickMetrics()
this.eventEmitter.emit('tick')
this.currentTick++
@@ -140,12 +155,12 @@ export default class Game {
visibleEntities(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) {
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) {
@@ -156,20 +171,14 @@ export default class Game {
visionByTeam(team) {
const visionSources = this.visionSources(team)
const visibleEntities = new Set(visionSources.map((it) => it.entitiesInVision()).flat())
const visibleProjectiles = new Set(visionSources.map((it) => it.projectilesInVision()).flat())
const visibleEntities = new Set(visionSources.map((it) => it.entitiesInVision).flat())
const visibleProjectiles = new Set(visionSources.map((it) => it.projectilesInVision).flat())
return {
...this,
entities: this.entities.filter((it) => visibleEntities.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() {
if (this.#nextTickAt != null) {
const tickBudget = this.#tickBudget
@@ -185,15 +194,9 @@ export default class Game {
const taken = (after - before)
const useAbsoluteBehind = true
const absoluteBehind = Math.max(0, (start - this.startTimestamp) - ((this.currentTick) * tickBudget))
this.#behindMs = absoluteBehind
const absoluteBehind = Math.max(0, (start - this.#startTimestamp) - ((this.currentTick) * tickBudget))
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) {
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}`)