From 2ac2443d43965dd91659771c5cb9631215a0ea86 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Wed, 11 Dec 2024 11:45:49 +0000 Subject: [PATCH] fully lint --- README.md | 7 +- package.json | 9 +- src/@types/matrix-js-sdk.d.ts | 29 + src/assets.ts | 42 +- src/assets/ASSETS.md | 21 +- src/assets/manifest.ts | 150 +-- src/camera.ts | 173 +-- src/components/app.tsx | 179 +-- src/components/changelog.tsx | 141 ++- src/components/config.ts | 22 +- .../gameui/weapon-select.module.css | 74 +- src/components/gameui/weapon-select.tsx | 51 +- src/components/ingame-view.module.css | 14 +- src/components/ingame-view.tsx | 61 +- src/components/lobby.tsx | 250 ++-- src/components/menu.css | 59 +- src/components/menu.tsx | 219 ++-- src/components/menus/account.module.css | 8 +- src/components/menus/account.tsx | 201 +-- src/components/menus/overlaytest.tsx | 31 +- src/components/menus/team-editor.tsx | 22 +- src/components/menus/types.ts | 10 +- src/consts.ts | 2 +- src/entities/background.ts | 249 ++-- src/entities/bitmapTerrain.ts | 698 ++++++----- src/entities/entity.ts | 52 +- src/entities/explosion.ts | 270 ++-- src/entities/index.ts | 20 +- src/entities/phys/bazookaShell.ts | 180 +-- src/entities/phys/firework.ts | 348 +++--- src/entities/phys/grenade.ts | 244 ++-- src/entities/phys/mine.ts | 256 ++-- src/entities/phys/physicsEntity.ts | 251 ++-- src/entities/phys/timedExplosive.ts | 210 ++-- src/entities/playable/playable.ts | 362 +++--- src/entities/playable/remoteWorm.ts | 135 +- src/entities/playable/testDummy.ts | 185 +-- src/entities/playable/worm.ts | 1085 ++++++++++------- src/entities/type.ts | 12 +- src/entities/water.ts | 188 +-- src/flags.ts | 38 +- src/game.ts | 227 ++-- src/index.css | 2 +- src/input.ts | 283 +++-- src/interop/gamechannel.ts | 41 +- src/log.ts | 86 +- src/logic/gamestate.ts | 408 ++++--- src/logic/teams.ts | 97 +- src/main.tsx | 8 +- src/mixins/bodyWireframe..ts | 123 +- src/mixins/styles.ts | 29 +- src/movementController.ts | 190 +-- src/net/client.ts | 380 +++--- src/net/models.ts | 114 +- src/overlays/debugOverlay.ts | 238 ++-- src/overlays/gameStateOverlay.ts | 305 +++-- src/overlays/toaster.ts | 138 ++- src/overlays/windDial.ts | 141 +-- src/scenarios/boneIsles.ts | 157 +-- src/scenarios/borealisTribute.ts | 62 +- src/scenarios/grenadeIsland.ts | 253 ++-- src/scenarios/netGame.ts | 338 ++--- src/scenarios/replayTesting.ts | 395 +++--- src/scenarios/testingGround.ts | 340 +++--- src/scenarios/uiTest.ts | 53 +- src/settings.ts | 2 +- src/shaders/gradient.ts | 8 +- src/shaders/index.ts | 2 +- src/state/model.ts | 127 +- src/state/player.ts | 242 ++-- src/state/recorder.ts | 264 ++-- src/terrain/index.ts | 182 +-- src/text/toasts.ts | 95 +- src/utils/coodinate.ts | 120 +- src/utils/damage.ts | 71 +- src/utils/index.ts | 37 +- src/weapons/bazooka.ts | 55 +- src/weapons/grenade.ts | 60 +- src/weapons/index.ts | 26 +- src/weapons/shotgun.ts | 95 +- src/weapons/weapon.ts | 71 +- src/world.ts | 448 ++++--- yarn.lock | 5 + 83 files changed, 7226 insertions(+), 5650 deletions(-) create mode 100644 src/@types/matrix-js-sdk.d.ts diff --git a/README.md b/README.md index f9669ba..c152108 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ -wormgine --------- +## wormgine -*Everything, including the name is subject to change.* +_Everything, including the name is subject to change._ This project started off as an attempt to clone Worms Armageddon for the browser, however for a few reasons the plan has changed. This will hopefully be an entirely @@ -53,4 +52,4 @@ This is far less fleshed out than other sections of the game due to frontloading The terrain system works by being given a Texture (which is then rendered to a canvas as a bitmap), and then breaking the alpha channel down into a quadtree. As the map is damanged, the bitmap is altered and the quadtree is re-rendered. The system is somewhat -crude but performance is reasonable. \ No newline at end of file +crude but performance is reasonable. diff --git a/package.json b/package.json index bea9519..d77ea33 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,11 @@ "dev": "VITE_BUILD_COMMIT=`git log --pretty=format:'%h' -n 1` VITE_DEFAULT_HOMESERVER=http://localhost:8008 vite", "build": "tsc && vite build", "preview": "vite preview", - "lint": "eslint src/", + "lint:eslint": "eslint src/", "test": "jest", - "assets": "node scripts/generateAssetManifest.mjs > src/assets/manifest.ts" + "assets": "node scripts/generateAssetManifest.mjs > src/assets/manifest.ts", + "lint:prettier": "prettier 'src/**/*.(ts|tsx|md)' '*.md' -w", + "lint": "yarn lint:eslint && yarn lint:prettier" }, "dependencies": { "@dimforge/rapier2d-compat": "^0.14.0", @@ -17,7 +19,8 @@ "matrix-js-sdk": "^34.8.0", "pixi-viewport": "^5.0.3", "pixi.js": "^8.5.1", - "preact": "^10.24.3" + "preact": "^10.24.3", + "prettier": "^3.4.2" }, "devDependencies": { "@jest/globals": "^29.7.0", diff --git a/src/@types/matrix-js-sdk.d.ts b/src/@types/matrix-js-sdk.d.ts new file mode 100644 index 0000000..c1ba97c --- /dev/null +++ b/src/@types/matrix-js-sdk.d.ts @@ -0,0 +1,29 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { + GameConfigEvent, + GameConfigEventType, + GameStageEvent, + GameStageEventType, + FullGameStateEvent, + PlayerAckEvent, + PlayerAckEventType, + GameStateEventType, +} from "../net/models"; + +// Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types +declare module "matrix-js-sdk" { + export interface StateEvents { + [GameConfigEventType]: GameConfigEvent["content"]; + [GameStageEventType]: GameStageEvent["content"]; + } + export interface TimelineEvents { + [PlayerAckEventType]: PlayerAckEvent["content"]; + [GameStateEventType]: FullGameStateEvent["content"]; + } +} diff --git a/src/assets.ts b/src/assets.ts index f8825e2..967adba 100644 --- a/src/assets.ts +++ b/src/assets.ts @@ -6,32 +6,32 @@ let textures: Record; let sounds: Record; export async function loadAssets(progressFn: (totalProgress: number) => void) { - await Assets.init({ manifest }); + await Assets.init({ manifest }); - const bundleCount = Object.keys(manifest.bundles).length; - let bundleIndex = 0; - for (const {name} of manifest.bundles) { - const bundle = await Assets.loadBundle(name, (progress) => { - const totalProgress = bundleIndex/bundleCount + (progress / bundleCount); - progressFn(totalProgress); - }); - bundleIndex++; - if (name === 'textures') { - textures = bundle; - } else if (name === 'sounds') { - sounds = bundle; - } + const bundleCount = Object.keys(manifest.bundles).length; + let bundleIndex = 0; + for (const { name } of manifest.bundles) { + const bundle = await Assets.loadBundle(name, (progress) => { + const totalProgress = bundleIndex / bundleCount + progress / bundleCount; + progressFn(totalProgress); + }); + bundleIndex++; + if (name === "textures") { + textures = bundle; + } else if (name === "sounds") { + sounds = bundle; } + } } export function getAssets() { - if (!textures || !sounds) { - throw Error('Assets not preloaded'); - } - return { - textures: textures as unknown as AssetTextures, - sounds: sounds as unknown as AssetSounds - } + if (!textures || !sounds) { + throw Error("Assets not preloaded"); + } + return { + textures: textures as unknown as AssetTextures, + sounds: sounds as unknown as AssetSounds, + }; } export type AssetPack = ReturnType; diff --git a/src/assets/ASSETS.md b/src/assets/ASSETS.md index cee403c..3edb065 100644 --- a/src/assets/ASSETS.md +++ b/src/assets/ASSETS.md @@ -2,21 +2,20 @@ #### Sounds provided by https://gameburp.itch.io: - - `explosion_1.ogg` - - `explosion_2.ogg` - - `explosion_3.ogg` - - `metal_bounce_heavy.ogg` - - `metal_bounce_light.ogg` +- `explosion_1.ogg` +- `explosion_2.ogg` +- `explosion_3.ogg` +- `metal_bounce_heavy.ogg` +- `metal_bounce_light.ogg` These are provided under a commercial licence and will need removing prior to the game being open sourced. - #### Sounds provided by https://pixabay.com - - `splash.ogg` - - `firework.ogg` - - `shotgun.ogg` +- `splash.ogg` +- `firework.ogg` +- `shotgun.ogg` #### "Monogram" font provided by https://datagoblin.itch.io/monogram uncer [CC0](https://creativecommons.org/publicdomain/zero/1.0/) - - - `monogram.woff2` \ No newline at end of file + +- `monogram.woff2` diff --git a/src/assets/manifest.ts b/src/assets/manifest.ts index 6463050..6180b70 100644 --- a/src/assets/manifest.ts +++ b/src/assets/manifest.ts @@ -1,4 +1,3 @@ - import { AssetsManifest, Texture } from "pixi.js"; import { Sound } from "@pixi/sound"; import "@pixi/sound"; @@ -41,81 +40,88 @@ import splashSnd from "./splash.ogg"; import monogramFnt from "./monogram.woff2"; export interface AssetTextures { - bazooka: Texture; - boneIsles: Texture; - firework: Texture; - grenade: Texture; - island1: Texture; - mine: Texture; - mineActive: Texture; - terrain2: Texture; - testDolby: Texture; - testDolbyBlush: Texture; - testDolbyDamage1: Texture; - testDolbyDamage1Blush: Texture; - testDolbyDamage2Blush: Texture; - testDolbyDamage3: Texture; - testDolbyDamage3Blush: Texture; - testingGround: Texture; - windScroll: Texture; + bazooka: Texture; + boneIsles: Texture; + firework: Texture; + grenade: Texture; + island1: Texture; + mine: Texture; + mineActive: Texture; + terrain2: Texture; + testDolby: Texture; + testDolbyBlush: Texture; + testDolbyDamage1: Texture; + testDolbyDamage1Blush: Texture; + testDolbyDamage2Blush: Texture; + testDolbyDamage3: Texture; + testDolbyDamage3Blush: Texture; + testingGround: Texture; + windScroll: Texture; } export interface AssetSounds { - bazookafire: Sound; - explosion1: Sound; - explosion2: Sound; - explosion3: Sound; - firework: Sound; - metalBounceHeavy: Sound; - metalBounceLight: Sound; - mineBeep: Sound; - placeholder: Sound; - shotgun: Sound; - splash: Sound; + bazookafire: Sound; + explosion1: Sound; + explosion2: Sound; + explosion3: Sound; + firework: Sound; + metalBounceHeavy: Sound; + metalBounceLight: Sound; + mineBeep: Sound; + placeholder: Sound; + shotgun: Sound; + splash: Sound; } export const manifest = { - bundles: [{ - name: "textures", - assets: [ - {src: bazookaTex, alias: "bazooka"}, - {src: boneIslesTex, alias: "boneIsles"}, - {src: fireworkTex, alias: "firework"}, - {src: grenadeTex, alias: "grenade"}, - {src: island1Tex, alias: "island1"}, - {src: mineTex, alias: "mine"}, - {src: mineActiveTex, alias: "mineActive"}, - {src: terrain2Tex, alias: "terrain2"}, - {src: testDolbyTex, alias: "testDolby"}, - {src: testDolbyBlushTex, alias: "testDolbyBlush"}, - {src: testDolbyDamage1Tex, alias: "testDolbyDamage1"}, - {src: testDolbyDamage1BlushTex, alias: "testDolbyDamage1Blush"}, - {src: testDolbyDamage2BlushTex, alias: "testDolbyDamage2Blush"}, - {src: testDolbyDamage3Tex, alias: "testDolbyDamage3"}, - {src: testDolbyDamage3BlushTex, alias: "testDolbyDamage3Blush"}, - {src: testingGroundTex, alias: "testingGround"}, - {src: windScrollTex, alias: "windScroll"} - ] - }, { - name: "sounds", - assets: [ - {src: bazookafireSnd, alias: "bazookafire"}, - {src: explosion1Snd, alias: "explosion1"}, - {src: explosion2Snd, alias: "explosion2"}, - {src: explosion3Snd, alias: "explosion3"}, - {src: fireworkSnd, alias: "firework"}, - {src: metalBounceHeavySnd, alias: "metalBounceHeavy"}, - {src: metalBounceLightSnd, alias: "metalBounceLight"}, - {src: mineBeepSnd, alias: "mineBeep"}, - {src: placeholderSnd, alias: "placeholder"}, - {src: shotgunSnd, alias: "shotgun"}, - {src: splashSnd, alias: "splash"} - ] - }, { - name: "fonts", - assets: [ - {src: monogramFnt, alias: "monogram", data: {"family":"Monogram","weights":["normal"]}} - ] - }] + bundles: [ + { + name: "textures", + assets: [ + { src: bazookaTex, alias: "bazooka" }, + { src: boneIslesTex, alias: "boneIsles" }, + { src: fireworkTex, alias: "firework" }, + { src: grenadeTex, alias: "grenade" }, + { src: island1Tex, alias: "island1" }, + { src: mineTex, alias: "mine" }, + { src: mineActiveTex, alias: "mineActive" }, + { src: terrain2Tex, alias: "terrain2" }, + { src: testDolbyTex, alias: "testDolby" }, + { src: testDolbyBlushTex, alias: "testDolbyBlush" }, + { src: testDolbyDamage1Tex, alias: "testDolbyDamage1" }, + { src: testDolbyDamage1BlushTex, alias: "testDolbyDamage1Blush" }, + { src: testDolbyDamage2BlushTex, alias: "testDolbyDamage2Blush" }, + { src: testDolbyDamage3Tex, alias: "testDolbyDamage3" }, + { src: testDolbyDamage3BlushTex, alias: "testDolbyDamage3Blush" }, + { src: testingGroundTex, alias: "testingGround" }, + { src: windScrollTex, alias: "windScroll" }, + ], + }, + { + name: "sounds", + assets: [ + { src: bazookafireSnd, alias: "bazookafire" }, + { src: explosion1Snd, alias: "explosion1" }, + { src: explosion2Snd, alias: "explosion2" }, + { src: explosion3Snd, alias: "explosion3" }, + { src: fireworkSnd, alias: "firework" }, + { src: metalBounceHeavySnd, alias: "metalBounceHeavy" }, + { src: metalBounceLightSnd, alias: "metalBounceLight" }, + { src: mineBeepSnd, alias: "mineBeep" }, + { src: placeholderSnd, alias: "placeholder" }, + { src: shotgunSnd, alias: "shotgun" }, + { src: splashSnd, alias: "splash" }, + ], + }, + { + name: "fonts", + assets: [ + { + src: monogramFnt, + alias: "monogram", + data: { family: "Monogram", weights: ["normal"] }, + }, + ], + }, + ], } satisfies AssetsManifest; - diff --git a/src/camera.ts b/src/camera.ts index 5982719..20290f1 100644 --- a/src/camera.ts +++ b/src/camera.ts @@ -6,100 +6,107 @@ import { PlayableEntity } from "./entities/playable/playable"; import { MovedEvent } from "pixi-viewport/dist/types"; import Logger from "./log"; -const logger = new Logger('ViewportCamera'); +const logger = new Logger("ViewportCamera"); export enum CameraLockPriority { - // Do not lock the camera to this object - NoLock = 0, - // Snap the camera to this object if the current player isn't local, but allow the user to move away. - SuggestedLockNonLocal = 1, - // Snap the camera to this object, but allow the user to move away. - SuggestedLockLocal = 2, - // Lock the camera to this object, but only suggest it to local players. - LockIfNotLocalPlayer = 3, - // Always lock the camera to this object. - AlwaysLock = 4 + // Do not lock the camera to this object + NoLock = 0, + // Snap the camera to this object if the current player isn't local, but allow the user to move away. + SuggestedLockNonLocal = 1, + // Snap the camera to this object, but allow the user to move away. + SuggestedLockLocal = 2, + // Lock the camera to this object, but only suggest it to local players. + LockIfNotLocalPlayer = 3, + // Always lock the camera to this object. + AlwaysLock = 4, } export class ViewportCamera { - private currentLockTarget: PhysicsEntity|null = null; - private userWantsControl = false; - private lastMoveHash = 0; + private currentLockTarget: PhysicsEntity | null = null; + private userWantsControl = false; + private lastMoveHash = 0; - constructor(private readonly viewport: Viewport, private readonly world: GameWorld) { - viewport.on('moved', (event: MovedEvent) => { - if (event.type === "clamp-y" || event.type === "clamp-x") { - // Ignore, the director moved us. - return; - } - this.userWantsControl = true; - logger.debug('Player took control'); - }); + constructor( + private readonly viewport: Viewport, + private readonly world: GameWorld, + ) { + viewport.on("moved", (event: MovedEvent) => { + if (event.type === "clamp-y" || event.type === "clamp-x") { + // Ignore, the director moved us. + return; + } + this.userWantsControl = true; + logger.debug("Player took control"); + }); + } + + public update(_dt: Ticker, currentWorm: PlayableEntity | undefined) { + let newTarget: PhysicsEntity | null = null; + let priority: CameraLockPriority = CameraLockPriority.NoLock; + if (this.currentLockTarget?.destroyed) { + this.currentLockTarget = null; + } + for (const e of this.world.entities.values()) { + if (e instanceof PhysicsEntity === false) { + continue; + } + if (e.cameraLockPriority > priority) { + newTarget = e; + priority = e.cameraLockPriority; + } + } + if (!newTarget) { + return; } - public update(dt: Ticker, currentWorm: PlayableEntity|undefined) { - let newTarget: PhysicsEntity|null = null; - let priority: CameraLockPriority = CameraLockPriority.NoLock; - if (this.currentLockTarget?.destroyed) { - this.currentLockTarget = null; - } - for (const e of this.world.entities.values()) { - if (e instanceof PhysicsEntity === false) { - continue; - } - if (e.cameraLockPriority > priority) { - newTarget = e; - priority = e.cameraLockPriority; - } - } - if (!newTarget) { - return; - } + const isLocal = !currentWorm?.wormIdent.team.playerUserId; + if (newTarget !== this.currentLockTarget) { + // Reset user control. + this.userWantsControl = false; + logger.debug("New lock target", newTarget); + } + this.currentLockTarget = newTarget; + const targetXY: [number, number] = [ + this.currentLockTarget.sprite.position.x, + this.currentLockTarget.sprite.position.y, + ]; + // Short circuit skip move if it's cheaper not to. + const newMoveHash = + this.currentLockTarget.sprite.position.x + + this.currentLockTarget.sprite.position.y; + if (this.lastMoveHash === newMoveHash) { + return; + } + this.lastMoveHash = newMoveHash; - const isLocal = !currentWorm?.wormIdent.team.playerUserId; - if (newTarget !== this.currentLockTarget) { - // Reset user control. - this.userWantsControl = false; - logger.debug("New lock target", newTarget); + switch (this.currentLockTarget.cameraLockPriority) { + case CameraLockPriority.SuggestedLockNonLocal: + if (this.userWantsControl) { + return; } - this.currentLockTarget = newTarget; - - const targetXY: [number, number] = [this.currentLockTarget.sprite.position.x, this.currentLockTarget.sprite.position.y]; - // Short circuit skip move if it's cheaper not to. - let newMoveHash = (this.currentLockTarget.sprite.position.x + this.currentLockTarget.sprite.position.y); - if (this.lastMoveHash === newMoveHash) { - return; + // Need a better way to determine this. + if (!isLocal) { + this.viewport.moveCenter(...targetXY); } - this.lastMoveHash = newMoveHash; - - switch (this.currentLockTarget.cameraLockPriority) { - case CameraLockPriority.SuggestedLockNonLocal: - if (this.userWantsControl) { - return; - } - // Need a better way to determine this. - if (!isLocal) { - this.viewport.moveCenter(...targetXY); - } - break; - case CameraLockPriority.SuggestedLockLocal: - if (this.userWantsControl) { - return; - } - this.viewport.moveCenter(...targetXY); - break; - case CameraLockPriority.LockIfNotLocalPlayer: - if (!isLocal) { - this.viewport.moveCenter(...targetXY); - } else if (!this.userWantsControl) { - this.viewport.moveCenter(...targetXY); - } - break; - - case CameraLockPriority.AlwaysLock: - this.viewport.moveCenter(...targetXY); - break; + break; + case CameraLockPriority.SuggestedLockLocal: + if (this.userWantsControl) { + return; } + this.viewport.moveCenter(...targetXY); + break; + case CameraLockPriority.LockIfNotLocalPlayer: + if (!isLocal) { + this.viewport.moveCenter(...targetXY); + } else if (!this.userWantsControl) { + this.viewport.moveCenter(...targetXY); + } + break; + + case CameraLockPriority.AlwaysLock: + this.viewport.moveCenter(...targetXY); + break; } -} \ No newline at end of file + } +} diff --git a/src/components/app.tsx b/src/components/app.tsx index 987abd1..439a19c 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -7,92 +7,119 @@ import { Lobby } from "./lobby"; import { GameReactChannel } from "../interop/gamechannel"; interface LoadGameProps { - level: string; - gameInstance?: NetGameInstance; + level: string; + gameInstance?: NetGameInstance; } - export function App() { - const [gameState, setGameState] = useState(); - const [assetProgress, setAssetProgress] = useState(0); - const [assetsLoaded, setAssetsLoaded] = useState(false); - const [showLobby, setShowLobby] = useState(false); - const [client, setClient]= useState(); - const lobbyGameRoomId = new URLSearchParams(window.location.search)?.get('gameRoomId') ?? undefined; - const [clientReady, setClientReady] = useState(client?.ready); - const [clientShouldReload, setClientShouldReload] = useState(true); - const gameReactChannel = new GameReactChannel(); - - useEffect(() => { - if (!clientShouldReload) { - return; - } - if (client) { - return; - } - setClientShouldReload(false); - // Load client. - const configStr = localStorage.getItem('wormgine_client_config'); - if (configStr) { - const client = new NetGameClient(JSON.parse(configStr)); - client.once('sync', () => { - setClientReady(true); - }); - setClient(client); - client.start(); - } - }, [clientShouldReload]); + const [gameState, setGameState] = useState(); + const [assetProgress, setAssetProgress] = useState(0); + const [assetsLoaded, setAssetsLoaded] = useState(false); + const [showLobby, setShowLobby] = useState(false); + const [client, setClient] = useState(); + const lobbyGameRoomId = + new URLSearchParams(window.location.search)?.get("gameRoomId") ?? undefined; + const [clientReady, setClientReady] = useState(client?.ready); + const [clientShouldReload, setClientShouldReload] = useState(true); + const gameReactChannel = new GameReactChannel(); - gameReactChannel.on('goToMenu', () => { - // TODO: Show a win screen! - setGameState(undefined); - }); + useEffect(() => { + if (!clientShouldReload) { + return; + } + if (client) { + return; + } + setClientShouldReload(false); + // Load client. + const configStr = localStorage.getItem("wormgine_client_config"); + if (configStr) { + const client = new NetGameClient(JSON.parse(configStr)); + client.once("sync", () => { + setClientReady(true); + }); + setClient(client); + client.start(); + } + }, [clientShouldReload]); - useEffect(() => { - void loadAssets((v) => { setAssetProgress(v) }).then(() => setAssetsLoaded(true)); - }, [setAssetProgress]); + gameReactChannel.on("goToMenu", () => { + // TODO: Show a win screen! + setGameState(undefined); + }); - useEffect(() => { - if (client && lobbyGameRoomId && clientReady && !gameState) { - // Now we can load into the game. - setShowLobby(true); - } - }) + useEffect(() => { + void loadAssets((v) => { + setAssetProgress(v); + }).then(() => setAssetsLoaded(true)); + }, [setAssetProgress]); - const onNewGame = useCallback((level: string) => { - if (level === 'lobbyGame') { - // TODO: Bit of a hack - setShowLobby(true); - } else { - setGameState({level}); - } - }, [setGameState]); - - const onLobbyGameStarted = useCallback((instance: NetGameInstance) => { - setGameState({level: 'netGame', gameInstance: instance}); - setShowLobby(false); - }, [setGameState]); - - if (!assetsLoaded) { - return
- Loading {Math.round(assetProgress*100)}% -
; + useEffect(() => { + if (client && lobbyGameRoomId && clientReady && !gameState) { + // Now we can load into the game. + setShowLobby(true); } + }); - if (lobbyGameRoomId && !clientReady) { - return
- Waiting for client to be ready. -
; - } + const onNewGame = useCallback( + (level: string) => { + if (level === "lobbyGame") { + // TODO: Bit of a hack + setShowLobby(true); + } else { + setGameState({ level }); + } + }, + [setGameState], + ); - if (showLobby) { - return - } + const onLobbyGameStarted = useCallback( + (instance: NetGameInstance) => { + setGameState({ level: "netGame", gameInstance: instance }); + setShowLobby(false); + }, + [setGameState], + ); - if (gameState) { - return - } + if (!assetsLoaded) { + return ( +
+ Loading{" "} + {" "} + {Math.round(assetProgress * 100)}% +
+ ); + } + + if (lobbyGameRoomId && !clientReady) { + return
Waiting for client to be ready.
; + } + if (showLobby) { + return ( + + ); + } - return setClientShouldReload(true)} client={client}/> -} \ No newline at end of file + if (gameState) { + return ( + + ); + } + + return ( + setClientShouldReload(true)} + client={client} + /> + ); +} diff --git a/src/components/changelog.tsx b/src/components/changelog.tsx index 862c63c..baae5a6 100644 --- a/src/components/changelog.tsx +++ b/src/components/changelog.tsx @@ -1,61 +1,88 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; - -export function ChangelogModal({buildNumber, buildCommit, lastCommit}: {buildNumber?: number, buildCommit?: string, lastCommit?: string|null}) { - const modalRef = useRef(null); - const hasNewBuild = useMemo(() => buildCommit && lastCommit && buildCommit !== lastCommit, [buildCommit, lastCommit]); - const [latestChanges, setLatestChanges] = useState(); - - useEffect(() => { - if (!buildCommit || !lastCommit) { - return; - } - - (async () => { - const req = await fetch(`https://api.github.com/repos/Half-Shot/wormgine/compare/${lastCommit}...${buildCommit}`); - if (!req.ok) { - // No good. - setLatestChanges(['Could not load changes']); - } - const result = await req.json(); - setLatestChanges(result.commits.map((c: {commit: {message: string}}) => `${c.commit.message}`).reverse()); - })(); - }, [buildCommit, lastCommit, setLatestChanges]); - - const onClick = useCallback((e: MouseEvent) => { - e.preventDefault(); - modalRef.current?.showModal(); - }, [modalRef]); - - const newChangesModal = useMemo(() => { - const title = buildNumber ? `Build #${buildNumber}` : `Developer Build ${buildCommit}`; - return -

{title}

-

- Changes since {lastCommit?.slice(0,8)} -

-
    - {latestChanges?.map((v,i) => -
  1. {v}
  2. - )} -
- -
; - }, [buildNumber, buildCommit, lastCommit, latestChanges, modalRef]); - - if (!buildNumber && !buildCommit) { - return

Unknown build

; - } +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "preact/hooks"; - let newChangesButton = null; +export function ChangelogModal({ + buildNumber, + buildCommit, + lastCommit, +}: { + buildNumber?: number; + buildCommit?: string; + lastCommit?: string | null; +}) { + const modalRef = useRef(null); + const hasNewBuild = useMemo( + () => buildCommit && lastCommit && buildCommit !== lastCommit, + [buildCommit, lastCommit], + ); + const [latestChanges, setLatestChanges] = useState(); - if (hasNewBuild) { - newChangesButton = ; + useEffect(() => { + if (!buildCommit || !lastCommit) { + return; } - return <> -

Build number {buildNumber ?? {buildCommit}} {newChangesButton}

- {newChangesModal} - ; -} \ No newline at end of file + (async () => { + const req = await fetch( + `https://api.github.com/repos/Half-Shot/wormgine/compare/${lastCommit}...${buildCommit}`, + ); + if (!req.ok) { + // No good. + setLatestChanges(["Could not load changes"]); + } + const result = await req.json(); + setLatestChanges( + result.commits + .map((c: { commit: { message: string } }) => `${c.commit.message}`) + .reverse(), + ); + })(); + }, [buildCommit, lastCommit, setLatestChanges]); + + const onClick = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + modalRef.current?.showModal(); + }, + [modalRef], + ); + + const newChangesModal = useMemo(() => { + const title = buildNumber + ? `Build #${buildNumber}` + : `Developer Build ${buildCommit}`; + return ( + +

{title}

+

Changes since {lastCommit?.slice(0, 8)}

+
    {latestChanges?.map((v, i) =>
  1. {v}
  2. )}
+ +
+ ); + }, [buildNumber, buildCommit, lastCommit, latestChanges, modalRef]); + + if (!buildNumber && !buildCommit) { + return

Unknown build

; + } + + let newChangesButton = null; + + if (hasNewBuild) { + newChangesButton = ; + } + + return ( + <> +

+ Build number {buildNumber ?? {buildCommit}}{" "} + {newChangesButton} +

+ {newChangesModal} + + ); +} diff --git a/src/components/config.ts b/src/components/config.ts index fa7ae7c..53ec3c3 100644 --- a/src/components/config.ts +++ b/src/components/config.ts @@ -1,20 +1,18 @@ interface WormgineClientConfiguration { - defaultHomeserver: string|null, + defaultHomeserver: string | null; } -const { - VITE_DEFAULT_HOMESERVER -} = import.meta.env; +const { VITE_DEFAULT_HOMESERVER } = import.meta.env; -function truthyStringOrNull(value: any) { - if (typeof value === "string" && value) { - return value; - } - return null; +function truthyStringOrNull(value: unknown) { + if (typeof value === "string" && value) { + return value; + } + return null; } const config: WormgineClientConfiguration = { - defaultHomeserver: truthyStringOrNull(VITE_DEFAULT_HOMESERVER), -} + defaultHomeserver: truthyStringOrNull(VITE_DEFAULT_HOMESERVER), +}; -export default config; \ No newline at end of file +export default config; diff --git a/src/components/gameui/weapon-select.module.css b/src/components/gameui/weapon-select.module.css index a94464f..9c099ce 100644 --- a/src/components/gameui/weapon-select.module.css +++ b/src/components/gameui/weapon-select.module.css @@ -1,42 +1,42 @@ .root { - position: relative; - width: 500px; - height: 500px; - top: 25vh; - ul { - margin: 0; - padding: 0; - width: 100%; - height: 100%; - border-radius: 250px; - background: rgba(63, 55, 55, 0.800); - } - margin-left: auto; - margin-right: auto; + position: relative; + width: 500px; + height: 500px; + top: 25vh; + ul { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + border-radius: 250px; + background: rgba(63, 55, 55, 0.8); + } + margin-left: auto; + margin-right: auto; } .weaponOption { - position: absolute; - display: inline; - button { - border: none; - margin-left: auto; - margin-top: auto; - border-radius: 75px; - width: 1; - height: 1; - transform-origin: 50% 50%; - transform: translate(-50%, -50%); - display: grid; - padding: 24px; - font-size: 13pt; - width: 128px; - height: 128px; - img { - margin-left: auto; - margin-right: auto; - margin-top: 1em; - width: 16px; - } + position: absolute; + display: inline; + button { + border: none; + margin-left: auto; + margin-top: auto; + border-radius: 75px; + width: 1; + height: 1; + transform-origin: 50% 50%; + transform: translate(-50%, -50%); + display: grid; + padding: 24px; + font-size: 13pt; + width: 128px; + height: 128px; + img { + margin-left: auto; + margin-right: auto; + margin-top: 1em; + width: 16px; } -} \ No newline at end of file + } +} diff --git a/src/components/gameui/weapon-select.tsx b/src/components/gameui/weapon-select.tsx index f59ab08..9b915e5 100644 --- a/src/components/gameui/weapon-select.tsx +++ b/src/components/gameui/weapon-select.tsx @@ -4,24 +4,37 @@ import styles from "./weapon-select.module.css"; import { pointOnRadius } from "../../utils"; export const WeaponSelector: FunctionalComponent<{ - weapons: IWeaponDefiniton[] - onWeaponPicked: (code: IWeaponCode) => void, -}> = ({weapons, onWeaponPicked}) => { - const radiansPerItem = (2*Math.PI) / weapons.length; - - return
-
    - {weapons.map((weapon, i) => { - const point = pointOnRadius(-250, 250, radiansPerItem * i, 250); - return
  • - -
  • }) - } -
+ + + + ); + })} +
-} \ No newline at end of file + ); +}; diff --git a/src/components/ingame-view.module.css b/src/components/ingame-view.module.css index 4cca9fe..05574d6 100644 --- a/src/components/ingame-view.module.css +++ b/src/components/ingame-view.module.css @@ -1,11 +1,11 @@ body { - overflow: hidden; + overflow: hidden; } .overlay { - position: fixed; - left: 0; - height: 0; - width: 100vw; - height: 100vh; -} \ No newline at end of file + position: fixed; + left: 0; + height: 0; + width: 100vw; + height: 100vh; +} diff --git a/src/components/ingame-view.tsx b/src/components/ingame-view.tsx index 2bfcb29..6b65ec1 100644 --- a/src/components/ingame-view.tsx +++ b/src/components/ingame-view.tsx @@ -1,24 +1,32 @@ -import { useEffect, useRef, useState } from 'preact/hooks' -import styles from './ingame-view.module.css' -import { Game } from '../game'; -import { NetGameInstance } from '../net/client'; -import { GameReactChannel } from '../interop/gamechannel'; -import { IWeaponDefiniton } from '../weapons/weapon'; -import { WeaponSelector } from './gameui/weapon-select'; +import { useEffect, useRef, useState } from "preact/hooks"; +import styles from "./ingame-view.module.css"; +import { Game } from "../game"; +import { NetGameInstance } from "../net/client"; +import { GameReactChannel } from "../interop/gamechannel"; +import { IWeaponDefiniton } from "../weapons/weapon"; +import { WeaponSelector } from "./gameui/weapon-select"; -export function IngameView({level, gameReactChannel, gameInstance}: {level: string, gameReactChannel: GameReactChannel, gameInstance?: NetGameInstance}) { +export function IngameView({ + level, + gameReactChannel, + gameInstance, +}: { + level: string; + gameReactChannel: GameReactChannel; + gameInstance?: NetGameInstance; +}) { const [game, setGame] = useState(); const ref = useRef(null); - const [weaponMenu, setWeaponMenu] = useState(null); + const [weaponMenu, setWeaponMenu] = useState(null); useEffect(() => { Game.create(window, level, gameReactChannel, gameInstance).then((game) => { - (window as unknown as {wormgine: Game}).wormgine = game; + (window as unknown as { wormgine: Game }).wormgine = game; game.loadResources().then(() => { - setGame(game) + setGame(game); }); - }) + }); }, []); - + useEffect(() => { if (!ref.current || !game) { return; @@ -43,15 +51,22 @@ export function IngameView({level, gameReactChannel, gameInstance}: {level: stri }; gameReactChannel.on("openWeaponMenu", fn); return () => gameReactChannel.off("openWeaponMenu", fn); - }, [gameReactChannel, weaponMenu]) + }, [gameReactChannel, weaponMenu]); - return <> -
- {weaponMenu && { - gameReactChannel.weaponMenuSelect(code); - setWeaponMenu(null); - }} />} -
-
- + return ( + <> +
+ {weaponMenu && ( + { + gameReactChannel.weaponMenuSelect(code); + setWeaponMenu(null); + }} + /> + )} +
+
+ + ); } diff --git a/src/components/lobby.tsx b/src/components/lobby.tsx index 33b215c..7d0bf51 100644 --- a/src/components/lobby.tsx +++ b/src/components/lobby.tsx @@ -4,128 +4,156 @@ import { GameStage } from "../net/models"; import { TeamGroup } from "../logic/teams"; import Logger from "../log"; -const logger = new Logger('Lobby'); - +const logger = new Logger("Lobby"); interface Props { - client?: NetGameClient, - onOpenIngame: (gameInstance: NetGameInstance) => void, - gameRoomId?: string, + client?: NetGameClient; + onOpenIngame: (gameInstance: NetGameInstance) => void; + gameRoomId?: string; } -export function Lobby({client, gameRoomId: initialGameRoomId, onOpenIngame}: Props) { - const [gameRoomId, setGameRoomId] = useState(initialGameRoomId); - const [error, setError] = useState(); - const [gameInstance, setGameInstance] = useState(); - - const lobbyLink = useMemo(() => - gameRoomId && `${window.location.origin}${window.location.pathname}?gameRoomId=${encodeURIComponent(gameRoomId)}` - , [gameRoomId]); +export function Lobby({ + client, + gameRoomId: initialGameRoomId, + onOpenIngame, +}: Props) { + const [gameRoomId, setGameRoomId] = useState( + initialGameRoomId, + ); + const [error, setError] = useState(); + const [gameInstance, setGameInstance] = useState(); - const createGameCallback = useCallback(() => { - if (!client) { - throw Error('No client in lobby screen!'); - } - client.createGameRoom({ - rules: { - winWhenOneGroupRemains: true, - }, - teams: [{ - name: 'Foobars', - group: TeamGroup.Red, - playerUserId: client.client.getUserId(), - worms: [{ - name: 'Rev', - health: 100, - maxHealth: 100, - }] - }] - }) - .then(roomId => setGameRoomId(roomId)) - .catch(ex => { - setError('Failed to create new game!'); - // This should show a proper error - console.error('Failed to create game', ex); - }); - }, [client]); + const lobbyLink = useMemo( + () => + gameRoomId && + `${window.location.origin}${window.location.pathname}?gameRoomId=${encodeURIComponent(gameRoomId)}`, + [gameRoomId], + ); - useEffect(() => { - if (!gameRoomId) { - return; - } - if (gameInstance) { - return; - } - if (!client) { - throw Error('No client in lobby screen!'); - } - logger.info('Loading game instance', gameRoomId); - client.joinGameRoom(gameRoomId).then((instance) => { - setGameInstance(instance); - }).catch((ex) => { - logger.error('Failed to load game', ex); - setError('Failed load existing game!'); - }); - }, [gameRoomId, gameInstance, client]); + const createGameCallback = useCallback(() => { + if (!client) { + throw Error("No client in lobby screen!"); + } + client + .createGameRoom({ + rules: { + winWhenOneGroupRemains: true, + }, + teams: [ + { + name: "Foobars", + group: TeamGroup.Red, + playerUserId: client.client.getUserId(), + worms: [ + { + name: "Rev", + health: 100, + maxHealth: 100, + }, + ], + }, + ], + }) + .then((roomId) => setGameRoomId(roomId)) + .catch((ex) => { + setError("Failed to create new game!"); + // This should show a proper error + console.error("Failed to create game", ex); + }); + }, [client]); - const startGame = useCallback(async () => { - if (!gameInstance) { - throw Error('Must have a game instance'); - } - try { - await gameInstance.startGame(); - onOpenIngame(gameInstance); - } catch (ex) { - logger.error('Failed to start game', ex); - setError('Failed to start game!'); - } - }, [gameInstance]); + useEffect(() => { + if (!gameRoomId) { + return; + } + if (gameInstance) { + return; + } + if (!client) { + throw Error("No client in lobby screen!"); + } + logger.info("Loading game instance", gameRoomId); + client + .joinGameRoom(gameRoomId) + .then((instance) => { + setGameInstance(instance); + }) + .catch((ex) => { + logger.error("Failed to load game", ex); + setError("Failed load existing game!"); + }); + }, [gameRoomId, gameInstance, client]); + const startGame = useCallback(async () => { if (!gameInstance) { - if (!gameRoomId) { - return
-

Game Lobby

- {error &&

{error}

} -

- This area is the staging area for a new networked game. -

- -
; - } - else { - return
-

Game Lobby

- {error &&

{error}

} -

- Loading lobby... -

-
; - } + throw Error("Must have a game instance"); } - - // TODO: Boot them straight to the game? - if (gameInstance.stage !== GameStage.Lobby) { - return
-

Game Lobby

-

Game is already in progress

-
; + try { + await gameInstance.startGame(); + onOpenIngame(gameInstance); + } catch (ex) { + logger.error("Failed to start game", ex); + setError("Failed to start game!"); } + }, [gameInstance]); + if (!gameInstance) { + if (!gameRoomId) { + return ( +
+

Game Lobby

+ {error &&

{error}

} +

This area is the staging area for a new networked game.

+ +
+ ); + } else { + return ( +
+

Game Lobby

+ {error &&

{error}

} +

Loading lobby...

+
+ ); + } + } - return
+ // TODO: Boot them straight to the game? + if (gameInstance.stage !== GameStage.Lobby) { + return ( +

Game Lobby

- {error &&

{error}

} -

- This area is the staging area for a new networked game. -

-

- You can invite players by sending them a link to {lobbyLink}. -

-
    - {Object.entries(gameInstance.members).map(([userId, displayname]) => { - return
  • {displayname} {userId === gameInstance.hostUserId ? 🌟 : null}
  • - })} -
- -
; -} \ No newline at end of file +

Game is already in progress

+
+ ); + } + + return ( +
+

Game Lobby

+ {error &&

{error}

} +

This area is the staging area for a new networked game.

+

+ You can invite players by sending them a link to{" "} + {lobbyLink}. +

+
    + {Object.entries(gameInstance.members).map(([userId, displayname]) => { + return ( +
  • + {displayname}{" "} + {userId === gameInstance.hostUserId ? ( + 🌟 + ) : null} +
  • + ); + })} +
+ +
+ ); +} diff --git a/src/components/menu.css b/src/components/menu.css index 8a8203e..2bfc3ae 100644 --- a/src/components/menu.css +++ b/src/components/menu.css @@ -1,52 +1,51 @@ .menu { - margin-left: auto; - margin-right: auto; - width: fit-content; - min-width: 33vw; + margin-left: auto; + margin-right: auto; + width: fit-content; + min-width: 33vw; } p { - font-size: 1.25em; + font-size: 1.25em; } button { - margin-left: 0.25em; - font-size: 1em; - padding: 0.5em 1em; - border-radius: 0.25em; + margin-left: 0.25em; + font-size: 1em; + padding: 0.5em 1em; + border-radius: 0.25em; } .borealis { - background: repeating-linear-gradient( - 45deg, - #289AD6, - #289AD6 10px, - #E27A00 10px, - #E27A00 20px, - #31358E 20px, - #31358E 30px - ); - font-weight: 600; - color: white; + background: repeating-linear-gradient( + 45deg, + #289ad6, + #289ad6 10px, + #e27a00 10px, + #e27a00 20px, + #31358e 20px, + #31358e 30px + ); + font-weight: 600; + color: white; } h1 { - font-size: 2.5em; + font-size: 2.5em; } - .levelPicker button { - font-size: 1.5em; + font-size: 1.5em; } li { - margin-bottom: 2em; - list-style: none; + margin-bottom: 2em; + list-style: none; } .error { - padding: 1em; - border: 1px solid black; - background: grey; - color: rgb(255, 77, 77); -} \ No newline at end of file + padding: 1em; + border: 1px solid black; + background: grey; + color: rgb(255, 77, 77); +} diff --git a/src/components/menu.tsx b/src/components/menu.tsx index eaa65f5..e00d55d 100644 --- a/src/components/menu.tsx +++ b/src/components/menu.tsx @@ -7,94 +7,153 @@ import AccountMenu from "./menus/account"; import { OverlayTest } from "./menus/overlaytest"; interface Props { - onNewGame: (level: string) => void, - reloadClient: () => void, - client?: NetGameClient, + onNewGame: (level: string) => void; + reloadClient: () => void; + client?: NetGameClient; } const buildNumber = import.meta.env.VITE_BUILD_NUMBER; const buildCommit = import.meta.env.VITE_BUILD_COMMIT; -const lastCommit = localStorage.getItem('wormgine_last_commit'); +const lastCommit = localStorage.getItem("wormgine_last_commit"); - -function mainMenu(onStartNewGame: (level: string) => void, setCurrentMenu: (menu: GameMenu) => void, clientReady?: boolean) { - return
-

Wormgine Debug Build

-

- The game is still in heavy development, this site is updated with the latest builds as they - are built. -

-

Test Scenarios

-

- Each of these levels are used to test certain engine features. Gameplay Demo is the most complete, - as it demonstrates a match between two human players. -

-
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -

    Network options (requires configured client)

    -
  • - -
  • -
  • - -
  • -
  • - (Requires configured client) -
  • -

    Developer tools

    -
  • - -
  • -
- -

- You can check out the source code over on GitHub. -

-

- Assets are used under various licences -

-
; +function mainMenu( + onStartNewGame: (level: string) => void, + setCurrentMenu: (menu: GameMenu) => void, + clientReady?: boolean, +) { + return ( +
+

Wormgine Debug Build

+

+ The game is still in heavy development, this site is updated with the + latest builds as they are built. +

+

Test Scenarios

+

+ Each of these levels are used to test certain engine features. Gameplay + Demo is the most complete, as it demonstrates a match between two human + players. +

+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +

    Network options (requires configured client)

    +
  • + +
  • +
  • + +
  • +
  • + {" "} + (Requires configured client) +
  • +

    Developer tools

    +
  • + +
  • +
+ +

+ You can check out the source code over on{" "} + + GitHub + + . +

+

+ + Assets are used under various licences + +

+
+ ); } -export function Menu({onNewGame, client, reloadClient}: Props) { - const [currentMenu, setCurrentMenu] = useState(GameMenu.MainMenu); - const [clientReady, setClientReady] = useState(client?.ready); +export function Menu({ onNewGame, client, reloadClient }: Props) { + const [currentMenu, setCurrentMenu] = useState(GameMenu.MainMenu); + const [clientReady, setClientReady] = useState(client?.ready); - client?.once('sync', () => { - setClientReady(true); - }) + client?.once("sync", () => { + setClientReady(true); + }); - const onStartNewGame = useCallback((level: string) => { - localStorage.setItem("wormgine_last_commit", buildCommit); - onNewGame(level); - }, [onNewGame]); + const onStartNewGame = useCallback( + (level: string) => { + localStorage.setItem("wormgine_last_commit", buildCommit); + onNewGame(level); + }, + [onNewGame], + ); - if (currentMenu === GameMenu.MainMenu) { - return mainMenu(onStartNewGame, setCurrentMenu, clientReady); - } else if (currentMenu === GameMenu.AccountMenu) { - return ; - } else if (currentMenu === GameMenu.TeamEditor) { - return ; - } else if (currentMenu === GameMenu.OverlayTest) { - return ; - } + if (currentMenu === GameMenu.MainMenu) { + return mainMenu(onStartNewGame, setCurrentMenu, clientReady); + } else if (currentMenu === GameMenu.AccountMenu) { + return ( + + ); + } else if (currentMenu === GameMenu.TeamEditor) { + return ( + + ); + } else if (currentMenu === GameMenu.OverlayTest) { + return ; + } - return null; -} \ No newline at end of file + return null; +} diff --git a/src/components/menus/account.module.css b/src/components/menus/account.module.css index 66eda1d..e7f3830 100644 --- a/src/components/menus/account.module.css +++ b/src/components/menus/account.module.css @@ -1,5 +1,5 @@ .avatar { - width: 64px; - height: 64px; - border-radius: 32px; -} \ No newline at end of file + width: 64px; + height: 64px; + border-radius: 32px; +} diff --git a/src/components/menus/account.tsx b/src/components/menus/account.tsx index fecfeeb..4247c83 100644 --- a/src/components/menus/account.tsx +++ b/src/components/menus/account.tsx @@ -5,97 +5,144 @@ import config from "../config"; import styles from "./account.module.css"; interface Props { - client: NetGameClient|undefined, - setCurrentMenu: (menu: GameMenu) => void, - reloadClient: () => void, + client: NetGameClient | undefined; + setCurrentMenu: (menu: GameMenu) => void; + reloadClient: () => void; } const AVATAR_PX = 64; -function LoggedInView({client}: {client: NetGameClient}) { - const [displayname, setDisplayName] = useState(); - const [authenticatedAvatarBlob, setAvatarBlobUrl] = useState(); +function LoggedInView({ client }: { client: NetGameClient }) { + const [displayname, setDisplayName] = useState(); + const [authenticatedAvatarBlob, setAvatarBlobUrl] = useState(); - useEffect(() => { - client.client.getProfileInfo(client.client.getUserId()!).then((data) => { - setDisplayName(data.displayname ?? client.client.getUserIdLocalpart()!); - const avatarUrl = data.avatar_url && client.client.mxcUrlToHttp(data.avatar_url, AVATAR_PX, AVATAR_PX, "scale", false, true, true); - if (avatarUrl) { - fetch(avatarUrl, { headers: { - Authorization: `Bearer ${client.client.getAccessToken()}` - }}).then((req) => { - if (!req.ok) { - throw Error('Request not OK'); - } - return req.blob(); - }).then((blob) => { - setAvatarBlobUrl(URL.createObjectURL(blob)); - }); + useEffect(() => { + client.client.getProfileInfo(client.client.getUserId()!).then((data) => { + setDisplayName(data.displayname ?? client.client.getUserIdLocalpart()!); + const avatarUrl = + data.avatar_url && + client.client.mxcUrlToHttp( + data.avatar_url, + AVATAR_PX, + AVATAR_PX, + "scale", + false, + true, + true, + ); + if (avatarUrl) { + fetch(avatarUrl, { + headers: { + Authorization: `Bearer ${client.client.getAccessToken()}`, + }, + }) + .then((req) => { + if (!req.ok) { + throw Error("Request not OK"); } - }); - }, [client]); + return req.blob(); + }) + .then((blob) => { + setAvatarBlobUrl(URL.createObjectURL(blob)); + }); + } + }); + }, [client]); - if (!displayname) { - return null; - } + if (!displayname) { + return null; + } - return
-

You are logged in as {displayname}

- + return ( +
+

+ You are logged in as {displayname} +

+
+ ); } -export default function AccountMenu({client, setCurrentMenu, reloadClient}: Props) { - const [loginInProgress, setLoginInProgress] = useState(false); - const [error, setError] = useState(); - const onSubmit = useCallback(async (e: SubmitEvent) => { - e.preventDefault(); - if (!config.defaultHomeserver) { - return; - } - try { - setLoginInProgress(true); - const target = e.target as HTMLFormElement; - const username = (target.elements.namedItem('username') as HTMLInputElement).value; - const password = (target.elements.namedItem('password') as HTMLInputElement).value; - const { accessToken } = await NetGameClient.login(config.defaultHomeserver, username, password); - localStorage.setItem('wormgine_client_config', JSON.stringify({ - accessToken, - baseUrl: config.defaultHomeserver, - } as NetClientConfig)); - reloadClient(); - } catch (ex) { - setError((ex as Error).toString()); - } finally { - setLoginInProgress(false); - } - }, [reloadClient]); +export default function AccountMenu({ + client, + setCurrentMenu, + reloadClient, +}: Props) { + const [loginInProgress, setLoginInProgress] = useState(false); + const [error, setError] = useState(); + const onSubmit = useCallback( + async (e: SubmitEvent) => { + e.preventDefault(); + if (!config.defaultHomeserver) { + return; + } + try { + setLoginInProgress(true); + const target = e.target as HTMLFormElement; + const username = ( + target.elements.namedItem("username") as HTMLInputElement + ).value; + const password = ( + target.elements.namedItem("password") as HTMLInputElement + ).value; + const { accessToken } = await NetGameClient.login( + config.defaultHomeserver, + username, + password, + ); + localStorage.setItem( + "wormgine_client_config", + JSON.stringify({ + accessToken, + baseUrl: config.defaultHomeserver, + } as NetClientConfig), + ); + reloadClient(); + } catch (ex) { + setError((ex as Error).toString()); + } finally { + setLoginInProgress(false); + } + }, + [reloadClient], + ); - let content; - if (client?.ready) { - content = ; - } else if (client?.ready === false) { - content =

Account information is stored and in the progress of connecting.

; - } else if (!client && !config.defaultHomeserver) { - content =

This instance is not configured for network play.

- } else { - content =
+ let content; + if (client?.ready) { + content = ; + } else if (client?.ready === false) { + content = ( +

Account information is stored and in the progress of connecting.

+ ); + } else if (!client && !config.defaultHomeserver) { + content =

This instance is not configured for network play.

; + } else { + content = ( +

- You may log into an existing account below by specifying a username and password. Future versions will allow you to - create an account / login to other servers. + You may log into an existing account below by specifying a username + and password. Future versions will allow you to create an account / + login to other servers.

- {error &&

Error: {error}

} + {error && ( +

+ Error: {error} +

+ )}
- - - + + +
-
; - } +
+ ); + } - return
-

Wormgine Account

- - {content} -
; -} \ No newline at end of file + return ( +
+

Wormgine Account

+ + {content} +
+ ); +} diff --git a/src/components/menus/overlaytest.tsx b/src/components/menus/overlaytest.tsx index 5968f2f..ead023d 100644 --- a/src/components/menus/overlaytest.tsx +++ b/src/components/menus/overlaytest.tsx @@ -1,16 +1,27 @@ import { FunctionalComponent } from "preact"; -import { IWeaponCode, IWeaponDefiniton } from "../../weapons/weapon"; -import styles from "./weapon-select.module.css"; import { useState } from "preact/hooks"; import { WeaponSelector } from "../gameui/weapon-select"; import { WeaponBazooka, WeaponGrenade, WeaponShotgun } from "../../weapons"; export const OverlayTest: FunctionalComponent = () => { - const [weaponMenuOpen, setWeaponMenuOpen] = useState(false); - return
- - {weaponMenuOpen && setWeaponMenuOpen(false)} />} -
; -} \ No newline at end of file + const [weaponMenuOpen, setWeaponMenuOpen] = useState(false); + return ( +
+ + {weaponMenuOpen && ( + setWeaponMenuOpen(false)} + /> + )} +
+ ); +}; diff --git a/src/components/menus/team-editor.tsx b/src/components/menus/team-editor.tsx index bdb5914..1394fd1 100644 --- a/src/components/menus/team-editor.tsx +++ b/src/components/menus/team-editor.tsx @@ -1,13 +1,11 @@ export default function AccountMenu() { - - return
-

Team Editor

-

- You can edit teams on this page. Teams are saved to your account. -

-
- -
- -
; -} \ No newline at end of file + return ( +
+

Team Editor

+

You can edit teams on this page. Teams are saved to your account.

+
+ +
+
+ ); +} diff --git a/src/components/menus/types.ts b/src/components/menus/types.ts index 72ff125..8655ab4 100644 --- a/src/components/menus/types.ts +++ b/src/components/menus/types.ts @@ -1,6 +1,6 @@ export enum GameMenu { - MainMenu, - AccountMenu, - TeamEditor, - OverlayTest, -} \ No newline at end of file + MainMenu, + AccountMenu, + TeamEditor, + OverlayTest, +} diff --git a/src/consts.ts b/src/consts.ts index 81a056a..264036c 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -1 +1 @@ -export const HEALTH_CHANGE_TENSION_TIMER = 75; \ No newline at end of file +export const HEALTH_CHANGE_TENSION_TIMER = 75; diff --git a/src/entities/background.ts b/src/entities/background.ts index fe652a8..d988623 100644 --- a/src/entities/background.ts +++ b/src/entities/background.ts @@ -1,4 +1,13 @@ -import { ColorSource, Container, Geometry, Graphics, Mesh, Point, Shader, UPDATE_PRIORITY } from "pixi.js"; +import { + ColorSource, + Container, + Geometry, + Graphics, + Mesh, + Point, + Shader, + UPDATE_PRIORITY, +} from "pixi.js"; import { IGameEntity } from "./entity"; import { GradientShader } from "../shaders"; import { BitmapTerrain } from "./bitmapTerrain"; @@ -7,10 +16,10 @@ import { Coordinate } from "../utils"; import globalFlags from "../flags"; interface RainParticle { - position: Point; - length: number; - angle: number; - speed: number; + position: Point; + length: number; + angle: number; + speed: number; } const MAX_RAIN_LENGTH = 15; @@ -21,113 +30,149 @@ const RAINDROP_COUNT = 350; * Background of the game world. Includes rain particles. */ export class Background implements IGameEntity { - static create(viewWidth: number, viewHeight: number, viewport: Viewport, color: [number, number, number, number], terrain: BitmapTerrain): Background { - return new Background(viewWidth, viewHeight, viewport, color, terrain); - } - private rainSpeed = 15; - private rainSpeedVariation = 1; - // TODO: Constrain to size of screen. - private windDirection = 5; - private rainColor: ColorSource = 'rgba(100,100,100,0.33)'; - priority = UPDATE_PRIORITY.LOW; + static create( + viewWidth: number, + viewHeight: number, + viewport: Viewport, + color: [number, number, number, number], + terrain: BitmapTerrain, + ): Background { + return new Background(viewWidth, viewHeight, viewport, color, terrain); + } + private rainSpeed = 15; + private rainSpeedVariation = 1; + // TODO: Constrain to size of screen. + private windDirection = 5; + private rainColor: ColorSource = "rgba(100,100,100,0.33)"; + priority = UPDATE_PRIORITY.LOW; + + private gradientMesh: Mesh; - private gradientMesh: Mesh; + private rainGraphic = new Graphics(); + private rainParticles: RainParticle[] = []; - private rainGraphic = new Graphics(); - private rainParticles: RainParticle[] = []; + private constructor( + viewWidth: number, + viewHeight: number, + private viewport: Viewport, + color: [number, number, number, number], + private readonly terrain: BitmapTerrain, + ) { + const halfViewWidth = viewWidth / 2; + const halfViewHeight = viewHeight / 2; + const geometry = new Geometry({ + attributes: { + aVertexPosition: [ + -halfViewWidth, + -halfViewHeight, // x, y + halfViewWidth, + -halfViewHeight, // x, y + halfViewWidth, + halfViewHeight, + -halfViewWidth, + halfViewHeight, + ], // x, y + }, + indexBuffer: [0, 1, 2, 0, 2, 3], + }); - private constructor(viewWidth: number, viewHeight: number, private viewport: Viewport, color: [number, number, number, number], private readonly terrain: BitmapTerrain) { - const halfViewWidth = viewWidth / 2; - const halfViewHeight = viewHeight / 2; - const geometry = new Geometry({ - attributes: { - aVertexPosition: - [-halfViewWidth, -halfViewHeight, // x, y - halfViewWidth, -halfViewHeight, // x, y - halfViewWidth, halfViewHeight, - -halfViewWidth, halfViewHeight], // x, y + this.gradientMesh = new Mesh({ + geometry, + shader: new Shader({ + glProgram: GradientShader, + resources: { + uniforms: { + uStartColor: { + value: new Float32Array(color.map((v) => v / 255)), + type: "vec4", }, - indexBuffer: [0, 1, 2, 0, 2, 3] - }); + }, + }, + }), + }); - this.gradientMesh = new Mesh({ - geometry, - shader: new Shader({ - glProgram: GradientShader, - resources: { - uniforms: { - uStartColor: { value: new Float32Array(color.map(v => v/255)), type: 'vec4' }, - } - } - }) - }); - - this.gradientMesh.position.set(halfViewWidth, halfViewHeight); - this.rainGraphic.position.set(0,0); - // Create some rain - const rainCount = Math.ceil(RAINDROP_COUNT * (viewWidth/1920)); - for (let rainIndex = 0; rainIndex < rainCount; rainIndex += 1) { - this.addRainParticle(); - } - } - - addRainParticle() { - const x = this.viewport.center.x + Math.round(Math.random()*this.viewport.screenWidth) - this.viewport.screenWidth/2; - const y = this.viewport.center.y + (0-Math.round(Math.random()*this.viewport.screenHeight) - 200); - this.rainParticles.push({ - position: new Point(x,y), - length: MIN_RAIN_LENGTH + Math.round(Math.random()*(MAX_RAIN_LENGTH-MIN_RAIN_LENGTH)), - angle: (Math.random()-0.5)* 15, - speed: (this.rainSpeed*(0.5 + (Math.random()*this.rainSpeedVariation))) - }); + this.gradientMesh.position.set(halfViewWidth, halfViewHeight); + this.rainGraphic.position.set(0, 0); + // Create some rain + const rainCount = Math.ceil(RAINDROP_COUNT * (viewWidth / 1920)); + for (let rainIndex = 0; rainIndex < rainCount; rainIndex += 1) { + this.addRainParticle(); } + } - addToWorld(worldContainer: Container, viewport: Container) { - worldContainer.addChildAt(this.gradientMesh, 0); - viewport.addChildAt(this.rainGraphic, 0); - } + addRainParticle() { + const x = + this.viewport.center.x + + Math.round(Math.random() * this.viewport.screenWidth) - + this.viewport.screenWidth / 2; + const y = + this.viewport.center.y + + (0 - Math.round(Math.random() * this.viewport.screenHeight) - 200); + this.rainParticles.push({ + position: new Point(x, y), + length: + MIN_RAIN_LENGTH + + Math.round(Math.random() * (MAX_RAIN_LENGTH - MIN_RAIN_LENGTH)), + angle: (Math.random() - 0.5) * 15, + speed: this.rainSpeed * (0.5 + Math.random() * this.rainSpeedVariation), + }); + } - get destroyed() { - return this.gradientMesh.destroyed; - } + addToWorld(worldContainer: Container, viewport: Container) { + worldContainer.addChildAt(this.gradientMesh, 0); + viewport.addChildAt(this.rainGraphic, 0); + } - update(): void { - if (globalFlags.DebugView) { - // Don't render during debug view. - this.rainGraphic.clear(); - return; - } - this.rainGraphic.clear(); - for (let rainIndex = 0; rainIndex < this.rainParticles.length; rainIndex += 1) { - const particle = this.rainParticles[rainIndex]; - // Hit water - if (particle.position.y > 900) { - // TODO: Properly detect terrain - this.rainParticles.splice(rainIndex, 1); - // TODO: And splash - this.addRainParticle(); - continue; - } + get destroyed() { + return this.gradientMesh.destroyed; + } - if (this.terrain.pointInTerrain(Coordinate.fromScreen(particle.position.x, particle.position.y))) { - // TODO: Properly detect terrain - this.rainParticles.splice(rainIndex, 1); - // TODO: And splash - this.addRainParticle(); - continue; - } - const anglularVelocity = particle.angle*0.1; - particle.position.x += this.windDirection + anglularVelocity; - particle.position.y += particle.speed; - const lengthX = particle.position.x + (anglularVelocity*5); - const lengthY = particle.position.y - particle.length + anglularVelocity; - this.rainGraphic.stroke({ width: 2, color: this.rainColor }).moveTo( - particle.position.x, lengthY - ).lineTo(lengthX, particle.position.y) - } + update(): void { + if (globalFlags.DebugView) { + // Don't render during debug view. + this.rainGraphic.clear(); + return; } + this.rainGraphic.clear(); + for ( + let rainIndex = 0; + rainIndex < this.rainParticles.length; + rainIndex += 1 + ) { + const particle = this.rainParticles[rainIndex]; + // Hit water + if (particle.position.y > 900) { + // TODO: Properly detect terrain + this.rainParticles.splice(rainIndex, 1); + // TODO: And splash + this.addRainParticle(); + continue; + } - destroy(): void { - this.gradientMesh.destroy(); + if ( + this.terrain.pointInTerrain( + Coordinate.fromScreen(particle.position.x, particle.position.y), + ) + ) { + // TODO: Properly detect terrain + this.rainParticles.splice(rainIndex, 1); + // TODO: And splash + this.addRainParticle(); + continue; + } + const anglularVelocity = particle.angle * 0.1; + particle.position.x += this.windDirection + anglularVelocity; + particle.position.y += particle.speed; + const lengthX = particle.position.x + anglularVelocity * 5; + const lengthY = particle.position.y - particle.length + anglularVelocity; + this.rainGraphic + .stroke({ width: 2, color: this.rainColor }) + .moveTo(particle.position.x, lengthY) + .lineTo(lengthX, particle.position.y); } -} \ No newline at end of file + } + + destroy(): void { + this.gradientMesh.destroy(); + } +} diff --git a/src/entities/bitmapTerrain.ts b/src/entities/bitmapTerrain.ts index 7b70da6..0fb06d8 100644 --- a/src/entities/bitmapTerrain.ts +++ b/src/entities/bitmapTerrain.ts @@ -1,13 +1,35 @@ -import { UPDATE_PRIORITY, Container, Graphics, Rectangle, Texture, Sprite } from "pixi.js"; +import { + UPDATE_PRIORITY, + Container, + Graphics, + Rectangle, + Texture, + Sprite, +} from "pixi.js"; import { IPhysicalEntity } from "./entity"; -import { generateQuadTreeFromTerrain, imageDataToTerrainBoundaries } from "../terrain"; +import { + generateQuadTreeFromTerrain, + imageDataToTerrainBoundaries, +} from "../terrain"; import Flags from "../flags"; -import { collisionGroupBitmask, CollisionGroups, GameWorld, PIXELS_PER_METER, RapierPhysicsObject } from "../world"; -import { Collider, ColliderDesc, RigidBody, RigidBodyDesc, Vector2 } from "@dimforge/rapier2d-compat"; +import { + collisionGroupBitmask, + CollisionGroups, + GameWorld, + PIXELS_PER_METER, + RapierPhysicsObject, +} from "../world"; +import { + Collider, + ColliderDesc, + RigidBody, + RigidBodyDesc, + Vector2, +} from "@dimforge/rapier2d-compat"; import { Coordinate, MetersValue } from "../utils/coodinate"; import Logger from "../log"; -const logger = new Logger('BitmapTerrain'); +const logger = new Logger("BitmapTerrain"); export type OnDamage = () => void; @@ -15,296 +37,416 @@ export type OnDamage = () => void; * The terrain that objects sit upon. May be damanged by entities. */ export class BitmapTerrain implements IPhysicalEntity { - public readonly priority = UPDATE_PRIORITY.LOW; - private static readonly collisionBitmask = collisionGroupBitmask(CollisionGroups.WorldObjects, [CollisionGroups.Terrain, CollisionGroups.WorldObjects, CollisionGroups.Player]); - - public get destroyed() { - // Terrain cannot be destroyed. - return false; - } - - private readonly gfx: Graphics = new Graphics(); - private parts: RapierPhysicsObject[] = []; - - private bounds: Rectangle; - - private readonly foregroundCanvas: HTMLCanvasElement; - private readonly backgroundCanvas: HTMLCanvasElement; - private texture: Texture; - private textureBg: Texture; - private readonly sprite: Sprite; - private readonly spriteBackdrop: Sprite; - // collider.handle -> fn - private registeredDamageFunctions = new Map(); - - static create(viewWidth: number, viewHeight: number, gameWorld: GameWorld, texture: Texture) { - return new BitmapTerrain(viewWidth, viewHeight, gameWorld, texture); + public readonly priority = UPDATE_PRIORITY.LOW; + private static readonly collisionBitmask = collisionGroupBitmask( + CollisionGroups.WorldObjects, + [ + CollisionGroups.Terrain, + CollisionGroups.WorldObjects, + CollisionGroups.Player, + ], + ); + + public get destroyed() { + // Terrain cannot be destroyed. + return false; + } + + private readonly gfx: Graphics = new Graphics(); + private parts: RapierPhysicsObject[] = []; + + private bounds: Rectangle; + + private readonly foregroundCanvas: HTMLCanvasElement; + private readonly backgroundCanvas: HTMLCanvasElement; + private texture: Texture; + private textureBg: Texture; + private readonly sprite: Sprite; + private readonly spriteBackdrop: Sprite; + // collider.handle -> fn + private registeredDamageFunctions = new Map(); + + static create( + viewWidth: number, + viewHeight: number, + gameWorld: GameWorld, + texture: Texture, + ) { + return new BitmapTerrain(viewWidth, viewHeight, gameWorld, texture); + } + + static drawToCanvas(viewWidth: number, viewHeight: number, texture: Texture) { + const canvas = document.createElement("canvas"); + canvas.width = viewWidth; + canvas.height = viewHeight; + const context = canvas.getContext("2d"); + if (!context) { + throw Error("Failed to get render context of canvas"); } - - static drawToCanvas(viewWidth: number, viewHeight: number, texture: Texture) { - const canvas = document.createElement('canvas') - canvas.width = viewWidth; - canvas.height = viewHeight; - const context = canvas.getContext('2d'); - if (!context) { - throw Error('Failed to get render context of canvas'); - } - const bitmap = texture.source.resource; - context.drawImage(bitmap as CanvasImageSource, (viewWidth / 2) - (texture.width / 2), viewHeight - texture.height); - return canvas; + const bitmap = texture.source.resource; + context.drawImage( + bitmap as CanvasImageSource, + viewWidth / 2 - texture.width / 2, + viewHeight - texture.height, + ); + return canvas; + } + + private constructor( + viewWidth: number, + viewHeight: number, + private readonly gameWorld: GameWorld, + texture: Texture, + ) { + this.foregroundCanvas = BitmapTerrain.drawToCanvas( + viewWidth, + viewHeight, + texture, + ); + this.texture = Texture.from(this.foregroundCanvas, true); + this.sprite = new Sprite(this.texture); + this.sprite.anchor.x = 0; + this.sprite.anchor.y = 0; + + // Somehow make rain fall infront of this. + this.backgroundCanvas = BitmapTerrain.drawToCanvas( + viewWidth, + viewHeight, + texture, + ); + this.textureBg = Texture.from(this.foregroundCanvas, true); + this.spriteBackdrop = new Sprite( + Texture.from(this.textureBg._source, true), + ); + this.spriteBackdrop.anchor.x = 0; + this.spriteBackdrop.anchor.y = 0; + this.spriteBackdrop.tint = "0x222222"; + + this.bounds = new Rectangle( + Number.MAX_SAFE_INTEGER, + Number.MAX_SAFE_INTEGER, + 0, + 0, + ); + + Flags.on("toggleDebugView", (value) => { + if (!value) { + this.gfx.clear(); + } + }); + + // Calculate bounding boxes + this.calculateBoundaryVectors(); + } + + addToWorld(parent: Container) { + parent.addChild(this.spriteBackdrop, this.sprite, this.gfx); + } + + calculateBoundaryVectors( + boundaryX = 0, + boundaryY = 0, + boundaryWidth = this.foregroundCanvas.width, + boundaryHeight = this.foregroundCanvas.height, + ) { + console.time("Generating terrain"); + const context = this.foregroundCanvas.getContext("2d"); + if (!context) { + throw Error("Failed to get render context of canvas"); } - private constructor(viewWidth: number, viewHeight: number, private readonly gameWorld: GameWorld, texture: Texture) { - this.foregroundCanvas = BitmapTerrain.drawToCanvas(viewWidth, viewHeight, texture); - this.texture = Texture.from(this.foregroundCanvas, true); - this.sprite = new Sprite(this.texture); - this.sprite.anchor.x = 0; - this.sprite.anchor.y = 0; - - // Somehow make rain fall infront of this. - this.backgroundCanvas = BitmapTerrain.drawToCanvas(viewWidth, viewHeight, texture); - this.textureBg = Texture.from(this.foregroundCanvas, true); - this.spriteBackdrop = new Sprite(Texture.from(this.textureBg._source, true)); - this.spriteBackdrop.anchor.x = 0; - this.spriteBackdrop.anchor.y = 0; - this.spriteBackdrop.tint = '0x222222'; - - this.bounds = new Rectangle(Number.MAX_SAFE_INTEGER,Number.MAX_SAFE_INTEGER,0,0); - - Flags.on('toggleDebugView', (value) => { - if (!value) { - this.gfx.clear(); - } - }); - - // Calculate bounding boxes - this.calculateBoundaryVectors(); + // Remove everything within the boundaries + const removableBodies = this.parts.filter((b) => { + let tr = b.body.translation(); + tr = { x: tr.x * PIXELS_PER_METER, y: tr.y * PIXELS_PER_METER }; + return ( + tr.x >= boundaryX && + tr.x <= boundaryX + boundaryWidth && + tr.y >= boundaryY && + tr.y <= boundaryY + boundaryHeight + ); + }); + + for (const body of removableBodies) { + this.gameWorld.removeBody(body); + const damageFn = this.registeredDamageFunctions.get(body.collider.handle); + if (damageFn) { + this.registeredDamageFunctions.delete(body.collider.handle); + damageFn?.(); + } } - - addToWorld(parent: Container) { - parent.addChild(this.spriteBackdrop, this.sprite, this.gfx); - } - - calculateBoundaryVectors(boundaryX = 0, boundaryY = 0, boundaryWidth = this.foregroundCanvas.width, boundaryHeight = this.foregroundCanvas.height) { - console.time('Generating terrain'); - const context = this.foregroundCanvas.getContext('2d'); - if (!context) { - throw Error('Failed to get render context of canvas'); - } - - // Remove everything within the boundaries - const removableBodies = this.parts.filter( - (b) => { - let tr = b.body.translation(); - tr = { x: tr.x * PIXELS_PER_METER, y: tr.y * PIXELS_PER_METER }; - return (tr.x >= boundaryX && tr.x <= boundaryX + boundaryWidth) && - (tr.y >= boundaryY && tr.y <= boundaryY + boundaryHeight)} + // TODO: Fix this. + this.parts = this.parts.filter( + (b) => !removableBodies.some((rB) => b.body.handle === rB.body.handle), + ); + const imgData = context.getImageData( + boundaryX, + boundaryY, + boundaryWidth, + boundaryHeight, + ); + const { boundaries, boundingBox } = imageDataToTerrainBoundaries( + boundaryX, + boundaryY, + imgData, + ); + this.bounds = boundingBox; + + // Turn it into a quadtree of rects + const quadtreeRects = generateQuadTreeFromTerrain( + boundaries, + boundingBox.width, + boundingBox.height, + boundingBox.x, + boundingBox.y, + ); + logger.debug("Found", quadtreeRects.length, "quads in terrain"); + + // Now create the pieces + const newParts: RapierPhysicsObject[] = []; + for (const quad of quadtreeRects) { + const body = this.gameWorld.createRigidBodyCollider( + ColliderDesc.cuboid( + quad.width / (PIXELS_PER_METER * 2), + quad.height / (PIXELS_PER_METER * 2), ) - - - for (const body of removableBodies) { - this.gameWorld.removeBody(body); - const damageFn = this.registeredDamageFunctions.get(body.collider.handle); - if (damageFn) { - this.registeredDamageFunctions.delete(body.collider.handle); - damageFn?.(); - } - } - // TODO: Fix this. - this.parts = this.parts.filter(b => !removableBodies.some(rB => b.body.handle === rB.body.handle)); - const imgData = context.getImageData(boundaryX, boundaryY, boundaryWidth, boundaryHeight); - const { boundaries, boundingBox } = imageDataToTerrainBoundaries(boundaryX, boundaryY, imgData); - this.bounds = boundingBox; - - // Turn it into a quadtree of rects - const quadtreeRects = generateQuadTreeFromTerrain(boundaries, boundingBox.width, boundingBox.height, boundingBox.x, boundingBox.y); - logger.debug("Found", quadtreeRects.length, "quads in terrain"); - - // Now create the pieces - const newParts: RapierPhysicsObject[] = []; - for (const quad of quadtreeRects) { - const body = this.gameWorld.createRigidBodyCollider( - ColliderDesc.cuboid(quad.width/(PIXELS_PER_METER*2), quad.height/(PIXELS_PER_METER*2)) - .setCollisionGroups(BitmapTerrain.collisionBitmask) - .setSolverGroups(BitmapTerrain.collisionBitmask), - - RigidBodyDesc.fixed().setTranslation( - (quad.x + this.sprite.x)/PIXELS_PER_METER, (quad.y + this.sprite.y)/PIXELS_PER_METER) - ) - newParts.push(body); - } - this.parts.push(...newParts); - - this.gameWorld.addBody(this, ...newParts.map(p => p.collider)); - console.timeEnd("Generating terrain"); + .setCollisionGroups(BitmapTerrain.collisionBitmask) + .setSolverGroups(BitmapTerrain.collisionBitmask), + + RigidBodyDesc.fixed().setTranslation( + (quad.x + this.sprite.x) / PIXELS_PER_METER, + (quad.y + this.sprite.y) / PIXELS_PER_METER, + ), + ); + newParts.push(body); } + this.parts.push(...newParts); - onDamage(point: Vector2, radius: MetersValue) { - logger.debug(`Terrain took damaged (${point.x} ${point.y}`,radius); - const context = this.foregroundCanvas.getContext('2d'); - if (!context) { - throw Error('Failed to get context'); - } - - // Optmise this check! - const imageX = (point.x*PIXELS_PER_METER) - this.sprite.x; - const imageY = (point.y*PIXELS_PER_METER) - this.sprite.y; - const snapshotX = (imageX-radius.pixels) - 30; - const snapshotY = (imageY-radius.pixels) - 30; - const snapshotWidth = (radius.pixels*3); - const snapshotHeight = (radius.pixels*3); - - // Fetch the current image - const before = context.getImageData(snapshotX,snapshotY, snapshotWidth, snapshotHeight); - // Draw a circle - - // Give the exploded area a border - // context.fillStyle = 'green'; - // context.arc(imageX, imageY, radius + 15, 0, 2 * Math.PI); - // context.fill(); - - context.fillStyle = 'grey'; - context.beginPath(); - context.arc(imageX, imageY, radius.pixels, 0, 2 * Math.PI); - context.fill(); - - - const smallerRadius = radius.pixels / 3; - if (smallerRadius) { - const beforeBg = context.getImageData(snapshotX,snapshotY, snapshotWidth, snapshotHeight); - const contextBg = this.backgroundCanvas.getContext('2d'); - if (!contextBg) { - throw Error('Failed to get context'); - } - contextBg.fillStyle = 'grey'; - contextBg.beginPath(); - const offset = (radius.pixels/2) - smallerRadius; - contextBg.arc(offset + imageX, offset + imageY, smallerRadius, 0, 2 * Math.PI); - contextBg.fill(); - - const afterBg = contextBg.getImageData(snapshotX,snapshotY, snapshotWidth, snapshotHeight); - - // See what has changed, hopefully a red cricle! - for (let i = 0; i < before.data.length; i += 4) { - const oldDataValue = beforeBg.data[i]+beforeBg.data[i+1]+beforeBg.data[i+2]+beforeBg.data[i+3]; - const newDataValue = afterBg.data[i]+afterBg.data[i+1]+afterBg.data[i+2]+afterBg.data[i+3]; - if (oldDataValue !== newDataValue) { - // Zero the alpha channel for anything that has changed...like a red cricle - afterBg.data[i+0] = 0; - afterBg.data[i+1] = 0; - afterBg.data[i+2] = 0; - afterBg.data[i+3] = 0; - } - } - contextBg.putImageData(afterBg, snapshotX, snapshotY); - const newTex = Texture.from(this.backgroundCanvas); - this.spriteBackdrop.texture = newTex; - this.textureBg.destroy(); - this.textureBg = newTex; - } - - + this.gameWorld.addBody(this, ...newParts.map((p) => p.collider)); + console.timeEnd("Generating terrain"); + } - // Fetch the new image - const after = context.getImageData(snapshotX,snapshotY, snapshotWidth, snapshotHeight); - - // See what has changed, hopefully a red cricle! - for (let i = 0; i < before.data.length; i += 4) { - const oldDataValue = before.data[i]+before.data[i+1]+before.data[i+2]+before.data[i+3]; - const newDataValue = after.data[i]+after.data[i+1]+after.data[i+2]+after.data[i+3]; - if (oldDataValue !== newDataValue) { - // Zero the alpha channel for anything that has changed...like a red cricle - after.data[i+0] = 0; - after.data[i+1] = 0; - after.data[i+2] = 0; - after.data[i+3] = 0; - } - } - - - // Show the new image with our newly created hole. - context.putImageData(after, snapshotX, snapshotY); - - // Remember to recalculate the collision paths - this.calculateBoundaryVectors(snapshotX,snapshotY, snapshotWidth, snapshotHeight); - const newTex = Texture.from(this.foregroundCanvas); - this.sprite.texture = newTex; - this.texture.destroy(); - this.texture = newTex; + onDamage(point: Vector2, radius: MetersValue) { + logger.debug(`Terrain took damaged (${point.x} ${point.y}`, radius); + const context = this.foregroundCanvas.getContext("2d"); + if (!context) { + throw Error("Failed to get context"); } - public update(): void { - if (!Flags.DebugView) { - return; + // Optmise this check! + const imageX = point.x * PIXELS_PER_METER - this.sprite.x; + const imageY = point.y * PIXELS_PER_METER - this.sprite.y; + const snapshotX = imageX - radius.pixels - 30; + const snapshotY = imageY - radius.pixels - 30; + const snapshotWidth = radius.pixels * 3; + const snapshotHeight = radius.pixels * 3; + + // Fetch the current image + const before = context.getImageData( + snapshotX, + snapshotY, + snapshotWidth, + snapshotHeight, + ); + // Draw a circle + + // Give the exploded area a border + // context.fillStyle = 'green'; + // context.arc(imageX, imageY, radius + 15, 0, 2 * Math.PI); + // context.fill(); + + context.fillStyle = "grey"; + context.beginPath(); + context.arc(imageX, imageY, radius.pixels, 0, 2 * Math.PI); + context.fill(); + + const smallerRadius = radius.pixels / 3; + if (smallerRadius) { + const beforeBg = context.getImageData( + snapshotX, + snapshotY, + snapshotWidth, + snapshotHeight, + ); + const contextBg = this.backgroundCanvas.getContext("2d"); + if (!contextBg) { + throw Error("Failed to get context"); + } + contextBg.fillStyle = "grey"; + contextBg.beginPath(); + const offset = radius.pixels / 2 - smallerRadius; + contextBg.arc( + offset + imageX, + offset + imageY, + smallerRadius, + 0, + 2 * Math.PI, + ); + contextBg.fill(); + + const afterBg = contextBg.getImageData( + snapshotX, + snapshotY, + snapshotWidth, + snapshotHeight, + ); + + // See what has changed, hopefully a red cricle! + for (let i = 0; i < before.data.length; i += 4) { + const oldDataValue = + beforeBg.data[i] + + beforeBg.data[i + 1] + + beforeBg.data[i + 2] + + beforeBg.data[i + 3]; + const newDataValue = + afterBg.data[i] + + afterBg.data[i + 1] + + afterBg.data[i + 2] + + afterBg.data[i + 3]; + if (oldDataValue !== newDataValue) { + // Zero the alpha channel for anything that has changed...like a red cricle + afterBg.data[i + 0] = 0; + afterBg.data[i + 1] = 0; + afterBg.data[i + 2] = 0; + afterBg.data[i + 3] = 0; } + } + contextBg.putImageData(afterBg, snapshotX, snapshotY); + const newTex = Texture.from(this.backgroundCanvas); + this.spriteBackdrop.texture = newTex; + this.textureBg.destroy(); + this.textureBg = newTex; } - public getNearestTerrainPosition(point: Vector2, width: number, maxHeightDiff: number, xDirection = 0): {point: Vector2, fell: false}|{fell: true, point: null} { - // This needs a rethink, we really want to have it so that the character's "platform" is visualised - // by this algorithm. We want to figure out if we can move left or right, and if not if we're going to fall. - - // First filter for all the points within the range of the point. - const filteredPoints = this.parts.filter((p) => { - return p.body.translation().x < point.x + width + xDirection && - p.body.translation().x > point.x - width - xDirection && - p.body.translation().y > point.y - maxHeightDiff - }); - - // This needs to answer the following as quickly as possible: - - // Can we go to the next x point without falling? - let closestTerrainPoint: Vector2|undefined; - - const rejectedPoints: RigidBody[] = []; - - for (const terrain of filteredPoints) { - const terrainPoint = terrain.body.translation(); - const distY = Math.abs(terrainPoint.y - point.y); - if (xDirection < 0 && terrainPoint.x - point.x > xDirection) { - // If moving left, -3 - continue; - } - if (xDirection > 0 && terrainPoint.x - point.x < xDirection) { - // If moving right - continue; - } - if (distY > maxHeightDiff) { - rejectedPoints.push(terrain.body); - continue; - } - const distX = Math.abs(terrainPoint.x - (point.x + xDirection)); - const prevDistX = closestTerrainPoint ? Math.abs(closestTerrainPoint.x - (point.x + xDirection)) : Number.MAX_SAFE_INTEGER; - if (distX < prevDistX) { - closestTerrainPoint = terrainPoint; - } - } - - if (closestTerrainPoint) { - return { point: closestTerrainPoint, fell: false}; - } - - logger.verbose("Rejected points from getNearestTerrainPosition", rejectedPoints); - - // We have fallen, look for the closest X position to land on. - return { - point: null, - fell: true, - }; + // Fetch the new image + const after = context.getImageData( + snapshotX, + snapshotY, + snapshotWidth, + snapshotHeight, + ); + + // See what has changed, hopefully a red cricle! + for (let i = 0; i < before.data.length; i += 4) { + const oldDataValue = + before.data[i] + + before.data[i + 1] + + before.data[i + 2] + + before.data[i + 3]; + const newDataValue = + after.data[i] + + after.data[i + 1] + + after.data[i + 2] + + after.data[i + 3]; + if (oldDataValue !== newDataValue) { + // Zero the alpha channel for anything that has changed...like a red cricle + after.data[i + 0] = 0; + after.data[i + 1] = 0; + after.data[i + 2] = 0; + after.data[i + 3] = 0; + } } - public pointInTerrain(point: Coordinate): boolean { - // Avoid costly iteration with this one neat trick. - if (!this.bounds.contains(point.screenX, point.screenY)) { - return false; - } - return this.gameWorld.pointInAnyObject(point); + // Show the new image with our newly created hole. + context.putImageData(after, snapshotX, snapshotY); + + // Remember to recalculate the collision paths + this.calculateBoundaryVectors( + snapshotX, + snapshotY, + snapshotWidth, + snapshotHeight, + ); + const newTex = Texture.from(this.foregroundCanvas); + this.sprite.texture = newTex; + this.texture.destroy(); + this.texture = newTex; + } + + public update(): void { + if (!Flags.DebugView) { + return; + } + } + + public getNearestTerrainPosition( + point: Vector2, + width: number, + maxHeightDiff: number, + xDirection = 0, + ): { point: Vector2; fell: false } | { fell: true; point: null } { + // This needs a rethink, we really want to have it so that the character's "platform" is visualised + // by this algorithm. We want to figure out if we can move left or right, and if not if we're going to fall. + + // First filter for all the points within the range of the point. + const filteredPoints = this.parts.filter((p) => { + return ( + p.body.translation().x < point.x + width + xDirection && + p.body.translation().x > point.x - width - xDirection && + p.body.translation().y > point.y - maxHeightDiff + ); + }); + + // This needs to answer the following as quickly as possible: + + // Can we go to the next x point without falling? + let closestTerrainPoint: Vector2 | undefined; + + const rejectedPoints: RigidBody[] = []; + + for (const terrain of filteredPoints) { + const terrainPoint = terrain.body.translation(); + const distY = Math.abs(terrainPoint.y - point.y); + if (xDirection < 0 && terrainPoint.x - point.x > xDirection) { + // If moving left, -3 + continue; + } + if (xDirection > 0 && terrainPoint.x - point.x < xDirection) { + // If moving right + continue; + } + if (distY > maxHeightDiff) { + rejectedPoints.push(terrain.body); + continue; + } + const distX = Math.abs(terrainPoint.x - (point.x + xDirection)); + const prevDistX = closestTerrainPoint + ? Math.abs(closestTerrainPoint.x - (point.x + xDirection)) + : Number.MAX_SAFE_INTEGER; + if (distX < prevDistX) { + closestTerrainPoint = terrainPoint; + } } - public registerDamageListener(collider: Collider, fn: OnDamage) { - this.registeredDamageFunctions.set(collider.handle, fn); + if (closestTerrainPoint) { + return { point: closestTerrainPoint, fell: false }; } - destroy(): void { - throw new Error("Never destroyed."); + logger.verbose( + "Rejected points from getNearestTerrainPosition", + rejectedPoints, + ); + + // We have fallen, look for the closest X position to land on. + return { + point: null, + fell: true, + }; + } + + public pointInTerrain(point: Coordinate): boolean { + // Avoid costly iteration with this one neat trick. + if (!this.bounds.contains(point.screenX, point.screenY)) { + return false; } -} \ No newline at end of file + return this.gameWorld.pointInAnyObject(point); + } + + public registerDamageListener(collider: Collider, fn: OnDamage) { + this.registeredDamageFunctions.set(collider.handle, fn); + } + + destroy(): void { + throw new Error("Never destroyed."); + } +} diff --git a/src/entities/entity.ts b/src/entities/entity.ts index 9f6007c..df810b8 100644 --- a/src/entities/entity.ts +++ b/src/entities/entity.ts @@ -7,46 +7,44 @@ import { WeaponFireResult } from "../weapons/weapon"; * Base entity which all game objects implement */ export interface IGameEntity { + priority: UPDATE_PRIORITY; + destroyed: boolean; - priority: UPDATE_PRIORITY; - destroyed: boolean; - - update?(dt: number): void; - destroy(): void; + update?(dt: number): void; + destroy(): void; } export interface OnDamageOpts { - maxDamage?: number; + maxDamage?: number; } /** * Any entity that has attached bodies in the game. Unlike `physicsEntity` which * may be attached to one sprite and can be affected by other entites, this interface * merely provides functions for collisions and damage. - * + * * For instance, this may be used for terrain. */ export interface IPhysicalEntity extends IGameEntity { - - body?: RigidBody; - - /** - * - * @param other - * @param contactPoint - * @returns True if the collision should stop being processed - */ - onCollision?(other: IPhysicalEntity, contactPoint: Vector2|null): boolean; - - /** - * Called when another entity has damaged this entity. - * - * @param point The point from where the damage originates. - * @param radius The radius of the explosion. - */ - onDamage?(point: Vector2, radius: MetersValue, opts: OnDamageOpts): void + body?: RigidBody; + + /** + * + * @param other + * @param contactPoint + * @returns True if the collision should stop being processed + */ + onCollision?(other: IPhysicalEntity, contactPoint: Vector2 | null): boolean; + + /** + * Called when another entity has damaged this entity. + * + * @param point The point from where the damage originates. + * @param radius The radius of the explosion. + */ + onDamage?(point: Vector2, radius: MetersValue, opts: OnDamageOpts): void; } export interface IWeaponEntity { - onFireResult: Promise; -} \ No newline at end of file + onFireResult: Promise; +} diff --git a/src/entities/explosion.ts b/src/entities/explosion.ts index b40d923..6eaff04 100644 --- a/src/entities/explosion.ts +++ b/src/entities/explosion.ts @@ -1,4 +1,12 @@ -import { Color, ColorSource, Container, Graphics, Point, Ticker, UPDATE_PRIORITY } from "pixi.js"; +import { + Color, + ColorSource, + Container, + Graphics, + Point, + Ticker, + UPDATE_PRIORITY, +} from "pixi.js"; import { IGameEntity } from "./entity"; import { Sound } from "@pixi/sound"; import { MetersValue } from "../utils/coodinate"; @@ -6,138 +14,156 @@ import { AssetPack } from "../assets"; import { Vector } from "@dimforge/rapier2d-compat"; export interface ExplosionsOptions { - shrapnelMin: number, - shrapnelMax: number, - hue: ColorSource, - shrapnelHue: ColorSource, - playSound?: boolean, + shrapnelMin: number; + shrapnelMax: number; + hue: ColorSource; + shrapnelHue: ColorSource; + playSound?: boolean; } /** * Standard, reusable explosion effect. */ export class Explosion implements IGameEntity { - - public static readAssets({sounds}: AssetPack) { - Explosion.explosionSounds = - [ - sounds.explosion1, - sounds.explosion2, - sounds.explosion3 - ]; + public static readAssets({ sounds }: AssetPack) { + Explosion.explosionSounds = [ + sounds.explosion1, + sounds.explosion2, + sounds.explosion3, + ]; + } + + priority = UPDATE_PRIORITY.LOW; + private static explosionSounds: Sound[]; + private explosionMs = 500; + + public get destroyed() { + return this.gfx.destroyed; + } + + private readonly gfx: Graphics; + private timer: number; + private radiusExpandBy: number; + private shrapnel: { + point: Point; + speed: Point; + accel: Point; + radius: number; + alpha: number; + kind: "fire" | "pop"; + }[] = []; + + static create( + parent: Container, + point: Vector, + initialRadius: MetersValue, + opts: Partial = {}, + ) { + const ent = new Explosion(point, initialRadius, { + shrapnelMax: 25, + shrapnelMin: 8, + hue: 0xffffff, + shrapnelHue: 0xffffff, + ...opts, + }); + parent.addChild(ent.gfx); + return ent; + } + + private constructor( + point: Vector, + private initialRadius: MetersValue, + private readonly opts: ExplosionsOptions, + ) { + for ( + let index = 0; + index < + opts.shrapnelMin + + Math.ceil(Math.random() * (opts.shrapnelMax - opts.shrapnelMin)); + index++ + ) { + const xSpeed = Math.random() * 7 - 3.5; + const kind = Math.random() >= 0.75 ? "fire" : "pop"; + this.shrapnel.push({ + alpha: 1, + point: new Point(), + speed: new Point(xSpeed, Math.random() * 0.5 - 7), + accel: new Point( + // Invert the accel + -(xSpeed / 120), + Math.random(), + ), + radius: 2 + Math.random() * (kind === "pop" ? 8.5 : 4.5), + kind, + }); } - - priority = UPDATE_PRIORITY.LOW; - private static explosionSounds: Sound[]; - private explosionMs = 500; - - public get destroyed() { - return this.gfx.destroyed; + this.gfx = new Graphics({ position: { x: point.x, y: point.y } }); + this.timer = Ticker.targetFPMS * this.explosionMs; + this.radiusExpandBy = initialRadius.pixels * 0.2; + if (opts.playSound !== false) { + const soundIndex = Math.floor( + Math.random() * Explosion.explosionSounds.length, + ); + Explosion.explosionSounds[soundIndex].play(); } - - private readonly gfx: Graphics; - private timer: number; - private radiusExpandBy: number; - private shrapnel: { - point: Point, - speed: Point, - accel: Point, - radius: number, - alpha: number, - kind: "fire"|"pop" - }[] = [] - - static create(parent: Container, point: Vector, initialRadius: MetersValue, opts: Partial = { }) { - const ent = new Explosion(point, initialRadius, { - shrapnelMax: 25, - shrapnelMin: 8, - hue: 0xffffff, - shrapnelHue: 0xffffff, - ...opts - }); - parent.addChild(ent.gfx); - return ent; + } + + update(dt: number): void { + this.timer -= dt; + const ttl = this.timer / (Ticker.targetFPMS * this.explosionMs); + const ttlInverse = 1 - ttl; + + const expandBy = 1 - this.radiusExpandBy * ttl; + const radius = this.initialRadius.pixels + expandBy; + this.gfx.clear(); + + const shrapnelHue = new Color(0xaaaaaa).multiply(this.opts.shrapnelHue); + let anyShrapnelVisible = false; + for (const shrapnel of this.shrapnel) { + shrapnel.speed.x += shrapnel.accel.x * dt; + shrapnel.speed.y += shrapnel.accel.y * dt; + shrapnel.point.x += shrapnel.speed.x * dt; + shrapnel.point.y += shrapnel.speed.y * dt; + shrapnel.alpha = Math.max(0, shrapnel.alpha - Math.random() * dt * 0.03); + anyShrapnelVisible = + anyShrapnelVisible || shrapnel.point.y < 1200 || shrapnel.alpha < 0; + if (shrapnel.kind === "pop") { + this.gfx + .circle(shrapnel.point.x, shrapnel.point.y, shrapnel.radius) + .fill({ color: shrapnelHue, alpha: shrapnel.alpha }); + } else { + this.gfx + .circle(shrapnel.point.x, shrapnel.point.y, shrapnel.radius) + .fill({ color: 0xfd4301, alpha: shrapnel.alpha }); + this.gfx + .circle(shrapnel.point.x, shrapnel.point.y, shrapnel.radius - 3) + .fill({ color: 0xfde101, alpha: shrapnel.alpha }); + } } - private constructor(point: Vector, private initialRadius: MetersValue, private readonly opts: ExplosionsOptions) { - for (let index = 0; index < (opts.shrapnelMin + Math.ceil(Math.random() * (opts.shrapnelMax-opts.shrapnelMin))); index++) { - const xSpeed = (Math.random()*7)-3.5; - const kind = Math.random() >= 0.75 ? "fire" : "pop"; - this.shrapnel.push({ - alpha: 1, - point: new Point(), - speed: new Point( - xSpeed, - (Math.random()*0.5)-7, - ), - accel: new Point( - // Invert the accel - -(xSpeed/120), - Math.random(), - ), - radius: 2 + Math.random()*(kind === "pop" ? 8.5 : 4.5), - kind, - }) - - } - this.gfx = new Graphics({ position: {x: point.x, y: point.y}}); - this.timer = Ticker.targetFPMS * this.explosionMs; - this.radiusExpandBy = initialRadius.pixels * 0.2; - if (opts.playSound !== false) { - const soundIndex = Math.floor(Math.random()*Explosion.explosionSounds.length); - Explosion.explosionSounds[soundIndex].play(); - } + const hue = new Color(0xaaaaaa).multiply(this.opts.hue); + const outerHue = new Color(0xaaeeff).multiply(this.opts.hue); + + if (this.timer > 0) { + const alphaLarger = Math.round(ttl * 100) / 150; + const alphaSmaller = Math.round(ttl * 100) / 100; + this.gfx.circle(0, 0, radius).fill({ color: hue, alpha: alphaLarger }); + const outerWidth = ttlInverse * (radius * 2); + this.gfx + .ellipse(0, 0, outerWidth, radius / 1.5) + .fill({ color: outerHue, alpha: alphaSmaller }); + if (outerWidth - 20 > 0) { + this.gfx.ellipse(0, 0, outerWidth - 20, radius / 2).cut(); + } } + // Just wait for the shrapnel to leave the stage. - - update(dt: number): void { - this.timer -= dt; - const ttl = this.timer / (Ticker.targetFPMS*this.explosionMs); - const ttlInverse = 1-ttl; - - const expandBy = 1-(this.radiusExpandBy*ttl); - const radius = this.initialRadius.pixels + expandBy; - this.gfx.clear(); - - const shrapnelHue = new Color(0xaaaaaa).multiply(this.opts.shrapnelHue); - let anyShrapnelVisible = false; - for (const shrapnel of this.shrapnel) { - shrapnel.speed.x += shrapnel.accel.x*dt; - shrapnel.speed.y += shrapnel.accel.y*dt; - shrapnel.point.x += shrapnel.speed.x*dt; - shrapnel.point.y += shrapnel.speed.y*dt; - shrapnel.alpha = Math.max(0, shrapnel.alpha-(Math.random()*dt*0.03)); - anyShrapnelVisible = anyShrapnelVisible || shrapnel.point.y < 1200 || shrapnel.alpha < 0; - if (shrapnel.kind === "pop") { - this.gfx.circle(shrapnel.point.x, shrapnel.point.y, shrapnel.radius).fill({ color: shrapnelHue, alpha: shrapnel.alpha }); - } else { - this.gfx.circle(shrapnel.point.x, shrapnel.point.y, shrapnel.radius).fill({ color: 0xfd4301, alpha: shrapnel.alpha }); - this.gfx.circle(shrapnel.point.x, shrapnel.point.y, shrapnel.radius-3).fill({ color: 0xfde101, alpha: shrapnel.alpha }); - } - - } - - const hue = new Color(0xaaaaaa).multiply(this.opts.hue); - const outerHue = new Color(0xAAEEFF).multiply(this.opts.hue); - - if (this.timer > 0) { - const alphaLarger = Math.round(ttl * 100) / 150; - const alphaSmaller = Math.round(ttl * 100) / 100; - this.gfx.circle(0, 0, radius).fill({ color: hue, alpha: alphaLarger }); - const outerWidth = ttlInverse*(radius * 2); - this.gfx.ellipse(0, 0, outerWidth, radius/1.5).fill({color: outerHue, alpha: alphaSmaller }); - if (outerWidth - 20 > 0) { - this.gfx.ellipse(0, 0, outerWidth - 20, radius / 2).cut(); - } - } - // Just wait for the shrapnel to leave the stage. - - if (!anyShrapnelVisible) { - this.destroy(); - } + if (!anyShrapnelVisible) { + this.destroy(); } + } - destroy(): void { - this.gfx.destroy(); - } -} \ No newline at end of file + destroy(): void { + this.gfx.destroy(); + } +} diff --git a/src/entities/index.ts b/src/entities/index.ts index d8951d8..0526a8a 100644 --- a/src/entities/index.ts +++ b/src/entities/index.ts @@ -11,15 +11,15 @@ import { Worm } from "./playable/worm"; /** * Should be called during game startup to load all assets to * entitires that need them. - * @param assets + * @param assets */ export function readAssetsForEntities(assets: AssetPack): void { - BazookaShell.readAssets(assets); - Grenade.readAssets(assets); - Mine.readAssets(assets); - TestDummy.readAssets(assets); - Firework.readAssets(assets); - Worm.readAssets(assets); - Explosion.readAssets(assets); - PhysicsEntity.readAssets(assets); -} \ No newline at end of file + BazookaShell.readAssets(assets); + Grenade.readAssets(assets); + Mine.readAssets(assets); + TestDummy.readAssets(assets); + Firework.readAssets(assets); + Worm.readAssets(assets); + Explosion.readAssets(assets); + PhysicsEntity.readAssets(assets); +} diff --git a/src/entities/phys/bazookaShell.ts b/src/entities/phys/bazookaShell.ts index afaf933..0c779cd 100644 --- a/src/entities/phys/bazookaShell.ts +++ b/src/entities/phys/bazookaShell.ts @@ -1,95 +1,115 @@ -import { Container, Graphics, Sprite, Texture } from 'pixi.js'; +import { Container, Graphics, Sprite, Texture } from "pixi.js"; import { TimedExplosive } from "./timedExplosive"; -import { collisionGroupBitmask, CollisionGroups, GameWorld } from '../../world'; -import { ActiveEvents, ColliderDesc, RigidBodyDesc, Vector2, VectorOps } from "@dimforge/rapier2d-compat"; -import { Coordinate, MetersValue } from '../../utils/coodinate'; -import { AssetPack } from '../../assets'; -import { WormInstance } from '../../logic/teams'; -import { angleForVector } from '../../utils'; -import { EntityType } from '../type'; - +import { collisionGroupBitmask, CollisionGroups, GameWorld } from "../../world"; +import { + ActiveEvents, + ColliderDesc, + RigidBodyDesc, + Vector2, + VectorOps, +} from "@dimforge/rapier2d-compat"; +import { Coordinate, MetersValue } from "../../utils/coodinate"; +import { AssetPack } from "../../assets"; +import { WormInstance } from "../../logic/teams"; +import { angleForVector } from "../../utils"; +import { EntityType } from "../type"; /** * Standard shell, affected by wind. */ export class BazookaShell extends TimedExplosive { - public static readAssets(assets: AssetPack) { - BazookaShell.texture = assets.textures.bazooka; - } + public static readAssets(assets: AssetPack) { + BazookaShell.texture = assets.textures.bazooka; + } - private static readonly collisionBitmask = collisionGroupBitmask(CollisionGroups.WorldObjects, [CollisionGroups.Terrain, CollisionGroups.WorldObjects]); - private static texture: Texture; + private static readonly collisionBitmask = collisionGroupBitmask( + CollisionGroups.WorldObjects, + [CollisionGroups.Terrain, CollisionGroups.WorldObjects], + ); + private static texture: Texture; - private readonly force: Vector2 = VectorOps.zeros(); - private readonly gfx = new Graphics(); - - static create(parent: Container, gameWorld: GameWorld, position: Coordinate, force: Vector2, owner?: WormInstance) { - const ent = new BazookaShell(position, gameWorld, parent, force, owner); - gameWorld.addBody(ent, ent.physObject.collider); - parent.addChild(ent.sprite); - parent.addChild(ent.wireframe.renderable); - return ent; - } + private readonly force: Vector2 = VectorOps.zeros(); + private readonly gfx = new Graphics(); - private constructor(position: Coordinate, world: GameWorld, parent: Container, initialForce: Vector2, owner?: WormInstance) { - const sprite = new Sprite(BazookaShell.texture); - const body = world.createRigidBodyCollider( - ColliderDesc.cuboid( - 0.50, - 0.20).setActiveEvents(ActiveEvents.COLLISION_EVENTS) - .setCollisionGroups(BazookaShell.collisionBitmask) - .setSolverGroups(BazookaShell.collisionBitmask).setMass(1), - // TODO: Angle rotation the right way. - RigidBodyDesc - .dynamic() - .setTranslation(position.worldX, position.worldY) - .setLinvel(initialForce.x, initialForce.y) - // TODO: Check - // TODO: Friction - .setLinearDamping(0.05) - ); + static create( + parent: Container, + gameWorld: GameWorld, + position: Coordinate, + force: Vector2, + owner?: WormInstance, + ) { + const ent = new BazookaShell(position, gameWorld, parent, force, owner); + gameWorld.addBody(ent, ent.physObject.collider); + parent.addChild(ent.sprite); + parent.addChild(ent.wireframe.renderable); + return ent; + } - super(sprite, body, world, parent, { - explosionRadius: new MetersValue(2.25), - explodeOnContact: true, - timerSecs: 30, - autostartTimer: true, - ownerWorm: owner, - maxDamage: 45, - }); - this.sprite.x = position.screenX; - this.sprite.y = position.screenY; - this.sprite.scale.set(0.5, 0.5); - this.sprite.anchor.set(0.5, 0.5); - - // Align sprite with body. - this.rotationOffset = Math.PI/2; - this.body.addForce({x: this.gameWorld.wind*1.25, y: 0}, false); - } - + private constructor( + position: Coordinate, + world: GameWorld, + parent: Container, + initialForce: Vector2, + owner?: WormInstance, + ) { + const sprite = new Sprite(BazookaShell.texture); + const body = world.createRigidBodyCollider( + ColliderDesc.cuboid(0.5, 0.2) + .setActiveEvents(ActiveEvents.COLLISION_EVENTS) + .setCollisionGroups(BazookaShell.collisionBitmask) + .setSolverGroups(BazookaShell.collisionBitmask) + .setMass(1), + // TODO: Angle rotation the right way. + RigidBodyDesc.dynamic() + .setTranslation(position.worldX, position.worldY) + .setLinvel(initialForce.x, initialForce.y) + // TODO: Check + // TODO: Friction + .setLinearDamping(0.05), + ); - update(dt: number): void { - this.wireframe.setDebugText(`${this.body.rotation()} ${Math.round(this.body.linvel().x)} ${Math.round(this.body.linvel().y)}`); - this.body.setRotation(angleForVector(this.body.linvel()), false); + super(sprite, body, world, parent, { + explosionRadius: new MetersValue(2.25), + explodeOnContact: true, + timerSecs: 30, + autostartTimer: true, + ownerWorm: owner, + maxDamage: 45, + }); + this.sprite.x = position.screenX; + this.sprite.y = position.screenY; + this.sprite.scale.set(0.5, 0.5); + this.sprite.anchor.set(0.5, 0.5); - super.update(dt); - if (!this.physObject || this.sprite.destroyed) { - return; - } - // Fix for other angles. - this.force.x *= Math.min(1, dt * 3); - this.force.y *= Math.min(1, dt * 3); - } + // Align sprite with body. + this.rotationOffset = Math.PI / 2; + this.body.addForce({ x: this.gameWorld.wind * 1.25, y: 0 }, false); + } - destroy(): void { - super.destroy(); - this.gfx.destroy(); - } + update(dt: number): void { + this.wireframe.setDebugText( + `${this.body.rotation()} ${Math.round(this.body.linvel().x)} ${Math.round(this.body.linvel().y)}`, + ); + this.body.setRotation(angleForVector(this.body.linvel()), false); - recordState() { - return { - ...super.recordState(), - type: EntityType.BazookaShell, - } + super.update(dt); + if (!this.physObject || this.sprite.destroyed) { + return; } -} \ No newline at end of file + // Fix for other angles. + this.force.x *= Math.min(1, dt * 3); + this.force.y *= Math.min(1, dt * 3); + } + + destroy(): void { + super.destroy(); + this.gfx.destroy(); + } + + recordState() { + return { + ...super.recordState(), + type: EntityType.BazookaShell, + }; + } +} diff --git a/src/entities/phys/firework.ts b/src/entities/phys/firework.ts index 13ce96d..d51eeb9 100644 --- a/src/entities/phys/firework.ts +++ b/src/entities/phys/firework.ts @@ -1,181 +1,201 @@ -import { Color, Container, Graphics, Point, Sprite, Texture, UPDATE_PRIORITY } from 'pixi.js'; +import { + Color, + Container, + Graphics, + Point, + Sprite, + Texture, + UPDATE_PRIORITY, +} from "pixi.js"; import { TimedExplosive } from "./timedExplosive"; -import { IPhysicalEntity } from '../entity'; -import { IMediaInstance, Sound } from '@pixi/sound'; -import { collisionGroupBitmask, CollisionGroups, GameWorld } from '../../world'; -import { ActiveEvents, ColliderDesc, RigidBodyDesc, Vector2 } from '@dimforge/rapier2d-compat'; -import { Coordinate, MetersValue } from '../../utils/coodinate'; -import { AssetPack } from '../../assets'; -import { BitmapTerrain } from '../bitmapTerrain'; -import { angleForVector } from '../../utils'; -import { EntityType } from '../type'; - - -const COLOUR_SET = [ - 0x08ff08, - 0xffcf00, - 0xfe1493, - 0xff5555, - 0x00fdff, - 0xccff02 -]; +import { IPhysicalEntity } from "../entity"; +import { IMediaInstance, Sound } from "@pixi/sound"; +import { collisionGroupBitmask, CollisionGroups, GameWorld } from "../../world"; +import { + ActiveEvents, + ColliderDesc, + RigidBodyDesc, + Vector2, +} from "@dimforge/rapier2d-compat"; +import { Coordinate, MetersValue } from "../../utils/coodinate"; +import { AssetPack } from "../../assets"; +import { BitmapTerrain } from "../bitmapTerrain"; +import { angleForVector } from "../../utils"; +import { EntityType } from "../type"; + +const COLOUR_SET = [0x08ff08, 0xffcf00, 0xfe1493, 0xff5555, 0x00fdff, 0xccff02]; /** * Firework projectile. */ export class Firework extends TimedExplosive { - public static readAssets(assets: AssetPack) { - Firework.texture = assets.textures.firework; - Firework.screamSound = assets.sounds.firework; + public static readAssets(assets: AssetPack) { + Firework.texture = assets.textures.firework; + Firework.screamSound = assets.sounds.firework; + } + + private static readonly collisionBitmask = collisionGroupBitmask( + CollisionGroups.WorldObjects, + [CollisionGroups.Terrain, CollisionGroups.WorldObjects], + ); + private static texture: Texture; + private static screamSound: Sound; + private scream?: Promise; + private readonly gfx: Graphics; + private trail: { + point: Point; + speed: Point; + accel: Point; + radius: number; + alpha: number; + kind: "fire" | "pop"; + }[] = []; + + priority = UPDATE_PRIORITY.LOW; + + static create(parent: Container, world: GameWorld, position: Coordinate) { + const ent = new Firework(position, world, parent); + parent.addChild(ent.sprite, ent.wireframe.renderable, ent.gfx); + return ent; + } + + private constructor( + position: Coordinate, + world: GameWorld, + parent: Container, + ) { + const sprite = new Sprite(Firework.texture); + sprite.scale.set(0.15); + sprite.anchor.set(0.5); + + const upwardVeolcity = 60 + Math.ceil(Math.random() * 30); + const xVelocity = -30 + Math.ceil(Math.random() * 120); + + const primaryColor = + COLOUR_SET[Math.floor(Math.random() * COLOUR_SET.length)]; + const secondaryColor = + COLOUR_SET[Math.floor(Math.random() * COLOUR_SET.length)]; + + const body = world.createRigidBodyCollider( + ColliderDesc.roundCuboid(0.05, 0.05, 0.5) + .setActiveEvents(ActiveEvents.COLLISION_EVENTS) + .setCollisionGroups(Firework.collisionBitmask) + .setSolverGroups(Firework.collisionBitmask) + .setMass(0.5), + RigidBodyDesc.dynamic() + .setTranslation(position.worldX, position.worldY) + // Fix rot + .setLinvel(xVelocity, -upwardVeolcity) + .setLinearDamping(1.5), + ); + + sprite.position = body.body.translation(); + super(sprite, body, world, parent, { + explosionRadius: new MetersValue(4), + explodeOnContact: true, + explosionHue: primaryColor, + explosionShrapnelHue: secondaryColor, + timerSecs: 1.33, + autostartTimer: true, + maxDamage: 35, + }); + this.rotationOffset = Math.PI / 2; + this.scream = Promise.resolve(Firework.screamSound.play()); + this.gfx = new Graphics(); + } + + update(dt: number): void { + if (!this.sprite.destroyed) { + this.body.setRotation(angleForVector(this.body.linvel()), false); + super.update(dt); } - private static readonly collisionBitmask = collisionGroupBitmask(CollisionGroups.WorldObjects, [CollisionGroups.Terrain, CollisionGroups.WorldObjects]); - private static texture: Texture; - private static screamSound: Sound; - private scream?: Promise; - private readonly gfx: Graphics; - private trail: { - point: Point, - speed: Point, - accel: Point, - radius: number, - alpha: number, - kind: "fire"|"pop" - }[] = []; - - priority = UPDATE_PRIORITY.LOW; - - static create(parent: Container, world: GameWorld, position: Coordinate) { - const ent = new Firework(position, world, parent); - parent.addChild(ent.sprite, ent.wireframe.renderable, ent.gfx); - return ent; + this.gfx.clear(); + if (!this.hasExploded) { + const xSpeed = Math.random() * 0.5 - 0.25; + const kind = Math.random() >= 0.75 ? "fire" : "pop"; + const coodinate = new Coordinate( + this.physObject.body.translation().x, + this.physObject.body.translation().y, + ); + this.trail.push({ + alpha: 1, + point: new Point(coodinate.screenX, coodinate.screenY), + speed: new Point(xSpeed, 0.5), + accel: new Point( + // Invert the accel + xSpeed / 2, + 0.25, + ), + radius: 1 + Math.random() * (kind === "pop" ? 4.5 : 2.5), + kind, + }); } - private constructor(position: Coordinate, world: GameWorld, parent: Container) { - const sprite = new Sprite(Firework.texture); - sprite.scale.set(0.15); - sprite.anchor.set(0.5); - - const upwardVeolcity = 60 + Math.ceil(Math.random()*30); - const xVelocity = -30 + Math.ceil(Math.random()*120); - - const primaryColor = COLOUR_SET[Math.floor(Math.random()*COLOUR_SET.length)]; - const secondaryColor = COLOUR_SET[Math.floor(Math.random()*COLOUR_SET.length)] - - const body = world.createRigidBodyCollider( - ColliderDesc.roundCuboid( - 0.05, - 0.05, 0.50).setActiveEvents(ActiveEvents.COLLISION_EVENTS) - .setCollisionGroups(Firework.collisionBitmask) - .setSolverGroups(Firework.collisionBitmask).setMass(0.5), - RigidBodyDesc - .dynamic() - .setTranslation(position.worldX, position.worldY) - // Fix rot - .setLinvel(xVelocity, -upwardVeolcity).setLinearDamping(1.5) - ); - - sprite.position = body.body.translation(); - super(sprite, body, world, parent, { - explosionRadius: new MetersValue(4), - explodeOnContact: true, - explosionHue: primaryColor, - explosionShrapnelHue: secondaryColor, - timerSecs: 1.33, - autostartTimer: true, - maxDamage: 35, - }); - this.rotationOffset = Math.PI/2; - this.scream = Promise.resolve(Firework.screamSound.play()); - this.gfx = new Graphics(); + const shrapnelHue = new Color(0xaaaaaa); + for (const shrapnel of this.trail) { + shrapnel.speed.x += shrapnel.accel.x * dt; + shrapnel.speed.y += shrapnel.accel.y * dt; + shrapnel.point.x += shrapnel.speed.x * dt; + shrapnel.point.y += shrapnel.speed.y * dt; + shrapnel.alpha = Math.max(0, shrapnel.alpha - Math.random() * dt * 0.03); + if (shrapnel.alpha === 0) { + this.trail.splice(this.trail.indexOf(shrapnel), 1); + } + if (shrapnel.kind === "pop") { + this.gfx + .circle(shrapnel.point.x, shrapnel.point.y, shrapnel.radius) + .fill({ color: shrapnelHue, alpha: shrapnel.alpha }); + } else { + this.gfx + .circle(shrapnel.point.x, shrapnel.point.y, shrapnel.radius) + .fill({ color: 0xfd4301, alpha: shrapnel.alpha }); + this.gfx + .circle(shrapnel.point.x, shrapnel.point.y, shrapnel.radius - 3) + .fill({ color: 0xfde101, alpha: shrapnel.alpha }); + } } - update(dt: number): void { - if (!this.sprite.destroyed) { - this.body.setRotation(angleForVector(this.body.linvel()), false); - super.update(dt); - } - - this.gfx.clear(); - if (!this.hasExploded) { - const xSpeed = (Math.random()*0.5)-0.25; - const kind = Math.random() >= 0.75 ? "fire" : "pop"; - const coodinate = new Coordinate(this.physObject.body.translation().x, this.physObject.body.translation().y); - this.trail.push({ - alpha: 1, - point: new Point(coodinate.screenX, coodinate.screenY), - speed: new Point( - xSpeed, - 0.5, - ), - accel: new Point( - // Invert the accel - xSpeed/2, - 0.25 - ), - radius: 1 + Math.random()*(kind === "pop" ? 4.5 : 2.5), - kind, - }) - } - - const shrapnelHue = new Color(0xaaaaaa); - for (const shrapnel of this.trail) { - shrapnel.speed.x += shrapnel.accel.x*dt; - shrapnel.speed.y += shrapnel.accel.y*dt; - shrapnel.point.x += shrapnel.speed.x*dt; - shrapnel.point.y += shrapnel.speed.y*dt; - shrapnel.alpha = Math.max(0, shrapnel.alpha-(Math.random()*dt*0.03)); - if (shrapnel.alpha === 0) { - this.trail.splice(this.trail.indexOf(shrapnel), 1); - } - if (shrapnel.kind === "pop") { - this.gfx.circle(shrapnel.point.x, shrapnel.point.y, shrapnel.radius).fill({ color: shrapnelHue, alpha: shrapnel.alpha }); - } else { - this.gfx.circle(shrapnel.point.x, shrapnel.point.y, shrapnel.radius).fill({ color: 0xfd4301, alpha: shrapnel.alpha }); - this.gfx.circle(shrapnel.point.x, shrapnel.point.y, shrapnel.radius-3).fill({ color: 0xfde101, alpha: shrapnel.alpha }); - } - } - - if (this.trail.length === 0 && this.hasExploded) { - this.destroy(); - } + if (this.trail.length === 0 && this.hasExploded) { + this.destroy(); } + } - onCollision(otherEnt: IPhysicalEntity, contactPoint: Vector2) { - if (super.onCollision(otherEnt, contactPoint)) { - if (this.isSinking) { - this.scream?.then((b) => { - b.stop(); - }); - } - return true; - } - if (otherEnt instanceof BitmapTerrain || otherEnt === this) { - // Meh. - return false; - } - return false; + onCollision(otherEnt: IPhysicalEntity, contactPoint: Vector2) { + if (super.onCollision(otherEnt, contactPoint)) { + if (this.isSinking) { + this.scream?.then((b) => { + b.stop(); + }); + } + return true; } - - recordState() { - return { - ...super.recordState(), - type: EntityType.Firework, - } + if (otherEnt instanceof BitmapTerrain || otherEnt === this) { + // Meh. + return false; } - - destroy(): void { - if (this.trail.length) { - super.destroy(); - this.isDestroyed = false; - // Skip until the trail has gone - return; - } - this.scream?.then((b) => { - b.stop(); - }) - this.gfx.clear(); - this.gfx.destroy(); - this.isDestroyed = true; + return false; + } + + recordState() { + return { + ...super.recordState(), + type: EntityType.Firework, + }; + } + + destroy(): void { + if (this.trail.length) { + super.destroy(); + this.isDestroyed = false; + // Skip until the trail has gone + return; } -} \ No newline at end of file + this.scream?.then((b) => { + b.stop(); + }); + this.gfx.clear(); + this.gfx.destroy(); + this.isDestroyed = true; + } +} diff --git a/src/entities/phys/grenade.ts b/src/entities/phys/grenade.ts index cad7bb2..ee61ef7 100644 --- a/src/entities/phys/grenade.ts +++ b/src/entities/phys/grenade.ts @@ -1,126 +1,160 @@ -import { Container, Sprite, Text, Texture, Ticker } from 'pixi.js'; +import { Container, Sprite, Text, Texture, Ticker } from "pixi.js"; import { TimedExplosive } from "./timedExplosive"; -import { IPhysicalEntity } from '../entity'; -import { BitmapTerrain } from '../bitmapTerrain'; -import { IMediaInstance, Sound } from '@pixi/sound'; -import { collisionGroupBitmask, CollisionGroups, GameWorld } from '../../world'; -import { ActiveEvents, ColliderDesc, RigidBodyDesc, Vector2 } from '@dimforge/rapier2d-compat'; -import { magnitude } from '../../utils'; -import { Coordinate, MetersValue } from '../../utils/coodinate'; -import { AssetPack } from '../../assets'; -import { DefaultTextStyle } from '../../mixins/styles'; -import { WormInstance } from '../../logic/teams'; -import { EntityType } from '../type'; - +import { IPhysicalEntity } from "../entity"; +import { BitmapTerrain } from "../bitmapTerrain"; +import { IMediaInstance, Sound } from "@pixi/sound"; +import { collisionGroupBitmask, CollisionGroups, GameWorld } from "../../world"; +import { + ActiveEvents, + ColliderDesc, + RigidBodyDesc, + Vector2, +} from "@dimforge/rapier2d-compat"; +import { magnitude } from "../../utils"; +import { Coordinate, MetersValue } from "../../utils/coodinate"; +import { AssetPack } from "../../assets"; +import { DefaultTextStyle } from "../../mixins/styles"; +import { WormInstance } from "../../logic/teams"; +import { EntityType } from "../type"; /** * Grenade projectile. */ export class Grenade extends TimedExplosive { - public static readAssets({textures, sounds}: AssetPack) { - Grenade.texture = textures.grenade; - Grenade.bounceSoundsLight = sounds.metalBounceLight; - Grenade.boundSoundHeavy = sounds.metalBounceHeavy; - } + public static readAssets({ textures, sounds }: AssetPack) { + Grenade.texture = textures.grenade; + Grenade.bounceSoundsLight = sounds.metalBounceLight; + Grenade.boundSoundHeavy = sounds.metalBounceHeavy; + } - private static readonly collisionBitmask = collisionGroupBitmask(CollisionGroups.WorldObjects, [CollisionGroups.Terrain, CollisionGroups.WorldObjects]); - private static texture: Texture; - private static bounceSoundsLight: Sound; - private static boundSoundHeavy: Sound; + private static readonly collisionBitmask = collisionGroupBitmask( + CollisionGroups.WorldObjects, + [CollisionGroups.Terrain, CollisionGroups.WorldObjects], + ); + private static texture: Texture; + private static bounceSoundsLight: Sound; + private static boundSoundHeavy: Sound; - static create(parent: Container, world: GameWorld, position: Coordinate, initialForce: { x: number; y: number; }, timerSecs = 3, worm?: WormInstance) { - const ent = new Grenade(position, initialForce, world, parent, timerSecs, worm); - parent.addChild(ent.sprite, ent.wireframe.renderable); - return ent; - } + static create( + parent: Container, + world: GameWorld, + position: Coordinate, + initialForce: { x: number; y: number }, + timerSecs = 3, + worm?: WormInstance, + ) { + const ent = new Grenade( + position, + initialForce, + world, + parent, + timerSecs, + worm, + ); + parent.addChild(ent.sprite, ent.wireframe.renderable); + return ent; + } - private timerText: Text; + private timerText: Text; - private get timerTextValue() { - return `${((this.timer ?? 0) / (Ticker.targetFPMS*1000)).toFixed(1)}` - } - public bounceSoundPlayback?: IMediaInstance; + private get timerTextValue() { + return `${((this.timer ?? 0) / (Ticker.targetFPMS * 1000)).toFixed(1)}`; + } + public bounceSoundPlayback?: IMediaInstance; + + private constructor( + position: Coordinate, + initialForce: { x: number; y: number }, + world: GameWorld, + parent: Container, + timerSecs: number, + owner?: WormInstance, + ) { + const sprite = new Sprite(Grenade.texture); + sprite.scale.set(0.5); + sprite.anchor.set(0.5); + const body = world.createRigidBodyCollider( + ColliderDesc.roundCuboid(0.05, 0.05, 0.5) + .setActiveEvents(ActiveEvents.COLLISION_EVENTS) + .setCollisionGroups(Grenade.collisionBitmask) + .setSolverGroups(Grenade.collisionBitmask) + .setMass(1), + RigidBodyDesc.dynamic().setTranslation(position.worldX, position.worldY), + ); + sprite.position = body.body.translation(); + super(sprite, body, world, parent, { + explosionRadius: new MetersValue(3), + explodeOnContact: false, + timerSecs, + autostartTimer: true, + ownerWorm: owner, + maxDamage: 40, + }); + this.timerText = new Text({ + text: "", + style: { + ...DefaultTextStyle, + align: "center", + }, + }); + this.sprite.addChild(this.timerText); + this.body.setLinvel(initialForce, true); + } - private constructor(position: Coordinate, initialForce: { x: number, y: number}, world: GameWorld, parent: Container, timerSecs: number, owner?: WormInstance) { - const sprite = new Sprite(Grenade.texture); - sprite.scale.set(0.5); - sprite.anchor.set(0.5); - const body = world.createRigidBodyCollider( - ColliderDesc.roundCuboid( - 0.05, - 0.05, 0.50).setActiveEvents(ActiveEvents.COLLISION_EVENTS) - .setCollisionGroups(Grenade.collisionBitmask) - .setSolverGroups(Grenade.collisionBitmask).setMass(1), - RigidBodyDesc - .dynamic() - .setTranslation(position.worldX, position.worldY) - ); - sprite.position = body.body.translation(); - super(sprite, body, world, parent, { - explosionRadius: new MetersValue(3), - explodeOnContact: false, - timerSecs, - autostartTimer: true, - ownerWorm: owner, - maxDamage: 40, - }); - this.timerText = new Text({ - text: '', - style: { - ...DefaultTextStyle, - align: 'center', - } - }); - this.sprite.addChild(this.timerText); - this.body.setLinvel(initialForce, true); + update(dt: number): void { + super.update(dt); + if (this.sprite.destroyed) { + return; } - update(dt: number): void { - super.update(dt); - if (this.sprite.destroyed) { - return; - } - - this.wireframe.setDebugText(`velocity: ${Math.round(magnitude(this.physObject.body.linvel())*1000)/1000}`) + this.wireframe.setDebugText( + `velocity: ${Math.round(magnitude(this.physObject.body.linvel()) * 1000) / 1000}`, + ); - if (!this.timerText.destroyed) { - this.timerText.rotation = -this.physObject.body.rotation(); - this.timerText.text = this.timerTextValue; - } + if (!this.timerText.destroyed) { + this.timerText.rotation = -this.physObject.body.rotation(); + this.timerText.text = this.timerTextValue; } + } - onCollision(otherEnt: IPhysicalEntity, contactPoint: Vector2) { - if (super.onCollision(otherEnt, contactPoint)) { - this.timerText.destroy(); - return true; - } - // We don't explode, but we do make a noise. - if (otherEnt instanceof BitmapTerrain === false) { - return false; - } + onCollision(otherEnt: IPhysicalEntity, contactPoint: Vector2) { + if (super.onCollision(otherEnt, contactPoint)) { + this.timerText.destroy(); + return true; + } + // We don't explode, but we do make a noise. + if (otherEnt instanceof BitmapTerrain === false) { + return false; + } - const velocity = magnitude(this.physObject.body.linvel()); + const velocity = magnitude(this.physObject.body.linvel()); - // TODO: can these interrupt? - if (!this.bounceSoundPlayback?.progress || this.bounceSoundPlayback.progress === 1 && this.timer) { - // TODO: Hacks - Promise.resolve( - (velocity >= 8 ? Grenade.boundSoundHeavy : Grenade.bounceSoundsLight).play() - ).then((instance) =>{ - this.bounceSoundPlayback = instance; - }) - } - return false; + // TODO: can these interrupt? + if ( + !this.bounceSoundPlayback?.progress || + (this.bounceSoundPlayback.progress === 1 && this.timer) + ) { + // TODO: Hacks + Promise.resolve( + (velocity >= 8 + ? Grenade.boundSoundHeavy + : Grenade.bounceSoundsLight + ).play(), + ).then((instance) => { + this.bounceSoundPlayback = instance; + }); } + return false; + } - recordState() { - return { - ...super.recordState(), - type: EntityType.Grenade, - } - } + recordState() { + return { + ...super.recordState(), + type: EntityType.Grenade, + }; + } - destroy(): void { - super.destroy(); - } -} \ No newline at end of file + destroy(): void { + super.destroy(); + } +} diff --git a/src/entities/phys/mine.ts b/src/entities/phys/mine.ts index a72e700..06c98ab 100644 --- a/src/entities/phys/mine.ts +++ b/src/entities/phys/mine.ts @@ -1,140 +1,158 @@ -import { Container, Sprite, Text, Texture, Ticker } from 'pixi.js'; +import { Container, Sprite, Text, Texture, Ticker } from "pixi.js"; import { TimedExplosive } from "./timedExplosive"; -import { IPhysicalEntity } from '../entity'; -import { IMediaInstance, Sound } from '@pixi/sound'; -import { collisionGroupBitmask, CollisionGroups, GameWorld } from '../../world'; -import { ActiveEvents, Collider, ColliderDesc, RigidBodyDesc, Vector2 } from '@dimforge/rapier2d-compat'; -import { Coordinate, MetersValue } from '../../utils/coodinate'; -import { AssetPack } from '../../assets'; -import { BitmapTerrain } from '../bitmapTerrain'; -import { DefaultTextStyle } from '../../mixins/styles'; -import { EntityType } from '../type'; +import { IPhysicalEntity } from "../entity"; +import { IMediaInstance, Sound } from "@pixi/sound"; +import { collisionGroupBitmask, CollisionGroups, GameWorld } from "../../world"; +import { + ActiveEvents, + Collider, + ColliderDesc, + RigidBodyDesc, + Vector2, +} from "@dimforge/rapier2d-compat"; +import { Coordinate, MetersValue } from "../../utils/coodinate"; +import { AssetPack } from "../../assets"; +import { BitmapTerrain } from "../bitmapTerrain"; +import { DefaultTextStyle } from "../../mixins/styles"; +import { EntityType } from "../type"; /** * Proximity mine. */ export class Mine extends TimedExplosive { - public static readAssets(assets: AssetPack) { - Mine.texture = assets.textures.mine; - Mine.textureActive = assets.textures.mineActive; - Mine.beep = assets.sounds.mineBeep; - } - - private static MineTriggerRadius = new MetersValue(5); + public static readAssets(assets: AssetPack) { + Mine.texture = assets.textures.mine; + Mine.textureActive = assets.textures.mineActive; + Mine.beep = assets.sounds.mineBeep; + } - private static readonly collisionBitmask = collisionGroupBitmask(CollisionGroups.WorldObjects, [CollisionGroups.Terrain, CollisionGroups.WorldObjects]); - private static readonly sensorCollisionBitmask = collisionGroupBitmask(CollisionGroups.WorldObjects, [CollisionGroups.Player]); - private static texture: Texture; - private static textureActive: Texture; - private static beep: Sound; - private readonly sensor: Collider; - private beeping?: Promise; - private readonly timerText: Text; - - static create(parent: Container, world: GameWorld, position: Coordinate) { - const ent = new Mine(position, world, parent); - parent.addChild(ent.sprite, ent.wireframe.renderable); - return ent; - } + private static MineTriggerRadius = new MetersValue(5); - private get timerTextValue() { - return `${((this.timer ?? 0) / (Ticker.targetFPMS*1000)).toFixed(1)}` - } - public bounceSoundPlayback?: IMediaInstance; + private static readonly collisionBitmask = collisionGroupBitmask( + CollisionGroups.WorldObjects, + [CollisionGroups.Terrain, CollisionGroups.WorldObjects], + ); + private static readonly sensorCollisionBitmask = collisionGroupBitmask( + CollisionGroups.WorldObjects, + [CollisionGroups.Player], + ); + private static texture: Texture; + private static textureActive: Texture; + private static beep: Sound; + private readonly sensor: Collider; + private beeping?: Promise; + private readonly timerText: Text; - private constructor(position: Coordinate, world: GameWorld, parent: Container) { - const sprite = new Sprite(Mine.texture); - sprite.scale.set(0.15); - sprite.anchor.set(0.5); - const body = world.createRigidBodyCollider( - ColliderDesc.roundCuboid( - 0.05, - 0.05, 0.50).setActiveEvents(ActiveEvents.COLLISION_EVENTS) - .setCollisionGroups(Mine.collisionBitmask) - .setSolverGroups(Mine.collisionBitmask).setMass(0.5), - RigidBodyDesc - .dynamic() - .setTranslation(position.worldX, position.worldY) - ); - - sprite.position = body.body.translation(); - super(sprite, body, world, parent, { - explosionRadius: new MetersValue(4), - explodeOnContact: false, - timerSecs: 5, - autostartTimer: false, - maxDamage: 40, - }); - this.sensor = world.rapierWorld.createCollider(ColliderDesc.ball( - Mine.MineTriggerRadius.value).setActiveEvents(ActiveEvents.COLLISION_EVENTS) - .setCollisionGroups(Mine.sensorCollisionBitmask) - .setSolverGroups(Mine.sensorCollisionBitmask) - .setSensor(true)); - this.gameWorld.addBody(this, this.sensor); - this.timerText = new Text({ - text: '', - style: { - ...DefaultTextStyle, - fontSize: 100, - align: 'center', - } - }); - sprite.addChild(this.timerText); - } + static create(parent: Container, world: GameWorld, position: Coordinate) { + const ent = new Mine(position, world, parent); + parent.addChild(ent.sprite, ent.wireframe.renderable); + return ent; + } - update(dt: number): void { - super.update(dt); - if (this.sprite.destroyed) { - return; - } + private get timerTextValue() { + return `${((this.timer ?? 0) / (Ticker.targetFPMS * 1000)).toFixed(1)}`; + } + public bounceSoundPlayback?: IMediaInstance; + private constructor( + position: Coordinate, + world: GameWorld, + parent: Container, + ) { + const sprite = new Sprite(Mine.texture); + sprite.scale.set(0.15); + sprite.anchor.set(0.5); + const body = world.createRigidBodyCollider( + ColliderDesc.roundCuboid(0.05, 0.05, 0.5) + .setActiveEvents(ActiveEvents.COLLISION_EVENTS) + .setCollisionGroups(Mine.collisionBitmask) + .setSolverGroups(Mine.collisionBitmask) + .setMass(0.5), + RigidBodyDesc.dynamic().setTranslation(position.worldX, position.worldY), + ); - if (this.timer) { - this.sprite.texture = (this.timer % 20) > 10 ? Mine.texture : Mine.textureActive; - } + sprite.position = body.body.translation(); + super(sprite, body, world, parent, { + explosionRadius: new MetersValue(4), + explodeOnContact: false, + timerSecs: 5, + autostartTimer: false, + maxDamage: 40, + }); + this.sensor = world.rapierWorld.createCollider( + ColliderDesc.ball(Mine.MineTriggerRadius.value) + .setActiveEvents(ActiveEvents.COLLISION_EVENTS) + .setCollisionGroups(Mine.sensorCollisionBitmask) + .setSolverGroups(Mine.sensorCollisionBitmask) + .setSensor(true), + ); + this.gameWorld.addBody(this, this.sensor); + this.timerText = new Text({ + text: "", + style: { + ...DefaultTextStyle, + fontSize: 100, + align: "center", + }, + }); + sprite.addChild(this.timerText); + } - if (!this.timerText.destroyed && this.timer) { - this.timerText.rotation = -this.physObject.body.rotation(); - this.timerText.text = this.timerTextValue; - } - this.sensor.setTranslation(this.physObject.body.translation()); + update(dt: number): void { + super.update(dt); + if (this.sprite.destroyed) { + return; } - onCollision(otherEnt: IPhysicalEntity, contactPoint: Vector2) { - if (super.onCollision(otherEnt, contactPoint)) { - if (this.isSinking) { - this.timerText.destroy(); - this.beeping?.then((b) => { - b.stop(); - this.beeping = Promise.resolve(Mine.beep.play({speed: 0.5, volume: 0.25})); - }); - } - return true; - } - if (otherEnt instanceof BitmapTerrain || otherEnt === this) { - // Meh. - return false; - } - - if (this.timer === undefined) { - this.startTimer(); - this.beeping = Promise.resolve(Mine.beep.play({loop: true})); - } - return false; + if (this.timer) { + this.sprite.texture = + this.timer % 20 > 10 ? Mine.texture : Mine.textureActive; } - recordState() { - return { - ...super.recordState(), - type: EntityType.Mine, - } + if (!this.timerText.destroyed && this.timer) { + this.timerText.rotation = -this.physObject.body.rotation(); + this.timerText.text = this.timerTextValue; } + this.sensor.setTranslation(this.physObject.body.translation()); + } - destroy(): void { + onCollision(otherEnt: IPhysicalEntity, contactPoint: Vector2) { + if (super.onCollision(otherEnt, contactPoint)) { + if (this.isSinking) { + this.timerText.destroy(); this.beeping?.then((b) => { - b.stop(); - }) - super.destroy(); - this.gameWorld.rapierWorld.removeCollider(this.sensor, false); + b.stop(); + this.beeping = Promise.resolve( + Mine.beep.play({ speed: 0.5, volume: 0.25 }), + ); + }); + } + return true; } -} \ No newline at end of file + if (otherEnt instanceof BitmapTerrain || otherEnt === this) { + // Meh. + return false; + } + + if (this.timer === undefined) { + this.startTimer(); + this.beeping = Promise.resolve(Mine.beep.play({ loop: true })); + } + return false; + } + + recordState() { + return { + ...super.recordState(), + type: EntityType.Mine, + }; + } + + destroy(): void { + this.beeping?.then((b) => { + b.stop(); + }); + super.destroy(); + this.gameWorld.rapierWorld.removeCollider(this.sensor, false); + } +} diff --git a/src/entities/phys/physicsEntity.ts b/src/entities/phys/physicsEntity.ts index 085589d..96bb195 100644 --- a/src/entities/phys/physicsEntity.ts +++ b/src/entities/phys/physicsEntity.ts @@ -14,127 +14,146 @@ import { CameraLockPriority } from "../../camera"; /** * Abstract class for any physical object in the world. The * object must have at most one body and one sprite. - * + * * Collision on water and force from explosions are automatically * calculated. */ -export abstract class PhysicsEntity implements IPhysicalEntity { - public static readAssets({sounds}: AssetPack) { - PhysicsEntity.splashSound = sounds.splash; +export abstract class PhysicsEntity< + T extends RecordedEntityState = RecordedEntityState, +> implements IPhysicalEntity +{ + public static readAssets({ sounds }: AssetPack) { + PhysicsEntity.splashSound = sounds.splash; + } + + protected isSinking = false; + protected isDestroyed = false; + protected sinkingY = 0; + protected wireframe: BodyWireframe; + + protected renderOffset?: Point; + protected rotationOffset = 0; + + private static splashSound: Sound; + + priority = UPDATE_PRIORITY.NORMAL; + private splashSoundPlayback?: IMediaInstance; + + public cameraLockPriority: CameraLockPriority = CameraLockPriority.NoLock; + + public get destroyed() { + return this.isDestroyed; + } + + public get body() { + return this.physObject.body; + } + + constructor( + public readonly sprite: Sprite, + protected physObject: RapierPhysicsObject, + protected gameWorld: GameWorld, + ) { + this.wireframe = new BodyWireframe( + this.physObject, + globalFlags.DebugView >= DebugLevel.BasicOverlay, + ); + globalFlags.on("toggleDebugView", (level: DebugLevel) => { + this.wireframe.enabled = level >= DebugLevel.BasicOverlay; + }); + } + + destroy(): void { + this.cameraLockPriority = CameraLockPriority.NoLock; + this.isDestroyed = true; + this.sprite.destroy(); + this.wireframe.renderable.destroy(); + this.gameWorld.removeBody(this.physObject); + this.gameWorld.removeEntity(this); + } + + update(dt: number): void { + const pos = this.physObject.body.translation(); + const rotation = this.physObject.body.rotation() + this.rotationOffset; + this.sprite.updateTransform({ + x: pos.x * PIXELS_PER_METER + (this.renderOffset?.x ?? 0), + y: pos.y * PIXELS_PER_METER + (this.renderOffset?.y ?? 0), + rotation, + }); + + this.wireframe.update(); + + // TODO: We do need a better system for this. + if (this.body.translation().y > 1080 / PIXELS_PER_METER) { + this.isSinking = true; } - protected isSinking = false; - protected isDestroyed = false; - protected sinkingY = 0; - protected wireframe: BodyWireframe; - - protected renderOffset?: Point; - protected rotationOffset = 0; - - private static splashSound: Sound; - - priority = UPDATE_PRIORITY.NORMAL; - private splashSoundPlayback?: IMediaInstance; - - public cameraLockPriority: CameraLockPriority = CameraLockPriority.NoLock; - - public get destroyed() { - return this.isDestroyed; - } - - public get body() { - return this.physObject.body; + // Sinking. + if (this.isSinking) { + this.physObject.body.setTranslation( + { x: pos.x, y: pos.y + 0.05 * dt }, + false, + ); + if (pos.y > this.sinkingY) { + this.destroy(); + } } - - constructor(public readonly sprite: Sprite, protected physObject: RapierPhysicsObject, protected gameWorld: GameWorld) { - this.wireframe = new BodyWireframe(this.physObject, globalFlags.DebugView >= DebugLevel.BasicOverlay); - globalFlags.on('toggleDebugView', (level: DebugLevel) => { - this.wireframe.enabled = level >= DebugLevel.BasicOverlay; + } + + onCollision(otherEnt: IPhysicalEntity, contactPoint: Vector2) { + if (otherEnt instanceof Water) { + this.cameraLockPriority = CameraLockPriority.NoLock; + + if ( + !this.splashSoundPlayback?.progress || + this.splashSoundPlayback.progress === 1 + ) { + // TODO: Hacks + Promise.resolve(PhysicsEntity.splashSound.play()).then((instance) => { + this.splashSoundPlayback = instance; }); + } + const contactY = contactPoint.y; + // Time to sink + this.isSinking = true; + this.sinkingY = contactY + 10; + // Set static. + this.physObject.body.setEnabled(false); + return true; } - - destroy(): void { - this.cameraLockPriority = CameraLockPriority.NoLock; - this.isDestroyed = true; - this.sprite.destroy(); - this.wireframe.renderable.destroy(); - this.gameWorld.removeBody(this.physObject); - this.gameWorld.removeEntity(this); - } - - update(dt: number): void { - const pos = this.physObject.body.translation(); - const rotation = this.physObject.body.rotation() + this.rotationOffset; - this.sprite.updateTransform({ - x: (pos.x * PIXELS_PER_METER) + (this.renderOffset?.x ?? 0), - y: (pos.y * PIXELS_PER_METER) + (this.renderOffset?.y ?? 0), - rotation - }); - - this.wireframe.update(); - - // TODO: We do need a better system for this. - if (this.body.translation().y > (1080/PIXELS_PER_METER)) { - this.isSinking = true; - } - - // Sinking. - if (this.isSinking) { - this.physObject.body.setTranslation({x: pos.x, y: pos.y + (0.05 * dt)}, false); - if (pos.y > this.sinkingY) { - this.destroy(); - } - } - - } - - onCollision(otherEnt: IPhysicalEntity, contactPoint: Vector2) { - if (otherEnt instanceof Water) { - this.cameraLockPriority = CameraLockPriority.NoLock; - - if (!this.splashSoundPlayback?.progress || this.splashSoundPlayback.progress === 1) { - // TODO: Hacks - Promise.resolve(PhysicsEntity.splashSound.play()).then((instance) =>{ - this.splashSoundPlayback = instance; - }) - } - const contactY = contactPoint.y; - // Time to sink - this.isSinking = true; - this.sinkingY = contactY + 10; - // Set static. - this.physObject.body.setEnabled(false); - return true; - } - return false; - } - - onDamage(point: Vector2, radius: MetersValue, _opts: OnDamageOpts): void { - const bodyTranslation = this.physObject.body.translation(); - const forceMag = radius.value/magnitude(sub(point,this.physObject.body.translation())); - const force = mult(sub(point, bodyTranslation), new Vector2(-forceMag, -forceMag*1.5)); - this.physObject.body.applyImpulse(force, true) - } - - recordState(): T { - const translation = this.body.translation(); - const rotation = this.body.rotation(); - const linvel = this.body.linvel(); - return { - type: -1, - tra: { - x: translation.x.toString(), - y: translation.y.toString(), - }, - rot: rotation.toString(), - vel: { - x: linvel.x.toString(), - y: linvel.y.toString(), - } - } as T; - } - - loadState(_d: T): void { - // TODO: Load the state from above. - } -} \ No newline at end of file + return false; + } + + onDamage(point: Vector2, radius: MetersValue, _opts: OnDamageOpts): void { + const bodyTranslation = this.physObject.body.translation(); + const forceMag = + radius.value / magnitude(sub(point, this.physObject.body.translation())); + const force = mult( + sub(point, bodyTranslation), + new Vector2(-forceMag, -forceMag * 1.5), + ); + this.physObject.body.applyImpulse(force, true); + } + + recordState(): T { + const translation = this.body.translation(); + const rotation = this.body.rotation(); + const linvel = this.body.linvel(); + return { + type: -1, + tra: { + x: translation.x.toString(), + y: translation.y.toString(), + }, + rot: rotation.toString(), + vel: { + x: linvel.x.toString(), + y: linvel.y.toString(), + }, + } as T; + } + + loadState(_d: T): void { + // TODO: Load the state from above. + } +} diff --git a/src/entities/phys/timedExplosive.ts b/src/entities/phys/timedExplosive.ts index 495c135..eeeb53e 100644 --- a/src/entities/phys/timedExplosive.ts +++ b/src/entities/phys/timedExplosive.ts @@ -1,4 +1,10 @@ -import { UPDATE_PRIORITY, Ticker, Sprite, ColorSource, Container } from "pixi.js"; +import { + UPDATE_PRIORITY, + Ticker, + Sprite, + ColorSource, + Container, +} from "pixi.js"; import { IPhysicalEntity, IWeaponEntity } from "../entity"; import { PhysicsEntity } from "./physicsEntity"; import { GameWorld, RapierPhysicsObject } from "../../world"; @@ -10,123 +16,139 @@ import { handleDamageInRadius } from "../../utils/damage"; import { RecordedEntityState } from "../../state/model"; interface Opts { - explosionRadius: MetersValue, - explodeOnContact: boolean, - explosionHue?: ColorSource, - explosionShrapnelHue?: ColorSource, - autostartTimer: boolean, - timerSecs?: number, - ownerWorm?: WormInstance, - maxDamage: number, + explosionRadius: MetersValue; + explodeOnContact: boolean; + explosionHue?: ColorSource; + explosionShrapnelHue?: ColorSource; + autostartTimer: boolean; + timerSecs?: number; + ownerWorm?: WormInstance; + maxDamage: number; } interface RecordedState extends RecordedEntityState { - owner?: string, - timerSecs?: number, - timer?: number, + owner?: string; + timerSecs?: number; + timer?: number; } /** * Any projectile type that can explode after a set timer. Implementing classes * must include their own timer. */ -export abstract class TimedExplosive extends PhysicsEntity implements IWeaponEntity { - protected timer: number|undefined; - protected hasExploded = false; +export abstract class TimedExplosive + extends PhysicsEntity + implements IWeaponEntity +{ + protected timer: number | undefined; + protected hasExploded = false; - private fireResultFn!: (fireResult: WeaponFireResult[]) => void; - public onFireResult: Promise; + private fireResultFn!: (fireResult: WeaponFireResult[]) => void; + public onFireResult: Promise; - priority = UPDATE_PRIORITY.NORMAL; + priority = UPDATE_PRIORITY.NORMAL; - constructor(sprite: Sprite, body: RapierPhysicsObject, gameWorld: GameWorld, private readonly parent: Container, protected readonly opts: Opts) { - super(sprite, body, gameWorld); - this.gameWorld.addBody(this, body.collider); - if (opts.autostartTimer) { - this.timer = opts.timerSecs ? Ticker.targetFPMS * opts.timerSecs * 1000 : 0; - } - // TODO: timeout. - this.onFireResult = new Promise((r) => this.fireResultFn = r); + constructor( + sprite: Sprite, + body: RapierPhysicsObject, + gameWorld: GameWorld, + private readonly parent: Container, + protected readonly opts: Opts, + ) { + super(sprite, body, gameWorld); + this.gameWorld.addBody(this, body.collider); + if (opts.autostartTimer) { + this.timer = opts.timerSecs + ? Ticker.targetFPMS * opts.timerSecs * 1000 + : 0; } + // TODO: timeout. + this.onFireResult = new Promise((r) => (this.fireResultFn = r)); + } - startTimer() { - if (this.timer !== undefined) { - throw Error('Timer already started'); - } - if (!this.opts.timerSecs) { - throw Error('No timer secs defined'); - } - this.timer = Ticker.targetFPMS * this.opts.timerSecs * 1000; + startTimer() { + if (this.timer !== undefined) { + throw Error("Timer already started"); } - - onTimerFinished() { - if (!this.physObject || !this.gameWorld) { - throw Error('Timer expired without a body'); - } - this.onExplode(); + if (!this.opts.timerSecs) { + throw Error("No timer secs defined"); } + this.timer = Ticker.targetFPMS * this.opts.timerSecs * 1000; + } - onExplode() { - if (this.hasExploded) { - throw Error('Tried to explode twice'); - } - this.hasExploded = true; - this.timer = undefined; - const result = handleDamageInRadius( - this.gameWorld, this.parent, this.body.translation(), this.opts.explosionRadius, - { - shrapnelMax: 35, - shrapnelMin: 15, - hue: this.opts.explosionHue ?? 0xffffff, - shrapnelHue: this.opts.explosionShrapnelHue ?? 0xffffff, - }, this.physObject.collider, this.opts.ownerWorm); - this.fireResultFn(result); - this.destroy(); + onTimerFinished() { + if (!this.physObject || !this.gameWorld) { + throw Error("Timer expired without a body"); } + this.onExplode(); + } - update(dt: number): void { - super.update(dt); - if (this.timer !== undefined) { - if (this.timer > 0) { - this.timer -= dt; - } else if (this.timer <= 0 && !this.isSinking) { - this.onTimerFinished(); - } - } + onExplode() { + if (this.hasExploded) { + throw Error("Tried to explode twice"); } + this.hasExploded = true; + this.timer = undefined; + const result = handleDamageInRadius( + this.gameWorld, + this.parent, + this.body.translation(), + this.opts.explosionRadius, + { + shrapnelMax: 35, + shrapnelMin: 15, + hue: this.opts.explosionHue ?? 0xffffff, + shrapnelHue: this.opts.explosionShrapnelHue ?? 0xffffff, + }, + this.physObject.collider, + this.opts.ownerWorm, + ); + this.fireResultFn(result); + this.destroy(); + } - onCollision(otherEnt: IPhysicalEntity, contactPoint: Vector2) { - if (super.onCollision(otherEnt, contactPoint)) { - if (this.isSinking) { - this.timer = 0; - this.physObject.body.setRotation(0.15, false); - this.fireResultFn([WeaponFireResult.NoHit]); - } - return true; - } + update(dt: number): void { + super.update(dt); + if (this.timer !== undefined) { + if (this.timer > 0) { + this.timer -= dt; + } else if (this.timer <= 0 && !this.isSinking) { + this.onTimerFinished(); + } + } + } - if (this.opts.explodeOnContact && !this.hasExploded) { - this.onExplode(); - return true; - } - - return false; + onCollision(otherEnt: IPhysicalEntity, contactPoint: Vector2) { + if (super.onCollision(otherEnt, contactPoint)) { + if (this.isSinking) { + this.timer = 0; + this.physObject.body.setRotation(0.15, false); + this.fireResultFn([WeaponFireResult.NoHit]); + } + return true; } - recordState() { - return { - // No floats. - timer: this.timer && Math.round(this.timer), - owner: this.opts.ownerWorm?.uuid, - timerSecs: this.opts.timerSecs, - ...super.recordState(), - } + if (this.opts.explodeOnContact && !this.hasExploded) { + this.onExplode(); + return true; } + return false; + } - loadState(d: RecordedState) { - super.loadState(d); - this.timer = d.timer; - this.opts.timerSecs = d.timerSecs; - } -} \ No newline at end of file + recordState() { + return { + // No floats. + timer: this.timer && Math.round(this.timer), + owner: this.opts.ownerWorm?.uuid, + timerSecs: this.opts.timerSecs, + ...super.recordState(), + }; + } + + loadState(d: RecordedState) { + super.loadState(d); + this.timer = d.timer; + this.opts.timerSecs = d.timerSecs; + } +} diff --git a/src/entities/playable/playable.ts b/src/entities/playable/playable.ts index 0b6afc6..9d40a3a 100644 --- a/src/entities/playable/playable.ts +++ b/src/entities/playable/playable.ts @@ -1,4 +1,11 @@ -import { Point, Sprite, UPDATE_PRIORITY, Text, DEG_TO_RAD, Graphics } from "pixi.js"; +import { + Point, + Sprite, + UPDATE_PRIORITY, + Text, + DEG_TO_RAD, + Graphics, +} from "pixi.js"; import { PhysicsEntity } from "../phys/physicsEntity"; import { GameWorld, RapierPhysicsObject } from "../../world"; import { magnitude, MetersValue, mult, sub } from "../../utils"; @@ -12,187 +19,234 @@ import { RecordedEntityState } from "../../state/model"; import { HEALTH_CHANGE_TENSION_TIMER } from "../../consts"; import Logger from "../../log"; -const logger = new Logger('Playable'); - +const logger = new Logger("Playable"); interface Opts { - explosionRadius: MetersValue, - damageMultiplier: number, + explosionRadius: MetersValue; + damageMultiplier: number; } // This is clearly not milliseconds, something is odd about our dt. const SELF_EXPLODE_MAX_DAMAGE = 25; interface RecordedState extends RecordedEntityState { - wormIdent: string -} + wormIdent: string; +} /** * Entity that can be directly controlled by a player. */ export abstract class PlayableEntity extends PhysicsEntity { - priority = UPDATE_PRIORITY.LOW; - - private nameText: Text; - private healthText: Text; - protected healthTextBox: Graphics; - - private visibleHealth: number; - private healthChangeTensionTimer: number|null = null; - - get position() { - return this.physObject.body.translation(); + priority = UPDATE_PRIORITY.LOW; + + private nameText: Text; + private healthText: Text; + protected healthTextBox: Graphics; + + private visibleHealth: number; + private healthChangeTensionTimer: number | null = null; + + get position() { + return this.physObject.body.translation(); + } + + get health() { + return this.wormIdent.health; + } + + set health(v: number) { + this.wormIdent.health = v; + logger.info( + `Worm (${this.wormIdent.uuid}, ${this.wormIdent.name}) health adjusted`, + ); + // Potentially further delay until the player has stopped moving. + this.healthChangeTensionTimer = HEALTH_CHANGE_TENSION_TIMER; + } + + constructor( + sprite: Sprite, + body: RapierPhysicsObject, + world: GameWorld, + protected parent: Viewport, + public readonly wormIdent: WormInstance, + private readonly opts: Opts, + ) { + super(sprite, body, world); + this.renderOffset = new Point(4, 1); + const { fg } = teamGroupToColorSet(wormIdent.team.group); + this.nameText = new Text({ + text: this.wormIdent.name, + style: { + ...DefaultTextStyle, + fontSize: 28, + lineHeight: 32, + fill: fg, + align: "center", + }, + }); + this.healthText = new Text({ + text: this.health, + style: { + ...DefaultTextStyle, + fontSize: 28, + lineHeight: 32, + fill: fg, + align: "center", + }, + }); + this.visibleHealth = this.health; + this.healthTextBox = new Graphics(); + this.healthText.position.set( + this.nameText.width / 2 - this.healthText.width / 2, + 34, + ); + applyGenericBoxStyle(this.healthTextBox) + .roundRect(-5, 0, this.nameText.width + 10, 30, 4) + .stroke() + .fill(); + applyGenericBoxStyle(this.healthTextBox) + .roundRect( + this.nameText.width / 2 - this.healthText.width / 2 - 5, + 36, + this.healthText.width + 10, + 28, + 4, + ) + .stroke() + .fill(); + this.healthTextBox.addChild(this.healthText, this.nameText); + } + + public update(dt: number): void { + super.update(dt); + if (this.destroyed) { + // TODO: Feels totally unnessacery. + return; } - - get health() { - return this.wormIdent.health; + if (!this.healthTextBox.destroyed) { + // Nice and simple parenting :Z + this.healthTextBox.rotation = 0; + this.healthTextBox.position.set( + this.sprite.x - this.healthTextBox.width / 2 + this.sprite.width / 2, + this.sprite.y - 100, + ); } - set health(v: number) { - this.wormIdent.health = v; - logger.info(`Worm (${this.wormIdent.uuid}, ${this.wormIdent.name}) health adjusted`); - // Potentially further delay until the player has stopped moving. - this.healthChangeTensionTimer = HEALTH_CHANGE_TENSION_TIMER; + if (this.healthChangeTensionTimer) { + this.wireframe.setDebugText(`tension: ${this.healthChangeTensionTimer}`); } - constructor(sprite: Sprite, body: RapierPhysicsObject, world: GameWorld, protected parent: Viewport, public readonly wormIdent: WormInstance, private readonly opts: Opts) { - super(sprite, body, world); - this.renderOffset = new Point(4, 1); - const {fg} = teamGroupToColorSet(wormIdent.team.group); - this.nameText = new Text({ - text: this.wormIdent.name, - style: { - ...DefaultTextStyle, - fontSize: 28, - lineHeight: 32, - fill: fg, - align: 'center', - }, - }); - this.healthText = new Text({ - text: this.health, - style: { - ...DefaultTextStyle, - fontSize: 28, - lineHeight: 32, - fill: fg, - align: 'center', - }, - }); - this.visibleHealth = this.health; - this.healthTextBox = new Graphics(); - this.healthText.position.set((this.nameText.width/2) - this.healthText.width/2, 34); - applyGenericBoxStyle(this.healthTextBox).roundRect(-5,0,this.nameText.width+10,30, 4).stroke().fill(); - applyGenericBoxStyle(this.healthTextBox).roundRect(((this.nameText.width/2) - this.healthText.width/2) - 5,36,this.healthText.width+10,28, 4).stroke().fill(); - this.healthTextBox.addChild(this.healthText, this.nameText); - } + // TODO: Settling code. + // if (!this.physObject.body.isMoving() && this.wasMoving) { + // this.wasMoving = false; + // this.physObject.body.setRotation(0, false); + // this.physObject.body.setTranslation(add(this.physObject.body.translation(), new Vector2(0, -0.25)), false); - public update(dt: number): void { - super.update(dt); - if (this.destroyed) { - // TODO: Feels totally unnessacery. - return; - } - if (!this.healthTextBox.destroyed) { - // Nice and simple parenting :Z - this.healthTextBox.rotation = 0; - this.healthTextBox.position.set((this.sprite.x - (this.healthTextBox.width/2)) + (this.sprite.width/2), this.sprite.y - 100); - } + // } - if (this.healthChangeTensionTimer) { - this.wireframe.setDebugText(`tension: ${this.healthChangeTensionTimer}`); - } - - - // TODO: Settling code. - // if (!this.physObject.body.isMoving() && this.wasMoving) { - // this.wasMoving = false; - // this.physObject.body.setRotation(0, false); - // this.physObject.body.setTranslation(add(this.physObject.body.translation(), new Vector2(0, -0.25)), false); - - // } - - // Complex logic ahead, welcome to the health box tension timer! - // Whenever the entity takes damage, `healthChangeTensionTimer` is set to a unit of time before - // we can render the damage to the player. - - // Only decrease the timer when we have come to a standstill. - if (!this.gameWorld.areEntitiesMoving() && this.healthChangeTensionTimer) { - this.healthChangeTensionTimer -= dt; - } - - // If the timer has run out, set to null to indiciate it has expired. - if (this.healthChangeTensionTimer && this.healthChangeTensionTimer <= 0) { - if (this.visibleHealth === 0 && !this.isSinking) { - this.explode(); - return; - } - this.healthChangeTensionTimer = null; - } - - // If the timer is null, decrease the rendered health if nessacery. - if (this.healthChangeTensionTimer === null) { - if (this.visibleHealth > this.health) { - this.onHealthTensionTimerExpired(true); - this.visibleHealth--; - this.healthText.text = this.visibleHealth; - this.healthText.position.set((this.nameText.width/2) - this.healthText.width/2, 34); - if (this.visibleHealth <= this.health) { - this.onHealthTensionTimerExpired(false); - } - } - - // If we are dead, set a new timer to decrease to explode after a small delay. - if (this.visibleHealth === 0) { - this.healthChangeTensionTimer = HEALTH_CHANGE_TENSION_TIMER; - } - } + // Complex logic ahead, welcome to the health box tension timer! + // Whenever the entity takes damage, `healthChangeTensionTimer` is set to a unit of time before + // we can render the damage to the player. + // Only decrease the timer when we have come to a standstill. + if (!this.gameWorld.areEntitiesMoving() && this.healthChangeTensionTimer) { + this.healthChangeTensionTimer -= dt; } - protected onHealthTensionTimerExpired(decreasing: boolean) { + // If the timer has run out, set to null to indiciate it has expired. + if (this.healthChangeTensionTimer && this.healthChangeTensionTimer <= 0) { + if (this.visibleHealth === 0 && !this.isSinking) { + this.explode(); return; + } + this.healthChangeTensionTimer = null; } - public explode() { - const point = this.physObject.body.translation(); - handleDamageInRadius(this.gameWorld, this.parent, point, this.opts.explosionRadius, { maxDamage: SELF_EXPLODE_MAX_DAMAGE }); - this.destroy(); - } - - public onCollision(otherEnt: IPhysicalEntity, contactPoint: Vector2): boolean { - if (super.onCollision(otherEnt, contactPoint)) { - if (this.isSinking) { - this.wormIdent.health = 0; - this.healthTextBox.destroy(); - this.physObject.body.setRotation(DEG_TO_RAD*180, false); - } - return true; + // If the timer is null, decrease the rendered health if nessacery. + if (this.healthChangeTensionTimer === null) { + if (this.visibleHealth > this.health) { + this.onHealthTensionTimerExpired(true); + this.visibleHealth--; + this.healthText.text = this.visibleHealth; + this.healthText.position.set( + this.nameText.width / 2 - this.healthText.width / 2, + 34, + ); + if (this.visibleHealth <= this.health) { + this.onHealthTensionTimerExpired(false); } - return false; - } + } - public onDamage(point: Vector2, radius: MetersValue, opts: OnDamageOpts): void { - // TODO: Animate damage taken. - const bodyTranslation = this.physObject.body.translation(); - const forceMag = radius.value/magnitude(sub(point,this.physObject.body.translation())); - const damage = Math.min(opts.maxDamage ?? 100, Math.round((forceMag/20)*this.opts.damageMultiplier)); - this.health = Math.max(0, this.health - damage); - const force = mult(sub(point, bodyTranslation), new Vector2(-forceMag, -forceMag)); - this.physObject.body.applyImpulse(force, true) + // If we are dead, set a new timer to decrease to explode after a small delay. + if (this.visibleHealth === 0) { + this.healthChangeTensionTimer = HEALTH_CHANGE_TENSION_TIMER; + } } - - public recordState() { - return { - ...super.recordState(), - wormIdent: this.wormIdent.uuid, - } + } + + protected onHealthTensionTimerExpired(_decreasing: boolean) { + return; + } + + public explode() { + const point = this.physObject.body.translation(); + handleDamageInRadius( + this.gameWorld, + this.parent, + point, + this.opts.explosionRadius, + { maxDamage: SELF_EXPLODE_MAX_DAMAGE }, + ); + this.destroy(); + } + + public onCollision( + otherEnt: IPhysicalEntity, + contactPoint: Vector2, + ): boolean { + if (super.onCollision(otherEnt, contactPoint)) { + if (this.isSinking) { + this.wormIdent.health = 0; + this.healthTextBox.destroy(); + this.physObject.body.setRotation(DEG_TO_RAD * 180, false); + } + return true; } - - public destroy(): void { - super.destroy(); - if (!this.healthTextBox.destroyed) { - this.healthTextBox.destroy(); - } + return false; + } + + public onDamage( + point: Vector2, + radius: MetersValue, + opts: OnDamageOpts, + ): void { + // TODO: Animate damage taken. + const bodyTranslation = this.physObject.body.translation(); + const forceMag = + radius.value / magnitude(sub(point, this.physObject.body.translation())); + const damage = Math.min( + opts.maxDamage ?? 100, + Math.round((forceMag / 20) * this.opts.damageMultiplier), + ); + this.health = Math.max(0, this.health - damage); + const force = mult( + sub(point, bodyTranslation), + new Vector2(-forceMag, -forceMag), + ); + this.physObject.body.applyImpulse(force, true); + } + + public recordState() { + return { + ...super.recordState(), + wormIdent: this.wormIdent.uuid, + }; + } + + public destroy(): void { + super.destroy(); + if (!this.healthTextBox.destroyed) { + this.healthTextBox.destroy(); } -} \ No newline at end of file + } +} diff --git a/src/entities/playable/remoteWorm.ts b/src/entities/playable/remoteWorm.ts index be2bb36..e8ef5a6 100644 --- a/src/entities/playable/remoteWorm.ts +++ b/src/entities/playable/remoteWorm.ts @@ -8,71 +8,100 @@ import { StateWormAction } from "../../state/model"; import { InputKind } from "../../input"; import Logger from "../../log"; -const logger = new Logger('RemoteWorm'); +const logger = new Logger("RemoteWorm"); /** * An instance of the worm class controlled by a remote (or AI) player. */ export class RemoteWorm extends Worm { - static create(parent: Viewport, world: GameWorld, position: Coordinate, wormIdent: WormInstance, onFireWeapon: FireFn, toaster?: Toaster) { - const ent = new RemoteWorm(position, world, parent, wormIdent, onFireWeapon, toaster); - world.addBody(ent, ent.physObject.collider); - parent.addChild(ent.targettingGfx); - parent.addChild(ent.sprite); - parent.addChild(ent.wireframe.renderable); - parent.addChild(ent.healthTextBox); - return ent; - } + static create( + parent: Viewport, + world: GameWorld, + position: Coordinate, + wormIdent: WormInstance, + onFireWeapon: FireFn, + toaster?: Toaster, + ) { + const ent = new RemoteWorm( + position, + world, + parent, + wormIdent, + onFireWeapon, + toaster, + ); + world.addBody(ent, ent.physObject.collider); + parent.addChild(ent.targettingGfx); + parent.addChild(ent.sprite); + parent.addChild(ent.wireframe.renderable); + parent.addChild(ent.healthTextBox); + return ent; + } - private movementCyclesLeft = 0; - private remoteWeaponFiringDuration: number|undefined; + private movementCyclesLeft = 0; + private remoteWeaponFiringDuration: number | undefined; + private constructor( + position: Coordinate, + world: GameWorld, + parent: Viewport, + wormIdent: WormInstance, + onFireWeapon: FireFn, + toaster?: Toaster, + ) { + super(position, world, parent, wormIdent, onFireWeapon, toaster, undefined); + } - private constructor(position: Coordinate, world: GameWorld, parent: Viewport, wormIdent: WormInstance, onFireWeapon: FireFn, toaster?: Toaster) { - super(position, world, parent, wormIdent, onFireWeapon, toaster, undefined); + public replayWormAction(remoteAction: StateWormAction) { + switch (remoteAction) { + case StateWormAction.Jump: + this.onJump(); + break; + case StateWormAction.Backflip: + this.onBackflip(); + break; } + } - public replayWormAction(remoteAction: StateWormAction) { - switch (remoteAction) { - case StateWormAction.Jump: - this.onJump(); - break; - case StateWormAction.Backflip: - this.onBackflip(); - break; - } - } + replayFire(duration: number | undefined) { + this.onBeginFireWeapon(); + this.remoteWeaponFiringDuration = duration; + } - replayFire(duration: number | undefined) { - this.onBeginFireWeapon(); - this.remoteWeaponFiringDuration = duration; - } + replayMovement(action: StateWormAction, cycles: number) { + const inputKind = + action === StateWormAction.MoveLeft + ? InputKind.MoveLeft + : InputKind.MoveRight; + this.setMoveDirection(inputKind); + this.movementCyclesLeft = cycles; + } - replayMovement(action: StateWormAction, cycles: number) { - const inputKind = action === StateWormAction.MoveLeft ? InputKind.MoveLeft : InputKind.MoveRight; - this.setMoveDirection(inputKind); - this.movementCyclesLeft = cycles; + update(dt: number): void { + if (this.state === WormState.Firing) { + if ( + this.remoteWeaponFiringDuration === undefined || + this.fireWeaponDuration > this.remoteWeaponFiringDuration + ) { + logger.debug("firing weapon"); + this.fireWeaponDuration = this.remoteWeaponFiringDuration ?? 0; + this.onEndFireWeapon(); + } } - - update(dt: number): void { - if (this.state === WormState.Firing) { - if (this.remoteWeaponFiringDuration === undefined || this.fireWeaponDuration > this.remoteWeaponFiringDuration) { - logger.debug('firing weapon'); - this.fireWeaponDuration = this.remoteWeaponFiringDuration ?? 0; - this.onEndFireWeapon(); - } - } - super.update(dt); - if (this.state === WormState.MovingLeft || this.state === WormState.MovingRight) { - this.movementCyclesLeft -= 1; - if (this.movementCyclesLeft === 0) { - this.state = WormState.Idle; - } - } + super.update(dt); + if ( + this.state === WormState.MovingLeft || + this.state === WormState.MovingRight + ) { + this.movementCyclesLeft -= 1; + if (this.movementCyclesLeft === 0) { + this.state = WormState.Idle; + } } + } - replayAim(_dir: "up"|"down", aim: number) { - // TODO: Needs animation. - this.fireAngle = aim; - } -} \ No newline at end of file + replayAim(_dir: "up" | "down", aim: number) { + // TODO: Needs animation. + this.fireAngle = aim; + } +} diff --git a/src/entities/playable/testDummy.ts b/src/entities/playable/testDummy.ts index d85d201..53c6183 100644 --- a/src/entities/playable/testDummy.ts +++ b/src/entities/playable/testDummy.ts @@ -1,8 +1,17 @@ import { Sprite, Texture, UPDATE_PRIORITY } from "pixi.js"; import { AssetPack } from "../../assets"; -import { collisionGroupBitmask, CollisionGroups, GameWorld, PIXELS_PER_METER } from "../../world"; +import { + collisionGroupBitmask, + CollisionGroups, + GameWorld, + PIXELS_PER_METER, +} from "../../world"; import { Coordinate, MetersValue } from "../../utils"; -import { ActiveEvents, ColliderDesc, RigidBodyDesc } from "@dimforge/rapier2d-compat"; +import { + ActiveEvents, + ColliderDesc, + RigidBodyDesc, +} from "@dimforge/rapier2d-compat"; import { WormInstance } from "../../logic/teams"; import { PlayableEntity } from "./playable"; import { Viewport } from "pixi-viewport"; @@ -14,90 +23,110 @@ import { EntityType } from "../type"; * hitpoints for a team. */ export class TestDummy extends PlayableEntity { + public static readAssets(assets: AssetPack) { + TestDummy.texture_normal = assets.textures.testDolby; + TestDummy.texture_blush = assets.textures.testDolbyBlush; + TestDummy.texture_damage_1 = assets.textures.testDolbyDamage1; + TestDummy.texture_damage_blush_1 = assets.textures.testDolbyDamage1Blush; + TestDummy.texture_damage_2 = assets.textures.testDolbyDamage2Blush; + TestDummy.texture_damage_blush_2 = assets.textures.testDolbyDamage2Blush; + TestDummy.texture_damage_3 = assets.textures.testDolbyDamage3; + TestDummy.texture_damage_blush_3 = assets.textures.testDolbyDamage3Blush; + } - public static readAssets(assets: AssetPack) { - TestDummy.texture_normal = assets.textures.testDolby; - TestDummy.texture_blush = assets.textures.testDolbyBlush; - TestDummy.texture_damage_1 = assets.textures.testDolbyDamage1; - TestDummy.texture_damage_blush_1 = assets.textures.testDolbyDamage1Blush; - TestDummy.texture_damage_2 = assets.textures.testDolbyDamage2Blush; - TestDummy.texture_damage_blush_2 = assets.textures.testDolbyDamage2Blush; - TestDummy.texture_damage_3 = assets.textures.testDolbyDamage3; - TestDummy.texture_damage_blush_3 = assets.textures.testDolbyDamage3Blush; - } + private static texture_normal: Texture; + private static texture_blush: Texture; + private static texture_damage_1: Texture; + private static texture_damage_blush_1: Texture; + private static texture_damage_2: Texture; + private static texture_damage_blush_2: Texture; + private static texture_damage_3: Texture; + private static texture_damage_blush_3: Texture; - private static texture_normal: Texture; - private static texture_blush: Texture; - private static texture_damage_1: Texture; - private static texture_damage_blush_1: Texture; - private static texture_damage_2: Texture; - private static texture_damage_blush_2: Texture; - private static texture_damage_3: Texture; - private static texture_damage_blush_3: Texture; + priority = UPDATE_PRIORITY.LOW; + private static readonly collisionBitmask = collisionGroupBitmask( + [CollisionGroups.WorldObjects], + [CollisionGroups.Terrain, CollisionGroups.WorldObjects], + ); - priority = UPDATE_PRIORITY.LOW; - private static readonly collisionBitmask = collisionGroupBitmask([CollisionGroups.WorldObjects], [CollisionGroups.Terrain, CollisionGroups.WorldObjects]); + static create( + parent: Viewport, + world: GameWorld, + position: Coordinate, + wormIdent: WormInstance, + ) { + const ent = new TestDummy(position, world, parent, wormIdent); + world.addBody(ent, ent.physObject.collider); + parent.addChild(ent.sprite); + parent.addChild(ent.wireframe.renderable); + parent.addChild(ent.healthTextBox); + return ent; + } + private constructor( + position: Coordinate, + world: GameWorld, + parent: Viewport, + wormIdent: WormInstance, + ) { + const sprite = new Sprite(TestDummy.texture_normal); + sprite.scale.set(0.2); + sprite.anchor.set(0.5); + const body = world.createRigidBodyCollider( + ColliderDesc.cuboid( + (sprite.width - 7) / (PIXELS_PER_METER * 2), + (sprite.height - 15) / (PIXELS_PER_METER * 2), + ) + .setActiveEvents(ActiveEvents.COLLISION_EVENTS) + .setCollisionGroups(TestDummy.collisionBitmask) + .setSolverGroups(TestDummy.collisionBitmask) + .setMass(0.35), + RigidBodyDesc.dynamic().setTranslation(position.worldX, position.worldY), + ); + super(sprite, body, world, parent, wormIdent, { + explosionRadius: new MetersValue(3), + damageMultiplier: 250, + }); + } - static create(parent: Viewport, world: GameWorld, position: Coordinate, wormIdent: WormInstance) { - const ent = new TestDummy(position, world, parent, wormIdent); - world.addBody(ent, ent.physObject.collider); - parent.addChild(ent.sprite); - parent.addChild(ent.wireframe.renderable); - parent.addChild(ent.healthTextBox); - return ent; - } + private getTexture() { + const isBlush = this.health < 100 && this.physObject.body.isMoving(); - private constructor(position: Coordinate, world: GameWorld, parent: Viewport, wormIdent: WormInstance) { - const sprite = new Sprite(TestDummy.texture_normal); - sprite.scale.set(0.20); - sprite.anchor.set(0.5); - const body = world.createRigidBodyCollider( - ColliderDesc.cuboid((sprite.width-7) / (PIXELS_PER_METER*2), (sprite.height-15) / (PIXELS_PER_METER*2)) - .setActiveEvents(ActiveEvents.COLLISION_EVENTS) - .setCollisionGroups(TestDummy.collisionBitmask) - .setSolverGroups(TestDummy.collisionBitmask) - .setMass(0.35), - RigidBodyDesc.dynamic().setTranslation(position.worldX, position.worldY) - ); - super(sprite, body, world, parent, wormIdent, { - explosionRadius: new MetersValue(3), - damageMultiplier: 250, - }); + if (this.health >= 80) { + return isBlush ? TestDummy.texture_blush : TestDummy.texture_normal; + } else if (this.health >= 60) { + return isBlush + ? TestDummy.texture_damage_blush_1 + : TestDummy.texture_damage_1; + } else if (this.health >= 25) { + return isBlush + ? TestDummy.texture_damage_blush_2 + : TestDummy.texture_damage_2; + } else { + return isBlush + ? TestDummy.texture_damage_blush_3 + : TestDummy.texture_damage_3; } + } - private getTexture() { - const isBlush = this.health < 100 && this.physObject.body.isMoving(); - - if (this.health >= 80) { - return isBlush ? TestDummy.texture_blush : TestDummy.texture_normal; - } else if (this.health >= 60) { - return isBlush ? TestDummy.texture_damage_blush_1 : TestDummy.texture_damage_1; - } else if (this.health >= 25) { - return isBlush ? TestDummy.texture_damage_blush_2 : TestDummy.texture_damage_2; - } else { - return isBlush ? TestDummy.texture_damage_blush_3 : TestDummy.texture_damage_3; - } + public update(dt: number): void { + const expectedTexture = this.getTexture(); + if (this.sprite.texture !== expectedTexture) { + this.sprite.texture = expectedTexture; } + super.update(dt); + } - public update(dt: number): void { - const expectedTexture = this.getTexture(); - if (this.sprite.texture !== expectedTexture) { - this.sprite.texture = expectedTexture; - } - super.update(dt); - } + public destroy(): void { + super.destroy(); + this.parent.plugins.remove("follow"); + this.parent.snap(800, 0); + } - public destroy(): void { - super.destroy(); - this.parent.plugins.remove('follow'); - this.parent.snap(800,0); - } - - public recordState() { - return { - ...super.recordState(), - type: EntityType.TestDummy, - } - } -} \ No newline at end of file + public recordState() { + return { + ...super.recordState(), + type: EntityType.TestDummy, + }; + } +} diff --git a/src/entities/playable/worm.ts b/src/entities/playable/worm.ts index ec82e38..0af3545 100644 --- a/src/entities/playable/worm.ts +++ b/src/entities/playable/worm.ts @@ -1,42 +1,70 @@ -import { Graphics, Sprite, Texture } from 'pixi.js'; -import { FireOpts, IWeaponDefiniton, WeaponFireResult } from '../../weapons/weapon'; -import Controller, { InputKind } from '../../input'; -import { collisionGroupBitmask, CollisionGroups, GameWorld, PIXELS_PER_METER } from '../../world'; -import { ActiveEvents, ColliderDesc, RigidBodyDesc, Vector2 } from "@dimforge/rapier2d-compat"; -import { Coordinate, MetersValue } from '../../utils/coodinate'; -import { AssetPack } from '../../assets'; -import { PlayableEntity } from './playable'; -import { teamGroupToColorSet, WormInstance } from '../../logic/teams'; -import { calculateMovement } from '../../movementController'; -import { Viewport } from 'pixi-viewport'; -import { magnitude, pointOnRadius, sub } from '../../utils'; -import { Toaster } from '../../overlays/toaster'; -import { FireResultHitEnemy, FireResultHitOwnTeam, FireResultHitSelf, FireResultKilledEnemy, FireResultKilledOwnTeam, FireResultKilledSelf, FireResultMiss, templateRandomText, TurnEndTextFall, TurnStartText, WeaponTimerText, WormDeathGeneric, WormDeathSinking } from '../../text/toasts'; -import { WeaponBazooka } from '../../weapons'; -import { EntityType } from '../type'; -import { StateRecorder } from '../../state/recorder'; -import { StateWormAction } from '../../state/model'; -import { CameraLockPriority } from '../../camera'; -import { OnDamageOpts } from '../entity'; +import { Graphics, Sprite, Texture } from "pixi.js"; +import { + FireOpts, + IWeaponDefiniton, + WeaponFireResult, +} from "../../weapons/weapon"; +import Controller, { InputKind } from "../../input"; +import { + collisionGroupBitmask, + CollisionGroups, + GameWorld, + PIXELS_PER_METER, +} from "../../world"; +import { + ActiveEvents, + ColliderDesc, + RigidBodyDesc, + Vector2, +} from "@dimforge/rapier2d-compat"; +import { Coordinate, MetersValue } from "../../utils/coodinate"; +import { AssetPack } from "../../assets"; +import { PlayableEntity } from "./playable"; +import { teamGroupToColorSet, WormInstance } from "../../logic/teams"; +import { calculateMovement } from "../../movementController"; +import { Viewport } from "pixi-viewport"; +import { magnitude, pointOnRadius, sub } from "../../utils"; +import { Toaster } from "../../overlays/toaster"; +import { + FireResultHitEnemy, + FireResultHitOwnTeam, + FireResultHitSelf, + FireResultKilledEnemy, + FireResultKilledOwnTeam, + FireResultKilledSelf, + FireResultMiss, + templateRandomText, + TurnEndTextFall, + TurnStartText, + WeaponTimerText, + WormDeathGeneric, + WormDeathSinking, +} from "../../text/toasts"; +import { WeaponBazooka } from "../../weapons"; +import { EntityType } from "../type"; +import { StateRecorder } from "../../state/recorder"; +import { StateWormAction } from "../../state/model"; +import { CameraLockPriority } from "../../camera"; +import { OnDamageOpts } from "../entity"; export enum WormState { - Idle = 0, - InMotion = 1, - Firing = 2, - MovingLeft = 3, - MovingRight = 4, - AimingUp = 5, - AimingDown = 6, - InactiveWaiting = 98, - Inactive = 99, + Idle = 0, + InMotion = 1, + Firing = 2, + MovingLeft = 3, + MovingRight = 4, + AimingUp = 5, + AimingDown = 6, + InactiveWaiting = 98, + Inactive = 99, } export enum EndTurnReason { - TimerElapsed = 0, - FallDamage = 1, - FiredWeapon= 2, - Sank = 3, - TookDamage = 4, + TimerElapsed = 0, + FallDamage = 1, + FiredWeapon = 2, + Sank = 3, + TookDamage = 4, } const MaxAim = Math.PI * 1.5; // Up @@ -46,477 +74,612 @@ const FireAngleArcPadding = 0.15; const maxWormStep = new MetersValue(0.6); const aimMoveSpeed = 0.02; -export type FireFn = (worm: Worm, selectedWeapon: IWeaponDefiniton, opts: FireOpts) => Promise; +export type FireFn = ( + worm: Worm, + selectedWeapon: IWeaponDefiniton, + opts: FireOpts, +) => Promise; interface PerRoundState { - shotsTaken: number; -}; + shotsTaken: number; +} const DEFAULT_PER_ROUND_STATE = { - shotsTaken: 0, -} + shotsTaken: 0, +}; /** * Physical representation of a worm on the map. May be controlled. */ export class Worm extends PlayableEntity { - private static readonly collisionBitmask = collisionGroupBitmask([CollisionGroups.WorldObjects], [CollisionGroups.Terrain, CollisionGroups.WorldObjects]); - - public static readAssets(assets: AssetPack) { - Worm.texture = assets.textures.grenade; + private static readonly collisionBitmask = collisionGroupBitmask( + [CollisionGroups.WorldObjects], + [CollisionGroups.Terrain, CollisionGroups.WorldObjects], + ); + + public static readAssets(assets: AssetPack) { + Worm.texture = assets.textures.grenade; + } + + private static texture: Texture; + private static impactDamageMultiplier = 0.75; + private static minImpactForDamage = 12; + + protected fireWeaponDuration = 0; + private currentWeapon: IWeaponDefiniton = WeaponBazooka; + protected state: WormState = WormState.Inactive; + private statePriorToMotion: WormState = WormState.Idle; + private turnEndedReason: EndTurnReason | undefined; + private impactVelocity = 0; + // TODO: Best place for this var? + private weaponTimerSecs = 3; + public fireAngle = 0; + protected targettingGfx: Graphics; + private facingRight = true; + private movingCycles = 0; + private perRoundState: PerRoundState = { ...DEFAULT_PER_ROUND_STATE }; + + static create( + parent: Viewport, + world: GameWorld, + position: Coordinate, + wormIdent: WormInstance, + onFireWeapon: FireFn, + toaster?: Toaster, + recorder?: StateRecorder, + ) { + const ent = new Worm( + position, + world, + parent, + wormIdent, + onFireWeapon, + toaster, + recorder, + ); + world.addBody(ent, ent.physObject.collider); + parent.addChild(ent.targettingGfx); + parent.addChild(ent.sprite); + parent.addChild(ent.wireframe.renderable); + parent.addChild(ent.healthTextBox); + return ent; + } + + get position() { + return this.physObject.body.translation(); + } + + get currentState() { + return this.state; + } + + get endTurnReason() { + return this.turnEndedReason; + } + + get collider() { + return this.physObject.collider; + } + + get weapon() { + return this.currentWeapon; + } + + public selectWeapon(weapon: IWeaponDefiniton) { + if (this.perRoundState.shotsTaken > 0) { + // Worm is already in progress of shooting things. + return; } - - private static texture: Texture; - private static impactDamageMultiplier = 0.75; - private static minImpactForDamage = 12; - - protected fireWeaponDuration = 0; - private currentWeapon: IWeaponDefiniton = WeaponBazooka; - protected state: WormState = WormState.Inactive; - private statePriorToMotion: WormState = WormState.Idle; - private turnEndedReason: EndTurnReason|undefined; - private impactVelocity = 0; - // TODO: Best place for this var? - private weaponTimerSecs = 3; - public fireAngle = 0; - protected targettingGfx: Graphics; - private facingRight = true; - private movingCycles = 0; - private perRoundState: PerRoundState = { ...DEFAULT_PER_ROUND_STATE }; - - static create(parent: Viewport, world: GameWorld, position: Coordinate, wormIdent: WormInstance, onFireWeapon: FireFn, toaster?: Toaster, recorder?: StateRecorder) { - const ent = new Worm(position, world, parent, wormIdent, onFireWeapon, toaster, recorder); - world.addBody(ent, ent.physObject.collider); - parent.addChild(ent.targettingGfx); - parent.addChild(ent.sprite); - parent.addChild(ent.wireframe.renderable); - parent.addChild(ent.healthTextBox); - return ent; + this.currentWeapon = weapon; + this.recorder?.recordWormSelectWeapon(this.wormIdent.uuid, weapon.code); + } + + protected constructor( + position: Coordinate, + world: GameWorld, + parent: Viewport, + wormIdent: WormInstance, + private readonly onFireWeapon: FireFn, + private readonly toaster?: Toaster, + private readonly recorder?: StateRecorder, + ) { + const sprite = new Sprite(Worm.texture); + sprite.scale.set(0.5, 0.5); + sprite.anchor.set(0.5, 0.5); + const body = world.createRigidBodyCollider( + ColliderDesc.cuboid( + sprite.width / (PIXELS_PER_METER * 2), + sprite.height / (PIXELS_PER_METER * 2), + ) + .setActiveEvents(ActiveEvents.COLLISION_EVENTS) + .setCollisionGroups(Worm.collisionBitmask) + .setSolverGroups(Worm.collisionBitmask), + RigidBodyDesc.dynamic() + .setTranslation(position.worldX, position.worldY) + .lockRotations(), + ); + super(sprite, body, world, parent, wormIdent, { + explosionRadius: new MetersValue(5), + damageMultiplier: 250, + }); + this.targettingGfx = new Graphics({ visible: false }); + this.updateTargettingGfx(); + } + + onWormSelected() { + this.state = WormState.Idle; + this.cameraLockPriority = CameraLockPriority.SuggestedLockLocal; + this.perRoundState = { ...DEFAULT_PER_ROUND_STATE }; + this.toaster?.pushToast( + templateRandomText(TurnStartText, { + WormName: this.wormIdent.name, + TeamName: this.wormIdent.team.name, + }), + 3000, + teamGroupToColorSet(this.wormIdent.team.group).fg, + ); + Controller.on("inputBegin", this.onInputBegin); + Controller.on("inputEnd", this.onInputEnd); + } + + onEndOfTurn() { + Controller.removeListener("inputBegin", this.onInputBegin); + Controller.removeListener("inputEnd", this.onInputEnd); + this.cameraLockPriority = CameraLockPriority.NoLock; + this.targettingGfx.visible = false; + } + + onJump() { + this.recorder?.recordWormAction(this.wormIdent.uuid, StateWormAction.Jump); + this.state = WormState.InMotion; + this.body.applyImpulse({ x: this.facingRight ? 5 : -5, y: -8 }, true); + } + + onBackflip() { + this.state = WormState.InMotion; + this.recorder?.recordWormAction( + this.wormIdent.uuid, + StateWormAction.Backflip, + ); + this.body.applyImpulse({ x: this.facingRight ? -3 : 3, y: -13 }, true); + } + + onInputBegin = (inputKind: InputKind) => { + if (this.state === WormState.Firing) { + // Ignore all input when the worm is firing. + return; } - - get position() { - return this.physObject.body.translation(); + if (inputKind === InputKind.MoveLeft || inputKind === InputKind.MoveRight) { + this.setMoveDirection(inputKind); + } else if (this.state !== WormState.Idle) { + return; + } else if (inputKind === InputKind.AimUp) { + this.state = WormState.AimingUp; + } else if (inputKind === InputKind.AimDown) { + this.state = WormState.AimingDown; + } else if (inputKind === InputKind.Fire) { + this.onBeginFireWeapon(); + } else if (inputKind === InputKind.Jump) { + this.onJump(); + } else if (inputKind === InputKind.Backflip) { + this.onBackflip(); } - - get currentState() { - return this.state; + if (this.currentWeapon.timerAdjustable) { + const oldTime = this.weaponTimerSecs; + switch (inputKind) { + case InputKind.WeaponTimer1: + this.weaponTimerSecs = 1; + break; + case InputKind.WeaponTimer2: + this.weaponTimerSecs = 2; + break; + case InputKind.WeaponTimer3: + this.weaponTimerSecs = 3; + break; + case InputKind.WeaponTimer4: + this.weaponTimerSecs = 4; + break; + case InputKind.WeaponTimer5: + this.weaponTimerSecs = 5; + break; + } + if (this.weaponTimerSecs !== oldTime) { + this.toaster?.pushToast( + templateRandomText(WeaponTimerText, { + Time: this.weaponTimerSecs.toString(), + }), + 1250, + undefined, + true, + ); + } } + }; - get endTurnReason() { - return this.turnEndedReason; + onInputEnd = (inputKind: InputKind) => { + if (inputKind === InputKind.Fire) { + this.onEndFireWeapon(); } - - get collider() { - return this.physObject.collider; + if (this.state === WormState.Firing) { + // Ignore all input when the worm is firing. + return; } - - get weapon() { - return this.currentWeapon; + if (inputKind === InputKind.MoveLeft || inputKind === InputKind.MoveRight) { + this.resetMoveDirection(inputKind); } - - public selectWeapon(weapon: IWeaponDefiniton) { - if (this.perRoundState.shotsTaken > 0) { - // Worm is already in progress of shooting things. - return; - } - this.currentWeapon = weapon; - this.recorder?.recordWormSelectWeapon(this.wormIdent.uuid, weapon.code); + if (inputKind === InputKind.AimUp || inputKind === InputKind.AimDown) { + this.recorder?.recordWormAim( + this.wormIdent.uuid, + this.state === WormState.AimingUp ? "up" : "down", + this.fireAngle, + ); + this.state = WormState.Idle; } + }; - protected constructor(position: Coordinate, world: GameWorld, parent: Viewport, wormIdent: WormInstance, private readonly onFireWeapon: FireFn, private readonly toaster?: Toaster, private readonly recorder?: StateRecorder) { - const sprite = new Sprite(Worm.texture); - sprite.scale.set(0.5, 0.5); - sprite.anchor.set(0.5, 0.5); - const body = world.createRigidBodyCollider( - ColliderDesc.cuboid(sprite.width / (PIXELS_PER_METER*2), sprite.height / (PIXELS_PER_METER*2)) - .setActiveEvents(ActiveEvents.COLLISION_EVENTS) - .setCollisionGroups(Worm.collisionBitmask) - .setSolverGroups(Worm.collisionBitmask), - RigidBodyDesc.dynamic().setTranslation(position.worldX, position.worldY).lockRotations() - ); - super(sprite, body, world, parent, wormIdent, { - explosionRadius: new MetersValue(5), - damageMultiplier: 250, - }); - this.targettingGfx = new Graphics({ visible: false }); - this.updateTargettingGfx(); + setMoveDirection(direction: InputKind.MoveLeft | InputKind.MoveRight) { + // We can only change direction if we are idle. + if (this.state !== WormState.Idle) { + // Falling, can't move + return; } - - onWormSelected() { - this.state = WormState.Idle; - this.cameraLockPriority = CameraLockPriority.SuggestedLockLocal; - this.perRoundState = {...DEFAULT_PER_ROUND_STATE}; - this.toaster?.pushToast(templateRandomText(TurnStartText, { - WormName: this.wormIdent.name, - TeamName: this.wormIdent.team.name, - }), 3000, teamGroupToColorSet(this.wormIdent.team.group).fg); - Controller.on('inputBegin', this.onInputBegin); - Controller.on('inputEnd', this.onInputEnd); + const changedDirection = + (direction === InputKind.MoveLeft && this.facingRight) || + (direction === InputKind.MoveRight && !this.facingRight); + + if (changedDirection) { + this.fireAngle = MaxAim + (MaxAim - this.fireAngle); + if (this.fireAngle > Math.PI * 2) { + this.fireAngle -= Math.PI * 2; + } + if (this.fireAngle < 0) { + this.fireAngle = Math.PI * 2 - this.fireAngle; + } + this.facingRight = !this.facingRight; } - onEndOfTurn() { - Controller.removeListener('inputBegin', this.onInputBegin); - Controller.removeListener('inputEnd', this.onInputEnd); - this.cameraLockPriority = CameraLockPriority.NoLock; - this.targettingGfx.visible = false; + this.state = + direction === InputKind.MoveLeft + ? WormState.MovingLeft + : WormState.MovingRight; + } + + protected onHealthTensionTimerExpired(decreasing: boolean): void { + this.cameraLockPriority = decreasing + ? CameraLockPriority.LockIfNotLocalPlayer + : CameraLockPriority.NoLock; + } + + resetMoveDirection( + inputDirection?: InputKind.MoveLeft | InputKind.MoveRight, + ) { + // We can only stop moving if we are in control of our movements and the input that + // completed was the movement key. + + if (this.state === WormState.InMotion) { + this.statePriorToMotion = WormState.Idle; } - onJump() { - this.recorder?.recordWormAction(this.wormIdent.uuid, StateWormAction.Jump); - this.state = WormState.InMotion; - this.body.applyImpulse({x: this.facingRight ? 5 : -5, y: -8}, true); + if ( + (this.state === WormState.MovingLeft && + inputDirection === InputKind.MoveLeft) || + (this.state === WormState.MovingRight && + inputDirection === InputKind.MoveRight) || + !inputDirection + ) { + this.recorder?.recordWormMove( + this.wormIdent.uuid, + this.state === WormState.MovingLeft ? "left" : "right", + this.movingCycles, + ); + this.movingCycles = 0; + this.state = WormState.Idle; + return; } - - onBackflip() { - this.state = WormState.InMotion; - this.recorder?.recordWormAction(this.wormIdent.uuid, StateWormAction.Backflip); - this.body.applyImpulse({x: this.facingRight ? -3 : 3, y: -13}, true); + } + + onMove(moveState: WormState.MovingLeft | WormState.MovingRight, dt: number) { + const movementMod = 0.1 * dt; + const moveMod = new Vector2( + moveState === WormState.MovingLeft ? -movementMod : movementMod, + 0, + ); + const move = calculateMovement( + this.physObject, + moveMod, + maxWormStep, + this.gameWorld, + ); + this.movingCycles += 1; + this.physObject.body.setTranslation(move, false); + } + + onBeginFireWeapon() { + this.state = WormState.Firing; + } + + public onDamage( + point: Vector2, + radius: MetersValue, + opts: OnDamageOpts, + ): void { + super.onDamage(point, radius, opts); + if ( + this.state === WormState.InactiveWaiting || + this.state === WormState.Idle + ) { + this.state = WormState.Inactive; + this.turnEndedReason = EndTurnReason.TookDamage; } + } - onInputBegin = (inputKind: InputKind) => { - if (this.state === WormState.Firing) { - // Ignore all input when the worm is firing. - return; - } - if (inputKind === InputKind.MoveLeft || inputKind === InputKind.MoveRight) { - this.setMoveDirection(inputKind); - } else if (this.state !== WormState.Idle) { - return; - } else if (inputKind === InputKind.AimUp) { - this.state = WormState.AimingUp; - } else if (inputKind === InputKind.AimDown) { - this.state = WormState.AimingDown; - } else if (inputKind === InputKind.Fire) { - this.onBeginFireWeapon(); - } else if (inputKind === InputKind.Jump) { - this.onJump(); - } else if (inputKind === InputKind.Backflip) { - this.onBackflip(); - } - if (this.currentWeapon.timerAdjustable) { - const oldTime = this.weaponTimerSecs; - switch(inputKind) { - case InputKind.WeaponTimer1: - this.weaponTimerSecs = 1; - break; - case InputKind.WeaponTimer2: - this.weaponTimerSecs = 2; - break; - case InputKind.WeaponTimer3: - this.weaponTimerSecs = 3; - break; - case InputKind.WeaponTimer4: - this.weaponTimerSecs = 4; - break; - case InputKind.WeaponTimer5: - this.weaponTimerSecs = 5; - break; - } - if (this.weaponTimerSecs !== oldTime) { - this.toaster?.pushToast(templateRandomText(WeaponTimerText, { - Time: this.weaponTimerSecs.toString(), - }), 1250, undefined, true); - } - } + onEndFireWeapon() { + if (this.state !== WormState.Firing) { + return; } - - onInputEnd = (inputKind: InputKind) => { - if (inputKind === InputKind.Fire) { - this.onEndFireWeapon(); - } - if (this.state === WormState.Firing) { - // Ignore all input when the worm is firing. - return; - } - if (inputKind === InputKind.MoveLeft || inputKind === InputKind.MoveRight) { - this.resetMoveDirection(inputKind); - } - if (inputKind === InputKind.AimUp || inputKind === InputKind.AimDown) { - this.recorder?.recordWormAim(this.wormIdent.uuid, this.state === WormState.AimingUp ? "up" : "down", this.fireAngle); - this.state = WormState.Idle; - } + const maxShots = this.weapon.shots ?? 1; + const duration = this.fireWeaponDuration; + this.recorder?.recordWormFire(this.wormIdent.uuid, duration); + this.targettingGfx.visible = false; + this.perRoundState.shotsTaken++; + // TODO: Need a middle state for while the world is still active. + this.cameraLockPriority = CameraLockPriority.NoLock; + this.state = WormState.InactiveWaiting; + this.fireWeaponDuration = 0; + this.onFireWeapon(this, this.currentWeapon, { + duration, + timer: this.weaponTimerSecs, + angle: this.fireAngle, + }).then((fireResult) => { + if (maxShots === this.perRoundState.shotsTaken) { + this.turnEndedReason = EndTurnReason.FiredWeapon; + this.state = WormState.Inactive; + } else { + this.state = WormState.Idle; + } + let randomTextSet: string[]; + if (fireResult.includes(WeaponFireResult.KilledOwnTeam)) { + randomTextSet = FireResultKilledOwnTeam; + } else if (fireResult.includes(WeaponFireResult.KilledSelf)) { + randomTextSet = FireResultKilledSelf; + } else if (fireResult.includes(WeaponFireResult.KilledEnemy)) { + randomTextSet = FireResultKilledEnemy; + } else if (fireResult.includes(WeaponFireResult.HitEnemy)) { + randomTextSet = FireResultHitEnemy; + } else if (fireResult.includes(WeaponFireResult.HitOwnTeam)) { + randomTextSet = FireResultHitOwnTeam; + } else if (fireResult.includes(WeaponFireResult.HitSelf)) { + randomTextSet = FireResultHitSelf; + } else if (fireResult.includes(WeaponFireResult.NoHit)) { + randomTextSet = FireResultMiss; + } else { + // Unknown. + return; + } + + this.toaster?.pushToast( + templateRandomText(randomTextSet, { + WormName: this.wormIdent.name, + TeamName: this.wormIdent.team.name, + }), + 2000, + ); + }); + this.updateTargettingGfx(); + } + + updateTargettingGfx() { + this.targettingGfx.clear(); + const teamFgColour = teamGroupToColorSet(this.wormIdent.team.group).fg; + this.targettingGfx + .circle(0, 0, 12) + .stroke({ + color: teamFgColour, + width: 2, + }) + .moveTo(-12, 0) + .lineTo(12, 0) + .moveTo(0, -12) + .lineTo(0, 12) + .stroke({ + color: teamFgColour, + width: 4, + }) + .circle(0, 0, 3) + .fill({ + color: "white", + }); + if (this.state === WormState.Firing && this.currentWeapon.maxDuration) { + const mag = this.fireWeaponDuration / this.currentWeapon.maxDuration; + const relativeSpritePos = sub( + this.sprite.position, + this.targettingGfx.position, + ); + this.targettingGfx + .moveTo(relativeSpritePos.x, relativeSpritePos.y) + .arc( + relativeSpritePos.x, + relativeSpritePos.y, + mag * targettingRadius.pixels, + this.fireAngle - FireAngleArcPadding, + this.fireAngle + FireAngleArcPadding, + ) + .moveTo(relativeSpritePos.x, relativeSpritePos.y) + .fill({ + color: teamFgColour, + }); } + } - setMoveDirection(direction: InputKind.MoveLeft|InputKind.MoveRight) { - // We can only change direction if we are idle. - if (this.state !== WormState.Idle) { - // Falling, can't move - return; + updateAiming() { + if (this.state === WormState.AimingUp) { + if (this.facingRight) { + if (this.fireAngle >= MaxAim || this.fireAngle <= MinAim) { + this.fireAngle = this.fireAngle - aimMoveSpeed; } - const changedDirection = (direction === InputKind.MoveLeft && this.facingRight) || (direction === InputKind.MoveRight && !this.facingRight); - - if (changedDirection) { - this.fireAngle = MaxAim + (MaxAim - this.fireAngle); - if (this.fireAngle > Math.PI*2) { - this.fireAngle -= Math.PI*2; - } - if (this.fireAngle < 0) { - this.fireAngle = (Math.PI*2) - this.fireAngle; - } - this.facingRight = !this.facingRight; + } else { + if (this.fireAngle <= MaxAim || this.fireAngle >= MinAim) { + this.fireAngle = this.fireAngle + aimMoveSpeed; } - - this.state = direction === InputKind.MoveLeft ? WormState.MovingLeft : WormState.MovingRight; - } - - protected onHealthTensionTimerExpired(decreasing: boolean): void { - this.cameraLockPriority = decreasing ? CameraLockPriority.LockIfNotLocalPlayer : CameraLockPriority.NoLock; - } - - resetMoveDirection(inputDirection?: InputKind.MoveLeft|InputKind.MoveRight) { - // We can only stop moving if we are in control of our movements and the input that - // completed was the movement key. - - if (this.state === WormState.InMotion) { - this.statePriorToMotion = WormState.Idle; - } - - if ((this.state === WormState.MovingLeft && inputDirection === InputKind.MoveLeft) || - (this.state === WormState.MovingRight && inputDirection === InputKind.MoveRight) || - !inputDirection - ) { - this.recorder?.recordWormMove(this.wormIdent.uuid, this.state === WormState.MovingLeft ? "left" : "right", this.movingCycles); - this.movingCycles = 0; - this.state = WormState.Idle; - return; + } + } else if (this.state === WormState.AimingDown) { + if (this.facingRight) { + if (this.fireAngle >= MaxAim || this.fireAngle <= MinAim) { + this.fireAngle = this.fireAngle + aimMoveSpeed; // Math.max(this.fireAngle - aimMoveSpeed, MinAim); } + } else { + this.fireAngle = this.fireAngle - aimMoveSpeed; //Math.min(this.fireAngle + aimMoveSpeed, MaxAim); + } + } // else, we're idle and not currently moving. + + if (this.facingRight) { + if ( + this.fireAngle < MaxAim && + this.fireAngle > MaxAim - aimMoveSpeed * 2 + ) { + this.fireAngle = MaxAim; + } + if (this.fireAngle > MinAim && this.fireAngle < MaxAim) { + this.fireAngle = MinAim; + } + } else { + if ( + this.fireAngle > MaxAim && + this.fireAngle < MaxAim + aimMoveSpeed * 2 + ) { + this.fireAngle = MaxAim; + } + if (this.fireAngle < MinAim && this.fireAngle < MaxAim) { + this.fireAngle = MinAim; + } } - onMove(moveState: WormState.MovingLeft|WormState.MovingRight, dt: number) { - const movementMod = 0.1*dt; - const moveMod = new Vector2(moveState === WormState.MovingLeft ? -movementMod : movementMod, 0); - const move = calculateMovement(this.physObject, moveMod, maxWormStep, this.gameWorld); - this.movingCycles += 1; - this.physObject.body.setTranslation(move, false); + if (this.fireAngle > Math.PI * 2) { + this.fireAngle = 0; } - - onBeginFireWeapon() { - this.state = WormState.Firing; - } - - public onDamage(point: Vector2, radius: MetersValue, opts: OnDamageOpts): void { - super.onDamage(point, radius, opts); - if (this.state === WormState.InactiveWaiting || this.state === WormState.Idle) { - this.state = WormState.Inactive; - this.turnEndedReason = EndTurnReason.TookDamage; - } + if (this.fireAngle < 0) { + this.fireAngle = Math.PI * 2; } + } - onEndFireWeapon() { - if (this.state !== WormState.Firing) { - return; - } - const maxShots = this.weapon.shots ?? 1; - const duration = this.fireWeaponDuration; - this.recorder?.recordWormFire(this.wormIdent.uuid, duration); - this.targettingGfx.visible = false; - this.perRoundState.shotsTaken++; - // TODO: Need a middle state for while the world is still active. - this.cameraLockPriority = CameraLockPriority.NoLock; - this.state = WormState.InactiveWaiting; - this.fireWeaponDuration = 0; - this.onFireWeapon(this, this.currentWeapon, { - duration, - timer: this.weaponTimerSecs, - angle: this.fireAngle, - }).then((fireResult) => { - if (maxShots === this.perRoundState.shotsTaken) { - this.turnEndedReason = EndTurnReason.FiredWeapon; - this.state = WormState.Inactive; - } else { - this.state = WormState.Idle; - } - let randomTextSet: string[]; - if (fireResult.includes(WeaponFireResult.KilledOwnTeam)) { - randomTextSet = FireResultKilledOwnTeam; - } - else if (fireResult.includes(WeaponFireResult.KilledSelf)) { - randomTextSet = FireResultKilledSelf; - } - else if (fireResult.includes(WeaponFireResult.KilledEnemy)) { - randomTextSet = FireResultKilledEnemy; - } - else if (fireResult.includes(WeaponFireResult.HitEnemy)) { - randomTextSet = FireResultHitEnemy; - } - else if (fireResult.includes(WeaponFireResult.HitOwnTeam)) { - randomTextSet = FireResultHitOwnTeam; - } - else if (fireResult.includes(WeaponFireResult.HitSelf)) { - randomTextSet = FireResultHitSelf; - } - else if (fireResult.includes(WeaponFireResult.NoHit)) { - randomTextSet = FireResultMiss; - } else { - // Unknown. - return; - } - - this.toaster?.pushToast(templateRandomText(randomTextSet, { - WormName: this.wormIdent.name, - TeamName: this.wormIdent.team.name, - }), 2000); - }) - this.updateTargettingGfx(); + update(dt: number): void { + super.update(dt); + if (this.sprite.destroyed) { + return; } - - updateTargettingGfx() { - this.targettingGfx.clear(); - const teamFgColour = teamGroupToColorSet(this.wormIdent.team.group).fg; - this.targettingGfx.circle(0,0,12).stroke({ - color: teamFgColour, - width: 2, - }) - .moveTo(-12,0).lineTo(12,0) - .moveTo(0,-12).lineTo(0,12) - .stroke({ - color: teamFgColour, - width: 4, - }).circle(0,0,3).fill({ - color: 'white', - }); - if (this.state === WormState.Firing && this.currentWeapon.maxDuration) { - const mag = this.fireWeaponDuration / this.currentWeapon.maxDuration; - const relativeSpritePos = sub(this.sprite.position, this.targettingGfx.position); - this.targettingGfx.moveTo(relativeSpritePos.x, relativeSpritePos.y) - .arc(relativeSpritePos.x, relativeSpritePos.y, mag * targettingRadius.pixels, this.fireAngle-FireAngleArcPadding, this.fireAngle+FireAngleArcPadding) - .moveTo(relativeSpritePos.x, relativeSpritePos.y).fill({ - color: teamFgColour, - }); - } + this.wireframe.setDebugText( + `worm_state: ${WormState[this.state]}, velocity: ${this.body.linvel().y} ${this.impactVelocity}, aim: ${this.fireAngle}`, + ); + if (this.state === WormState.Inactive) { + // Do nothing. + return; } - - updateAiming() { - if (this.state === WormState.AimingUp) { - if (this.facingRight) { - if (this.fireAngle >= MaxAim || this.fireAngle <= MinAim) { - this.fireAngle = this.fireAngle -aimMoveSpeed; - } - } else { - if (this.fireAngle <= MaxAim || this.fireAngle >= MinAim) { - this.fireAngle = this.fireAngle + aimMoveSpeed; - } - } - } else if (this.state === WormState.AimingDown) { - if (this.facingRight) { - if (this.fireAngle >= MaxAim || this.fireAngle <= MinAim) { - this.fireAngle = this.fireAngle +aimMoveSpeed; // Math.max(this.fireAngle - aimMoveSpeed, MinAim); - } - } else { - this.fireAngle = this.fireAngle -aimMoveSpeed; //Math.min(this.fireAngle + aimMoveSpeed, MaxAim); - } - } // else, we're idle and not currently moving. - - if (this.facingRight) { - if (this.fireAngle < MaxAim && this.fireAngle > (MaxAim -aimMoveSpeed*2)) { - this.fireAngle = MaxAim; - } - if (this.fireAngle > MinAim && this.fireAngle < MaxAim) { - this.fireAngle = MinAim; - } - } else { - if (this.fireAngle > MaxAim && this.fireAngle < (MaxAim +aimMoveSpeed*2)) { - this.fireAngle = MaxAim; - } - if (this.fireAngle < MinAim && this.fireAngle < MaxAim) { - this.fireAngle = MinAim; - } - } - - if (this.fireAngle > Math.PI*2) { - this.fireAngle = 0; - } - if (this.fireAngle < 0) { - this.fireAngle = Math.PI*2; - } + const falling = !this.isSinking && this.body.linvel().y > 4; + this.targettingGfx.visible = + !!this.currentWeapon.showTargetGuide && + [ + WormState.Firing, + WormState.Idle, + WormState.AimingDown, + WormState.AimingUp, + ].includes(this.state); + + if (this.targettingGfx.visible) { + const { x, y } = pointOnRadius( + this.sprite.x, + this.sprite.y, + this.fireAngle, + targettingRadius.pixels, + ); + this.targettingGfx.position.set(x, y); } - update(dt: number): void { - super.update(dt); - if (this.sprite.destroyed) { - return; - } - this.wireframe.setDebugText(`worm_state: ${WormState[this.state]}, velocity: ${this.body.linvel().y} ${this.impactVelocity}, aim: ${this.fireAngle}` ); - if (this.state === WormState.Inactive) { - // Do nothing. - return; - } - const falling = !this.isSinking && this.body.linvel().y > 4; - this.targettingGfx.visible = !!this.currentWeapon.showTargetGuide && [WormState.Firing, WormState.Idle, WormState.AimingDown, WormState.AimingUp].includes(this.state); - - if (this.targettingGfx.visible) { - const {x, y} = pointOnRadius(this.sprite.x, this.sprite.y, this.fireAngle, targettingRadius.pixels); - this.targettingGfx.position.set(x, y); - } - - if (this.state === WormState.Firing) { - this.updateTargettingGfx(); - } - - if (this.state === WormState.InMotion) { - this.impactVelocity = Math.max(magnitude(this.body.linvel()), this.impactVelocity); - if (!this.body.isMoving()) { - // Stopped moving, must not be in motion anymore. - this.state = this.statePriorToMotion; - this.statePriorToMotion = WormState.Idle; - // Gravity does not affect us while we are idle. - //this.body.setGravityScale(0, false); - if (this.impactVelocity > Worm.minImpactForDamage) { - const damage = this.impactVelocity*Worm.impactDamageMultiplier; - this.health -= damage; - this.state = WormState.Inactive; - this.toaster?.pushToast(templateRandomText(TurnEndTextFall, { - WormName: this.wormIdent.name, - TeamName: this.wormIdent.team.name, - }), 2000); - this.turnEndedReason = EndTurnReason.FallDamage; - } - this.impactVelocity = 0; - } - } else if (this.state === WormState.Firing) { - if (!this.currentWeapon.maxDuration) { - this.onEndFireWeapon(); - } else if (this.fireWeaponDuration > this.currentWeapon.maxDuration) { - this.onEndFireWeapon(); - } else { - this.fireWeaponDuration += dt; - } - } else if (falling) { - this.statePriorToMotion = this.state; - this.resetMoveDirection(); - this.state = WormState.InMotion; - } else if (this.state === WormState.MovingLeft || this.state === WormState.MovingRight) { - this.onMove(this.state, dt); - // TODO: Allow moving aim while firing. - } else if (this.state === WormState.AimingUp || this.state === WormState.AimingDown) { - this.updateAiming(); - } + if (this.state === WormState.Firing) { + this.updateTargettingGfx(); } - public recordState() { - return { - ...super.recordState(), - wormIdent: this.wormIdent.uuid, - type: EntityType.Worm, + if (this.state === WormState.InMotion) { + this.impactVelocity = Math.max( + magnitude(this.body.linvel()), + this.impactVelocity, + ); + if (!this.body.isMoving()) { + // Stopped moving, must not be in motion anymore. + this.state = this.statePriorToMotion; + this.statePriorToMotion = WormState.Idle; + // Gravity does not affect us while we are idle. + //this.body.setGravityScale(0, false); + if (this.impactVelocity > Worm.minImpactForDamage) { + const damage = this.impactVelocity * Worm.impactDamageMultiplier; + this.health -= damage; + this.state = WormState.Inactive; + this.toaster?.pushToast( + templateRandomText(TurnEndTextFall, { + WormName: this.wormIdent.name, + TeamName: this.wormIdent.team.name, + }), + 2000, + ); + this.turnEndedReason = EndTurnReason.FallDamage; } + this.impactVelocity = 0; + } + } else if (this.state === WormState.Firing) { + if (!this.currentWeapon.maxDuration) { + this.onEndFireWeapon(); + } else if (this.fireWeaponDuration > this.currentWeapon.maxDuration) { + this.onEndFireWeapon(); + } else { + this.fireWeaponDuration += dt; + } + } else if (falling) { + this.statePriorToMotion = this.state; + this.resetMoveDirection(); + this.state = WormState.InMotion; + } else if ( + this.state === WormState.MovingLeft || + this.state === WormState.MovingRight + ) { + this.onMove(this.state, dt); + // TODO: Allow moving aim while firing. + } else if ( + this.state === WormState.AimingUp || + this.state === WormState.AimingDown + ) { + this.updateAiming(); } - - destroy(): void { - super.destroy(); - // XXX: This might need to be dead. - this.state = WormState.Inactive; - if (this.isSinking) { - this.toaster?.pushToast(templateRandomText(WormDeathSinking, { - WormName: this.wormIdent.name, - TeamName: this.wormIdent.team.name, - }), 3000); - // Sinking death - } else if (this.health === 0) { - // Generic death - this.toaster?.pushToast(templateRandomText(WormDeathGeneric, { - WormName: this.wormIdent.name, - TeamName: this.wormIdent.team.name, - }), 3000); - } + } + + public recordState() { + return { + ...super.recordState(), + wormIdent: this.wormIdent.uuid, + type: EntityType.Worm, + }; + } + + destroy(): void { + super.destroy(); + // XXX: This might need to be dead. + this.state = WormState.Inactive; + if (this.isSinking) { + this.toaster?.pushToast( + templateRandomText(WormDeathSinking, { + WormName: this.wormIdent.name, + TeamName: this.wormIdent.team.name, + }), + 3000, + ); + // Sinking death + } else if (this.health === 0) { + // Generic death + this.toaster?.pushToast( + templateRandomText(WormDeathGeneric, { + WormName: this.wormIdent.name, + TeamName: this.wormIdent.team.name, + }), + 3000, + ); } -} \ No newline at end of file + } +} diff --git a/src/entities/type.ts b/src/entities/type.ts index 7c907b0..579d82c 100644 --- a/src/entities/type.ts +++ b/src/entities/type.ts @@ -1,8 +1,8 @@ export enum EntityType { - Worm, - Grenade, - BazookaShell, - Mine, - Firework, - TestDummy, + Worm, + Grenade, + BazookaShell, + Mine, + Firework, + TestDummy, } diff --git a/src/entities/water.ts b/src/entities/water.ts index 228fe53..12d0a0a 100644 --- a/src/entities/water.ts +++ b/src/entities/water.ts @@ -1,8 +1,21 @@ -import { Container, Filter, Geometry, Mesh, Shader, UPDATE_PRIORITY } from "pixi.js"; +import { + Container, + Filter, + Geometry, + Mesh, + Shader, + UPDATE_PRIORITY, +} from "pixi.js"; import { IPhysicalEntity } from "./entity"; -import vertex from '../shaders/water.vert?raw'; -import fragment from '../shaders/water.frag?raw'; -import { collisionGroupBitmask, CollisionGroups, GameWorld, PIXELS_PER_METER, RapierPhysicsObject } from "../world"; +import vertex from "../shaders/water.vert?raw"; +import fragment from "../shaders/water.frag?raw"; +import { + collisionGroupBitmask, + CollisionGroups, + GameWorld, + PIXELS_PER_METER, + RapierPhysicsObject, +} from "../world"; import { ColliderDesc, RigidBodyDesc } from "@dimforge/rapier2d-compat"; import { MetersValue } from "../utils"; @@ -11,89 +24,96 @@ import { MetersValue } from "../utils"; * and insta-kill them. */ export class Water implements IPhysicalEntity { - private static readonly collisionBitmask = collisionGroupBitmask(CollisionGroups.Terrain, [CollisionGroups.WorldObjects]); - priority = UPDATE_PRIORITY.LOW; - private readonly geometry: Geometry; - private readonly waterMesh: Mesh; + private static readonly collisionBitmask = collisionGroupBitmask( + CollisionGroups.Terrain, + [CollisionGroups.WorldObjects], + ); + priority = UPDATE_PRIORITY.LOW; + private readonly geometry: Geometry; + private readonly waterMesh: Mesh; - public get destroyed() { - // Water cannot be destroyed - return false; - } + public get destroyed() { + // Water cannot be destroyed + return false; + } - private readonly physObject: RapierPhysicsObject; - private readonly shader: Shader; + private readonly physObject: RapierPhysicsObject; + private readonly shader: Shader; - constructor(private readonly width: MetersValue, private readonly height: MetersValue, world: GameWorld) { - const indexBuffer = ['a','b'].flatMap((_v, i) => { - i = i * 3; - if (i === 0) { - return [ - i, i+1, i+2, - i, i+2, i+3, - ] - } else { - return [ - i, i-1, i+1, - i, i+1, i+2, - ] - } - }); - this.geometry = new Geometry({ - attributes: { - aPosition: [ - -100, 0, // top left - -100, 100, // bottom left - 0, 100, // bottom middle - 0, 0, // top middle - 100, 100, // bottom right - 100, 0, // top right - ], - }, - indexBuffer, - }); - this.shader = Filter.from({ - gl: {vertex, fragment, name: 'water'}, - resources: { - waveUniforms: { - iTime: { type: 'f32', value: 0 }, - } - } - }); - // TODO: Potentially optimise into a polyline? - this.physObject = world.createRigidBodyCollider( - ColliderDesc.cuboid(width.value, 6) - .setSensor(true), - // .setCollisionGroups(Water.collisionBitmask) - // .setSolverGroups(Water.collisionBitmask), - RigidBodyDesc.fixed().setTranslation( - 0, - (height.value) - ) - ) - const meshPos = this.physObject.body.translation(); - const meshHeight = 6.5; - this.waterMesh = new Mesh({ - geometry: this.geometry, - shader: this.shader, - position: {x: width.pixels/6, y: (meshPos.y - meshHeight)*PIXELS_PER_METER}, - visible: true, - }); - this.waterMesh.width = this.width.value; - this.waterMesh.height = this.height.value; - this.waterMesh.scale.set(40, 3.5); - } + constructor( + private readonly width: MetersValue, + private readonly height: MetersValue, + world: GameWorld, + ) { + const indexBuffer = ["a", "b"].flatMap((_v, i) => { + i = i * 3; + if (i === 0) { + return [i, i + 1, i + 2, i, i + 2, i + 3]; + } else { + return [i, i - 1, i + 1, i, i + 1, i + 2]; + } + }); + this.geometry = new Geometry({ + attributes: { + aPosition: [ + -100, + 0, // top left + -100, + 100, // bottom left + 0, + 100, // bottom middle + 0, + 0, // top middle + 100, + 100, // bottom right + 100, + 0, // top right + ], + }, + indexBuffer, + }); + this.shader = Filter.from({ + gl: { vertex, fragment, name: "water" }, + resources: { + waveUniforms: { + iTime: { type: "f32", value: 0 }, + }, + }, + }); + // TODO: Potentially optimise into a polyline? + this.physObject = world.createRigidBodyCollider( + ColliderDesc.cuboid(width.value, 6).setSensor(true), + // .setCollisionGroups(Water.collisionBitmask) + // .setSolverGroups(Water.collisionBitmask), + RigidBodyDesc.fixed().setTranslation(0, height.value), + ); + const meshPos = this.physObject.body.translation(); + const meshHeight = 6.5; + this.waterMesh = new Mesh({ + geometry: this.geometry, + shader: this.shader, + position: { + x: width.pixels / 6, + y: (meshPos.y - meshHeight) * PIXELS_PER_METER, + }, + visible: true, + }); + this.waterMesh.width = this.width.value; + this.waterMesh.height = this.height.value; + this.waterMesh.scale.set(40, 3.5); + } - addToWorld(parent: Container, world: GameWorld) { - parent.addChildAt(this.waterMesh, Math.max(0, parent.children.length-1)); - world.addBody(this, this.physObject.collider); - } + addToWorld(parent: Container, world: GameWorld) { + parent.addChildAt(this.waterMesh, Math.max(0, parent.children.length - 1)); + world.addBody(this, this.physObject.collider); + } - update(): void { - this.shader.resources.waveUniforms.uniforms.iTime = performance.now() / 1000; - } + update(): void { + this.shader.resources.waveUniforms.uniforms.iTime = + performance.now() / 1000; + } - destroy(): void { - this.shader.destroy(); - } -} \ No newline at end of file + destroy(): void { + this.shader.destroy(); + } +} diff --git a/src/flags.ts b/src/flags.ts index c3c7e0e..113f5db 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -2,29 +2,31 @@ import { EventEmitter } from "pixi.js"; import Input, { InputKind } from "./input"; export enum DebugLevel { - None = 0, - BasicOverlay = 1, - PhysicsOverlay = 2, + None = 0, + BasicOverlay = 1, + PhysicsOverlay = 2, } class Flags extends EventEmitter { - public DebugView: DebugLevel; + public DebugView: DebugLevel; - constructor() { - super(); - const qs = new URLSearchParams(window.location.search); - this.DebugView = qs.get('debug') ? DebugLevel.PhysicsOverlay : DebugLevel.None; - Input.on('inputEnd', (type) => { - if (type === InputKind.ToggleDebugView) { - if (++this.DebugView > DebugLevel.PhysicsOverlay) { - this.DebugView = DebugLevel.None; - } - } - this.emit('toggleDebugView', this.DebugView); - }) - } + constructor() { + super(); + const qs = new URLSearchParams(window.location.search); + this.DebugView = qs.get("debug") + ? DebugLevel.PhysicsOverlay + : DebugLevel.None; + Input.on("inputEnd", (type) => { + if (type === InputKind.ToggleDebugView) { + if (++this.DebugView > DebugLevel.PhysicsOverlay) { + this.DebugView = DebugLevel.None; + } + } + this.emit("toggleDebugView", this.DebugView); + }); + } } const globalFlags = new Flags(); -export default globalFlags; \ No newline at end of file +export default globalFlags; diff --git a/src/game.ts b/src/game.ts index 40dde85..bc0feec 100644 --- a/src/game.ts +++ b/src/game.ts @@ -1,119 +1,138 @@ -import { Application, Graphics, UPDATE_PRIORITY } from 'pixi.js'; -import { Viewport } from 'pixi-viewport'; +import { Application, Graphics, UPDATE_PRIORITY } from "pixi.js"; +import { Viewport } from "pixi-viewport"; import { getAssets } from "./assets"; import { GameDebugOverlay } from "./overlays/debugOverlay"; import { GameWorld } from "./world"; import RAPIER from "@dimforge/rapier2d-compat"; import { readAssetsForEntities } from "./entities"; -import { Team } from './logic/teams'; -import { readAssetsForWeapons } from './weapons'; -import { WindDial } from './overlays/windDial'; -import { NetGameInstance } from './net/client'; -import { GameReactChannel } from './interop/gamechannel'; -import staticController from './input'; -import { sound } from '@pixi/sound'; -import Logger from './log'; +import { Team } from "./logic/teams"; +import { readAssetsForWeapons } from "./weapons"; +import { WindDial } from "./overlays/windDial"; +import { NetGameInstance } from "./net/client"; +import { GameReactChannel } from "./interop/gamechannel"; +import staticController from "./input"; +import { sound } from "@pixi/sound"; +import Logger from "./log"; const worldWidth = 1920; const worldHeight = 1080; sound.volumeAll = 0.25; -const logger = new Logger('Game'); +const logger = new Logger("Game"); export class Game { - public readonly viewport: Viewport; - private readonly rapierWorld: RAPIER.World; - public readonly world: GameWorld; - public readonly rapierGfx: Graphics; - - public get pixiRoot() { - return this.viewport; - } - - public static async create(window: Window, level: string, gameReactChannel: GameReactChannel, netGameInstance?: NetGameInstance): Promise { - await RAPIER.init(); - const pixiApp = new Application(); - await pixiApp.init({ resizeTo: window, preference: 'webgl' }); - return new Game(pixiApp, level, gameReactChannel, netGameInstance); - } - - constructor(public readonly pixiApp: Application, private readonly scenario: string, public readonly gameReactChannel: GameReactChannel, public readonly netGameInstance?: NetGameInstance) { - // TODO: Set a sensible static width/height and have the canvas pan it. - this.rapierWorld = new RAPIER.World({ x: 0, y: 9.81 }); - this.rapierGfx = new Graphics(); - this.viewport = new Viewport({ - screenHeight: this.pixiApp.screen.height, - screenWidth: this.pixiApp.screen.width, - worldWidth: worldWidth, - worldHeight: worldHeight, - // the interaction module is important for wheel to work properly when renderer.view is placed or scaled - events: this.pixiApp.renderer.events - }); - this.world = new GameWorld(this.rapierWorld, this.pixiApp.ticker); - this.pixiApp.stage.addChild(this.viewport); - this.viewport - .clamp({ - top: -3000, - bottom: 2000, - left: -2000, - right: 3000, - direction: 'y', - }) - .decelerate() - .drag() - this.viewport.zoom(8); - - // TODO: Bit of a hack? - staticController.bindMouseInput(); - } - - public goToMenu(winningTeams?: Team[]) { - this.pixiApp.destroy(); - this.gameReactChannel.goToMenu(winningTeams); + public readonly viewport: Viewport; + private readonly rapierWorld: RAPIER.World; + public readonly world: GameWorld; + public readonly rapierGfx: Graphics; + + public get pixiRoot() { + return this.viewport; + } + + public static async create( + window: Window, + level: string, + gameReactChannel: GameReactChannel, + netGameInstance?: NetGameInstance, + ): Promise { + await RAPIER.init(); + const pixiApp = new Application(); + await pixiApp.init({ resizeTo: window, preference: "webgl" }); + return new Game(pixiApp, level, gameReactChannel, netGameInstance); + } + + constructor( + public readonly pixiApp: Application, + private readonly scenario: string, + public readonly gameReactChannel: GameReactChannel, + public readonly netGameInstance?: NetGameInstance, + ) { + // TODO: Set a sensible static width/height and have the canvas pan it. + this.rapierWorld = new RAPIER.World({ x: 0, y: 9.81 }); + this.rapierGfx = new Graphics(); + this.viewport = new Viewport({ + screenHeight: this.pixiApp.screen.height, + screenWidth: this.pixiApp.screen.width, + worldWidth: worldWidth, + worldHeight: worldHeight, + // the interaction module is important for wheel to work properly when renderer.view is placed or scaled + events: this.pixiApp.renderer.events, + }); + this.world = new GameWorld(this.rapierWorld, this.pixiApp.ticker); + this.pixiApp.stage.addChild(this.viewport); + this.viewport + .clamp({ + top: -3000, + bottom: 2000, + left: -2000, + right: 3000, + direction: "y", + }) + .decelerate() + .drag(); + this.viewport.zoom(8); + + // TODO: Bit of a hack? + staticController.bindMouseInput(); + } + + public goToMenu(winningTeams?: Team[]) { + this.pixiApp.destroy(); + this.gameReactChannel.goToMenu(winningTeams); + } + + public async loadResources() { + const assetPack = getAssets(); + readAssetsForEntities(assetPack); + readAssetsForWeapons(assetPack); + WindDial.loadAssets(assetPack.textures); + } + + public async run() { + // Load this scenario + if (this.scenario.replaceAll(/[A-Za-z]/g, "") !== "") { + throw Error("Invalid level name"); } - - public async loadResources() { - const assetPack = getAssets(); - readAssetsForEntities(assetPack); - readAssetsForWeapons(assetPack); - WindDial.loadAssets(assetPack.textures); - } - - public async run() { - // Load this scenario - if (this.scenario.replaceAll(/[A-Za-z]/g, '') !== "") { - throw Error('Invalid level name'); + logger.info(`Loading scenario ${this.scenario}`); + const module = await import(`./scenarios/${this.scenario}.ts`); + await module.default(this); + + const overlay = new GameDebugOverlay( + this.rapierWorld, + this.pixiApp.ticker, + this.pixiApp.stage, + this.viewport, + ); + this.pixiApp.stage.addChildAt(this.rapierGfx, 0); + + // Run physics engine at 90fps. + const tickEveryMs = 1000 / 90; + let lastPhysicsTick = 0; + + this.pixiApp.ticker.add( + (dt) => { + // TODO: Timing. + const startTime = performance.now(); + lastPhysicsTick += dt.deltaMS; + // Note: If we are lagging behind terribly, this will multiple ticks + while (lastPhysicsTick >= tickEveryMs) { + this.world.step(); + lastPhysicsTick -= tickEveryMs; } - logger.info(`Loading scenario ${this.scenario}`); - const module = await import(`./scenarios/${this.scenario}.ts`) - await module.default(this); - - const overlay = new GameDebugOverlay(this.rapierWorld, this.pixiApp.ticker, this.pixiApp.stage, this.viewport); - this.pixiApp.stage.addChildAt(this.rapierGfx, 0); - - // Run physics engine at 90fps. - const tickEveryMs = 1000/90; - let lastPhysicsTick = 0; - - this.pixiApp.ticker.add((dt) => { - // TODO: Timing. - const startTime = performance.now(); - lastPhysicsTick += dt.deltaMS; - // Note: If we are lagging behind terribly, this will multiple ticks - while (lastPhysicsTick >= tickEveryMs) { - this.world.step(); - lastPhysicsTick -= tickEveryMs; - } - overlay.physicsSamples.push(performance.now()-startTime); - }, undefined, UPDATE_PRIORITY.HIGH); - } - - public get canvas() { - return this.pixiApp.canvas; - } - - public destroy() { - this.pixiApp.destroy(); - } -} \ No newline at end of file + overlay.physicsSamples.push(performance.now() - startTime); + }, + undefined, + UPDATE_PRIORITY.HIGH, + ); + } + + public get canvas() { + return this.pixiApp.canvas; + } + + public destroy() { + this.pixiApp.destroy(); + } +} diff --git a/src/index.css b/src/index.css index a7a8ecc..46708fe 100644 --- a/src/index.css +++ b/src/index.css @@ -34,4 +34,4 @@ body { margin: 0; padding: 0; width: 100%; -} \ No newline at end of file +} diff --git a/src/input.ts b/src/input.ts index 013fc66..b18c9df 100644 --- a/src/input.ts +++ b/src/input.ts @@ -1,161 +1,176 @@ import { EventEmitter } from "pixi.js"; export enum InputKind { - MoveLeft, - MoveRight, - AimUp, - AimDown, - Jump, - Backflip, - Fire, - ToggleDebugView, - DebugSwitchWeapon, - WeaponTimer1, - WeaponTimer2, - WeaponTimer3, - WeaponTimer4, - WeaponTimer5, - WeaponMenu, + MoveLeft, + MoveRight, + AimUp, + AimDown, + Jump, + Backflip, + Fire, + ToggleDebugView, + DebugSwitchWeapon, + WeaponTimer1, + WeaponTimer2, + WeaponTimer3, + WeaponTimer4, + WeaponTimer5, + WeaponMenu, } const MouseButtonNames = ["MouseLeft", "MouseRight", "MouseWheel"]; const DefaultBinding: Record = Object.freeze({ - "ArrowLeft": InputKind.MoveLeft, - "ArrowRight": InputKind.MoveRight, - "ArrowUp": InputKind.AimUp, - "ArrowDown": InputKind.AimDown, - "Enter": InputKind.Jump, - "Backspace,Backspace": InputKind.Backflip, - "MouseRight": InputKind.WeaponMenu, - // I LOVE THE CONSISTENCY HERE BROWSERS - " ": InputKind.Fire, - "F9": InputKind.ToggleDebugView, - "s": InputKind.DebugSwitchWeapon, - "1": InputKind.WeaponTimer1, - "2": InputKind.WeaponTimer2, - "3": InputKind.WeaponTimer3, - "4": InputKind.WeaponTimer4, - "5": InputKind.WeaponTimer5, + ArrowLeft: InputKind.MoveLeft, + ArrowRight: InputKind.MoveRight, + ArrowUp: InputKind.AimUp, + ArrowDown: InputKind.AimDown, + Enter: InputKind.Jump, + "Backspace,Backspace": InputKind.Backflip, + MouseRight: InputKind.WeaponMenu, + // I LOVE THE CONSISTENCY HERE BROWSERS + " ": InputKind.Fire, + F9: InputKind.ToggleDebugView, + s: InputKind.DebugSwitchWeapon, + "1": InputKind.WeaponTimer1, + "2": InputKind.WeaponTimer2, + "3": InputKind.WeaponTimer3, + "4": InputKind.WeaponTimer4, + "5": InputKind.WeaponTimer5, }); const sequenceTimeoutMs = 250; -type Sequence = {sequence: string[], inputKind: InputKind}; +type Sequence = { sequence: string[]; inputKind: InputKind }; class Controller extends EventEmitter { - private readonly activeInputs = new Set(); - private activeSequences = new Array(); - private readonly sequences = new Array(); - private activeTimeout: NodeJS.Timeout|undefined; - - constructor(private readonly bindings: Record = DefaultBinding) { - super(); - for (const [keyBind, inputKind] of Object.entries(bindings)) { - const parts = keyBind.split(','); - if (parts.length === 1) { - continue; - } - this.sequences.push({sequence: parts, inputKind}); - } - // TODO: Only bind when the game has started. - window.addEventListener('keydown', this.onKeyDown.bind(this)); - window.addEventListener('keyup', this.onKeyUp.bind(this)); - } - - public bindMouseInput() { - const overlayElement = document.querySelector('#overlay'); - if (!overlayElement) { - throw Error('Missing overlay element'); - } - overlayElement.addEventListener('mousedown', this.onMouseDown.bind(this)); - overlayElement.addEventListener('mouseup', this.onMouseUp.bind(this)); - overlayElement.addEventListener('contextmenu', event => event.preventDefault()); + private readonly activeInputs = new Set(); + private activeSequences = new Array(); + private readonly sequences = new Array(); + private activeTimeout: NodeJS.Timeout | undefined; + + constructor( + private readonly bindings: Record = DefaultBinding, + ) { + super(); + for (const [keyBind, inputKind] of Object.entries(bindings)) { + const parts = keyBind.split(","); + if (parts.length === 1) { + continue; + } + this.sequences.push({ sequence: parts, inputKind }); } - - public isInputActive(kind: InputKind) { - return this.activeInputs.has(kind); + // TODO: Only bind when the game has started. + window.addEventListener("keydown", this.onKeyDown.bind(this)); + window.addEventListener("keyup", this.onKeyUp.bind(this)); + } + + public bindMouseInput() { + const overlayElement = document.querySelector("#overlay"); + if (!overlayElement) { + throw Error("Missing overlay element"); } - - private onKeyDown(ev: KeyboardEvent) { - const inputKind = this.bindings[ev.key]; - - - // TODO: Optimise. - if (this.activeSequences.length > 0) { - this.activeSequences = this.activeSequences.filter(s => s.sequence[0] === ev.key).map(s => { - s.sequence.splice(0,1); - return s; - }); - const sequencesToFire = this.activeSequences.filter(s => s.sequence.length === 0); - if (sequencesToFire.length) { - for (const element of this.activeSequences.filter(s => s.sequence.length === 0)) { - this.emit('inputBegin', element.inputKind); - // TODO: Wait for actual input end? - this.emit('inputEnd', element.inputKind); - } - this.activeSequences = []; - clearTimeout(this.activeTimeout); - } - } else { - this.activeSequences.push(...this.sequences.filter(s => s.sequence[0] === ev.key).map(s => { - return { - sequence: s.sequence.slice(1), - inputKind: s.inputKind, - }; - })); + overlayElement.addEventListener("mousedown", this.onMouseDown.bind(this)); + overlayElement.addEventListener("mouseup", this.onMouseUp.bind(this)); + overlayElement.addEventListener("contextmenu", (event) => + event.preventDefault(), + ); + } + + public isInputActive(kind: InputKind) { + return this.activeInputs.has(kind); + } + + private onKeyDown(ev: KeyboardEvent) { + const inputKind = this.bindings[ev.key]; + + // TODO: Optimise. + if (this.activeSequences.length > 0) { + this.activeSequences = this.activeSequences + .filter((s) => s.sequence[0] === ev.key) + .map((s) => { + s.sequence.splice(0, 1); + return s; + }); + const sequencesToFire = this.activeSequences.filter( + (s) => s.sequence.length === 0, + ); + if (sequencesToFire.length) { + for (const element of this.activeSequences.filter( + (s) => s.sequence.length === 0, + )) { + this.emit("inputBegin", element.inputKind); + // TODO: Wait for actual input end? + this.emit("inputEnd", element.inputKind); } - + this.activeSequences = []; clearTimeout(this.activeTimeout); - this.activeTimeout = setTimeout(() => { - this.activeSequences = []; - this.activeTimeout = undefined; - }, sequenceTimeoutMs); - - if (inputKind === undefined || this.activeInputs.has(inputKind)) { - return; - } - this.activeInputs.add(inputKind); - this.emit('inputBegin', inputKind); + } + } else { + this.activeSequences.push( + ...this.sequences + .filter((s) => s.sequence[0] === ev.key) + .map((s) => { + return { + sequence: s.sequence.slice(1), + inputKind: s.inputKind, + }; + }), + ); } - private onKeyUp(ev: KeyboardEvent) { - const inputKind = DefaultBinding[ev.key]; - if (inputKind === undefined || !this.activeInputs.has(inputKind)) { - return; - } - this.activeInputs.delete(inputKind); - this.emit('inputEnd', inputKind); - } + clearTimeout(this.activeTimeout); + this.activeTimeout = setTimeout(() => { + this.activeSequences = []; + this.activeTimeout = undefined; + }, sequenceTimeoutMs); - private onMouseDown(ev: MouseEvent) { - const buttonNames = MouseButtonNames.filter((_name, i) => - Boolean(ev.buttons & (1 << i))) - - const inputKinds = buttonNames.map(v => DefaultBinding[v]); - for (const inputKind of inputKinds) { - if (this.activeInputs.has(inputKind)) { - continue; - } - this.activeInputs.add(inputKind); - this.emit('inputBegin', inputKind); - } + if (inputKind === undefined || this.activeInputs.has(inputKind)) { + return; } - private onMouseUp(ev: MouseEvent) { - const buttonName = MouseButtonNames.find((_name, i) => - Boolean(ev.button & (1 << i))); - - if (buttonName) { - const inputKind = DefaultBinding[buttonName]; - if (!this.activeInputs.has(inputKind)) { - return; - } - this.activeInputs.delete(inputKind); - this.emit('inputEnd', inputKind); - } + this.activeInputs.add(inputKind); + this.emit("inputBegin", inputKind); + } + + private onKeyUp(ev: KeyboardEvent) { + const inputKind = DefaultBinding[ev.key]; + if (inputKind === undefined || !this.activeInputs.has(inputKind)) { + return; + } + this.activeInputs.delete(inputKind); + this.emit("inputEnd", inputKind); + } + + private onMouseDown(ev: MouseEvent) { + const buttonNames = MouseButtonNames.filter((_name, i) => + Boolean(ev.buttons & (1 << i)), + ); + + const inputKinds = buttonNames.map((v) => DefaultBinding[v]); + for (const inputKind of inputKinds) { + if (this.activeInputs.has(inputKind)) { + continue; + } + this.activeInputs.add(inputKind); + this.emit("inputBegin", inputKind); + } + } + private onMouseUp(ev: MouseEvent) { + const buttonName = MouseButtonNames.find((_name, i) => + Boolean(ev.button & (1 << i)), + ); + + if (buttonName) { + const inputKind = DefaultBinding[buttonName]; + if (!this.activeInputs.has(inputKind)) { + return; + } + this.activeInputs.delete(inputKind); + this.emit("inputEnd", inputKind); } + } } const staticController = new Controller(); -export default staticController; \ No newline at end of file +export default staticController; diff --git a/src/interop/gamechannel.ts b/src/interop/gamechannel.ts index db4357d..e48512d 100644 --- a/src/interop/gamechannel.ts +++ b/src/interop/gamechannel.ts @@ -1,33 +1,32 @@ -import EventEmitter from "events" -import TypedEmitter from "typed-emitter" +import EventEmitter from "events"; +import TypedEmitter from "typed-emitter"; import type { Team } from "../logic/teams"; import type { IWeaponCode, IWeaponDefiniton } from "../weapons/weapon"; - interface GoToMenuEvent { - winningTeams?: Team[], + winningTeams?: Team[]; } type GameReactChannelEvents = { - goToMenu: (event: GoToMenuEvent) => void; - openWeaponMenu: (weapons: IWeaponDefiniton[]) => void; - weaponSelected: (code: IWeaponCode) => void; -} - + goToMenu: (event: GoToMenuEvent) => void; + openWeaponMenu: (weapons: IWeaponDefiniton[]) => void; + weaponSelected: (code: IWeaponCode) => void; +}; + export class GameReactChannel extends (EventEmitter as new () => TypedEmitter) { - constructor() { - super(); - } + constructor() { + super(); + } - public goToMenu(winningTeams?: Team[]) { - this.emit("goToMenu", { winningTeams }); - } + public goToMenu(winningTeams?: Team[]) { + this.emit("goToMenu", { winningTeams }); + } - public openWeaponMenu(weapons: IWeaponDefiniton[]) { - this.emit("openWeaponMenu", weapons); - } + public openWeaponMenu(weapons: IWeaponDefiniton[]) { + this.emit("openWeaponMenu", weapons); + } - public weaponMenuSelect(code: IWeaponCode) { - this.emit("weaponSelected", code); - } + public weaponMenuSelect(code: IWeaponCode) { + this.emit("weaponSelected", code); + } } diff --git a/src/log.ts b/src/log.ts index 0b70e80..a1abbee 100644 --- a/src/log.ts +++ b/src/log.ts @@ -1,46 +1,64 @@ export enum LogLevels { - Verbose = 0, - Debug = 1, - Info = 2, - Warning = 3, - Error = 4, -}; + Verbose = 0, + Debug = 1, + Info = 2, + Warning = 3, + Error = 4, +} export default class Logger { - public static LogLevel = LogLevels.Verbose; - constructor(private readonly moduleName: string) { + public static LogLevel = LogLevels.Verbose; + constructor(private readonly moduleName: string) {} + public verbose(...info: unknown[]) { + if (Logger.LogLevel > LogLevels.Verbose) { + return; } + console.debug( + `%c[${this.moduleName}]`, + "color: yellow; background-color: black; font-weight: 600;", + ...info, + ); + } - public verbose(...info: any[]) { - if (Logger.LogLevel > LogLevels.Verbose) { - return; - } - console.debug(`%c[${this.moduleName}]`, "color: yellow; background-color: black; font-weight: 600;", ...info); + public debug(...info: unknown[]) { + if (Logger.LogLevel > LogLevels.Debug) { + return; } + console.debug( + `%c[${this.moduleName}]`, + "color: lightblue; background-color: black; font-weight: 600;", + ...info, + ); + } - public debug(...info: any[]) { - if (Logger.LogLevel > LogLevels.Debug) { - return; - } - console.debug(`%c[${this.moduleName}]`, "color: lightblue; background-color: black; font-weight: 600;", ...info); + public info(...info: unknown[]) { + if (Logger.LogLevel > LogLevels.Info) { + return; } + console.debug( + `%c[${this.moduleName}]`, + "color: green; background-color: black; font-weight: 600;", + ...info, + ); + } - public info(...info: any[]) { - if (Logger.LogLevel > LogLevels.Info) { - return; - } - console.debug(`%c[${this.moduleName}]`, "color: green; background-color: black; font-weight: 600;", ...info); + public warning(...info: unknown[]) { + if (Logger.LogLevel > LogLevels.Warning) { + return; } + console.warn( + `%c[${this.moduleName}]`, + "color: white; background-color: black; font-weight: 600;", + ...info, + ); + } - public warning(...info: any[]) { - if (Logger.LogLevel > LogLevels.Warning) { - return; - } - console.warn(`%c[${this.moduleName}]`, "color: white; background-color: black; font-weight: 600;", ...info); - } - - public error(...info: any[]) { - console.error(`%c[${this.moduleName}]`, "color: white; background-color: black; font-weight: 600;", ...info); - } -} \ No newline at end of file + public error(...info: unknown[]) { + console.error( + `%c[${this.moduleName}]`, + "color: white; background-color: black; font-weight: 600;", + ...info, + ); + } +} diff --git a/src/logic/gamestate.ts b/src/logic/gamestate.ts index e5a5bb7..5a5e60f 100644 --- a/src/logic/gamestate.ts +++ b/src/logic/gamestate.ts @@ -4,207 +4,229 @@ import type { StateRecordWormGameState } from "../state/model"; import Logger from "../log"; export interface GameRules { - winWhenOneGroupRemains: boolean; + winWhenOneGroupRemains: boolean; } const PreRoundMs = 5000; const RoundTimerMs = 60000; -const logger = new Logger('GameState'); +const logger = new Logger("GameState"); export class InternalTeam implements Team { - public readonly worms: WormInstance[]; - private nextWormStack: WormInstance[]; - - constructor(private readonly team: Team, onHealthChange: () => void) { - this.worms = team.worms.map(w => new WormInstance(w, team, onHealthChange)); - this.nextWormStack = [...this.worms]; - } - - get name() { - return this.team.name; - } - - get playerUserId() { - return this.team.playerUserId; - } - - get group() { - return this.team.group; - } - - get health() { - return this.worms.map(w => w.health).reduce((a,b) => a + b); - } - - get maxHealth() { - return this.worms.map(w => w.maxHealth).reduce((a,b) => a + b); - } - - public popNextWorm(): WormInstance { - // Clear any dead worms - this.nextWormStack = this.nextWormStack.filter(w => w.health > 0); - const [next] = this.nextWormStack.splice(0, 1); - if (!next) { - throw Error('Exhausted all worms from team'); - } - this.nextWormStack.push(next); - return next; - } + public readonly worms: WormInstance[]; + private nextWormStack: WormInstance[]; + + constructor( + private readonly team: Team, + onHealthChange: () => void, + ) { + this.worms = team.worms.map( + (w) => new WormInstance(w, team, onHealthChange), + ); + this.nextWormStack = [...this.worms]; + } + + get name() { + return this.team.name; + } + + get playerUserId() { + return this.team.playerUserId; + } + + get group() { + return this.team.group; + } + + get health() { + return this.worms.map((w) => w.health).reduce((a, b) => a + b); + } + + get maxHealth() { + return this.worms.map((w) => w.maxHealth).reduce((a, b) => a + b); + } + + public popNextWorm(): WormInstance { + // Clear any dead worms + this.nextWormStack = this.nextWormStack.filter((w) => w.health > 0); + const [next] = this.nextWormStack.splice(0, 1); + if (!next) { + throw Error("Exhausted all worms from team"); + } + this.nextWormStack.push(next); + return next; + } } export class GameState { - static getTeamMaxHealth(team: Team) { - return team.worms.map(w => w.maxHealth).reduce((a,b) => a + b); - } - - static getTeamHealth(team: Team) { - return team.worms.map(w => w.health).reduce((a,b) => a + b); - } - - static getTeamHealthPercentage(team: Team) { - return Math.ceil(team.worms.map(w => w.health).reduce((a,b) => a + b) / team.worms.map(w => w.maxHealth).reduce((a,b) => a + b) * 100)/100; - } - - private currentTeam?: InternalTeam; - private readonly teams: InternalTeam[]; - private nextTeamStack: InternalTeam[]; - private remainingRoundTimeMs = 0; - - /** - * Wind strength. Integer between -10 and 10. - */ - private wind = 0; - - private roundOverAtTs = 0; - private preRoundOverAtTs = 0; - - private stateIteration = 0; - - public iterateRound() { - const prev = this.stateIteration; - logger.debug("Iterating round", prev, prev+1); - this.stateIteration++; - } - - get currentWind() { - return this.wind; - } - - get remainingRoundTime() { - return this.remainingRoundTimeMs; - } - - get isPreRound() { - return this.preRoundOverAtTs !== 0; - } - - get activeTeam() { - return this.currentTeam; - } - - constructor(teams: Team[], private readonly rules: GameRules = { winWhenOneGroupRemains: false }) { - if (teams.length < 1) { - throw Error('Must have at least one team'); - } - this.teams = teams.map((team) => new InternalTeam(team, () => { - this.iterateRound(); - })); - this.nextTeamStack = [...this.teams]; - } - - public getTeamByIndex(index: number) { - return this.teams[index]; - } - - public getTeams() { - return this.teams; - } - - public getActiveTeams() { - return this.teams.filter(t => t.worms.some(w => w.health > 0)); - } - - public get iteration(): number { - return this.stateIteration; - } - - public update(ticker: Ticker) { - if (this.remainingRoundTimeMs) { - this.remainingRoundTimeMs = Math.min(0, this.remainingRoundTimeMs - ticker.deltaMS); + static getTeamMaxHealth(team: Team) { + return team.worms.map((w) => w.maxHealth).reduce((a, b) => a + b); + } + + static getTeamHealth(team: Team) { + return team.worms.map((w) => w.health).reduce((a, b) => a + b); + } + + static getTeamHealthPercentage(team: Team) { + return ( + Math.ceil( + (team.worms.map((w) => w.health).reduce((a, b) => a + b) / + team.worms.map((w) => w.maxHealth).reduce((a, b) => a + b)) * + 100, + ) / 100 + ); + } + + private currentTeam?: InternalTeam; + private readonly teams: InternalTeam[]; + private nextTeamStack: InternalTeam[]; + private remainingRoundTimeMs = 0; + + /** + * Wind strength. Integer between -10 and 10. + */ + private wind = 0; + + private roundOverAtTs = 0; + private preRoundOverAtTs = 0; + + private stateIteration = 0; + + public iterateRound() { + const prev = this.stateIteration; + logger.debug("Iterating round", prev, prev + 1); + this.stateIteration++; + } + + get currentWind() { + return this.wind; + } + + get remainingRoundTime() { + return this.remainingRoundTimeMs; + } + + get isPreRound() { + return this.preRoundOverAtTs !== 0; + } + + get activeTeam() { + return this.currentTeam; + } + + constructor( + teams: Team[], + private readonly rules: GameRules = { winWhenOneGroupRemains: false }, + ) { + if (teams.length < 1) { + throw Error("Must have at least one team"); + } + this.teams = teams.map( + (team) => + new InternalTeam(team, () => { + this.iterateRound(); + }), + ); + this.nextTeamStack = [...this.teams]; + } + + public getTeamByIndex(index: number) { + return this.teams[index]; + } + + public getTeams() { + return this.teams; + } + + public getActiveTeams() { + return this.teams.filter((t) => t.worms.some((w) => w.health > 0)); + } + + public get iteration(): number { + return this.stateIteration; + } + + public update(ticker: Ticker) { + if (this.remainingRoundTimeMs) { + this.remainingRoundTimeMs = Math.min( + 0, + this.remainingRoundTimeMs - ticker.deltaMS, + ); + } + if (this.remainingRoundTimeMs === 0 && this.preRoundOverAtTs) { + // Timer expired. + this.preRoundOverAtTs = 0; + this.roundOverAtTs = Date.now() + RoundTimerMs; + this.remainingRoundTimeMs = RoundTimerMs; + } + } + + public applyGameStateUpdate(stateUpdate: StateRecordWormGameState["data"]) { + // TODO: Is this order garunteed? + let index = -1; + for (const teamData of stateUpdate.teams) { + index++; + const teamWormSet = this.teams[index].worms; + for (const wormData of teamData.worms) { + const foundWorm = teamWormSet.find((w) => w.uuid === wormData.uuid); + if (foundWorm) { + foundWorm.health = wormData.health; } - if (this.remainingRoundTimeMs === 0 && this.preRoundOverAtTs) { - // Timer expired. - this.preRoundOverAtTs = 0; - this.roundOverAtTs = Date.now() + RoundTimerMs; - this.remainingRoundTimeMs = RoundTimerMs; - } - } - - public applyGameStateUpdate(stateUpdate: StateRecordWormGameState["data"]) { - // TODO: Is this order garunteed? - let index = -1; - for (const teamData of stateUpdate.teams) { - index++; - const teamWormSet = this.teams[index].worms; - for (const wormData of teamData.worms) { - const foundWorm = teamWormSet.find(w => w.uuid === wormData.uuid); - if (foundWorm) { - foundWorm.health = wormData.health; - } - } - } - const data = this.advanceRound(); - this.wind = stateUpdate.wind; - return data; - } - - public advanceRound(): {nextTeam: InternalTeam, nextWorm: WormInstance}|{winningTeams: InternalTeam[]} { - logger.debug('Advancing round'); - this.wind = Math.ceil((Math.random()*20)-11); - if (!this.currentTeam) { - const [firstTeam] = this.nextTeamStack.splice(0, 1); - this.currentTeam = firstTeam; - return { - nextTeam: this.currentTeam, - // Team *should* have at least one healthy worm. - nextWorm: this.currentTeam.popNextWorm(), - } - } - const previousTeam = this.currentTeam; - this.nextTeamStack.push(previousTeam); - - for (let index = 0; index < this.nextTeamStack.length; index++) { - const nextTeam = this.nextTeamStack[index]; - if (nextTeam.group === previousTeam.group) { - continue; - } - if (nextTeam.worms.some(w => w.health > 0)) { - this.nextTeamStack.splice(index, 1); - this.currentTeam = nextTeam; - } - } - // We wrapped around. - if (this.currentTeam === previousTeam) { - this.stateIteration++; - if (this.rules.winWhenOneGroupRemains) { - // All remaining teams are part of the same group - return { - winningTeams: this.getActiveTeams(), - } - } else if (this.currentTeam.health === 0) { - // This is a draw - return { - winningTeams: [], - } - } - } - this.stateIteration++; - this.preRoundOverAtTs = Date.now() + PreRoundMs; - + } + } + const data = this.advanceRound(); + this.wind = stateUpdate.wind; + return data; + } + + public advanceRound(): + | { nextTeam: InternalTeam; nextWorm: WormInstance } + | { winningTeams: InternalTeam[] } { + logger.debug("Advancing round"); + this.wind = Math.ceil(Math.random() * 20 - 11); + if (!this.currentTeam) { + const [firstTeam] = this.nextTeamStack.splice(0, 1); + this.currentTeam = firstTeam; + return { + nextTeam: this.currentTeam, + // Team *should* have at least one healthy worm. + nextWorm: this.currentTeam.popNextWorm(), + }; + } + const previousTeam = this.currentTeam; + this.nextTeamStack.push(previousTeam); + + for (let index = 0; index < this.nextTeamStack.length; index++) { + const nextTeam = this.nextTeamStack[index]; + if (nextTeam.group === previousTeam.group) { + continue; + } + if (nextTeam.worms.some((w) => w.health > 0)) { + this.nextTeamStack.splice(index, 1); + this.currentTeam = nextTeam; + } + } + // We wrapped around. + if (this.currentTeam === previousTeam) { + this.stateIteration++; + if (this.rules.winWhenOneGroupRemains) { + // All remaining teams are part of the same group return { - nextTeam: this.currentTeam, - // We should have already validated that this team has healthy worms. - nextWorm: this.currentTeam.popNextWorm(), - } - } -} \ No newline at end of file + winningTeams: this.getActiveTeams(), + }; + } else if (this.currentTeam.health === 0) { + // This is a draw + return { + winningTeams: [], + }; + } + } + this.stateIteration++; + this.preRoundOverAtTs = Date.now() + PreRoundMs; + + return { + nextTeam: this.currentTeam, + // We should have already validated that this team has healthy worms. + nextWorm: this.currentTeam.popNextWorm(), + }; + } +} diff --git a/src/logic/teams.ts b/src/logic/teams.ts index e5ad390..2204d00 100644 --- a/src/logic/teams.ts +++ b/src/logic/teams.ts @@ -2,68 +2,73 @@ import { ColorSource } from "pixi.js"; import Logger from "../log"; export interface WormIdentity { - uuid?: string; - name: string; - health: number; - maxHealth: number; + uuid?: string; + name: string; + health: number; + maxHealth: number; } export enum TeamGroup { - Red, - Blue, - Green, - Yellow, - Purple, - Orange, + Red, + Blue, + Green, + Yellow, + Purple, + Orange, } - export interface Team { - name: string; - group: TeamGroup; - worms: WormIdentity[] - // For net games only - playerUserId: string|null, + name: string; + group: TeamGroup; + worms: WormIdentity[]; + // For net games only + playerUserId: string | null; } -export function teamGroupToColorSet(group: TeamGroup): {bg: ColorSource, fg: ColorSource } { - switch (group) { - case TeamGroup.Red: - return { bg: 0xCC3333, fg: 0xBB5555 }; - case TeamGroup.Blue: - return { bg: 0x2244CC, fg: 0x3366CC }; - default: - return { bg: 0xCC00CC, fg: 0x111111 }; - } +export function teamGroupToColorSet(group: TeamGroup): { + bg: ColorSource; + fg: ColorSource; +} { + switch (group) { + case TeamGroup.Red: + return { bg: 0xcc3333, fg: 0xbb5555 }; + case TeamGroup.Blue: + return { bg: 0x2244cc, fg: 0x3366cc }; + default: + return { bg: 0xcc00cc, fg: 0x111111 }; + } } -const logger = new Logger('WormInstance'); +const logger = new Logger("WormInstance"); /** * Instance of a worm, keeping track of it's status. */ export class WormInstance { - public readonly uuid; - constructor(private readonly identity: WormIdentity, public readonly team: Team, private readonly onHealthUpdated: () => void) { - this.uuid = identity.uuid ?? globalThis.crypto.randomUUID(); - } - + public readonly uuid; + constructor( + private readonly identity: WormIdentity, + public readonly team: Team, + private readonly onHealthUpdated: () => void, + ) { + this.uuid = identity.uuid ?? globalThis.crypto.randomUUID(); + } - get name() { - return this.identity.name; - } + get name() { + return this.identity.name; + } - get maxHealth() { - return this.identity.maxHealth; - } + get maxHealth() { + return this.identity.maxHealth; + } - get health() { - return this.identity.health; - } + get health() { + return this.identity.health; + } - set health(health: number) { - logger.debug(`Worm (${this.uuid}, ${this.name}) health updated ${health}`); - this.identity.health = health; - this.onHealthUpdated(); - } -} \ No newline at end of file + set health(health: number) { + logger.debug(`Worm (${this.uuid}, ${this.name}) health updated ${health}`); + this.identity.health = health; + this.onHealthUpdated(); + } +} diff --git a/src/main.tsx b/src/main.tsx index 1cd9f8e..b3ab4d0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,5 +1,5 @@ -import { render } from 'preact' -import './index.css' -import { App } from './components/app' +import { render } from "preact"; +import "./index.css"; +import { App } from "./components/app"; -render(, document.getElementById('app') as HTMLElement) +render(, document.getElementById("app") as HTMLElement); diff --git a/src/mixins/bodyWireframe..ts b/src/mixins/bodyWireframe..ts index ff4de43..e7777ca 100644 --- a/src/mixins/bodyWireframe..ts +++ b/src/mixins/bodyWireframe..ts @@ -7,74 +7,83 @@ import { DefaultTextStyle } from "./styles"; * Render a wireframe in pixi.js around a matter body. */ - -const globalWindow = (window as unknown as {debugPivotModX: number, debugPivotModY: number, debugRotation: number}); +const globalWindow = window as unknown as { + debugPivotModX: number; + debugPivotModY: number; + debugRotation: number; +}; globalWindow.debugPivotModX = 0; globalWindow.debugPivotModY = 0; globalWindow.debugRotation = 0; export class BodyWireframe { - private gfx = new Graphics(); - private debugText = new Text({ - text: '', - style: { - ...DefaultTextStyle, - fontSize: 16, - align: 'center', - } - }); + private gfx = new Graphics(); + private debugText = new Text({ + text: "", + style: { + ...DefaultTextStyle, + fontSize: 16, + align: "center", + }, + }); - private shouldRender: boolean; - public set enabled(value: boolean) { - this.shouldRender = value; - this.debugText.visible = value; - this.gfx.visible = value; - } + private shouldRender: boolean; + public set enabled(value: boolean) { + this.shouldRender = value; + this.debugText.visible = value; + this.gfx.visible = value; + } - public get enabled() { - return this.shouldRender; - } + public get enabled() { + return this.shouldRender; + } - public get renderable() { - return this.gfx; - } + public get renderable() { + return this.gfx; + } - private readonly width: number; - private readonly height: number; + private readonly width: number; + private readonly height: number; - constructor(private parent: RapierPhysicsObject, enabled = true) { - this.gfx.addChild(this.debugText); - // TODO - const shape = parent.collider.shape as Cuboid; - this.width = shape.halfExtents.x * 2 * PIXELS_PER_METER; - this.height = shape.halfExtents.y * 2 * PIXELS_PER_METER; - this.debugText.position.x = this.width + 5; + constructor( + private parent: RapierPhysicsObject, + enabled = true, + ) { + this.gfx.addChild(this.debugText); + // TODO + const shape = parent.collider.shape as Cuboid; + this.width = shape.halfExtents.x * 2 * PIXELS_PER_METER; + this.height = shape.halfExtents.y * 2 * PIXELS_PER_METER; + this.debugText.position.x = this.width + 5; - // To make TS happy. - this.shouldRender = enabled; - this.enabled = enabled; - } + // To make TS happy. + this.shouldRender = enabled; + this.enabled = enabled; + } - setDebugText(text: string) { - this.debugText.text = text; - } + setDebugText(text: string) { + this.debugText.text = text; + } - update() { - // TODO: Wasteful? - this.gfx.clear(); - if (!this.shouldRender) { - return; - } - this.gfx.circle(this.width / 2, this.height / 2, 3).stroke({width: 1, color: 0xFF0000}); - this.gfx.rect(0, 0,this.width,this.height).stroke({width: 1, color: 0xFFBD01, alpha: 1}); - const t = this.parent.body.translation(); - this.gfx.updateTransform({ - x: (t.x * PIXELS_PER_METER) - this.width/2, - y: (t.y * PIXELS_PER_METER) - this.height/2, - // rotation: this.body.angle, - // pivotX: globalWindow.debugPivotModX, - // pivotY: globalWindow.debugPivotModY, - }); - + update() { + // TODO: Wasteful? + this.gfx.clear(); + if (!this.shouldRender) { + return; } -} \ No newline at end of file + this.gfx + .circle(this.width / 2, this.height / 2, 3) + .stroke({ width: 1, color: 0xff0000 }); + this.gfx + .rect(0, 0, this.width, this.height) + .stroke({ width: 1, color: 0xffbd01, alpha: 1 }); + const t = this.parent.body.translation(); + this.gfx.updateTransform({ + x: t.x * PIXELS_PER_METER - this.width / 2, + y: t.y * PIXELS_PER_METER - this.height / 2, + // rotation: this.body.angle, + // pivotX: globalWindow.debugPivotModX, + // pivotY: globalWindow.debugPivotModY, + }); + } +} diff --git a/src/mixins/styles.ts b/src/mixins/styles.ts index b333012..6a2b561 100644 --- a/src/mixins/styles.ts +++ b/src/mixins/styles.ts @@ -1,17 +1,22 @@ import { ColorSource, Graphics, TextOptions } from "pixi.js"; -export function applyGenericBoxStyle(gfx: Graphics, borderColor: ColorSource = 0xAAAAAA) { - return gfx.setStrokeStyle({ - width: 2, - color: borderColor, - cap: 'butt', - join: 'round', - }).setFillStyle({ color: 0x111111, alpha: 0.95}) +export function applyGenericBoxStyle( + gfx: Graphics, + borderColor: ColorSource = 0xaaaaaa, +) { + return gfx + .setStrokeStyle({ + width: 2, + color: borderColor, + cap: "butt", + join: "round", + }) + .setFillStyle({ color: 0x111111, alpha: 0.95 }); } export const DefaultTextStyle = { - fontFamily: 'Monogram', - fontSize: 24, - fill: 0xFFFFFF, - align: 'left', -} as TextOptions["style"]; \ No newline at end of file + fontFamily: "Monogram", + fontSize: 24, + fill: 0xffffff, + align: "left", +} as TextOptions["style"]; diff --git a/src/movementController.ts b/src/movementController.ts index a6cfec2..99c6a28 100644 --- a/src/movementController.ts +++ b/src/movementController.ts @@ -3,99 +3,125 @@ import { GameWorld, RapierPhysicsObject } from "./world"; import { add, Coordinate, MetersValue, mult } from "./utils"; import Logger from "./log"; -const logger = new Logger('movementController'); +const logger = new Logger("movementController"); export let debugData: { - rayCoodinate: Coordinate, - shape: Cuboid, + rayCoodinate: Coordinate; + shape: Cuboid; }; export function getGroundDifference(colliderA: Collider, colliderB: Collider) { - const [higher,lower] = [colliderA, colliderB].sort((a,b) => b.translation().y - a.translation().y); - const higherBottom = higher.translation().y - (higher.shape as Cuboid).halfExtents.y; - const lowerTop = lower.translation().y + (lower.shape as Cuboid).halfExtents.y; - return Math.round((lowerTop - higherBottom) * 100)/100; + const [higher, lower] = [colliderA, colliderB].sort( + (a, b) => b.translation().y - a.translation().y, + ); + const higherBottom = + higher.translation().y - (higher.shape as Cuboid).halfExtents.y; + const lowerTop = + lower.translation().y + (lower.shape as Cuboid).halfExtents.y; + return Math.round((lowerTop - higherBottom) * 100) / 100; } -export function calculateMovement(physObject: RapierPhysicsObject, movement: Vector2, maxSteppy: MetersValue, world: GameWorld): Vector2 { - const currentTranslation = physObject.body.translation(); - // Offset from current shape - if (physObject.collider.shape instanceof Cuboid === false) { - throw Error('calculateMovement only supports cuboid objects'); - } - const currentShape = physObject.collider.shape as Cuboid; - const move = add(mult( - add( - currentTranslation, - movement, - ), - // TODO: Mutiply by a scaling factor? - { x: 1, y: 1 } - // Add shape extents. - ), { y: 0, x: 0 }); +export function calculateMovement( + physObject: RapierPhysicsObject, + movement: Vector2, + maxSteppy: MetersValue, + world: GameWorld, +): Vector2 { + const currentTranslation = physObject.body.translation(); + // Offset from current shape + if (physObject.collider.shape instanceof Cuboid === false) { + throw Error("calculateMovement only supports cuboid objects"); + } + const currentShape = physObject.collider.shape as Cuboid; + const move = add( + mult( + add(currentTranslation, movement), + // TODO: Mutiply by a scaling factor? + { x: 1, y: 1 }, + // Add shape extents. + ), + { y: 0, x: 0 }, + ); - const {y: objHalfHeight, x: objHalfWidth } = (physObject.collider.shape as Cuboid).halfExtents; - // Get the extremity. - const rayCoodinate = new Coordinate( - // Coodinate check in advance of the current shape - move.x + (movement.x < 0 ? currentShape.halfExtents.x*-1.5 : currentShape.halfExtents.x*1.5), - // Increase the bounds to the steppy position. - move.y - (maxSteppy.value / 2), - ); + const { y: objHalfHeight, x: objHalfWidth } = ( + physObject.collider.shape as Cuboid + ).halfExtents; + // Get the extremity. + const rayCoodinate = new Coordinate( + // Coodinate check in advance of the current shape + move.x + + (movement.x < 0 + ? currentShape.halfExtents.x * -1.5 + : currentShape.halfExtents.x * 1.5), + // Increase the bounds to the steppy position. + move.y - maxSteppy.value / 2, + ); - // Increase by steppy amount. - const initialCollisionShape = new Cuboid(objHalfWidth, objHalfHeight + maxSteppy.value); - debugData = { rayCoodinate, shape: initialCollisionShape }; + // Increase by steppy amount. + const initialCollisionShape = new Cuboid( + objHalfWidth, + objHalfHeight + maxSteppy.value, + ); + debugData = { rayCoodinate, shape: initialCollisionShape }; - const collides = world.checkCollisionShape(rayCoodinate, initialCollisionShape, physObject.collider); - // Pop the highest collider - const highestCollider = collides.sort((a,b) => a.collider.translation().y-b.collider.translation().y)[0]; + const collides = world.checkCollisionShape( + rayCoodinate, + initialCollisionShape, + physObject.collider, + ); + // Pop the highest collider + const highestCollider = collides.sort( + (a, b) => a.collider.translation().y - b.collider.translation().y, + )[0]; - // No collisions, go go go! - if (!highestCollider) { - logger.debug('No collision'); - return move; - } + // No collisions, go go go! + if (!highestCollider) { + logger.debug("No collision"); + return move; + } - // const shape = highestCollider.collider.shape; - const bodyT = highestCollider.collider.translation(); - const stepSize = currentTranslation.y - bodyT.y; - if (stepSize > maxSteppy.value) { - return currentTranslation; - } - // TODO: Support more types. - // const halfHeight = shape.type === ShapeType.Cuboid ? (shape as Cuboid).halfExtents.y : (shape as Ball).radius; + // const shape = highestCollider.collider.shape; + const bodyT = highestCollider.collider.translation(); + const stepSize = currentTranslation.y - bodyT.y; + if (stepSize > maxSteppy.value) { + return currentTranslation; + } + // TODO: Support more types. + // const halfHeight = shape.type === ShapeType.Cuboid ? (shape as Cuboid).halfExtents.y : (shape as Ball).radius; - // Step - const differential = getGroundDifference(physObject.collider, highestCollider.collider); - if (differential >= 1.5) { - return currentTranslation; - } else if (differential > 0) { - move.y -= (differential + 0.1); - } - - return move; + // Step + const differential = getGroundDifference( + physObject.collider, + highestCollider.collider, + ); + if (differential >= 1.5) { + return currentTranslation; + } else if (differential > 0) { + move.y -= differential + 0.1; + } + + return move; - // const newMove = new Coordinate( - // bodyT.x, - // // Crop a bit off the top to avoid colliding with it. - // bodyT.y - halfHeight - objHalfHeight - 0.01, - // ) - // const newCollisionShape = new Cuboid( - // objHalfWidth, - // objHalfHeight, - // ); - // console.log('wants to move to', highestCollider.collider.handle, highestCollider.collider.translation()); - - // Check step is safe - // console.log(debugData); - // debugData = { rayCoodinate: newMove, shape: initialCollisionShape } - // console.log(debugData); - // const [secondaryCollision] = world.checkCollisionShape(newMove, newCollisionShape, highestCollider.collider); - // if (secondaryCollision){ - // //console.log('Collision!', secondaryCollision.collider.handle, secondaryCollision.collider.translation()); - // return currentTranslation; - // } - // console.log('Moved'); - // return newMove.toWorldVector(); -} \ No newline at end of file + // const newMove = new Coordinate( + // bodyT.x, + // // Crop a bit off the top to avoid colliding with it. + // bodyT.y - halfHeight - objHalfHeight - 0.01, + // ) + // const newCollisionShape = new Cuboid( + // objHalfWidth, + // objHalfHeight, + // ); + // console.log('wants to move to', highestCollider.collider.handle, highestCollider.collider.translation()); + + // Check step is safe + // console.log(debugData); + // debugData = { rayCoodinate: newMove, shape: initialCollisionShape } + // console.log(debugData); + // const [secondaryCollision] = world.checkCollisionShape(newMove, newCollisionShape, highestCollider.collider); + // if (secondaryCollision){ + // //console.log('Collision!', secondaryCollision.collider.handle, secondaryCollision.collider.translation()); + // return currentTranslation; + // } + // console.log('Moved'); + // return newMove.toWorldVector(); +} diff --git a/src/net/client.ts b/src/net/client.ts index e53bce4..954a60c 100644 --- a/src/net/client.ts +++ b/src/net/client.ts @@ -1,182 +1,232 @@ - - -import { GameStageEvent, GameConfigEvent, GameStage, PlayerAckEvent } from "./models"; +import { + GameStageEvent, + GameConfigEvent, + GameStage, + PlayerAckEvent, + GameStageEventType, + GameConfigEventType, + PlayerAckEventType, + GameStateEventType, + FullGameStateEvent, +} from "./models"; import { EventEmitter } from "pixi.js"; import { StateRecordLine } from "../state/model"; -import { ClientEvent, createClient, MatrixClient, MemoryStore, Preset, Room, RoomEvent, Visibility } from "matrix-js-sdk"; +import { + ClientEvent, + createClient, + MatrixClient, + MemoryStore, + Preset, + Room, + RoomEvent, + Visibility, +} from "matrix-js-sdk"; import { Team } from "../logic/teams"; import { GameRules } from "../logic/gamestate"; - export interface NetClientConfig { - baseUrl: string, - accessToken: string, + baseUrl: string; + accessToken: string; } interface NetGameConfiguration { - myUserId: string, - hostUserId: string, - members: Record, - teams: Team[], - stage: GameStage, - rules: GameRules, + myUserId: string; + hostUserId: string; + members: Record; + teams: Team[]; + stage: GameStage; + rules: GameRules; } export class NetGameInstance { - private _members: Record; - public readonly hostUserId: string; - public readonly isHost: boolean; - private _stage: GameStage; - private room: Room; - private rules: GameRules; - private teams: Team[]; - - public get members() { - return {...this._members}; - } - - public get stage() { - return this._stage; - } - - constructor(private readonly roomId: string, private readonly client: NetGameClient, initialConfiguration: NetGameConfiguration) { - this.hostUserId = initialConfiguration.hostUserId; - this.isHost = initialConfiguration.hostUserId === initialConfiguration.myUserId; - // TODO: Auto update on new members - this._members = initialConfiguration.members; - this._stage = initialConfiguration.stage; - this.room = this.client.client.getRoom(roomId)!; - this.rules = initialConfiguration.rules; - this.teams = initialConfiguration.teams; - if (!this.room) { - throw Error('Room not found'); - } - } - - public async updateGameConfig() { - await this.client.client.sendStateEvent(this.roomId, 'uk.half-shot.uk.wormgine.game_config' as any, { - teams: this.teams, - rules: this.rules, - } satisfies GameConfigEvent["content"]); - } - - public async startGame() { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await this.client.client.sendStateEvent(this.roomId, 'uk.half-shot.uk.wormgine.game_stage' as any, { - stage: GameStage.InProgress, - } satisfies GameStageEvent["content"]); - } - - public async sendAck() { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await this.client.client.sendEvent(this.roomId, 'uk.half-shot.uk.wormgine.ack' as any, { - ack: true, - } satisfies PlayerAckEvent["content"]); - } - - public async sendGameState(data: Record) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await this.client.client.sendEvent(this.roomId, 'uk.half-shot.wormgine.game_state' as any, data); - } - - public subscribeToGameState(_fn: (data: StateRecordLine) => void) { - throw Error('Not implemented'); - this.room.on(RoomEvent.TimelineRefresh, (_room, timelineSet) => { - console.log(timelineSet.getLiveTimeline().getEvents()); - }); - } + private _members: Record; + public readonly hostUserId: string; + public readonly isHost: boolean; + private _stage: GameStage; + private room: Room; + private rules: GameRules; + private teams: Team[]; + + public get members() { + return { ...this._members }; + } + + public get stage() { + return this._stage; + } + + constructor( + private readonly roomId: string, + private readonly client: NetGameClient, + initialConfiguration: NetGameConfiguration, + ) { + this.hostUserId = initialConfiguration.hostUserId; + this.isHost = + initialConfiguration.hostUserId === initialConfiguration.myUserId; + // TODO: Auto update on new members + this._members = initialConfiguration.members; + this._stage = initialConfiguration.stage; + this.room = this.client.client.getRoom(roomId)!; + this.rules = initialConfiguration.rules; + this.teams = initialConfiguration.teams; + if (!this.room) { + throw Error("Room not found"); + } + } + + public async updateGameConfig() { + await this.client.client.sendStateEvent(this.roomId, GameConfigEventType, { + teams: this.teams, + rules: this.rules, + } satisfies GameConfigEvent["content"]); + } + + public async startGame() { + await this.client.client.sendStateEvent(this.roomId, GameStageEventType, { + stage: GameStage.InProgress, + } satisfies GameStageEvent["content"]); + } + + public async sendAck() { + await this.client.client.sendEvent(this.roomId, PlayerAckEventType, { + ack: true, + } satisfies PlayerAckEvent["content"]); + } + + public async sendGameState(data: FullGameStateEvent["content"]) { + await this.client.client.sendEvent(this.roomId, GameStateEventType, data); + } + + public subscribeToGameState(_fn: (data: StateRecordLine) => void) { + throw Error("Not implemented"); + this.room.on(RoomEvent.TimelineRefresh, (_room, timelineSet) => { + console.log(timelineSet.getLiveTimeline().getEvents()); + }); + } } const WormgineRoomType = "uk.half-shot.wormgine.v1"; export class NetGameClient extends EventEmitter { - public readonly client: MatrixClient; - - public static async register(homeserverUrl: string, name: string, password: string) { - const client = createClient({ - baseUrl: homeserverUrl, - fetchFn: (input, init) => globalThis.fetch(input, init), - }); - return await client.register(name, password, null, { type: "m.login.password"}); - } - public static async login(homeserverUrl: string, username: string, password: string): Promise<{ accessToken: string; }> { - const client = createClient({ - baseUrl: homeserverUrl, - fetchFn: (input, init) => globalThis.fetch(input, init), - store: new MemoryStore({ localStorage: window.localStorage }), - }); - const response = await client.loginWithPassword(username, password); - return { accessToken: response.access_token }; - } - - constructor(config: NetClientConfig) { - super(); - this.client = createClient({ - baseUrl: config.baseUrl, - accessToken: config.accessToken, - fetchFn: (input, init) => globalThis.fetch(input, init), - store: new MemoryStore({ localStorage: window.localStorage }), - }); - } - - public get ready() { - return this.client.isInitialSyncComplete(); - } - - public async start() { - const whoami = await this.client.whoami(); - this.client.credentials.userId = whoami.user_id; - this.client.deviceId = whoami.device_id ?? null; - this.client.addListener(ClientEvent.Sync, () => { - this.emit('sync'); - }) - await this.client.startClient(); - } - - public async setDisplayname(name: string): Promise { - await this.client.setDisplayName(name); - } - - public async createGameRoom(initialConfig: GameConfigEvent["content"]): Promise { - return (await this.client.createRoom({ - name: `Wormtrix ${new Date().toUTCString()}`, - preset: Preset.PublicChat, - visibility: Visibility.Private, - creation_content: { - type: WormgineRoomType - }, - initial_state: [{ - state_key: "", type: "uk.half-shot.uk.wormgine.game_stage", content: { stage: GameStage.Lobby } - } satisfies GameStageEvent, { - state_key: "", type: "uk.half-shot.uk.wormgine.game_config", content: initialConfig - } satisfies GameConfigEvent] - })).room_id; - } - - public async joinGameRoom(roomId: string): Promise { - // TODO: Check game room is a real game room. - await this.client.joinRoom(roomId); - const stateEvents = await this.client.roomState(roomId); - const createEvent = stateEvents.find(s => s.type === "m.room.create"); - const stageEvent = stateEvents.find(s => s.type === "uk.half-shot.uk.wormgine.game_stage"); - const configEvent = stateEvents.find(s => s.type === "uk.half-shot.uk.wormgine.game_config") as unknown as GameConfigEvent; - if (createEvent?.content.type !== WormgineRoomType) { - throw Error('Room is not a wormgine room'); - } - const gameStage = stageEvent?.content.stage as GameStage; - // TODO: Test that this value is correct. - if (!gameStage) { - throw Error('Unknown game stage, cannot continue'); - } - return new NetGameInstance(roomId, this, { - // Should be forced in start() - myUserId: this.client.getUserId()!, - hostUserId: createEvent.sender, - // TODO: How to figure this out? - members: Object.fromEntries(stateEvents.filter(m => m.type === "m.room.member" && m.content.membership === "join").map(m => [m.state_key, m.content.displayname ?? m.state_key])), - stage: gameStage, - teams: configEvent.content.teams, - rules: configEvent.content.rules, - }) - } -} \ No newline at end of file + public readonly client: MatrixClient; + + public static async register( + homeserverUrl: string, + name: string, + password: string, + ) { + const client = createClient({ + baseUrl: homeserverUrl, + fetchFn: (input, init) => globalThis.fetch(input, init), + }); + return await client.register(name, password, null, { + type: "m.login.password", + }); + } + public static async login( + homeserverUrl: string, + username: string, + password: string, + ): Promise<{ accessToken: string }> { + const client = createClient({ + baseUrl: homeserverUrl, + fetchFn: (input, init) => globalThis.fetch(input, init), + store: new MemoryStore({ localStorage: window.localStorage }), + }); + const response = await client.loginWithPassword(username, password); + return { accessToken: response.access_token }; + } + + constructor(config: NetClientConfig) { + super(); + this.client = createClient({ + baseUrl: config.baseUrl, + accessToken: config.accessToken, + fetchFn: (input, init) => globalThis.fetch(input, init), + store: new MemoryStore({ localStorage: window.localStorage }), + }); + } + + public get ready() { + return this.client.isInitialSyncComplete(); + } + + public async start() { + const whoami = await this.client.whoami(); + this.client.credentials.userId = whoami.user_id; + this.client.deviceId = whoami.device_id ?? null; + this.client.addListener(ClientEvent.Sync, () => { + this.emit("sync"); + }); + await this.client.startClient(); + } + + public async setDisplayname(name: string): Promise { + await this.client.setDisplayName(name); + } + + public async createGameRoom( + initialConfig: GameConfigEvent["content"], + ): Promise { + return ( + await this.client.createRoom({ + name: `Wormtrix ${new Date().toUTCString()}`, + preset: Preset.PublicChat, + visibility: Visibility.Private, + creation_content: { + type: WormgineRoomType, + }, + initial_state: [ + { + state_key: "", + type: "uk.half-shot.uk.wormgine.game_stage", + content: { stage: GameStage.Lobby }, + } satisfies GameStageEvent, + { + state_key: "", + type: "uk.half-shot.uk.wormgine.game_config", + content: initialConfig, + } satisfies GameConfigEvent, + ], + }) + ).room_id; + } + + public async joinGameRoom(roomId: string): Promise { + // TODO: Check game room is a real game room. + await this.client.joinRoom(roomId); + const stateEvents = await this.client.roomState(roomId); + const createEvent = stateEvents.find((s) => s.type === "m.room.create"); + const stageEvent = stateEvents.find( + (s) => s.type === "uk.half-shot.uk.wormgine.game_stage", + ); + const configEvent = stateEvents.find( + (s) => s.type === "uk.half-shot.uk.wormgine.game_config", + ) as unknown as GameConfigEvent; + if (createEvent?.content.type !== WormgineRoomType) { + throw Error("Room is not a wormgine room"); + } + const gameStage = stageEvent?.content.stage as GameStage; + // TODO: Test that this value is correct. + if (!gameStage) { + throw Error("Unknown game stage, cannot continue"); + } + return new NetGameInstance(roomId, this, { + // Should be forced in start() + myUserId: this.client.getUserId()!, + hostUserId: createEvent.sender, + // TODO: How to figure this out? + members: Object.fromEntries( + stateEvents + .filter( + (m) => + m.type === "m.room.member" && m.content.membership === "join", + ) + .map((m) => [m.state_key, m.content.displayname ?? m.state_key]), + ), + stage: gameStage, + teams: configEvent.content.teams, + rules: configEvent.content.rules, + }); + } +} diff --git a/src/net/models.ts b/src/net/models.ts index a2cd68e..0477937 100644 --- a/src/net/models.ts +++ b/src/net/models.ts @@ -2,7 +2,6 @@ import { InputKind } from "../input"; import { GameRules } from "../logic/gamestate"; import { Team } from "../logic/teams"; - /** * Matrix will need this as an integer, so this will be encoded as an integer * and multiplied. @@ -10,89 +9,90 @@ import { Team } from "../logic/teams"; type EncodedFloatingPointNumber = number; export interface EntityDescriptor { - pos: {x: EncodedFloatingPointNumber, y: EncodedFloatingPointNumber}; - rot: number; - + pos: { x: EncodedFloatingPointNumber; y: EncodedFloatingPointNumber }; + rot: number; } export enum GameStage { - Lobby = "lobby", - InProgress = "in_progress", - Finished = "completed", + Lobby = "lobby", + InProgress = "in_progress", + Finished = "completed", } - +export const GameStageEventType = "uk.half-shot.uk.wormgine.game_stage"; export interface GameStageEvent { - type: "uk.half-shot.uk.wormgine.game_stage", - state_key: "", - content: { - stage: GameStage, - } + type: typeof GameStageEventType; + state_key: ""; + content: { + stage: GameStage; + }; } - +export const GameStateEventType = "uk.half-shot.wormgine.game_state"; export interface FullGameStateEvent { - type: "uk.half-shot.uk.wormgine.game_state", - content: { - iteration: number; - bitmap_hash: string; - ents: EntityDescriptor[]; - teams: Team[]; - } + type: typeof GameStateEventType; + content: { + iteration: number; + bitmap_hash: string; + ents: EntityDescriptor[]; + teams: Team[]; + }; } export interface GameControlEvent { - type: "uk.half-shot.uk.wormgine.game_state", - content: { - input: InputKind; - entity: number; // ? - } + type: typeof GameStateEventType; + content: { + input: InputKind; + entity: number; // ? + }; } export interface BitmapEvent { - type: "uk.half-shot.uk.wormgine.bitmap", - content: { - mxc: string; - bitmap_hash: string; - } + type: "uk.half-shot.uk.wormgine.bitmap"; + content: { + mxc: string; + bitmap_hash: string; + }; } export interface BitmapUpdateEvent { - type: "uk.half-shot.uk.wormgine.bitmap", - content: { - b64: string; - region: { x: number, y: number}; - bitmap_hash: string; - } + type: "uk.half-shot.uk.wormgine.bitmap"; + content: { + b64: string; + region: { x: number; y: number }; + bitmap_hash: string; + }; } +export const PlayerAckEventType = "uk.half-shot.uk.wormgine.ack"; export interface PlayerAckEvent { - type: "uk.half-shot.uk.wormgine.ack", - content: { - ack: true - } + type: typeof PlayerAckEventType; + content: { + ack: true; + }; } +export const GameConfigEventType = "uk.half-shot.uk.wormgine.game_config"; export interface GameConfigEvent { - state_key: "", - type: "uk.half-shot.uk.wormgine.game_config", - content: { - rules: GameRules - teams: Team[], - // Need to decide on some config. - } + state_key: ""; + type: typeof GameConfigEventType; + content: { + rules: GameRules; + teams: Team[]; + // Need to decide on some config. + }; } export interface ClientReadyEvent { - type: "uk.half-shot.uk.wormgine.ready", - // Need to decide on some config. - content: Record + type: "uk.half-shot.uk.wormgine.ready"; + // Need to decide on some config. + content: Record; } export interface GameStartEvent { - type: "uk.half-shot.uk.wormgine.start", - content: { - bitmap_hash: string; - // Need to decide on some config. - }&GameConfigEvent["content"] -} \ No newline at end of file + type: "uk.half-shot.uk.wormgine.start"; + content: { + bitmap_hash: string; + // Need to decide on some config. + } & GameConfigEvent["content"]; +} diff --git a/src/overlays/debugOverlay.ts b/src/overlays/debugOverlay.ts index 6bb69df..7a3ccd1 100644 --- a/src/overlays/debugOverlay.ts +++ b/src/overlays/debugOverlay.ts @@ -1,4 +1,11 @@ -import { Container, Graphics, Point, Text, Ticker, UPDATE_PRIORITY } from "pixi.js"; +import { + Container, + Graphics, + Point, + Text, + Ticker, + UPDATE_PRIORITY, +} from "pixi.js"; import globalFlags, { DebugLevel } from "../flags"; import RAPIER from "@dimforge/rapier2d-compat"; import { PIXELS_PER_METER } from "../world"; @@ -10,117 +17,136 @@ const PHYSICS_SAMPLES = 60; const FRAME_SAMPLES = 60; export class GameDebugOverlay { - private readonly fpsSamples: number[] = []; - public readonly physicsSamples: number[] = []; - private readonly text: Text; - private readonly tickerFn: (dt: Ticker) => void; - private readonly rapierGfx: Graphics; - - private skippedUpdates = 0; - private skippedUpdatesTarget = 0; - private mouse: Point = new Point(); - private mouseMoveListener: (e: MouseEvent) => void; - - constructor( - private readonly rapierWorld: RAPIER.World, - private readonly ticker: Ticker, - private readonly stage: Container, - private readonly viewport: Viewport, - ) { - this.text = new Text({ - text: '', - style: { - ...DefaultTextStyle, - fontSize: 20, - }, - }); - this.rapierGfx = new Graphics(); - this.tickerFn = this.update.bind(this); - globalFlags.on('toggleDebugView', (level: DebugLevel) => { - if (level !== DebugLevel.None) { - this.enableOverlay(); - } else { - this.disableOverlay(); - } - }); - if (globalFlags.DebugView) { - this.enableOverlay(); - } - this.mouseMoveListener = async (evt: MouseEvent) => { - const pos = this.viewport.toWorld(new Point(evt.clientX, evt.clientY)); - this.mouse = pos; - }; + private readonly fpsSamples: number[] = []; + public readonly physicsSamples: number[] = []; + private readonly text: Text; + private readonly tickerFn: (dt: Ticker) => void; + private readonly rapierGfx: Graphics; + + private skippedUpdates = 0; + private skippedUpdatesTarget = 0; + private mouse: Point = new Point(); + private mouseMoveListener: (e: MouseEvent) => void; + + constructor( + private readonly rapierWorld: RAPIER.World, + private readonly ticker: Ticker, + private readonly stage: Container, + private readonly viewport: Viewport, + ) { + this.text = new Text({ + text: "", + style: { + ...DefaultTextStyle, + fontSize: 20, + }, + }); + this.rapierGfx = new Graphics(); + this.tickerFn = this.update.bind(this); + globalFlags.on("toggleDebugView", (level: DebugLevel) => { + if (level !== DebugLevel.None) { + this.enableOverlay(); + } else { + this.disableOverlay(); + } + }); + if (globalFlags.DebugView) { + this.enableOverlay(); + } + this.mouseMoveListener = async (evt: MouseEvent) => { + const pos = this.viewport.toWorld(new Point(evt.clientX, evt.clientY)); + this.mouse = pos; + }; + } + + private enableOverlay() { + this.stage.addChild(this.text); + this.viewport.addChild(this.rapierGfx); + this.ticker.add(this.tickerFn, undefined, UPDATE_PRIORITY.UTILITY); + window.addEventListener("mousemove", this.mouseMoveListener); + } + + private disableOverlay() { + this.ticker.remove(this.tickerFn); + this.stage.removeChild(this.text); + this.viewport.removeChild(this.rapierGfx); + window.removeEventListener("mousemove", this.mouseMoveListener); + } + + private update(dt: Ticker) { + this.fpsSamples.splice(0, 0, dt.FPS); + while (this.fpsSamples.length > FRAME_SAMPLES) { + this.fpsSamples.pop(); } + const avgFps = Math.round( + this.fpsSamples.reduce((a, b) => a + b, 0) / this.fpsSamples.length, + ); + while (this.physicsSamples.length > PHYSICS_SAMPLES) { + this.physicsSamples.pop(); + } + + const avgPhysicsCostMs = + Math.ceil( + (this.physicsSamples.reduce((a, b) => a + b, 0) / + (this.physicsSamples.length || 1)) * + 100, + ) / 100; + + this.text.text = `FPS: ${avgFps} | Physics time: ${avgPhysicsCostMs}ms| Total bodies: ${this.rapierWorld.bodies.len()} | Mouse: ${Math.round(this.mouse.x)} ${Math.round(this.mouse.y)} | Ticker fns: ${this.ticker.count}`; - private enableOverlay() { - this.stage.addChild(this.text); - this.viewport.addChild(this.rapierGfx); - this.ticker.add(this.tickerFn, undefined, UPDATE_PRIORITY.UTILITY); - window.addEventListener('mousemove', this.mouseMoveListener); + this.skippedUpdatesTarget = 180 / avgFps; + + if (this.skippedUpdatesTarget >= this.skippedUpdates) { + this.skippedUpdates++; + return; } + this.skippedUpdates = 0; + + this.rapierGfx.clear(); + if (debugData) { + const castWidth = debugData.shape.halfExtents.x * PIXELS_PER_METER; + const castHeight = debugData.shape.halfExtents.y * PIXELS_PER_METER; - private disableOverlay() { - this.ticker.remove(this.tickerFn); - this.stage.removeChild(this.text); - this.viewport.removeChild(this.rapierGfx); - window.removeEventListener('mousemove', this.mouseMoveListener); + this.rapierGfx + .setStrokeStyle({ + color: "green", + width: 3, + }) + .rect( + debugData.rayCoodinate.screenX - castWidth, + debugData.rayCoodinate.screenY - castHeight, + castWidth * 2, + castHeight * 2, + ) + .stroke(); } - private update(dt: Ticker) { - this.fpsSamples.splice(0, 0, dt.FPS); - while (this.fpsSamples.length > FRAME_SAMPLES) { - this.fpsSamples.pop(); - } - const avgFps = Math.round(this.fpsSamples.reduce((a,b) => a + b, 0) / this.fpsSamples.length); - while (this.physicsSamples.length > PHYSICS_SAMPLES) { - this.physicsSamples.pop(); - } - - const avgPhysicsCostMs = Math.ceil(this.physicsSamples.reduce((a,b) => a + b, 0) / (this.physicsSamples.length || 1) * 100)/100; - - this.text.text = `FPS: ${avgFps} | Physics time: ${avgPhysicsCostMs}ms| Total bodies: ${this.rapierWorld.bodies.len()} | Mouse: ${Math.round(this.mouse.x)} ${Math.round(this.mouse.y)} | Ticker fns: ${this.ticker.count}`; - - this.skippedUpdatesTarget = (180/avgFps); - - if (this.skippedUpdatesTarget >= this.skippedUpdates) { - this.skippedUpdates++; - return; - } - this.skippedUpdates = 0; - - this.rapierGfx.clear(); - if (debugData) { - const castWidth = debugData.shape.halfExtents.x * PIXELS_PER_METER; - const castHeight = debugData.shape.halfExtents.y * PIXELS_PER_METER; - - this.rapierGfx.setStrokeStyle({ - color: "green", - width: 3 - }).rect(debugData.rayCoodinate.screenX - castWidth, debugData.rayCoodinate.screenY - castHeight, castWidth*2, castHeight*2).stroke(); - } - - if (globalFlags.DebugView === DebugLevel.PhysicsOverlay) { - this.renderPhysicsOverlay(); - } + if (globalFlags.DebugView === DebugLevel.PhysicsOverlay) { + this.renderPhysicsOverlay(); } + } + + private renderPhysicsOverlay() { + const buffers = this.rapierWorld.debugRender(); + const vtx = buffers.vertices; + const cls = buffers.colors; - private renderPhysicsOverlay() { - const buffers = this.rapierWorld.debugRender(); - const vtx = buffers.vertices; - const cls = buffers.colors; - - for (let i = 0; i < vtx.length / 4; i += 1) { - const vtxA = vtx[i * 4] * PIXELS_PER_METER; - const vtxB = vtx[i * 4 + 1] * PIXELS_PER_METER; - const vtxC = vtx[i * 4 + 2] * PIXELS_PER_METER; - const vtxD = vtx[i * 4 + 3] * PIXELS_PER_METER; - const color = new Float32Array([ - cls[i * 8], - cls[i * 8 + 1], - cls[i * 8 + 2], - cls[i * 8 + 3], - ]) - this.rapierGfx.setStrokeStyle({width: 1, color }).moveTo(vtxA, vtxB).lineTo(vtxC, vtxD).stroke(); - } + for (let i = 0; i < vtx.length / 4; i += 1) { + const vtxA = vtx[i * 4] * PIXELS_PER_METER; + const vtxB = vtx[i * 4 + 1] * PIXELS_PER_METER; + const vtxC = vtx[i * 4 + 2] * PIXELS_PER_METER; + const vtxD = vtx[i * 4 + 3] * PIXELS_PER_METER; + const color = new Float32Array([ + cls[i * 8], + cls[i * 8 + 1], + cls[i * 8 + 2], + cls[i * 8 + 3], + ]); + this.rapierGfx + .setStrokeStyle({ width: 1, color }) + .moveTo(vtxA, vtxB) + .lineTo(vtxC, vtxD) + .stroke(); } -} \ No newline at end of file + } +} diff --git a/src/overlays/gameStateOverlay.ts b/src/overlays/gameStateOverlay.ts index 145cc6f..8ac842d 100644 --- a/src/overlays/gameStateOverlay.ts +++ b/src/overlays/gameStateOverlay.ts @@ -8,141 +8,192 @@ import { WindDial } from "./windDial"; import { HEALTH_CHANGE_TENSION_TIMER } from "../consts"; import Logger from "../log"; -const logger = new Logger('GameStateOverlay'); +const logger = new Logger("GameStateOverlay"); export class GameStateOverlay { - public readonly physicsSamples: number[] = []; - private readonly roundTimer: Text; - private readonly tickerFn: (dt: Ticker) => void; - private readonly gfx: Graphics; - private previousStateIteration = -1; - private visibleTeamHealth: Record = {}; - private healthChangeTensionTimer: number|null = null; - private readonly largestHealthPool: number; + public readonly physicsSamples: number[] = []; + private readonly roundTimer: Text; + private readonly tickerFn: (dt: Ticker) => void; + private readonly gfx: Graphics; + private previousStateIteration = -1; + private visibleTeamHealth: Record = {}; + private healthChangeTensionTimer: number | null = null; + private readonly largestHealthPool: number; + + public readonly toaster: Toaster; + private readonly winddial: WindDial; + private readonly bottomOfScreenY; + + constructor( + private readonly ticker: Ticker, + private readonly stage: Container, + private readonly gameState: GameState, + private readonly gameWorld: GameWorld, + private readonly screenWidth: number, + private readonly screenHeight: number, + ) { + this.roundTimer = new Text({ + text: "60", + style: { + ...DefaultTextStyle, + fontSize: 64, + align: "center", + }, + }); + this.bottomOfScreenY = (this.screenHeight / 10) * 8.75; + + this.toaster = new Toaster(screenWidth, screenHeight); + this.winddial = new WindDial( + (this.screenWidth / 30) * 26, + this.bottomOfScreenY, + this.gameState, + ); + + this.roundTimer.position.set( + this.screenWidth / 30 + 14, + this.bottomOfScreenY + 12, + ); + this.gfx = new Graphics(); + this.stage.addChild(this.toaster.container); + this.stage.addChild(this.gfx); + this.stage.addChild(this.roundTimer); + this.stage.addChild(this.winddial.container); + this.tickerFn = this.update.bind(this); + this.ticker.add(this.tickerFn, undefined, UPDATE_PRIORITY.UTILITY); + this.largestHealthPool = this.gameState + .getTeams() + .reduceRight((value, team) => Math.max(value, team.maxHealth), 0); + this.gameState.getActiveTeams().forEach((t) => { + this.visibleTeamHealth[t.name] = t.health; + }); + } + + private update(dt: Ticker) { + this.toaster.update(dt); + this.winddial.update(); + const centerX = this.screenWidth / 2; + if (this.healthChangeTensionTimer && !this.gameWorld.areEntitiesMoving()) { + this.healthChangeTensionTimer -= dt.deltaTime; + } - public readonly toaster: Toaster; - private readonly winddial: WindDial; - private readonly bottomOfScreenY; + const shouldChangeTeamHealth = + this.healthChangeTensionTimer !== null && + this.healthChangeTensionTimer <= 0; - constructor( - private readonly ticker: Ticker, - private readonly stage: Container, - private readonly gameState: GameState, - private readonly gameWorld: GameWorld, - private readonly screenWidth: number, - private readonly screenHeight: number, + if ( + this.previousStateIteration === this.gameState.iteration && + !shouldChangeTeamHealth ) { - this.roundTimer = new Text({ - text: '60', - style: { - ...DefaultTextStyle, - fontSize: 64, - align: 'center', - }, - }); - this.bottomOfScreenY = (this.screenHeight / 10) * 8.75; - - this.toaster = new Toaster(screenWidth, screenHeight); - this.winddial = new WindDial((this.screenWidth / 30) * 26, this.bottomOfScreenY, this.gameState); - - this.roundTimer.position.set((this.screenWidth / 30) + 14, this.bottomOfScreenY + 12); - this.gfx = new Graphics(); - this.stage.addChild(this.toaster.container); - this.stage.addChild(this.gfx); - this.stage.addChild(this.roundTimer); - this.stage.addChild(this.winddial.container); - this.tickerFn = this.update.bind(this); - this.ticker.add(this.tickerFn, undefined, UPDATE_PRIORITY.UTILITY); - this.largestHealthPool = this.gameState.getTeams().reduceRight((value, team) => Math.max(value, team.maxHealth) , 0); - this.gameState.getActiveTeams().forEach((t) => { - this.visibleTeamHealth[t.name] = t.health; - }); + return; } - - - - private update(dt: Ticker) { - this.toaster.update(dt); - this.winddial.update(); - const centerX = this.screenWidth / 2; - if (this.healthChangeTensionTimer && !this.gameWorld.areEntitiesMoving()) { - this.healthChangeTensionTimer -= dt.deltaTime; - } - - const shouldChangeTeamHealth = this.healthChangeTensionTimer !== null && this.healthChangeTensionTimer <= 0; - - if (this.previousStateIteration === this.gameState.iteration && !shouldChangeTeamHealth) { - return; + this.previousStateIteration = this.gameState.iteration; + logger.debug(`Running update on iteration ${this.gameState.iteration}`); + + // TODO: Could the gameState flag this explicitly. + // Check for health change. + if (this.healthChangeTensionTimer === null) { + for (const team of this.gameState.getTeams()) { + if (this.visibleTeamHealth[team.name] === undefined) { + continue; } - this.previousStateIteration = this.gameState.iteration; - logger.debug(`Running update on iteration ${this.gameState.iteration}`); - - // TODO: Could the gameState flag this explicitly. - // Check for health change. - if (this.healthChangeTensionTimer === null) { - for (const team of this.gameState.getTeams()) { - if (this.visibleTeamHealth[team.name] === undefined) { - continue; - } - if (this.visibleTeamHealth[team.name] !== team.health) { - // TODO: Const, same as the one for Playable. - this.healthChangeTensionTimer = HEALTH_CHANGE_TENSION_TIMER; - return; - } - } + if (this.visibleTeamHealth[team.name] !== team.health) { + // TODO: Const, same as the one for Playable. + this.healthChangeTensionTimer = HEALTH_CHANGE_TENSION_TIMER; + return; } + } + } + this.roundTimer.text = Math.floor(this.gameState.remainingRoundTime / 1000); + this.gfx.clear(); + + // Remove any previous text. + this.gfx.removeChildren(0, this.gfx.children.length); + const currentTeamColors = this.gameState.activeTeam + ? teamGroupToColorSet(this.gameState.activeTeam?.group) + : { fg: 0xaaaaaa }; + + // Round timer + applyGenericBoxStyle(this.gfx, currentTeamColors.fg) + .roundRect( + this.roundTimer.x - 8, + this.roundTimer.y - 8, + this.roundTimer.width + 16, + this.roundTimer.height + 16, + 4, + ) + .stroke() + .fill(); + + // For each team: + // TODO: Sort by health and group + // TODO: Evenly space. + let allHealthAccurate = true; + let teamBottomY = this.bottomOfScreenY; + for (const team of this.gameState.getActiveTeams()) { + if ( + this.visibleTeamHealth[team.name] > team.health && + shouldChangeTeamHealth + ) { + this.visibleTeamHealth[team.name] -= 1; + allHealthAccurate = false; + } + const teamHealthPercentage = + this.visibleTeamHealth[team.name] / this.largestHealthPool; + const { bg, fg } = teamGroupToColorSet(team.group); + const nameTag = new Text({ + text: team.name, + style: { + ...DefaultTextStyle, + fontSize: 28, + align: "center", + }, + }); + const border = team === this.gameState.activeTeam ? 0xffffff : undefined; + const nameTagStartX = centerX - nameTag.width - 120; + applyGenericBoxStyle(this.gfx, border) + .roundRect( + nameTagStartX - 3, + teamBottomY - 2, + nameTag.width + 6, + nameTag.height + 4, + 4, + ) + .stroke() + .fill(); + nameTag.position.set(nameTagStartX, teamBottomY); + applyGenericBoxStyle(this.gfx, border) + .roundRect(centerX - 102, teamBottomY - 2, 204, 24, 4) + .stroke() + .fill(); + this.gfx + .setStrokeStyle({ + width: 5, + color: fg, + cap: "butt", + join: "round", + }) + .setFillStyle({ color: bg }) + .roundRect( + centerX - 100, + teamBottomY, + 200 * teamHealthPercentage, + 20, + 4, + ) + .fill(); + this.gfx.addChild(nameTag); + teamBottomY += 30; + } - this.roundTimer.text = Math.floor(this.gameState.remainingRoundTime/1000); - this.gfx.clear(); - - // Remove any previous text. - this.gfx.removeChildren(0, this.gfx.children.length); - const currentTeamColors = this.gameState.activeTeam ? teamGroupToColorSet(this.gameState.activeTeam?.group) : { fg: 0xAAAAAA }; - - // Round timer - applyGenericBoxStyle(this.gfx, currentTeamColors.fg).roundRect(this.roundTimer.x - 8, this.roundTimer.y - 8, this.roundTimer.width + 16, this.roundTimer.height + 16, 4).stroke().fill(); - - // For each team: - // TODO: Sort by health and group - // TODO: Evenly space. - let allHealthAccurate = true; - let teamBottomY = this.bottomOfScreenY; - for (const team of this.gameState.getActiveTeams()) { - if (this.visibleTeamHealth[team.name] > team.health && shouldChangeTeamHealth) { - this.visibleTeamHealth[team.name] -= 1; - allHealthAccurate = false; - } - const teamHealthPercentage = this.visibleTeamHealth[team.name] / this.largestHealthPool; - const {bg, fg} = teamGroupToColorSet(team.group); - const nameTag = new Text({ - text: team.name, - style: { - ...DefaultTextStyle, - fontSize: 28, - align: 'center', - } - }); - const border = team === this.gameState.activeTeam ? 0xFFFFFF : undefined; - const nameTagStartX = centerX - nameTag.width - 120; - applyGenericBoxStyle(this.gfx, border).roundRect(nameTagStartX - 3, teamBottomY-2, nameTag.width + 6, nameTag.height + 4, 4).stroke().fill(); - nameTag.position.set(nameTagStartX, teamBottomY); - applyGenericBoxStyle(this.gfx, border).roundRect(centerX - 102, teamBottomY-2, 204, 24, 4).stroke().fill(); - this.gfx.setStrokeStyle({ - width: 5, - color: fg, - cap: 'butt', - join: 'round', - }).setFillStyle({ color: bg }).roundRect(centerX - 100, teamBottomY, 200 * teamHealthPercentage, 20, 4).fill(); - this.gfx.addChild(nameTag); - teamBottomY += 30; - } - - if (allHealthAccurate) { - logger.debug('All health considered accurate'); - if (this.healthChangeTensionTimer !== null && this.healthChangeTensionTimer <= 0) { - this.healthChangeTensionTimer = null; - } - } + if (allHealthAccurate) { + logger.debug("All health considered accurate"); + if ( + this.healthChangeTensionTimer !== null && + this.healthChangeTensionTimer <= 0 + ) { + this.healthChangeTensionTimer = null; + } } -} \ No newline at end of file + } +} diff --git a/src/overlays/toaster.ts b/src/overlays/toaster.ts index 90d7212..5436fb2 100644 --- a/src/overlays/toaster.ts +++ b/src/overlays/toaster.ts @@ -2,79 +2,91 @@ import { ColorSource, Graphics, Container, Ticker, Text } from "pixi.js"; import { DefaultTextStyle, applyGenericBoxStyle } from "../mixins/styles"; interface Toast { - text: string; - timer: number; - color: ColorSource; - interruptable: boolean; + text: string; + timer: number; + color: ColorSource; + interruptable: boolean; } /** * Displays toast at the top of the screen during gameplay. */ export class Toaster { - private readonly gfx: Graphics; - private toastTime = 0; - private currentToastIsInterruptable = true; - private toaster: Toast[] = []; - private readonly text: Text; - public readonly container: Container; + private readonly gfx: Graphics; + private toastTime = 0; + private currentToastIsInterruptable = true; + private toaster: Toast[] = []; + private readonly text: Text; + public readonly container: Container; - constructor(private readonly screenWidth: number, private readonly screenHeight: number) { - const topY = this.screenHeight / 20; - this.container = new Container(); - this.gfx = new Graphics(); - this.text = new Text({ - text: '', - style: { - ...DefaultTextStyle, - fontSize: 48, - align: 'center', - }, - }); - this.text.position.set(this.screenWidth / 2, topY); - this.text.anchor.set(0.5, 0.5); - this.container.addChild(this.gfx, this.text); - } - - public update(dt: Ticker) { - const shouldInterrupt = this.currentToastIsInterruptable && this.toaster.length; + constructor( + private readonly screenWidth: number, + private readonly screenHeight: number, + ) { + const topY = this.screenHeight / 20; + this.container = new Container(); + this.gfx = new Graphics(); + this.text = new Text({ + text: "", + style: { + ...DefaultTextStyle, + fontSize: 48, + align: "center", + }, + }); + this.text.position.set(this.screenWidth / 2, topY); + this.text.anchor.set(0.5, 0.5); + this.container.addChild(this.gfx, this.text); + } - if (!this.text.text || shouldInterrupt) { - const newToast = this.toaster.pop(); - if (newToast) { - this.gfx.clear(); - this.text.text = newToast.text; - this.text.style.fill = newToast.color; - this.toastTime = newToast.timer; - this.currentToastIsInterruptable = newToast.interruptable; - this.gfx.position.set( - (this.screenWidth / 2) - (this.text.width /2) - 6, - (this.screenHeight / 20) - (this.text.height/2) - 4, - ) - applyGenericBoxStyle(this.gfx).roundRect(0, 0, this.text.width + 12, this.text.height + 8, 4).stroke().fill(); - } - } + public update(dt: Ticker) { + const shouldInterrupt = + this.currentToastIsInterruptable && this.toaster.length; - if (this.text.text) { - // Render toast - this.toastTime -= dt.deltaMS; - this.container.alpha = Math.min(1, this.toastTime/100); - if (this.toastTime <= 0) { - this.text.text = ''; - this.toastTime = 0; - this.gfx.clear(); - } - } + if (!this.text.text || shouldInterrupt) { + const newToast = this.toaster.pop(); + if (newToast) { + this.gfx.clear(); + this.text.text = newToast.text; + this.text.style.fill = newToast.color; + this.toastTime = newToast.timer; + this.currentToastIsInterruptable = newToast.interruptable; + this.gfx.position.set( + this.screenWidth / 2 - this.text.width / 2 - 6, + this.screenHeight / 20 - this.text.height / 2 - 4, + ); + applyGenericBoxStyle(this.gfx) + .roundRect(0, 0, this.text.width + 12, this.text.height + 8, 4) + .stroke() + .fill(); + } } - /** - * Adds some text to be displayed at the top of the screen. - * @param text The text notice. - * @param timer How long should the notice be displayed. - * @param color The colour of the text. - * @param interruptable Should the toast be interrupted by the next notice? - */ - public pushToast(text: string, timer = 5000, color: ColorSource = '#FFFFFF', interruptable = false) { - this.toaster.splice(0,0, { text, timer, color, interruptable }); + if (this.text.text) { + // Render toast + this.toastTime -= dt.deltaMS; + this.container.alpha = Math.min(1, this.toastTime / 100); + if (this.toastTime <= 0) { + this.text.text = ""; + this.toastTime = 0; + this.gfx.clear(); + } } + } + + /** + * Adds some text to be displayed at the top of the screen. + * @param text The text notice. + * @param timer How long should the notice be displayed. + * @param color The colour of the text. + * @param interruptable Should the toast be interrupted by the next notice? + */ + public pushToast( + text: string, + timer = 5000, + color: ColorSource = "#FFFFFF", + interruptable = false, + ) { + this.toaster.splice(0, 0, { text, timer, color, interruptable }); + } } diff --git a/src/overlays/windDial.ts b/src/overlays/windDial.ts index b08ebb6..2b80eeb 100644 --- a/src/overlays/windDial.ts +++ b/src/overlays/windDial.ts @@ -8,81 +8,84 @@ import { AssetTextures } from "../assets/manifest"; * Displays toast at the top of the screen during gameplay. */ export class WindDial { - public static loadAssets(textures: AssetTextures) { - this.texture = textures.windScroll; - } - private static texture: Texture; + public static loadAssets(textures: AssetTextures) { + this.texture = textures.windScroll; + } + private static texture: Texture; - private readonly gfx: Graphics; - public readonly container: Container; - private readonly windScroller: TilingSprite; - public currentWind: number|null = null; - private readonly windX: number; - private readonly windY: number; + private readonly gfx: Graphics; + public readonly container: Container; + private readonly windScroller: TilingSprite; + public currentWind: number | null = null; + private readonly windX: number; + private readonly windY: number; + constructor( + x: number, + y: number, + private gameState: GameState, + ) { + this.gfx = new Graphics({}); - constructor(x: number, y: number, private gameState: GameState) { - this.gfx = new Graphics({}); + this.windX = x + 14; + this.windY = y + 12; + this.windScroller = new TilingSprite({ + texture: WindDial.texture, + width: 0, + height: 16, + x: this.windX, + y: this.windY + 4, + tint: 0xffffff, + alpha: 1, + tileScale: { + x: 0.08, + y: 0.08, + }, + }); + this.container = new Container({}); + this.container.addChild(this.gfx, this.windScroller); + } - this.windX = x + 14; - this.windY = y + 12; - this.windScroller = new TilingSprite({ - texture: WindDial.texture, - width: 0, - height: 16, - x: this.windX, - y: this.windY + 4, - tint: 0xFFFFFF, - alpha: 1, - tileScale: { - x: 0.08, - y: 0.08, - }, - }); - this.container = new Container({}); - this.container.addChild(this.gfx, this.windScroller); + public update() { + if (this.currentWind !== null) { + this.windScroller.tilePosition.x += this.currentWind * 0.1; + const windAbsDelta = Math.abs( + this.gameState.currentWind - this.currentWind, + ); + if (windAbsDelta <= 0.1) { + return; + } + } else { + this.currentWind = 0; } + this.gfx.clear(); - public update() { - if (this.currentWind !== null) { - this.windScroller.tilePosition.x += (this.currentWind*0.1); - const windAbsDelta = Math.abs(this.gameState.currentWind - this.currentWind); - if (windAbsDelta <= 0.1) { - return; - } - } else { - this.currentWind = 0; - } - this.gfx.clear(); - - // Move progressively - if (this.gameState.currentWind > this.currentWind) { - this.currentWind += 0.1; - } else if (this.gameState.currentWind < this.currentWind) { - this.currentWind -= 0.1; - } + // Move progressively + if (this.gameState.currentWind > this.currentWind) { + this.currentWind += 0.1; + } else if (this.gameState.currentWind < this.currentWind) { + this.currentWind -= 0.1; + } - applyGenericBoxStyle(this.gfx).roundRect( - this.windX, - this.windY, - 200, - 25, - 4 - ).stroke().fill(); + applyGenericBoxStyle(this.gfx) + .roundRect(this.windX, this.windY, 200, 25, 4) + .stroke() + .fill(); - const windScale = this.currentWind / MAX_WIND; - const boxX = (windScale >= 0 ? (this.windX + 100) : ((this.windX + 100) + (100*windScale))) + 2; - applyGenericBoxStyle(this.gfx).roundRect( - boxX, - this.windY + 2, - 96 * Math.abs(windScale), - 21, - 4 - ).fill({ color: windScale > 0 ? 0xEE5555 : 0x5555EE }); - this.windScroller.x = boxX; - this.windScroller.tileRotation = windScale > 0 ? Math.PI : 0; - this.windScroller.tint = windScale > 0 ? 0xEE3333 : 0x3333EE; - this.windScroller.width = 96 * Math.abs(windScale); - applyGenericBoxStyle(this.gfx).moveTo(this.windX + 100, this.windY).lineTo(this.windX + 100, this.windY+25).stroke(); - } + const windScale = this.currentWind / MAX_WIND; + const boxX = + (windScale >= 0 ? this.windX + 100 : this.windX + 100 + 100 * windScale) + + 2; + applyGenericBoxStyle(this.gfx) + .roundRect(boxX, this.windY + 2, 96 * Math.abs(windScale), 21, 4) + .fill({ color: windScale > 0 ? 0xee5555 : 0x5555ee }); + this.windScroller.x = boxX; + this.windScroller.tileRotation = windScale > 0 ? Math.PI : 0; + this.windScroller.tint = windScale > 0 ? 0xee3333 : 0x3333ee; + this.windScroller.width = 96 * Math.abs(windScale); + applyGenericBoxStyle(this.gfx) + .moveTo(this.windX + 100, this.windY) + .lineTo(this.windX + 100, this.windY + 25) + .stroke(); + } } diff --git a/src/scenarios/boneIsles.ts b/src/scenarios/boneIsles.ts index 3888fa8..1ee40c5 100644 --- a/src/scenarios/boneIsles.ts +++ b/src/scenarios/boneIsles.ts @@ -1,9 +1,9 @@ -import { Assets, Text} from "pixi.js"; +import { Assets, Text } from "pixi.js"; import { Background } from "../entities/background"; import { BitmapTerrain } from "../entities/bitmapTerrain"; import type { Game } from "../game"; import { Water } from "../entities/water"; -import { Mine } from "../entities/phys/mine" +import { Mine } from "../entities/phys/mine"; import { Grenade } from "../entities/phys/grenade"; import { Coordinate, MetersValue } from "../utils/coodinate"; import staticController, { InputKind } from "../input"; @@ -16,81 +16,98 @@ import { DefaultTextStyle } from "../mixins/styles"; const weapons = ["grenade", "mine", "firework"]; export default async function runScenario(game: Game) { - const parent = game.viewport; - const world = game.world; - const { worldWidth, worldHeight } = game.viewport; + const parent = game.viewport; + const world = game.world; + const { worldWidth, worldHeight } = game.viewport; - const terrain = BitmapTerrain.create( - worldWidth, - worldHeight, - game.world, - Assets.get('boneIsles') - ); + const terrain = BitmapTerrain.create( + worldWidth, + worldHeight, + game.world, + Assets.get("boneIsles"), + ); - const gameState = new GameState([{ - name: "The Dummys", - group: TeamGroup.Blue, - worms: [{ - name: "Test Dolby", - maxHealth: 100, - health: 100, - }], - playerUserId: null, - }]); + const gameState = new GameState([ + { + name: "The Dummys", + group: TeamGroup.Blue, + worms: [ + { + name: "Test Dolby", + maxHealth: 100, + health: 100, + }, + ], + playerUserId: null, + }, + ]); - new GameStateOverlay(game.pixiApp.ticker, game.pixiApp.stage, gameState, world, game.viewport.screenWidth, game.viewport.screenHeight); + new GameStateOverlay( + game.pixiApp.ticker, + game.pixiApp.stage, + gameState, + world, + game.viewport.screenWidth, + game.viewport.screenHeight, + ); - const bg = await world.addEntity(Background.create(game.viewport.screenWidth, game.viewport.screenHeight, game.viewport, [20, 21, 50, 35], terrain)); - await world.addEntity(terrain); - bg.addToWorld(game.pixiApp.stage, parent); - terrain.addToWorld(parent); + const bg = await world.addEntity( + Background.create( + game.viewport.screenWidth, + game.viewport.screenHeight, + game.viewport, + [20, 21, 50, 35], + terrain, + ), + ); + await world.addEntity(terrain); + bg.addToWorld(game.pixiApp.stage, parent); + terrain.addToWorld(parent); - const water = world.addEntity( - new Water( - MetersValue.fromPixels(worldWidth*4), - MetersValue.fromPixels(worldHeight), - world) - ); - water.addToWorld(game.viewport, world); + const water = world.addEntity( + new Water( + MetersValue.fromPixels(worldWidth * 4), + MetersValue.fromPixels(worldHeight), + world, + ), + ); + water.addToWorld(game.viewport, world); - world.addEntity(Mine.create(parent, world, Coordinate.fromScreen(900,200))); + world.addEntity(Mine.create(parent, world, Coordinate.fromScreen(900, 200))); - let selectedWeaponIndex = 2; - const weaponText = new Text({ - text: `Selected Weapon (press S to switch): ${weapons[selectedWeaponIndex]}`, - style: DefaultTextStyle, - }); - weaponText.position.set(20, 50); - - staticController.on('inputEnd', (kind: InputKind) => { - if (kind !== InputKind.DebugSwitchWeapon) { - return; - } - selectedWeaponIndex++; - if (selectedWeaponIndex === weapons.length) { - selectedWeaponIndex = 0; - } - weaponText.text = `Selected Weapon (press S to switch): ${weapons[selectedWeaponIndex]}`; - }); + let selectedWeaponIndex = 2; + const weaponText = new Text({ + text: `Selected Weapon (press S to switch): ${weapons[selectedWeaponIndex]}`, + style: DefaultTextStyle, + }); + weaponText.position.set(20, 50); + staticController.on("inputEnd", (kind: InputKind) => { + if (kind !== InputKind.DebugSwitchWeapon) { + return; + } + selectedWeaponIndex++; + if (selectedWeaponIndex === weapons.length) { + selectedWeaponIndex = 0; + } + weaponText.text = `Selected Weapon (press S to switch): ${weapons[selectedWeaponIndex]}`; + }); + game.pixiApp.stage.addChild(weaponText); - game.pixiApp.stage.addChild(weaponText); - - game.viewport.on('clicked', async (evt) => { - const position = Coordinate.fromScreen(evt.world.x, evt.world.y); - let entity; - const wep = weapons[selectedWeaponIndex]; - if (wep === "grenade") { - entity = Grenade.create(parent, world, position, {x: 0, y:0}); - } else if (wep === "mine") { - entity = Mine.create(parent, world, position) - } else if (wep === "firework") { - entity = Firework.create(parent, world, position); - } else { - throw new Error('unknown weapon'); - } - world.addEntity(entity); - }); - -} \ No newline at end of file + game.viewport.on("clicked", async (evt) => { + const position = Coordinate.fromScreen(evt.world.x, evt.world.y); + let entity; + const wep = weapons[selectedWeaponIndex]; + if (wep === "grenade") { + entity = Grenade.create(parent, world, position, { x: 0, y: 0 }); + } else if (wep === "mine") { + entity = Mine.create(parent, world, position); + } else if (wep === "firework") { + entity = Firework.create(parent, world, position); + } else { + throw new Error("unknown weapon"); + } + world.addEntity(entity); + }); +} diff --git a/src/scenarios/borealisTribute.ts b/src/scenarios/borealisTribute.ts index e32a32b..937ff6b 100644 --- a/src/scenarios/borealisTribute.ts +++ b/src/scenarios/borealisTribute.ts @@ -7,34 +7,42 @@ import { Coordinate, MetersValue } from "../utils"; import { Mine } from "../entities/phys/mine"; export default async function runScenario(game: Game) { - const parent = game.viewport; - const world = game.world; - const { worldWidth, worldHeight } = game.viewport; + const parent = game.viewport; + const world = game.world; + const { worldWidth, worldHeight } = game.viewport; - const terrain = BitmapTerrain.create( - worldWidth, - worldHeight, - world, - Assets.get('island1') - ); + const terrain = BitmapTerrain.create( + worldWidth, + worldHeight, + world, + Assets.get("island1"), + ); - const bg = await world.addEntity(Background.create(game.viewport.screenWidth, game.viewport.screenHeight, game.viewport, [20, 21, 50, 35], terrain)); - await world.addEntity(terrain); - bg.addToWorld(game.pixiApp.stage, parent); - terrain.addToWorld(parent); + const bg = await world.addEntity( + Background.create( + game.viewport.screenWidth, + game.viewport.screenHeight, + game.viewport, + [20, 21, 50, 35], + terrain, + ), + ); + await world.addEntity(terrain); + bg.addToWorld(game.pixiApp.stage, parent); + terrain.addToWorld(parent); - const water = world.addEntity( - new Water( - MetersValue.fromPixels(worldWidth*4), - MetersValue.fromPixels(worldHeight), - world) - ); - world.addEntity(water); + const water = world.addEntity( + new Water( + MetersValue.fromPixels(worldWidth * 4), + MetersValue.fromPixels(worldHeight), + world, + ), + ); + world.addEntity(water); - game.viewport.on('clicked', async (evt) => { - const position = Coordinate.fromScreen(evt.world.x, evt.world.y); - const entity = await Mine.create(parent, world, position); - world.addEntity(entity); - }); - -} \ No newline at end of file + game.viewport.on("clicked", async (evt) => { + const position = Coordinate.fromScreen(evt.world.x, evt.world.y); + const entity = await Mine.create(parent, world, position); + world.addEntity(entity); + }); +} diff --git a/src/scenarios/grenadeIsland.ts b/src/scenarios/grenadeIsland.ts index 28ed0c7..9993c70 100644 --- a/src/scenarios/grenadeIsland.ts +++ b/src/scenarios/grenadeIsland.ts @@ -1,9 +1,9 @@ -import { Assets, Text} from "pixi.js"; +import { Assets, Text } from "pixi.js"; import { Background } from "../entities/background"; import { BitmapTerrain } from "../entities/bitmapTerrain"; import type { Game } from "../game"; import { Water } from "../entities/water"; -import { Mine } from "../entities/phys/mine" +import { Mine } from "../entities/phys/mine"; import { Grenade } from "../entities/phys/grenade"; import { Coordinate, MetersValue } from "../utils/coodinate"; import { TestDummy } from "../entities/playable/testDummy"; @@ -18,113 +18,166 @@ import { DefaultTextStyle } from "../mixins/styles"; const weapons = ["grenade", "mine", "firework"]; export default async function runScenario(game: Game) { - const parent = game.viewport; - const world = game.world; - const { worldWidth, worldHeight } = game.viewport; + const parent = game.viewport; + const world = game.world; + const { worldWidth, worldHeight } = game.viewport; - const terrain = BitmapTerrain.create( - worldWidth, - worldHeight, - game.world, - Assets.get('terrain2') - ); + const terrain = BitmapTerrain.create( + worldWidth, + worldHeight, + game.world, + Assets.get("terrain2"), + ); - const gameState = new GameState([{ - name: "The Dummys", - group: TeamGroup.Blue, - worms: [{ - name: "Test Dolby", - maxHealth: 100, - health: 100, - },{ - name: "Yeen #2", - maxHealth: 100, - health: 100, - },{ - name: "Accident prone", - maxHealth: 100, - health: 100, - }], - playerUserId: null, - },{ - name: "The Invisible Duo", - group: TeamGroup.Red, - worms: [{ - name: "Egg face", - maxHealth: 100, - health: 100, - },{ - name: "Cream Guy", - maxHealth: 100, - health: 100, - }], - playerUserId: null, - }]); + const gameState = new GameState([ + { + name: "The Dummys", + group: TeamGroup.Blue, + worms: [ + { + name: "Test Dolby", + maxHealth: 100, + health: 100, + }, + { + name: "Yeen #2", + maxHealth: 100, + health: 100, + }, + { + name: "Accident prone", + maxHealth: 100, + health: 100, + }, + ], + playerUserId: null, + }, + { + name: "The Invisible Duo", + group: TeamGroup.Red, + worms: [ + { + name: "Egg face", + maxHealth: 100, + health: 100, + }, + { + name: "Cream Guy", + maxHealth: 100, + health: 100, + }, + ], + playerUserId: null, + }, + ]); - new GameStateOverlay(game.pixiApp.ticker, game.pixiApp.stage, gameState, world, game.viewport.screenWidth, game.viewport.screenHeight); + new GameStateOverlay( + game.pixiApp.ticker, + game.pixiApp.stage, + gameState, + world, + game.viewport.screenWidth, + game.viewport.screenHeight, + ); - const bg = await world.addEntity(Background.create(game.viewport.screenWidth, game.viewport.screenHeight, game.viewport, [20, 21, 50, 35], terrain)); - await world.addEntity(terrain); - bg.addToWorld(game.pixiApp.stage, parent); - terrain.addToWorld(parent); + const bg = await world.addEntity( + Background.create( + game.viewport.screenWidth, + game.viewport.screenHeight, + game.viewport, + [20, 21, 50, 35], + terrain, + ), + ); + await world.addEntity(terrain); + bg.addToWorld(game.pixiApp.stage, parent); + terrain.addToWorld(parent); - const water = world.addEntity( - new Water( - MetersValue.fromPixels(worldWidth*4), - MetersValue.fromPixels(worldHeight), - world) - ); - water.addToWorld(game.viewport, world); - // const worm = world.addEntity(await Worm.create(parent, world, Coordinate.fromScreen(500,400), async (worm, definition, duration) => { - // const newProjectile = await definition.fireFn(parent, world, worm, duration); - // world.addEntity(newProjectile); - // })); + const water = world.addEntity( + new Water( + MetersValue.fromPixels(worldWidth * 4), + MetersValue.fromPixels(worldHeight), + world, + ), + ); + water.addToWorld(game.viewport, world); + // const worm = world.addEntity(await Worm.create(parent, world, Coordinate.fromScreen(500,400), async (worm, definition, duration) => { + // const newProjectile = await definition.fireFn(parent, world, worm, duration); + // world.addEntity(newProjectile); + // })); - const dummy = world.addEntity(TestDummy.create(parent, world, Coordinate.fromScreen(650,620), gameState.getTeamByIndex(0).worms[0])); - world.addEntity(TestDummy.create(parent, world, Coordinate.fromScreen(1500,300), gameState.getTeamByIndex(0).worms[1])); - world.addEntity(TestDummy.create(parent, world, Coordinate.fromScreen(1012,678), gameState.getTeamByIndex(0).worms[2])); - world.addEntity(Worm.create(parent, world, Coordinate.fromScreen(600,550), gameState.getTeamByIndex(1).worms[0], async () => { + const dummy = world.addEntity( + TestDummy.create( + parent, + world, + Coordinate.fromScreen(650, 620), + gameState.getTeamByIndex(0).worms[0], + ), + ); + world.addEntity( + TestDummy.create( + parent, + world, + Coordinate.fromScreen(1500, 300), + gameState.getTeamByIndex(0).worms[1], + ), + ); + world.addEntity( + TestDummy.create( + parent, + world, + Coordinate.fromScreen(1012, 678), + gameState.getTeamByIndex(0).worms[2], + ), + ); + world.addEntity( + Worm.create( + parent, + world, + Coordinate.fromScreen(600, 550), + gameState.getTeamByIndex(1).worms[0], + async () => { return []; - })); - game.viewport.follow(dummy.sprite); + }, + ), + ); + game.viewport.follow(dummy.sprite); - world.addEntity(Mine.create(parent, world, Coordinate.fromScreen(900,200))); + world.addEntity(Mine.create(parent, world, Coordinate.fromScreen(900, 200))); - let selectedWeaponIndex = 0; - const weaponText = new Text({ - text: `Selected Weapon (press S to switch): ${weapons[selectedWeaponIndex]}`, - style: DefaultTextStyle, - }); - weaponText.position.set(20, 50); - - staticController.on('inputEnd', (kind: InputKind) => { - if (kind !== InputKind.DebugSwitchWeapon) { - return; - } - selectedWeaponIndex++; - if (selectedWeaponIndex === weapons.length) { - selectedWeaponIndex = 0; - } - weaponText.text = `Selected Weapon (press S to switch): ${weapons[selectedWeaponIndex]}`; - }); + let selectedWeaponIndex = 0; + const weaponText = new Text({ + text: `Selected Weapon (press S to switch): ${weapons[selectedWeaponIndex]}`, + style: DefaultTextStyle, + }); + weaponText.position.set(20, 50); + staticController.on("inputEnd", (kind: InputKind) => { + if (kind !== InputKind.DebugSwitchWeapon) { + return; + } + selectedWeaponIndex++; + if (selectedWeaponIndex === weapons.length) { + selectedWeaponIndex = 0; + } + weaponText.text = `Selected Weapon (press S to switch): ${weapons[selectedWeaponIndex]}`; + }); - game.pixiApp.stage.addChild(weaponText); + game.pixiApp.stage.addChild(weaponText); - game.viewport.on('clicked', async (evt) => { - const position = Coordinate.fromScreen(evt.world.x, evt.world.y); - let entity; - const wep = weapons[selectedWeaponIndex]; - if (wep === "grenade") { - entity = Grenade.create(parent, world, position, {x: 0, y:0}); - } else if (wep === "mine") { - entity = Mine.create(parent, world, position) - } else if (wep === "firework") { - entity = Firework.create(parent, world, position); - } else { - throw new Error('unknown weapon'); - } - world.addEntity(entity); - }); - -} \ No newline at end of file + game.viewport.on("clicked", async (evt) => { + const position = Coordinate.fromScreen(evt.world.x, evt.world.y); + let entity; + const wep = weapons[selectedWeaponIndex]; + if (wep === "grenade") { + entity = Grenade.create(parent, world, position, { x: 0, y: 0 }); + } else if (wep === "mine") { + entity = Mine.create(parent, world, position); + } else if (wep === "firework") { + entity = Firework.create(parent, world, position); + } else { + throw new Error("unknown weapon"); + } + world.addEntity(entity); + }); +} diff --git a/src/scenarios/netGame.ts b/src/scenarios/netGame.ts index b09f550..fe8b416 100644 --- a/src/scenarios/netGame.ts +++ b/src/scenarios/netGame.ts @@ -8,7 +8,11 @@ import { Coordinate, MetersValue } from "../utils/coodinate"; import { GameState } from "../logic/gamestate"; import { TeamGroup } from "../logic/teams"; import { GameStateOverlay } from "../overlays/gameStateOverlay"; -import { GameDrawText, TeamWinnerText, templateRandomText } from "../text/toasts"; +import { + GameDrawText, + TeamWinnerText, + templateRandomText, +} from "../text/toasts"; import { PhysicsEntity } from "../entities/phys/physicsEntity"; import { DefaultTextStyle } from "../mixins/styles"; import { WeaponBazooka, WeaponGrenade, WeaponShotgun } from "../weapons"; @@ -19,166 +23,204 @@ import { StateRecorder } from "../state/recorder"; const weapons = [WeaponBazooka, WeaponGrenade, WeaponShotgun]; export default async function runScenario(game: Game) { - const parent = game.viewport; - const world = game.world; - const { worldWidth, worldHeight } = game.viewport; - - const terrain = BitmapTerrain.create( - worldWidth, - worldHeight, - game.world, - Assets.get('testingGround') - ); - - const gameState = new GameState([{ + const parent = game.viewport; + const world = game.world; + const { worldWidth, worldHeight } = game.viewport; + + const terrain = BitmapTerrain.create( + worldWidth, + worldHeight, + game.world, + Assets.get("testingGround"), + ); + + const gameState = new GameState( + [ + { name: "The Prawns", group: TeamGroup.Red, - worms: [{ + worms: [ + { name: "Shrimp", maxHealth: 100, health: 100, - }], + }, + ], playerUserId: null, - },{ + }, + { name: "The Whales", group: TeamGroup.Blue, - worms: [{ + worms: [ + { name: "Welsh boy", maxHealth: 100, health: 100, - }], + }, + ], playerUserId: null, - }], { - winWhenOneGroupRemains: true, - }); - - const stateRecorder = new StateRecorder(world, gameState, { - async writeLine(data) { - if (game.netGameInstance) { - game.netGameInstance.sendGameState(data); + }, + ], + { + winWhenOneGroupRemains: true, + }, + ); + + const stateRecorder = new StateRecorder(world, gameState, { + async writeLine(_data) { + if (game.netGameInstance) { + throw Error("Unimpleted, fix data structure"); + //game.netGameInstance.sendGameState(data); + } + }, + }); + + const bg = await world.addEntity( + Background.create( + game.viewport.screenWidth, + game.viewport.screenHeight, + game.viewport, + [20, 21, 50, 35], + terrain, + ), + ); + bg.addToWorld(game.pixiApp.stage, parent); + await world.addEntity(terrain); + bg.addToWorld(game.pixiApp.stage, parent); + terrain.addToWorld(parent); + + const overlay = new GameStateOverlay( + game.pixiApp.ticker, + game.pixiApp.stage, + gameState, + world, + game.viewport.screenWidth, + game.viewport.screenHeight, + ); + + const water = world.addEntity( + new Water( + MetersValue.fromPixels(worldWidth * 4), + MetersValue.fromPixels(worldHeight), + world, + ), + ); + water.addToWorld(parent, world); + + const wormInstances = new Map(); + + let i = 300; + for (const team of gameState.getActiveTeams()) { + for (const wormInstance of team.worms) { + i += 200; + const wormEnt = world.addEntity( + await Worm.create( + parent, + world, + Coordinate.fromScreen(400 + i, 105), + wormInstance, + async (worm, definition, opts) => { + const newProjectile = definition.fireFn(parent, world, worm, opts); + if (newProjectile instanceof PhysicsEntity) { + parent.follow(newProjectile.sprite); + world.addEntity(newProjectile); } - }, - }); - - const bg = await world.addEntity(Background.create(game.viewport.screenWidth, game.viewport.screenHeight, game.viewport, [20, 21, 50, 35], terrain)); - bg.addToWorld(game.pixiApp.stage, parent); - await world.addEntity(terrain); - bg.addToWorld(game.pixiApp.stage, parent); - terrain.addToWorld(parent); - - const overlay = new GameStateOverlay(game.pixiApp.ticker, game.pixiApp.stage, gameState, world, game.viewport.screenWidth, game.viewport.screenHeight); - - const water = world.addEntity( - new Water( - MetersValue.fromPixels(worldWidth*4), - MetersValue.fromPixels(worldHeight), - world) - ); water.addToWorld(parent, world); - - const wormInstances = new Map(); - - let i = 300; - for (const team of gameState.getActiveTeams()) { - for (const wormInstance of team.worms) { - i += 200; - const wormEnt = world.addEntity( - await Worm.create(parent, world, Coordinate.fromScreen(400 + i,105), wormInstance, async (worm, definition, opts) => { - const newProjectile = definition.fireFn(parent, world, worm, opts); - if (newProjectile instanceof PhysicsEntity) { - parent.follow(newProjectile.sprite); - world.addEntity(newProjectile); - } - stateRecorder.syncEntityState(); - const res = await newProjectile.onFireResult; - if (newProjectile instanceof PhysicsEntity) { - parent.follow(worm.sprite); - } - return res; - }, overlay.toaster, stateRecorder)); - wormInstances.set(wormInstance.uuid, wormEnt); - } - } - - let endOfRoundWaitDuration: number|null = null; - let endOfGameFadeOut: number|null = null; - let currentWorm: Worm|undefined; - - let selectedWeaponIndex = 0; - const weaponText = new Text({ - text: `Selected Weapon (press S to switch): no-worm-selected`, - style: DefaultTextStyle, - }); - weaponText.position.set(20, 50); - - - staticController.on('inputEnd', (kind: InputKind) => { - if (kind !== InputKind.DebugSwitchWeapon) { - return; - } - selectedWeaponIndex++; - if (selectedWeaponIndex === weapons.length) { - selectedWeaponIndex = 0; - } - - if (!currentWorm) { - return; - } - currentWorm.selectWeapon(weapons[selectedWeaponIndex]); - weaponText.text = `Selected Weapon (press S to switch): ${IWeaponCode[currentWorm.weapon.code]}`; - }); - - - const roundHandlerFn = (dt: Ticker) => { - if (endOfGameFadeOut !== null) { - endOfGameFadeOut -= dt.deltaMS; - if (endOfGameFadeOut < 0) { - game.pixiApp.ticker.remove(roundHandlerFn); - game.goToMenu(gameState.getActiveTeams()); - } - return; - } - if (currentWorm && currentWorm.currentState !== WormState.Inactive) { - return; - } - if (endOfRoundWaitDuration === null) { stateRecorder.syncEntityState(); - const nextState = gameState.advanceRound(); - stateRecorder.recordGameStare(); - console.log('advancing round', nextState); - if ('winningTeams' in nextState) { - if (nextState.winningTeams.length) { - overlay.toaster.pushToast(templateRandomText(TeamWinnerText, { - TeamName: nextState.winningTeams.map(t => t.name).join(', '), - }), 8000); - } else { - // Draw - overlay.toaster.pushToast(templateRandomText(GameDrawText), 8000); - } - endOfGameFadeOut = 8000; - } else { - currentWorm?.onEndOfTurn(); - currentWorm = wormInstances.get(nextState.nextWorm.uuid); - // Turn just ended. - endOfRoundWaitDuration = 5000; + const res = await newProjectile.onFireResult; + if (newProjectile instanceof PhysicsEntity) { + parent.follow(worm.sprite); } - return; - } - if (endOfRoundWaitDuration <= 0) { - if (!currentWorm) { - throw Error('Expected next worm'); - } - world.setWind(gameState.currentWind); - currentWorm.onWormSelected(); - weaponText.text = `Selected Weapon (press S to switch): ${IWeaponCode[currentWorm.weapon.code]}`; - game.viewport.follow(currentWorm.sprite); - endOfRoundWaitDuration = null; - return; + return res; + }, + overlay.toaster, + stateRecorder, + ), + ); + wormInstances.set(wormInstance.uuid, wormEnt); + } + } + + let endOfRoundWaitDuration: number | null = null; + let endOfGameFadeOut: number | null = null; + let currentWorm: Worm | undefined; + + let selectedWeaponIndex = 0; + const weaponText = new Text({ + text: `Selected Weapon (press S to switch): no-worm-selected`, + style: DefaultTextStyle, + }); + weaponText.position.set(20, 50); + + staticController.on("inputEnd", (kind: InputKind) => { + if (kind !== InputKind.DebugSwitchWeapon) { + return; + } + selectedWeaponIndex++; + if (selectedWeaponIndex === weapons.length) { + selectedWeaponIndex = 0; + } + + if (!currentWorm) { + return; + } + currentWorm.selectWeapon(weapons[selectedWeaponIndex]); + weaponText.text = `Selected Weapon (press S to switch): ${IWeaponCode[currentWorm.weapon.code]}`; + }); + + const roundHandlerFn = (dt: Ticker) => { + if (endOfGameFadeOut !== null) { + endOfGameFadeOut -= dt.deltaMS; + if (endOfGameFadeOut < 0) { + game.pixiApp.ticker.remove(roundHandlerFn); + game.goToMenu(gameState.getActiveTeams()); + } + return; + } + if (currentWorm && currentWorm.currentState !== WormState.Inactive) { + return; + } + if (endOfRoundWaitDuration === null) { + stateRecorder.syncEntityState(); + const nextState = gameState.advanceRound(); + stateRecorder.recordGameStare(); + console.log("advancing round", nextState); + if ("winningTeams" in nextState) { + if (nextState.winningTeams.length) { + overlay.toaster.pushToast( + templateRandomText(TeamWinnerText, { + TeamName: nextState.winningTeams.map((t) => t.name).join(", "), + }), + 8000, + ); + } else { + // Draw + overlay.toaster.pushToast(templateRandomText(GameDrawText), 8000); } - endOfRoundWaitDuration -= dt.deltaMS; - }; - - game.pixiApp.ticker.add(roundHandlerFn); - game.pixiApp.stage.addChild(weaponText); - stateRecorder.recordGameStare(); - stateRecorder.syncEntityState(); -} \ No newline at end of file + endOfGameFadeOut = 8000; + } else { + currentWorm?.onEndOfTurn(); + currentWorm = wormInstances.get(nextState.nextWorm.uuid); + // Turn just ended. + endOfRoundWaitDuration = 5000; + } + return; + } + if (endOfRoundWaitDuration <= 0) { + if (!currentWorm) { + throw Error("Expected next worm"); + } + world.setWind(gameState.currentWind); + currentWorm.onWormSelected(); + weaponText.text = `Selected Weapon (press S to switch): ${IWeaponCode[currentWorm.weapon.code]}`; + game.viewport.follow(currentWorm.sprite); + endOfRoundWaitDuration = null; + return; + } + endOfRoundWaitDuration -= dt.deltaMS; + }; + + game.pixiApp.ticker.add(roundHandlerFn); + game.pixiApp.stage.addChild(weaponText); + stateRecorder.recordGameStare(); + stateRecorder.syncEntityState(); +} diff --git a/src/scenarios/replayTesting.ts b/src/scenarios/replayTesting.ts index 996d553..3326413 100644 --- a/src/scenarios/replayTesting.ts +++ b/src/scenarios/replayTesting.ts @@ -7,196 +7,235 @@ import { Worm, WormState } from "../entities/playable/worm"; import { Coordinate, MetersValue } from "../utils/coodinate"; import { GameState } from "../logic/gamestate"; import { GameStateOverlay } from "../overlays/gameStateOverlay"; -import { GameDrawText, TeamWinnerText, templateRandomText } from "../text/toasts"; +import { + GameDrawText, + TeamWinnerText, + templateRandomText, +} from "../text/toasts"; import { PhysicsEntity } from "../entities/phys/physicsEntity"; import { TextStateReplay } from "../state/player"; import { RemoteWorm } from "../entities/playable/remoteWorm"; import { EntityType } from "../entities/type"; import { getDefinitionForCode } from "../weapons"; -import { RecordedEntityState } from "../state/model"; export default async function runScenario(game: Game) { - const parent = game.viewport; - const world = game.world; - const { worldWidth, worldHeight } = game.viewport; - - const terrain = BitmapTerrain.create( - worldWidth, - worldHeight, - game.world, - Assets.get('testingGround') - ); - - const player = new TextStateReplay(replayData); - player.on('started', () => { - console.log('started playback'); - }); - - const dataPromise = player.waitForFullGameState(); - player.play(); - - const initialData = await dataPromise; - - - const gameState = new GameState(initialData.gameState.teams, { - // TODO: Rules. - winWhenOneGroupRemains: true, - }); - - - // TODO: Background - const bg = await world.addEntity(Background.create(game.viewport.screenWidth, game.viewport.screenHeight, game.viewport, [20, 21, 50, 35], terrain)); - bg.addToWorld(game.pixiApp.stage, parent); - await world.addEntity(terrain); - bg.addToWorld(game.pixiApp.stage, parent); - terrain.addToWorld(parent); - - const overlay = new GameStateOverlay(game.pixiApp.ticker, game.pixiApp.stage, gameState, world, game.viewport.screenWidth, game.viewport.screenHeight); - - const water = world.addEntity( - new Water( - MetersValue.fromPixels(worldWidth*4), - MetersValue.fromPixels(worldHeight), - world) - ); water.addToWorld(parent, world); - - const wormInstances = new Map(); - - player.on('wormAction', (wormAction) => { - const wormInst = wormInstances.get(wormAction.id); - if (!wormInst) { - throw Error('Worm not found'); - } - wormInst.replayWormAction(wormAction.action); - }); - - player.on('wormSelectWeapon', (wormWeapon) => { - const wormInst = wormInstances.get(wormWeapon.id); - if (!wormInst) { - throw Error('Worm not found'); - } - wormInst.selectWeapon(getDefinitionForCode(wormWeapon.weapon)); - }); - - player.on('wormActionAim', ({ id, dir, angle}) => { - const wormInst = wormInstances.get(id); - if (!wormInst) { - throw Error('Worm not found'); - } - wormInst.replayAim(dir, parseFloat(angle)); - }); - - player.on('wormActionMove', ({ id, action, cycles}) => { - const wormInst = wormInstances.get(id); - if (!wormInst) { - throw Error('Worm not found'); - } - wormInst.replayMovement(action, cycles); - }); - - player.on('wormActionFire', ({ id, duration}) => { - const wormInst = wormInstances.get(id); - if (!wormInst) { - throw Error('Worm not found'); - } - wormInst.replayFire(duration); - }); - - const worms = initialData.entitySync.filter(v => v.type === EntityType.Worm).reverse(); - - for (const team of gameState.getActiveTeams()) { - for (const wormInstance of team.worms) { - const existingEntData = worms.pop()!; - console.log('existing worm data', existingEntData); - const wormEnt = world.addEntity( - await RemoteWorm.create(parent, world, new Coordinate( - parseFloat(existingEntData.tra.x), - parseFloat(existingEntData.tra.y), - ), wormInstance, async (worm, definition, opts) => { - const newProjectile = definition.fireFn(parent, world, worm, opts); - if (newProjectile instanceof PhysicsEntity) { - parent.follow(newProjectile.sprite); - world.addEntity(newProjectile); - } - applyEntityData(); - const res = await newProjectile.onFireResult; - if (newProjectile instanceof PhysicsEntity) { - parent.follow(worm.sprite); - } - applyEntityData(); - return res; - }, overlay.toaster), existingEntData.uuid); - wormInstances.set(wormInstance.uuid, wormEnt); - } + const parent = game.viewport; + const world = game.world; + const { worldWidth, worldHeight } = game.viewport; + + const terrain = BitmapTerrain.create( + worldWidth, + worldHeight, + game.world, + Assets.get("testingGround"), + ); + + const player = new TextStateReplay(replayData); + player.on("started", () => { + console.log("started playback"); + }); + + const dataPromise = player.waitForFullGameState(); + player.play(); + + const initialData = await dataPromise; + + const gameState = new GameState(initialData.gameState.teams, { + // TODO: Rules. + winWhenOneGroupRemains: true, + }); + + // TODO: Background + const bg = await world.addEntity( + Background.create( + game.viewport.screenWidth, + game.viewport.screenHeight, + game.viewport, + [20, 21, 50, 35], + terrain, + ), + ); + bg.addToWorld(game.pixiApp.stage, parent); + await world.addEntity(terrain); + bg.addToWorld(game.pixiApp.stage, parent); + terrain.addToWorld(parent); + + const overlay = new GameStateOverlay( + game.pixiApp.ticker, + game.pixiApp.stage, + gameState, + world, + game.viewport.screenWidth, + game.viewport.screenHeight, + ); + + const water = world.addEntity( + new Water( + MetersValue.fromPixels(worldWidth * 4), + MetersValue.fromPixels(worldHeight), + world, + ), + ); + water.addToWorld(parent, world); + + const wormInstances = new Map(); + + player.on("wormAction", (wormAction) => { + const wormInst = wormInstances.get(wormAction.id); + if (!wormInst) { + throw Error("Worm not found"); } + wormInst.replayWormAction(wormAction.action); + }); - let endOfRoundWaitDuration: number|null = null; - let endOfGameFadeOut: number|null = null; - let currentWorm: Worm|undefined; - - function applyEntityData() { - console.log('Applying entity state data'); - for (const ent of player.latestEntityData) { - const existingEnt = world.entities.get(ent.uuid); - if (!existingEnt) { - throw new Error(`Ent ${ent.uuid} ${EntityType[ent.type]} was not found during entity sync`); - } else if (existingEnt instanceof PhysicsEntity === false) { - throw new Error(`Ent ${ent.uuid} ${EntityType[ent.type]} was unexpectedly not a PhysicsEntity`); - } - existingEnt.loadState(ent); - } + player.on("wormSelectWeapon", (wormWeapon) => { + const wormInst = wormInstances.get(wormWeapon.id); + if (!wormInst) { + throw Error("Worm not found"); } + wormInst.selectWeapon(getDefinitionForCode(wormWeapon.weapon)); + }); - player.on('gameState', (dataUpdate) => { - const nextState = gameState.applyGameStateUpdate(dataUpdate); - applyEntityData(); - console.log('advancing round', nextState); - if ('winningTeams' in nextState) { - if (nextState.winningTeams.length) { - overlay.toaster.pushToast(templateRandomText(TeamWinnerText, { - TeamName: nextState.winningTeams.map(t => t.name).join(', '), - }), 8000); - } else { - // Draw - overlay.toaster.pushToast(templateRandomText(GameDrawText), 8000); - } - endOfGameFadeOut = 8000; - } else { - currentWorm?.onEndOfTurn(); - currentWorm = wormInstances.get(nextState.nextWorm.uuid); - // Turn just ended. - endOfRoundWaitDuration = 5000; - } - }); - - const roundHandlerFn = (dt: Ticker) => { - if (endOfGameFadeOut !== null) { - endOfGameFadeOut -= dt.deltaMS; - if (endOfGameFadeOut < 0) { - game.pixiApp.ticker.remove(roundHandlerFn); - game.goToMenu(gameState.getActiveTeams()); + player.on("wormActionAim", ({ id, dir, angle }) => { + const wormInst = wormInstances.get(id); + if (!wormInst) { + throw Error("Worm not found"); + } + wormInst.replayAim(dir, parseFloat(angle)); + }); + + player.on("wormActionMove", ({ id, action, cycles }) => { + const wormInst = wormInstances.get(id); + if (!wormInst) { + throw Error("Worm not found"); + } + wormInst.replayMovement(action, cycles); + }); + + player.on("wormActionFire", ({ id, duration }) => { + const wormInst = wormInstances.get(id); + if (!wormInst) { + throw Error("Worm not found"); + } + wormInst.replayFire(duration); + }); + + const worms = initialData.entitySync + .filter((v) => v.type === EntityType.Worm) + .reverse(); + + for (const team of gameState.getActiveTeams()) { + for (const wormInstance of team.worms) { + const existingEntData = worms.pop()!; + console.log("existing worm data", existingEntData); + const wormEnt = world.addEntity( + await RemoteWorm.create( + parent, + world, + new Coordinate( + parseFloat(existingEntData.tra.x), + parseFloat(existingEntData.tra.y), + ), + wormInstance, + async (worm, definition, opts) => { + const newProjectile = definition.fireFn(parent, world, worm, opts); + if (newProjectile instanceof PhysicsEntity) { + parent.follow(newProjectile.sprite); + world.addEntity(newProjectile); } - return; - } - if (currentWorm && currentWorm.currentState !== WormState.Inactive) { - return; - } - if (endOfRoundWaitDuration === null) { - return; - } - if (endOfRoundWaitDuration <= 0) { - if (!currentWorm) { - throw Error('Expected next worm'); + applyEntityData(); + const res = await newProjectile.onFireResult; + if (newProjectile instanceof PhysicsEntity) { + parent.follow(worm.sprite); } - world.setWind(gameState.currentWind); - currentWorm.onWormSelected(); - game.viewport.follow(currentWorm.sprite); - endOfRoundWaitDuration = null; - return; - } - endOfRoundWaitDuration -= dt.deltaMS; - }; - game.pixiApp.ticker.add(roundHandlerFn); + applyEntityData(); + return res; + }, + overlay.toaster, + ), + existingEntData.uuid, + ); + wormInstances.set(wormInstance.uuid, wormEnt); + } + } + + let endOfRoundWaitDuration: number | null = null; + let endOfGameFadeOut: number | null = null; + let currentWorm: Worm | undefined; + + function applyEntityData() { + console.log("Applying entity state data"); + for (const ent of player.latestEntityData) { + const existingEnt = world.entities.get(ent.uuid); + if (!existingEnt) { + throw new Error( + `Ent ${ent.uuid} ${EntityType[ent.type]} was not found during entity sync`, + ); + } else if (existingEnt instanceof PhysicsEntity === false) { + throw new Error( + `Ent ${ent.uuid} ${EntityType[ent.type]} was unexpectedly not a PhysicsEntity`, + ); + } + existingEnt.loadState(ent); + } + } + + player.on("gameState", (dataUpdate) => { + const nextState = gameState.applyGameStateUpdate(dataUpdate); + applyEntityData(); + console.log("advancing round", nextState); + if ("winningTeams" in nextState) { + if (nextState.winningTeams.length) { + overlay.toaster.pushToast( + templateRandomText(TeamWinnerText, { + TeamName: nextState.winningTeams.map((t) => t.name).join(", "), + }), + 8000, + ); + } else { + // Draw + overlay.toaster.pushToast(templateRandomText(GameDrawText), 8000); + } + endOfGameFadeOut = 8000; + } else { + currentWorm?.onEndOfTurn(); + currentWorm = wormInstances.get(nextState.nextWorm.uuid); + // Turn just ended. + endOfRoundWaitDuration = 5000; + } + }); + + const roundHandlerFn = (dt: Ticker) => { + if (endOfGameFadeOut !== null) { + endOfGameFadeOut -= dt.deltaMS; + if (endOfGameFadeOut < 0) { + game.pixiApp.ticker.remove(roundHandlerFn); + game.goToMenu(gameState.getActiveTeams()); + } + return; + } + if (currentWorm && currentWorm.currentState !== WormState.Inactive) { + return; + } + if (endOfRoundWaitDuration === null) { + return; + } + if (endOfRoundWaitDuration <= 0) { + if (!currentWorm) { + throw Error("Expected next worm"); + } + world.setWind(gameState.currentWind); + currentWorm.onWormSelected(); + game.viewport.follow(currentWorm.sprite); + endOfRoundWaitDuration = null; + return; + } + endOfRoundWaitDuration -= dt.deltaMS; + }; + game.pixiApp.ticker.add(roundHandlerFn); } -const replayData = `{"index":0,"data":{"version":2},"kind":0,"ts":"3939.836"}|{"index":1,"data":{"iteration":0,"wind":0,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":100,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":100,"maxHealth":100}]}]},"kind":7,"ts":"3946.394"}|{"index":2,"data":{"entities":[{"uuid":"55939cbf-e318-4a06-aa10-e9a228892a5a","type":0,"tra":{"x":"45","y":"5.25"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"9a3919e0-e756-428e-9ccb-094b4b012183"},{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"55","y":"5.25"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"}]},"kind":1,"ts":"3946.562"}|{"index":3,"data":{"entities":[{"uuid":"55939cbf-e318-4a06-aa10-e9a228892a5a","type":0,"tra":{"x":"45","y":"5.3399248123168945"},"rot":"0","vel":{"x":"0","y":"1.3079999685287476"},"wormIdent":"9a3919e0-e756-428e-9ccb-094b4b012183"},{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"55","y":"5.3399248123168945"},"rot":"0","vel":{"x":"0","y":"1.3079999685287476"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"}]},"kind":1,"ts":"3951.805"}|{"index":4,"data":{"iteration":0,"wind":3,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":100,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":100,"maxHealth":100}]}]},"kind":7,"ts":"3951.903"}|{"index":5,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":100,"action":0},"kind":3,"ts":"13566.692"}|{"index":6,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"3.6415926535897936","dir":"up","action":2},"kind":4,"ts":"13857.656"}|{"index":7,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":24,"action":1},"kind":3,"ts":"14179.635"}|{"index":8,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"0.12000000000000001","dir":"down","action":2},"kind":4,"ts":"16613.708"}|{"index":9,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"5.323185307179607","dir":"up","action":2},"kind":4,"ts":"17098.375"}|{"index":10,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"5.983185307179593","dir":"down","action":2},"kind":4,"ts":"17519.758"}|{"index":11,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"0.02","dir":"down","action":2},"kind":4,"ts":"17839.937"}|{"index":12,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"5.863185307179595","dir":"up","action":2},"kind":4,"ts":"18100.703"}|{"index":13,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","weapon":0},"kind":6,"ts":"18139.988"}|{"index":14,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"5.16318530717961","dir":"up","action":2},"kind":4,"ts":"18615.055"}|{"index":15,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","duration":27.959999999999955,"action":3},"kind":5,"ts":"19278.808"}|{"index":16,"data":{"entities":[{"uuid":"55939cbf-e318-4a06-aa10-e9a228892a5a","type":0,"tra":{"x":"43.098121643066406","y":"6.4760661125183105"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"9a3919e0-e756-428e-9ccb-094b4b012183"},{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"55","y":"6.476071834564209"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"},{"uuid":"cdff34b1-cdbe-4be3-82dc-03fe11b6c572","timer":180,"owner":"9a3919e0-e756-428e-9ccb-094b4b012183","timerSecs":3,"type":1,"tra":{"x":"44.62083053588867","y":"6.176065921783447"},"rot":"0","vel":{"x":"5.321871757507324","y":"-10.994749069213867"}}]},"kind":1,"ts":"19280.034"}|{"index":17,"data":{"entities":[]},"kind":1,"ts":"22336.575"}|{"index":18,"data":{"iteration":1,"wind":2,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":100,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":100,"maxHealth":100}]}]},"kind":7,"ts":"22336.731"}|{"index":19,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","cycles":19,"action":0},"kind":3,"ts":"28405.955"}|{"index":20,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","angle":"3.3815926535897933","dir":"up","action":2},"kind":4,"ts":"28954.054"}|{"index":21,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","duration":29.039999999999953,"action":3},"kind":5,"ts":"31201.008"}|{"index":22,"data":{"entities":[{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"54.526031494140625","y":"6.4760661125183105"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"},{"uuid":"0f6d3da6-fb15-443f-b2c5-8ad51ece502d","timer":1800,"owner":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","timerSecs":30,"type":2,"tra":{"x":"51.41943359375","y":"5.9760661125183105"},"rot":"0","vel":{"x":"-14.90359878540039","y":"-2.4314396381378174"}}]},"kind":1,"ts":"31202.144"}|{"index":23,"data":{"entities":[{"uuid":"55939cbf-e318-4a06-aa10-e9a228892a5a","type":0,"tra":{"x":"43.006202697753906","y":"6.541950702667236"},"rot":"0","vel":{"x":"-1.3786027431488037","y":"1.2948399782180786"},"wormIdent":"9a3919e0-e756-428e-9ccb-094b4b012183"}]},"kind":1,"ts":"31610.144"}|{"index":24,"data":{"iteration":3,"wind":-4,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":69,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":100,"maxHealth":100}]}]},"kind":7,"ts":"31610.292"}|{"index":25,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":53,"action":1},"kind":3,"ts":"37177.894"}|{"index":26,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":0,"action":1},"kind":3,"ts":"37269.168"}|{"index":27,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":0,"action":1},"kind":3,"ts":"37732.093"}|{"index":28,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":66,"action":0},"kind":3,"ts":"38115.122"}|{"index":29,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":66,"action":0},"kind":3,"ts":"38415.036"}|{"index":30,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":0,"action":1},"kind":3,"ts":"38433.039"}|{"index":31,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":0,"action":1},"kind":3,"ts":"38437.593"}|{"index":32,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","action":4},"kind":2,"ts":"38626.191"}|{"index":33,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":216,"action":0},"kind":3,"ts":"40563.095"}|{"index":34,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":11,"action":1},"kind":3,"ts":"40810.796"}|{"index":35,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"5.563185307179602","dir":"down","action":2},"kind":4,"ts":"41470.206"}|{"index":36,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"5.123185307179611","dir":"up","action":2},"kind":4,"ts":"43925.224"}|{"index":37,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","duration":28.49999999999995,"action":3},"kind":5,"ts":"44867.26"}|{"index":38,"data":{"entities":[{"uuid":"55939cbf-e318-4a06-aa10-e9a228892a5a","type":0,"tra":{"x":"31.91230010986328","y":"6.4760661125183105"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"9a3919e0-e756-428e-9ccb-094b4b012183"},{"uuid":"b85b4a20-26cf-45ae-98cb-16e81a9dbf89","timer":240,"owner":"9a3919e0-e756-428e-9ccb-094b4b012183","timerSecs":4,"type":1,"tra":{"x":"33.33494567871094","y":"6.176065921783447"},"rot":"0","vel":{"x":"5.068180084228516","y":"-11.635520935058594"}}]},"kind":1,"ts":"44868.576"}|{"index":39,"data":{"entities":[{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"54.829036712646484","y":"6.479506492614746"},"rot":"0","vel":{"x":"1.9895514249801636","y":"0.4295259714126587"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"}]},"kind":1,"ts":"48936.236"}|{"index":40,"data":{"iteration":5,"wind":-10,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":69,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":71,"maxHealth":100}]}]},"kind":7,"ts":"48936.392"}|{"index":41,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","angle":"3.8215926535897937","dir":"up","action":2},"kind":4,"ts":"55217.401"}|{"index":42,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","duration":36.299999999999955,"action":3},"kind":5,"ts":"56594.373"}|{"index":43,"data":{"entities":[{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"55.38034439086914","y":"6.947047233581543"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"},{"uuid":"0cc99087-d69f-4d45-bc79-17171980db99","timer":1800,"owner":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","timerSecs":30,"type":2,"tra":{"x":"52.372928619384766","y":"6.447047233581543"},"rot":"0","vel":{"x":"-17.447668075561523","y":"-9.406169891357422"}}]},"kind":1,"ts":"56596.217"}|{"index":44,"data":{"entities":[{"uuid":"0cc99087-d69f-4d45-bc79-17171980db99","timer":0,"owner":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","timerSecs":30,"type":2,"tra":{"x":"-108.63345336914062","y":"47.79460906982422"},"rot":"2.7007789611816406","vel":{"x":"-60.218833923339844","y":"28.409711837768555"}}]},"kind":1,"ts":"59306.059"}|{"index":45,"data":{"iteration":6,"wind":-3,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":69,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":71,"maxHealth":100}]}]},"kind":7,"ts":"59306.182"}|{"index":46,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"5.483185307179603","dir":"down","action":2},"kind":4,"ts":"65216.52"}|{"index":47,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","duration":27.779999999999955,"action":3},"kind":5,"ts":"66294.166"}|{"index":48,"data":{"entities":[{"uuid":"72ff6276-1d6b-4675-b37b-266b79290b2c","timer":240,"owner":"9a3919e0-e756-428e-9ccb-094b4b012183","timerSecs":4,"type":1,"tra":{"x":"34.33161544799805","y":"6.176065921783447"},"rot":"0","vel":{"x":"8.401067733764648","y":"-8.650063514709473"}}]},"kind":1,"ts":"66294.701"}|{"index":49,"data":{"entities":[{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"55.69258499145508","y":"6.912735462188721"},"rot":"0","vel":{"x":"-5.8586010709404945e-8","y":"0.21543896198272705"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"}]},"kind":1,"ts":"70338.791"}|{"index":50,"data":{"iteration":8,"wind":-10,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":69,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":60,"maxHealth":100}]}]},"kind":7,"ts":"70338.933"}|{"index":51,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","weapon":2},"kind":6,"ts":"76609.55"}|{"index":52,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","weapon":1},"kind":6,"ts":"77290.666"}|{"index":53,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","duration":18.959999999999997,"action":3},"kind":5,"ts":"78094.748"}|{"index":54,"data":{"entities":[{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"55.683189392089844","y":"6.952401161193848"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"},{"uuid":"8a2b33ff-9c75-4751-84f4-7bebb6239076","timer":1800,"owner":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","timerSecs":30,"type":2,"tra":{"x":"54.19084167480469","y":"6.452401161193848"},"rot":"0","vel":{"x":"-4.29625940322876","y":"-2.316145896911621"}}]},"kind":1,"ts":"78096.702"}|{"index":55,"data":{"entities":[]},"kind":1,"ts":"78594.493"}|{"index":56,"data":{"iteration":9,"wind":-4,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":69,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":60,"maxHealth":100}]}]},"kind":7,"ts":"78594.645"}|{"index":57,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"5.883185307179595","dir":"down","action":2},"kind":4,"ts":"84118.353"}|{"index":58,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"6.263185307179587","dir":"down","action":2},"kind":4,"ts":"84447.741"}|{"index":59,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":24,"action":1},"kind":3,"ts":"84872.747"}|{"index":60,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","action":4},"kind":2,"ts":"84994.826"}|{"index":61,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":137,"action":1},"kind":3,"ts":"86407.776"}|{"index":62,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","action":4},"kind":2,"ts":"86544.828"}|{"index":63,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":67,"action":1},"kind":3,"ts":"87701.747"}|{"index":64,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":6,"action":1},"kind":3,"ts":"87760.094"}|{"index":65,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":41,"action":1},"kind":3,"ts":"88140.772"}|{"index":66,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","weapon":0},"kind":6,"ts":"88598.485"}|{"index":67,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","weapon":2},"kind":6,"ts":"89069.803"}|{"index":68,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"0.27999999999999997","dir":"down","action":2},"kind":4,"ts":"90286.762"}|{"index":69,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"0.019999999999999993","dir":"up","action":2},"kind":4,"ts":"91005.665"}|{"index":70,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","duration":0,"action":3},"kind":5,"ts":"92014.813"}|{"index":71,"data":{"entities":[{"uuid":"55939cbf-e318-4a06-aa10-e9a228892a5a","type":0,"tra":{"x":"46.71908950805664","y":"6.518653869628906"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"9a3919e0-e756-428e-9ccb-094b4b012183"},{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"55.683189392089844","y":"6.952401161193848"},"rot":"0","vel":{"x":"0.8804620504379272","y":"0.5019922852516174"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"}]},"kind":1,"ts":"92074.706"}|{"index":72,"data":{"entities":[{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"55.694698333740234","y":"6.906276226043701"},"rot":"0","vel":{"x":"0.22071585059165955","y":"0"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"}]},"kind":1,"ts":"92077.909"}|{"index":73,"data":{"iteration":11,"wind":3,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":69,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":35,"maxHealth":100}]}]},"kind":7,"ts":"92078.071"}|{"index":74,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","cycles":16,"action":0},"kind":3,"ts":"97635.973"}|{"index":75,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","angle":"3.5415926535897935","dir":"down","action":2},"kind":4,"ts":"98789.926"}|{"index":76,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","angle":"3.181592653589793","dir":"down","action":2},"kind":4,"ts":"99194.973"}|{"index":77,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","duration":45.239999999999995,"action":3},"kind":5,"ts":"100522.604"}|{"index":78,"data":{"entities":[{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"55.269622802734375","y":"7.552298069000244"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"},{"uuid":"3e42877e-8537-4539-9640-a8d0d6dca372","timer":1800,"owner":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","timerSecs":30,"type":2,"tra":{"x":"50.74505615234375","y":"7.052298069000244"},"rot":"0","vel":{"x":"-30.73215103149414","y":"-0.8199613690376282"}}]},"kind":1,"ts":"100523.866"}|{"index":79,"data":{"entities":[{"uuid":"55939cbf-e318-4a06-aa10-e9a228892a5a","type":0,"tra":{"x":"46.614994049072266","y":"6.458955764770508"},"rot":"0","vel":{"x":"-1.0410246849060059","y":"-0.12692099809646606"},"wormIdent":"9a3919e0-e756-428e-9ccb-094b4b012183"}]},"kind":1,"ts":"100676.134"}|{"index":80,"data":{"iteration":13,"wind":2,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":29,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":35,"maxHealth":100}]}]},"kind":7,"ts":"100676.345"}|{"index":81,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","weapon":1},"kind":6,"ts":"107131.033"}|{"index":82,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","duration":80.27999999999977,"action":3},"kind":5,"ts":"109001.501"}|{"index":83,"data":{"entities":[{"uuid":"55939cbf-e318-4a06-aa10-e9a228892a5a","type":0,"tra":{"x":"46.07716751098633","y":"7.702179908752441"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"9a3919e0-e756-428e-9ccb-094b4b012183"},{"uuid":"c4b9767b-6305-401e-829a-1ddebfade691","timer":1800,"owner":"9a3919e0-e756-428e-9ccb-094b4b012183","timerSecs":30,"type":2,"tra":{"x":"52.32472229003906","y":"7.202179908752441"},"rot":"0","vel":{"x":"58.55965805053711","y":"0.7808995246887207"}}]},"kind":1,"ts":"109003.155"}|{"index":84,"data":{"entities":[{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"55.271026611328125","y":"7.579299449920654"},"rot":"0","vel":{"x":"0.08451084792613983","y":"1.6814192533493042"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"}]},"kind":1,"ts":"109057.754"}|{"index":85,"data":{"iteration":15,"wind":0,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":29,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":0,"maxHealth":100}]}]},"kind":7,"ts":"109057.95"}`.split('|'); \ No newline at end of file +const replayData = + `{"index":0,"data":{"version":2},"kind":0,"ts":"3939.836"}|{"index":1,"data":{"iteration":0,"wind":0,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":100,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":100,"maxHealth":100}]}]},"kind":7,"ts":"3946.394"}|{"index":2,"data":{"entities":[{"uuid":"55939cbf-e318-4a06-aa10-e9a228892a5a","type":0,"tra":{"x":"45","y":"5.25"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"9a3919e0-e756-428e-9ccb-094b4b012183"},{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"55","y":"5.25"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"}]},"kind":1,"ts":"3946.562"}|{"index":3,"data":{"entities":[{"uuid":"55939cbf-e318-4a06-aa10-e9a228892a5a","type":0,"tra":{"x":"45","y":"5.3399248123168945"},"rot":"0","vel":{"x":"0","y":"1.3079999685287476"},"wormIdent":"9a3919e0-e756-428e-9ccb-094b4b012183"},{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"55","y":"5.3399248123168945"},"rot":"0","vel":{"x":"0","y":"1.3079999685287476"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"}]},"kind":1,"ts":"3951.805"}|{"index":4,"data":{"iteration":0,"wind":3,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":100,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":100,"maxHealth":100}]}]},"kind":7,"ts":"3951.903"}|{"index":5,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":100,"action":0},"kind":3,"ts":"13566.692"}|{"index":6,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"3.6415926535897936","dir":"up","action":2},"kind":4,"ts":"13857.656"}|{"index":7,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":24,"action":1},"kind":3,"ts":"14179.635"}|{"index":8,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"0.12000000000000001","dir":"down","action":2},"kind":4,"ts":"16613.708"}|{"index":9,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"5.323185307179607","dir":"up","action":2},"kind":4,"ts":"17098.375"}|{"index":10,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"5.983185307179593","dir":"down","action":2},"kind":4,"ts":"17519.758"}|{"index":11,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"0.02","dir":"down","action":2},"kind":4,"ts":"17839.937"}|{"index":12,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"5.863185307179595","dir":"up","action":2},"kind":4,"ts":"18100.703"}|{"index":13,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","weapon":0},"kind":6,"ts":"18139.988"}|{"index":14,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"5.16318530717961","dir":"up","action":2},"kind":4,"ts":"18615.055"}|{"index":15,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","duration":27.959999999999955,"action":3},"kind":5,"ts":"19278.808"}|{"index":16,"data":{"entities":[{"uuid":"55939cbf-e318-4a06-aa10-e9a228892a5a","type":0,"tra":{"x":"43.098121643066406","y":"6.4760661125183105"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"9a3919e0-e756-428e-9ccb-094b4b012183"},{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"55","y":"6.476071834564209"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"},{"uuid":"cdff34b1-cdbe-4be3-82dc-03fe11b6c572","timer":180,"owner":"9a3919e0-e756-428e-9ccb-094b4b012183","timerSecs":3,"type":1,"tra":{"x":"44.62083053588867","y":"6.176065921783447"},"rot":"0","vel":{"x":"5.321871757507324","y":"-10.994749069213867"}}]},"kind":1,"ts":"19280.034"}|{"index":17,"data":{"entities":[]},"kind":1,"ts":"22336.575"}|{"index":18,"data":{"iteration":1,"wind":2,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":100,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":100,"maxHealth":100}]}]},"kind":7,"ts":"22336.731"}|{"index":19,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","cycles":19,"action":0},"kind":3,"ts":"28405.955"}|{"index":20,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","angle":"3.3815926535897933","dir":"up","action":2},"kind":4,"ts":"28954.054"}|{"index":21,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","duration":29.039999999999953,"action":3},"kind":5,"ts":"31201.008"}|{"index":22,"data":{"entities":[{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"54.526031494140625","y":"6.4760661125183105"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"},{"uuid":"0f6d3da6-fb15-443f-b2c5-8ad51ece502d","timer":1800,"owner":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","timerSecs":30,"type":2,"tra":{"x":"51.41943359375","y":"5.9760661125183105"},"rot":"0","vel":{"x":"-14.90359878540039","y":"-2.4314396381378174"}}]},"kind":1,"ts":"31202.144"}|{"index":23,"data":{"entities":[{"uuid":"55939cbf-e318-4a06-aa10-e9a228892a5a","type":0,"tra":{"x":"43.006202697753906","y":"6.541950702667236"},"rot":"0","vel":{"x":"-1.3786027431488037","y":"1.2948399782180786"},"wormIdent":"9a3919e0-e756-428e-9ccb-094b4b012183"}]},"kind":1,"ts":"31610.144"}|{"index":24,"data":{"iteration":3,"wind":-4,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":69,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":100,"maxHealth":100}]}]},"kind":7,"ts":"31610.292"}|{"index":25,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":53,"action":1},"kind":3,"ts":"37177.894"}|{"index":26,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":0,"action":1},"kind":3,"ts":"37269.168"}|{"index":27,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":0,"action":1},"kind":3,"ts":"37732.093"}|{"index":28,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":66,"action":0},"kind":3,"ts":"38115.122"}|{"index":29,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":66,"action":0},"kind":3,"ts":"38415.036"}|{"index":30,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":0,"action":1},"kind":3,"ts":"38433.039"}|{"index":31,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":0,"action":1},"kind":3,"ts":"38437.593"}|{"index":32,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","action":4},"kind":2,"ts":"38626.191"}|{"index":33,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":216,"action":0},"kind":3,"ts":"40563.095"}|{"index":34,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":11,"action":1},"kind":3,"ts":"40810.796"}|{"index":35,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"5.563185307179602","dir":"down","action":2},"kind":4,"ts":"41470.206"}|{"index":36,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"5.123185307179611","dir":"up","action":2},"kind":4,"ts":"43925.224"}|{"index":37,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","duration":28.49999999999995,"action":3},"kind":5,"ts":"44867.26"}|{"index":38,"data":{"entities":[{"uuid":"55939cbf-e318-4a06-aa10-e9a228892a5a","type":0,"tra":{"x":"31.91230010986328","y":"6.4760661125183105"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"9a3919e0-e756-428e-9ccb-094b4b012183"},{"uuid":"b85b4a20-26cf-45ae-98cb-16e81a9dbf89","timer":240,"owner":"9a3919e0-e756-428e-9ccb-094b4b012183","timerSecs":4,"type":1,"tra":{"x":"33.33494567871094","y":"6.176065921783447"},"rot":"0","vel":{"x":"5.068180084228516","y":"-11.635520935058594"}}]},"kind":1,"ts":"44868.576"}|{"index":39,"data":{"entities":[{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"54.829036712646484","y":"6.479506492614746"},"rot":"0","vel":{"x":"1.9895514249801636","y":"0.4295259714126587"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"}]},"kind":1,"ts":"48936.236"}|{"index":40,"data":{"iteration":5,"wind":-10,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":69,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":71,"maxHealth":100}]}]},"kind":7,"ts":"48936.392"}|{"index":41,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","angle":"3.8215926535897937","dir":"up","action":2},"kind":4,"ts":"55217.401"}|{"index":42,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","duration":36.299999999999955,"action":3},"kind":5,"ts":"56594.373"}|{"index":43,"data":{"entities":[{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"55.38034439086914","y":"6.947047233581543"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"},{"uuid":"0cc99087-d69f-4d45-bc79-17171980db99","timer":1800,"owner":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","timerSecs":30,"type":2,"tra":{"x":"52.372928619384766","y":"6.447047233581543"},"rot":"0","vel":{"x":"-17.447668075561523","y":"-9.406169891357422"}}]},"kind":1,"ts":"56596.217"}|{"index":44,"data":{"entities":[{"uuid":"0cc99087-d69f-4d45-bc79-17171980db99","timer":0,"owner":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","timerSecs":30,"type":2,"tra":{"x":"-108.63345336914062","y":"47.79460906982422"},"rot":"2.7007789611816406","vel":{"x":"-60.218833923339844","y":"28.409711837768555"}}]},"kind":1,"ts":"59306.059"}|{"index":45,"data":{"iteration":6,"wind":-3,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":69,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":71,"maxHealth":100}]}]},"kind":7,"ts":"59306.182"}|{"index":46,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"5.483185307179603","dir":"down","action":2},"kind":4,"ts":"65216.52"}|{"index":47,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","duration":27.779999999999955,"action":3},"kind":5,"ts":"66294.166"}|{"index":48,"data":{"entities":[{"uuid":"72ff6276-1d6b-4675-b37b-266b79290b2c","timer":240,"owner":"9a3919e0-e756-428e-9ccb-094b4b012183","timerSecs":4,"type":1,"tra":{"x":"34.33161544799805","y":"6.176065921783447"},"rot":"0","vel":{"x":"8.401067733764648","y":"-8.650063514709473"}}]},"kind":1,"ts":"66294.701"}|{"index":49,"data":{"entities":[{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"55.69258499145508","y":"6.912735462188721"},"rot":"0","vel":{"x":"-5.8586010709404945e-8","y":"0.21543896198272705"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"}]},"kind":1,"ts":"70338.791"}|{"index":50,"data":{"iteration":8,"wind":-10,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":69,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":60,"maxHealth":100}]}]},"kind":7,"ts":"70338.933"}|{"index":51,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","weapon":2},"kind":6,"ts":"76609.55"}|{"index":52,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","weapon":1},"kind":6,"ts":"77290.666"}|{"index":53,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","duration":18.959999999999997,"action":3},"kind":5,"ts":"78094.748"}|{"index":54,"data":{"entities":[{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"55.683189392089844","y":"6.952401161193848"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"},{"uuid":"8a2b33ff-9c75-4751-84f4-7bebb6239076","timer":1800,"owner":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","timerSecs":30,"type":2,"tra":{"x":"54.19084167480469","y":"6.452401161193848"},"rot":"0","vel":{"x":"-4.29625940322876","y":"-2.316145896911621"}}]},"kind":1,"ts":"78096.702"}|{"index":55,"data":{"entities":[]},"kind":1,"ts":"78594.493"}|{"index":56,"data":{"iteration":9,"wind":-4,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":69,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":60,"maxHealth":100}]}]},"kind":7,"ts":"78594.645"}|{"index":57,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"5.883185307179595","dir":"down","action":2},"kind":4,"ts":"84118.353"}|{"index":58,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"6.263185307179587","dir":"down","action":2},"kind":4,"ts":"84447.741"}|{"index":59,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":24,"action":1},"kind":3,"ts":"84872.747"}|{"index":60,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","action":4},"kind":2,"ts":"84994.826"}|{"index":61,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":137,"action":1},"kind":3,"ts":"86407.776"}|{"index":62,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","action":4},"kind":2,"ts":"86544.828"}|{"index":63,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":67,"action":1},"kind":3,"ts":"87701.747"}|{"index":64,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":6,"action":1},"kind":3,"ts":"87760.094"}|{"index":65,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","cycles":41,"action":1},"kind":3,"ts":"88140.772"}|{"index":66,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","weapon":0},"kind":6,"ts":"88598.485"}|{"index":67,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","weapon":2},"kind":6,"ts":"89069.803"}|{"index":68,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"0.27999999999999997","dir":"down","action":2},"kind":4,"ts":"90286.762"}|{"index":69,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","angle":"0.019999999999999993","dir":"up","action":2},"kind":4,"ts":"91005.665"}|{"index":70,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","duration":0,"action":3},"kind":5,"ts":"92014.813"}|{"index":71,"data":{"entities":[{"uuid":"55939cbf-e318-4a06-aa10-e9a228892a5a","type":0,"tra":{"x":"46.71908950805664","y":"6.518653869628906"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"9a3919e0-e756-428e-9ccb-094b4b012183"},{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"55.683189392089844","y":"6.952401161193848"},"rot":"0","vel":{"x":"0.8804620504379272","y":"0.5019922852516174"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"}]},"kind":1,"ts":"92074.706"}|{"index":72,"data":{"entities":[{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"55.694698333740234","y":"6.906276226043701"},"rot":"0","vel":{"x":"0.22071585059165955","y":"0"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"}]},"kind":1,"ts":"92077.909"}|{"index":73,"data":{"iteration":11,"wind":3,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":69,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":35,"maxHealth":100}]}]},"kind":7,"ts":"92078.071"}|{"index":74,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","cycles":16,"action":0},"kind":3,"ts":"97635.973"}|{"index":75,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","angle":"3.5415926535897935","dir":"down","action":2},"kind":4,"ts":"98789.926"}|{"index":76,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","angle":"3.181592653589793","dir":"down","action":2},"kind":4,"ts":"99194.973"}|{"index":77,"data":{"id":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","duration":45.239999999999995,"action":3},"kind":5,"ts":"100522.604"}|{"index":78,"data":{"entities":[{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"55.269622802734375","y":"7.552298069000244"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"},{"uuid":"3e42877e-8537-4539-9640-a8d0d6dca372","timer":1800,"owner":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","timerSecs":30,"type":2,"tra":{"x":"50.74505615234375","y":"7.052298069000244"},"rot":"0","vel":{"x":"-30.73215103149414","y":"-0.8199613690376282"}}]},"kind":1,"ts":"100523.866"}|{"index":79,"data":{"entities":[{"uuid":"55939cbf-e318-4a06-aa10-e9a228892a5a","type":0,"tra":{"x":"46.614994049072266","y":"6.458955764770508"},"rot":"0","vel":{"x":"-1.0410246849060059","y":"-0.12692099809646606"},"wormIdent":"9a3919e0-e756-428e-9ccb-094b4b012183"}]},"kind":1,"ts":"100676.134"}|{"index":80,"data":{"iteration":13,"wind":2,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":29,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":35,"maxHealth":100}]}]},"kind":7,"ts":"100676.345"}|{"index":81,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","weapon":1},"kind":6,"ts":"107131.033"}|{"index":82,"data":{"id":"9a3919e0-e756-428e-9ccb-094b4b012183","duration":80.27999999999977,"action":3},"kind":5,"ts":"109001.501"}|{"index":83,"data":{"entities":[{"uuid":"55939cbf-e318-4a06-aa10-e9a228892a5a","type":0,"tra":{"x":"46.07716751098633","y":"7.702179908752441"},"rot":"0","vel":{"x":"0","y":"0"},"wormIdent":"9a3919e0-e756-428e-9ccb-094b4b012183"},{"uuid":"c4b9767b-6305-401e-829a-1ddebfade691","timer":1800,"owner":"9a3919e0-e756-428e-9ccb-094b4b012183","timerSecs":30,"type":2,"tra":{"x":"52.32472229003906","y":"7.202179908752441"},"rot":"0","vel":{"x":"58.55965805053711","y":"0.7808995246887207"}}]},"kind":1,"ts":"109003.155"}|{"index":84,"data":{"entities":[{"uuid":"7165b582-f2df-49a8-8bed-25ff0c6cbb6c","type":0,"tra":{"x":"55.271026611328125","y":"7.579299449920654"},"rot":"0","vel":{"x":"0.08451084792613983","y":"1.6814192533493042"},"wormIdent":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242"}]},"kind":1,"ts":"109057.754"}|{"index":85,"data":{"iteration":15,"wind":0,"teams":[{"name":"The Prawns","group":0,"playerUserId":null,"worms":[{"uuid":"9a3919e0-e756-428e-9ccb-094b4b012183","name":"Shrimp","health":29,"maxHealth":100}]},{"name":"The Whales","group":1,"playerUserId":null,"worms":[{"uuid":"d39e19a9-f8e5-4c2a-b4dc-eecb2d741242","name":"Welsh boy","health":0,"maxHealth":100}]}]},"kind":7,"ts":"109057.95"}`.split( + "|", + ); diff --git a/src/scenarios/testingGround.ts b/src/scenarios/testingGround.ts index eeee9ae..bf1f732 100644 --- a/src/scenarios/testingGround.ts +++ b/src/scenarios/testingGround.ts @@ -8,7 +8,11 @@ import { Coordinate, MetersValue } from "../utils/coodinate"; import { GameState } from "../logic/gamestate"; import { TeamGroup } from "../logic/teams"; import { GameStateOverlay } from "../overlays/gameStateOverlay"; -import { GameDrawText, TeamWinnerText, templateRandomText } from "../text/toasts"; +import { + GameDrawText, + TeamWinnerText, + templateRandomText, +} from "../text/toasts"; import { PhysicsEntity } from "../entities/phys/physicsEntity"; import { DefaultTextStyle } from "../mixins/styles"; import { WeaponBazooka, WeaponGrenade, WeaponShotgun } from "../weapons"; @@ -16,173 +20,209 @@ import staticController, { InputKind } from "../input"; import { IWeaponCode } from "../weapons/weapon"; import { StateRecorder } from "../state/recorder"; import { CameraLockPriority, ViewportCamera } from "../camera"; -import { MovedEvent } from "pixi-viewport/dist/types"; const weapons = [WeaponBazooka, WeaponGrenade, WeaponShotgun]; export default async function runScenario(game: Game) { - const parent = game.viewport; - const world = game.world; - const { worldWidth, worldHeight } = game.viewport; - - - const terrain = BitmapTerrain.create( - worldWidth, - worldHeight, - game.world, - Assets.get('testingGround') - ); - - const gameState = new GameState([{ + const parent = game.viewport; + const world = game.world; + const { worldWidth, worldHeight } = game.viewport; + + const terrain = BitmapTerrain.create( + worldWidth, + worldHeight, + game.world, + Assets.get("testingGround"), + ); + + const gameState = new GameState( + [ + { name: "The Prawns", group: TeamGroup.Red, - worms: [{ + worms: [ + { name: "Shrimp", maxHealth: 100, health: 100, - }], + }, + ], playerUserId: null, - },{ + }, + { name: "The Whales", group: TeamGroup.Blue, - worms: [{ + worms: [ + { name: "Welsh boy", maxHealth: 100, health: 100, - }], + }, + ], playerUserId: null, - }], { - winWhenOneGroupRemains: true, - }); - - - const recordedGameplayKey = `wormgine_recorded_${new Date().toISOString()}`; - let recordedState = ''; - - const stateRecorder = new StateRecorder(world, gameState, { - async writeLine(data) { - recordedState += `${JSON.stringify(data)}|`; - localStorage.setItem(recordedGameplayKey, recordedState); - }, - }); - - const bg = await world.addEntity(Background.create(game.viewport.screenWidth, game.viewport.screenHeight, game.viewport, [20, 21, 50, 35], terrain)); - bg.addToWorld(game.pixiApp.stage, parent); - await world.addEntity(terrain); - bg.addToWorld(game.pixiApp.stage, parent); - terrain.addToWorld(parent); - - const overlay = new GameStateOverlay(game.pixiApp.ticker, game.pixiApp.stage, gameState, world, game.viewport.screenWidth, game.viewport.screenHeight); - const camera = new ViewportCamera(game.viewport, world); - - const water = world.addEntity( - new Water( - MetersValue.fromPixels(worldWidth*4), - MetersValue.fromPixels(worldHeight), - world) - ); water.addToWorld(parent, world); - - const wormInstances = new Map(); - - let i = 300; - for (const team of gameState.getActiveTeams()) { - for (const wormInstance of team.worms) { - i += 200; - const wormEnt = world.addEntity( - await Worm.create(parent, world, Coordinate.fromScreen(400 + i,105), wormInstance, async (worm, definition, opts) => { - const newProjectile = definition.fireFn(parent, world, worm, opts); - if (newProjectile instanceof PhysicsEntity) { - newProjectile.cameraLockPriority = CameraLockPriority.LockIfNotLocalPlayer; - world.addEntity(newProjectile); - } - stateRecorder.syncEntityState(); - const res = await newProjectile.onFireResult; - return res; - }, overlay.toaster, stateRecorder)); - wormInstances.set(wormInstance.uuid, wormEnt); - } - } - - let endOfRoundWaitDuration: number|null = null; - let endOfGameFadeOut: number|null = null; - let currentWorm: Worm|undefined; - - const weaponText = new Text({ - text: `Selected Weapon: no-worm-selected`, - style: DefaultTextStyle, - }); - weaponText.position.set(20, 50); - staticController.on('inputEnd', (kind: InputKind) => { - if (kind === InputKind.WeaponMenu) { - game.gameReactChannel.openWeaponMenu(weapons); - } - }); - - game.gameReactChannel.on("weaponSelected", (code) => { - console.log('selected', code); - if (!currentWorm) { - return; - } - const newWep = weapons.findIndex(w => w.code === code); - if (newWep === -1) { - throw Error('Selected weapon is not owned by worm'); - } - currentWorm.selectWeapon(weapons[newWep]); - weaponText.text = `Selected Weapon: ${IWeaponCode[currentWorm.weapon.code]}`; - }) - - - const roundHandlerFn = (dt: Ticker) => { - if (endOfGameFadeOut !== null) { - endOfGameFadeOut -= dt.deltaMS; - if (endOfGameFadeOut < 0) { - game.pixiApp.ticker.remove(roundHandlerFn); - game.goToMenu(gameState.getActiveTeams()); + }, + ], + { + winWhenOneGroupRemains: true, + }, + ); + + const recordedGameplayKey = `wormgine_recorded_${new Date().toISOString()}`; + let recordedState = ""; + + const stateRecorder = new StateRecorder(world, gameState, { + async writeLine(data) { + recordedState += `${JSON.stringify(data)}|`; + localStorage.setItem(recordedGameplayKey, recordedState); + }, + }); + + const bg = await world.addEntity( + Background.create( + game.viewport.screenWidth, + game.viewport.screenHeight, + game.viewport, + [20, 21, 50, 35], + terrain, + ), + ); + bg.addToWorld(game.pixiApp.stage, parent); + await world.addEntity(terrain); + bg.addToWorld(game.pixiApp.stage, parent); + terrain.addToWorld(parent); + + const overlay = new GameStateOverlay( + game.pixiApp.ticker, + game.pixiApp.stage, + gameState, + world, + game.viewport.screenWidth, + game.viewport.screenHeight, + ); + const camera = new ViewportCamera(game.viewport, world); + + const water = world.addEntity( + new Water( + MetersValue.fromPixels(worldWidth * 4), + MetersValue.fromPixels(worldHeight), + world, + ), + ); + water.addToWorld(parent, world); + + const wormInstances = new Map(); + + let i = 300; + for (const team of gameState.getActiveTeams()) { + for (const wormInstance of team.worms) { + i += 200; + const wormEnt = world.addEntity( + await Worm.create( + parent, + world, + Coordinate.fromScreen(400 + i, 105), + wormInstance, + async (worm, definition, opts) => { + const newProjectile = definition.fireFn(parent, world, worm, opts); + if (newProjectile instanceof PhysicsEntity) { + newProjectile.cameraLockPriority = + CameraLockPriority.LockIfNotLocalPlayer; + world.addEntity(newProjectile); } - return; - } - if (currentWorm && currentWorm.currentState !== WormState.Inactive) { - return; - } - if (endOfRoundWaitDuration === null) { stateRecorder.syncEntityState(); - const nextState = gameState.advanceRound(); - stateRecorder.recordGameStare(); - if ('winningTeams' in nextState) { - if (nextState.winningTeams.length) { - overlay.toaster.pushToast(templateRandomText(TeamWinnerText, { - TeamName: nextState.winningTeams.map(t => t.name).join(', '), - }), 8000); - } else { - // Draw - overlay.toaster.pushToast(templateRandomText(GameDrawText), 8000); - } - endOfGameFadeOut = 8000; - } else { - currentWorm?.onEndOfTurn(); - currentWorm = wormInstances.get(nextState.nextWorm.uuid); - // Turn just ended. - endOfRoundWaitDuration = 5000; - } - return; - } - if (endOfRoundWaitDuration <= 0) { - if (!currentWorm) { - throw Error('Expected next worm'); - } - world.setWind(gameState.currentWind); - currentWorm.onWormSelected(); - weaponText.text = `Selected Weapon: ${IWeaponCode[currentWorm.weapon.code]}`; - endOfRoundWaitDuration = null; - return; + const res = await newProjectile.onFireResult; + return res; + }, + overlay.toaster, + stateRecorder, + ), + ); + wormInstances.set(wormInstance.uuid, wormEnt); + } + } + + let endOfRoundWaitDuration: number | null = null; + let endOfGameFadeOut: number | null = null; + let currentWorm: Worm | undefined; + + const weaponText = new Text({ + text: `Selected Weapon: no-worm-selected`, + style: DefaultTextStyle, + }); + weaponText.position.set(20, 50); + staticController.on("inputEnd", (kind: InputKind) => { + if (kind === InputKind.WeaponMenu) { + game.gameReactChannel.openWeaponMenu(weapons); + } + }); + + game.gameReactChannel.on("weaponSelected", (code) => { + console.log("selected", code); + if (!currentWorm) { + return; + } + const newWep = weapons.findIndex((w) => w.code === code); + if (newWep === -1) { + throw Error("Selected weapon is not owned by worm"); + } + currentWorm.selectWeapon(weapons[newWep]); + weaponText.text = `Selected Weapon: ${IWeaponCode[currentWorm.weapon.code]}`; + }); + + const roundHandlerFn = (dt: Ticker) => { + if (endOfGameFadeOut !== null) { + endOfGameFadeOut -= dt.deltaMS; + if (endOfGameFadeOut < 0) { + game.pixiApp.ticker.remove(roundHandlerFn); + game.goToMenu(gameState.getActiveTeams()); + } + return; + } + if (currentWorm && currentWorm.currentState !== WormState.Inactive) { + return; + } + if (endOfRoundWaitDuration === null) { + stateRecorder.syncEntityState(); + const nextState = gameState.advanceRound(); + stateRecorder.recordGameStare(); + if ("winningTeams" in nextState) { + if (nextState.winningTeams.length) { + overlay.toaster.pushToast( + templateRandomText(TeamWinnerText, { + TeamName: nextState.winningTeams.map((t) => t.name).join(", "), + }), + 8000, + ); + } else { + // Draw + overlay.toaster.pushToast(templateRandomText(GameDrawText), 8000); } - endOfRoundWaitDuration -= dt.deltaMS; - }; + endOfGameFadeOut = 8000; + } else { + currentWorm?.onEndOfTurn(); + currentWorm = wormInstances.get(nextState.nextWorm.uuid); + // Turn just ended. + endOfRoundWaitDuration = 5000; + } + return; + } + if (endOfRoundWaitDuration <= 0) { + if (!currentWorm) { + throw Error("Expected next worm"); + } + world.setWind(gameState.currentWind); + currentWorm.onWormSelected(); + weaponText.text = `Selected Weapon: ${IWeaponCode[currentWorm.weapon.code]}`; + endOfRoundWaitDuration = null; + return; + } + endOfRoundWaitDuration -= dt.deltaMS; + }; - game.pixiApp.ticker.add((dt) => camera.update(dt, currentWorm)); + game.pixiApp.ticker.add((dt) => camera.update(dt, currentWorm)); - game.pixiApp.ticker.add(roundHandlerFn); - game.pixiApp.stage.addChild(weaponText); - stateRecorder.recordGameStare(); - stateRecorder.syncEntityState(); -} \ No newline at end of file + game.pixiApp.ticker.add(roundHandlerFn); + game.pixiApp.stage.addChild(weaponText); + stateRecorder.recordGameStare(); + stateRecorder.syncEntityState(); +} diff --git a/src/scenarios/uiTest.ts b/src/scenarios/uiTest.ts index bf75625..bdb1199 100644 --- a/src/scenarios/uiTest.ts +++ b/src/scenarios/uiTest.ts @@ -4,36 +4,53 @@ import { TeamGroup } from "../logic/teams"; import { GameStateOverlay } from "../overlays/gameStateOverlay"; export default async function runScenario(game: Game) { - const world = game.world; + const world = game.world; - const gameState = new GameState([{ + const gameState = new GameState( + [ + { name: "The Prawns", group: TeamGroup.Red, - worms: [{ + worms: [ + { name: "Shrimp", maxHealth: 100, health: 100, - }], + }, + ], playerUserId: null, - },{ + }, + { name: "The Whales", group: TeamGroup.Blue, - worms: [{ + worms: [ + { name: "Welsh boy", maxHealth: 100, health: 100, - }], + }, + ], playerUserId: null, - }], { - winWhenOneGroupRemains: true, - }); + }, + ], + { + winWhenOneGroupRemains: true, + }, + ); - const overlay = new GameStateOverlay(game.pixiApp.ticker, game.pixiApp.stage, gameState, world, game.viewport.screenWidth, game.viewport.screenHeight); + const overlay = new GameStateOverlay( + game.pixiApp.ticker, + game.pixiApp.stage, + gameState, + world, + game.viewport.screenWidth, + game.viewport.screenHeight, + ); - let toastCounter = 0; - do { - overlay.toaster.pushToast(`This is toast #${++toastCounter}`, 5000); - gameState.advanceRound(); - await new Promise(r => setTimeout(r, 6000)); - } while (toastCounter < 50000) -} \ No newline at end of file + let toastCounter = 0; + do { + overlay.toaster.pushToast(`This is toast #${++toastCounter}`, 5000); + gameState.advanceRound(); + await new Promise((r) => setTimeout(r, 6000)); + } while (toastCounter < 50000); +} diff --git a/src/settings.ts b/src/settings.ts index 1931dab..94c74ba 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1 +1 @@ -export const SOUND_EFFECT_VOLUME = 0.5; \ No newline at end of file +export const SOUND_EFFECT_VOLUME = 0.5; diff --git a/src/shaders/gradient.ts b/src/shaders/gradient.ts index 91b41f0..7ea2f44 100644 --- a/src/shaders/gradient.ts +++ b/src/shaders/gradient.ts @@ -3,7 +3,7 @@ import fragment from "./gradient.frag?raw"; import { GlProgram } from "pixi.js"; export default GlProgram.from({ - vertex, - fragment, - name: 'rain' -}); \ No newline at end of file + vertex, + fragment, + name: "rain", +}); diff --git a/src/shaders/index.ts b/src/shaders/index.ts index 4d7caac..3c94d0c 100644 --- a/src/shaders/index.ts +++ b/src/shaders/index.ts @@ -1 +1 @@ -export { default as GradientShader } from "./gradient"; \ No newline at end of file +export { default as GradientShader } from "./gradient"; diff --git a/src/state/model.ts b/src/state/model.ts index 2c31ae4..5701f65 100644 --- a/src/state/model.ts +++ b/src/state/model.ts @@ -1,91 +1,90 @@ -import { TeamGroup } from "../logic/teams" -import { IWeaponCode } from "../weapons/weapon" +import { TeamGroup } from "../logic/teams"; +import { IWeaponCode } from "../weapons/weapon"; export interface RecordedEntityState { - type: number, - // Translation - tra: {x: string, y: string}, - // Rotation - rot: string, - // Linear velocity - vel: {x: string, y: string}, + type: number; + // Translation + tra: { x: string; y: string }; + // Rotation + rot: string; + // Linear velocity + vel: { x: string; y: string }; } - export enum StateRecordKind { - Header, - EntitySync, - WormAction, - WormActionMove, - WormActionAim, - WormActionFire, - WormSelectWeapon, - GameState, + Header, + EntitySync, + WormAction, + WormActionMove, + WormActionAim, + WormActionFire, + WormSelectWeapon, + GameState, } export interface StateRecordLine { - index: number, - kind: StateRecordKind, - data: T, - ts: string, + index: number; + kind: StateRecordKind; + data: T; + ts: string; } -export type StateRecordHeader = StateRecordLine<{version: number}> +export type StateRecordHeader = StateRecordLine<{ version: number }>; export type StateRecordEntitySync = StateRecordLine<{ - entities: (RecordedEntityState&{uuid: string})[] -}> + entities: (RecordedEntityState & { uuid: string })[]; +}>; export enum StateWormAction { - MoveLeft, - MoveRight, - Aim, - Fire, - Jump, - Backflip, + MoveLeft, + MoveRight, + Aim, + Fire, + Jump, + Backflip, } export type StateRecordWormActionMove = StateRecordLine<{ - id: string, - action: StateWormAction, - cycles: number, -}> + id: string; + action: StateWormAction; + cycles: number; +}>; export type StateRecordWormActionAim = StateRecordLine<{ - id: string, - action: StateWormAction, - dir: "up"|"down" - angle: string, -}> + id: string; + action: StateWormAction; + dir: "up" | "down"; + angle: string; +}>; export type StateRecordWormActionFire = StateRecordLine<{ - id: string, - action: StateWormAction, - duration?: number, -}> + id: string; + action: StateWormAction; + duration?: number; +}>; export type StateRecordWormAction = StateRecordLine<{ - id: string, - action: StateWormAction, -}> + id: string; + action: StateWormAction; +}>; export type StateRecordWormSelectWeapon = StateRecordLine<{ - id: string, - weapon: IWeaponCode, -}> + id: string; + weapon: IWeaponCode; +}>; export type StateRecordWormGameState = StateRecordLine<{ - teams: { - group: TeamGroup, - name: string, - worms: { - uuid: string, - name: string, - health: number, - maxHealth: number, - }[], - playerUserId: string|null, - }[], - iteration: number, - wind: number, -}> \ No newline at end of file + teams: { + group: TeamGroup; + name: string; + worms: { + uuid: string; + name: string; + health: number; + maxHealth: number; + }[]; + playerUserId: string | null; + }[]; + iteration: number; + wind: number; +}>; diff --git a/src/state/player.ts b/src/state/player.ts index cc3b3d0..0e5eeda 100644 --- a/src/state/player.ts +++ b/src/state/player.ts @@ -1,135 +1,161 @@ import { EventEmitter } from "pixi.js"; -import { RecordedEntityState, StateRecordEntitySync, StateRecordKind, StateRecordLine, StateRecordWormAction, StateRecordWormActionAim, StateRecordWormActionFire, StateRecordWormActionMove, StateRecordWormGameState, StateRecordWormSelectWeapon } from "./model"; +import { + RecordedEntityState, + StateRecordEntitySync, + StateRecordKind, + StateRecordLine, + StateRecordWormAction, + StateRecordWormActionAim, + StateRecordWormActionFire, + StateRecordWormActionMove, + StateRecordWormGameState, + StateRecordWormSelectWeapon, +} from "./model"; import { NetGameInstance } from "../net/client"; interface EventTypes { - 'started': void, - 'entitySync': [StateRecordEntitySync["data"]["entities"]], - 'wormAction': [StateRecordWormAction["data"]], - 'wormActionMove': [StateRecordWormActionMove["data"]], - 'wormActionAim': [StateRecordWormActionAim["data"]], - 'wormActionFire': [StateRecordWormActionFire["data"]], - 'wormSelectWeapon': [StateRecordWormSelectWeapon["data"]], - 'gameState': [StateRecordWormGameState["data"]], + started: void; + entitySync: [StateRecordEntitySync["data"]["entities"]]; + wormAction: [StateRecordWormAction["data"]]; + wormActionMove: [StateRecordWormActionMove["data"]]; + wormActionAim: [StateRecordWormActionAim["data"]]; + wormActionFire: [StateRecordWormActionFire["data"]]; + wormSelectWeapon: [StateRecordWormSelectWeapon["data"]]; + gameState: [StateRecordWormGameState["data"]]; } export class StateReplay extends EventEmitter { - protected lastActionTs = -1; - /** - * The relative time when the playback started from the host. - */ - protected hostStartTs = -1; - /** - * The relative time when the playback started locally. - */ - protected localStartTs = -1; + protected lastActionTs = -1; + /** + * The relative time when the playback started from the host. + */ + protected hostStartTs = -1; + /** + * The relative time when the playback started locally. + */ + protected localStartTs = -1; - protected waitingForStop?: StateRecordWormAction; + protected waitingForStop?: StateRecordWormAction; - protected _latestEntityData?: (RecordedEntityState&{uuid: string})[]; + protected _latestEntityData?: (RecordedEntityState & { uuid: string })[]; - public async waitForFullGameState() { - const startPromise = new Promise(r => this.once('started', () => r())); - const gameStatePromise = new Promise(r => this.once('gameState', (state) => r(state))); - const entitySyncPromise = new Promise(r => this.once('entitySync', (state) => r(state))); - const [_start, gameState, entitySync] = await Promise.all([startPromise, gameStatePromise, entitySyncPromise]); - return { - gameState, - entitySync, - } - } + public async waitForFullGameState() { + const startPromise = new Promise((r) => + this.once("started", () => r()), + ); + const gameStatePromise = new Promise( + (r) => this.once("gameState", (state) => r(state)), + ); + const entitySyncPromise = new Promise< + StateRecordEntitySync["data"]["entities"] + >((r) => this.once("entitySync", (state) => r(state))); + const [_start, gameState, entitySync] = await Promise.all([ + startPromise, + gameStatePromise, + entitySyncPromise, + ]); + return { + gameState, + entitySync, + }; + } - public get elapsedRelativeLocalTime() { - return performance.now() - this.localStartTs!; - } + public get elapsedRelativeLocalTime() { + return performance.now() - this.localStartTs!; + } - public get latestEntityData() { - return [...this._latestEntityData ?? []]; - } + public get latestEntityData() { + return [...(this._latestEntityData ?? [])]; + } - protected async parseData(data: StateRecordLine): Promise { - const ts = parseFloat(data.ts); - if (data.kind === StateRecordKind.Header) { - this.emit('started'); - this.lastActionTs = ts; - this.hostStartTs = ts; - this.localStartTs = performance.now(); - return ; - } else if (!this.lastActionTs) { - throw Error('Missing header'); - } - // Calculate the number of ms to delay since the start of the game. - const elapsedRelativeTimeForState = ts - this.hostStartTs; - // and substract the local runtime. (e.g. if the relative time is 10s and we are 5s along, wait 5s) - const waitFor = elapsedRelativeTimeForState - this.elapsedRelativeLocalTime; + protected async parseData(data: StateRecordLine): Promise { + const ts = parseFloat(data.ts); + if (data.kind === StateRecordKind.Header) { + this.emit("started"); + this.lastActionTs = ts; + this.hostStartTs = ts; + this.localStartTs = performance.now(); + return; + } else if (!this.lastActionTs) { + throw Error("Missing header"); + } + // Calculate the number of ms to delay since the start of the game. + const elapsedRelativeTimeForState = ts - this.hostStartTs; + // and substract the local runtime. (e.g. if the relative time is 10s and we are 5s along, wait 5s) + const waitFor = elapsedRelativeTimeForState - this.elapsedRelativeLocalTime; - console.log(ts, elapsedRelativeTimeForState, waitFor, data.data); + console.log(ts, elapsedRelativeTimeForState, waitFor, data.data); - await new Promise(r => setTimeout(r, waitFor)); - this.lastActionTs = ts; - switch (data.kind) { - case StateRecordKind.EntitySync: - // TODO: Apply deltas somehow. - this._latestEntityData = (data as StateRecordEntitySync).data.entities; - this.emit('entitySync', (data as StateRecordEntitySync).data.entities); - break; - case StateRecordKind.WormAction: { - const actionData = data as StateRecordWormAction; - this.emit('wormAction', actionData.data); - break; - } - case StateRecordKind.WormActionAim: - this.emit('wormActionAim', (data as StateRecordWormActionAim).data); - break; - case StateRecordKind.WormActionMove: - this.emit('wormActionMove', (data as StateRecordWormActionMove).data); - break; - case StateRecordKind.WormActionFire: - this.emit('wormActionFire', (data as StateRecordWormActionFire).data); - break; - case StateRecordKind.WormSelectWeapon: - this.emit('wormSelectWeapon', (data as StateRecordWormSelectWeapon).data); - break; - case StateRecordKind.GameState: - this.emit('gameState', (data as StateRecordWormGameState).data); - break; - default: - throw Error('Unknown state action, possibly older format!'); - } + await new Promise((r) => setTimeout(r, waitFor)); + this.lastActionTs = ts; + switch (data.kind) { + case StateRecordKind.EntitySync: + // TODO: Apply deltas somehow. + this._latestEntityData = (data as StateRecordEntitySync).data.entities; + this.emit("entitySync", (data as StateRecordEntitySync).data.entities); + break; + case StateRecordKind.WormAction: { + const actionData = data as StateRecordWormAction; + this.emit("wormAction", actionData.data); + break; + } + case StateRecordKind.WormActionAim: + this.emit("wormActionAim", (data as StateRecordWormActionAim).data); + break; + case StateRecordKind.WormActionMove: + this.emit("wormActionMove", (data as StateRecordWormActionMove).data); + break; + case StateRecordKind.WormActionFire: + this.emit("wormActionFire", (data as StateRecordWormActionFire).data); + break; + case StateRecordKind.WormSelectWeapon: + this.emit( + "wormSelectWeapon", + (data as StateRecordWormSelectWeapon).data, + ); + break; + case StateRecordKind.GameState: + this.emit("gameState", (data as StateRecordWormGameState).data); + break; + default: + throw Error("Unknown state action, possibly older format!"); } + } } export class TextStateReplay extends StateReplay { - private stateLines: StateRecordLine[]; + private stateLines: StateRecordLine[]; - constructor(state: string[]) { - super(); - this.stateLines = state.map(s => JSON.parse(s)); - } + constructor(state: string[]) { + super(); + this.stateLines = state.map((s) => JSON.parse(s)); + } - public async play() { - if (this.hostStartTs != -1) { - throw Error('Already playing'); - } - for (const line of this.stateLines) { - await this.parseData(line); - } + public async play() { + if (this.hostStartTs != -1) { + throw Error("Already playing"); + } + for (const line of this.stateLines) { + await this.parseData(line); } + } } export class MatrixStateReplay extends StateReplay { - constructor(private gameInst: NetGameInstance) { - super(); - } + constructor(private gameInst: NetGameInstance) { + super(); + } - public async play() { - if (this.hostStartTs != -1) { - throw Error('Already playing'); - } - let prevPromise = Promise.resolve(); - this.gameInst.subscribeToGameState((data) => { - prevPromise = prevPromise.finally(() => this.parseData(data).catch((ex) => { - console.error('Failed to process line', ex); - })); - }); + public async play() { + if (this.hostStartTs != -1) { + throw Error("Already playing"); } + let prevPromise = Promise.resolve(); + this.gameInst.subscribeToGameState((data) => { + prevPromise = prevPromise.finally(() => + this.parseData(data).catch((ex) => { + console.error("Failed to process line", ex); + }), + ); + }); + } } diff --git a/src/state/recorder.ts b/src/state/recorder.ts index e2e3641..2971a4d 100644 --- a/src/state/recorder.ts +++ b/src/state/recorder.ts @@ -1,140 +1,162 @@ import { GameState } from "../logic/gamestate"; import { IWeaponCode } from "../weapons/weapon"; import { GameWorld } from "../world"; -import { StateRecordEntitySync, StateRecordHeader, StateRecordKind, StateRecordWormAction, StateRecordWormActionAim, StateRecordWormActionFire, StateRecordWormActionMove, StateRecordWormGameState, StateRecordWormSelectWeapon, StateWormAction } from "./model"; +import { + StateRecordEntitySync, + StateRecordHeader, + StateRecordKind, + StateRecordWormAction, + StateRecordWormActionAim, + StateRecordWormActionFire, + StateRecordWormActionMove, + StateRecordWormGameState, + StateRecordWormSelectWeapon, + StateWormAction, +} from "./model"; interface StateRecorderStore { - writeLine(data: Record): Promise; + writeLine(data: Record): Promise; } function hashCode(str: string) { - let hash = 0; - for (let i = 0, len = str.length; i < len; i++) { - const chr = str.charCodeAt(i); - hash = (hash << 5) - hash + chr; - hash |= 0; // Convert to 32bit integer - } - return hash; + let hash = 0; + for (let i = 0, len = str.length; i < len; i++) { + const chr = str.charCodeAt(i); + hash = (hash << 5) - hash + chr; + hash |= 0; // Convert to 32bit integer + } + return hash; } export class StateRecorder { - public static RecorderVersion = 2; - private recordIndex = 0; - private entHashes = new Map(); // uuid -> hash - constructor(private readonly gameWorld: GameWorld, private readonly gameState: GameState, private readonly store: StateRecorderStore) { - this.store.writeLine({ - index: this.recordIndex++, - data: {version: StateRecorder.RecorderVersion}, - kind: StateRecordKind.Header, - ts: performance.now().toString(), - } satisfies StateRecordHeader); - } + public static RecorderVersion = 2; + private recordIndex = 0; + private entHashes = new Map(); // uuid -> hash + constructor( + private readonly gameWorld: GameWorld, + private readonly gameState: GameState, + private readonly store: StateRecorderStore, + ) { + this.store.writeLine({ + index: this.recordIndex++, + data: { version: StateRecorder.RecorderVersion }, + kind: StateRecordKind.Header, + ts: performance.now().toString(), + } satisfies StateRecordHeader); + } - public syncEntityState() { - const stateToSend = []; - for (const entState of this.gameWorld.collectEntityState()) { - const newHash = hashCode(JSON.stringify(entState)); - if (this.entHashes.get(entState.uuid) !== newHash) { - stateToSend.push(entState); - } - this.entHashes.set(entState.uuid, newHash); - } - this.store.writeLine({ - index: this.recordIndex++, - data: { - entities: stateToSend, - }, - kind: StateRecordKind.EntitySync, - ts: performance.now().toString(), - } satisfies StateRecordEntitySync); + public syncEntityState() { + const stateToSend = []; + for (const entState of this.gameWorld.collectEntityState()) { + const newHash = hashCode(JSON.stringify(entState)); + if (this.entHashes.get(entState.uuid) !== newHash) { + stateToSend.push(entState); + } + this.entHashes.set(entState.uuid, newHash); } + this.store.writeLine({ + index: this.recordIndex++, + data: { + entities: stateToSend, + }, + kind: StateRecordKind.EntitySync, + ts: performance.now().toString(), + } satisfies StateRecordEntitySync); + } - public recordWormAction(worm: string, action: StateWormAction) { - this.store.writeLine({ - index: this.recordIndex++, - data: { - id: worm, - action, - }, - kind: StateRecordKind.WormAction, - ts: performance.now().toString(), - } satisfies StateRecordWormAction); - } + public recordWormAction(worm: string, action: StateWormAction) { + this.store.writeLine({ + index: this.recordIndex++, + data: { + id: worm, + action, + }, + kind: StateRecordKind.WormAction, + ts: performance.now().toString(), + } satisfies StateRecordWormAction); + } - public recordWormMove(worm: string, direction: "left"|"right", cycles: number) { - this.store.writeLine({ - index: this.recordIndex++, - data: { - id: worm, - cycles, - action: direction === "left" ? StateWormAction.MoveLeft : StateWormAction.MoveRight - }, - kind: StateRecordKind.WormActionMove, - ts: performance.now().toString(), - } satisfies StateRecordWormActionMove); - } + public recordWormMove( + worm: string, + direction: "left" | "right", + cycles: number, + ) { + this.store.writeLine({ + index: this.recordIndex++, + data: { + id: worm, + cycles, + action: + direction === "left" + ? StateWormAction.MoveLeft + : StateWormAction.MoveRight, + }, + kind: StateRecordKind.WormActionMove, + ts: performance.now().toString(), + } satisfies StateRecordWormActionMove); + } - public recordWormAim(worm: string, direction: "up"|"down", angle: number) { - this.store.writeLine({ - index: this.recordIndex++, - data: { - id: worm, - angle: angle.toString(), - dir: direction, - action: StateWormAction.Aim, - }, - kind: StateRecordKind.WormActionAim, - ts: performance.now().toString(), - } satisfies StateRecordWormActionAim); - } + public recordWormAim(worm: string, direction: "up" | "down", angle: number) { + this.store.writeLine({ + index: this.recordIndex++, + data: { + id: worm, + angle: angle.toString(), + dir: direction, + action: StateWormAction.Aim, + }, + kind: StateRecordKind.WormActionAim, + ts: performance.now().toString(), + } satisfies StateRecordWormActionAim); + } - public recordWormFire(worm: string, duration: number) { - this.store.writeLine({ - index: this.recordIndex++, - data: { - id: worm, - duration, - action: StateWormAction.Fire, - }, - kind: StateRecordKind.WormActionFire, - ts: performance.now().toString(), - } satisfies StateRecordWormActionFire); - } + public recordWormFire(worm: string, duration: number) { + this.store.writeLine({ + index: this.recordIndex++, + data: { + id: worm, + duration, + action: StateWormAction.Fire, + }, + kind: StateRecordKind.WormActionFire, + ts: performance.now().toString(), + } satisfies StateRecordWormActionFire); + } - public recordWormSelectWeapon(worm: string, weapon: IWeaponCode) { - this.store.writeLine({ - index: this.recordIndex++, - data: { - id: worm, - weapon: weapon, - }, - kind: StateRecordKind.WormSelectWeapon, - ts: performance.now().toString(), - } satisfies StateRecordWormSelectWeapon); - } + public recordWormSelectWeapon(worm: string, weapon: IWeaponCode) { + this.store.writeLine({ + index: this.recordIndex++, + data: { + id: worm, + weapon: weapon, + }, + kind: StateRecordKind.WormSelectWeapon, + ts: performance.now().toString(), + } satisfies StateRecordWormSelectWeapon); + } - public recordGameStare() { - const iteration = this.gameState.iteration; - const teams = this.gameState.getTeams(); - this.store.writeLine({ - index: this.recordIndex++, - data: { - iteration: iteration, - wind: this.gameState.currentWind, - teams: teams.map(t => ({ - name: t.name, - group: t.group, - playerUserId: t.playerUserId, - worms: t.worms.map(w => ({ - uuid: w.uuid, - name: w.name, - health: w.health, - maxHealth: w.maxHealth, - })) - })), - }, - kind: StateRecordKind.GameState, - ts: performance.now().toString(), - } satisfies StateRecordWormGameState); - } -} \ No newline at end of file + public recordGameStare() { + const iteration = this.gameState.iteration; + const teams = this.gameState.getTeams(); + this.store.writeLine({ + index: this.recordIndex++, + data: { + iteration: iteration, + wind: this.gameState.currentWind, + teams: teams.map((t) => ({ + name: t.name, + group: t.group, + playerUserId: t.playerUserId, + worms: t.worms.map((w) => ({ + uuid: w.uuid, + name: w.name, + health: w.health, + maxHealth: w.maxHealth, + })), + })), + }, + kind: StateRecordKind.GameState, + ts: performance.now().toString(), + } satisfies StateRecordWormGameState); + } +} diff --git a/src/terrain/index.ts b/src/terrain/index.ts index deec3b9..1409709 100644 --- a/src/terrain/index.ts +++ b/src/terrain/index.ts @@ -1,91 +1,125 @@ import { Vector2 } from "@dimforge/rapier2d-compat"; import { Rectangle } from "pixi.js"; -export function imageDataToTerrainBoundaries(boundaryX: number, boundaryY: number, imgData: ImageData): { boundaries: Vector2[], boundingBox: Rectangle} { - const boundingBox = new Rectangle(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, 0, 0); - const boundaries: Array = []; - const xBoundaryTracker= new Array(imgData.width); - const yBoundaryTracker = new Array(imgData.height); +export function imageDataToTerrainBoundaries( + boundaryX: number, + boundaryY: number, + imgData: ImageData, +): { boundaries: Vector2[]; boundingBox: Rectangle } { + const boundingBox = new Rectangle( + Number.MAX_SAFE_INTEGER, + Number.MAX_SAFE_INTEGER, + 0, + 0, + ); + const boundaries: Array = []; + const xBoundaryTracker = new Array(imgData.width); + const yBoundaryTracker = new Array(imgData.height); - const lengthOfOneRow = imgData.width*4; - for (let i = 0; i < imgData.data.length; i += 4) { - const x = (i % lengthOfOneRow) / 4; - const y = Math.ceil(i / lengthOfOneRow); - const realX = x + boundaryX; - const realY = y + boundaryY; - const [,,,a] = imgData.data.slice(i, i+4); + const lengthOfOneRow = imgData.width * 4; + for (let i = 0; i < imgData.data.length; i += 4) { + const x = (i % lengthOfOneRow) / 4; + const y = Math.ceil(i / lengthOfOneRow); + const realX = x + boundaryX; + const realY = y + boundaryY; + const [, , , a] = imgData.data.slice(i, i + 4); - if (x === 0) { - xBoundaryTracker[x] = false; - yBoundaryTracker[y] = false; - } - - if (a > 5) { - if (!xBoundaryTracker[x] || !yBoundaryTracker[y]) { - // This is to stop us from drawing straight lines down - // when we have a boundary, but I don't know why this works. - if (x > 1 && y > 1) { - boundaries.push(new Vector2(realX,realY)); - } - xBoundaryTracker[x] = true; - yBoundaryTracker[y] = true; + if (x === 0) { + xBoundaryTracker[x] = false; + yBoundaryTracker[y] = false; + } - boundingBox.x = Math.min(boundingBox.x, realX); - boundingBox.y = Math.min(boundingBox.y, realY); - boundingBox.width = Math.max(boundingBox.width, realX); - boundingBox.height = Math.max(boundingBox.height, realY); - } - } else if (a === 0) { - if (xBoundaryTracker[x] || yBoundaryTracker[y]) { - boundaries.push(new Vector2(realX,realY)); - xBoundaryTracker[x] = false; - yBoundaryTracker[y] = false; - } + if (a > 5) { + if (!xBoundaryTracker[x] || !yBoundaryTracker[y]) { + // This is to stop us from drawing straight lines down + // when we have a boundary, but I don't know why this works. + if (x > 1 && y > 1) { + boundaries.push(new Vector2(realX, realY)); } + xBoundaryTracker[x] = true; + yBoundaryTracker[y] = true; + + boundingBox.x = Math.min(boundingBox.x, realX); + boundingBox.y = Math.min(boundingBox.y, realY); + boundingBox.width = Math.max(boundingBox.width, realX); + boundingBox.height = Math.max(boundingBox.height, realY); + } + } else if (a === 0) { + if (xBoundaryTracker[x] || yBoundaryTracker[y]) { + boundaries.push(new Vector2(realX, realY)); + xBoundaryTracker[x] = false; + yBoundaryTracker[y] = false; + } } - boundingBox.width -= boundingBox.x; - boundingBox.height -= boundingBox.y; - return { - boundaries, - boundingBox, - }; + } + boundingBox.width -= boundingBox.x; + boundingBox.height -= boundingBox.y; + return { + boundaries, + boundingBox, + }; } export const QuadtreeCutoff = 4; -export function generateQuadTreeFromTerrain(boundaries: Vector2[], width: number, height: number, x: number, y: number): Rectangle[] { - function inner(boundaries: Vector2[], width: number, height: number, x: number, y: number): Rectangle[]|Rectangle { - // For performance, we just quad anything that's too small. - if (width < QuadtreeCutoff || height < QuadtreeCutoff) { - return new Rectangle(x,y,width,height); - } +export function generateQuadTreeFromTerrain( + boundaries: Vector2[], + width: number, + height: number, + x: number, + y: number, +): Rectangle[] { + function inner( + boundaries: Vector2[], + width: number, + height: number, + x: number, + y: number, + ): Rectangle[] | Rectangle { + // For performance, we just quad anything that's too small. + if (width < QuadtreeCutoff || height < QuadtreeCutoff) { + return new Rectangle(x, y, width, height); + } - // Are there any points within this quad? - const interestedBoundaries = boundaries.filter(v => v.x >= x && v.x < x + width && v.y >= y && v.y < y + height ); + // Are there any points within this quad? + const interestedBoundaries = boundaries.filter( + (v) => v.x >= x && v.x < x + width && v.y >= y && v.y < y + height, + ); - // No? Turn it into a quad and stop recursing. - if (interestedBoundaries.length === 0) { - return new Rectangle(x,y,width,height); - } + // No? Turn it into a quad and stop recursing. + if (interestedBoundaries.length === 0) { + return new Rectangle(x, y, width, height); + } - // Split the quad into 4 - const newWidth = Math.round(width / 2); - const newHeight = Math.round(height / 2); + // Split the quad into 4 + const newWidth = Math.round(width / 2); + const newHeight = Math.round(height / 2); - const rects: Rectangle[] = []; - for (const opts of [[false, false], [true, false], [false, true], [true, true]]) { - const newX = x + (opts[0] ? newWidth : 0); - const newY = y + (opts[1] ? newHeight : 0); - const newRects = inner(interestedBoundaries, newWidth, newHeight, newX, newY); - // For each inner quad, delete any that contain none of the terrain. - if (Array.isArray(newRects)) { - rects.push(...newRects); - } else if (boundaries.some(s => newRects.contains(s.x, s.y))) { - rects.push(newRects); - } - } - return rects; + const rects: Rectangle[] = []; + for (const opts of [ + [false, false], + [true, false], + [false, true], + [true, true], + ]) { + const newX = x + (opts[0] ? newWidth : 0); + const newY = y + (opts[1] ? newHeight : 0); + const newRects = inner( + interestedBoundaries, + newWidth, + newHeight, + newX, + newY, + ); + // For each inner quad, delete any that contain none of the terrain. + if (Array.isArray(newRects)) { + rects.push(...newRects); + } else if (boundaries.some((s) => newRects.contains(s.x, s.y))) { + rects.push(newRects); + } } - const result = inner(boundaries, width, height, x, y); - return Array.isArray(result) ? result : [result]; -} \ No newline at end of file + return rects; + } + const result = inner(boundaries, width, height, x, y); + return Array.isArray(result) ? result : [result]; +} diff --git a/src/text/toasts.ts b/src/text/toasts.ts index 278a607..19c4054 100644 --- a/src/text/toasts.ts +++ b/src/text/toasts.ts @@ -1,85 +1,78 @@ export const TurnStartText = [ - "GO GO GO $WormName of $TeamName", - "Look who's up, it's $WormName!", - "Let's see if $WormName has what it takes." + "GO GO GO $WormName of $TeamName", + "Look who's up, it's $WormName!", + "Let's see if $WormName has what it takes.", ]; export const TurnEndTextFall = [ - "Better dial 999, $WormName had a fall!", - "Accidental or insurance fraud? Either way, $TeamName is not cashing in this round.", - "Ouch, that's gotta hurt...their self esteem." + "Better dial 999, $WormName had a fall!", + "Accidental or insurance fraud? Either way, $TeamName is not cashing in this round.", + "Ouch, that's gotta hurt...their self esteem.", ]; - export const TeamKilledText = [ - "$TeamName has bitten the dusty dusty dirt", - "$TeamName is pushing up the daises", - "With $WormName gone, $TeamName is no more", - "BREAKING NEWS: $TeamName is history", + "$TeamName has bitten the dusty dusty dirt", + "$TeamName is pushing up the daises", + "With $WormName gone, $TeamName is no more", + "BREAKING NEWS: $TeamName is history", ]; export const TeamWinnerText = [ - "$TeamName has won. This calls for a celebration!", + "$TeamName has won. This calls for a celebration!", ]; export const GameDrawText = [ - "Oh what, a draw? How boring", - "You could have at least let the other team win? This sucks" + "Oh what, a draw? How boring", + "You could have at least let the other team win? This sucks", ]; - export const TurnEndTextOther = [ - "The developer failed to write a description here. How terrible!", + "The developer failed to write a description here. How terrible!", ]; -export const WeaponTimerText = [ - "Timer adjusted to $Time seconds.", -]; +export const WeaponTimerText = ["Timer adjusted to $Time seconds."]; export const WormDeathSinking = [ - "$WormName went for a one way scuba dive.", - "$WormName is fish bait now.", - "$WormName discovered that they do not have gills.", - "Sadly, $WormName did not return for air." + "$WormName went for a one way scuba dive.", + "$WormName is fish bait now.", + "$WormName discovered that they do not have gills.", + "Sadly, $WormName did not return for air.", ]; export const WormDeathGeneric = [ - "$WormName is providing to the funeral industry now.", -] + "$WormName is providing to the funeral industry now.", +]; -export const FireResultHitSelf = [ - "Hmm, you probably didn't want to do that?", -] -export const FireResultKilledSelf = [ - "$WormName has won the darwin award!.", -] +export const FireResultHitSelf = ["Hmm, you probably didn't want to do that?"]; +export const FireResultKilledSelf = ["$WormName has won the darwin award!."]; -export const FireResultHitEnemy = [ - "$WormName takes a chunk out of the enemy.", -] +export const FireResultHitEnemy = ["$WormName takes a chunk out of the enemy."]; export const FireResultKilledEnemy = [ - "$WormName levels out the playing field.", -] + "$WormName levels out the playing field.", +]; export const FireResultKilledOwnTeam = [ - "$WormName appears to have gone colourblind.. that's *YOUR* team!", -] + "$WormName appears to have gone colourblind.. that's *YOUR* team!", +]; export const FireResultHitOwnTeam = [ - "$TeamName apparently is more interested in hurting themselves.", -] + "$TeamName apparently is more interested in hurting themselves.", +]; export const FireResultMiss = [ - "Did $WormName forget their glasses?", - "Absolutely no idea what was meant to happen there...", - "$TeamName looking to disprove the the old saying about monkeys and typewriters", - "$TeamName may be entering their pacifist arc." + "Did $WormName forget their glasses?", + "Absolutely no idea what was meant to happen there...", + "$TeamName looking to disprove the the old saying about monkeys and typewriters", + "$TeamName may be entering their pacifist arc.", ]; -export function templateRandomText(options: string[], parameters: Record = {}) { - let chooseOption = options[Math.floor(Math.random()*options.length)]; - for (const [key, value] of Object.entries(parameters)) { - chooseOption = chooseOption.replaceAll("$" + key, value); - } - return chooseOption; -} \ No newline at end of file +export function templateRandomText( + options: string[], + parameters: Record = {}, +) { + let chooseOption = options[Math.floor(Math.random() * options.length)]; + for (const [key, value] of Object.entries(parameters)) { + chooseOption = chooseOption.replaceAll("$" + key, value); + } + return chooseOption; +} diff --git a/src/utils/coodinate.ts b/src/utils/coodinate.ts index 1ca7c38..1b452f5 100644 --- a/src/utils/coodinate.ts +++ b/src/utils/coodinate.ts @@ -2,66 +2,66 @@ import { Vector2 } from "@dimforge/rapier2d-compat"; import { PIXELS_PER_METER } from "../world"; export class MetersValue { - static fromPixels(pixels: number) { - return new MetersValue(pixels / PIXELS_PER_METER); - } - constructor (public value: number) { - - } - - set pixels(value: number) { - this.value = value / PIXELS_PER_METER; - } - - get pixels() { - return this.value * PIXELS_PER_METER; - } - - public valueOf() { - return this.value; - } - - public toString() { - return `{${this.value}m, ${this.value}px}` - } + static fromPixels(pixels: number) { + return new MetersValue(pixels / PIXELS_PER_METER); + } + constructor(public value: number) {} + + set pixels(value: number) { + this.value = value / PIXELS_PER_METER; + } + + get pixels() { + return this.value * PIXELS_PER_METER; + } + + public valueOf() { + return this.value; + } + + public toString() { + return `{${this.value}m, ${this.value}px}`; + } } export class Coordinate { - - static fromScreen(screenX: number, screenY: number) { - return new Coordinate(screenX / PIXELS_PER_METER, screenY / PIXELS_PER_METER); - } - - - static fromWorld(vec: Vector2) { - return new Coordinate(vec.x, vec.y); - } - - constructor(public worldX: number, public worldY: number) { } - - - toWorldVector(): Vector2 { - return new Vector2(this.worldX, this.worldY); - } - - - get screenX() { - return this.worldX * PIXELS_PER_METER; - } - - set screenX(value: number) { - this.worldX = value / PIXELS_PER_METER; - } - - get screenY() { - return this.worldY * PIXELS_PER_METER; - } - - set screenY(value: number) { - this.worldX = value / PIXELS_PER_METER; - } - - public toString() { - return `Coodinate {wx: ${this.worldX} wy:${this.worldY}} {sx: ${this.screenX}, sy: ${this.screenY}}` - } -} \ No newline at end of file + static fromScreen(screenX: number, screenY: number) { + return new Coordinate( + screenX / PIXELS_PER_METER, + screenY / PIXELS_PER_METER, + ); + } + + static fromWorld(vec: Vector2) { + return new Coordinate(vec.x, vec.y); + } + + constructor( + public worldX: number, + public worldY: number, + ) {} + + toWorldVector(): Vector2 { + return new Vector2(this.worldX, this.worldY); + } + + get screenX() { + return this.worldX * PIXELS_PER_METER; + } + + set screenX(value: number) { + this.worldX = value / PIXELS_PER_METER; + } + + get screenY() { + return this.worldY * PIXELS_PER_METER; + } + + set screenY(value: number) { + this.worldX = value / PIXELS_PER_METER; + } + + public toString() { + return `Coodinate {wx: ${this.worldX} wy:${this.worldY}} {sx: ${this.screenX}, sy: ${this.screenY}}`; + } +} diff --git a/src/utils/damage.ts b/src/utils/damage.ts index 7d407ab..c002207 100644 --- a/src/utils/damage.ts +++ b/src/utils/damage.ts @@ -8,31 +8,52 @@ import { Explosion, ExplosionsOptions } from "../entities/explosion"; import { Container } from "pixi.js"; import { OnDamageOpts } from "../entities/entity"; -interface Opts extends Partial, OnDamageOpts { - -} +interface Opts extends Partial, OnDamageOpts {} export function handleDamageInRadius( - gameWorld: GameWorld, parent: Container, point: Vector2, - radius: MetersValue, opts: Opts, ignoreCollider?: Collider, ownerWorm?: WormInstance): WeaponFireResult[] { - // Detect if anything is around us. - const explosionCollidesWith = gameWorld.checkCollision(new Coordinate(point.x, point.y), radius, ignoreCollider); - const fireResults = new Set(); - for (const element of explosionCollidesWith) { - element.onDamage?.(point, radius, opts); - // Dependency issue, Worm depends on us. - if ('health' in element) { - const worm = element as unknown as Worm; - const killed = element.health === 0; - if (worm.wormIdent.uuid === ownerWorm?.uuid) { - fireResults.add(killed ? WeaponFireResult.KilledSelf : WeaponFireResult.HitSelf); - } else if (worm.wormIdent.team.group === ownerWorm?.team.group) { - fireResults.add(killed ? WeaponFireResult.KilledOwnTeam : WeaponFireResult.HitSelf); - } else if (worm.wormIdent.team.group !== ownerWorm?.team.group) { - fireResults.add(killed ? WeaponFireResult.KilledEnemy : WeaponFireResult.HitEnemy); - } - } + gameWorld: GameWorld, + parent: Container, + point: Vector2, + radius: MetersValue, + opts: Opts, + ignoreCollider?: Collider, + ownerWorm?: WormInstance, +): WeaponFireResult[] { + // Detect if anything is around us. + const explosionCollidesWith = gameWorld.checkCollision( + new Coordinate(point.x, point.y), + radius, + ignoreCollider, + ); + const fireResults = new Set(); + for (const element of explosionCollidesWith) { + element.onDamage?.(point, radius, opts); + // Dependency issue, Worm depends on us. + if ("health" in element) { + const worm = element as unknown as Worm; + const killed = element.health === 0; + if (worm.wormIdent.uuid === ownerWorm?.uuid) { + fireResults.add( + killed ? WeaponFireResult.KilledSelf : WeaponFireResult.HitSelf, + ); + } else if (worm.wormIdent.team.group === ownerWorm?.team.group) { + fireResults.add( + killed ? WeaponFireResult.KilledOwnTeam : WeaponFireResult.HitSelf, + ); + } else if (worm.wormIdent.team.group !== ownerWorm?.team.group) { + fireResults.add( + killed ? WeaponFireResult.KilledEnemy : WeaponFireResult.HitEnemy, + ); + } } - gameWorld.addEntity(Explosion.create(parent, {x: point.x*PIXELS_PER_METER, y: point.y*PIXELS_PER_METER}, radius, opts)); - return fireResults.size === 0 ? [WeaponFireResult.NoHit] : [...fireResults]; -} \ No newline at end of file + } + gameWorld.addEntity( + Explosion.create( + parent, + { x: point.x * PIXELS_PER_METER, y: point.y * PIXELS_PER_METER }, + radius, + opts, + ), + ); + return fireResults.size === 0 ? [WeaponFireResult.NoHit] : [...fireResults]; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 4b54e1c..eea9d1d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,32 +1,35 @@ import { Vector, Vector2 } from "@dimforge/rapier2d-compat"; export { Coordinate, MetersValue } from "./coodinate"; - - -export function magnitude(v: Vector) { - return Math.sqrt(Math.pow(Math.abs(v.x), 2) + Math.pow(Math.abs(v.y), 2)); +export function magnitude(v: Vector) { + return Math.sqrt(Math.pow(Math.abs(v.x), 2) + Math.pow(Math.abs(v.y), 2)); } export function add(a: Vector, b: Vector) { - return new Vector2(a.x + b.x, a.y + b.y); + return new Vector2(a.x + b.x, a.y + b.y); } export function sub(a: Vector, b: Vector) { - return new Vector2(a.x - b.x, a.y - b.y); + return new Vector2(a.x - b.x, a.y - b.y); } -export function mult(a: Vector, b: Vector|number) { - const bVec = typeof b === "number" ? { x: b, y: b} : b; - return new Vector2(a.x * bVec.x, a.y * bVec.y); +export function mult(a: Vector, b: Vector | number) { + const bVec = typeof b === "number" ? { x: b, y: b } : b; + return new Vector2(a.x * bVec.x, a.y * bVec.y); } -export function pointOnRadius(originX: number, originY: number, radians: number, radius: number): Vector2 { - const x = Math.cos(radians)*radius; - const y = Math.sin(radians)*radius; - return new Vector2(originX + x, originY + y); +export function pointOnRadius( + originX: number, + originY: number, + radians: number, + radius: number, +): Vector2 { + const x = Math.cos(radians) * radius; + const y = Math.sin(radians) * radius; + return new Vector2(originX + x, originY + y); } -export function angleForVector({x,y}: Vector) { - // https://www.omnicalculator.com/math/vector-direction - return Math.atan2(y, x); -} \ No newline at end of file +export function angleForVector({ x, y }: Vector) { + // https://www.omnicalculator.com/math/vector-direction + return Math.atan2(y, x); +} diff --git a/src/weapons/bazooka.ts b/src/weapons/bazooka.ts index 8bf3681..ce3180f 100644 --- a/src/weapons/bazooka.ts +++ b/src/weapons/bazooka.ts @@ -12,29 +12,32 @@ import icon from "../assets/bazooka.png"; let fireSound: Sound; export const WeaponBazooka: IWeaponDefiniton = { - name: "Bazooka", - code: IWeaponCode.Bazooka, - icon, - maxDuration: 80, - timerAdjustable: false, - showTargetGuide: true, - loadAssets(assets: AssetPack) { - fireSound = assets.sounds.bazookafire; - }, - fireFn(parent: Container, world: GameWorld, worm: Worm, opts: FireOpts) { - if (opts.duration === undefined) { - throw Error('Duration expected but not given'); - } - if (opts.angle === undefined) { - throw Error('Angle expected but not given'); - } - fireSound.play(); - const forceComponent = Math.log(opts.duration/10)*3; - const x = forceComponent*Math.cos(opts.angle); - const y = forceComponent*Math.sin(opts.angle); - const force = mult(new Vector2(1.5 * forceComponent, forceComponent), { x, y }); - // TODO: Refactor ALL OF THIS - const position = Coordinate.fromWorld(add(worm.position, {x, y: -0.5})); - return BazookaShell.create(parent, world, position, force, worm.wormIdent); - }, -} \ No newline at end of file + name: "Bazooka", + code: IWeaponCode.Bazooka, + icon, + maxDuration: 80, + timerAdjustable: false, + showTargetGuide: true, + loadAssets(assets: AssetPack) { + fireSound = assets.sounds.bazookafire; + }, + fireFn(parent: Container, world: GameWorld, worm: Worm, opts: FireOpts) { + if (opts.duration === undefined) { + throw Error("Duration expected but not given"); + } + if (opts.angle === undefined) { + throw Error("Angle expected but not given"); + } + fireSound.play(); + const forceComponent = Math.log(opts.duration / 10) * 3; + const x = forceComponent * Math.cos(opts.angle); + const y = forceComponent * Math.sin(opts.angle); + const force = mult(new Vector2(1.5 * forceComponent, forceComponent), { + x, + y, + }); + // TODO: Refactor ALL OF THIS + const position = Coordinate.fromWorld(add(worm.position, { x, y: -0.5 })); + return BazookaShell.create(parent, world, position, force, worm.wormIdent); + }, +}; diff --git a/src/weapons/grenade.ts b/src/weapons/grenade.ts index 0feb758..2318100 100644 --- a/src/weapons/grenade.ts +++ b/src/weapons/grenade.ts @@ -8,28 +8,38 @@ import { add, Coordinate, mult } from "../utils"; import icon from "../assets/grenade.png"; export const WeaponGrenade: IWeaponDefiniton = { - name: "Grenade", - icon, - code: IWeaponCode.Grenade, - maxDuration: 50, - timerAdjustable: true, - showTargetGuide: true, - fireFn(parent: Container, world: GameWorld, worm: Worm, opts: FireOpts) { - if (!opts.duration) { - throw Error('Duration expected but not given'); - } - if (!opts.timer) { - throw Error('Timer expected but not given'); - } - if (opts.angle === undefined) { - throw Error('Angle expected but not given'); - } - const forceComponent = opts.duration/8; - const x = forceComponent*Math.cos(opts.angle); - const y = forceComponent*Math.sin(opts.angle); - const force = mult(new Vector2(1 * forceComponent, forceComponent), { x, y }); - // TODO: Refactor ALL OF THIS - const position = Coordinate.fromWorld(add(worm.position, {x, y: -0.3})); - return Grenade.create(parent, world, position, force, opts.timer, worm.wormIdent); - }, -} \ No newline at end of file + name: "Grenade", + icon, + code: IWeaponCode.Grenade, + maxDuration: 50, + timerAdjustable: true, + showTargetGuide: true, + fireFn(parent: Container, world: GameWorld, worm: Worm, opts: FireOpts) { + if (!opts.duration) { + throw Error("Duration expected but not given"); + } + if (!opts.timer) { + throw Error("Timer expected but not given"); + } + if (opts.angle === undefined) { + throw Error("Angle expected but not given"); + } + const forceComponent = opts.duration / 8; + const x = forceComponent * Math.cos(opts.angle); + const y = forceComponent * Math.sin(opts.angle); + const force = mult(new Vector2(1 * forceComponent, forceComponent), { + x, + y, + }); + // TODO: Refactor ALL OF THIS + const position = Coordinate.fromWorld(add(worm.position, { x, y: -0.3 })); + return Grenade.create( + parent, + world, + position, + force, + opts.timer, + worm.wormIdent, + ); + }, +}; diff --git a/src/weapons/index.ts b/src/weapons/index.ts index 170c043..84573c4 100644 --- a/src/weapons/index.ts +++ b/src/weapons/index.ts @@ -9,19 +9,19 @@ export { WeaponBazooka } from "./bazooka"; export { WeaponShotgun }; export function getDefinitionForCode(code: IWeaponCode) { - switch (code) { - case IWeaponCode.Bazooka: - return WeaponBazooka; - case IWeaponCode.Grenade: - return WeaponGrenade; - case IWeaponCode.Shotgun: - return WeaponShotgun; - default: - throw Error('Unknown weapon code'); - } + switch (code) { + case IWeaponCode.Bazooka: + return WeaponBazooka; + case IWeaponCode.Grenade: + return WeaponGrenade; + case IWeaponCode.Shotgun: + return WeaponShotgun; + default: + throw Error("Unknown weapon code"); + } } export function readAssetsForWeapons(assets: AssetPack): void { - WeaponShotgun.loadAssets?.(assets); - WeaponBazooka.loadAssets?.(assets); -} \ No newline at end of file + WeaponShotgun.loadAssets?.(assets); + WeaponBazooka.loadAssets?.(assets); +} diff --git a/src/weapons/shotgun.ts b/src/weapons/shotgun.ts index cca3fd2..492b70d 100644 --- a/src/weapons/shotgun.ts +++ b/src/weapons/shotgun.ts @@ -1,5 +1,10 @@ import { Container } from "pixi.js"; -import { FireOpts, IWeaponCode, IWeaponDefiniton, WeaponFireResult } from "./weapon"; +import { + FireOpts, + IWeaponCode, + IWeaponDefiniton, + WeaponFireResult, +} from "./weapon"; import { Worm } from "../entities/playable/worm"; import { GameWorld } from "../world"; import { Coordinate, MetersValue } from "../utils"; @@ -13,45 +18,51 @@ const radius = new MetersValue(1.5); let fireSound: Sound; const WeaponShotgun: IWeaponDefiniton = { - name: "Shotgun", - iconWidth: 64, - icon, - code: IWeaponCode.Shotgun, - timerAdjustable: false, - showTargetGuide: true, - shots: 2, - loadAssets(assets: AssetPack) { - fireSound = assets.sounds.shotgun; - }, - fireFn(parent: Container, world: GameWorld, worm: Worm, opts: FireOpts) { - if (opts.angle === undefined) { - throw Error('Angle expected but not given'); - } - fireSound.play(); - const x = Math.cos(opts.angle); - const y = Math.sin(opts.angle); - const hit = world.rayTrace( - Coordinate.fromWorld(worm.position), - {x,y}, - worm.collider, - ); - if (hit) { - const result = handleDamageInRadius( - world, parent, hit.hitLoc.toWorldVector(), radius, - { - shrapnelMax: 15, - shrapnelMin: 10, - maxDamage: 25, - playSound: false, - }, undefined, worm.wormIdent); - return { - onFireResult: Promise.resolve(result) - } - } - return { - onFireResult: Promise.resolve([WeaponFireResult.NoHit]) - } - }, -} + name: "Shotgun", + iconWidth: 64, + icon, + code: IWeaponCode.Shotgun, + timerAdjustable: false, + showTargetGuide: true, + shots: 2, + loadAssets(assets: AssetPack) { + fireSound = assets.sounds.shotgun; + }, + fireFn(parent: Container, world: GameWorld, worm: Worm, opts: FireOpts) { + if (opts.angle === undefined) { + throw Error("Angle expected but not given"); + } + fireSound.play(); + const x = Math.cos(opts.angle); + const y = Math.sin(opts.angle); + const hit = world.rayTrace( + Coordinate.fromWorld(worm.position), + { x, y }, + worm.collider, + ); + if (hit) { + const result = handleDamageInRadius( + world, + parent, + hit.hitLoc.toWorldVector(), + radius, + { + shrapnelMax: 15, + shrapnelMin: 10, + maxDamage: 25, + playSound: false, + }, + undefined, + worm.wormIdent, + ); + return { + onFireResult: Promise.resolve(result), + }; + } + return { + onFireResult: Promise.resolve([WeaponFireResult.NoHit]), + }; + }, +}; -export default WeaponShotgun; \ No newline at end of file +export default WeaponShotgun; diff --git a/src/weapons/weapon.ts b/src/weapons/weapon.ts index 7245d1d..9eadfde 100644 --- a/src/weapons/weapon.ts +++ b/src/weapons/weapon.ts @@ -5,47 +5,52 @@ import { GameWorld } from "../world"; import { AssetPack } from "../assets"; export enum IWeaponCode { - Grenade, - Bazooka, - Shotgun, + Grenade, + Bazooka, + Shotgun, } export interface FireOpts { - duration?: number, - timer?: number, - angle?: number, + duration?: number; + timer?: number; + angle?: number; } export enum WeaponFireResult { - // In order of importance for toasts - NoHit, // Never appears if the others appear. - KilledOwnTeam, - KilledSelf, - KilledEnemy, - HitEnemy, - HitOwnTeam, - HitSelf, + // In order of importance for toasts + NoHit, // Never appears if the others appear. + KilledOwnTeam, + KilledSelf, + KilledEnemy, + HitEnemy, + HitOwnTeam, + HitSelf, } export interface IWeaponDefiniton { - name: string, - code: IWeaponCode, - icon: string, - iconWidth?: number, - /** - * How long can the fire button be held down for? - */ - maxDuration?: number, - /** - * Can the timer on the weapon be adjusted? - */ - timerAdjustable?: boolean, - showTargetGuide?: boolean, + name: string; + code: IWeaponCode; + icon: string; + iconWidth?: number; + /** + * How long can the fire button be held down for? + */ + maxDuration?: number; + /** + * Can the timer on the weapon be adjusted? + */ + timerAdjustable?: boolean; + showTargetGuide?: boolean; - /** - * How many shots can the player take. Defaults to 1. - */ - shots?: number; - fireFn: (parent: Container, world: GameWorld, worm: Worm, opts: FireOpts) => IWeaponEntity, - loadAssets?: (assetPack: AssetPack) => void, + /** + * How many shots can the player take. Defaults to 1. + */ + shots?: number; + fireFn: ( + parent: Container, + world: GameWorld, + worm: Worm, + opts: FireOpts, + ) => IWeaponEntity; + loadAssets?: (assetPack: AssetPack) => void; } diff --git a/src/world.ts b/src/world.ts index 4992a46..70e0333 100644 --- a/src/world.ts +++ b/src/world.ts @@ -1,42 +1,56 @@ import { IGameEntity, IPhysicalEntity } from "./entities/entity"; import { Ticker, UPDATE_PRIORITY } from "pixi.js"; -import { Ball, Collider, ColliderDesc, EventQueue, QueryFilterFlags, Ray, RigidBody, RigidBodyDesc, Shape, Vector2, World } from "@dimforge/rapier2d-compat"; +import { + Ball, + Collider, + ColliderDesc, + EventQueue, + QueryFilterFlags, + Ray, + RigidBody, + RigidBodyDesc, + Shape, + Vector2, + World, +} from "@dimforge/rapier2d-compat"; import { Coordinate, MetersValue } from "./utils/coodinate"; import { add, mult } from "./utils"; import type { PhysicsEntity } from "./entities/phys/physicsEntity"; import { RecordedEntityState } from "./state/model"; import Logger from "./log"; -const logger = new Logger('World'); +const logger = new Logger("World"); /** * Utility class holding the matterjs composite and entity map. */ export interface RapierPhysicsObject { - collider: Collider; - body: RigidBody + collider: Collider; + body: RigidBody; } - export const PIXELS_PER_METER = 20; export const MAX_WIND = 10; export enum CollisionGroups { - Terrain = 1, // 0001 - WorldObjects = 2, //0010 - Player = 4, + Terrain = 1, // 0001 + WorldObjects = 2, //0010 + Player = 4, } -export function collisionGroupBitmask(groups: CollisionGroups|CollisionGroups[], collides: CollisionGroups|CollisionGroups[]) { - // https://rapier.rs/docs/user_guides/javascript/colliders/#collision-groups-and-solver-groups - groups = Array.isArray(groups) ? groups : [groups]; - collides = Array.isArray(collides) ? groups :[collides]; +export function collisionGroupBitmask( + groups: CollisionGroups | CollisionGroups[], + collides: CollisionGroups | CollisionGroups[], +) { + // https://rapier.rs/docs/user_guides/javascript/colliders/#collision-groups-and-solver-groups + groups = Array.isArray(groups) ? groups : [groups]; + collides = Array.isArray(collides) ? groups : [collides]; - const groupsInt = groups.reduce((o, c) => o + c) << 16; - const collidesInt = collides.reduce((o, c) => o + c); + const groupsInt = groups.reduce((o, c) => o + c) << 16; + const collidesInt = collides.reduce((o, c) => o + c); - return groupsInt + collidesInt; + return groupsInt + collidesInt; } /** @@ -44,208 +58,256 @@ export function collisionGroupBitmask(groups: CollisionGroups|CollisionGroups[], * physics operations. */ export class GameWorld { - public readonly bodyEntityMap = new Map(); - public readonly entities = new Map(); - private readonly eventQueue = new EventQueue(true); - private _wind = 0; + public readonly bodyEntityMap = new Map(); + public readonly entities = new Map(); + private readonly eventQueue = new EventQueue(true); + private _wind = 0; - get wind() { - return this._wind; - } - - constructor(public readonly rapierWorld: World, public readonly ticker: Ticker) { - - } + get wind() { + return this._wind; + } - public setWind(windSpeed: number) { - this._wind = windSpeed; - } + constructor( + public readonly rapierWorld: World, + public readonly ticker: Ticker, + ) {} - public areEntitiesMoving() { - for (const e of this.bodyEntityMap.values()) { - if (!e.destroyed && e.body?.isEnabled?.() && e.body?.isDynamic?.() && e.body?.isMoving?.()) { - return true; - } - } - return false; - } + public setWind(windSpeed: number) { + this._wind = windSpeed; + } - public step() { - this.rapierWorld.step(this.eventQueue); - this.eventQueue.drainCollisionEvents((collider1, collider2, started) => { - if (started) { - this.onCollision( - this.rapierWorld.getCollider(collider1), - this.rapierWorld.getCollider(collider2) - ); - } - }); - this.eventQueue.drainContactForceEvents((event) => { - logger.verbose('contactForceEvents', event); - }); + public areEntitiesMoving() { + for (const e of this.bodyEntityMap.values()) { + if ( + !e.destroyed && + e.body?.isEnabled?.() && + e.body?.isDynamic?.() && + e.body?.isMoving?.() + ) { + return true; + } } + return false; + } - private onCollision(collider1: Collider, collider2: Collider) { - const [entA, entB] = [ this.bodyEntityMap.get(collider1.handle), this.bodyEntityMap.get(collider2.handle)]; - - if (!entA || !entB) { - console.warn(`Untracked collision between ${collider1.handle} (${entA}) and ${collider2.handle} (${entB})`); - return; - } - - const shapeColA = collider1.contactCollider(collider2, 4); - - if (!shapeColA) { - console.warn(`Collision contactCollider failed after onCollision call for ${entA} and ${entB}`) - return; - } + public step() { + this.rapierWorld.step(this.eventQueue); + this.eventQueue.drainCollisionEvents((collider1, collider2, started) => { + if (started) { + this.onCollision( + this.rapierWorld.getCollider(collider1), + this.rapierWorld.getCollider(collider2), + ); + } + }); + this.eventQueue.drainContactForceEvents((event) => { + logger.verbose("contactForceEvents", event); + }); + } + private onCollision(collider1: Collider, collider2: Collider) { + const [entA, entB] = [ + this.bodyEntityMap.get(collider1.handle), + this.bodyEntityMap.get(collider2.handle), + ]; - entA.onCollision?.(entB, shapeColA.point1); - entB.onCollision?.(entA, shapeColA.point2); + if (!entA || !entB) { + console.warn( + `Untracked collision between ${collider1.handle} (${entA}) and ${collider2.handle} (${entB})`, + ); + return; } - /** - * Add an entity to the world. If the entity is coming from a remote source, provide the uuid. - * @param entity - * @param uuid - * @returns - */ - public addEntity(entity: T, uuid?: string): T { - if ([...this.entities.values()].includes(entity)) { - console.warn(`Tried to add entity twice to game world`, entity); - return entity; - } - const entUuid = uuid ?? globalThis.crypto.randomUUID(); - this.entities.set(entUuid, entity); - const tickerFn = (dt: Ticker) => { - if (entity.destroyed) { - this.ticker.remove(tickerFn); - this.entities.delete(entUuid); - return; - } - entity.update?.(dt.deltaTime); - }; - this.ticker.add(tickerFn, undefined, entity.priority ? entity.priority : UPDATE_PRIORITY.LOW); - return entity; - } + const shapeColA = collider1.contactCollider(collider2, 4); - public createRigidBodyCollider(colliderDesc: ColliderDesc, rigidBodyDesc: RigidBodyDesc): RapierPhysicsObject { - const body = this.rapierWorld.createRigidBody(rigidBodyDesc); - const collider = this.rapierWorld.createCollider( - colliderDesc, - body - ); - return { body, collider }; + if (!shapeColA) { + console.warn( + `Collision contactCollider failed after onCollision call for ${entA} and ${entB}`, + ); + return; } - public addBody(entity: T, ...colliders: Collider[]) { - if (colliders.length === 0) { - throw Error('Must provide at least one collider'); - } - colliders.forEach(collider => { - if (this.bodyEntityMap.has(collider.handle)) { - console.warn(`Tried to add collider entity twice to game world`, collider.handle, entity); - return; - } - this.bodyEntityMap.set(collider.handle, entity); - }); - } + entA.onCollision?.(entB, shapeColA.point1); + entB.onCollision?.(entA, shapeColA.point2); + } - removeBody(obj: RapierPhysicsObject) { - this.rapierWorld.removeCollider(obj.collider, false); - this.rapierWorld.removeRigidBody(obj.body); - this.bodyEntityMap.delete(obj.collider.handle); + /** + * Add an entity to the world. If the entity is coming from a remote source, provide the uuid. + * @param entity + * @param uuid + * @returns + */ + public addEntity(entity: T, uuid?: string): T { + if ([...this.entities.values()].includes(entity)) { + console.warn(`Tried to add entity twice to game world`, entity); + return entity; } + const entUuid = uuid ?? globalThis.crypto.randomUUID(); + this.entities.set(entUuid, entity); + const tickerFn = (dt: Ticker) => { + if (entity.destroyed) { + this.ticker.remove(tickerFn); + this.entities.delete(entUuid); + return; + } + entity.update?.(dt.deltaTime); + }; + this.ticker.add( + tickerFn, + undefined, + entity.priority ? entity.priority : UPDATE_PRIORITY.LOW, + ); + return entity; + } - removeEntity(entity: IGameEntity) { - const key = [...this.entities.entries()].find(([_k,v]) => v === entity)?.[0]; - if (!key) { - throw Error('Entity not found in world'); - } - this.entities.delete(key); + public createRigidBodyCollider( + colliderDesc: ColliderDesc, + rigidBodyDesc: RigidBodyDesc, + ): RapierPhysicsObject { + const body = this.rapierWorld.createRigidBody(rigidBodyDesc); + const collider = this.rapierWorld.createCollider(colliderDesc, body); + return { body, collider }; + } + + public addBody( + entity: T, + ...colliders: Collider[] + ) { + if (colliders.length === 0) { + throw Error("Must provide at least one collider"); } + colliders.forEach((collider) => { + if (this.bodyEntityMap.has(collider.handle)) { + console.warn( + `Tried to add collider entity twice to game world`, + collider.handle, + entity, + ); + return; + } + this.bodyEntityMap.set(collider.handle, entity); + }); + } + removeBody(obj: RapierPhysicsObject) { + this.rapierWorld.removeCollider(obj.collider, false); + this.rapierWorld.removeRigidBody(obj.body); + this.bodyEntityMap.delete(obj.collider.handle); + } - public pointInAnyObject(position: Coordinate): boolean { - // Ensure a unique set of results. - let found = false; - this.rapierWorld.intersectionsWithPoint( - new Vector2(position.worldX, position.worldY), - () => { - found = true; - return false; - }, - QueryFilterFlags.EXCLUDE_SENSORS, - ); - return found; + removeEntity(entity: IGameEntity) { + const key = [...this.entities.entries()].find( + ([_k, v]) => v === entity, + )?.[0]; + if (!key) { + throw Error("Entity not found in world"); } + this.entities.delete(key); + } - public checkCollisionShape(position: Coordinate, shape: Shape, ownCollier: Collider): {collider: Collider, entity: IPhysicalEntity}[] { - // Ensure a unique set of results. - const results = new Array<{collider: Collider, entity: IPhysicalEntity}>(); - this.rapierWorld.intersectionsWithShape( - new Vector2(position.worldX, position.worldY), - 0, - shape, - (collider) => { - if (collider.handle !== ownCollier.handle) { - const entity = this.bodyEntityMap.get(collider.handle); - if (entity) { - results.push({entity, collider}); - } - } - return true; - }, - ); - return [...results]; - } + public pointInAnyObject(position: Coordinate): boolean { + // Ensure a unique set of results. + let found = false; + this.rapierWorld.intersectionsWithPoint( + new Vector2(position.worldX, position.worldY), + () => { + found = true; + return false; + }, + QueryFilterFlags.EXCLUDE_SENSORS, + ); + return found; + } - public checkCollision(position: Coordinate, radius: number|MetersValue, ownCollier?: Collider): IPhysicalEntity[] { - // Ensure a unique set of results. - const results = new Set(); - this.rapierWorld.intersectionsWithShape( - new Vector2(position.worldX, position.worldY), - 0, - new Ball(radius.valueOf()), - (collider) => { - if (!ownCollier || collider.handle !== ownCollier.handle) { - const entity = this.bodyEntityMap.get(collider.handle); - if (entity) { - results.add(entity); - } - } - return true; - }, - ); - return [...results]; - } + public checkCollisionShape( + position: Coordinate, + shape: Shape, + ownCollier: Collider, + ): { collider: Collider; entity: IPhysicalEntity }[] { + // Ensure a unique set of results. + const results = new Array<{ + collider: Collider; + entity: IPhysicalEntity; + }>(); + this.rapierWorld.intersectionsWithShape( + new Vector2(position.worldX, position.worldY), + 0, + shape, + (collider) => { + if (collider.handle !== ownCollier.handle) { + const entity = this.bodyEntityMap.get(collider.handle); + if (entity) { + results.push({ entity, collider }); + } + } + return true; + }, + ); + return [...results]; + } - public rayTrace(position: Coordinate, direction: Vector2, ignore: Collider): {entity: IPhysicalEntity, hitLoc: Coordinate}|null { - const hit = this.rapierWorld.castRay(new Ray(position.toWorldVector(), direction), 1000, true, undefined, undefined, ignore); - if (hit?.collider) { - const entity = this.bodyEntityMap.get(hit.collider.handle); - if (!entity) { - throw new Error('Hit collider but no mapped entity'); - } - return { - entity, - hitLoc: Coordinate.fromWorld(add(position.toWorldVector(), mult(direction, hit.timeOfImpact))), - }; + public checkCollision( + position: Coordinate, + radius: number | MetersValue, + ownCollier?: Collider, + ): IPhysicalEntity[] { + // Ensure a unique set of results. + const results = new Set(); + this.rapierWorld.intersectionsWithShape( + new Vector2(position.worldX, position.worldY), + 0, + new Ball(radius.valueOf()), + (collider) => { + if (!ownCollier || collider.handle !== ownCollier.handle) { + const entity = this.bodyEntityMap.get(collider.handle); + if (entity) { + results.add(entity); + } } - return null; + return true; + }, + ); + return [...results]; + } + + public rayTrace( + position: Coordinate, + direction: Vector2, + ignore: Collider, + ): { entity: IPhysicalEntity; hitLoc: Coordinate } | null { + const hit = this.rapierWorld.castRay( + new Ray(position.toWorldVector(), direction), + 1000, + true, + undefined, + undefined, + ignore, + ); + if (hit?.collider) { + const entity = this.bodyEntityMap.get(hit.collider.handle); + if (!entity) { + throw new Error("Hit collider but no mapped entity"); + } + return { + entity, + hitLoc: Coordinate.fromWorld( + add(position.toWorldVector(), mult(direction, hit.timeOfImpact)), + ), + }; } + return null; + } - public collectEntityState() { - const state: (RecordedEntityState&{uuid:string})[] = []; - for (const [uuid, ent] of this.entities.entries()) { - if ('recordState' in ent) { - state.push({ - uuid, - ...(ent as PhysicsEntity).recordState() - }); - } - } - return state; + public collectEntityState() { + const state: (RecordedEntityState & { uuid: string })[] = []; + for (const [uuid, ent] of this.entities.entries()) { + if ("recordState" in ent) { + state.push({ + uuid, + ...(ent as PhysicsEntity).recordState(), + }); + } } -} \ No newline at end of file + return state; + } +} diff --git a/yarn.lock b/yarn.lock index 9ed5eca..613cbda 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3328,6 +3328,11 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== +prettier@^3.4.2: + version "3.4.2" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.4.2.tgz#a5ce1fb522a588bf2b78ca44c6e6fe5aa5a2b13f" + integrity sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ== + pretty-format@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812"