diff --git a/src/entity.js b/src/entity.js index 7f896cd..1340f26 100644 --- a/src/entity.js +++ b/src/entity.js @@ -12,20 +12,14 @@ export default class Entity { static #nextUniqueId = 0 abilities = {} - bbox = new Float32Array(4) buffs = [] casting = null - collision = true cooldowns = {} dead = false - ghostable = true ghosting = false health = null height = 40 maxHealth = 1 - memory = {} - pathfindingCooldown = 0 - pathfindingObstacleLimit = null position = null radius = 0 rotation = 0 @@ -34,8 +28,15 @@ export default class Entity { visionRange = 900 visualRadius = null + #collision = true + #ghostable = true #attacking = false + #bbox = new Float32Array(4) #colliders = [] + #entitiesInVision = [] + #projectilesInVision = [] + #pathfindingCooldown = 0 + #pathfindingObstacleLimit = null #dest = null #game = null #logic = null @@ -44,7 +45,6 @@ export default class Entity { #noPathfindingUntil = 0 #spawnPosition = new Vector2() - static bbox(x, y, 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 bbox() { return this.#bbox } + get collision() { return this.#collision } get destination() { return this.#dest } - get logic() { return this.#logic } + get entitiesInVision() { return this.#entitiesInVision } 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 x() { return this.position.x } 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 logic(value) { this.#logic = 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 x(value) { this.position.x = value } set y(value) { this.position.y = value } @@ -273,7 +285,7 @@ export default class Entity { if (targetsInRange.length < 1) { return } 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)) { return absoluteClosestTarget } @@ -313,17 +325,6 @@ export default class Entity { 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) { return this.customBboxCollidables(new Float32Array([ futurePosition.y + this.radius, @@ -435,16 +436,6 @@ export default class Entity { 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) { 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() { 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 { + this.#calculateVision() this.#cast() this.#checkHealth() this.#move() @@ -529,6 +522,20 @@ export default class Entity { 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() { if (this.casting == null) { return false diff --git a/src/game.js b/src/game.js index 9205057..23dfe34 100644 --- a/src/game.js +++ b/src/game.js @@ -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}`) diff --git a/src/index.js b/src/index.js index 42fe469..6d999fe 100644 --- a/src/index.js +++ b/src/index.js @@ -23,6 +23,7 @@ app.ws('/ws', async (req, res) => { const message = JSON.parse(rawData) console.log(message) if (message.action == 'join') { + websocket.send(JSON.stringify(game.joinReport())) const subscription = game.subscription(websocket, message.id).bind(game) game.eventEmitter.on('tick', subscription) diff --git a/src/projectile.js b/src/projectile.js index 28023ed..6b65762 100644 --- a/src/projectile.js +++ b/src/projectile.js @@ -8,9 +8,7 @@ export default class Projectile { static nextId() { return this.#nextUniqueId++ } static #nextUniqueId = 0 - bbox = new Float32Array(4) height = 50 - memory = {} owner = null position = new Vector2() radius = 0 @@ -21,19 +19,26 @@ export default class Projectile { visualRadius = null #after = null + #bbox = new Float32Array(4) #dest = null + #entitiesInVision = [] + #game = null #homingTarget = null #logic = null #onCollide = null - #game = null + #projectilesInVision = [] get after() { return this.#after } + get bbox() { return this.#bbox } + get entitiesInVision() { return this.#entitiesInVision } get game() { return this.#game } get homingTarget() { return this.#homingTarget } get logic() { return this.#logic } get onCollide() { return this.#onCollide } + get projectilesInVision() { return this.#projectilesInVision } set after(value) { this.#after = value } + set bbox(value) { this.#bbox = value } set destination(value) { this.#dest = value } set game(value) { this.#game = value } set homingTarget(value) { this.#homingTarget = value } @@ -59,16 +64,6 @@ export default class Projectile { 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) { const bbox = Entity.tunnelBbox(this.position.x, this.position.y, destination.x, destination.y, 0) const terrains = this.game?.terrains ?? [] @@ -90,6 +85,7 @@ export default class Projectile { } update() { + this.#calculateVision() this.#move() this.#checkStationaryCollisions() 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() { this.bbox[0] = this.position.y + 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 } + #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() { if (this.destination == null) { return } if (!this.position.equals(this.destination)) { return } diff --git a/src/template.js b/src/template.js index 61caac0..08054e6 100644 --- a/src/template.js +++ b/src/template.js @@ -44,6 +44,7 @@ export default class Template { const checkpointSize = 300 const recalculateDestRadius = 50 const aggroRadius = 500 + const memory = {} return function builtMinionLogic() { const entity = this @@ -62,12 +63,12 @@ export default class Template { } if ((route.length > 0 || entity.attacking) && target == null) { - const routeIndex = entity.memory.routeCheckpoint ?? 0 + const routeIndex = memory.routeCheckpoint ?? 0 const goal = route[routeIndex].clone() if (goal instanceof Vector2) { if (entity.distanceTo(goal) < checkpointSize) { if (routeIndex + 1 < route.length) { - entity.memory.routeCheckpoint = routeIndex + 1 + memory.routeCheckpoint = routeIndex + 1 } } diff --git a/src/worker/object-to-json.js b/src/worker/object-to-json.js new file mode 100644 index 0000000..4a4d3ce --- /dev/null +++ b/src/worker/object-to-json.js @@ -0,0 +1,5 @@ +import { parentPort } from 'node:worker_threads' + +parentPort.on('message', (message) => { + parentPort.postMessage(JSON.stringify(message)) +})