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 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 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 }), visionRange: new THREE.MeshToonMaterial({ color: 0x226022 }), // visionRange: new THREE.MeshToonMaterial({ color: 0x00ffff, transparent: true, opacity: opacity / 6 }), } // 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 animationActions = {} const entities = {} 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.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 }) 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) var delta = 0 function render() { stats.begin() delta = clock.getDelta() cameraMovement() 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() } 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) 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 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 } 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 var playerTeam = 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 && 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 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) } } } 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) // TODO: change entity material 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, 0) scene.add(entity) const hpMargin = 0.5 const maxHp = new THREE.Sprite(new THREE.SpriteMaterial({ color: 0xd03333 })) maxHp.position.set(0, 0, 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(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.TorusGeometry(0.95, 0.15), buffMaterial) buffMarker.rotation.x = Math.PI / 2 buffMarker.layers.set(1) buffMarker.visible = false entity.add(buffMarker) 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 / 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.dead && e.buffs.some((it) => it.id == 'exposed') // TODO: only works for Exposed now const animations = animationActions[e.id] ?? {} const fadeIn = created ? 0 : 0.15 if (e.dead) { 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 { 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 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(3).visible = !e.dead && e.id == playerId // entity.children.at(5).visible = !e.dead && e.team == playerTeam } 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] } } 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 } 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 = `