From bf38f69071d2391b85f96940b034a19480e0af7f Mon Sep 17 00:00:00 2001 From: Thayol Date: Mon, 20 Jan 2025 00:05:48 +0900 Subject: [PATCH] add vision --- public/client.js | 36 +++++++++++--------- src/ability.js | 30 +++++++++++++++++ src/entity.js | 83 +++++++++++++++++++++++++++++++++++------------ src/game.js | 70 ++++++++++++++++++++++++++++++++++++--- src/index.js | 50 +++++++--------------------- src/level.js | 28 +++++++++++++++- src/projectile.js | 27 +++++++++++++-- src/template.js | 1 - 8 files changed, 241 insertions(+), 84 deletions(-) diff --git a/public/client.js b/public/client.js index 96f08e9..d28d824 100644 --- a/public/client.js +++ b/public/client.js @@ -23,12 +23,13 @@ camera.layers.enable(2) const projectileMaterial = new THREE.MeshToonMaterial({ color: 0xcccccc }) const terrainMaterial = new THREE.MeshToonMaterial({ color: 0x5c4033 }) -const bboxMaterial = new THREE.MeshToonMaterial({ color: 0xffd700, transparent: true, opacity: 0.2 }) +// const bboxMaterial = new THREE.MeshToonMaterial({ color: 0xffd700, transparent: true, opacity: 0.2 }) const opacity = 0.3 const teamMaterials = { blue: new THREE.MeshToonMaterial({ color: 0x4444ff }), blueTransparent: new THREE.MeshToonMaterial({ color: 0x4444ff, transparent: true, opacity }), - neutral: new THREE.MeshToonMaterial({ color: 0x22dd22, transparent: true, opacity }), + neutral: new THREE.MeshToonMaterial({ color: 0xcccccc }), + neutralTransparent: new THREE.MeshToonMaterial({ color: 0xcccccc, transparent: true, opacity }), red: new THREE.MeshToonMaterial({ color: 0xff4444 }), redTransparent: new THREE.MeshToonMaterial({ color: 0xff4444, transparent: true, opacity }), projectile: new THREE.MeshToonMaterial({ color: 0xff00ff, transparent: true, opacity }), @@ -169,6 +170,7 @@ function connectWebSocket() { websocket.onopen = () => { document.getElementById('connection').innerHTML = 'open' clearInterval(timerId) + websocket.send(JSON.stringify({ action: 'join', id: playerId })) } websocket.onclose = () => { websocket = null @@ -329,9 +331,10 @@ function connectWebSocket() { rotationBase.add(castingMarker) const rangeMaterial = teamMaterials['range'] - const rangeSize = (state.abilities.find((it) => it.id == e.abilities?.a)?.range ?? 0) + e.radius + const rangeSize = e.visionRange ?? 0 + // const rangeSize = (state.abilities.find((it) => it.id == e.abilities?.a)?.range ?? 0) + e.radius const rangeMarker = new THREE.Mesh(new THREE.CylinderGeometry((rangeSize) / 100, (rangeSize) / 100, 1), rangeMaterial) - const rangeMarkerSize = 4000 + const rangeMarkerSize = 5000 rangeMarker.scale.y = e.height / rangeMarkerSize rangeMarker.position.y = (e.height / (rangeMarkerSize * 2)) - (e.height / 100) rangeMarker.layers.set(1) @@ -382,7 +385,7 @@ function connectWebSocket() { scene.add(projectile) projectile.rotation.x = Math.PI / 2 // needed for the team marker... - const teamMaterial = teamMaterials['projectile'] + const teamMaterial = teamMaterials[`${p.team}Transparent`] ?? teamMaterials['projectile'] const teamMarker = new THREE.Mesh(new THREE.CylinderGeometry((p.radius) / 100, (p.radius) / 100, 1), teamMaterial) const teamMarkerSize = 4000 teamMarker.scale.y = p.height / teamMarkerSize @@ -462,7 +465,7 @@ function connectWebSocket() { } } - if (player.casting?.ability?.id == ability.id) { + if (player.casting?.ability == ability.id) { document.getElementById(`ability-${abilityIndex}-cooldown`).style.clipPath = `polygon(50% 0%, 0% 100%, 100% 100%)` // triangle } else { @@ -485,16 +488,19 @@ function connectWebSocket() { let castIndicatorDisplay = 'none' if (player.casting != null) { castIndicatorDisplay = 'block' - const castDuration = (player.casting.ability.castTime * state.tickRate) ?? 0 - const remainingCastTime = (player.casting.timestamp + castDuration) - state.currentTick - let cssPercentage = '100%' - if (remainingCastTime > 0) { - const castPercentage = 1 - (remainingCastTime / castDuration) - cssPercentage = `${Math.round(100 * castPercentage)}%` - } + const ability = state.abilities.find((it) => it.id == player.casting.ability) + if (ability != null) { + const castDuration = (ability.castTime * state.tickRate) ?? 0 + const remainingCastTime = (player.casting.timestamp + castDuration) - state.currentTick + let cssPercentage = '100%' + if (remainingCastTime > 0) { + const castPercentage = 1 - (remainingCastTime / castDuration) + cssPercentage = `${Math.round(100 * castPercentage)}%` + } - document.getElementById('cast_indicator_progress').style.clipPath = `polygon(0 0, ${cssPercentage} 0, ${cssPercentage} 100%, 0% 100%)` - document.getElementById('cast_indicator_name').innerHTML = player.casting.ability?.name ?? '' + document.getElementById('cast_indicator_progress').style.clipPath = `polygon(0 0, ${cssPercentage} 0, ${cssPercentage} 100%, 0% 100%)` + document.getElementById('cast_indicator_name').innerHTML = ability.name ?? '' + } } document.getElementById('cast_indicator').style.display = castIndicatorDisplay diff --git a/src/ability.js b/src/ability.js index 826e48d..a9f12a8 100644 --- a/src/ability.js +++ b/src/ability.js @@ -216,4 +216,34 @@ export default class Ability { cooldown: 5, effect: function controlEffect(caster, cursor) { }, }) + + static castingVision = new Ability({ + id: 'casting_vision', + name: 'Casting Vision', + radius: 300, + duration: 2, + effect: function castingVisionEffect(caster, cursor) { + const ability = this + + const currentTick = caster.game?.currentTick ?? 0 + const duration = caster.game?.secToTick(ability.duration) ?? 0 + const despawnAfter = currentTick + duration + + const castingVisionLogic = function castingVisionLogic(projectile) { + const currentTick = projectile.game?.currentTick ?? 0 + if (currentTick > despawnAfter) { + projectile.despawn() + } + } + + const projectile = new Projectile({ + logic: castingVisionLogic, + owner: caster.id, + position: cursor.clone(), + visionRange: ability.radius, + }) + + caster.game?.spawnProjectile(projectile) + }, + }) } diff --git a/src/entity.js b/src/entity.js index f996312..e595eb9 100644 --- a/src/entity.js +++ b/src/entity.js @@ -1,9 +1,10 @@ import { Vector2 } from 'three' +import Ability from './ability.js' +import Buff from './buff.js' 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 = `entity-${Entity.nextId()}` @@ -179,8 +180,8 @@ export default class Entity { if (ability == null) { return } if (this.casting != null) { - const abilityBeingCasted = this.casting.ability - if (abilityBeingCasted.id == ability.id) { + const abilityBeingCasted = this.game?.abilities.filter((it) => it.id == this.casting.ability) + if (abilityBeingCasted != null && abilityBeingCasted.id == ability.id) { return false } @@ -203,7 +204,7 @@ export default class Entity { return false } - this.casting = { ability, cursor, timestamp } // TODO: use ID only for ability + this.casting = { ability: ability.id, cursor, timestamp } return true } @@ -213,8 +214,8 @@ export default class Entity { } moveAction(cursor, attack = false) { - if (this.casting != null && this.casting.ability.moveCancelable) { - if (!attack && !(this.casting != null && this.casting.ability.id == this.abilities[0])) { + if (this.casting != null && this.game?.abilities.filter((it) => it.id == this.casting.ability)?.moveCancelable) { + if (!attack && !(this.casting != null && this.casting.ability == this.abilities[0])) { this.casting = null } } @@ -278,10 +279,12 @@ export default class Entity { } closestTargetTo(cursor, range) { - return this - .game - ?.entities - .filter((e) => this.team != e.team && e.distanceTo(cursor) <= range + this.radius + e.radius) + const visibleEntityIds = this.visibleEntities() + const entities = this.game?.entities + if (entities == null) { return } + + return entities + .filter((it) => visibleEntityIds.includes(it.id) && this.team != it.team && it.distanceTo(cursor) <= range + this.radius + it.radius) .reduce((e1, e2) => (e1?.distanceTo(cursor) ?? Infinity) < e2.distanceTo(cursor) ? e1 : e2, null) } @@ -306,6 +309,16 @@ export default class Entity { return this.position.distanceTo(cursor) } + 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, @@ -386,6 +399,28 @@ export default class Entity { return colliders.some((it) => SATX.collideObject(collider, it)) } + isInLineOfSight(destination, position = this.position) { + const bbox = Entity.tunnelBbox(position.x, position.y, destination.x, destination.y, this.radius) + const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? []) + const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => SATX.bboxCheck(bbox, it.bbox)) + if (bboxCheckedObstacles.length < 1) { return true } + + const colliders = bboxCheckedObstacles.map((it) => it.colliders()).flat() + const collider = Entity.tunnelCollider(position.x, position.y, destination.x, destination.y, this.radius) + return !colliders.some((it) => SATX.collideObject(collider, it)) + } + + isInLineOfVision(destination) { + const bbox = Entity.tunnelBbox(this.position.x, this.position.y, destination.x, destination.y, 0) + const terrains = this.game?.terrains ?? [] + const bboxCheckedObstacles = terrains.filter((it) => SATX.bboxCheck(bbox, it.bbox)) + if (bboxCheckedObstacles.length < 1) { return true } + + const colliders = bboxCheckedObstacles.map((it) => it.colliders()).flat() + const collider = Entity.tunnelCollider(this.position.x, this.position.y, destination.x, destination.y, 0) + return !colliders.some((it) => SATX.collideObject(collider, it)) + } + obstaclesInStraightPath(destination, position = this.position) { const bbox = Entity.tunnelBbox(position.x, position.y, destination.x, destination.y, this.radius) const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? []) @@ -396,16 +431,14 @@ export default class Entity { return bboxCheckedObstacles.filter((obstacle) => obstacle.colliders().some((it) => SATX.collideObject(collider, it))) } - isInLineOfSight(destination, position = this.position) { - const bbox = Entity.tunnelBbox(position.x, position.y, destination.x, destination.y, this.radius) - const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? []) - const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => SATX.bboxCheck(bbox, it.bbox)) - if (bboxCheckedObstacles.length < 1) { return true } + 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)) - const colliders = bboxCheckedObstacles.map((it) => it.colliders()).flat() - const collider = Entity.tunnelCollider(position.x, position.y, destination.x, destination.y, this.radius) - return !colliders.some((it) => SATX.collideObject(collider, it)) + return projectilesInLineOfSight.map((it) => it.id) } removeBuff(id) { @@ -444,6 +477,10 @@ export default class Entity { } } + visibleEntities() { + return this.game?.visibleEntities(this.team) + } + waypoints() { const entityColliders = (this.game?.entities ?? []).filter((e) => e.id != this.id) const terrainColliders = (this.game?.terrains ?? []) @@ -481,16 +518,22 @@ export default class Entity { return false } - const castTime = this.game?.secToTick(this.casting.ability.castTime) ?? 0 + const ability = this.game?.abilities.find((it) => it.id == this.casting.ability) + if (ability == null) { + return + } + + const castTime = this.game?.secToTick(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) + ability.effect(this, this.casting.cursor) this.casting = null + Ability.castingVision.effect(this, this.position) return true } diff --git a/src/game.js b/src/game.js index a967824..da135cc 100644 --- a/src/game.js +++ b/src/game.js @@ -1,4 +1,5 @@ import { EventEmitter } from 'node:events' +import { Vector2 } from 'three' import Ability from './ability.js' import Buff from './buff.js' import Entity from './entity.js' @@ -11,12 +12,13 @@ export default class Game { averageTick = 0 currentTick = 0 entities = [] - height = 1000 + gameLoopIntervalId = null + height = 0 projectiles = [] secondToSlowestTick = 0 terrains = [] tickRate = 30 - width = 1000 + width = 0 #behindMs = 0 #currentTiming = 0 @@ -35,6 +37,20 @@ export default class Game { return this.terrains.map((t) => t.unadjustedWaypoints).concat(this.entities.map((e) => e.unadjustedWaypoints)).flat() } + action(id, options) { + const entity = this.entities.find((it) => it.id == id) + if (entity == null) { + console.error({ error: 'Invalid ID' }) + return + } + + if (options.action == 'attack') { entity.attackAction(new Vector2(options.x, options.y)) } + if (options.action == 'cast') { entity.castAction(options.slot, new Vector2(options.x, options.y)) } + if (options.action == 'halt') { entity.haltAction(), delay } + if (options.action == 'stop') { entity.stopAction() } + if (options.action == 'move') { entity.moveAction(new Vector2(options.x, options.y)) } + } + addTerrain(terrain) { this.terrains.push(terrain) } @@ -82,7 +98,28 @@ export default class Game { } start() { - setInterval(this.#gameLoopCall.bind(this), 1) + if (this.gameLoopIntervalId != null) { return } + + this.gameLoopIntervalId = setInterval(this.#gameLoopCall.bind(this), 1) + } + + stop() { + if (this.gameLoopIntervalId == null) { return } + + clearInterval(this.gameLoopIntervalId) + this.gameLoopIntervalId = null + } + + subscription(websocket, id) { + 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)) + } } update() { @@ -98,8 +135,31 @@ export default class Game { this.currentTick++ } - visionByTeam() { - return null // TODO: vision + visibleEntities(team) { + const visionSources = this.visionSources(team) + 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())) + } + + visionSources(team) { + const entityVisionSources = this.entities.filter((it) => it.team == team) + const projectileVisionSources = this.projectiles.filter((it) => it.visionRange > 0 && (it.team == null || it.team == team)) + return entityVisionSources.concat(projectileVisionSources) + } + + 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()) + return { + ...this, + entities: this.entities.filter((it) => visibleEntities.has(it.id)), + projectiles: this.projectiles.filter((it) => visibleProjectiles.has(it.id)), + } } #calculateTickMetrics() { diff --git a/src/index.js b/src/index.js index 45470b4..05489fe 100644 --- a/src/index.js +++ b/src/index.js @@ -1,8 +1,7 @@ -import { Vector2 } from 'three' import { WebSocketExpress } from 'websocket-express' import express from 'express' import Game from './game.js' -import { Dungeon, Ravine } from './level.js' +import { Dungeon } from './level.js' const app = new WebSocketExpress() const port = 1280 @@ -20,52 +19,25 @@ app.use('/tools/', express.static('tools')) app.ws('/ws', async (req, res) => { const websocket = await res.accept() - const subscription = () => websocket.send(JSON.stringify(game)) - game.eventEmitter.on('tick', subscription) - - websocket.on('close', () => { - game.eventEmitter.removeListener('tick', subscription) - }) - websocket.on('message', (rawData) => { - let delay = 0 const message = JSON.parse(rawData) - const entity = message.id != null ? game.entities.find((e) => e.id == message.id) : null - if (entity == null) { - console.error({ error: { reason: 'Invalid ID', message } }) + console.log(message) + if (message.action == 'join') { + const subscription = game.subscription(websocket, message.id).bind(game) + game.eventEmitter.on('tick', subscription) + + websocket.on('close', () => { + game.eventEmitter.removeListener('tick', subscription) + }) return } - else { - console.log(message) - } - if (message.action == 'attack') { - setTimeout(() => entity.attackAction(new Vector2(message.x, message.y)), delay) - } - - if (message.action == 'cast') { - setTimeout(() => entity.castAction(message.slot, new Vector2(message.x, message.y)), delay) - } - - if (message.action == 'halt') { - setTimeout(() => entity.haltAction(), delay) - } - - if (message.action == 'stop') { - setTimeout(() => entity.stopAction(), delay) - } - - if (message.action == 'move') { - setTimeout(() => entity.moveAction(new Vector2(message.x, message.y)), delay) - } + game.action(message.id, message) }) }) app.listen(port, () => { console.log(`Server started! Visit http://localhost:${port}`) - // Dungeon.scenario(game) - Ravine.scenario(game) - - game.start() + Dungeon.scenario(game) }) diff --git a/src/level.js b/src/level.js index 402befc..c37fae4 100644 --- a/src/level.js +++ b/src/level.js @@ -1,11 +1,35 @@ import { Vector2 } from 'three' +import Ability from './ability.js' +import Entity from './entity.js' import Team from './team.js' import Template from './template.js' import Terrain from './terrain.js' -import Entity from './entity.js' export class Dungeon { + static scenario(game) { + game.width = 3000 + game.height = 3000 + const playerSpawn = new Vector2(game.width / 2, game.height / 2) + game.spawnEntity(new Entity(Template.player({ id: '1', spawnPosition: playerSpawn, team: Team.blue }))) + game.entities.at(0).moveAction(playerSpawn.clone().add(new Vector2(0, -200))) + const dummyLogic = function dummyLogic() { + if (game.currentTick % (3 * game.tickRate) == 0) { + this.castAction('q', playerSpawn) + } + } + const dummy = { radius: 100, visualRadius: 50, abilities: { q: Ability.straightShot.id }, logic: dummyLogic } + game.spawnEntity(new Entity({ ...dummy, position: new Vector2(1 * (game.width / 5), 1 * (game.height / 4)) })) + game.spawnEntity(new Entity({ ...dummy, position: new Vector2(1 * (game.width / 5), 3 * (game.height / 4)) })) + game.addTerrain(new Terrain([ + new Vector2(3 * (game.width / 10), 2 * (game.height / 5)), + new Vector2(3 * (game.width / 10), 1 * (game.height / 5)), + new Vector2(4 * (game.width / 10), 1 * (game.height / 5)), + new Vector2(4 * (game.width / 10), 2 * (game.height / 5)), + ])) + + game.start() + } } export class Ravine { @@ -21,6 +45,8 @@ export class Ravine { team: Team.blue, })) game.spawnEntity(player1) + + game.start() } static logic() { const game = this diff --git a/src/projectile.js b/src/projectile.js index 811685d..426d2a2 100644 --- a/src/projectile.js +++ b/src/projectile.js @@ -1,7 +1,7 @@ import { Vector2 } from 'three' +import Entity from './entity.js' import SAT from 'sat' import SATX from './satx.js' -import Entity from './entity.js' export default class Projectile { id = `projectile-${Projectile.nextId()}` @@ -13,8 +13,11 @@ export default class Projectile { memory = {} owner = null position = new Vector2() - radius = 5 + radius = 0 speed = 1000 + team = null + visibleThroughTerrain = true + visionRange = 0 visualRadius = null #after = null @@ -56,6 +59,15 @@ 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) + + return entitiesInVisionRange.concat([this]).map((it) => it.id) + } + setPosition(vector) { this.position.copy(vector) this.#calculateBbox() @@ -66,10 +78,19 @@ export default class Projectile { this.#checkStationaryCollisions() this.#checkIfArrived() if (this.#logic != null) { - this.#logic() + this.#logic(this) } } + projectilesInVision() { + const projectiles = this.game?.projectiles + if (projectiles == null) { return } + + const projectilesInVisionRange = projectiles.filter((it) => this.position.distanceTo(it.position) <= this.visionRange + it.radius) + + return projectilesInVisionRange.map((it) => it.id) + } + #calculateBbox() { this.bbox[0] = this.position.y + this.radius this.bbox[1] = this.position.x + this.radius diff --git a/src/template.js b/src/template.js index b200f2c..82cb270 100644 --- a/src/template.js +++ b/src/template.js @@ -37,7 +37,6 @@ export default class Template { } } - // TODO: minion aggro // TODO: incremental pathfinding stuck in thicker than recalculateDestRadius walls // TODO: minions despawn prematurely (too large checkpointSize?) static #minionLogic(route = []) {