From 51b61ab449f8c2c141ea55f621bbf853f2d05974 Mon Sep 17 00:00:00 2001 From: Thayol Date: Sun, 12 Jan 2025 00:11:00 +0900 Subject: [PATCH] add skillshots --- public/client.js | 93 ++++++++++++++++++++++++++++++++++++++++++----- src/ability.js | 20 ++++++++++ src/effect.js | 10 +++++ src/entity.js | 22 ++++++++++- src/game.js | 47 ++++++++++++++++++++---- src/index.js | 27 ++++++++------ src/projectile.js | 86 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 275 insertions(+), 30 deletions(-) create mode 100644 src/ability.js create mode 100644 src/effect.js create mode 100644 src/projectile.js diff --git a/public/client.js b/public/client.js index 83d28f1..456c4cc 100644 --- a/public/client.js +++ b/public/client.js @@ -13,6 +13,7 @@ camera.rotation.set((60 / 180) * Math.PI, 0, 0) camera.layers.enable(1) const entityMaterial = new THREE.MeshToonMaterial({ color: 0xffffff }) +const projectileMaterial = new THREE.MeshToonMaterial({ color: 0xff00ff }) const terrainMaterial = new THREE.MeshToonMaterial({ color: 0xffd700 }) const minimapCamera = new THREE.OrthographicCamera(-10, 10, 10, -10) @@ -23,6 +24,7 @@ minimapRenderer.setAnimationLoop(minimapRender) minimapCamera.position.set(10, 10, 10) const entities = {} +const projectiles = {} const positionTweens = {} const terrains = {} @@ -44,12 +46,13 @@ global.renderer = renderer global.camera = camera global.scene = scene -const tweenDuration = 60 +const tweenDuration = 33 const keysDown = {} +const mouse = {} function render() { cameraMovement() - Object.values(positionTweens).forEach((tween) => tween.update()) + Object.values(positionTweens).forEach((tween) => tween.update()) // TODO: clean up tweens renderer.render(scene, camera) } @@ -111,6 +114,20 @@ function cameraMovement() { } } +function raycastToGround() { + const canvas = renderer.domElement + raycaster.setFromCamera(new THREE.Vector2((mouse.x / canvas.clientWidth) * 2 - 1, (mouse.y / canvas.clientHeight) * -2 + 1), camera) + const intersect = raycaster.intersectObject(ground).at(0)?.point + if (intersect != null) { + return { + x: Math.round(intersect.x * 100), + y: Math.round(intersect.y * 100), + } + } + + return null +} + var websocket = null global.websocket = null var timerId = null @@ -152,6 +169,7 @@ function connectWebSocket() { entity.rotation.x = Math.PI / 2 entity.userData.type = 'entity' entity.userData.id = e.id + entity.position.set(e.position.x / 100, e.position.y / 100, e.radius / 100) scene.add(entity) const hpMargin = 0.4 @@ -179,6 +197,38 @@ function connectWebSocket() { hp.position.x = -(1 - percentageHp) / 2 } + for (const p of Object.values(projectiles)) { + p.userData.flaggedForRemoval = true + } + + for (const p of state.projectiles ?? []) { + let projectile + if (p.id in projectiles) { + projectile = projectiles[p.id] + } + else { + projectile = new THREE.Mesh(new THREE.SphereGeometry(p.radius / 100), projectileMaterial) + projectile.userData.type = 'projectile' + projectile.userData.id = p.id + projectile.position.set(p.position.x / 100, p.position.y / 100, p.visualHeight / 100) + scene.add(projectile) + + projectiles[p.id] = projectile + } + + projectile.userData.flaggedForRemoval = false + // projectile.position.set(p.position.x / 100, p.position.y / 100, p.visualHeight / 100) + positionTweens[projectile.id] = new Tween(projectile.position).to({ x: p.position.x / 100, y: p.position.y / 100, z: p.visualHeight / 100 }, tweenDuration).start() + } + + for (const p of Object.values(projectiles)) { + if (p.userData.flaggedForRemoval) { + scene.remove(p) + delete projectiles[p.userData.id] + delete positionTweens[p.userData.id] + } + } + for (const t of state.terrains ?? []) { let terrain if (t.id in terrains) { @@ -216,22 +266,43 @@ window.addEventListener('load', () => { canvas.classList.add('canvas') canvas.addEventListener('mousedown', (event) => { - raycaster.setFromCamera(new THREE.Vector2((event.clientX / canvas.clientWidth) * 2 - 1, (event.clientY / canvas.clientHeight) * -2 + 1), camera) - const intersect = raycaster.intersectObject(ground).at(0)?.point + const intersect = raycastToGround() if (intersect != null) { + const { x, y } = intersect if (event.button == 0) { - const x = Math.round(intersect.x * 100) - const y = Math.round(intersect.y * 100) - websocket.send(JSON.stringify({ action: 'teleport', id: playerId, x, y })) + websocket.send(JSON.stringify({ action: 'cast', slot: 0, id: playerId, x, y })) } if (event.button == 2) { - const x = Math.round(intersect.x * 100) - const y = Math.round(intersect.y * 100) websocket.send(JSON.stringify({ action: 'move', id: playerId, x, y })) } } }) + window.addEventListener('keydown', (event) => { + const intersect = raycastToGround() + if (intersect != null) { + const { x, y } = intersect + if (event.code == 'KeyQ') { + websocket.send(JSON.stringify({ action: 'cast', slot: 1, id: playerId, x, y })) + } + if (event.code == 'KeyW') { + websocket.send(JSON.stringify({ action: 'cast', slot: 2, id: playerId, x, y })) + } + if (event.code == 'KeyE') { + websocket.send(JSON.stringify({ action: 'cast', slot: 3, id: playerId, x, y })) + } + if (event.code == 'KeyR') { + websocket.send(JSON.stringify({ action: 'cast', slot: 4, id: playerId, x, y })) + } + + if (event.code == 'KeyD') { + websocket.send(JSON.stringify({ action: 'teleport', id: playerId, x, y })) + } + if (event.code == 'KeyF') { + websocket.send(JSON.stringify({ action: 'teleport', id: playerId, x, y })) + } + } + }) document.addEventListener('wheel', (event) => { if (event.deltaY < 0) { @@ -264,6 +335,10 @@ window.addEventListener('load', () => { cameraLocked = !cameraLocked } }) + window.addEventListener('mousemove', (event) => { + mouse.x = event.clientX + mouse.y = event.clientY + }) document.body.appendChild(canvas) diff --git a/src/ability.js b/src/ability.js new file mode 100644 index 0000000..18cfe03 --- /dev/null +++ b/src/ability.js @@ -0,0 +1,20 @@ +import { Vector2 } from 'three' +import Projectile from './projectile.js' + +export default class Ability { + static skillshot({ range, radius, speed, onCollide, after }) { + return function(x, y) { + console.log(this) + const projectile = new Projectile() + const destination = this.position.clone().add(new Vector2(x, y).sub(this.position).normalize().multiplyScalar(range)) + projectile.owner = this.id + projectile.position.copy(this.position) + projectile.destination = destination + projectile.radius = radius + projectile.speed = speed + projectile.after = after + projectile.onCollide = onCollide + this.game?.spawnProjectile(projectile) + } + } +} diff --git a/src/effect.js b/src/effect.js new file mode 100644 index 0000000..9a85da8 --- /dev/null +++ b/src/effect.js @@ -0,0 +1,10 @@ +export default class Effect { + static damage({ despawn }) { + return function(projectile, entity) { + entity.health -= 10 + if (despawn) { + projectile.despawn() + } + } + } +} \ No newline at end of file diff --git a/src/entity.js b/src/entity.js index 40f1ced..4c1b913 100644 --- a/src/entity.js +++ b/src/entity.js @@ -2,6 +2,8 @@ import { Vector2 } from 'three' import SAT from 'sat' import SATX from './satx.js' import Pathfind from './pathfind.js' +import Ability from './ability.js' +import Effect from './effect.js' export default class Entity { id = crypto.randomUUID() @@ -9,6 +11,13 @@ export default class Entity { radius = 0 health = 1 maxHealth = 1 + abilities = [ + () => {}, + Ability.skillshot({ range: 800, radius: 5, speed: 3000, onCollide: Effect.damage({ despawn: true }) }), + () => {}, + () => {}, + () => {}, + ] #position = new Vector2() #dest = null @@ -59,6 +68,10 @@ export default class Entity { ]) } + castAction(slot, x, y) { + this.abilities[slot].bind(this)(x, y) + } + collidables() { const entityColliders = (this.game?.entities ?? []).filter((e) => e.id != this.id).map((e) => e.collider) const terrainColliders = (this.game?.terrains ?? []).map((t) => t.colliders).flat() @@ -74,6 +87,10 @@ export default class Entity { return entityColliders.concat(terrainColliders) } + despawn() { + this.game?.despawn(this) + } + fixPosition() { this.#position = SATX.fixCollisions(this.#position, this.collidables(), this.radius, this.game?.width, this.game?.height) } @@ -122,8 +139,9 @@ export default class Entity { if (this.#path.length > 0) { const destination = this.#path.at(0) - const distance = this.position.clone().sub(destination).length() - const direction = destination.clone().sub(this.position).normalize() + const difference = destination.clone().sub(this.position) + const distance = difference.length() + const direction = difference.clone().normalize() const stepTaken = this.position.clone().add(direction.multiplyScalar(speed)) const position = distance <= speed ? destination : stepTaken diff --git a/src/game.js b/src/game.js index 515fa12..bc01fd0 100644 --- a/src/game.js +++ b/src/game.js @@ -1,4 +1,7 @@ import { EventEmitter } from 'node:events' +import Entity from './entity.js' +import Terrain from './terrain.js' +import Projectile from './projectile.js' export default class Game { tickRate = 30 @@ -8,11 +11,13 @@ export default class Game { #entities = [] #eventEmitter = new EventEmitter() + #projectiles = [] #terrains = [] #tickBudget = Math.floor(1000 / this.tickRate) get entities() { return this.#entities } get eventEmitter() { return this.#eventEmitter } + get projectiles() { return this.#projectiles } get terrains() { return this.#terrains } get tickBudget() { return this.#tickBudget } @@ -20,29 +25,54 @@ export default class Game { return this.terrains.map((t) => t.unadjustedWaypoints).concat(this.entities.map((e) => e.unadjustedWaypoints)).flat() } - spawn_entity(entity) { - this.#entities.push(entity) - entity.game = this + addTerrain(terrain) { + this.#terrains.push(terrain) } - despawn(entity) { + despawn(object) { + if (object instanceof Entity) { this.despawnEntity(object) } + else if (object instanceof Terrain) { this.removeTerrain(object) } + else if (object instanceof Projectile) { this.despawnProjectile(object) } + else { console.error({ error: { reason: 'Can\'t despawn object', object } }) } + } + + despawnEntity(entity) { this.#entities = this.#entities.filter((e) => e.id != entity.id) entity.game = null } - add_terrain(terrain) { - this.#terrains.push(terrain) + despawnProjectile(projectile) { + this.#projectiles = this.#projectiles.filter((p) => p.id != projectile.id) + projectile.game = null } - remove_terrain(terrain) { + removeTerrain(terrain) { this.#terrains = this.#terrains.filter((t) => t.id != terrain.id) } + spawn(object) { + if (object instanceof Entity) { this.spawnEntity(object) } + else if (object instanceof Terrain) { this.addTerrain(object) } + else if (object instanceof Projectile) { this.spawnProjectile(object) } + else { console.error({ error: { reason: 'Can\'t spawn object', object } }) } + } + + spawnEntity(entity) { + this.#entities.push(entity) + entity.game = this + } + + spawnProjectile(projectile) { + this.#projectiles.push(projectile) + projectile.game = this + } + state() { return { ...this, entities: this.#entities.map((e) => e.state()), terrains: this.#terrains.map((t) => t.state()), + projectiles: this.#projectiles.map((p) => p.state()), } } @@ -53,7 +83,8 @@ export default class Game { } update() { - this.#entities.map((e) => e.update()) + this.#entities.forEach((e) => e.update()) + this.#projectiles.forEach((p) => p.update()) this.currentTick++ this.eventEmitter.emit('tick') } diff --git a/src/index.js b/src/index.js index ec3ce4a..1dfcb63 100644 --- a/src/index.js +++ b/src/index.js @@ -41,6 +41,10 @@ app.ws('/ws', async (req, res) => { if (message.action == 'move') { entity.moveAction(message.x, message.y) } + + if (message.action == 'cast') { + entity.castAction(message.slot, message.x, message.y) + } }) }) @@ -51,7 +55,7 @@ function testScenario() { entity1.radius = 50 entity1.maxHealth = 100 entity1.health = 80 - game.spawn_entity(entity1) + game.spawnEntity(entity1) const entity2 = new Entity() entity2.id = '2' @@ -59,7 +63,7 @@ function testScenario() { entity2.radius = 50 entity2.maxHealth = 50 entity2.health = 50 - game.spawn_entity(entity2) + game.spawnEntity(entity2) const horseshoe = new Terrain([ { x: 400, y: 200 }, @@ -73,7 +77,7 @@ function testScenario() { { x: 400, y: 300 }, ]) horseshoe.id = 'horseshoe' - game.add_terrain(horseshoe) + game.addTerrain(horseshoe) const stopsign = new Terrain([ { x: 800, y: 800 }, @@ -87,7 +91,7 @@ function testScenario() { { x: 700, y: 800 }, ]) stopsign.id = 'stopsign' - game.add_terrain(stopsign) + game.addTerrain(stopsign) const box = new Terrain([ { x: 1200, y: 700 }, @@ -96,7 +100,7 @@ function testScenario() { { x: 1300, y: 700 }, ]) box.id = 'box' - game.add_terrain(box) + game.addTerrain(box) const diamond = new Terrain([ { x: 1000, y: 300 }, @@ -105,7 +109,7 @@ function testScenario() { { x: 900, y: 400 }, ]) diamond.id = 'diamond' - game.add_terrain(diamond) + game.addTerrain(diamond) const pole = new Terrain([ { x: 400, y: 1000 }, @@ -114,7 +118,7 @@ function testScenario() { { x: 400, y: 1010 }, ]) pole.id = 'pole' - game.add_terrain(pole) + game.addTerrain(pole) } function laneScenario() { @@ -124,7 +128,7 @@ function laneScenario() { entity1.radius = 50 entity1.maxHealth = 100 entity1.health = 100 - game.spawn_entity(entity1) + game.spawnEntity(entity1) const entity2 = new Entity() entity2.id = '2' @@ -132,7 +136,7 @@ function laneScenario() { entity2.radius = 50 entity2.maxHealth = 100 entity2.health = 100 - game.spawn_entity(entity2) + game.spawnEntity(entity2) const midWallStart = new Vector2(400, 400) const midWallEnd = new Vector2(1600, 1600) @@ -151,19 +155,20 @@ function laneScenario() { const midNorthWallPoints = midWallPoints.map((p) => p.clone().add(midNorthWallOffset)) const midNorthWall = new Terrain(midNorthWallPoints) midNorthWall.id = 'midNorthWall' - game.add_terrain(midNorthWall) + game.addTerrain(midNorthWall) const midSouthWallOffset = new Vector2(200, -200) const midSouthWallPoints = midWallPoints.map((p) => p.clone().add(midSouthWallOffset)) const midSouthWall = new Terrain(midSouthWallPoints) midSouthWall.id = 'midSouthWall' - game.add_terrain(midSouthWall) + game.addTerrain(midSouthWall) } app.listen(port, () => { console.log(`Server started! Visit http://localhost:${port}`) laneScenario() + game.entities[0].castAction(1, 2000, 2000) game.start() }) diff --git a/src/projectile.js b/src/projectile.js new file mode 100644 index 0000000..d363d0b --- /dev/null +++ b/src/projectile.js @@ -0,0 +1,86 @@ +import SAT from 'sat' +import SATX from './satx.js' +import { Vector2 } from 'three' + +export default class Projectile { + id = crypto.randomUUID() + after = null + speed = 1000 + radius = 5 + owner = null + onCollide = null + visualHeight = 50 + + #position = new Vector2() + #dest = null + #game = null + + get collider() { + return new SAT.Circle(new SAT.Vector(this.x, this.y), this.radius) + } + + constructor(...options) { + Object.entries(options).forEach((value, key) => this[key] = value) + } + + get game() { return this.#game } + get position() { return this.#position } + get x() { return this.position.x } + get y() { return this.position.y } + set game(value) { this.#game = value } + set x(value) { this.position.x = value } + set y(value) { this.position.y = value } + set destination(value) { this.#dest = value } + + checkCollisions() { + (this.game?.entities ?? []).filter((e) => e.id != this.id).forEach((e) => { + if (e.id == this.owner) { return } + + if (SATX.collideObject(this.collider, e.collider)) { + this.onCollide(this, e) + } + }) + } + + checkIfArrived() { + if (!this.#position.equals(this.#dest)) { return } + + if (this.after != null) { + this.after(this) + } + + this.despawn() + } + + despawn() { + this.game?.despawn(this) + } + + state() { + return { + ...this, + position: { + x: this.x, + y: this.y, + }, + } + } + + takeStep() { + const speed = (this.speed / (this.game?.tickBudget ?? 1000)) + const destination = this.#dest + const difference = destination.clone().sub(this.position) + const distance = difference.length() + const direction = difference.clone().normalize() + const stepTaken = this.position.clone().add(direction.multiplyScalar(speed)) + const position = distance <= speed ? destination : stepTaken + + this.position.copy(position) + } + + update() { + this.takeStep() + if (this.onCollide != null) { this.checkCollisions() } + this.checkIfArrived() + } +}