From 0db1ceeedca4aca04315a950228fe1519f7af5b4 Mon Sep 17 00:00:00 2001 From: Thayol Date: Wed, 22 Jan 2025 22:52:08 +0900 Subject: [PATCH] fix dead state --- public/client.js | 46 +++++++++++++++++++++----- public/index.html | 15 +++++++++ src/ability.js | 76 +++++++++++++++++++++++++++++++++++++++--- src/buff.js | 6 ++-- src/entity.js | 84 ++++++++++++++++++++++++++++++++++++++++++----- src/game.js | 15 ++++----- src/index.js | 10 +++--- src/level.js | 23 +++++++++++-- src/template.js | 29 +++++++++++++--- src/terrain.js | 2 ++ 10 files changed, 263 insertions(+), 43 deletions(-) diff --git a/public/client.js b/public/client.js index f0fcafb..6aff89e 100644 --- a/public/client.js +++ b/public/client.js @@ -197,11 +197,18 @@ function connectWebSocket() { state.height = stateUpdates.height minimapCamera.top = state.height / 200 - minimapCamera.right = state.height / 200 + minimapCamera.right = state.width / 200 minimapCamera.bottom = -state.height / 200 - minimapCamera.left = -state.height / 200 + minimapCamera.left = -state.width / 200 minimapCamera.updateProjectionMatrix() minimapCamera.position.set(state.width / 200, state.height / 200, minimapCameraZ) + + const size = 300 + const wide = state.width > state.height + minimapRenderer.setSize( + wide ? size : (state.width / state.height) * size, + wide ? (state.height / state.width) * size : size, + ) } for (const [key, value] of Object.entries(stateUpdates)) { @@ -333,8 +340,8 @@ function connectWebSocket() { rotationBase.add(castingMarker) const rangeMaterial = teamMaterials['range'] - const rangeSize = e.visionRange ?? 0 - // 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 = 5000 rangeMarker.scale.y = e.height / rangeMarkerSize @@ -346,18 +353,32 @@ function connectWebSocket() { entities[e.id] = entity } + entity.children.at(0).visible = !e.dead + entity.children.at(1).visible = !e.dead entity.children.at(2).visible = e.buffs.some((it) => it.id == 'exposed') // TODO: only works for Exposed now + let z = e.height / 100 + + if (e.dead) { + entity.rotation.x = 0 + entity.position.z = 0 + z = 0 + } + else { + entity.rotation.x = Math.PI / 2 + entity.position.z = e.height / 100 + } + entity.userData.flaggedForRemoval = false entity.children.at(3).rotation.y = e.rotation - positionTweens[entity.id] = new Tween(entity.position).to({ x: e.position.x / 100, y: e.position.y / 100, z: e.height / 100 }, tweenDuration).start() + positionTweens[entity.id] = new Tween(entity.position).to({ x: e.position.x / 100, y: e.position.y / 100, z }, tweenDuration).start() const hp = entity.children.at(0).children.at(0) const percentageHp = e.health / e.maxHealth hp.scale.x = percentageHp hp.position.x = -(1 - percentageHp) / 2 - entity.children.at(4).visible = e.id == playerId // TODO: undo, just for clarity + // entity.children.at(4).visible = e.id == playerId entity.children.at(3).children.at(0).visible = e.casting != null } @@ -446,8 +467,8 @@ function connectWebSocket() { if (playerId != null) { const player = state.entities.find((e) => e.id == playerId) if (player != null) { - for (let abilityIndex = 0; abilityIndex < 4; abilityIndex++) { - const abilityKey = ['a', 'q', 'w', 'e'][abilityIndex] + for (let abilityIndex = 0; abilityIndex < 7; abilityIndex++) { + const abilityKey = ['a', 'q', 'w', 'e', 'r', 'd', 'f'][abilityIndex] if (player.abilities[abilityKey] != null) { const abilityId = player.abilities[abilityKey] const ability = state.abilities.find((it) => it.id == abilityId) @@ -565,6 +586,15 @@ window.addEventListener('load', () => { if (event.code == 'KeyE') { websocket.send(JSON.stringify({ action: 'cast', slot: 'e', id: playerId, x, y })) } + if (event.code == 'KeyR') { + websocket.send(JSON.stringify({ action: 'cast', slot: 'r', id: playerId, x, y })) + } + if (event.code == 'KeyD') { + websocket.send(JSON.stringify({ action: 'cast', slot: 'd', id: playerId, x, y })) + } + if (event.code == 'KeyF') { + websocket.send(JSON.stringify({ action: 'cast', slot: 'f', id: playerId, x, y })) + } } }) diff --git a/public/index.html b/public/index.html index 0346538..8883ce4 100644 --- a/public/index.html +++ b/public/index.html @@ -199,6 +199,21 @@
+
+ R +
+
+
+
+ D +
+
+
+
+ F +
+
+
diff --git a/src/ability.js b/src/ability.js index a9f12a8..34e8f2e 100644 --- a/src/ability.js +++ b/src/ability.js @@ -10,7 +10,7 @@ export default class Ability { name = 'Ability' - castTime = 0 + castTime = null cooldown = 0 damage = 0 moveCancelable = false @@ -18,15 +18,17 @@ export default class Ability { range = 0 speed = 1000 - #effect = () => {} + #effect = null - get effect() { return this.#effect } + get effect() { return this.#effect ?? Ability.noEffect } set effect(value) { this.#effect = value } constructor(options = {}) { Object.entries(options).forEach(([key, value]) => this[key] = value) } + static get noEffect() { return function noEffect() {} } + static straightShot = new Ability({ id: 'straight_shot', name: 'Straight Shot', @@ -155,8 +157,7 @@ export default class Ability { static blink = new Ability({ id: 'blink', name: 'Blink', - castTime: 0.25, - cooldown: 2, + cooldown: 10, range: 475, effect: function blinkEffect(caster, cursor) { const ability = this @@ -246,4 +247,69 @@ export default class Ability { caster.game?.spawnProjectile(projectile) }, }) + + static circleOfResurrectionChannel = new Ability({ + id: 'channel:circle_of_resurrection', + name: 'Channeling: Circle of Resurrection', + castTime: 3, + }) + + static circleOfResurrection = new Ability({ + id: 'circle_of_resurrection', + name: 'Circle of Resurrection', + castTime: 0.5, + cooldown: 100, + duration: 3, + radius: 300, + range: 300, + effect: function circleOfResurrectionEffect(caster, cursor) { + const ability = this + caster.haltAction() + + const direction = cursor.clone().sub(caster.position) + if (direction.length() > ability.range) { + direction.normalize().multiplyScalar(ability.range) + } + + const destination = caster.position.clone().add(direction) + + const team = caster.team + const currentTick = caster.game?.currentTick ?? 0 + const duration = caster.game?.secToTick(ability.duration) ?? 0 + const despawnAfter = currentTick + duration + const casterPosition = caster.position.clone() + + const circleOfResurrectionLogic = function castingVisionLogic(projectile) { + const currentTick = projectile.game?.currentTick ?? 0 + if (casterPosition.distanceTo(caster.position) > 1) { + projectile.despawn() + } + + if (currentTick > despawnAfter) { + const entities = projectile.game?.entities ?? [] + const pos = projectile.position + projectile.despawn() + const nearbyDeadTeammates = entities.filter((it) => it.dead && it.team == team) + const closestDeadTeammate = nearbyDeadTeammates.reduce((e1, e2) => (e1?.distanceTo(pos) ?? Infinity) < e2.distanceTo(pos) ? e1 : e2, null) + if (closestDeadTeammate != null) { + closestDeadTeammate.revive(closestDeadTeammate.maxHealth / 4) + caster.cooldown(ability.id) + } + } + } + + const projectile = new Projectile({ + logic: circleOfResurrectionLogic, + owner: caster.id, + position: destination, + radius: ability.radius, + visualRadius: 0, + }) + + caster.game?.spawnProjectile(projectile) + if (caster.casting != null) { + caster.forceCast(Ability.circleOfResurrectionChannel.id, destination) + } + }, + }) } diff --git a/src/buff.js b/src/buff.js index 1d09e64..4180edf 100644 --- a/src/buff.js +++ b/src/buff.js @@ -7,11 +7,13 @@ export default class Buff { duration = 0 - #effect = () => {} + #effect = null - get effect() { return this.#effect } + get effect() { return this.#effect ?? Buff.noEffect } set effect(value) { this.#effect = value } + static get noEffect() { return function noEffect() {} } + constructor(options = {}) { Object.entries(options).forEach(([key, value]) => this[key] = value) } diff --git a/src/entity.js b/src/entity.js index 1340f26..fe2608c 100644 --- a/src/entity.js +++ b/src/entity.js @@ -172,10 +172,14 @@ export default class Entity { set y(value) { this.position.y = value } attackAction(cursor) { + if (this.dead) { return } + this.moveAction(cursor, true) } castAction(slot, cursor, halt = false) { + if (this.dead) { return } + const ability = this.ability(slot) if (ability == null) { return } @@ -197,6 +201,12 @@ export default class Entity { this.rotation = targetPosition.clone().sub(this.position).angle() } + if (ability.castTime == null) { + ability.effect(this, cursor) + + return true + } + const cooldown = this.game?.secToTick(ability.cooldown) ?? 0 const lastCast = this.cooldowns[ability.id] const timestamp = this.game?.currentTick ?? 0 @@ -210,10 +220,14 @@ export default class Entity { } haltAction() { + if (this.dead) { return } + this.#moving = false } moveAction(cursor, attack = false) { + if (this.dead) { return } + 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 @@ -226,6 +240,8 @@ export default class Entity { } stopAction() { + if (this.dead) { return } + this.casting = null this.#moving = true this.#attacking = false @@ -301,10 +317,12 @@ export default class Entity { customBboxCollidables(bbox) { const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? []) - return entitiesAndTerrains.filter((it) => it.collision && !((this.ghosting && it.ghostable) || (this.ghostable && it.ghosting)) && SATX.bboxCheck(bbox, it.bbox)) + return entitiesAndTerrains.filter((it) => !it.dead && it.collision && !((this.ghosting && it.ghostable) || (this.ghostable && it.ghosting)) && SATX.bboxCheck(bbox, it.bbox)) } damage(amount, source = null) { + if (this.dead) { return } + let damage = amount if (this.hasBuff(Buff.exposed.id)) { const buff = this.getBuff(Buff.exposed.id) @@ -325,6 +343,27 @@ export default class Entity { return this.position.distanceTo(cursor) } + forceCast(abilityId, cursor) { + if (this.dead) { return } + + const ability = this.game?.abilities.find((it) => it.id == abilityId) + if (ability == null) { return } + + const targetPosition = (cursor instanceof Vector2) ? cursor : this.game?.entities.find((it) => it.id == cursor)?.position + if (targetPosition instanceof Vector2) { + this.rotation = targetPosition.clone().sub(this.position).angle() + } + + const timestamp = this.game?.currentTick ?? 0 + + if (ability.castTime == null) { + ability.effect(this, cursor) + } + else { + this.casting = { ability: ability.id, cursor, timestamp } + } + } + futureCollidables(futurePosition) { return this.customBboxCollidables(new Float32Array([ futurePosition.y + this.radius, @@ -335,6 +374,8 @@ export default class Entity { } getBuff(id) { + if (this.dead) { return } + const entityBuff = this.buffs.find((it) => it.id == id) if (entityBuff == null) { return } @@ -345,10 +386,14 @@ export default class Entity { } hasBuff(id) { + if (this.dead) { return false } + return this.buffs.some((it) => it.id == id) && this.game?.buffs.some((it) => it.id == id) } heal(amount) { + if (this.dead) { return } + this.health = Math.min(Math.max(0, this.health + amount), this.maxHealth) } @@ -403,7 +448,7 @@ export default class Entity { 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) => it.collision && !((this.ghosting && it.ghostable) || (this.ghostable && it.ghosting)) && SATX.bboxCheck(bbox, it.bbox)) + const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => !it.dead && it.collision && !((this.ghosting && it.ghostable) || (this.ghostable && it.ghosting)) && SATX.bboxCheck(bbox, it.bbox)) if (bboxCheckedObstacles.length < 1) { return true } const colliders = bboxCheckedObstacles.map((it) => it.colliders()).flat() @@ -429,7 +474,7 @@ export default class Entity { 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 ?? []) - const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => it.collision && !((this.ghosting && it.ghostable) || (this.ghostable && it.ghosting)) && SATX.bboxCheck(bbox, it.bbox)) + const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => !it.dead && it.collision && !((this.ghosting && it.ghostable) || (this.ghostable && it.ghosting)) && SATX.bboxCheck(bbox, it.bbox)) if (bboxCheckedObstacles.length < 1) { return [] } const collider = Entity.tunnelCollider(position.x, position.y, destination.x, destination.y, this.radius) @@ -437,6 +482,8 @@ export default class Entity { } removeBuff(id) { + if (this.dead) { return } + this.buffs = this.buffs.filter((it) => it.id != id) } @@ -446,6 +493,15 @@ export default class Entity { this.dead = false } + revive(startingHealth = null) { + this.dead = false + const health = (startingHealth ?? this.maxHealth) + this.health = Math.max(0, Math.min(health, this.maxHealth)) + + this.#calculateCollider() + this.#calculateVision() + } + setPosition(vector) { this.position.copy(vector) this.#calculateCollider() @@ -475,15 +531,14 @@ export default class Entity { ]) } - // TODO: make non-race-condition calculations multi-threaded update() { + this.#calculateVision() + this.#checkHealth() if (this.dead) { // TODO: do something while the entity is dead (and disallow casting, vision, etc) } else { - this.#calculateVision() this.#cast() - this.#checkHealth() this.#move() this.#tickBuffs() this.fixPosition() @@ -523,6 +578,12 @@ export default class Entity { } #calculateVision() { + if (this.dead) { + this.#entitiesInVision = [this.id] + this.#projectilesInVision = [] + return + } + const entities = this.game?.entities ?? [] const projectiles = this.game?.projectiles ?? [] @@ -555,15 +616,22 @@ export default class Entity { ability.effect(this, this.casting.cursor) - this.casting = null + if (this.casting.ability == ability.id) { + this.casting = null + } + // TODO: only spawn castingVision if slightly outside regular vision (or obstructed) Ability.castingVision.effect(this, this.position) return true } #checkHealth() { - if (this.health <= 0) { + if (!this.dead && this.health <= 0) { this.dead = true + this.buffs = [] + } + else if (this.dead && this.health > 0) { + this.health = 0 } } diff --git a/src/game.js b/src/game.js index 9fbbfa5..5690c6f 100644 --- a/src/game.js +++ b/src/game.js @@ -19,21 +19,18 @@ export default class Game { tickRate = 30 width = 0 - #eventEmitter = new EventEmitter() #gameLoopIntervalId = null #logic = null #nextTickAt = 0 #startTimestamp = 0 + #subscriptions = new Map() #tickBudget = 1000 / this.tickRate get logic() { return this.#logic } - get eventEmitter() { return this.#eventEmitter } get tickBudget() { return this.#tickBudget } - set logic(value) { this.#logic = value } + get subscriptions() { return this.#subscriptions } - constructor() { - this.#eventEmitter.setMaxListeners(20) - } + set logic(value) { this.#logic = value } action(id, options) { const entity = this.entities.find((it) => it.id == id) @@ -140,6 +137,10 @@ export default class Game { } update() { + for (const subscription of this.#subscriptions.values()) { + subscription() + } + const callUpdate = function callUpdate(object) { object.update() } this.entities.forEach(callUpdate) // TODO: entity with lower ID has unfair collision advantage (regular loop + until it fully loops around with an offset?) this.projectiles.forEach(callUpdate) @@ -147,8 +148,6 @@ export default class Game { this.#logic() } - this.eventEmitter.emit('tick') - this.currentTick++ } diff --git a/src/index.js b/src/index.js index aac1d5b..5eeaf1f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,4 @@ -import { Dungeon, Ravine } from './level.js' +import { Dungeon } from './level.js' import { WebSocketExpress } from 'websocket-express' import express from 'express' import Game from './game.js' @@ -33,13 +33,14 @@ app.ws('/ws', async (req, res) => { console.log(message) if (message.action == 'join') { const id = message.id + const connectionId = crypto.randomUUID() websocket.send(JSON.stringify(game.joinReport())) const subscription = game.subscription(websocket, id).bind(game) - game.eventEmitter.on('tick', subscription) + game.subscriptions.set(connectionId, subscription) websocket.on('close', () => { console.log({ event: 'disconnected', id }) - game.eventEmitter.removeListener('tick', subscription) + game.subscriptions.delete(connectionId) }) return } @@ -51,6 +52,5 @@ app.ws('/ws', async (req, res) => { app.listen(port, () => { console.info(`Server started! Visit http://localhost:${port}`) - // Dungeon.scenario(game) - Ravine.scenario(game) + Dungeon.scenario(game) }) diff --git a/src/level.js b/src/level.js index e06e3ed..4556383 100644 --- a/src/level.js +++ b/src/level.js @@ -6,11 +6,28 @@ import Terrain from './terrain.js' export class Dungeon { static scenario(game) { - game.width = 3000 - game.height = 3000 + game.width = 2500 + game.height = 1500 - const from = new Vector2(100, 100) + const team = Team.blue + const enemy = Team.neutral + + game.spawnEntity(new Entity(Template.player({ id: '1', spawnPosition: new Vector2(1500, 700), team }))) + game.spawnEntity(new Entity(Template.player({ id: '2', spawnPosition: new Vector2(200, 1300), team, health: 10 }))) + + game.spawnEntity(new Entity(Template.basilisk({ id: 'boss', spawnPosition: new Vector2(2200, 750), team: enemy }))) + + setTimeout(() => game.entities.find((it) => it.id == '1').damage(9999), 10) + + game.start() + } +} + +export class Zigzag { + static scenario(game) { + game.width = 3000 game.height = 2000 + const from = new Vector2(100, 100) game.spawnEntity(new Entity(Template.player({ id: '1', spawnPosition: from, pathfindingObstacleLimit: 1, pathfindingCooldown: 0 }))) for (let i = 100; i < game.width; i += 300) { const highest = ((i - 100) % 600) == 0 ? 0 : 500 diff --git a/src/template.js b/src/template.js index 08054e6..69abb56 100644 --- a/src/template.js +++ b/src/template.js @@ -3,6 +3,19 @@ import Ability from './ability.js' import Team from './team.js' export default class Template { + static basilisk(overrides) { + return { + abilities: {}, + height: 100, + logic: this.#basiliskLogic, + radius: 180, + speed: 230, + visualRadius: 170, + maxHealth: 3000, + ...overrides, + } + } + static minion(team, options = {}) { return { abilities: { a: options.ranged ? Ability.rangedAttack.id : Ability.meleeAttack.id }, @@ -26,7 +39,9 @@ export default class Template { a: Ability.rangedAttack.id, q: Ability.straightShot.id, w: Ability.expose.id, - e: Ability.blink.id, + e: Ability.control.id, + d: Ability.circleOfResurrection.id, + f: Ability.blink.id, }, height: 80, logic: this.#playerLogic, @@ -40,6 +55,12 @@ export default class Template { } } + static #basiliskLogic() { + const entity = this + + return + } + static #minionLogic(route = [], odd = false) { const checkpointSize = 300 const recalculateDestRadius = 50 @@ -87,8 +108,8 @@ export default class Template { static #playerLogic() { const entity = this - if (entity.dead) { - entity.respawn() - } + // if (entity.dead) { + // entity.respawn() + // } } } diff --git a/src/terrain.js b/src/terrain.js index 9a98322..5d03994 100644 --- a/src/terrain.js +++ b/src/terrain.js @@ -32,7 +32,9 @@ export default class Terrain { this.#calculateUnadjustedWaypoints() this.#calculateBbox() } + get vertices() { return this.#vertices } + get dead() { return false } static waypointsForSide(fromVertex, toVertex, isClockwise = false) { const from = isClockwise ? toVertex : fromVertex