import * as THREE from 'three' import { Tween } from '@tweenjs/tween.js' 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 renderer = new THREE.WebGLRenderer() const backgroundColor = new THREE.Color().setHex(0x112233) scene.background = backgroundColor renderer.setSize(window.innerWidth, window.innerHeight) renderer.setAnimationLoop(render) const cameraOffsetX = 0 const cameraOffsetY = -13.5 const cameraOffsetZ = 20 camera.position.set(cameraOffsetX, cameraOffsetY, cameraOffsetZ) camera.rotation.set((34 / 180) * Math.PI, 0, 0) camera.zoom += 0.2 camera.updateProjectionMatrix() camera.layers.enable(1) 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 }), blueTransparent: new THREE.MeshToonMaterial({ color: 0x4444ff, transparent: true, opacity }), neutral: new THREE.MeshToonMaterial({ color: 0xcccccc }), neutralTransparent: new THREE.MeshToonMaterial({ color: 0xcccccc, transparent: true, opacity }), red: new THREE.MeshToonMaterial({ color: 0xff4444 }), redTransparent: new THREE.MeshToonMaterial({ color: 0xff4444, transparent: true, opacity }), projectile: new THREE.MeshToonMaterial({ color: 0xff00ff, transparent: true, opacity }), range: new THREE.MeshToonMaterial({ color: 0x00ffff, transparent: true, opacity: opacity / 2 }), } // TODO: draw lines of path for minimap camera const minimapCameraZ = 10 const minimapCamera = new THREE.OrthographicCamera(-10, 10, 10, -10) const minimapRenderer = new THREE.WebGLRenderer() minimapRenderer.setSize(300, 300) minimapRenderer.setAnimationLoop(minimapRender) minimapCamera.position.set(10, 10, 10) const entities = {} const projectiles = {} const positionTweens = {} const terrains = {} var state = { abilities: [], entities: [], terrains: [], projectiles: [] } global.entities = entities global.projectiles = projectiles global.terrains = terrains global.state = state const geometry = new THREE.PlaneGeometry(0, 0) const material = new THREE.MeshToonMaterial({ color: 0x115011 }) const ground = new THREE.Mesh(geometry, material) scene.add(ground) const ambientLight = new THREE.AmbientLight(0x404040, 10) scene.add(ambientLight) const directionalLight = new THREE.DirectionalLight(0xffffff, 2.5) directionalLight.position.set(-0.5, -0.05, 1) directionalLight.power = 3000 scene.add(directionalLight) global.THREE = THREE global.renderer = renderer global.camera = camera global.scene = scene var tweenDuration = 1 const keysDown = {} const mouse = {} var stats = new Stats() stats.showPanel(0) function render() { stats.begin() cameraMovement() Object.values(positionTweens).forEach((tween) => tween.update()) // TODO: clean up tweens renderer.render(scene, camera) stats.end() } function minimapRender() { minimapRenderer.render(scene, minimapCamera) } var cameraLocked = true function followCamera() { const entity = entities[playerId] if (entity == null) { return } const distanceX = Math.abs((entity.position.x + cameraOffsetX) - camera.position.x) const distanceY = Math.abs((entity.position.y + cameraOffsetY) - camera.position.y) camera.position.z = cameraOffsetZ if (distanceX > 0.01) { if (entity.position.x + cameraOffsetX > camera.position.x) { camera.position.x += cameraSpeed * distanceX } if (entity.position.x + cameraOffsetX < camera.position.x) { camera.position.x -= cameraSpeed * distanceX } } else if (distanceX != 0) { camera.position.x = entity.position.x + cameraOffsetX } if (distanceY > 0.01) { if (entity.position.y + cameraOffsetY > camera.position.y) { camera.position.y += cameraSpeed * distanceY } if (entity.position.y + cameraOffsetY < camera.position.y) { camera.position.y -= cameraSpeed * distanceY } } else if (distanceY != 0) { camera.position.y = entity.position.y + cameraOffsetY } } const cameraSpeed = 0.03 function cameraMovement() { if (cameraLocked) { followCamera() return } if (keysDown.ArrowLeft) { camera.position.x -= cameraSpeed } else if (keysDown.ArrowRight) { camera.position.x += cameraSpeed } if (keysDown.ArrowUp) { camera.position.y += cameraSpeed } else if (keysDown.ArrowDown) { camera.position.y -= cameraSpeed } if (keysDown.Space) { camera.position.set(entities[playerId].position.x + cameraOffsetX, entities[playerId].position.y + cameraOffsetY, cameraOffsetZ) } } 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 var playerId = null function connectWebSocket() { websocket = new WebSocket(`ws://${window.location.hostname}:1280/ws`) global.websocket = websocket websocket.onerror = () => websocket.close() websocket.onopen = () => { document.getElementById('connection').innerHTML = 'open' clearInterval(timerId) websocket.send(JSON.stringify({ action: 'join', id: playerId })) } websocket.onclose = () => { websocket = null document.getElementById('connection').innerHTML = 'closed' timerId = setInterval(() => { if (websocket == null) { connectWebSocket() } }, 2000) } websocket.onmessage = (event) => { state.byteSize = new Blob([event.data]).size const stateUpdates = JSON.parse(event.data) if (stateUpdates.tickRate != null) { tweenDuration = 1000 / stateUpdates.tickRate } if (stateUpdates.width != null && stateUpdates.height != null) { state.width = stateUpdates.width state.height = stateUpdates.height minimapCamera.top = state.height / 200 minimapCamera.right = state.width / 200 minimapCamera.bottom = -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)) { if (!['abilities', 'terrains', 'entities', 'projectiles', 'width', 'height'].includes(key)) { state[key] = value } } if (stateUpdates.abilities != null) { const ids = stateUpdates.abilities.map((it) => it.id) state.abilities = state.abilities.filter((it) => ids.includes(it.id)) for (const ability of stateUpdates.abilities ?? []) { const index = state?.abilities?.findIndex((it) => it.id == ability.id) if (index > -1) { state.abilities[index] = {...state.abilities[index], ...ability} } else { state.abilities.push(ability) } } } if (stateUpdates.entities != null) { const ids = stateUpdates.entities.map((it) => it.id) state.entities = state.entities.filter((it) => ids.includes(it.id)) for (const entity of stateUpdates.entities ?? []) { const index = state?.entities?.findIndex((it) => it.id == entity.id) if (index > -1) { state.entities[index] = {...state.entities[index], ...entity} } else { state.entities.push(entity) } } } if (stateUpdates.terrains != null) { const ids = stateUpdates.terrains.map((it) => it.id) state.terrains = state.terrains.filter((it) => ids.includes(it.id)) for (const terrain of stateUpdates.terrains ?? []) { const index = state?.terrains?.findIndex((it) => it.id == terrain.id) if (index > -1) { state.terrains[index] = {...state.terrains[index], ...terrain} } else { state.terrains.push(terrain) } } } if (stateUpdates.projectiles != null) { const ids = stateUpdates.projectiles.map((it) => it.id) state.projectiles = state.projectiles.filter((it) => ids.includes(it.id)) for (const projectile of stateUpdates.projectiles) { const index = state?.projectiles?.findIndex((it) => it.id == projectile.id) if (index > -1) { state.projectiles[index] = {...state.projectiles[index], ...projectile} } else { state.projectiles.push(projectile) } } } 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 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) 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.height / 100) scene.add(entity) const hpMargin = 0.4 const maxHp = new THREE.Sprite(new THREE.SpriteMaterial({ color: 0xd03333 })) maxHp.position.set(0, (e.height / 100) + hpMargin, 0) maxHp.scale.set(1.5, 0.2, 1) maxHp.layers.set(1) entity.add(maxHp) const hp = new THREE.Sprite(new THREE.SpriteMaterial({ color: 0x77ff77 })) hp.position.set(0, 0, 0) hp.scale.set(1, 1, 1) hp.layers.set(1) 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) 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 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) rangeMarker.layers.set(1) rangeMarker.visible = false entity.add(rangeMarker) 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 }, 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 } 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 } 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.visualRadius / 100), projectileMaterial) projectile.userData.type = 'projectile' projectile.userData.id = p.id projectile.position.set(p.position.x / 100, p.position.y / 100, p.height / 100) projectile.layers.set(2) scene.add(projectile) projectile.rotation.x = Math.PI / 2 // needed for the team marker... const teamMaterial = teamMaterials[`${p.team}Transparent`] ?? teamMaterials['projectile'] const teamMarker = new THREE.Mesh(new THREE.CylinderGeometry((p.radius) / 100, (p.radius) / 100, 1), teamMaterial) const teamMarkerSize = 4000 teamMarker.scale.y = p.height / teamMarkerSize teamMarker.position.y = (p.height / (teamMarkerSize * 2)) - (p.height / 100) teamMarker.position.y += 0.01 teamMarker.layers.set(2) projectile.add(teamMarker) projectiles[p.id] = projectile } projectile.userData.flaggedForRemoval = false positionTweens[projectile.id] = new Tween(projectile.position).to({ x: p.position.x / 100, y: p.position.y / 100, z: p.height / 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) { terrain = terrains[t.id] } else { const vertices = t.relativeVertices const shape = new THREE.Shape() shape.moveTo(vertices.at(0).x / 100, vertices.at(0).y / 100) vertices.slice(1).forEach((v) => shape.lineTo(v.x / 100, v.y / 100)) terrain = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { bevelEnabled: false, depth: t.collision ? 0.5 : 0.35 }), t.collision ? terrainMaterial : passableTerrainMaterial) terrain.userData.type = 'terrain' 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) } if (playerId != null) { const player = state.entities.find((e) => e.id == playerId) if (player != null) { const playerAbilities = player.abilities let abilitiesHTML = '' let i = 0 for (const [abilityKey, _abilityId] of Object.entries(playerAbilities)) { i++ const abilityKeyText = abilityKey.toUpperCase() const abilityTemplate = `