import * as THREE from 'three' import { Tween } from '@tweenjs/tween.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 entityMaterial = new THREE.MeshToonMaterial({ color: 0xffffff }) const projectileMaterial = new THREE.MeshToonMaterial({ color: 0xcccccc }) const terrainMaterial = new THREE.MeshToonMaterial({ color: 0xffd700 }) 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: 0x22dd22, 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 }), } 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 = {} let state = { abilities: [], entities: [], terrains: [], projectiles: [] } 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 const tweenDuration = 33 const keysDown = {} const mouse = {} function render() { cameraMovement() Object.values(positionTweens).forEach((tween) => tween.update()) // TODO: clean up tweens renderer.render(scene, camera) } 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.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.width != null && stateUpdates.height != null) { state.width = stateUpdates.width state.height = stateUpdates.height minimapCamera.top = state.height / 200 minimapCamera.right = state.height / 200 minimapCamera.bottom = -state.height / 200 minimapCamera.left = -state.height / 200 minimapCamera.updateProjectionMatrix() minimapCamera.position.set(state.width / 200, state.height / 200, minimapCameraZ) } 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) } } } console.log(state) 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) if (e.id == playerId) { const rangeMaterial = teamMaterials['range'] 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 = 4000 rangeMarker.scale.y = e.height / rangeMarkerSize rangeMarker.position.y = (e.height / (rangeMarkerSize * 2)) - (e.height / 100) rangeMarker.layers.set(1) entity.add(rangeMarker) } 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.height / 100 }, 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 } 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['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: 0.5 }), terrainMaterial) terrain.userData.type = 'terrain' terrain.userData.id = t.id scene.add(terrain) terrains[t.id] = terrain } 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) { for (let abilityIndex = 0; abilityIndex < 4; abilityIndex++) { const abilityKey = ['a', 'q', 'w', 'e'][abilityIndex] if (player.abilities[abilityKey] != null) { const abilityId = player.abilities[abilityKey] const ability = state.abilities.find((it) => it.id == abilityId) const lastCast = player.cooldowns[ability.id] ?? -Infinity const cooldownDuration = (ability.cooldown * state.tickRate) ?? 0 const remainingCooldown = (lastCast + cooldownDuration) - state.currentTick let cssPercentage = '100%' let text = '' if (remainingCooldown > 0) { const cooldownPercentage = 1 - (remainingCooldown / cooldownDuration) cssPercentage = `${Math.round(100 * cooldownPercentage)}%` if (remainingCooldown / state.tickRate <= 5) { text = `${(Math.round(10 * remainingCooldown / state.tickRate) / 10).toFixed(1)}` } else { text = `${Math.round(remainingCooldown / state.tickRate)}` } } if (player.casting?.ability?.id == ability.id) { document.getElementById(`ability-${abilityIndex}-cooldown`).style.clipPath = `polygon(50% 0%, 0% 100%, 100% 100%)` // triangle } else { document.getElementById(`ability-${abilityIndex}-cooldown`).style.clipPath = `polygon(0 ${cssPercentage}, 100% ${cssPercentage}, 100% 100%, 0 100%)` } document.getElementById(`ability-${abilityIndex}-cooldown-text`).innerHTML = text } } let castIndicatorDisplay = 'none' if (player.casting != null) { castIndicatorDisplay = 'block' const castDuration = (player.casting.ability.castTime * state.tickRate) ?? 0 const remainingCastTime = (player.casting.timestamp + castDuration) - state.currentTick let cssPercentage = '100%' if (remainingCastTime > 0) { const castPercentage = 1 - (remainingCastTime / castDuration) cssPercentage = `${Math.round(100 * castPercentage)}%` } document.getElementById('cast_indicator_progress').style.clipPath = `polygon(0 0, ${cssPercentage} 0, ${cssPercentage} 100%, 0% 100%)` document.getElementById('cast_indicator_name').innerHTML = player.casting.ability?.name ?? '' } document.getElementById('cast_indicator').style.display = castIndicatorDisplay } } // document.getElementById('state').innerHTML = JSON.stringify(stateUpdates, null, 2) } } window.addEventListener('load', () => { const params = Object.fromEntries(new URLSearchParams(window.location.search).entries()) playerId = params.id if (playerId == null) { playerId = prompt('Player ID:') } connectWebSocket() const canvas = renderer.domElement canvas.classList.add('canvas') window.addEventListener('mousedown', (event) => { const intersect = raycastToGround() if (intersect != null) { const { x, y } = intersect if (event.button == 0) { websocket.send(JSON.stringify({ action: 'attack', id: playerId, x, y })) } if (event.button == 2) { 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 == 'KeyA') { websocket.send(JSON.stringify({ action: 'attack', id: playerId, x, y })) } if (event.code == 'KeyX') { websocket.send(JSON.stringify({ action: 'cast', slot: 'a', id: playerId, x, y })) } if (event.code == 'KeyS') { websocket.send(JSON.stringify({ action: 'stop', id: playerId })) } if (event.code == 'KeyH') { websocket.send(JSON.stringify({ action: 'halt', id: playerId })) } if (event.code == 'KeyQ') { websocket.send(JSON.stringify({ action: 'cast', slot: 'q', id: playerId, x, y })) } if (event.code == 'KeyW') { websocket.send(JSON.stringify({ action: 'cast', slot: 'w', id: playerId, x, y })) } if (event.code == 'KeyE') { websocket.send(JSON.stringify({ action: 'cast', slot: 'e', id: playerId, x, y })) } } }) window.addEventListener('wheel', (event) => { if (event.deltaY < 0) { camera.zoom += 0.2 if (camera.zoom > 3) { camera.zoom = 3 } camera.updateProjectionMatrix() } if (event.deltaY > 0) { camera.zoom -= 0.2 if (camera.zoom < 1) { camera.zoom = 1 } camera.updateProjectionMatrix() } }) window.addEventListener('resize', (event) => { camera.aspect = window.innerWidth / window.innerHeight camera.updateProjectionMatrix() renderer.setSize(window.innerWidth, window.innerHeight) }) window.addEventListener('contextmenu', (event) => event.preventDefault()) window.addEventListener('keydown', (event) => keysDown[event.code] = true) window.addEventListener('keyup', (event) => keysDown[event.code] = false) window.addEventListener('keydown', (event) => { if (event.code == 'Space') { cameraLocked = !cameraLocked } }) window.addEventListener('mousemove', (event) => { mouse.x = event.clientX mouse.y = event.clientY }) document.body.appendChild(canvas) const minimap = minimapRenderer.domElement minimap.classList.add('minimap') document.body.appendChild(minimap) })