Compare commits
10 Commits
55e5e8117c
...
nodejs
| Author | SHA1 | Date | |
|---|---|---|---|
|
8ae113b2cf
|
|||
|
11ec464d27
|
|||
|
e799be0b59
|
|||
|
78c52c2cc8
|
|||
|
ff4483e9cf
|
|||
|
2b2336bf70
|
|||
|
de3c175914
|
|||
|
52a0da10fe
|
|||
|
305980b7f9
|
|||
|
de4c82fd8b
|
@@ -0,0 +1 @@
|
||||
nodejs 23.6.1
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 543 B |
Binary file not shown.
|
After Width: | Height: | Size: 95 B |
Binary file not shown.
|
After Width: | Height: | Size: 95 B |
+141
-66
@@ -1,11 +1,13 @@
|
||||
import * as THREE from 'three'
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
|
||||
import { Tween } from '@tweenjs/tween.js'
|
||||
import * as THREE from 'three'
|
||||
import Stats from 'stats.js'
|
||||
|
||||
const global = (0,eval)('this')
|
||||
const scene = new THREE.Scene()
|
||||
const raycaster = new THREE.Raycaster()
|
||||
const camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 1000)
|
||||
const clock = new THREE.Clock()
|
||||
const renderer = new THREE.WebGLRenderer()
|
||||
const backgroundColor = new THREE.Color().setHex(0x112233)
|
||||
scene.background = backgroundColor
|
||||
@@ -24,7 +26,6 @@ camera.layers.enable(2)
|
||||
const projectileMaterial = new THREE.MeshToonMaterial({ color: 0xcccccc })
|
||||
const terrainMaterial = new THREE.MeshToonMaterial({ color: 0x5c4033 })
|
||||
const passableTerrainMaterial = new THREE.MeshToonMaterial({ color: 0x228822, transparent: true, opacity: 0.65 })
|
||||
// const bboxMaterial = new THREE.MeshToonMaterial({ color: 0xffd700, transparent: true, opacity: 0.2 })
|
||||
const opacity = 0.3
|
||||
const teamMaterials = {
|
||||
blue: new THREE.MeshToonMaterial({ color: 0x4444ff }),
|
||||
@@ -35,6 +36,7 @@ const teamMaterials = {
|
||||
redTransparent: new THREE.MeshToonMaterial({ color: 0xff4444, transparent: true, opacity }),
|
||||
projectile: new THREE.MeshToonMaterial({ color: 0xff00ff, transparent: true, opacity }),
|
||||
range: new THREE.MeshToonMaterial({ color: 0x00ffff, transparent: true, opacity: opacity / 2 }),
|
||||
visionRange: new THREE.MeshToonMaterial({ color: 0x00ffff, transparent: true, opacity: opacity / 6 }),
|
||||
}
|
||||
|
||||
// TODO: draw lines of path for minimap camera
|
||||
@@ -46,16 +48,51 @@ minimapRenderer.setSize(300, 300)
|
||||
minimapRenderer.setAnimationLoop(minimapRender)
|
||||
minimapCamera.position.set(10, 10, 10)
|
||||
|
||||
const animationActions = {}
|
||||
const entities = {}
|
||||
const projectiles = {}
|
||||
const gltf = {}
|
||||
const mixers = {}
|
||||
const positionTweens = {}
|
||||
const projectiles = {}
|
||||
const rotationTweens = {}
|
||||
const terrains = {}
|
||||
var state = { abilities: [], entities: [], terrains: [], projectiles: [] }
|
||||
|
||||
global.animationActions = animationActions
|
||||
global.entities = entities
|
||||
global.gltf = gltf
|
||||
global.mixers = mixers
|
||||
global.projectiles = projectiles
|
||||
global.terrains = terrains
|
||||
global.state = state
|
||||
global.terrains = terrains
|
||||
|
||||
const gltfLoader = new GLTFLoader()
|
||||
const preloadGLTF = function loadTemplate(path) {
|
||||
gltfLoader.load(path, (loadedGLTF) => gltf[path] = loadedGLTF)
|
||||
}
|
||||
|
||||
const addGLTF = function addGLTF(scene, path, id, additionalSteps = function noAdditionalSteps() {}) {
|
||||
if (gltf[path] == null) {
|
||||
setTimeout(() => addGLTF(scene, path, id, additionalSteps), 200)
|
||||
return
|
||||
}
|
||||
|
||||
const scale = 2
|
||||
const model = gltf[path].scene.clone()
|
||||
|
||||
const mixer = new THREE.AnimationMixer(model)
|
||||
mixers[id] = mixer
|
||||
|
||||
animationActions[id] = {}
|
||||
gltf[path].animations.forEach((it) => {
|
||||
const animation = mixer.clipAction(it)
|
||||
animationActions[id][it.name] = animation
|
||||
})
|
||||
|
||||
model.scale.set(scale, scale, scale)
|
||||
additionalSteps(model)
|
||||
scene.add(model)
|
||||
}
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(0, 0)
|
||||
const material = new THREE.MeshToonMaterial({ color: 0x115011 })
|
||||
@@ -82,10 +119,14 @@ const mouse = {}
|
||||
var stats = new Stats()
|
||||
stats.showPanel(0)
|
||||
|
||||
var delta = 0
|
||||
function render() {
|
||||
stats.begin()
|
||||
delta = clock.getDelta()
|
||||
cameraMovement()
|
||||
Object.values(positionTweens).forEach((tween) => tween.update()) // TODO: clean up tweens
|
||||
Object.values(positionTweens).forEach((tween) => tween.update())
|
||||
Object.values(rotationTweens).forEach((tween) => tween.update())
|
||||
Object.values(mixers).forEach((mixer) => mixer.update(delta))
|
||||
renderer.render(scene, camera)
|
||||
stats.end()
|
||||
}
|
||||
@@ -94,11 +135,14 @@ function minimapRender() {
|
||||
minimapRenderer.render(scene, minimapCamera)
|
||||
}
|
||||
|
||||
const lockedCameraSpeedMultiplier = 3
|
||||
var cameraLocked = true
|
||||
function followCamera() {
|
||||
const entity = entities[playerId]
|
||||
if (entity == null) { return }
|
||||
|
||||
const cameraSpeed = lockedCameraSpeedMultiplier * delta
|
||||
|
||||
const distanceX = Math.abs((entity.position.x + cameraOffsetX) - camera.position.x)
|
||||
const distanceY = Math.abs((entity.position.y + cameraOffsetY) - camera.position.y)
|
||||
|
||||
@@ -128,13 +172,15 @@ function followCamera() {
|
||||
}
|
||||
}
|
||||
|
||||
const cameraSpeed = 0.03
|
||||
const cameraSpeedMultiplier = 10
|
||||
function cameraMovement() {
|
||||
if (cameraLocked) {
|
||||
followCamera()
|
||||
return
|
||||
}
|
||||
|
||||
const cameraSpeed = cameraSpeedMultiplier * delta
|
||||
|
||||
if (keysDown.ArrowLeft) { camera.position.x -= cameraSpeed }
|
||||
else if (keysDown.ArrowRight) { camera.position.x += cameraSpeed }
|
||||
|
||||
@@ -164,6 +210,7 @@ var websocket = null
|
||||
global.websocket = null
|
||||
var timerId = null
|
||||
var playerId = null
|
||||
var playerTeam = null
|
||||
|
||||
function connectWebSocket() {
|
||||
websocket = new WebSocket(`ws://${window.location.hostname}:1280/ws`)
|
||||
@@ -192,6 +239,11 @@ function connectWebSocket() {
|
||||
tweenDuration = 1000 / stateUpdates.tickRate
|
||||
}
|
||||
|
||||
if (stateUpdates.width != null && stateUpdates.height != null && stateUpdates.width != (state.width ?? -1) && stateUpdates.height != (state.height ?? -1) ) {
|
||||
ground.geometry = new THREE.PlaneGeometry(stateUpdates.width / 100, stateUpdates.height / 100)
|
||||
ground.position.set(stateUpdates.width / 200, stateUpdates.height / 200, 0)
|
||||
}
|
||||
|
||||
if (stateUpdates.width != null && stateUpdates.height != null) {
|
||||
state.width = stateUpdates.width
|
||||
state.height = stateUpdates.height
|
||||
@@ -273,32 +325,35 @@ function connectWebSocket() {
|
||||
}
|
||||
}
|
||||
|
||||
if (state.width != null && state.height != null && (ground.geometry.attributes.width != state.width || ground.geometry.attributes.height != state.height)) {
|
||||
ground.geometry = new THREE.PlaneGeometry(state.width / 100, state.height / 100)
|
||||
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
|
||||
let created = false
|
||||
|
||||
if (e.id == playerId && playerTeam != e.team) {
|
||||
playerTeam = e.team
|
||||
}
|
||||
|
||||
if (e.id in entities) {
|
||||
entity = entities[e.id]
|
||||
}
|
||||
else {
|
||||
const entityMaterial = teamMaterials[e.team]
|
||||
entity = new THREE.Mesh(new THREE.CylinderGeometry(e.visualRadius / 100, e.visualRadius / 100, e.height / 50), entityMaterial)
|
||||
created = true
|
||||
|
||||
entity = new THREE.Group()
|
||||
entity.rotation.x = Math.PI / 2
|
||||
entity.scale.set(e.visualRadius / 100, e.visualRadius / 100, e.visualRadius / 100)
|
||||
entity.userData.type = 'entity'
|
||||
entity.userData.id = e.id
|
||||
entity.position.set(e.position.x / 100, e.position.y / 100, e.height / 100)
|
||||
entity.position.set(e.position.x / 100, e.position.y / 100, 0)
|
||||
scene.add(entity)
|
||||
|
||||
const hpMargin = 0.4
|
||||
const hpMargin = 0.5
|
||||
const maxHp = new THREE.Sprite(new THREE.SpriteMaterial({ color: 0xd03333 }))
|
||||
maxHp.position.set(0, (e.height / 100) + hpMargin, 0)
|
||||
maxHp.position.set(0, 0, 0)
|
||||
maxHp.scale.set(1.5, 0.2, 1)
|
||||
maxHp.layers.set(1)
|
||||
entity.add(maxHp)
|
||||
@@ -310,83 +365,107 @@ function connectWebSocket() {
|
||||
maxHp.add(hp)
|
||||
|
||||
const teamMaterial = teamMaterials[`${e.team}Transparent`]
|
||||
const teamMarker = new THREE.Mesh(new THREE.CylinderGeometry((e.radius) / 100, (e.radius) / 100, 1), teamMaterial)
|
||||
const teamMarkerSize = 4000
|
||||
teamMarker.scale.y = e.height / teamMarkerSize
|
||||
teamMarker.position.y = (e.height / (teamMarkerSize * 2)) - (e.height / 100)
|
||||
teamMarker.position.y += 0.01
|
||||
teamMarker.layers.set(1)
|
||||
const teamMarker = new THREE.Mesh(new THREE.CylinderGeometry(1, 0.00001, 1), teamMaterial)
|
||||
teamMarker.position.y = -0.493
|
||||
entity.add(teamMarker)
|
||||
|
||||
const buffMaterial = new THREE.MeshToonMaterial({ color: 0xffff00, transparent: true, opacity: 0.4 })
|
||||
const buffMarker = new THREE.Mesh(new THREE.CylinderGeometry((e.visualRadius + 10) / 100, (e.visualRadius + 10) / 100, 1), buffMaterial)
|
||||
const buffMarkerSize = 400
|
||||
buffMarker.scale.y = e.height / buffMarkerSize
|
||||
const buffMarker = new THREE.Mesh(new THREE.TorusGeometry(0.95, 0.15), buffMaterial)
|
||||
buffMarker.rotation.x = Math.PI / 2
|
||||
buffMarker.layers.set(1)
|
||||
buffMarker.visible = false
|
||||
entity.add(buffMarker)
|
||||
|
||||
const rotationBase = new THREE.Object3D()
|
||||
entity.add(rotationBase)
|
||||
|
||||
const castingMaterial = new THREE.MeshToonMaterial({ color: 0x10dde0, transparent: true, opacity: 0.4 })
|
||||
const castingMarker = new THREE.Mesh(new THREE.CylinderGeometry((e.height * 0.9) / 100, (e.height * 0.9) / 100, 1), castingMaterial)
|
||||
const castingMarkerSize = 800
|
||||
castingMarker.rotation.z = Math.PI / 2
|
||||
castingMarker.position.x = (e.radius) / 100
|
||||
castingMarker.scale.y = e.height / castingMarkerSize
|
||||
castingMarker.layers.set(1)
|
||||
buffMarker.visible = false
|
||||
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 rangeMarker = new THREE.Mesh(new THREE.CylinderGeometry((rangeSize) / 100, (rangeSize) / 100, 1), rangeMaterial)
|
||||
const rangeMarkerSize = 5000
|
||||
rangeMarker.scale.y = e.height / rangeMarkerSize
|
||||
rangeMarker.position.y = (e.height / (rangeMarkerSize * 2)) - (e.height / 100)
|
||||
const rangeMarker = new THREE.Mesh(new THREE.CylinderGeometry(rangeSize / e.visualRadius, rangeSize / e.visualRadius, 0.001), rangeMaterial)
|
||||
rangeMarker.position.y = 0.004
|
||||
rangeMarker.layers.set(1)
|
||||
rangeMarker.visible = false
|
||||
entity.add(rangeMarker)
|
||||
|
||||
const modelRotationBase = new THREE.Object3D()
|
||||
modelRotationBase.rotation.y = e.rotation - (Math.PI / 2)
|
||||
modelRotationBase.layers.set(1)
|
||||
entity.add(modelRotationBase)
|
||||
|
||||
const visionRangeMaterial = teamMaterials['visionRange']
|
||||
const visionRangeSize = e.visionRange ?? 0
|
||||
const visionRangeMarker = new THREE.Mesh(new THREE.CylinderGeometry(visionRangeSize / e.visualRadius, visionRangeSize / e.visualRadius, 0.001), visionRangeMaterial)
|
||||
visionRangeMarker.position.y = 0.002
|
||||
visionRangeMarker.layers.set(1)
|
||||
visionRangeMarker.visible = false
|
||||
entity.add(visionRangeMarker)
|
||||
|
||||
if (e.model != null) {
|
||||
addGLTF(modelRotationBase, e.model, e.id, function(model) {
|
||||
const box = new THREE.Box3().setFromObject(model)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
maxHp.position.set(0, size.y + hpMargin, 0)
|
||||
buffMarker.position.y = size.y / 2
|
||||
buffMarker.scale.z = size.y / 10
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
entity.children.at(2).visible = !e.dead && e.buffs.some((it) => it.id == 'exposed')
|
||||
|
||||
let z = e.height / 100
|
||||
const animations = animationActions[e.id] ?? {}
|
||||
const fadeIn = created ? 0 : 0.15
|
||||
|
||||
if (e.dead) {
|
||||
entity.rotation.x = 0
|
||||
entity.position.z = 0
|
||||
z = 0
|
||||
if (!animations.dead?.isRunning()) {
|
||||
Object.values(animations).forEach((it) => it.isRunning() && it.reset().fadeOut(fadeIn).play())
|
||||
animations.dead?.reset().fadeIn(fadeIn).play()
|
||||
}
|
||||
}
|
||||
else if (e.casting != null) {
|
||||
if (!animations.cast?.isRunning()) {
|
||||
Object.values(animations).forEach((it) => it.isRunning() && it.reset().fadeOut(fadeIn).play())
|
||||
animations.cast?.reset().fadeIn(fadeIn).play()
|
||||
}
|
||||
}
|
||||
else {
|
||||
entity.rotation.x = Math.PI / 2
|
||||
entity.position.z = e.height / 100
|
||||
if (!animations.default?.isRunning()) {
|
||||
Object.values(animations).forEach((it) => it.isRunning() && it.reset().fadeOut(fadeIn).play())
|
||||
animations.default?.reset().fadeIn(fadeIn).play()
|
||||
}
|
||||
}
|
||||
|
||||
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 }, tweenDuration).start()
|
||||
const oldRotationY = entity.children.at(4).rotation.y
|
||||
const newRotationY = e.rotation - (Math.PI / 2)
|
||||
if (Math.abs((oldRotationY - (2 * Math.PI)) - newRotationY) < Math.abs(oldRotationY - newRotationY)) {
|
||||
entity.children.at(4).rotation.y = oldRotationY - (2 * Math.PI)
|
||||
}
|
||||
if (Math.abs((oldRotationY + (2 * Math.PI)) - newRotationY) < Math.abs(oldRotationY - newRotationY)) {
|
||||
entity.children.at(4).rotation.y = oldRotationY + (2 * Math.PI)
|
||||
}
|
||||
|
||||
positionTweens[entity.id] = new Tween(entity.position).to({ x: e.position.x / 100, y: e.position.y / 100, z: 0 }, tweenDuration).start()
|
||||
rotationTweens[entity.id] = new Tween(entity.children.at(4).rotation).to({ x: 0, y: newRotationY, z: 0 }, 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
|
||||
entity.children.at(3).children.at(0).visible = e.casting != null
|
||||
entity.children.at(3).visible = !e.dead && e.id == playerId
|
||||
// entity.children.at(5).visible = !e.dead && e.team == playerTeam // TODO: clipping makes the screen unviewable
|
||||
}
|
||||
|
||||
for (const e of Object.values(entities)) {
|
||||
if (e.userData.flaggedForRemoval) {
|
||||
scene.remove(e)
|
||||
delete animationActions[e.userData.id]
|
||||
delete entities[e.userData.id]
|
||||
delete mixers[e.userData.id]
|
||||
delete positionTweens[e.userData.id]
|
||||
delete rotationTweens[e.userData.id]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,18 +526,6 @@ function connectWebSocket() {
|
||||
terrain.userData.id = t.id
|
||||
scene.add(terrain)
|
||||
terrains[t.id] = terrain
|
||||
|
||||
// // TODO: bboxes aren't tracked and can leak memory
|
||||
// const bboxValues = Object.values(t.bbox)
|
||||
// if (bboxValues.length >= 4) {
|
||||
// const width = (bboxValues[1] - bboxValues[3]) / 100
|
||||
// const height = (bboxValues[0] - bboxValues[2]) / 100
|
||||
|
||||
// const bbox = new THREE.Mesh(new THREE.BoxGeometry(width, height, 0.2), bboxMaterial)
|
||||
// bbox.position.set((bboxValues[3] / 100) + (width / 2), (bboxValues[2] / 100) + (height / 2), 0)
|
||||
// bbox.layers.set(1)
|
||||
// scene.add(bbox)
|
||||
// }
|
||||
}
|
||||
|
||||
terrain.position.set(t.position.x / 100, t.position.y / 100, 0)
|
||||
@@ -549,10 +616,18 @@ function connectWebSocket() {
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
preloadGLTF('models/generic-bam-placeholder.gltf')
|
||||
preloadGLTF('models/generic-player-placeholder.gltf')
|
||||
preloadGLTF('models/generic-player-placeholder-red.gltf')
|
||||
|
||||
const params = Object.fromEntries(new URLSearchParams(window.location.search).entries())
|
||||
playerId = params.id
|
||||
if (playerId == null) {
|
||||
playerId = prompt('Player ID:')
|
||||
if (playerId == '') {
|
||||
window.location.href = '/menu/'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
connectWebSocket()
|
||||
|
||||
@@ -159,6 +159,7 @@
|
||||
|
||||
.buff:hover {
|
||||
overflow: visible;
|
||||
height: fit-content;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
@@ -167,6 +168,7 @@
|
||||
padding: 5px;
|
||||
background-color: black;
|
||||
width: fit-content;
|
||||
min-width: 200px;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<style>
|
||||
html {
|
||||
background-color: black;
|
||||
color: white;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
a:link, a:hover, a:active, a:visited {
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
<h1>Take control of a unit:</h1>
|
||||
<ul id="links"></ul>
|
||||
<script>
|
||||
websocket = new WebSocket(`ws://${window.location.hostname}:1280/ws`)
|
||||
websocket.onopen = () => { websocket.send(JSON.stringify({ action: 'entities' })) }
|
||||
websocket.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data)
|
||||
const entityIds = message?.entities
|
||||
if (entityIds == null) { return }
|
||||
|
||||
websocket.close()
|
||||
let links = ''
|
||||
entityIds.forEach((entityId) => links += `<li><a href="/?id=${encodeURI(entityId)}">${entityId}</a></li>`)
|
||||
document.getElementById('links').innerHTML = links
|
||||
}
|
||||
</script>
|
||||
+3
-3
@@ -235,7 +235,7 @@ export default class Ability {
|
||||
const entityId = collidingEntity.id
|
||||
|
||||
if (!collided.has(entityId)) {
|
||||
collidingEntity.heal(amount, caster)
|
||||
collidingEntity.applyBuff(Buff.shieldThrowShield.id, caster.id)
|
||||
collided.add(entityId)
|
||||
}
|
||||
}
|
||||
@@ -252,8 +252,8 @@ export default class Ability {
|
||||
}
|
||||
|
||||
const shieldThrowSecondAfter = function shieldThrowSecondAfter(projectile, homingTarget) {
|
||||
caster.heal(amount, caster)
|
||||
caster.heal(amount, caster) // NOTE: duplicated on purpose
|
||||
caster.applyBuff(Buff.shieldThrowShield.id, caster.id)
|
||||
caster.applyBuff(Buff.shieldThrowShield.id, caster.id) // NOTE: duplicated on purpose
|
||||
}
|
||||
|
||||
const shieldThrowFirstAfter = function shieldThrowFirstAfter(projectile, homingTarget) {
|
||||
|
||||
@@ -7,6 +7,7 @@ export default class Buff {
|
||||
|
||||
damageMultiplier = null
|
||||
duration = 0
|
||||
shield = null
|
||||
|
||||
#effect = null
|
||||
|
||||
@@ -25,4 +26,11 @@ export default class Buff {
|
||||
duration: 4,
|
||||
onHitMultiplier: 3,
|
||||
})
|
||||
|
||||
static shieldThrowShield = new Buff({
|
||||
id: 'shield_throw_shield',
|
||||
name: 'Shield (of Shield Throw)',
|
||||
duration: 5,
|
||||
shield: 200,
|
||||
})
|
||||
}
|
||||
|
||||
+53
-27
@@ -18,8 +18,9 @@ export default class Entity {
|
||||
dead = false
|
||||
ghosting = false
|
||||
health = null
|
||||
height = 40
|
||||
height = null
|
||||
maxHealth = 1
|
||||
model = null
|
||||
position = null
|
||||
radius = 0
|
||||
rotation = 0
|
||||
@@ -28,21 +29,21 @@ 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
|
||||
#collision = true
|
||||
#dest = null
|
||||
#entitiesInVision = []
|
||||
#game = null
|
||||
#ghostable = true
|
||||
#logic = null
|
||||
#moving = false
|
||||
#path = []
|
||||
#noPathfindingUntil = 0
|
||||
#path = []
|
||||
#pathfindingCooldown = 0
|
||||
#pathfindingObstacleLimit = null
|
||||
#projectilesInVision = []
|
||||
#spawnPosition = new Vector2()
|
||||
|
||||
static bbox(x, y, radius) {
|
||||
@@ -140,6 +141,9 @@ export default class Entity {
|
||||
if (this.visualRadius == null) {
|
||||
this.visualRadius = this.radius
|
||||
}
|
||||
if (this.height == null) {
|
||||
this.height = this.visualRadius ?? this.radius
|
||||
}
|
||||
|
||||
this.#calculateCollider()
|
||||
}
|
||||
@@ -177,7 +181,6 @@ export default class Entity {
|
||||
this.moveAction(cursor, true)
|
||||
}
|
||||
|
||||
// TODO: buffer skill inputs
|
||||
castAction(slot, cursor, halt = false) {
|
||||
if (this.dead) { return }
|
||||
|
||||
@@ -267,16 +270,27 @@ export default class Entity {
|
||||
}
|
||||
|
||||
applyBuff(id, sourceId = null) {
|
||||
const buff = (this.game?.buffs ?? []).find((it) => it.id == id)
|
||||
if (buff == null) { return false }
|
||||
|
||||
const index = this.buffs.findIndex((it) => it.id == id)
|
||||
const source = sourceId ?? this.id
|
||||
const timestamp = this.game?.currentTick ?? 0
|
||||
|
||||
if (index > -1) {
|
||||
this.buffs[index].timestamp = timestamp
|
||||
this.buffs[index].source = source
|
||||
if (index < 0) {
|
||||
const entityBuff = { id, source, timestamp }
|
||||
if (buff.shield != null) {
|
||||
entityBuff.shield = buff.shield
|
||||
}
|
||||
|
||||
this.buffs.push(entityBuff)
|
||||
}
|
||||
else {
|
||||
this.buffs.push({ id, source, timestamp })
|
||||
this.buffs[index].timestamp = timestamp
|
||||
this.buffs[index].source = source
|
||||
if (buff.shield != null) {
|
||||
this.buffs[index].shield = (this.buffs[index].shield ?? 0) + buff.shield
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,24 +336,34 @@ export default class Entity {
|
||||
return entitiesAndTerrains.filter((it) => !it.dead && it.collision && !((this.ghosting && it.ghostable) || (this.ghostable && it.ghosting)) && SATX.bboxCheck(bbox, it.bbox))
|
||||
}
|
||||
|
||||
// TODO: add shielding logic
|
||||
damage(amount, source = null) {
|
||||
if (this.dead) { return }
|
||||
|
||||
let customMultipliers = 0
|
||||
if (this.hasBuff(Buff.exposed.id)) {
|
||||
const buff = this.getBuff(Buff.exposed.id)
|
||||
if (buff.source == source.id) {
|
||||
if (buff.source == source?.id) {
|
||||
customMultipliers += (buff.onHitMultiplier - 1)
|
||||
this.removeBuff(Buff.exposed.id)
|
||||
}
|
||||
}
|
||||
|
||||
const damageMultiplerBuffs = source.buffs.map((it) => it.getBuff).filter((it) => it != null && it.damageMultiplier != null)
|
||||
const buffs = this.buffs ?? []
|
||||
const damageMultiplerBuffs = buffs.map((it) => it.getBuff).filter((it) => it != null && it.damageMultiplier != null)
|
||||
const buffPassiveDamageMultiplier = damageMultiplerBuffs.reduce((it) => it.damageMultiplier - 1, 0)
|
||||
|
||||
const damageMultipler = 1 + buffPassiveDamageMultiplier + customMultipliers
|
||||
const damage = amount * damageMultipler
|
||||
let damage = amount * damageMultipler
|
||||
|
||||
if (damage >= 0) {
|
||||
buffs.filter((it) => it.shield != null && it.shield > 0).forEach((it) => {
|
||||
if (damage <= 0) { return }
|
||||
|
||||
const shielded = Math.max(0, Math.min(damage, it.shield))
|
||||
it.shield -= shielded
|
||||
damage -= shielded
|
||||
})
|
||||
}
|
||||
|
||||
this.health = Math.min(Math.max(0, this.health - damage), this.maxHealth)
|
||||
}
|
||||
@@ -395,6 +419,10 @@ export default class Entity {
|
||||
return { ...buffDefinition, ...entityBuff }
|
||||
}
|
||||
|
||||
getBuffs() {
|
||||
return this.buffs.map((it) => this.getBuff(it.id)).filter((it) => it != null)
|
||||
}
|
||||
|
||||
hasBuff(id) {
|
||||
if (this.dead) { return false }
|
||||
|
||||
@@ -440,7 +468,7 @@ export default class Entity {
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`Can't fix position ([${futurePosition.x}, ${futurePosition.y}]) of entity ID: ${this.id}`)
|
||||
console.error({ error: 'position_unfixable', id: this.id, futurePosition })
|
||||
}
|
||||
|
||||
isColliding() {
|
||||
@@ -472,9 +500,10 @@ export default class Entity {
|
||||
const bboxCheckedObstacles = terrains.filter((it) => SATX.bboxCheck(bbox, it.bbox))
|
||||
if (bboxCheckedObstacles.length < 1) { return true }
|
||||
|
||||
const posCollider = Entity.collider(this.position.x, this.position.y, 0)
|
||||
const posBbox = Entity.bbox(this.position.x, this.position.y, 0)
|
||||
const unpassableTerrain = bboxCheckedObstacles.filter((it) => !(SATX.bboxCheck(posBbox, it.bbox) && it.colliders().some((c) => SATX.collideObject(posCollider, c))))
|
||||
const inWallVisionBypassRadius = Math.max(0, this.radius - 1)
|
||||
const posCollider = Entity.collider(this.position.x, this.position.y, inWallVisionBypassRadius)
|
||||
const posBbox = Entity.bbox(this.position.x, this.position.y, inWallVisionBypassRadius)
|
||||
const unpassableTerrain = bboxCheckedObstacles.filter((it) => !SATX.bboxCheck(posBbox, it.bbox) || !it.colliders().some((c) => SATX.collideObject(posCollider, c)))
|
||||
|
||||
const colliders = unpassableTerrain.map((it) => it.colliders()).flat()
|
||||
const collider = Entity.tunnelCollider(this.position.x, this.position.y, destination.x, destination.y, 0)
|
||||
@@ -633,11 +662,8 @@ export default class Entity {
|
||||
|
||||
#castingVision() {
|
||||
const enemyTeam = this.team == Team.blue ? Team.red : (this.team == Team.red ? Team.blue : null)
|
||||
if (enemyTeam == null) {
|
||||
return // only blue/red teams have casting vision
|
||||
}
|
||||
|
||||
const enemiesNearby = (this.game?.entities ?? []).some((it) => !it.dead && it.team == enemyTeam && it.distanceTo(this.position) <= (it.visionRange + this.radius))
|
||||
const enemiesNearby = (this.game?.entities ?? []).some((it) => !it.dead && (enemyTeam == null || it.team == enemyTeam) && it.distanceTo(this.position) <= (it.visionRange + this.radius))
|
||||
if (enemiesNearby) {
|
||||
const radius = 300
|
||||
const duration = this.game?.secToTick(2) ?? 0
|
||||
@@ -658,6 +684,7 @@ export default class Entity {
|
||||
owner: this.id,
|
||||
position: this.position.clone(),
|
||||
visionRange: radius,
|
||||
team: enemyTeam,
|
||||
})
|
||||
|
||||
this.game?.spawnProjectile(projectile)
|
||||
@@ -742,8 +769,7 @@ export default class Entity {
|
||||
}
|
||||
}
|
||||
|
||||
for (let failsafe = 0; failsafe <= (this.pathfindingObstacleLimit ?? 1000); failsafe++) {
|
||||
if (failsafe >= 10) { console.error('Failsafe is reached!!!'); process.exit(0) }
|
||||
for (let failsafe = 0; failsafe <= (this.pathfindingObstacleLimit ?? 10); failsafe++) {
|
||||
const obstaclesArray = Array.from(obstacles.values())
|
||||
|
||||
for (const obstacle of obstaclesArray) {
|
||||
|
||||
+15
-11
@@ -35,7 +35,7 @@ export default class Game {
|
||||
action(id, options) {
|
||||
const entity = this.entities.find((it) => it.id == id)
|
||||
if (entity == null) {
|
||||
console.error({ error: 'Invalid ID' })
|
||||
console.info({ info: 'action_invalid_id', id, options })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export default class Game {
|
||||
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 } }) }
|
||||
else { console.error({ error: 'despawn_unknown_object', object }) }
|
||||
}
|
||||
|
||||
despawnEntity(entity) {
|
||||
@@ -92,7 +92,7 @@ export default class Game {
|
||||
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 } }) }
|
||||
else { console.error({ error: 'spawn_unknown_object', object }) }
|
||||
}
|
||||
|
||||
spawnEntity(entity) {
|
||||
@@ -109,7 +109,7 @@ export default class Game {
|
||||
if (this.#gameLoopIntervalId != null) { return }
|
||||
|
||||
this.#startTimestamp = performance.now() + (this.currentTick * this.tickBudget)
|
||||
console.info(`Started game ${this.id} with ${this.tickRate} tps. Starting on tick ${this.currentTick}.`)
|
||||
console.info({ event: 'game_start', id: this.id, tickRate: this.tickRate, currentTick: this.currentTick })
|
||||
this.#gameLoopIntervalId = setInterval(this.#gameLoopCall.bind(this), this.tickBudget / 5)
|
||||
}
|
||||
|
||||
@@ -118,15 +118,20 @@ export default class Game {
|
||||
|
||||
clearInterval(this.#gameLoopIntervalId)
|
||||
this.#gameLoopIntervalId = null
|
||||
console.info(`Stopped game ${this.id}. Stopped on tick ${this.currentTick}.`)
|
||||
console.info({ event: 'game_stop', id: this.id, currentTick: this.currentTick })
|
||||
}
|
||||
|
||||
subscription(websocket, id) {
|
||||
return function builtSubscription() {
|
||||
return function builtSubscription(query = null) {
|
||||
const game = this
|
||||
if (query == 'id') { return id }
|
||||
if (query != null) { return }
|
||||
|
||||
const entity = game.entities.find((it) => it.id == id)
|
||||
if (entity == null) { return }
|
||||
if (entity == null) {
|
||||
websocket.close()
|
||||
return
|
||||
}
|
||||
|
||||
const team = entity.team
|
||||
const state = game.visionByTeam(team)
|
||||
@@ -189,15 +194,14 @@ export default class Game {
|
||||
const before = performance.now()
|
||||
this.update()
|
||||
const after = performance.now()
|
||||
const taken = (after - before)
|
||||
const tickTaken = after - before
|
||||
|
||||
const useAbsoluteBehind = true
|
||||
const absoluteBehind = Math.max(0, (start - this.#startTimestamp) - ((this.currentTick) * tickBudget))
|
||||
this.#nextTickAt = start + tickBudget - (useAbsoluteBehind ? absoluteBehind : prevBehind)
|
||||
|
||||
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}`)
|
||||
if (tickTaken > tickBudget) {
|
||||
console.warn({ warn: 'overload', tickTaken, tickBudget, absoluteBehind })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
-6
@@ -1,4 +1,4 @@
|
||||
import { Dungeon } from './level.js'
|
||||
import * as LEVEL from './level.js'
|
||||
import { WebSocketExpress } from 'websocket-express'
|
||||
import express from 'express'
|
||||
import Game from './game.js'
|
||||
@@ -9,7 +9,7 @@ try {
|
||||
os.setPriority(process.pid, os.constants.priority.PRIORITY_HIGHEST)
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('Could not adjust process priority on startup.')
|
||||
console.warn({ warn: 'process_priority_unadjustable' })
|
||||
}
|
||||
|
||||
const app = new WebSocketExpress()
|
||||
@@ -23,6 +23,8 @@ app.use('/@tweenjs/', express.static('node_modules/@tweenjs'))
|
||||
app.use('/stats.js/', express.static('node_modules/stats.js'))
|
||||
|
||||
app.use('/', express.static('public'))
|
||||
app.use('/models', express.static('models'))
|
||||
|
||||
app.use('/tools/', express.static('tools'))
|
||||
|
||||
app.ws('/ws', async (req, res) => {
|
||||
@@ -30,16 +32,27 @@ app.ws('/ws', async (req, res) => {
|
||||
|
||||
websocket.on('message', (rawData) => {
|
||||
const message = JSON.parse(rawData)
|
||||
console.log(message)
|
||||
console.info(message)
|
||||
if (message.action == 'entities') {
|
||||
websocket.send(JSON.stringify({ entities: game.entities.map((it) => it.id) }))
|
||||
return
|
||||
}
|
||||
|
||||
if (message.action == 'join') {
|
||||
const id = message.id
|
||||
const connectionId = crypto.randomUUID()
|
||||
if (!game.entities.some((it) => it.id == id)) {
|
||||
console.info({ error: 'join_invalid_id', id, connectionId })
|
||||
return
|
||||
}
|
||||
|
||||
console.info({ event: 'connected', id, connectionId })
|
||||
websocket.send(JSON.stringify(game.joinReport()))
|
||||
const subscription = game.subscription(websocket, id).bind(game)
|
||||
game.subscriptions.set(connectionId, subscription)
|
||||
|
||||
websocket.on('close', () => {
|
||||
console.log({ event: 'disconnected', id })
|
||||
console.info({ event: 'disconnected', id })
|
||||
game.subscriptions.delete(connectionId)
|
||||
})
|
||||
return
|
||||
@@ -50,7 +63,8 @@ app.ws('/ws', async (req, res) => {
|
||||
})
|
||||
|
||||
app.listen(port, () => {
|
||||
console.info(`Server started! Visit http://localhost:${port}`)
|
||||
console.info({ event: 'startup', visit: `http://localhost:${port}/menu/` })
|
||||
|
||||
Dungeon.scenario(game)
|
||||
LEVEL.Chase.scenario(game)
|
||||
game.start()
|
||||
})
|
||||
|
||||
+17
-6
@@ -26,8 +26,24 @@ export class Dungeon {
|
||||
game.spawnEntity(new Entity(Template.player({ id: '2', spawnPosition: new Vector2(1500, 700), team, dead: true })))
|
||||
|
||||
game.spawnEntity(new Entity(Template.basilisk({ id: 'boss', spawnPosition: new Vector2(2200, 750), team: Team.neutral })))
|
||||
}
|
||||
}
|
||||
|
||||
game.start()
|
||||
export class Chase {
|
||||
static scenario(game) {
|
||||
game.width = 1000
|
||||
game.height = 1000
|
||||
|
||||
const chaserTemplate = {
|
||||
// TODO: TODO
|
||||
}
|
||||
|
||||
const runnerTemplate = {
|
||||
// TODO: TODO
|
||||
}
|
||||
|
||||
game.spawnEntity(new Entity({ ...chaserTemplate }))
|
||||
game.spawnEntity(new Entity({ ...runnerTemplate }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,9 +63,6 @@ export class Zigzag {
|
||||
new Vector2(i + 100, game.height - lowest),
|
||||
]))
|
||||
}
|
||||
|
||||
|
||||
game.start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,8 +81,6 @@ export class Ravine {
|
||||
team,
|
||||
})))
|
||||
}
|
||||
|
||||
game.start()
|
||||
}
|
||||
static logic() {
|
||||
const game = this
|
||||
|
||||
@@ -182,15 +182,6 @@ export default class Pathfind {
|
||||
graphIndex += 5
|
||||
}
|
||||
|
||||
// const niceGraph = []
|
||||
// for (let i = 0; i < graph.length / 5; i += 5) {
|
||||
// niceGraph.push({
|
||||
// from: [graph[i], graph[i + 1]],
|
||||
// to: [graph[i + 2], graph[i + 3]],
|
||||
// distance: graph[i + 4],
|
||||
// })
|
||||
// }
|
||||
// console.log(niceGraph)
|
||||
return graph
|
||||
}
|
||||
|
||||
|
||||
+43
-12
@@ -5,13 +5,12 @@ import Team from './team.js'
|
||||
export default class Template {
|
||||
static basilisk(overrides) {
|
||||
return {
|
||||
abilities: {},
|
||||
height: 100,
|
||||
logic: this.#basiliskLogic,
|
||||
abilities: { a: Ability.rangedAttack.id },
|
||||
logic: this.#basiliskLogic(),
|
||||
maxHealth: 300,
|
||||
model: 'models/generic-bam-placeholder.gltf',
|
||||
radius: 180,
|
||||
speed: 230,
|
||||
visualRadius: 170,
|
||||
maxHealth: 3000,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -19,17 +18,16 @@ export default class Template {
|
||||
static minion(team, options = {}) {
|
||||
return {
|
||||
abilities: { a: options.ranged ? Ability.rangedAttack.id : Ability.meleeAttack.id },
|
||||
height: options.ranged ? 40 : 38,
|
||||
logic: this.#minionLogic(options.route, (team != Team.blue)),
|
||||
maxHealth: options.ranged ? 300 : 450,
|
||||
model: Team.blue == (team ?? Team.blue) ? 'models/generic-player-placeholder.gltf' : 'models/generic-player-placeholder-red.gltf',
|
||||
pathfindingCooldown: 0.2,
|
||||
pathfindingObstacleLimit: 0,
|
||||
position: options.route?.at(0) ?? options.position ?? new Vector2(0, 0),
|
||||
radius: 48,
|
||||
radius: options.ranged ? 36 : 38,
|
||||
speed: 325,
|
||||
team,
|
||||
visionRange: 1200,
|
||||
visualRadius: options.ranged ? 36 : 38,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,22 +42,55 @@ export default class Template {
|
||||
d: Ability.circleOfResurrection.id,
|
||||
f: Ability.blink.id,
|
||||
},
|
||||
height: 80,
|
||||
logic: this.#playerLogic,
|
||||
maxHealth: 600,
|
||||
model: Team.blue == (overrides.team ?? Team.blue) ? 'models/generic-player-placeholder.gltf' : 'models/generic-player-placeholder-red.gltf',
|
||||
pathfindingObstacleLimit: 3,
|
||||
radius: 65,
|
||||
spawnPosition: new Vector2(500, 150),
|
||||
visionRange: 1350,
|
||||
visualRadius: 40,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
static #basiliskLogic() {
|
||||
const entity = this
|
||||
let diedOnTick = null
|
||||
let targetInRangeSince = null
|
||||
|
||||
return
|
||||
return function builtBasiliskLogic() {
|
||||
const entity = this
|
||||
if (Array.from(entity.game?.subscriptions.values()).some((it) => it('id') == entity.id)) { return }
|
||||
|
||||
const attackDelaySec = 2
|
||||
const despawnDelaySec = 5
|
||||
|
||||
const despawnDelay = entity.game?.secToTick(despawnDelaySec) ?? 1
|
||||
const timestamp = entity.game?.currentTick ?? 0
|
||||
|
||||
if (entity.dead && diedOnTick == null) { diedOnTick = timestamp }
|
||||
if (entity.dead && diedOnTick != null && diedOnTick + despawnDelay < timestamp) { entity.despawn() }
|
||||
if (!entity.dead) { diedOnTick = null }
|
||||
if (entity.dead) { return }
|
||||
|
||||
const target = entity.closestTargetTo(entity.position, 500)
|
||||
if (target == null) {
|
||||
targetInRangeSince = null
|
||||
return
|
||||
}
|
||||
|
||||
if (targetInRangeSince == null) {
|
||||
targetInRangeSince = timestamp
|
||||
}
|
||||
|
||||
const attackDelay = entity.game?.secToTick(attackDelaySec) ?? 1
|
||||
if (targetInRangeSince + attackDelay < timestamp) {
|
||||
entity.castAction('a', target.id)
|
||||
}
|
||||
|
||||
const directionToTarget = target.position.clone().sub(entity.position).normalize()
|
||||
const entityRotationVector = new Vector2(1, 0).rotateAround(new Vector2(), entity.rotation)
|
||||
entity.rotation = directionToTarget.clone().add(entityRotationVector).add(entityRotationVector).add(entityRotationVector).angle()
|
||||
}
|
||||
}
|
||||
|
||||
static #minionLogic(route = [], odd = false) {
|
||||
|
||||
Reference in New Issue
Block a user