Compare commits
1 Commits
nodejs
..
3ef5254792
| Author | SHA1 | Date | |
|---|---|---|---|
|
3ef5254792
|
@@ -1,4 +0,0 @@
|
||||
.git
|
||||
*Dockerfile*
|
||||
*docker-compose*
|
||||
node_modules
|
||||
@@ -0,0 +1,2 @@
|
||||
# Normalize EOL for all files that Git considers text files.
|
||||
* text=auto eol=lf
|
||||
@@ -1,136 +1,3 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Files generated by the app
|
||||
public/temp
|
||||
|
||||
# Flamegraphs
|
||||
*.0X
|
||||
# Godot 4+ specific ignores
|
||||
.godot/
|
||||
/android/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
nodejs 23.6.1
|
||||
@@ -1,7 +0,0 @@
|
||||
FROM node:current-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install
|
||||
COPY public ./public
|
||||
COPY src ./src
|
||||
CMD ["node", "src/index.js"]
|
||||
@@ -1,86 +0,0 @@
|
||||
# Instructions Clear
|
||||
|
||||
Instructions Clear is a Multiplayer Online Battle Arena (MOBA) where two teams of five players fight for victory.
|
||||
The players have the ability to upgrade their abilities via their gear earned from quests.
|
||||
But make sure to prioritize resurrecting your allies first because once you wipe, it's all over.
|
||||
The game blends some elements of traditional MMORPG dungeons with modern MOBAs.
|
||||
|
||||
## Design Pillars
|
||||
|
||||
| Power | Reaction | Competition |
|
||||
| --- | --- | --- |
|
||||
| Players gradually get stronger as they complete quests. | Quests provide upgrade paths that provide opportunity to adapt. | If not by sheer power or a good build, teams have to outperform the opposing team to win by any means. |
|
||||
|
||||
## Audience & Market
|
||||
|
||||
The game is designed to be played online.
|
||||
Elements of the no longer popular MMORPG genre are used to give former players a modern game.
|
||||
It is aimed at young adults.
|
||||
|
||||
## Core Gameplay
|
||||
|
||||
The game controls are single unit isometric Real-Time Strategy (RTS) style (point-and-click).
|
||||
|
||||
The player can decide the pathing destination via right mouse clicks, combine it with auto-attacking the closest target via left mouse clicks, and cast abilities using the Q, W, and E keys.
|
||||
|
||||
Players of the same team collectively deal damage to players of the other team to bring the members' health points to zero. If a team's collective health pool has run out, that team loses.
|
||||
|
||||
Quests provide the income of players. Each quest gives the option of changing an ability or cashing out for gold.
|
||||
|
||||
Abilities provided by quests can either be a "legendary" (main) ability or a "basic" (minor) ability. A player is limited to one legendary ability per game.
|
||||
|
||||
The main ideology behind legendary abilities is the traditional damage, control, support trio.
|
||||
|
||||
Gold can be used to resurrect others (in lieu of a specialized ability), and to upgrade ability slots (to scale power).
|
||||
|
||||
The game is likely to provide a single level only.
|
||||
|
||||
## Mechanics for mechanics
|
||||
|
||||
The game concept is mainly dictated by MOBAs with the tried and tested systems, however, there are differences:
|
||||
|
||||
* Abilities replace items to reduce the possible combinations and to ease the learning curve.
|
||||
* Quests replace last-hitting to make income more diverse to enable traditionally low-income roles like position 5 supports to earn equally.
|
||||
* The win condition is changed to team elimination instead of the destruction of the main structure so that the game is more centered around the already celebrated team fights.
|
||||
* Equal, generic ability kits replace heroes (or champions) to eliminate the possibility of a "lost draft" at the hero selection phase.
|
||||
|
||||
TBD: What are quests?
|
||||
TBD: How (and if) structures work
|
||||
TBD: Unlike in the Defense Of The Ancients (DOTA), the core structure (the Ancient) is not a win condition but it provides a safe haven until destroyed.
|
||||
|
||||
## Gameplay Balance & Pacing
|
||||
|
||||
As all MOBAs, if balancing is not perfect, players will gravitate towards "metas" to win.
|
||||
|
||||
TBD: The pacing is so that it doesn't feel like too much preparation but it isn't dragged out.
|
||||
TBD: The "AFK farming" tactic should be crossed out by giving players incentive to fight.
|
||||
|
||||
### Possible Issues
|
||||
|
||||
**Corpse camping:** After a successful pick, the enemies would not let the picked player revive.
|
||||
|
||||
**Long death timers:** The player can get bored until they get revived.
|
||||
|
||||
## Setting & World
|
||||
|
||||
TBD: Diagonal lanes or a free-for-all layout?
|
||||
|
||||
## Narrative
|
||||
|
||||
TBD: Gameplay is king, narrative is going to be an afterthought.
|
||||
|
||||
## Business Model
|
||||
|
||||
This is a free-to-play game. Since running servers costs money and the server binary is not public, a subscription model can be introduced to spin up more servers on demand instead of waiting in queue for the free servers.
|
||||
|
||||
The cosmetic aspect ("skins" on the player model) is supposed to be responsible for the majority of the income.
|
||||
|
||||
## Timeline
|
||||
|
||||
1. Prototype with working pathing
|
||||
1. Win condition: full team elimination
|
||||
1. Basic ability mechanics
|
||||
1. Ability upgrades (upgrade trees)
|
||||
1. Quest mechanics
|
||||
1. Quest type diversification
|
||||
1. Retrospective to plan the remainder of the timeline
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="124" height="124" x="2" y="2" fill="#363d52" stroke="#212532" stroke-width="4" rx="14"/><g fill="#fff" transform="translate(12.322 12.322)scale(.101)"><path d="M105 673v33q407 354 814 0v-33z"/><path fill="#478cbf" d="m105 673 152 14q12 1 15 14l4 67 132 10 8-61q2-11 15-15h162q13 4 15 15l8 61 132-10 4-67q3-13 15-14l152-14V427q30-39 56-81-35-59-83-108-43 20-82 47-40-37-88-64 7-51 8-102-59-28-123-42-26 43-46 89-49-7-98 0-20-46-46-89-64 14-123 42 1 51 8 102-48 27-88 64-39-27-82-47-48 49-83 108 26 42 56 81zm0 33v39c0 276 813 276 814 0v-39l-134 12-5 69q-2 10-14 13l-162 11q-12 0-16-11l-10-65H446l-10 65q-4 11-16 11l-162-11q-12-3-14-13l-5-69z"/><path d="M483 600c0 34 58 34 58 0v-86c0-34-58-34-58 0z"/><circle cx="725" cy="526" r="90"/><circle cx="299" cy="526" r="90"/></g><g fill="#414042" transform="translate(12.322 12.322)scale(.101)"><circle cx="307" cy="532" r="60"/><circle cx="717" cy="532" r="60"/></g></svg>
|
||||
|
After Width: | Height: | Size: 994 B |
@@ -0,0 +1,37 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://rf3iei6hroc0"
|
||||
path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://icon.svg"
|
||||
dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
|
Before Width: | Height: | Size: 95 B |
|
Before Width: | Height: | Size: 543 B |
|
Before Width: | Height: | Size: 95 B |
|
Before Width: | Height: | Size: 95 B |
|
Before Width: | Height: | Size: 95 B |
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "instructions-clear",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "Thayol",
|
||||
"license": "UNLICENSED",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@tweenjs/tween.js": "^25.0.0",
|
||||
"sat": "^0.9.0",
|
||||
"stats.js": "^0.17.0",
|
||||
"three": "^0.171.0",
|
||||
"websocket-express": "^3.1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
; Engine configuration file.
|
||||
; It's best edited using the editor UI and not directly,
|
||||
; since the parameters that go here are not all obvious.
|
||||
;
|
||||
; Format:
|
||||
; [section] ; section goes between []
|
||||
; param=value ; assign values to parameters
|
||||
|
||||
config_version=5
|
||||
|
||||
[application]
|
||||
|
||||
config/name="Instructions Clear"
|
||||
config/features=PackedStringArray("4.3", "Mobile")
|
||||
config/icon="res://icon.svg"
|
||||
|
||||
[rendering]
|
||||
|
||||
renderer/rendering_method="mobile"
|
||||
@@ -1,723 +0,0 @@
|
||||
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: 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 {
|
||||
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')
|
||||
|
||||
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 // 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]
|
||||
}
|
||||
}
|
||||
|
||||
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 = `<div id="ability-${i}" class="ability">${abilityKeyText}<div id="ability-${i}-cooldown" class="cooldown"></div><div id="ability-${i}-cooldown-text" class="cooldown-text"></div></div>`
|
||||
abilitiesHTML += abilityTemplate
|
||||
}
|
||||
|
||||
if (document.getElementById(`abilities`).innerHTML != abilitiesHTML) {
|
||||
document.getElementById(`abilities`).innerHTML = abilitiesHTML
|
||||
}
|
||||
|
||||
let abilityIndex = 0
|
||||
for (const [_abilityKey, abilityId] of Object.entries(playerAbilities)) {
|
||||
abilityIndex++
|
||||
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 == 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 buffs = ``
|
||||
player.buffs.forEach((b) => {
|
||||
buffs += `<div class="buff"><div class="buff-body">${state.buffs.find((it) => it.id == b.id).name}</div></div>`
|
||||
})
|
||||
|
||||
if (document.getElementById('buffs').innerHTML != buffs) {
|
||||
document.getElementById('buffs').innerHTML = buffs
|
||||
}
|
||||
|
||||
let castIndicatorDisplay = 'none'
|
||||
if (player.casting != null) {
|
||||
castIndicatorDisplay = 'block'
|
||||
const ability = state.abilities.find((it) => it.id == player.casting.ability)
|
||||
if (ability != null) {
|
||||
const castDuration = (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 = ability.name ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('cast_indicator').style.display = castIndicatorDisplay
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('state').innerHTML = JSON.stringify(stateUpdates, null, 2)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
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 }))
|
||||
}
|
||||
|
||||
const alreadyBound = ['A', 'X', 'S', 'H']
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').forEach((letter) => {
|
||||
if (alreadyBound.includes(letter)) { return }
|
||||
|
||||
if (event.code == `Key${letter}`) {
|
||||
websocket.send(JSON.stringify({ action: 'cast', slot: letter.toLowerCase(), 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)
|
||||
|
||||
document.body.appendChild(stats.dom)
|
||||
})
|
||||
@@ -1,192 +0,0 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="author" content="Thayol">
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "/three/build/three.module.js",
|
||||
"three/addons/": "/three/examples/jsm/",
|
||||
"@tweenjs/tween.js": "/@tweenjs/tween.js/dist/tween.esm.js",
|
||||
"stats.js": "/stats.js/src/Stats.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.debug-panel {
|
||||
display: none;
|
||||
font-size: 0.8rem;
|
||||
position: fixed;
|
||||
opacity: 0.2;
|
||||
overflow-y: scroll;
|
||||
inset: 0 0 290px auto;
|
||||
border-bottom-left-radius: 10px;
|
||||
padding: 10px 10px 20px 20px;
|
||||
background-color: white;
|
||||
border: 5px solid gray;
|
||||
border-top: none;
|
||||
border-right: none;
|
||||
width: 300px;
|
||||
transition-duration: 0.2s;
|
||||
transition-property: opacity;
|
||||
}
|
||||
|
||||
.debug-panel:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.minimap {
|
||||
position: fixed;
|
||||
inset: auto 0 0 auto;
|
||||
border: 5px solid gray;
|
||||
border-top-left-radius: 10px;
|
||||
border-bottom: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.abilities {
|
||||
display: none;
|
||||
position: fixed;
|
||||
gap: 10px;
|
||||
padding: 15px;
|
||||
padding-bottom: 10px;
|
||||
inset: auto 0 0 0;
|
||||
width: fit-content;
|
||||
margin: auto;
|
||||
border: 5px solid gray;
|
||||
background-color: black;
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.abilities:has(.ability) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ability {
|
||||
position: relative;
|
||||
flex: 1 0 0;
|
||||
border: 1px solid white;
|
||||
width: 75px;
|
||||
height: 75px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cooldown {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 73px;
|
||||
height: 73px;
|
||||
background-color: grey;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.cooldown-text {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 73px;
|
||||
height: 73px;
|
||||
line-height: 73px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.cast-indicator-wrapper {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: auto 0 30%;
|
||||
width: 400px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.cast-indicator-progress {
|
||||
position: absolute;
|
||||
background-color: #edd9ff;
|
||||
width: calc(100% - 4px);
|
||||
height: calc(100% - 4px);
|
||||
}
|
||||
|
||||
.cast-indicator-name {
|
||||
text-align: center;
|
||||
color: white;
|
||||
text-shadow: 1px 1px 2px black, 0 0 1em dimgray, 0 0 0.2em dimgray;
|
||||
}
|
||||
|
||||
.cast-indicator-bar {
|
||||
position: relative;
|
||||
background-color: dimgray;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.buffs {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
inset: auto 0 120px calc(50vw - 165px);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.buff {
|
||||
flex: 1 0 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background-color: black;
|
||||
/* border: 1px solid gray; */
|
||||
border-right: 1px solid gray;
|
||||
color: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.buff:hover {
|
||||
overflow: visible;
|
||||
height: fit-content;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.buff-body {
|
||||
border: 1px solid gray;
|
||||
padding: 5px;
|
||||
background-color: black;
|
||||
width: fit-content;
|
||||
min-width: 200px;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="debug-panel">
|
||||
<p>Connection: <span id="connection"></span></p>
|
||||
<pre id="state"></pre>
|
||||
</div>
|
||||
<div id="cast_indicator" class="cast-indicator-wrapper">
|
||||
<div id="cast_indicator_name" class="cast-indicator-name"></div>
|
||||
<div class="cast-indicator-bar">
|
||||
<div id="cast_indicator_progress" class="cast-indicator-progress"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="abilities" class="abilities">
|
||||
</div>
|
||||
<div id="buffs" class="buffs"></div>
|
||||
<script type="module" src="client.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,26 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 365 B |
@@ -1,320 +0,0 @@
|
||||
import Buff from './buff.js'
|
||||
import Projectile from './projectile.js'
|
||||
|
||||
// Three classes: Blade, Armor, Charm
|
||||
|
||||
export default class Ability {
|
||||
id = `ability-${Ability.nextId()}`
|
||||
static nextId() { return this.#nextUniqueId++ }
|
||||
static #nextUniqueId = 0
|
||||
|
||||
name = 'Ability'
|
||||
|
||||
castTime = null
|
||||
cooldown = 0
|
||||
damage = 0
|
||||
moveCancelable = false
|
||||
radius = 1
|
||||
range = 0
|
||||
speed = 1000
|
||||
|
||||
#effect = null
|
||||
|
||||
get effect() { return this.#effect ?? Ability.noEffect }
|
||||
set effect(value) { this.#effect = value }
|
||||
|
||||
constructor(options = {}) {
|
||||
Object.entries(options).forEach(([key, value]) => this[key] = value)
|
||||
}
|
||||
|
||||
static get noEffect() { return function noEffect() {} }
|
||||
|
||||
static blink = new Ability({
|
||||
id: 'blink',
|
||||
name: 'Blink',
|
||||
cooldown: 10,
|
||||
range: 475,
|
||||
effect: function blinkEffect(caster, cursor) {
|
||||
const ability = this
|
||||
const direction = cursor.clone().sub(caster.position)
|
||||
const realRange = ability.range + caster.radius
|
||||
if (direction.length() > realRange) {
|
||||
direction.normalize().multiplyScalar(realRange)
|
||||
}
|
||||
|
||||
const destination = caster.position.clone().add(direction)
|
||||
|
||||
caster.teleport(destination)
|
||||
caster.cooldown(ability.id)
|
||||
},
|
||||
})
|
||||
|
||||
static circleOfResurrection = new Ability({
|
||||
id: 'circle_of_resurrection',
|
||||
name: 'Circle of Resurrection',
|
||||
castTime: 0.5,
|
||||
cooldown: 100,
|
||||
duration: 3,
|
||||
moveCancelable: true,
|
||||
radius: 300,
|
||||
range: 300,
|
||||
effect: function circleOfResurrectionEffect(caster, cursor) {
|
||||
const ability = this
|
||||
caster.haltAction()
|
||||
|
||||
const direction = cursor.clone().sub(caster.position)
|
||||
if (direction.length() > ability.range) {
|
||||
direction.normalize().multiplyScalar(ability.range)
|
||||
}
|
||||
|
||||
const destination = caster.position.clone().add(direction)
|
||||
|
||||
const team = caster.team
|
||||
const currentTick = caster.game?.currentTick ?? 0
|
||||
const duration = caster.game?.secToTick(ability.duration) ?? 0
|
||||
const despawnAfter = currentTick + duration
|
||||
const casterPosition = caster.position.clone()
|
||||
|
||||
const circleOfResurrectionLogic = function castingVisionLogic(projectile) {
|
||||
const currentTick = projectile.game?.currentTick ?? 0
|
||||
if (casterPosition.distanceTo(caster.position) > 1) {
|
||||
projectile.despawn()
|
||||
}
|
||||
|
||||
if (currentTick > despawnAfter) {
|
||||
const entities = projectile.game?.entities ?? []
|
||||
const pos = projectile.position
|
||||
projectile.despawn()
|
||||
const nearbyDeadTeammates = entities.filter((it) => it.dead && it.team == team && it.distanceTo(pos) <= ability.radius)
|
||||
const closestDeadTeammate = nearbyDeadTeammates.reduce((e1, e2) => (e1?.distanceTo(pos) ?? Infinity) < e2.distanceTo(pos) ? e1 : e2, null)
|
||||
if (closestDeadTeammate != null) {
|
||||
closestDeadTeammate.revive(closestDeadTeammate.maxHealth / 4)
|
||||
caster.cooldown(ability.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const projectile = new Projectile({
|
||||
logic: circleOfResurrectionLogic,
|
||||
owner: caster.id,
|
||||
position: destination,
|
||||
radius: ability.radius,
|
||||
visualRadius: 0,
|
||||
})
|
||||
|
||||
caster.game?.spawnProjectile(projectile)
|
||||
if (caster.casting != null) {
|
||||
caster.forceCast(Ability.circleOfResurrectionChannel.id, destination)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
static circleOfResurrectionChannel = new Ability({
|
||||
id: 'channel:circle_of_resurrection',
|
||||
name: 'Channeling: Circle of Resurrection',
|
||||
castTime: 3,
|
||||
moveCancelable: true,
|
||||
})
|
||||
|
||||
static control = new Ability({
|
||||
id: 'control',
|
||||
name: 'Control',
|
||||
castTime: 1,
|
||||
cooldown: 5,
|
||||
effect: function controlEffect(caster, cursor) { },
|
||||
})
|
||||
|
||||
static expose = new Ability({
|
||||
id: 'expose',
|
||||
name: 'Expose',
|
||||
castTime: 0.25,
|
||||
cooldown: 6,
|
||||
radius: 80,
|
||||
range: 1200,
|
||||
speed: 1700,
|
||||
visualRadius: 50,
|
||||
effect: function exposeEffect(caster, cursor) {
|
||||
const ability = this
|
||||
const exposeCollision = function exposeCollision(projectile, collidingEntity) {
|
||||
if (projectile.game == null) { return }
|
||||
if (collidingEntity == null) { return }
|
||||
if (collidingEntity.team == caster.id) { return }
|
||||
if (collidingEntity.team == (caster.team ?? 'unknown')) { return }
|
||||
|
||||
collidingEntity.applyBuff(Buff.exposed.id, caster.id)
|
||||
projectile.despawn()
|
||||
}
|
||||
|
||||
const projectile = new Projectile({
|
||||
onCollide: exposeCollision,
|
||||
owner: caster.id,
|
||||
position: caster.position.clone(),
|
||||
radius: ability.radius,
|
||||
speed: ability.speed,
|
||||
visualRadius: ability.visualRadius,
|
||||
})
|
||||
|
||||
projectile.destination = caster.position.clone().add(cursor.clone().sub(caster.position).normalize().multiplyScalar(ability.range + caster.radius))
|
||||
caster.game?.spawnProjectile(projectile)
|
||||
caster.cooldown(ability.id)
|
||||
},
|
||||
})
|
||||
|
||||
static meleeAttack = new Ability({
|
||||
id: 'melee_attack',
|
||||
name: 'Melee Attack',
|
||||
castTime: (1.4 * 0.22),
|
||||
cooldown: 1.4,
|
||||
moveCancelable: true,
|
||||
damage: 60,
|
||||
radius: 5,
|
||||
range: 100,
|
||||
effect: function meleeAttackEffect(caster, targetId) {
|
||||
const ability = this
|
||||
const target = caster.game?.entities.find((it) => it.id == targetId)
|
||||
if (target == null) { return }
|
||||
|
||||
target.damage(ability.damage, caster)
|
||||
caster.cooldown(ability.id)
|
||||
},
|
||||
})
|
||||
|
||||
static rangedAttack = new Ability({
|
||||
id: 'ranged_attack',
|
||||
name: 'Ranged Attack',
|
||||
castTime: (1.6 * 0.18839),
|
||||
cooldown: 1.6,
|
||||
damage: 60,
|
||||
moveCancelable: true,
|
||||
radius: 5,
|
||||
range: 500,
|
||||
speed: 2000,
|
||||
effect: function rangedAttackEffect(caster, targetId) {
|
||||
const ability = this
|
||||
const target = caster.game?.entities.find((it) => it.id == targetId)
|
||||
if (target == null) { return }
|
||||
|
||||
const rangedAttackAfter = function rangedAttackAfter() {
|
||||
target.damage(ability.damage, caster)
|
||||
}
|
||||
|
||||
const projectile = new Projectile({
|
||||
after: rangedAttackAfter,
|
||||
homingTarget: target,
|
||||
owner: caster.id,
|
||||
position: caster.position.clone(),
|
||||
radius: ability.radius,
|
||||
speed: ability.speed,
|
||||
})
|
||||
|
||||
caster.game?.spawnProjectile(projectile)
|
||||
caster.cooldown(ability.id)
|
||||
},
|
||||
})
|
||||
|
||||
static shieldThrow = new Ability({
|
||||
id: 'shield_throw',
|
||||
name: 'Shield Throw',
|
||||
castTime: 0.25,
|
||||
cooldown: 5,
|
||||
damage: 90,
|
||||
deceleratePerTick: 90,
|
||||
radius: 110,
|
||||
range: 1025,
|
||||
speed: 2400,
|
||||
effect: function shieldThrowEffect(caster, cursor) {
|
||||
const ability = this
|
||||
const amount = ability.damage
|
||||
let onTheWayBack = false
|
||||
let collided = new Set()
|
||||
|
||||
const shieldThrowCollision = function shieldThrowCollision(projectile, collidingEntity) {
|
||||
if (collidingEntity == null) { return }
|
||||
if (collidingEntity.id == caster.id) { return }
|
||||
if (caster.team == null || collidingEntity.team == null || collidingEntity.team != caster.team) { return }
|
||||
const entityId = collidingEntity.id
|
||||
|
||||
if (!collided.has(entityId)) {
|
||||
collidingEntity.applyBuff(Buff.shieldThrowShield.id, caster.id)
|
||||
collided.add(entityId)
|
||||
}
|
||||
}
|
||||
|
||||
const accelerateLogic = function accelerateLogic(projectile) {
|
||||
if (onTheWayBack) {
|
||||
projectile.speed += ability.deceleratePerTick
|
||||
}
|
||||
else {
|
||||
if (projectile.speed - ability.deceleratePerTick >= ability.deceleratePerTick) {
|
||||
projectile.speed = projectile.speed - ability.deceleratePerTick
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shieldThrowSecondAfter = function shieldThrowSecondAfter(projectile, homingTarget) {
|
||||
caster.applyBuff(Buff.shieldThrowShield.id, caster.id)
|
||||
caster.applyBuff(Buff.shieldThrowShield.id, caster.id) // NOTE: duplicated on purpose
|
||||
}
|
||||
|
||||
const shieldThrowFirstAfter = function shieldThrowFirstAfter(projectile, homingTarget) {
|
||||
projectile.destination = null
|
||||
projectile.homingTarget = caster
|
||||
onTheWayBack = true
|
||||
collided.clear()
|
||||
projectile.after = shieldThrowSecondAfter
|
||||
}
|
||||
|
||||
const projectile = new Projectile({
|
||||
after: shieldThrowFirstAfter,
|
||||
logic: accelerateLogic,
|
||||
onCollide: shieldThrowCollision,
|
||||
owner: caster.id,
|
||||
position: caster.position.clone(),
|
||||
radius: ability.radius,
|
||||
speed: ability.speed,
|
||||
visionRange: ability.radius * 1.5,
|
||||
})
|
||||
|
||||
projectile.destination = caster.position.clone().add(cursor.clone().sub(caster.position).normalize().multiplyScalar(ability.range + caster.radius))
|
||||
caster.game?.spawnProjectile(projectile)
|
||||
caster.cooldown(ability.id)
|
||||
},
|
||||
})
|
||||
|
||||
static straightShot = new Ability({
|
||||
id: 'straight_shot',
|
||||
name: 'Straight Shot',
|
||||
castTime: 0.25,
|
||||
cooldown: 1,
|
||||
damage: 83,
|
||||
radius: 60,
|
||||
range: 1200,
|
||||
visualRadius: 20,
|
||||
speed: 2000,
|
||||
effect: function straightShotEffect(caster, cursor) {
|
||||
const ability = this
|
||||
const straightShotCollision = function straightShotCollision(projectile, collidingEntity) {
|
||||
if (projectile.game == null) { return }
|
||||
if (collidingEntity == null) { return }
|
||||
if (collidingEntity.id == caster.id) { return }
|
||||
if (collidingEntity.team == (caster.team ?? 'unknown')) { return }
|
||||
|
||||
collidingEntity.damage(ability.damage, caster)
|
||||
projectile.despawn()
|
||||
}
|
||||
|
||||
const projectile = new Projectile({
|
||||
onCollide: straightShotCollision,
|
||||
owner: caster.id,
|
||||
position: caster.position.clone(),
|
||||
radius: ability.radius,
|
||||
speed: ability.speed,
|
||||
visualRadius: ability.visualRadius,
|
||||
})
|
||||
|
||||
projectile.destination = caster.position.clone().add(cursor.clone().sub(caster.position).normalize().multiplyScalar(ability.range + caster.radius))
|
||||
caster.game?.spawnProjectile(projectile)
|
||||
caster.cooldown(ability.id)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
export default class Buff {
|
||||
id = `ability-${Buff.nextId()}`
|
||||
static nextId() { return this.#nextUniqueId++ }
|
||||
static #nextUniqueId = 0
|
||||
|
||||
name = 'Buff'
|
||||
|
||||
damageMultiplier = null
|
||||
duration = 0
|
||||
shield = null
|
||||
|
||||
#effect = null
|
||||
|
||||
get effect() { return this.#effect ?? Buff.noEffect }
|
||||
set effect(value) { this.#effect = value }
|
||||
|
||||
static get noEffect() { return function noEffect() {} }
|
||||
|
||||
constructor(options = {}) {
|
||||
Object.entries(options).forEach(([key, value]) => this[key] = value)
|
||||
}
|
||||
|
||||
static exposed = new Buff({
|
||||
id: 'exposed',
|
||||
name: 'Exposed',
|
||||
duration: 4,
|
||||
onHitMultiplier: 3,
|
||||
})
|
||||
|
||||
static shieldThrowShield = new Buff({
|
||||
id: 'shield_throw_shield',
|
||||
name: 'Shield (of Shield Throw)',
|
||||
duration: 5,
|
||||
shield: 200,
|
||||
})
|
||||
}
|
||||
@@ -1,886 +0,0 @@
|
||||
import { Vector2 } from 'three'
|
||||
import Buff from './buff.js'
|
||||
import Pathfind from './pathfind.js'
|
||||
import Projectile from './projectile.js'
|
||||
import SAT from 'sat'
|
||||
import SATX from './satx.js'
|
||||
import Team from './team.js'
|
||||
|
||||
export default class Entity {
|
||||
id = `entity-${Entity.nextId()}`
|
||||
static nextId() { return this.#nextUniqueId++ }
|
||||
static #nextUniqueId = 0
|
||||
|
||||
abilities = {}
|
||||
buffs = []
|
||||
casting = null
|
||||
cooldowns = {}
|
||||
dead = false
|
||||
ghosting = false
|
||||
health = null
|
||||
height = null
|
||||
maxHealth = 1
|
||||
model = null
|
||||
position = null
|
||||
radius = 0
|
||||
rotation = 0
|
||||
speed = 400
|
||||
team = Team.neutral
|
||||
visionRange = 900
|
||||
visualRadius = null
|
||||
|
||||
#attacking = false
|
||||
#bbox = new Float32Array(4)
|
||||
#colliders = []
|
||||
#collision = true
|
||||
#dest = null
|
||||
#entitiesInVision = []
|
||||
#game = null
|
||||
#ghostable = true
|
||||
#logic = null
|
||||
#moving = false
|
||||
#noPathfindingUntil = 0
|
||||
#path = []
|
||||
#pathfindingCooldown = 0
|
||||
#pathfindingObstacleLimit = null
|
||||
#projectilesInVision = []
|
||||
#spawnPosition = new Vector2()
|
||||
|
||||
static bbox(x, y, radius) {
|
||||
return new Float32Array([y + radius, x + radius, y - radius, x - radius])
|
||||
}
|
||||
|
||||
static collider(x, y, radius) {
|
||||
return new SAT.Circle(new SAT.Vector(x, y), radius)
|
||||
}
|
||||
|
||||
// deliberate code duplication for performance
|
||||
static tunnelCollider(fromX, fromY, toX, toY, radius) {
|
||||
if (radius <= 0) {
|
||||
return SATX.line(fromX, fromY, toX, toY)
|
||||
}
|
||||
|
||||
const sides = new Float32Array(5)
|
||||
sides[0] = toX - fromX
|
||||
sides[1] = toY - fromY
|
||||
sides[4] = Math.hypot(sides[0], sides[1])
|
||||
sides[2] = (sides[1] / sides[4]) * -radius // optimization: negation and swapping rotates
|
||||
sides[3] = (sides[0] / sides[4]) * radius
|
||||
|
||||
return new SAT.Polygon(new SAT.Vector(fromX - sides[2], fromY - sides[3]), [
|
||||
new SAT.Vector(),
|
||||
new SAT.Vector(sides[0], sides[1]),
|
||||
new SAT.Vector(sides[0] + (2 * sides[2]), sides[1] + (2 * sides[3])),
|
||||
new SAT.Vector(2 * sides[2], 2 * sides[3]),
|
||||
])
|
||||
}
|
||||
|
||||
// deliberate code duplication for performance
|
||||
static tunnelVertices(fromX, fromY, toX, toY, radius) {
|
||||
const sides = new Float32Array(5)
|
||||
sides[0] = toX - fromX
|
||||
sides[1] = toY - fromY
|
||||
sides[4] = Math.hypot(sides[0], sides[1])
|
||||
sides[2] = (sides[1] / sides[4]) * -radius // optimization: negation and swapping rotates
|
||||
sides[3] = (sides[0] / sides[4]) * radius
|
||||
|
||||
return [
|
||||
new Vector2(fromX - sides[2], fromY - sides[3]),
|
||||
new Vector2(fromX - sides[2] + sides[0], fromY - sides[3] + sides[1]),
|
||||
new Vector2(fromX + sides[2] + sides[0], fromY + sides[3] + sides[1]),
|
||||
new Vector2(fromX + sides[2], fromY + sides[3]),
|
||||
]
|
||||
}
|
||||
|
||||
// deliberate code duplication for performance
|
||||
static tunnelBbox(fromX, fromY, toX, toY, radius) {
|
||||
if (radius <= 0) {
|
||||
return new Float32Array([
|
||||
Math.max(fromY, toY),
|
||||
Math.max(fromX, toX),
|
||||
Math.min(fromY, toY),
|
||||
Math.min(fromX, toX),
|
||||
])
|
||||
}
|
||||
|
||||
const sides = new Float32Array(5)
|
||||
sides[0] = toX - fromX
|
||||
sides[1] = toY - fromY
|
||||
sides[4] = Math.hypot(sides[0], sides[1])
|
||||
sides[2] = (sides[1] / sides[4]) * -radius // optimization: negation and swapping rotates
|
||||
sides[3] = (sides[0] / sides[4]) * radius
|
||||
|
||||
const offsetX = fromX + sides[0]
|
||||
const x1 = fromX - sides[2]
|
||||
const x2 = fromX + sides[2]
|
||||
const x3 = offsetX - sides[2]
|
||||
const x4 = offsetX + sides[2]
|
||||
|
||||
const offsetY = fromY + sides[1]
|
||||
const y1 = fromY - sides[3]
|
||||
const y2 = fromY + sides[3]
|
||||
const y3 = offsetY - sides[3]
|
||||
const y4 = offsetY + sides[3]
|
||||
|
||||
return new Float32Array([
|
||||
Math.max(y1, y2, y3, y4),
|
||||
Math.max(x1, x2, x3, x4),
|
||||
Math.min(y1, y2, y3, y4),
|
||||
Math.min(x1, x2, x3, x4),
|
||||
])
|
||||
}
|
||||
|
||||
constructor(options = {}) {
|
||||
Object.entries(options).forEach(([key, value]) => this[key] = value)
|
||||
if (this.position == null) {
|
||||
this.position = this.#spawnPosition.clone()
|
||||
}
|
||||
if (this.health == null) {
|
||||
this.health = this.maxHealth
|
||||
}
|
||||
if (this.visualRadius == null) {
|
||||
this.visualRadius = this.radius
|
||||
}
|
||||
if (this.height == null) {
|
||||
this.height = this.visualRadius ?? this.radius
|
||||
}
|
||||
|
||||
this.#calculateCollider()
|
||||
}
|
||||
|
||||
get attacking() { return this.#attacking }
|
||||
get bbox() { return this.#bbox }
|
||||
get collision() { return this.#collision }
|
||||
get destination() { return this.#dest }
|
||||
get entitiesInVision() { return this.#entitiesInVision }
|
||||
get game() { return this.#game }
|
||||
get ghostable() { return this.#ghostable }
|
||||
get logic() { return this.#logic }
|
||||
get pathfindingCooldown() { return this.#pathfindingCooldown }
|
||||
get pathfindingObstacleLimit() { return this.#pathfindingObstacleLimit }
|
||||
get projectilesInVision() { return this.#projectilesInVision }
|
||||
get spawnPosition() { return this.#spawnPosition }
|
||||
get x() { return this.position.x }
|
||||
get y() { return this.position.y }
|
||||
|
||||
set bbox(value) { this.#bbox = value }
|
||||
set collision(value) { this.#collision = value }
|
||||
set destination(value) { this.#dest = value }
|
||||
set game(value) { this.#game = value }
|
||||
set ghostable(value) { this.#ghostable = value }
|
||||
set logic(value) { this.#logic = value }
|
||||
set pathfindingCooldown(value) { this.#pathfindingCooldown = value }
|
||||
set pathfindingObstacleLimit(value) { this.#pathfindingObstacleLimit = value }
|
||||
set spawnPosition(value) { this.#spawnPosition = value }
|
||||
set x(value) { this.position.x = value }
|
||||
set y(value) { this.position.y = value }
|
||||
|
||||
attackAction(cursor) {
|
||||
if (this.dead) { return }
|
||||
|
||||
this.moveAction(cursor, true)
|
||||
}
|
||||
|
||||
castAction(slot, cursor, halt = false) {
|
||||
if (this.dead) { return }
|
||||
|
||||
const ability = this.ability(slot)
|
||||
if (ability == null) { return }
|
||||
|
||||
if (this.casting != null) {
|
||||
const abilityBeingCasted = this.game?.abilities.find((it) => it.id == this.casting.ability)
|
||||
if (abilityBeingCasted != null && abilityBeingCasted.id == ability.id) {
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if (halt) {
|
||||
this.#moving = false
|
||||
}
|
||||
|
||||
const targetPosition = (cursor instanceof Vector2) ? cursor : this.game?.entities.find((it) => it.id == cursor)?.position
|
||||
if (targetPosition instanceof Vector2) {
|
||||
this.rotation = targetPosition.clone().sub(this.position).angle()
|
||||
}
|
||||
|
||||
const cooldown = this.game?.secToTick(ability.cooldown) ?? 0
|
||||
const lastCast = this.cooldowns[ability.id]
|
||||
const timestamp = this.game?.currentTick ?? 0
|
||||
if (lastCast != null && lastCast + cooldown > timestamp) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (ability.castTime == null) {
|
||||
this.#castingVision()
|
||||
ability.effect(this, cursor)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
this.casting = { ability: ability.id, cursor, timestamp }
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
haltAction() {
|
||||
if (this.dead) { return }
|
||||
|
||||
this.#moving = false
|
||||
}
|
||||
|
||||
moveAction(cursor, attack = false) {
|
||||
if (this.dead) { return }
|
||||
|
||||
if (this.casting != null && this.game?.abilities.find((it) => it.id == this.casting.ability)?.moveCancelable) {
|
||||
if (!attack && !(this.casting != null && this.casting.ability == this.abilities[0])) {
|
||||
this.casting = null
|
||||
}
|
||||
}
|
||||
|
||||
this.#attacking = attack
|
||||
this.#moving = true
|
||||
this.#dest = cursor.clone()
|
||||
}
|
||||
|
||||
stopAction() {
|
||||
if (this.dead) { return }
|
||||
|
||||
this.casting = null
|
||||
this.#moving = true
|
||||
this.#attacking = false
|
||||
}
|
||||
|
||||
// --- Actions above --- //
|
||||
|
||||
ability(slot) {
|
||||
if (this.abilities[slot] != null) {
|
||||
return this.game?.abilities.find((it) => it.id == this.abilities[slot])
|
||||
}
|
||||
}
|
||||
|
||||
adjustWaypoint(waypoint, direction) {
|
||||
return SATX.clamp(
|
||||
waypoint.clone().add(direction.clone().multiplyScalar(this.radius)),
|
||||
this.game?.width,
|
||||
this.game?.height,
|
||||
this.radius,
|
||||
)
|
||||
}
|
||||
|
||||
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 < 0) {
|
||||
const entityBuff = { id, source, timestamp }
|
||||
if (buff.shield != null) {
|
||||
entityBuff.shield = buff.shield
|
||||
}
|
||||
|
||||
this.buffs.push(entityBuff)
|
||||
}
|
||||
else {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collidables() {
|
||||
return this.customBboxCollidables(this.bbox)
|
||||
}
|
||||
|
||||
collider() {
|
||||
return this.#colliders.at(0)
|
||||
}
|
||||
|
||||
colliders() {
|
||||
return this.#colliders
|
||||
}
|
||||
|
||||
cooldown(id) {
|
||||
this.cooldowns[id] = this.game?.currentTick ?? 0
|
||||
}
|
||||
|
||||
closestTargetTo(cursor, range, targetAllies = false) {
|
||||
const entities = this.game?.entities
|
||||
if (entities == null) { return }
|
||||
const targetsInRange = targetAllies
|
||||
? entities.filter((it) => !it.dead && this.team == it.team && it.distanceTo(cursor) <= range + this.radius + it.radius)
|
||||
: entities.filter((it) => !it.dead && this.team != it.team && it.distanceTo(cursor) <= range + this.radius + it.radius)
|
||||
|
||||
if (targetsInRange.length < 1) { return }
|
||||
|
||||
const absoluteClosestTarget = targetsInRange.reduce((e1, e2) => (e1?.distanceTo(cursor) ?? Infinity) < e2.distanceTo(cursor) ? e1 : e2, null)
|
||||
const entityIdsInDirectVision = this.#entitiesInVision
|
||||
if (entityIdsInDirectVision.includes(absoluteClosestTarget.id)) {
|
||||
return absoluteClosestTarget
|
||||
}
|
||||
|
||||
const visibleEntityIds = this.visibleEntities()
|
||||
const visibleEntitiesInRange = targetsInRange.filter((it) => visibleEntityIds.includes(it.id))
|
||||
|
||||
return visibleEntitiesInRange.reduce((e1, e2) => (e1?.distanceTo(cursor) ?? Infinity) < e2.distanceTo(cursor) ? e1 : e2, null)
|
||||
}
|
||||
|
||||
customBboxCollidables(bbox) {
|
||||
const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? [])
|
||||
return entitiesAndTerrains.filter((it) => !it.dead && it.collision && !((this.ghosting && it.ghostable) || (this.ghostable && it.ghosting)) && SATX.bboxCheck(bbox, it.bbox))
|
||||
}
|
||||
|
||||
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) {
|
||||
customMultipliers += (buff.onHitMultiplier - 1)
|
||||
this.removeBuff(Buff.exposed.id)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
despawn() {
|
||||
this.game?.despawn(this)
|
||||
}
|
||||
|
||||
distanceTo(cursor) {
|
||||
return this.position.distanceTo(cursor)
|
||||
}
|
||||
|
||||
forceCast(abilityId, cursor) {
|
||||
if (this.dead) { return }
|
||||
|
||||
const ability = this.game?.abilities.find((it) => it.id == abilityId)
|
||||
if (ability == null) { return }
|
||||
|
||||
const targetPosition = (cursor instanceof Vector2) ? cursor : this.game?.entities.find((it) => it.id == cursor)?.position
|
||||
if (targetPosition instanceof Vector2) {
|
||||
this.rotation = targetPosition.clone().sub(this.position).angle()
|
||||
}
|
||||
|
||||
const timestamp = this.game?.currentTick ?? 0
|
||||
|
||||
if (ability.castTime == null) {
|
||||
this.#castingVision()
|
||||
ability.effect(this, cursor)
|
||||
}
|
||||
else {
|
||||
this.casting = { ability: ability.id, cursor, timestamp }
|
||||
}
|
||||
}
|
||||
|
||||
futureCollidables(futurePosition) {
|
||||
return this.customBboxCollidables(new Float32Array([
|
||||
futurePosition.y + this.radius,
|
||||
futurePosition.x + this.radius,
|
||||
futurePosition.y - this.radius,
|
||||
futurePosition.x - this.radius,
|
||||
]))
|
||||
}
|
||||
|
||||
getBuff(id) {
|
||||
if (this.dead) { return }
|
||||
|
||||
const entityBuff = this.buffs.find((it) => it.id == id)
|
||||
if (entityBuff == null) { return }
|
||||
|
||||
const buffDefinition = this.game?.buffs.find((it) => it.id == entityBuff.id)
|
||||
if (buffDefinition == null) { return }
|
||||
|
||||
return { ...buffDefinition, ...entityBuff }
|
||||
}
|
||||
|
||||
getBuffs() {
|
||||
return this.buffs.map((it) => this.getBuff(it.id)).filter((it) => it != null)
|
||||
}
|
||||
|
||||
hasBuff(id) {
|
||||
if (this.dead) { return false }
|
||||
|
||||
return this.buffs.some((it) => it.id == id) && this.game?.buffs.some((it) => it.id == id)
|
||||
}
|
||||
|
||||
heal(amount, _source) {
|
||||
if (this.dead) { return }
|
||||
|
||||
this.health = Math.min(Math.max(0, this.health + amount), this.maxHealth)
|
||||
}
|
||||
|
||||
fixPosition() {
|
||||
const fixedPosition = this.fixFuturePosition(this.position)
|
||||
if (this.position.equals(fixedPosition)) { return }
|
||||
|
||||
this.setPosition(fixedPosition)
|
||||
}
|
||||
|
||||
fixFuturePosition(futurePosition) {
|
||||
const maxX = this.game?.width ?? Infinity
|
||||
const maxY = this.game?.height ?? Infinity
|
||||
const radius = this.radius
|
||||
if (!this.willCollide(futurePosition)) {
|
||||
return SATX.clamp(futurePosition, maxX, maxY, radius)
|
||||
}
|
||||
|
||||
let direction = new Vector2(0, 5)
|
||||
let multiplier = 1
|
||||
const rotationSlices = 16
|
||||
const origin = new Vector2()
|
||||
|
||||
for (let limit = 1; limit <= 10000; limit++) {
|
||||
const rads = (limit % rotationSlices) * 2 * Math.PI / rotationSlices
|
||||
const offset = direction.clone().rotateAround(origin, rads).multiplyScalar(multiplier)
|
||||
const position = SATX.clamp(futurePosition.clone().add(offset), maxX, maxY, radius)
|
||||
if (!this.willCollide(position)) {
|
||||
return position
|
||||
}
|
||||
|
||||
if (limit % rotationSlices == 0) {
|
||||
multiplier++
|
||||
}
|
||||
}
|
||||
|
||||
console.error({ error: 'position_unfixable', id: this.id, futurePosition })
|
||||
}
|
||||
|
||||
isColliding() {
|
||||
const collidables = this.collidables()
|
||||
if (collidables.length < 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
const colliders = collidables.map((it) => it.colliders()).flat()
|
||||
const collider = this.collider()
|
||||
|
||||
return colliders.some((it) => SATX.collideObject(collider, it))
|
||||
}
|
||||
|
||||
isInLineOfSight(destination, position = this.position) {
|
||||
const bbox = Entity.tunnelBbox(position.x, position.y, destination.x, destination.y, this.radius)
|
||||
const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? [])
|
||||
const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => !it.dead && it.collision && !((this.ghosting && it.ghostable) || (this.ghostable && it.ghosting)) && SATX.bboxCheck(bbox, it.bbox))
|
||||
if (bboxCheckedObstacles.length < 1) { return true }
|
||||
|
||||
const colliders = bboxCheckedObstacles.map((it) => it.colliders()).flat()
|
||||
const collider = Entity.tunnelCollider(position.x, position.y, destination.x, destination.y, this.radius)
|
||||
return !colliders.some((it) => SATX.collideObject(collider, it))
|
||||
}
|
||||
|
||||
isInLineOfVision(destination) {
|
||||
const bbox = Entity.tunnelBbox(this.position.x, this.position.y, destination.x, destination.y, 0)
|
||||
const terrains = this.game?.terrains ?? []
|
||||
const bboxCheckedObstacles = terrains.filter((it) => SATX.bboxCheck(bbox, it.bbox))
|
||||
if (bboxCheckedObstacles.length < 1) { return true }
|
||||
|
||||
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)
|
||||
return !colliders.some((it) => SATX.collideObject(collider, it))
|
||||
}
|
||||
|
||||
obstaclesInStraightPath(destination, position = this.position) {
|
||||
const bbox = Entity.tunnelBbox(position.x, position.y, destination.x, destination.y, this.radius)
|
||||
const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? [])
|
||||
const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => !it.dead && it.collision && !((this.ghosting && it.ghostable) || (this.ghostable && it.ghosting)) && SATX.bboxCheck(bbox, it.bbox))
|
||||
if (bboxCheckedObstacles.length < 1) { return [] }
|
||||
|
||||
const collider = Entity.tunnelCollider(position.x, position.y, destination.x, destination.y, this.radius)
|
||||
return bboxCheckedObstacles.filter((obstacle) => obstacle.colliders().some((it) => SATX.collideObject(collider, it)))
|
||||
}
|
||||
|
||||
removeBuff(id) {
|
||||
if (this.dead) { return }
|
||||
|
||||
this.buffs = this.buffs.filter((it) => it.id != id)
|
||||
}
|
||||
|
||||
respawn() {
|
||||
this.setPosition(this.#spawnPosition)
|
||||
this.health = this.maxHealth
|
||||
this.dead = false
|
||||
}
|
||||
|
||||
revive(startingHealth = null) {
|
||||
this.dead = false
|
||||
const health = (startingHealth ?? this.maxHealth)
|
||||
this.health = Math.max(0, Math.min(health, this.maxHealth))
|
||||
|
||||
this.#calculateCollider()
|
||||
this.#calculateVision()
|
||||
}
|
||||
|
||||
setPosition(vector) {
|
||||
this.position.copy(vector)
|
||||
this.#calculateCollider()
|
||||
}
|
||||
|
||||
teleport(cursor) {
|
||||
this.setPosition(this.fixFuturePosition(cursor))
|
||||
}
|
||||
|
||||
unadjustedWaypoints() {
|
||||
const numberOfWaypoints = 8
|
||||
const margin = 1
|
||||
const enclosingRegularPolygonRadius = SATX.enclosingRegularPolygonRadius(numberOfWaypoints)
|
||||
const radius = this.radius * enclosingRegularPolygonRadius + margin
|
||||
const baseWaypoint = new Vector2(radius, 0)
|
||||
const waypoints = []
|
||||
|
||||
const origin = new Vector2
|
||||
const unitOfRotation = (Math.PI * 2 / numberOfWaypoints)
|
||||
for (let i = 0; i < numberOfWaypoints; i++) {
|
||||
waypoints.push(baseWaypoint.clone().rotateAround(origin, unitOfRotation * i))
|
||||
}
|
||||
|
||||
return waypoints.map((w) => [
|
||||
w.clone().add(this.position),
|
||||
w.clone().normalize().multiplyScalar(enclosingRegularPolygonRadius),
|
||||
])
|
||||
}
|
||||
|
||||
update() {
|
||||
this.#calculateVision()
|
||||
this.#checkHealth()
|
||||
if (!this.dead) {
|
||||
this.#cast()
|
||||
this.#move()
|
||||
this.#tickBuffs()
|
||||
this.fixPosition()
|
||||
}
|
||||
|
||||
if (this.#logic != null) {
|
||||
this.#logic()
|
||||
}
|
||||
}
|
||||
|
||||
visibleEntities() {
|
||||
return this.game?.visibleEntities(this.team)
|
||||
}
|
||||
|
||||
willCollide(futurePosition) {
|
||||
const collidables = this.futureCollidables(futurePosition)
|
||||
if (collidables.length < 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
const colliders = collidables.map((it) => it.colliders()).flat()
|
||||
const collider = Entity.collider(futurePosition.x, futurePosition.y, this.radius)
|
||||
|
||||
return colliders.some((it) => SATX.collideObject(collider, it))
|
||||
}
|
||||
|
||||
#calculateBbox() {
|
||||
this.bbox[0] = this.position.y + this.radius
|
||||
this.bbox[1] = this.position.x + this.radius
|
||||
this.bbox[2] = this.position.y - this.radius
|
||||
this.bbox[3] = this.position.x - this.radius
|
||||
}
|
||||
|
||||
#calculateCollider() {
|
||||
this.#calculateBbox()
|
||||
this.#colliders = [Entity.collider(this.position.x, this.position.y, this.radius)]
|
||||
}
|
||||
|
||||
#calculateVision() {
|
||||
if (this.dead) {
|
||||
this.#entitiesInVision = [this.id]
|
||||
this.#projectilesInVision = []
|
||||
return
|
||||
}
|
||||
|
||||
const entities = this.game?.entities ?? []
|
||||
const projectiles = this.game?.projectiles ?? []
|
||||
|
||||
const entitiesInVisionRange = entities.filter((it) => it.id != this.id && it.distanceTo(this.position) <= this.visionRange + it.radius)
|
||||
const entitiesInLineOfSight = entitiesInVisionRange.filter((it) => this.isInLineOfVision(it.position))
|
||||
|
||||
const projectilesInVisionRange = projectiles.filter((it) => this.position.distanceTo(it.position) <= this.visionRange + it.radius)
|
||||
const projectilesInLineOfSight = projectilesInVisionRange.filter((it) => it.visibleThroughTerrain || this.isInLineOfVision(it.position))
|
||||
|
||||
this.#entitiesInVision = entitiesInLineOfSight.concat([this]).map((it) => it.id)
|
||||
this.#projectilesInVision = projectilesInLineOfSight.map((it) => it.id)
|
||||
}
|
||||
|
||||
#cast() {
|
||||
if (this.casting == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ability = this.game?.abilities.find((it) => it.id == this.casting.ability)
|
||||
if (ability == null) {
|
||||
return
|
||||
}
|
||||
|
||||
const castTime = this.game?.secToTick(ability.castTime) ?? 0
|
||||
const castStart = this.casting.timestamp
|
||||
const timestamp = this.game?.currentTick ?? 0
|
||||
if (castStart + castTime > timestamp) {
|
||||
return false
|
||||
}
|
||||
|
||||
ability.effect(this, this.casting.cursor)
|
||||
|
||||
if (this.casting.ability == ability.id) {
|
||||
this.casting = null
|
||||
}
|
||||
|
||||
this.#castingVision()
|
||||
return true
|
||||
}
|
||||
|
||||
#castingVision() {
|
||||
const enemyTeam = this.team == Team.blue ? Team.red : (this.team == Team.red ? Team.blue : null)
|
||||
|
||||
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
|
||||
if (duration <= 0) { return }
|
||||
|
||||
const currentTick = this.game?.currentTick ?? 0
|
||||
const despawnAfter = currentTick + duration
|
||||
|
||||
const castingVisionLogic = function castingVisionLogic(projectile) {
|
||||
const currentTick = projectile.game?.currentTick ?? 0
|
||||
if (currentTick > despawnAfter) {
|
||||
projectile.despawn()
|
||||
}
|
||||
}
|
||||
|
||||
const projectile = new Projectile({
|
||||
logic: castingVisionLogic,
|
||||
owner: this.id,
|
||||
position: this.position.clone(),
|
||||
visionRange: radius,
|
||||
team: enemyTeam,
|
||||
})
|
||||
|
||||
this.game?.spawnProjectile(projectile)
|
||||
}
|
||||
}
|
||||
|
||||
#checkHealth() {
|
||||
if (!this.dead && this.health <= 0) {
|
||||
this.dead = true
|
||||
this.buffs = []
|
||||
}
|
||||
else if (this.dead && this.health > 0) {
|
||||
this.health = 0
|
||||
}
|
||||
}
|
||||
|
||||
#move(distanceTraveled = 0) {
|
||||
if (this.casting != null) { return false }
|
||||
const currentTick = this.game?.currentTick ?? 0
|
||||
|
||||
if (this.#attacking) {
|
||||
const cursor = this.#dest ?? this.position
|
||||
const basicAttack = this.ability('a')
|
||||
if (basicAttack != null) {
|
||||
const target = this.closestTargetTo(cursor, 500)
|
||||
if (target != null && this.distanceTo(target.position) < basicAttack.range + this.radius + target.radius) {
|
||||
const cooldown = this.game?.secToTick(basicAttack.cooldown) ?? 0
|
||||
const lastCast = this.cooldowns[basicAttack.id]
|
||||
if (lastCast != null && lastCast + cooldown > currentTick) { return false }
|
||||
|
||||
this.castAction('a', target.id, false)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.#moving || this.#dest == null) { return false }
|
||||
|
||||
const fixedDest = this.fixFuturePosition(this.#dest)
|
||||
const pathfinding = this.#noPathfindingUntil <= currentTick
|
||||
const obstacles = new Map()
|
||||
let pathGotObstructed = false
|
||||
|
||||
if (pathfinding && this.#path.length > 0) {
|
||||
const sectionDest = this.#path.at(0)
|
||||
const sectionObstacles = this.obstaclesInStraightPath(sectionDest)
|
||||
if (sectionObstacles.length > 0) {
|
||||
pathGotObstructed = true
|
||||
for (const obstacle of sectionObstacles) {
|
||||
if (!obstacles.has(obstacle.id)) {
|
||||
obstacles.set(obstacle.id, obstacle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#path.length < 1 || !this.#path.at(-1).equals(fixedDest)) {
|
||||
const lineOfSight = this.isInLineOfSight(fixedDest)
|
||||
if (lineOfSight) {
|
||||
this.#path = [fixedDest]
|
||||
}
|
||||
}
|
||||
|
||||
if (pathfinding && (pathGotObstructed || this.#path.length < 1 || (this.#path.at(-1)?.distanceTo(fixedDest) ?? 0) > 0.01)) {
|
||||
const start = SATX.vectorToFloat32Array(this.position)
|
||||
const goal = SATX.vectorToFloat32Array(fixedDest)
|
||||
const obstacleWaypoints = new Map()
|
||||
const obstacleColliders = new Map()
|
||||
const obstacleBboxes = new Map()
|
||||
|
||||
const initialObstaclesMargin = this.radius + 20
|
||||
const initialObstacles = this.customBboxCollidables(new Float32Array([
|
||||
this.position.y + initialObstaclesMargin,
|
||||
this.position.x + initialObstaclesMargin,
|
||||
this.position.y - initialObstaclesMargin,
|
||||
this.position.x - initialObstaclesMargin,
|
||||
]))
|
||||
|
||||
for (const obstacle of initialObstacles) {
|
||||
if (!obstacles.has(obstacle.id)) {
|
||||
obstacles.set(obstacle.id, obstacle)
|
||||
}
|
||||
}
|
||||
|
||||
for (let failsafe = 0; failsafe <= (this.pathfindingObstacleLimit ?? 10); failsafe++) {
|
||||
const obstaclesArray = Array.from(obstacles.values())
|
||||
|
||||
for (const obstacle of obstaclesArray) {
|
||||
if (!obstacleWaypoints.has(obstacle.id)) {
|
||||
const waypoint = obstacle.unadjustedWaypoints().map(([w, d]) => SATX.vectorToFloat32Array(this.adjustWaypoint(w, d)))
|
||||
const bbox = obstacle.bbox
|
||||
const colliders = obstacle.colliders()
|
||||
obstacleWaypoints.set(obstacle.id, waypoint)
|
||||
obstacleColliders.set(obstacle.id, colliders)
|
||||
obstacleBboxes.set(obstacle.id, bbox)
|
||||
}
|
||||
}
|
||||
|
||||
const waypoints = [
|
||||
start,
|
||||
goal,
|
||||
...Array.from(obstacleWaypoints.values()).flat()
|
||||
]
|
||||
|
||||
const bboxesSize = obstacleBboxes.size * 5
|
||||
const bboxes = new Float32Array(bboxesSize)
|
||||
let i = 0
|
||||
for (const obstacle of obstacleBboxes.values()) {
|
||||
bboxes[i] = obstacle[0]
|
||||
bboxes[i + 1] = obstacle[1]
|
||||
bboxes[i + 2] = obstacle[2]
|
||||
bboxes[i + 3] = obstacle[3]
|
||||
bboxes[i + 4] = Math.floor(i / 5)
|
||||
i += 5
|
||||
}
|
||||
|
||||
const colliders = Array.from(obstacleColliders.values())
|
||||
const graph = Pathfind.buildGraph(waypoints, bboxes, colliders, this.radius)
|
||||
const path = Pathfind.shortestPath(graph, start, goal).map((waypoint) => new Vector2(waypoint[0], waypoint[1]))
|
||||
|
||||
if (path.length == 0) {
|
||||
// WARNING: This unsets the destination because if an unreachable spot is clicked,
|
||||
// pathfinding cycles all obstacles forever. A possible alternative could
|
||||
// be setting a pathfinding timeout, but then moveAction must reset that!
|
||||
this.#dest = null
|
||||
break
|
||||
}
|
||||
|
||||
let obstacleInPath = false
|
||||
let lastSection = this.position
|
||||
for (const section of path) {
|
||||
const sectionObstacles = this.obstaclesInStraightPath(section, lastSection)
|
||||
if (sectionObstacles.length > 0) {
|
||||
obstacleInPath = true
|
||||
for (const obstacle of sectionObstacles) {
|
||||
if (!obstacles.has(obstacle.id)) {
|
||||
obstacles.set(obstacle.id, obstacle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastSection = section
|
||||
}
|
||||
|
||||
this.#path = path
|
||||
if (!obstacleInPath) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pathfinding && this.pathfindingCooldown > 0) {
|
||||
this.#noPathfindingUntil = currentTick + (this.game?.secToTick(this.pathfindingCooldown) ?? 0)
|
||||
}
|
||||
|
||||
if (this.#path.length > 0) {
|
||||
const speed = (this.speed / (this.game?.tickRate ?? 1)) - 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 rotation = direction.angle()
|
||||
|
||||
this.rotation = rotation
|
||||
|
||||
if (!this.willCollide(position)) {
|
||||
this.setPosition(position)
|
||||
}
|
||||
|
||||
if (this.position.equals(destination)) {
|
||||
this.#path = this.#path.slice(1)
|
||||
if (this.#path.length > 0) {
|
||||
this.#move(distance)
|
||||
}
|
||||
else {
|
||||
this.#dest = null
|
||||
this.#moving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#tickBuff(index) {
|
||||
if (this.buffs[index] == null) { return }
|
||||
const buff = this.getBuff(this.buffs[index].id)
|
||||
const duration = this.game?.secToTick(buff.duration) ?? 0
|
||||
const currentTick = this.game?.currentTick ?? 0
|
||||
|
||||
if (buff.timestamp + duration < currentTick) {
|
||||
this.removeBuff(buff.id)
|
||||
}
|
||||
}
|
||||
|
||||
#tickBuffs() {
|
||||
this.buffs.forEach((_v, i) => this.#tickBuff(i))
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { Vector2 } from 'three'
|
||||
import Ability from './ability.js'
|
||||
import Buff from './buff.js'
|
||||
import Entity from './entity.js'
|
||||
import Projectile from './projectile.js'
|
||||
import Terrain from './terrain.js'
|
||||
|
||||
export default class Game {
|
||||
id = crypto.randomUUID()
|
||||
|
||||
abilities = Object.values({...Ability})
|
||||
buffs = Object.values({...Buff})
|
||||
currentTick = 0
|
||||
entities = []
|
||||
height = 0
|
||||
projectiles = []
|
||||
terrains = []
|
||||
tickRate = 30
|
||||
width = 0
|
||||
|
||||
#gameLoopIntervalId = null
|
||||
#logic = null
|
||||
#nextTickAt = 0
|
||||
#startTimestamp = 0
|
||||
#subscriptions = new Map()
|
||||
#tickBudget = 1000 / this.tickRate
|
||||
|
||||
get logic() { return this.#logic }
|
||||
get tickBudget() { return this.#tickBudget }
|
||||
get subscriptions() { return this.#subscriptions }
|
||||
|
||||
set logic(value) { this.#logic = value }
|
||||
|
||||
action(id, options) {
|
||||
const entity = this.entities.find((it) => it.id == id)
|
||||
if (entity == null) {
|
||||
console.info({ info: 'action_invalid_id', id, options })
|
||||
return
|
||||
}
|
||||
|
||||
if (options.action == 'attack') { entity.attackAction(new Vector2(options.x, options.y)) }
|
||||
if (options.action == 'cast') { entity.castAction(options.slot, new Vector2(options.x, options.y)) }
|
||||
if (options.action == 'halt') { entity.haltAction() }
|
||||
if (options.action == 'stop') { entity.stopAction() }
|
||||
if (options.action == 'move') { entity.moveAction(new Vector2(options.x, options.y)) }
|
||||
}
|
||||
|
||||
addTerrain(terrain) {
|
||||
this.terrains.push(terrain)
|
||||
}
|
||||
|
||||
despawn(object) {
|
||||
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: 'despawn_unknown_object', object }) }
|
||||
}
|
||||
|
||||
despawnEntity(entity) {
|
||||
this.entities = this.entities.filter((e) => e.id != entity.id)
|
||||
entity.game = null
|
||||
}
|
||||
|
||||
despawnProjectile(projectile) {
|
||||
this.projectiles = this.projectiles.filter((p) => p.id != projectile.id)
|
||||
projectile.game = null
|
||||
}
|
||||
|
||||
joinReport() {
|
||||
return {
|
||||
id: this.id,
|
||||
height: this.height,
|
||||
width: this.width,
|
||||
currentTick: this.currentTick,
|
||||
abilities: this.abilities,
|
||||
buffs: this.buffs,
|
||||
terrains: this.terrains,
|
||||
tickRate: this.tickRate,
|
||||
}
|
||||
}
|
||||
|
||||
removeTerrain(terrain) {
|
||||
this.terrains = this.terrains.filter((t) => t.id != terrain.id)
|
||||
}
|
||||
|
||||
secToTick(sec) {
|
||||
return Math.floor(this.tickRate * sec)
|
||||
}
|
||||
|
||||
spawn(object) {
|
||||
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: 'spawn_unknown_object', object }) }
|
||||
}
|
||||
|
||||
spawnEntity(entity) {
|
||||
this.entities.push(entity)
|
||||
entity.game = this
|
||||
}
|
||||
|
||||
spawnProjectile(projectile) {
|
||||
this.projectiles.push(projectile)
|
||||
projectile.game = this
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.#gameLoopIntervalId != null) { return }
|
||||
|
||||
this.#startTimestamp = performance.now() + (this.currentTick * this.tickBudget)
|
||||
console.info({ event: 'game_start', id: this.id, tickRate: this.tickRate, currentTick: this.currentTick })
|
||||
this.#gameLoopIntervalId = setInterval(this.#gameLoopCall.bind(this), this.tickBudget / 5)
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.#gameLoopIntervalId == null) { return }
|
||||
|
||||
clearInterval(this.#gameLoopIntervalId)
|
||||
this.#gameLoopIntervalId = null
|
||||
console.info({ event: 'game_stop', id: this.id, currentTick: this.currentTick })
|
||||
}
|
||||
|
||||
subscription(websocket, id) {
|
||||
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) {
|
||||
websocket.close()
|
||||
return
|
||||
}
|
||||
|
||||
const team = entity.team
|
||||
const state = game.visionByTeam(team)
|
||||
state.currentTick = game.currentTick
|
||||
|
||||
websocket.send(JSON.stringify(state))
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
for (const subscription of this.#subscriptions.values()) {
|
||||
subscription()
|
||||
}
|
||||
|
||||
const callUpdate = function callUpdate(object) { object.update() }
|
||||
this.entities.forEach(callUpdate)
|
||||
this.projectiles.forEach(callUpdate)
|
||||
if (this.#logic != null) {
|
||||
this.#logic()
|
||||
}
|
||||
|
||||
this.currentTick++
|
||||
}
|
||||
|
||||
visibleEntities(team) {
|
||||
const visionSources = this.visionSources(team)
|
||||
return Array.from(new Set(visionSources.map((it) => it.entitiesInVision).flat()))
|
||||
}
|
||||
|
||||
visibleProjectiles(team) {
|
||||
const visionSources = this.visionSources(team)
|
||||
return Array.from(new Set(visionSources.map((it) => it.projectilesInVision).flat()))
|
||||
}
|
||||
|
||||
visionSources(team) {
|
||||
const entityVisionSources = this.entities.filter((it) => it.team == team)
|
||||
const projectileVisionSources = this.projectiles.filter((it) => it.visionRange > 0 && (it.team == null || it.team == team))
|
||||
return entityVisionSources.concat(projectileVisionSources)
|
||||
}
|
||||
|
||||
visionByTeam(team) {
|
||||
const visionSources = this.visionSources(team)
|
||||
const visibleEntities = new Set(visionSources.map((it) => it.entitiesInVision).flat())
|
||||
const visibleProjectiles = new Set(visionSources.map((it) => it.projectilesInVision).flat())
|
||||
return {
|
||||
entities: this.entities.filter((it) => visibleEntities.has(it.id)),
|
||||
projectiles: this.projectiles.filter((it) => visibleProjectiles.has(it.id)),
|
||||
}
|
||||
}
|
||||
|
||||
#gameLoop() {
|
||||
if (this.#nextTickAt != null) {
|
||||
const tickBudget = this.#tickBudget
|
||||
const nextTickAt = this.#nextTickAt
|
||||
this.#nextTickAt = null
|
||||
|
||||
let start = 0
|
||||
while (start < nextTickAt) { start = performance.now() }
|
||||
|
||||
const before = performance.now()
|
||||
this.update()
|
||||
const after = performance.now()
|
||||
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 (tickTaken > tickBudget) {
|
||||
console.warn({ warn: 'overload', tickTaken, tickBudget, absoluteBehind })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#gameLoopCall() {
|
||||
this.#gameLoop()
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import * as LEVEL from './level.js'
|
||||
import { WebSocketExpress } from 'websocket-express'
|
||||
import express from 'express'
|
||||
import Game from './game.js'
|
||||
import os from 'node:os'
|
||||
|
||||
try {
|
||||
// WARNING: process.nice can undermine dependencies?
|
||||
os.setPriority(process.pid, os.constants.priority.PRIORITY_HIGHEST)
|
||||
}
|
||||
catch (error) {
|
||||
console.warn({ warn: 'process_priority_unadjustable' })
|
||||
}
|
||||
|
||||
const app = new WebSocketExpress()
|
||||
const port = 1280
|
||||
const game = new Game()
|
||||
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
|
||||
app.use('/three/', express.static('node_modules/three'))
|
||||
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) => {
|
||||
const websocket = await res.accept()
|
||||
|
||||
websocket.on('message', (rawData) => {
|
||||
const message = JSON.parse(rawData)
|
||||
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.info({ event: 'disconnected', id })
|
||||
game.subscriptions.delete(connectionId)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
game.action(message.id, message)
|
||||
})
|
||||
})
|
||||
|
||||
app.listen(port, () => {
|
||||
console.info({ event: 'startup', visit: `http://localhost:${port}/menu/` })
|
||||
|
||||
LEVEL.Chase.scenario(game)
|
||||
game.start()
|
||||
})
|
||||
@@ -1,213 +0,0 @@
|
||||
import Entity from './entity.js'
|
||||
import PriorityQueue from './priority-queue.js'
|
||||
import SATX from './satx.js'
|
||||
|
||||
export default class Pathfind {
|
||||
static precision = 0.01
|
||||
static multiplier = 1000000 // (1 / this.precision) * 10^expected_digit_count / 10
|
||||
|
||||
static key2(a, b) {
|
||||
return `${a},${b}`
|
||||
}
|
||||
|
||||
// Fowler-Noll-Vo hash prime and offset basis for small keyspaces
|
||||
static floatKey4(a, b, c, d) {
|
||||
const prime = 16777619
|
||||
let result = 2166136261
|
||||
result ^= Math.floor(a * Pathfind.multiplier)
|
||||
result *= prime
|
||||
result ^= Math.floor(b * Pathfind.multiplier)
|
||||
result *= prime
|
||||
result ^= Math.floor(c * Pathfind.multiplier)
|
||||
result *= prime
|
||||
result ^= Math.floor(d * Pathfind.multiplier)
|
||||
result *= prime
|
||||
return result
|
||||
}
|
||||
|
||||
static uniqueWaypoints(waypoints) {
|
||||
const included = new Set()
|
||||
const uniqueWaypoints = []
|
||||
for (const waypoint of waypoints) {
|
||||
const key = Pathfind.key2(waypoint[0], waypoint[1])
|
||||
if (!included.has(key)) {
|
||||
included.add(key)
|
||||
uniqueWaypoints.push(waypoint)
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueWaypoints
|
||||
}
|
||||
|
||||
static shortestPath(graph, start, goal) {
|
||||
const queue = new PriorityQueue((a, b) => a[1] < b[1])
|
||||
const visited = new Map()
|
||||
|
||||
queue.push([[start], 0])
|
||||
|
||||
while (!queue.isEmpty()) {
|
||||
const [path, cost] = queue.pop()
|
||||
const waypoint = path.at(-1)
|
||||
|
||||
if (Math.abs(waypoint[0] - goal[0]) < Pathfind.precision && Math.abs(waypoint[1] - goal[1]) < Pathfind.precision) {
|
||||
path.shift()
|
||||
return path
|
||||
}
|
||||
|
||||
const waypointKey = Pathfind.key2(waypoint[0], waypoint[1])
|
||||
if (!visited.has(waypointKey) || visited.get(waypointKey) > cost) {
|
||||
visited.set(waypointKey, cost)
|
||||
|
||||
for (let i = 0; i < graph.length; i += 5) {
|
||||
if (Math.abs(waypoint[0] - graph[i]) > Pathfind.precision || Math.abs(waypoint[1] - graph[i + 1]) > Pathfind.precision) {
|
||||
continue // waypoint and graph.from aren't the same (so graph.to isn't a neighbor)
|
||||
}
|
||||
|
||||
const nextKey = Pathfind.key2(graph[i + 2], graph[i + 3])
|
||||
if (!visited.has(nextKey) || visited.get(nextKey) > cost + graph[i + 4]) {
|
||||
const next = new Float32Array(2)
|
||||
next[0] = graph[i + 2]
|
||||
next[1] = graph[i + 3]
|
||||
queue.push([[...path, next], cost + graph[i + 4]])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
static buildGraph(waypoints, bboxes, obstacles, radius) {
|
||||
const filteredWaypoints = []
|
||||
const checked = new Set()
|
||||
|
||||
if (radius > 0) {
|
||||
for (const waypoint of waypoints) {
|
||||
const bbox = Entity.bbox(waypoint[0], waypoint[1], radius)
|
||||
const bboxCheckedObstacles = []
|
||||
for (let i = 0; i < bboxes.length; i += 5) {
|
||||
if (bbox[0] <= bboxes[i + 2]) { continue }
|
||||
if (bbox[1] <= bboxes[i + 3]) { continue }
|
||||
if (bbox[2] >= bboxes[i]) { continue }
|
||||
if (bbox[3] >= bboxes[i + 1]) { continue }
|
||||
|
||||
bboxCheckedObstacles.push(obstacles[bboxes[i + 4]])
|
||||
}
|
||||
|
||||
if (bboxCheckedObstacles.length > 0) {
|
||||
const collider = Entity.collider(waypoint[0], waypoint[1], radius)
|
||||
const colliding = bboxCheckedObstacles.flat().some((it) => SATX.collideObject(collider, it))
|
||||
if (colliding) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
filteredWaypoints.push(waypoint)
|
||||
}
|
||||
}
|
||||
|
||||
const mergedWaypoints = new Float32Array(filteredWaypoints.length * 2)
|
||||
let mergedWaypointsIndex = 0
|
||||
for (const waypoint of filteredWaypoints) {
|
||||
mergedWaypoints[mergedWaypointsIndex] = waypoint[0]
|
||||
mergedWaypoints[mergedWaypointsIndex + 1] = waypoint[1]
|
||||
mergedWaypointsIndex += 2
|
||||
}
|
||||
|
||||
const nodes = []
|
||||
for (let i = 0; i < mergedWaypoints.length; i += 2) {
|
||||
for (let j = 0; j < mergedWaypoints.length; j += 2) {
|
||||
if (i == j) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (Math.abs(mergedWaypoints[i] - mergedWaypoints[j]) < Pathfind.precision && Math.abs(mergedWaypoints[i + 1] - mergedWaypoints[j + 1]) < Pathfind.precision) {
|
||||
continue
|
||||
}
|
||||
|
||||
const key = Pathfind.floatKey4(mergedWaypoints[i], mergedWaypoints[i + 1], mergedWaypoints[j], mergedWaypoints[j + 1])
|
||||
if (checked.has(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
checked.add(key)
|
||||
checked.add(Pathfind.floatKey4(mergedWaypoints[j], mergedWaypoints[j + 1], mergedWaypoints[i], mergedWaypoints[i + 1]))
|
||||
|
||||
const bbox = Entity.tunnelBbox(mergedWaypoints[i], mergedWaypoints[i + 1], mergedWaypoints[j], mergedWaypoints[j + 1], radius)
|
||||
|
||||
const bboxCheckedObstacles = []
|
||||
for (let i = 0; i < bboxes.length; i += 5) {
|
||||
if (bbox[0] <= bboxes[i + 2]) { continue }
|
||||
if (bbox[1] <= bboxes[i + 3]) { continue }
|
||||
if (bbox[2] >= bboxes[i]) { continue }
|
||||
if (bbox[3] >= bboxes[i + 1]) { continue }
|
||||
|
||||
bboxCheckedObstacles.push(obstacles[bboxes[i + 4]])
|
||||
}
|
||||
|
||||
if (bboxCheckedObstacles.length > 0) {
|
||||
const tunnel = Entity.tunnelCollider(mergedWaypoints[i], mergedWaypoints[i + 1], mergedWaypoints[j], mergedWaypoints[j + 1], radius)
|
||||
const colliding = bboxCheckedObstacles.some((it) => it.some((c) => SATX.collideObject(tunnel, c)))
|
||||
if (colliding) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const node = new Float32Array(5)
|
||||
node[0] = mergedWaypoints[i]
|
||||
node[1] = mergedWaypoints[i + 1]
|
||||
node[2] = mergedWaypoints[j]
|
||||
node[3] = mergedWaypoints[j + 1]
|
||||
node[4] = Math.hypot(mergedWaypoints[j] - mergedWaypoints[i], mergedWaypoints[j + 1] - mergedWaypoints[i + 1])
|
||||
nodes.push(node)
|
||||
|
||||
const reverseNode = new Float32Array(5)
|
||||
reverseNode[0] = mergedWaypoints[j]
|
||||
reverseNode[1] = mergedWaypoints[j + 1]
|
||||
reverseNode[2] = mergedWaypoints[i]
|
||||
reverseNode[3] = mergedWaypoints[i + 1]
|
||||
reverseNode[4] = node[4] // distance is the same, copying is less expensive
|
||||
nodes.push(reverseNode)
|
||||
}
|
||||
}
|
||||
|
||||
const graph = new Float32Array(nodes.length * 5)
|
||||
let graphIndex = 0
|
||||
for (const node of nodes) {
|
||||
graph[graphIndex] = node[0]
|
||||
graph[graphIndex + 1] = node[1]
|
||||
graph[graphIndex + 2] = node[2]
|
||||
graph[graphIndex + 3] = node[3]
|
||||
graph[graphIndex + 4] = node[4]
|
||||
graphIndex += 5
|
||||
}
|
||||
|
||||
return graph
|
||||
}
|
||||
|
||||
static formatFloat32Array(array, columns = 2, text = false) {
|
||||
const formatted = []
|
||||
let columnWidth = 0
|
||||
for (let i = 0; i < array.length; i += columns) {
|
||||
const row = []
|
||||
for (let j = i; j < i + columns; j++) {
|
||||
if (text) {
|
||||
row.push(`${array[j]}`)
|
||||
if (`${array[j]}`.length > columnWidth) {
|
||||
columnWidth = `${array[j]}`.length
|
||||
}
|
||||
}
|
||||
else {
|
||||
row.push(array[j])
|
||||
}
|
||||
}
|
||||
formatted.push(row)
|
||||
}
|
||||
|
||||
if (text) {
|
||||
return formatted.map((row) => row.map((v) => v.padEnd(columnWidth, ' ')).join(' | ')).join('\n')
|
||||
}
|
||||
|
||||
return formatted
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
const top = 0;
|
||||
const parent = i => ((i + 1) >>> 1) - 1;
|
||||
const left = i => (i << 1) + 1;
|
||||
const right = i => (i + 1) << 1;
|
||||
|
||||
export default class PriorityQueue {
|
||||
#heap
|
||||
#comparator
|
||||
|
||||
constructor(comparator = (a, b) => a > b) {
|
||||
this.#heap = []
|
||||
this.#comparator = comparator
|
||||
}
|
||||
|
||||
get length() { return this.#heap.length }
|
||||
|
||||
isEmpty() {
|
||||
return this.length < 1
|
||||
}
|
||||
|
||||
peek() {
|
||||
return this.#heap[top]
|
||||
}
|
||||
|
||||
push(...values) {
|
||||
values.forEach(value => {
|
||||
this.#heap.push(value)
|
||||
this.#siftUp();
|
||||
});
|
||||
return this.length;
|
||||
}
|
||||
|
||||
pop() {
|
||||
const poppedValue = this.peek()
|
||||
const bottom = this.length - 1
|
||||
if (bottom > top) {
|
||||
this.#swap(top, bottom)
|
||||
}
|
||||
this.#heap.pop()
|
||||
this.#siftDown()
|
||||
return poppedValue
|
||||
}
|
||||
|
||||
replace(value) {
|
||||
const replacedValue = this.peek()
|
||||
this.#heap[top] = value
|
||||
this.#siftDown()
|
||||
return replacedValue
|
||||
}
|
||||
|
||||
#greater(i, j) {
|
||||
return this.#comparator(this.#heap[i], this.#heap[j])
|
||||
}
|
||||
|
||||
#swap(i, j) {
|
||||
[this.#heap[i], this.#heap[j]] = [this.#heap[j], this.#heap[i]]
|
||||
}
|
||||
|
||||
#siftUp() {
|
||||
let node = this.length - 1
|
||||
while (node > top && this.#greater(node, parent(node))) {
|
||||
this.#swap(node, parent(node))
|
||||
node = parent(node)
|
||||
}
|
||||
}
|
||||
|
||||
#siftDown() {
|
||||
let node = top;
|
||||
while (
|
||||
(left(node) < this.length && this.#greater(left(node), node)) ||
|
||||
(right(node) < this.length && this.#greater(right(node), node))
|
||||
) {
|
||||
let maxChild = (right(node) < this.length && this.#greater(right(node), left(node))) ? right(node) : left(node)
|
||||
this.#swap(node, maxChild)
|
||||
node = maxChild
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { Vector2 } from 'three'
|
||||
import Entity from './entity.js'
|
||||
import SAT from 'sat'
|
||||
import SATX from './satx.js'
|
||||
|
||||
export default class Projectile {
|
||||
id = `projectile-${Projectile.nextId()}`
|
||||
static nextId() { return this.#nextUniqueId++ }
|
||||
static #nextUniqueId = 0
|
||||
|
||||
height = 50
|
||||
owner = null
|
||||
position = new Vector2()
|
||||
radius = 0
|
||||
speed = 1000
|
||||
team = null
|
||||
visibleThroughTerrain = true
|
||||
visionRange = 0
|
||||
visualRadius = null
|
||||
|
||||
#after = null
|
||||
#bbox = new Float32Array(4)
|
||||
#dest = null
|
||||
#entitiesInVision = []
|
||||
#game = null
|
||||
#homingTarget = null
|
||||
#logic = null
|
||||
#onCollide = null
|
||||
#projectilesInVision = []
|
||||
|
||||
get after() { return this.#after }
|
||||
get bbox() { return this.#bbox }
|
||||
get entitiesInVision() { return this.#entitiesInVision }
|
||||
get game() { return this.#game }
|
||||
get homingTarget() { return this.#homingTarget }
|
||||
get logic() { return this.#logic }
|
||||
get onCollide() { return this.#onCollide }
|
||||
get projectilesInVision() { return this.#projectilesInVision }
|
||||
|
||||
set after(value) { this.#after = value }
|
||||
set bbox(value) { this.#bbox = value }
|
||||
set destination(value) { this.#dest = value }
|
||||
set game(value) { this.#game = value }
|
||||
set homingTarget(value) { this.#homingTarget = value }
|
||||
set logic(value) { this.#logic = value }
|
||||
set onCollide(value) { this.#onCollide = value }
|
||||
|
||||
get destination() {
|
||||
return this.#dest ?? this.#homingTarget?.position
|
||||
}
|
||||
|
||||
constructor(options = {}) {
|
||||
Object.entries(options).forEach(([key, value]) => this[key] = value)
|
||||
if (this.visualRadius == null) {
|
||||
this.visualRadius = this.radius
|
||||
}
|
||||
}
|
||||
|
||||
collider() {
|
||||
return new SAT.Circle(new SAT.Vector(this.position.x, this.position.y), this.radius)
|
||||
}
|
||||
|
||||
despawn() {
|
||||
this.game?.despawn(this)
|
||||
}
|
||||
|
||||
isInLineOfVision(destination) {
|
||||
const bbox = Entity.tunnelBbox(this.position.x, this.position.y, destination.x, destination.y, 0)
|
||||
const terrains = this.game?.terrains ?? []
|
||||
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 colliders = unpassableTerrain.map((it) => it.colliders()).flat()
|
||||
const collider = Entity.tunnelCollider(this.position.x, this.position.y, destination.x, destination.y, 0)
|
||||
return !colliders.some((it) => SATX.collideObject(collider, it))
|
||||
}
|
||||
|
||||
setPosition(vector) {
|
||||
this.position.copy(vector)
|
||||
this.#calculateBbox()
|
||||
}
|
||||
|
||||
update() {
|
||||
this.#calculateVision()
|
||||
this.#move()
|
||||
this.#checkStationaryCollisions()
|
||||
this.#checkIfArrived()
|
||||
if (this.#logic != null) {
|
||||
this.#logic(this)
|
||||
}
|
||||
}
|
||||
|
||||
#calculateBbox() {
|
||||
this.bbox[0] = this.position.y + this.radius
|
||||
this.bbox[1] = this.position.x + this.radius
|
||||
this.bbox[2] = this.position.y - this.radius
|
||||
this.bbox[3] = this.position.x - this.radius
|
||||
}
|
||||
|
||||
#calculateVision() {
|
||||
const entities = this.game?.entities ?? []
|
||||
const projectiles = this.game?.projectiles ?? []
|
||||
|
||||
const entitiesInVisionRange = entities.filter((it) => it.id != this.id && it.distanceTo(this.position) <= this.visionRange + it.radius)
|
||||
const entitiesInLineOfSight = entitiesInVisionRange.filter((it) => this.isInLineOfVision(it.position))
|
||||
|
||||
const projectilesInVisionRange = projectiles.filter((it) => this.position.distanceTo(it.position) <= this.visionRange + it.radius)
|
||||
const projectilesInLineOfSight = projectilesInVisionRange.filter((it) => it.visibleThroughTerrain || this.isInLineOfVision(it.position))
|
||||
|
||||
this.#entitiesInVision = entitiesInLineOfSight.concat([this]).map((it) => it.id)
|
||||
this.#projectilesInVision = projectilesInLineOfSight.map((it) => it.id)
|
||||
}
|
||||
|
||||
#checkIfArrived() {
|
||||
if (this.destination == null) { return }
|
||||
if (!this.position.equals(this.destination)) { return }
|
||||
|
||||
if (this.#after != null) {
|
||||
this.#after(this, this.#homingTarget)
|
||||
}
|
||||
|
||||
if (this.destination == null) { return }
|
||||
if (!this.position.equals(this.destination)) { return }
|
||||
|
||||
this.despawn()
|
||||
}
|
||||
|
||||
#checkStationaryCollisions() {
|
||||
if (this.#onCollide == null) { return }
|
||||
|
||||
const bbox = this.bbox
|
||||
const entitiesAndTerrains = this.game?.entities ?? []
|
||||
const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => !it.dead && SATX.bboxCheck(bbox, it.bbox))
|
||||
if (bboxCheckedObstacles.length > 0) {
|
||||
const collider = this.collider()
|
||||
const colliding = bboxCheckedObstacles.filter((it) => it.colliders().some((c) => SATX.collideObject(collider, c)))
|
||||
colliding.forEach((it) => this.#onCollide(this, it))
|
||||
}
|
||||
}
|
||||
|
||||
#move() {
|
||||
if (this.destination == null) { return }
|
||||
|
||||
const speed = (this.speed / (this.game?.tickRate ?? 1))
|
||||
const prevPos = this.position.clone()
|
||||
if (this.position.distanceTo(this.destination) < speed) {
|
||||
this.setPosition(this.destination)
|
||||
}
|
||||
else {
|
||||
const step = this.destination.clone().sub(this.position).normalize().multiplyScalar(speed)
|
||||
this.position.add(step)
|
||||
}
|
||||
|
||||
if (this.#onCollide != null) {
|
||||
const bbox = Entity.tunnelBbox(prevPos.x, prevPos.y, this.position.x, this.position.y, this.radius)
|
||||
const entitiesAndTerrains = this.game?.entities ?? []
|
||||
const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => !it.dead && SATX.bboxCheck(bbox, it.bbox))
|
||||
if (bboxCheckedObstacles.length > 0) {
|
||||
const collider = Entity.tunnelCollider(prevPos.x, prevPos.y, this.position.x, this.position.y, this.radius)
|
||||
const colliding = bboxCheckedObstacles.filter((it) => it.colliders().some((c) => SATX.collideObject(collider, c)))
|
||||
colliding.sort((a, b) => a.distanceTo(prevPos) > b.distanceTo(prevPos)).forEach((it) => this.#onCollide(this, it))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { Vector2 } from 'three'
|
||||
import SAT from 'sat'
|
||||
|
||||
export default class SATX {
|
||||
static bboxCheck(bbox1, bbox2) {
|
||||
if (bbox1[0] <= bbox2[2]) { return false }
|
||||
if (bbox1[1] <= bbox2[3]) { return false }
|
||||
if (bbox1[2] >= bbox2[0]) { return false }
|
||||
if (bbox1[3] >= bbox2[1]) { return false }
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
static clamp(vectorOrObject, maxX = Infinity, maxY = Infinity, radius = 0) {
|
||||
let modified = null
|
||||
if (vectorOrObject instanceof Vector2) {
|
||||
modified = vectorOrObject.clone()
|
||||
}
|
||||
else if (vectorOrObject instanceof SAT.Vector) {
|
||||
modified = new SAT.Vector(vectorOrObject.x, vectorOrObject.y)
|
||||
}
|
||||
else {
|
||||
modified = { x: vectorOrObject.x, y: vectorOrObject.y }
|
||||
}
|
||||
|
||||
modified.x = Math.min(Math.max(radius, vectorOrObject.x), (maxX ?? Infinity) - radius)
|
||||
modified.y = Math.min(Math.max(radius, vectorOrObject.y), (maxY ?? Infinity) - radius)
|
||||
|
||||
return modified
|
||||
}
|
||||
|
||||
static collideObject(collider1, collider2, result = null) {
|
||||
if (collider1 instanceof SAT.Circle && collider2 instanceof SAT.Circle) {
|
||||
return SAT.testCircleCircle(collider1, collider2, result)
|
||||
}
|
||||
|
||||
if (collider1 instanceof SAT.Circle && collider2 instanceof SAT.Polygon) {
|
||||
return SAT.testCirclePolygon(collider1, collider2, result)
|
||||
}
|
||||
|
||||
if (collider1 instanceof SAT.Polygon && collider2 instanceof SAT.Circle) {
|
||||
return SAT.testPolygonCircle(collider1, collider2, result)
|
||||
}
|
||||
|
||||
if (collider1 instanceof SAT.Polygon && collider2 instanceof SAT.Polygon) {
|
||||
return SAT.testPolygonPolygon(collider1, collider2, result)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
static enclosingRegularPolygonRadius(numberOfVertices) {
|
||||
return 1 / Math.cos(Math.PI / numberOfVertices)
|
||||
}
|
||||
|
||||
static line(fromX, fromY, toX, toY) {
|
||||
return new SAT.Polygon(new SAT.Vector(fromX, fromY), [new SAT.Vector(), new SAT.Vector(toX - fromX, toY - fromY)])
|
||||
}
|
||||
|
||||
static satPolygonToVectors(polygon) {
|
||||
const position = new Vector2(polygon.pos.x, polygon.pos.y)
|
||||
return polygon.points.map((p) => new Vector2(p.x, p.y).add(position))
|
||||
}
|
||||
|
||||
static vectorToFloat32Array(vector) {
|
||||
const array = new Float32Array(2)
|
||||
array[0] = vector.x
|
||||
array[1] = vector.y
|
||||
|
||||
return array
|
||||
}
|
||||
|
||||
static float32ArrayToVector(array) {
|
||||
return new Vector2(array[0], array[1])
|
||||
}
|
||||
|
||||
static float32ArrayWithIndexToVector(array, index) {
|
||||
return new Vector2(array[index], array[index + 1])
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export default class Team {
|
||||
static neutral = 'neutral'
|
||||
static blue = 'blue'
|
||||
static red = 'red'
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { Vector2 } from 'three'
|
||||
import Ability from './ability.js'
|
||||
import Team from './team.js'
|
||||
|
||||
export default class Template {
|
||||
static basilisk(overrides) {
|
||||
return {
|
||||
abilities: { a: Ability.rangedAttack.id },
|
||||
logic: this.#basiliskLogic(),
|
||||
maxHealth: 300,
|
||||
model: 'models/generic-bam-placeholder.gltf',
|
||||
radius: 180,
|
||||
speed: 230,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
static minion(team, options = {}) {
|
||||
return {
|
||||
abilities: { a: options.ranged ? Ability.rangedAttack.id : Ability.meleeAttack.id },
|
||||
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: options.ranged ? 36 : 38,
|
||||
speed: 325,
|
||||
team,
|
||||
visionRange: 1200,
|
||||
}
|
||||
}
|
||||
|
||||
static player(overrides) {
|
||||
return {
|
||||
abilities: {
|
||||
a: Ability.rangedAttack.id,
|
||||
q: Ability.straightShot.id,
|
||||
w: Ability.expose.id,
|
||||
e: Ability.control.id,
|
||||
r: Ability.shieldThrow.id,
|
||||
d: Ability.circleOfResurrection.id,
|
||||
f: Ability.blink.id,
|
||||
},
|
||||
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,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
static #basiliskLogic() {
|
||||
let diedOnTick = null
|
||||
let targetInRangeSince = null
|
||||
|
||||
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) {
|
||||
const checkpointSize = 300
|
||||
const recalculateDestRadius = 50
|
||||
const aggroRadius = 500
|
||||
const memory = {}
|
||||
|
||||
return function builtMinionLogic() {
|
||||
const entity = this
|
||||
if (entity.dead) { entity.despawn() }
|
||||
|
||||
const currentTick = entity.game?.currentTick ?? 0
|
||||
const minionResponseTime = Math.floor(0.1 * (entity.game?.tickRate ?? 1))
|
||||
if (!(currentTick % minionResponseTime == 0 && Math.floor(currentTick / minionResponseTime) % 2 == (odd ? 1 : 0))) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = entity.closestTargetTo(entity.position, aggroRadius)
|
||||
if (target != null) {
|
||||
entity.ghosting = false
|
||||
entity.attackAction(target.position)
|
||||
}
|
||||
|
||||
if ((route.length > 0 || entity.attacking) && target == null) {
|
||||
const routeIndex = memory.routeCheckpoint ?? 0
|
||||
const goal = route[routeIndex].clone()
|
||||
if (goal instanceof Vector2) {
|
||||
if (entity.distanceTo(goal) < checkpointSize) {
|
||||
if (routeIndex + 1 < route.length) {
|
||||
memory.routeCheckpoint = routeIndex + 1
|
||||
}
|
||||
}
|
||||
|
||||
if ((entity.destination?.distanceTo(entity.position) ?? 0) < recalculateDestRadius) {
|
||||
entity.ghosting = true
|
||||
entity.moveAction(goal)
|
||||
}
|
||||
}
|
||||
|
||||
if (entity.position.equals(route.at(-1))) {
|
||||
entity.despawn()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static #playerLogic() {
|
||||
const entity = this
|
||||
// if (entity.dead) {
|
||||
// entity.respawn()
|
||||
// }
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { Shape, ShapeUtils, Vector2 } from 'three'
|
||||
import SAT from 'sat'
|
||||
|
||||
export default class Terrain {
|
||||
id = `terrain-${Terrain.nextId()}`
|
||||
static nextId() { return this.#nextUniqueId++ }
|
||||
static #nextUniqueId = 0
|
||||
|
||||
bbox = new Float32Array(4)
|
||||
collision = true
|
||||
ghostable = false
|
||||
position = new Vector2()
|
||||
relativeVertices = []
|
||||
|
||||
#colliders = []
|
||||
#vertices = []
|
||||
#unadjustedWaypoints = []
|
||||
|
||||
constructor(vertices, collision = null) {
|
||||
this.#vertices = vertices.map((v) => new Vector2(v.x, v.y))
|
||||
if (ShapeUtils.isClockWise(this.#vertices)) {
|
||||
this.#vertices.reverse()
|
||||
}
|
||||
|
||||
if (collision != null) {
|
||||
this.collision = collision
|
||||
}
|
||||
|
||||
this.#calculateColliders()
|
||||
this.#calculatePosition()
|
||||
this.#calculateRelativeVertices()
|
||||
this.#calculateUnadjustedWaypoints()
|
||||
this.#calculateBbox()
|
||||
}
|
||||
|
||||
get vertices() { return this.#vertices }
|
||||
get dead() { return false }
|
||||
|
||||
static waypointsForSide(fromVertex, toVertex, isClockwise = false) {
|
||||
const from = isClockwise ? toVertex : fromVertex
|
||||
const to = isClockwise ? fromVertex : toVertex
|
||||
const origin = new Vector2()
|
||||
const sideNormal = to.clone().sub(from).clone().normalize()
|
||||
|
||||
const margin = sideNormal.clone().rotateAround(origin, -3 * Math.PI / 4)
|
||||
const offset = margin.clone().multiplyScalar(Math.SQRT2)
|
||||
const inverseMargin = sideNormal.clone().negate().rotateAround(origin, 3 * Math.PI / 4)
|
||||
const inverseOffset = inverseMargin.clone().multiplyScalar(Math.SQRT2)
|
||||
|
||||
return [
|
||||
[margin.clone().add(from), offset],
|
||||
[inverseMargin.clone().add(to), inverseOffset],
|
||||
]
|
||||
}
|
||||
|
||||
colliders() { return this.#colliders }
|
||||
unadjustedWaypoints() { return this.#unadjustedWaypoints }
|
||||
|
||||
#shape() {
|
||||
const complexShape = new Shape()
|
||||
|
||||
complexShape.moveTo(this.#vertices.at(0).x, this.#vertices.at(0).y)
|
||||
this.#vertices.slice(1).forEach((v) => complexShape.lineTo(v.x, v.y))
|
||||
|
||||
return complexShape
|
||||
}
|
||||
|
||||
#calculateBbox() {
|
||||
const firstVertex = this.vertices.at(0)
|
||||
if (firstVertex != null) {
|
||||
this.bbox[0] = firstVertex.y
|
||||
this.bbox[1] = firstVertex.x
|
||||
this.bbox[2] = firstVertex.y
|
||||
this.bbox[3] = firstVertex.x
|
||||
}
|
||||
|
||||
this.vertices.forEach((v) => {
|
||||
if (v.y > this.bbox[0]) {
|
||||
this.bbox[0] = v.y
|
||||
}
|
||||
if (v.x > this.bbox[1]) {
|
||||
this.bbox[1] = v.x
|
||||
}
|
||||
if (v.y < this.bbox[2]) {
|
||||
this.bbox[2] = v.y
|
||||
}
|
||||
if (v.x < this.bbox[3]) {
|
||||
this.bbox[3] = v.x
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#calculateColliders() {
|
||||
const points = this.#shape().extractPoints(16)
|
||||
|
||||
const indicesToPolygon = (indices) => {
|
||||
const satPoints = [
|
||||
new SAT.Vector(...points.shape[indices[0]].toArray()),
|
||||
new SAT.Vector(...points.shape[indices[1]].clone().sub(points.shape[indices[0]]).toArray()),
|
||||
new SAT.Vector(...points.shape[indices[2]].clone().sub(points.shape[indices[0]]).toArray()),
|
||||
]
|
||||
|
||||
return new SAT.Polygon(satPoints[0], [new SAT.Vector(), satPoints[1], satPoints[2]])
|
||||
}
|
||||
|
||||
this.#colliders = ShapeUtils.triangulateShape(points.shape, points.holes).map(indicesToPolygon)
|
||||
}
|
||||
|
||||
#calculatePosition() {
|
||||
this.position = this.#vertices.reduce(((sum, v) => sum.add(v)), new Vector2()).divideScalar(this.#vertices.length)
|
||||
}
|
||||
|
||||
#calculateRelativeVertices() {
|
||||
this.relativeVertices = this.#vertices.map((v) => v.clone().sub(this.position))
|
||||
}
|
||||
|
||||
#calculateUnadjustedWaypoints() {
|
||||
this.#unadjustedWaypoints = this.#vertices.map((v, i, arr) => Terrain.waypointsForSide(v, i + 1 < arr.length ? arr[i + 1] : arr[0])).flat()
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import WebSocket from 'ws'
|
||||
|
||||
const numberOfClients = 10
|
||||
const url = 'ws://localhost:1280/ws'
|
||||
|
||||
for (let i = 1; i <= numberOfClients; i++) {
|
||||
const id = `${i}`
|
||||
const websocket = new WebSocket(url)
|
||||
|
||||
websocket.onerror = () => websocket.close()
|
||||
websocket.onopen = () => {
|
||||
websocket.send(JSON.stringify({ action: 'join', id }))
|
||||
console.log({ client: id, event: 'joined' })
|
||||
}
|
||||
websocket.onclose = () => {
|
||||
console.log({ client: id, event: 'disconnected' })
|
||||
}
|
||||
|
||||
websocket.onmessage = (event) => {
|
||||
const byteSize = new Blob([event.data]).size
|
||||
// console.log({ client: id, received: `${byteSize} B of data` })
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Terrain Creator</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background-color: black;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#map {
|
||||
background-color: white;
|
||||
background-image: url('./background.png');
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.point {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
margin-top: -5px;
|
||||
margin-left: -5px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: red;
|
||||
border: 1px solid white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
<script>
|
||||
var width = null
|
||||
var height = null
|
||||
var scale = null
|
||||
var points = []
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const params = Object.fromEntries(new URLSearchParams(window.location.search).entries())
|
||||
width = params.width
|
||||
height = params.height
|
||||
scale = params.scale
|
||||
if (width == null) {
|
||||
width = prompt('Width: ')
|
||||
}
|
||||
if (height == null) {
|
||||
height = prompt('Height: ')
|
||||
}
|
||||
if (scale == null) {
|
||||
scale = prompt('Scale: ')
|
||||
}
|
||||
|
||||
const map = document.getElementById('map')
|
||||
map.style.width = `${width / scale}px`
|
||||
map.style.height = `${height / scale}px`
|
||||
|
||||
map.addEventListener('contextmenu', (event) => event.preventDefault())
|
||||
map.addEventListener('mousedown', (event) => {
|
||||
if (event.button == 2) {
|
||||
console.log(`\n\n[\n` + points.map((p) => ` new Vector2(${p.x}, ${p.y}),`).join(`\n`) + `\n],\n`)
|
||||
points = []
|
||||
map.innerHTML = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (event.button == 0) {
|
||||
const x = Math.floor(event.pageX * scale)
|
||||
const y = Math.floor(height - (event.pageY * scale))
|
||||
points.push({ x, y })
|
||||
|
||||
const point = document.createElement('div')
|
||||
point.classList.add('point')
|
||||
point.style.left = event.pageX
|
||||
point.style.top = event.pageY
|
||||
map.appendChild(point)
|
||||
return
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||