diff --git a/public/client.js b/public/client.js index 0262264..7402c78 100644 --- a/public/client.js +++ b/public/client.js @@ -6,11 +6,14 @@ const scene = new THREE.Scene() const raycaster = new THREE.Raycaster() const camera = new THREE.PerspectiveCamera(30, window.innerWidth / window.innerHeight, 0.1, 1000) const renderer = new THREE.WebGLRenderer() +const backgroundColor = new THREE.Color().setHex(0x112233) +scene.background = backgroundColor renderer.setSize(window.innerWidth, window.innerHeight) renderer.setAnimationLoop(render) camera.position.set(5, -12, 10) camera.rotation.set((60 / 180) * Math.PI, 0, 0) camera.layers.enable(1) +camera.layers.enable(2) const entityMaterial = new THREE.MeshToonMaterial({ color: 0xffffff }) const projectileMaterial = new THREE.MeshToonMaterial({ color: 0xff00ff }) @@ -159,6 +162,10 @@ function connectWebSocket() { ground.position.set(state.width / 200, state.height / 200, 0) } + for (const e of Object.values(entities)) { + e.userData.flaggedForRemoval = true + } + for (const e of state.entities ?? []) { let entity if (e.id in entities) { @@ -190,6 +197,7 @@ function connectWebSocket() { entities[e.id] = entity } + entity.userData.flaggedForRemoval = false positionTweens[entity.id] = new Tween(entity.position).to({ x: e.position.x / 100, y: e.position.y / 100, z: e.radius / 100 }, tweenDuration).start() const hp = entity.children.at(0).children.at(0) @@ -198,6 +206,14 @@ function connectWebSocket() { hp.position.x = -(1 - percentageHp) / 2 } + for (const e of Object.values(entities)) { + if (e.userData.flaggedForRemoval) { + scene.remove(e) + delete entities[e.userData.id] + delete positionTweens[e.userData.id] + } + } + for (const p of Object.values(projectiles)) { p.userData.flaggedForRemoval = true } @@ -212,6 +228,7 @@ function connectWebSocket() { projectile.userData.type = 'projectile' projectile.userData.id = p.id projectile.position.set(p.position.x / 100, p.position.y / 100, p.visualHeight / 100) + projectile.layers.set(2) scene.add(projectile) projectiles[p.id] = projectile diff --git a/src/ability.js b/src/ability.js index 5fb9e92..55c561f 100644 --- a/src/ability.js +++ b/src/ability.js @@ -28,7 +28,7 @@ export default class Ability { id: 'straight_shot', name: 'Straight Shot', castTime: 0.1, - cooldown: 7, + cooldown: 1, damage: 10, radius: 7, range: 800, @@ -54,20 +54,20 @@ export default class Ability { }, }) - static basicAttack = new Ability({ - id: 'basic_attack', - name: 'Basic Attack', + static rangedAttack = new Ability({ + id: 'ranged_attack', + name: 'Ranged Attack', castTime: 0.25, cooldown: 1.25, damage: 5, radius: 5, - range: 600, + range: 500, speed: 600, - effect: function basicAttackEffect(caster, cursor) { + effect: function rangedAttackEffect(caster, cursor) { const ability = this let closest = null let distance = Infinity - caster.game?.entities.filter((e) => e.id != caster.id && e.position.clone().sub(caster.position).length() < ability.range).forEach((e) => { + caster.game?.entities.filter((e) => e.team != caster.team && e.position.clone().sub(caster.position).length() < ability.range).forEach((e) => { const newDistance = e.position.clone().sub(cursor).length() < distance if (newDistance < distance) { closest = e @@ -77,12 +77,12 @@ export default class Ability { if (closest == null) { return } - const basicAttackAfter = function basicAttackAfter() { + const rangedAttackAfter = function rangedAttackAfter() { closest.damage(ability.damage) } const projectile = new Projectile({ - after: basicAttackAfter, + after: rangedAttackAfter, homingTarget: closest, owner: caster, position: caster.position.clone(), @@ -95,12 +95,67 @@ export default class Ability { }, }) + static meleeAttack = new Ability({ + id: 'melee_attack', + name: 'Melee Attack', + castTime: 0.1, + cooldown: 1.75, + damage: 10, + radius: 5, + range: 100, + effect: function meleeAttackEffect(caster, cursor) { + const ability = this + let closest = null + let distance = Infinity + caster.game?.entities.filter((e) => e.team != caster.team && e.position.clone().sub(caster.position).length() < ability.range).forEach((e) => { + const newDistance = e.position.clone().sub(cursor).length() < distance + if (newDistance < distance) { + closest = e + distance = newDistance + } + }) + + if (closest == null) { return } + + closest.damage(ability.damage) + caster.cooldown(ability.id) + }, + }) + static shieldThrow = new Ability({ id: 'shield_throw', name: 'Shield Throw', - castTime: 0.1, - cooldown: 7, - effect: function shieldThrowEffect(caster, cursor) { }, + castTime: 0.15, + cooldown: 5, + radius: 20, + range: 1000, + speed: 2000, + effect: function shieldThrowEffect(caster, cursor) { + const ability = this + const shieldThrowReturn = function shieldThrowReturn(projectile, homingTarget) { + const returnProjectile = new Projectile({ + owner: caster, + position: projectile.position.clone(), + radius: ability.radius, + speed: ability.speed, + homingTarget: caster, + }) + + caster.game?.spawnProjectile(returnProjectile) + } + + const projectile = new Projectile({ + after: shieldThrowReturn, + owner: caster, + position: caster.position.clone(), + radius: ability.radius, + speed: ability.speed, + }) + + projectile.destination = caster.position.clone().add(cursor.clone().sub(caster.position).normalize().multiplyScalar(ability.range)) + caster.game?.spawnProjectile(projectile) + caster.cooldown(ability.id) + }, }) static blink = new Ability({ diff --git a/src/entity.js b/src/entity.js index 672f89d..8eea02a 100644 --- a/src/entity.js +++ b/src/entity.js @@ -3,6 +3,7 @@ import SAT from 'sat' import SATX from './satx.js' import Pathfind from './pathfind.js' import Ability from './ability.js' +import Team from './team.js' export default class Entity { id = crypto.randomUUID() @@ -11,21 +12,23 @@ export default class Entity { health = 1 maxHealth = 1 abilities = [ - Ability.basicAttack, + Ability.rangedAttack, Ability.straightShot, Ability.shieldThrow, Ability.blink, ] casting = null - // TODO: teams + team = Team.neutral cooldowns = {} #attack = false - #position = new Vector2() #dest = null #game = null + #logic = null #path = [] + #position = new Vector2() + #scheduledPathfinding = null static collider(x, y, radius) { return new SAT.Circle(new SAT.Vector(x, y), radius) @@ -35,11 +38,19 @@ export default class Entity { Object.entries(options).forEach(([key, value]) => this[key] = value) } + get destination() { return this.#dest } + get logic() { return this.#logic } get game() { return this.#game } get position() { return this.#position } + get scheduledPathfinding() { return this.#scheduledPathfinding } get x() { return this.position.x } get y() { return this.position.y } + + set destination(value) { this.#dest = value } + set logic(value) { this.#logic = value } set game(value) { this.#game = value } + set position(value) { this.#position = value } + set scheduledPathfinding(value) { this.#scheduledPathfinding = value } set x(value) { this.position.x = value } set y(value) { this.position.y = value } @@ -123,20 +134,6 @@ export default class Entity { this.#attack = false } - autoAttack() { - if (!this.#attack) { return false } - - if (this.game?.entities.some((e) => e.id != this.id && e.position.clone().sub(this.position).length() < this.abilities[0].range)) { - const cooldown = this.game?.secToTick(this.abilities[0].cooldown) ?? 0 - const lastCast = this.cooldowns[this.abilities[0].id] - const timestamp = this.game?.currentTick ?? 0 - if (lastCast != null && lastCast + cooldown > timestamp) { return false } - - const target = this.#dest ?? this.position - this.castAction(0, target.x, target.y, false) - } - } - cast() { if (this.casting == null) { return false @@ -209,50 +206,71 @@ export default class Entity { this.fixPosition() } - // TODO: unset destination on teleports, etc. - // TODO: recalculate path on obstructions (currently next waypoint is used) takeStep(distanceTraveled = 0) { if (this.casting != null) { return false } - const speed = (this.speed / (this.game?.tickBudget ?? 1000)) - distanceTraveled - const collidables = this.collidables() - if (this.#dest != null) { - const fixedDest = SATX.clamp(SATX.fixCollisions(this.#dest, collidables, this.radius), this.game?.width, this.game?.height, this.radius) + if (this.#attack && this.game?.entities.some((e) => e.team != this.team && e.position.clone().sub(this.position).length() < this.abilities[0].range)) { + const cooldown = this.game?.secToTick(this.abilities[0].cooldown) ?? 0 + const lastCast = this.cooldowns[this.abilities[0].id] + const timestamp = this.game?.currentTick ?? 0 + if (lastCast != null && lastCast + cooldown > timestamp) { return false } - if (this.#path.length < 1 || !this.#path.at(-1).equals(fixedDest)) { - // console.time('pathfinding') - const start = SATX.vectorToFloat32Array(this.position) - const goal = SATX.vectorToFloat32Array(fixedDest) - const nonUniqueWaypoints = this.waypoints().map((w) => SATX.vectorToFloat32Array(w)).concat([start, goal]) - const waypoints = Pathfind.uniqueWaypoints(nonUniqueWaypoints) - const graph = Pathfind.buildGraph(waypoints, collidables, this.radius) - this.#path = Pathfind.shortestPath(graph, start, goal).map((waypoint) => new Vector2(waypoint[0], waypoint[1])) - // console.timeEnd('pathfinding') + const target = this.#dest ?? this.position + this.castAction(0, target.x, target.y, false) + return true + } + + if (this.#dest == null) { return false } + + const collidables = this.collidables() + const fixedDest = SATX.clamp(SATX.fixCollisions(this.#dest, collidables, this.radius), this.game?.width, this.game?.height, this.radius) + const tunnel = SATX.entityTunnel(this.#position.x, this.#position.y, fixedDest.x, fixedDest.y, this.radius) + const destinationInLineOfSight = !SATX.collideObjects(tunnel, collidables) + + if (this.#path.length > 0) { + if (!destinationInLineOfSight) { + this.#path = [] + } + } + + if (this.#path.length < 1 || !this.#path.at(-1).equals(fixedDest)) { + if (destinationInLineOfSight) { + this.#path = [fixedDest] + } + } + + if ((this.#path.length < 1 || !this.#path.at(-1).equals(fixedDest)) && (!this.#scheduledPathfinding || this.game?.currentTick % this.game?.tickRate == this.#scheduledPathfinding)) { + const start = SATX.vectorToFloat32Array(this.position) + const goal = SATX.vectorToFloat32Array(fixedDest) + const nonUniqueWaypoints = this.waypoints().map((w) => SATX.vectorToFloat32Array(w)).concat([start, goal]) + const waypoints = Pathfind.uniqueWaypoints(nonUniqueWaypoints) + const graph = Pathfind.buildGraph(waypoints, collidables, this.radius) + this.#path = Pathfind.shortestPath(graph, start, goal).map((waypoint) => new Vector2(waypoint[0], waypoint[1])) + } + + if (this.#path.length > 0) { + const speed = (this.speed / (this.game?.tickBudget ?? 1000)) - distanceTraveled + const destination = this.#path.at(0) + 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 + + const collider = Entity.collider(position.x, position.y, this.radius) + const isColliding = SATX.collideObjects(collider, this.collidables()) + + if (!isColliding) { + this.position.copy(position) } - if (this.#path.length > 0) { - const destination = this.#path.at(0) - 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 - - const collider = Entity.collider(position.x, position.y, this.radius) - const isColliding = SATX.collideObjects(collider, this.collidables()) - - if (!isColliding) { - this.position.copy(position) + if (this.position.equals(destination)) { + this.#path = this.#path.slice(1) + if (this.#path.length > 0) { + this.takeStep(distance) } - - if (this.position.equals(destination)) { - this.#path = this.#path.slice(1) - if (this.#path.length > 0) { - this.takeStep(distance) - } - else { - this.#dest = null - } + else { + this.#dest = null } } } @@ -262,19 +280,8 @@ export default class Entity { this.cast() this.takeStep() this.fixPosition() - this.autoAttack() - - // TODO: proper death and respawn - if (this.health <= 0) { - if (this.id == '1' || this.id == '2') { - this.health = this.maxHealth - if (this.id == '1') { - this.teleport(new Vector2(200, 200)) - } - if (this.id == '2') { - this.teleport(new Vector2(1800, 1800)) - } - } + if (this.#logic != null) { + this.#logic() } } diff --git a/src/game.js b/src/game.js index c1c71f9..f83d52b 100644 --- a/src/game.js +++ b/src/game.js @@ -11,17 +11,20 @@ export default class Game { running = false nextTick = 0 + #logic = null #entities = [] #eventEmitter = new EventEmitter() #projectiles = [] #terrains = [] #tickBudget = 1000 / this.tickRate + get logic() { return this.#logic } 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 } + set logic(value) { this.#logic = value } get unadjustedWaypoints() { return this.terrains.map((t) => t.unadjustedWaypoints).concat(this.entities.map((e) => e.unadjustedWaypoints)).flat() @@ -104,6 +107,10 @@ export default class Game { update() { this.#entities.forEach((e) => e.update()) this.#projectiles.forEach((p) => p.update()) + if (this.#logic != null) { + this.#logic() + } + this.currentTick++ this.eventEmitter.emit('tick') } diff --git a/src/index.js b/src/index.js index 3d80af4..97c228b 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,8 @@ import Game from './game.js' import Entity from './entity.js' import Terrain from './terrain.js' import { Vector2 } from 'three' +import Team from './team.js' +import Ability from './ability.js' const app = new WebSocketExpress() const port = 1280 @@ -60,94 +62,100 @@ app.ws('/ws', async (req, res) => { }) }) -function testScenario() { - const entity1 = new Entity() - entity1.id = '1' - entity1.teleport(new Vector2(200, 500)) - entity1.radius = 50 - entity1.maxHealth = 100 - entity1.health = 80 - game.spawnEntity(entity1) +// function testScenario() { +// const entity1 = new Entity() +// entity1.id = '1' +// entity1.teleport(new Vector2(200, 500)) +// entity1.radius = 50 +// entity1.maxHealth = 100 +// entity1.health = 80 +// game.spawnEntity(entity1) - const entity2 = new Entity() - entity2.id = '2' - entity2.teleport(new Vector2(110, 110)) - entity2.radius = 50 - entity2.maxHealth = 50 - entity2.health = 50 - game.spawnEntity(entity2) +// const entity2 = new Entity() +// entity2.id = '2' +// entity2.teleport(new Vector2(110, 110)) +// entity2.radius = 50 +// entity2.maxHealth = 50 +// entity2.health = 50 +// game.spawnEntity(entity2) - const horseshoe = new Terrain([ - { x: 400, y: 200 }, - { x: 600, y: 200 }, - { x: 700, y: 300 }, - { x: 650, y: 600 }, - { x: 400, y: 600 }, - { x: 400, y: 450 }, - { x: 600, y: 500 }, - { x: 600, y: 300 }, - { x: 400, y: 300 }, - ]) - horseshoe.id = 'horseshoe' - game.addTerrain(horseshoe) +// const horseshoe = new Terrain([ +// { x: 400, y: 200 }, +// { x: 600, y: 200 }, +// { x: 700, y: 300 }, +// { x: 650, y: 600 }, +// { x: 400, y: 600 }, +// { x: 400, y: 450 }, +// { x: 600, y: 500 }, +// { x: 600, y: 300 }, +// { x: 400, y: 300 }, +// ]) +// horseshoe.id = 'horseshoe' +// game.addTerrain(horseshoe) - const stopsign = new Terrain([ - { x: 800, y: 800 }, - { x: 900, y: 900 }, - { x: 900, y: 1000 }, - { x: 800, y: 1100 }, - { x: 800, y: 1100 }, - { x: 700, y: 1100 }, - { x: 600, y: 1000 }, - { x: 600, y: 900 }, - { x: 700, y: 800 }, - ]) - stopsign.id = 'stopsign' - game.addTerrain(stopsign) +// const stopsign = new Terrain([ +// { x: 800, y: 800 }, +// { x: 900, y: 900 }, +// { x: 900, y: 1000 }, +// { x: 800, y: 1100 }, +// { x: 800, y: 1100 }, +// { x: 700, y: 1100 }, +// { x: 600, y: 1000 }, +// { x: 600, y: 900 }, +// { x: 700, y: 800 }, +// ]) +// stopsign.id = 'stopsign' +// game.addTerrain(stopsign) - const box = new Terrain([ - { x: 1200, y: 700 }, - { x: 1200, y: 800 }, - { x: 1300, y: 800 }, - { x: 1300, y: 700 }, - ]) - box.id = 'box' - game.addTerrain(box) +// const box = new Terrain([ +// { x: 1200, y: 700 }, +// { x: 1200, y: 800 }, +// { x: 1300, y: 800 }, +// { x: 1300, y: 700 }, +// ]) +// box.id = 'box' +// game.addTerrain(box) - const diamond = new Terrain([ - { x: 1000, y: 300 }, - { x: 1100, y: 400 }, - { x: 1000, y: 500 }, - { x: 900, y: 400 }, - ]) - diamond.id = 'diamond' - game.addTerrain(diamond) +// const diamond = new Terrain([ +// { x: 1000, y: 300 }, +// { x: 1100, y: 400 }, +// { x: 1000, y: 500 }, +// { x: 900, y: 400 }, +// ]) +// diamond.id = 'diamond' +// game.addTerrain(diamond) - const pole = new Terrain([ - { x: 400, y: 1000 }, - { x: 410, y: 1000 }, - { x: 410, y: 1010 }, - { x: 400, y: 1010 }, - ]) - pole.id = 'pole' - game.addTerrain(pole) -} +// const pole = new Terrain([ +// { x: 400, y: 1000 }, +// { x: 410, y: 1000 }, +// { x: 410, y: 1010 }, +// { x: 400, y: 1010 }, +// ]) +// pole.id = 'pole' +// game.addTerrain(pole) +// } function laneScenario() { - const entity1 = new Entity() - entity1.id = '1' - entity1.teleport(new Vector2(200, 200)) - entity1.radius = 50 - entity1.maxHealth = 100 - entity1.health = 100 + const entity1 = new Entity({ + id: '1', + health: 100, + maxHealth: 100, + position: new Vector2(500, 150), + radius: 50, + team: Team.blue, + }) + game.spawnEntity(entity1) - const entity2 = new Entity() - entity2.id = '2' - entity2.teleport(new Vector2(1800, 1800)) - entity2.radius = 50 - entity2.maxHealth = 100 - entity2.health = 100 + const entity2 = new Entity({ + id: '2', + health: 100, + maxHealth: 100, + position: new Vector2(1600, 1800), + radius: 50, + team: Team.red, + }) + game.spawnEntity(entity2) const midWallStart = new Vector2(400, 400) @@ -174,13 +182,141 @@ function laneScenario() { const midSouthWall = new Terrain(midSouthWallPoints) midSouthWall.id = 'midSouthWall' game.addTerrain(midSouthWall) + + // TODO: proper death and respawn + const playerLogic = function playerLogic() { + const entity = this + if (entity.health <= 0) { + if (entity.id == '1' || entity.id == '2') { + entity.health = entity.maxHealth + if (entity.id == '1') { + entity.teleport(new Vector2(500, 150)) + } + if (entity.id == '2') { + entity.teleport(new Vector2(1600, 1800)) + } + } + } + } + + entity1.logic = playerLogic + entity2.logic = playerLogic + + const blueMinionLogic = function minionLogic() { + const entity = this + let goal = new Vector2(1900, 1900) + if (entity.position.x < 800 || entity.position.y < 1100) { + goal = new Vector2(850, 1150) + } + + const direction = goal.clone().sub(entity.position).normalize().multiplyScalar(75) + const subGoal = entity.position.clone().add(direction) + // console.log(subGoal) + entity.attackAction(subGoal.x, subGoal.y) + + if (entity.health <= 0) { + entity.despawn() + } + } + + const redMinionLogic = function minionLogic() { + const entity = this + let goal = new Vector2(100, 100) + if (entity.position.x > 900 || entity.position.y > 1200) { + goal = new Vector2(850, 1150) + } + + const direction = goal.clone().sub(entity.position).normalize().multiplyScalar(75) + const subGoal = entity.position.clone().add(direction) + entity.attackAction(subGoal.x, subGoal.y) + + if (entity.health <= 0) { + entity.despawn() + } + } + + const minionTemplate = { + health: 20, + maxHealth: 20, + radius: 30, + speed: 300, + } + + const gameLogic = function gameLogic() { + const game = this + + const blueMinion = new Entity({ + ...minionTemplate, + logic: blueMinionLogic, + team: Team.blue, + position: new Vector2(200, 200), + }) + // blueMinion.scheduledPathfinding = game.entities.length % game.tickRate + + const blueMeleeMinion = new Entity({ + ...minionTemplate, + logic: blueMinionLogic, + team: Team.blue, + position: new Vector2(200, 200), + }) + blueMeleeMinion.abilities[0] = Ability.meleeAttack + + const redMinion = new Entity({ + ...minionTemplate, + logic: redMinionLogic, + team: Team.red, + position: new Vector2(1800, 1800), + }) + // redMinion.scheduledPathfinding = game.entities.length % game.tickRate + + const redMeleeMinion = new Entity({ + ...minionTemplate, + logic: redMinionLogic, + team: Team.red, + position: new Vector2(1800, 1800), + }) + redMeleeMinion.abilities[0] = Ability.meleeAttack + + if (game.currentTick % (30 * game.tickRate) == (0 * game.tickRate)) { + game.spawnEntity(blueMeleeMinion) + game.spawnEntity(redMeleeMinion) + } + + if (game.currentTick % (30 * game.tickRate) == (1 * game.tickRate)) { + game.spawnEntity(blueMeleeMinion) + game.spawnEntity(redMeleeMinion) + } + + if (game.currentTick % (30 * game.tickRate) == (2 * game.tickRate)) { + game.spawnEntity(blueMeleeMinion) + game.spawnEntity(redMeleeMinion) + } + + if (game.currentTick % (30 * game.tickRate) == (3 * game.tickRate)) { + game.spawnEntity(blueMinion) + game.spawnEntity(redMinion) + } + + if (game.currentTick % (30 * game.tickRate) == (4 * game.tickRate)) { + game.spawnEntity(blueMinion) + game.spawnEntity(redMinion) + } + + if (game.currentTick % (30 * game.tickRate) == (5 * game.tickRate)) { + game.spawnEntity(blueMinion) + game.spawnEntity(redMinion) + } + } + + entity2.attackAction(1600, 1800) + entity1.attackAction(500, 150) + game.logic = gameLogic } app.listen(port, () => { console.log(`Server started! Visit http://localhost:${port}`) laneScenario() - game.entities[0].castAction(3, 2000, 2000) game.start() }) diff --git a/src/team.js b/src/team.js new file mode 100644 index 0000000..bf195aa --- /dev/null +++ b/src/team.js @@ -0,0 +1,5 @@ +export default class Team { + static neutral = 'neutral' + static blue = 'blue' + static red = 'red' +}