From 38ac419204cef6a9c3f1781e849005cfb68ee11c Mon Sep 17 00:00:00 2001 From: Syncer Deploy Date: Mon, 31 Aug 2026 12:32:39 -0400 Subject: [PATCH] add Box3D-powered Bad Movers game --- README.md | 27 +- apps/server/src/index.ts | 4 + apps/web/src/App.tsx | 12 +- apps/web/src/Movers3D.tsx | 692 +++++++++++++++++++++ apps/web/src/MoversGame.tsx | 224 +++++++ apps/web/src/styles.css | 217 +++++++ apps/web/src/useMoversClient.ts | 275 ++++++++ package-lock.json | 12 +- packages/engine/src/index.ts | 5 + packages/engine/src/physics-backend.ts | 32 + packages/shared/package.json | 3 +- packages/shared/src/box3d-wasm.d.ts | 124 ++++ packages/shared/src/index.ts | 2 + packages/shared/src/movers-box3d.ts | 448 ++++++++++++++ packages/shared/src/movers-config.ts | 121 ++++ packages/shared/src/movers-game.ts | 827 +++++++++++++++++++++++++ packages/shared/src/movers-types.ts | 161 +++++ packages/shared/test/movers.test.mjs | 144 +++++ 18 files changed, 3320 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/Movers3D.tsx create mode 100644 apps/web/src/MoversGame.tsx create mode 100644 apps/web/src/useMoversClient.ts create mode 100644 packages/engine/src/physics-backend.ts create mode 100644 packages/shared/src/box3d-wasm.d.ts create mode 100644 packages/shared/src/movers-box3d.ts create mode 100644 packages/shared/src/movers-config.ts create mode 100644 packages/shared/src/movers-game.ts create mode 100644 packages/shared/src/movers-types.ts create mode 100644 packages/shared/test/movers.test.mjs diff --git a/README.md b/README.md index e6cd152..9d3c826 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Syncer -Three playable browser games built on one generic TypeScript multiplayer higher-order configuration. The engine runs authoritative servers, predicted clients, clocks, reconciliation, validation, privacy-aware replication, and bandwidth-budgeted interest management around game-supplied rules. +Four playable browser games built on one generic TypeScript multiplayer higher-order configuration. The engine runs authoritative servers, predicted clients, clocks, reconciliation, validation, privacy-aware replication, pluggable physics, and bandwidth-budgeted interest management around game-supplied rules. ## Structure @@ -10,7 +10,7 @@ apps/ web/ React + Vite predicted client packages/ engine/ Generic clocks, authority, prediction, validation, and protocol - shared/ Game definitions: Sync Arena, Flux Relay, and Syncer Royale + shared/ Game definitions: Arena, Flux Relay, Royale, and Bad Movers ``` ## Play @@ -20,13 +20,14 @@ npm install npm run dev ``` -Open `http://localhost:5173`. Use the selector to switch between three games: +Open `http://localhost:5173`. Use the selector to switch between four games: - **Sync Arena** — click **Deploy**, then use `WASD`, `Shift`, mouse look, left click, `R`, and weapon keys `1`–`3`. - **Flux Relay** — hold `Space` or the on-screen thruster to pull the shared core toward your team's gate while managing private energy. - **Syncer Royale** — click **Drop In**, then use `WASD`, `Shift`, mouse look, left click, and `R`. Survive 31 server bots, loot automatically, and stay inside the shrinking circle on a streamed 2 km island. Open it directly at `http://localhost:5173/#royale`. +- **Bad Movers** — use `WASD`, `Shift`, hold `Space` to grab, `E` to throw, and `F` to close your truck. Real Box3D rigid bodies run in WebAssembly on both the authority and predicted client. Open it directly at `http://localhost:5173/#movers`. -The arena includes pickups, armor, three weapons, headshots, reloads, respawns, a scoreboard, filtered spatial sound, and killcams; its server bots are disabled for player-only office matches. Flux Relay is intentionally unrelated to a shooter: it has teams, a shared core, balanced server bots, exact private energy, per-viewer events, and no weapons, visibility cones, or 3D physics. Royale proves the same engine can drive a much larger world: the map is generated client-side from a public seed, while players, private loot, combat, and the storm remain server authoritative. +The arena includes pickups, armor, three weapons, headshots, reloads, respawns, a scoreboard, filtered spatial sound, and killcams; its server bots are disabled for player-only office matches. Flux Relay proves the API is not shooter-specific. Royale proves it can drive a much larger generated world. Bad Movers proves a developer can attach a third-party deterministic physics backend while keeping plain serializable state, authority, prediction, reconciliation, and transport generic. ## Define a game @@ -89,7 +90,23 @@ const protocol = game.protocol; `createJsonCodec()` is included for prototypes and low-volume messages; games can replace it with custom packed binary codecs without changing any rules. `defineNetworkedGame()` remains available as the lower-level surface used internally and by advanced integrations. Every existing HOF—lag compensation, time travel, and replay transport—accepts the result of either definition API. -Flux Relay in `packages/shared/src/flux-game.ts` is the compact reference implementation. Both games are mounted by the same `hostNetworkedGame()` transport adapter, demonstrating that the server loop has no shooter knowledge. +Flux Relay in `packages/shared/src/flux-game.ts` is the compact reference implementation. Every game is mounted by the same `hostNetworkedGame()` transport adapter, demonstrating that the server loop has no game-specific knowledge. + +## Attach a physics backend + +`definePhysicsBackend()` defines the lifecycle for a native, WASM, or JavaScript physics plug-in. The backend owns runtime handles while the game state stays serializable for snapshots and checkpoints: + +```ts +const physics = definePhysicsBackend()({ + metadata: { name: "My Physics", runtime: "WebAssembly" }, + initialize: createWorldFromState, + step: stepWorldAndWriteBackState, + reconcile: reconcileWorldToSnapshot, + reset: resetWorldFromState, +}); +``` + +Bad Movers uses this API in `packages/shared/src/movers-box3d.ts` with Erin Catto's Box3D C17 engine compiled to WebAssembly SIMD. Both authority and prediction use a fixed 30 Hz step with four solver substeps; only the server decides damage, scoring, and winning. ## Stream stateful input safely diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 414e94d..36e3484 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -2,9 +2,11 @@ import { API_PATHS, FLUX_SOCKET_PATH, GAME_SOCKET_PATH, + MOVERS_SOCKET_PATH, ROYALE_SOCKET_PATH, createShooterGame, fluxGame, + moversGame, royaleGame, type ApiMessage, } from "@syncer/shared"; @@ -23,6 +25,7 @@ const staticSite = loadStaticSite(process.env.STATIC_ROOT); const gameLoops = [ hostNetworkedGame(app, GAME_SOCKET_PATH, createShooterGame({ botCount: 0 })), hostNetworkedGame(app, FLUX_SOCKET_PATH, fluxGame), + hostNetworkedGame(app, MOVERS_SOCKET_PATH, moversGame), hostNetworkedGame(app, ROYALE_SOCKET_PATH, royaleGame), ]; @@ -176,6 +179,7 @@ function contentType(filePath: string): string { case ".jpg": case ".jpeg": return "image/jpeg"; case ".webp": return "image/webp"; + case ".wasm": return "application/wasm"; case ".ico": return "image/x-icon"; case ".woff2": return "font/woff2"; default: return "application/octet-stream"; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index bf24cd7..d07ca69 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -7,12 +7,15 @@ import { import { useEffect, useState } from "react"; import { Arena3D } from "./Arena3D.js"; import { FluxGame } from "./FluxGame.js"; +import { MoversGame } from "./MoversGame.js"; import { RoyaleGame } from "./RoyaleGame.js"; import { useGameClient } from "./useGameClient.js"; export function App() { - const [demo, setDemo] = useState<"arena" | "flux" | "royale">(() => - window.location.hash === "#royale" + const [demo, setDemo] = useState<"arena" | "flux" | "royale" | "movers">(() => + window.location.hash === "#movers" + ? "movers" + : window.location.hash === "#royale" ? "royale" : window.location.hash === "#flux" ? "flux" @@ -25,7 +28,7 @@ export function App() { return ( <> - {demo === "arena" ? : demo === "flux" ? : } + {demo === "arena" ? : demo === "flux" ? : demo === "royale" ? : } ); diff --git a/apps/web/src/Movers3D.tsx b/apps/web/src/Movers3D.tsx new file mode 100644 index 0000000..17caaf7 --- /dev/null +++ b/apps/web/src/Movers3D.tsx @@ -0,0 +1,692 @@ +import { useEffect, useRef, type MutableRefObject } from "react"; +import * as THREE from "three"; +import { + MOVERS_WALLS, + type FurnitureKind, + type MoversClientState, + type MoversFurnitureView, + type MoversPlayerView, + type MoversTeam, +} from "@syncer/shared"; + +interface Movers3DProps { + world: MoversClientState; + playerId: number | null; +} + +interface TruckRig { + group: THREE.Group; + doors: [THREE.Mesh, THREE.Mesh]; + light: THREE.PointLight; +} + +interface TemporaryEffect { + object: THREE.Object3D; + startedAt: number; + expiresAt: number; + kind: "score" | "damage" | "impact"; +} + +interface MoversRuntime { + renderer: THREE.WebGLRenderer; + scene: THREE.Scene; + camera: THREE.PerspectiveCamera; + players: Map; + furniture: Map; + trucks: Record; + warningLight: THREE.DirectionalLight; + effects: TemporaryEffect[]; + resizeObserver: ResizeObserver; + animationFrame: number; + lastTime: number; +} + +const yellow = 0xffc629; +const blue = 0x2997ff; + +export function Movers3D({ world, playerId }: Movers3DProps) { + const hostRef = useRef(null); + const worldRef = useRef(world); + const playerIdRef = useRef(playerId); + const lastEventIdRef = useRef(0); + worldRef.current = world; + playerIdRef.current = playerId; + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + + const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.13; + host.append(renderer.domElement); + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x9ac8d4); + scene.fog = new THREE.Fog(0x9ac8d4, 55, 115); + const camera = new THREE.PerspectiveCamera(56, 1, 0.1, 180); + camera.position.set(0, 24, 24); + scene.add(camera); + + scene.add(new THREE.HemisphereLight(0xeaf9ff, 0x5b5140, 2.1)); + const sun = new THREE.DirectionalLight(0xfff3d4, 3.25); + sun.position.set(-26, 42, 18); + sun.castShadow = true; + sun.shadow.mapSize.set(2_048, 2_048); + sun.shadow.camera.left = -48; + sun.shadow.camera.right = 48; + sun.shadow.camera.top = 35; + sun.shadow.camera.bottom = -35; + scene.add(sun); + const warningLight = new THREE.DirectionalLight(0xff351f, 0); + warningLight.position.set(0, 18, -25); + scene.add(warningLight); + + buildProperty(scene); + const yellowTruck = buildTruck("yellow"); + const blueTruck = buildTruck("blue"); + scene.add(yellowTruck.group, blueTruck.group); + + const runtime: MoversRuntime = { + renderer, + scene, + camera, + players: new Map(), + furniture: new Map(), + trucks: { yellow: yellowTruck, blue: blueTruck }, + warningLight, + effects: [], + resizeObserver: new ResizeObserver(() => resize(runtime, host)), + animationFrame: 0, + lastTime: performance.now(), + }; + runtime.resizeObserver.observe(host); + resize(runtime, host); + + const animate = (time: number) => { + const deltaSeconds = Math.min(0.05, Math.max(0.001, (time - runtime.lastTime) / 1_000)); + runtime.lastTime = time; + updateRuntime(runtime, worldRef.current, playerIdRef.current, lastEventIdRef, time, deltaSeconds); + renderer.render(scene, camera); + runtime.animationFrame = window.requestAnimationFrame(animate); + }; + runtime.animationFrame = window.requestAnimationFrame(animate); + + return () => { + runtime.resizeObserver.disconnect(); + window.cancelAnimationFrame(runtime.animationFrame); + scene.traverse(disposeObject); + renderer.dispose(); + renderer.domElement.remove(); + }; + }, []); + + return
; +} + +function updateRuntime( + runtime: MoversRuntime, + world: MoversClientState, + playerId: number | null, + lastEventIdRef: MutableRefObject, + time: number, + deltaSeconds: number, +): void { + updatePlayers(runtime, world.players, playerId, deltaSeconds, time); + updateFurniture(runtime, world.furniture, deltaSeconds, time); + updateTrucks(runtime, world, deltaSeconds, time); + updateEffects(runtime, world, lastEventIdRef, time); + + const local = world.players.find((player) => player.id === playerId); + const targetX = local?.x ?? 0; + const targetZ = local?.z ?? 0; + const cameraAlpha = 1 - Math.exp(-5.8 * deltaSeconds); + runtime.camera.position.x = THREE.MathUtils.lerp(runtime.camera.position.x, targetX, cameraAlpha); + runtime.camera.position.y = THREE.MathUtils.lerp(runtime.camera.position.y, world.demolitionStarted ? 22 : 24, cameraAlpha); + runtime.camera.position.z = THREE.MathUtils.lerp(runtime.camera.position.z, targetZ + 24, cameraAlpha); + runtime.camera.lookAt(targetX, 0.6, targetZ - 3.5); + + runtime.warningLight.intensity = world.demolitionStarted + ? 1.1 + Math.max(0, Math.sin(time * 0.011)) * 2.2 + : 0; + runtime.scene.fog!.color.setHex(world.demolitionStarted ? 0xb88476 : 0x9ac8d4); + runtime.renderer.setClearColor(world.demolitionStarted ? 0xb88476 : 0x9ac8d4); +} + +function updatePlayers( + runtime: MoversRuntime, + players: MoversPlayerView[], + playerId: number | null, + deltaSeconds: number, + time: number, +): void { + const visible = new Set(players.map((player) => player.id)); + for (const player of players) { + let group = runtime.players.get(player.id); + if (!group) { + group = buildMover(player, player.id === playerId); + group.position.set(player.x, 0, player.z); + runtime.players.set(player.id, group); + runtime.scene.add(group); + } + const alpha = 1 - Math.exp(-(player.id === playerId ? 22 : 13) * deltaSeconds); + group.position.x = THREE.MathUtils.lerp(group.position.x, player.x, alpha); + group.position.z = THREE.MathUtils.lerp(group.position.z, player.z, alpha); + group.rotation.y = lerpAngle(group.rotation.y, player.yaw, alpha); + const speed = Math.hypot(player.velocityX, player.velocityZ); + const body = group.getObjectByName("body"); + if (body) body.position.y = 1.12 + Math.sin(time * 0.014 + player.id) * Math.min(0.08, speed * 0.01); + const ring = group.getObjectByName("local-ring") as THREE.Mesh | undefined; + if (ring) { + ring.rotation.z = time * 0.0012; + ring.scale.setScalar(1 + Math.sin(time * 0.004) * 0.05); + } + const sweat = group.getObjectByName("sweat") as THREE.PointLight | undefined; + if (sweat) sweat.intensity = player.sprinting ? 2.8 : 0; + } + for (const [id, group] of runtime.players) { + if (visible.has(id)) continue; + runtime.players.delete(id); + runtime.scene.remove(group); + group.traverse(disposeObject); + } +} + +function updateFurniture( + runtime: MoversRuntime, + items: MoversFurnitureView[], + deltaSeconds: number, + time: number, +): void { + const visible = new Set(items.map((item) => item.id)); + for (const item of items) { + let group = runtime.furniture.get(item.id); + if (!group) { + group = buildFurniture(item.kind); + group.position.set(item.x, item.y, item.z); + group.quaternion.set(item.rotationX, item.rotationY, item.rotationZ, item.rotationW); + runtime.furniture.set(item.id, group); + runtime.scene.add(group); + } + const alpha = 1 - Math.exp(-(item.carriedBy === null ? 14 : 24) * deltaSeconds); + group.position.x = THREE.MathUtils.lerp(group.position.x, item.x, alpha); + group.position.y = THREE.MathUtils.lerp(group.position.y, item.y, alpha); + group.position.z = THREE.MathUtils.lerp(group.position.z, item.z, alpha); + const targetRotation = new THREE.Quaternion( + item.rotationX, + item.rotationY, + item.rotationZ, + item.rotationW, + ).normalize(); + group.quaternion.slerp(targetRotation, alpha); + updateDamageAppearance(group, item.damage); + const securedLight = group.getObjectByName("secured") as THREE.PointLight | undefined; + if (securedLight) { + securedLight.color.setHex(item.securedBy === "yellow" ? yellow : item.securedBy === "blue" ? blue : 0xffffff); + securedLight.intensity = item.securedBy ? 2 + Math.sin(time * 0.005 + item.id) : 0; + } + } + for (const [id, group] of runtime.furniture) { + if (visible.has(id)) continue; + runtime.furniture.delete(id); + runtime.scene.remove(group); + group.traverse(disposeObject); + } +} + +function updateTrucks( + runtime: MoversRuntime, + world: MoversClientState, + deltaSeconds: number, + time: number, +): void { + for (const team of ["yellow", "blue"] as const) { + const truck = runtime.trucks[team]; + const ticks = team === "yellow" ? world.yellowDoorTicks : world.blueDoorTicks; + const closed = ticks > 0; + const alpha = 1 - Math.exp(-9 * deltaSeconds); + truck.doors[0].position.z = THREE.MathUtils.lerp(truck.doors[0].position.z, closed ? -2.45 : -5.4, alpha); + truck.doors[1].position.z = THREE.MathUtils.lerp(truck.doors[1].position.z, closed ? 2.45 : 5.4, alpha); + truck.light.intensity = closed ? 5 + Math.sin(time * 0.018) * 2 : 0.8; + } +} + +function updateEffects( + runtime: MoversRuntime, + world: MoversClientState, + lastEventIdRef: MutableRefObject, + time: number, +): void { + for (const entry of world.events) { + if (entry.event.id <= lastEventIdRef.current) continue; + lastEventIdRef.current = Math.max(lastEventIdRef.current, entry.event.id); + if (entry.event.type === "secured") { + const itemId = entry.event.itemId; + const item = world.furniture.find((candidate) => candidate.id === itemId); + if (item) spawnScoreEffect(runtime, item.x, item.z, entry.event.team, time); + } else if (entry.event.type === "damaged") { + const itemId = entry.event.itemId; + const item = world.furniture.find((candidate) => candidate.id === itemId); + if (item) spawnDamageEffect(runtime, item.x, item.z, time); + } else if (entry.event.type === "thrown") { + const itemId = entry.event.itemId; + const item = world.furniture.find((candidate) => candidate.id === itemId); + if (item) spawnImpactEffect(runtime, item.x, item.z, entry.event.team, time); + } + } + + runtime.effects = runtime.effects.filter((effect) => { + const progress = (time - effect.startedAt) / (effect.expiresAt - effect.startedAt); + if (progress >= 1) { + runtime.scene.remove(effect.object); + effect.object.traverse(disposeObject); + return false; + } + effect.object.position.y += effect.kind === "score" ? 0.018 : 0.006; + effect.object.rotation.y += effect.kind === "damage" ? 0.08 : 0.035; + effect.object.scale.setScalar(1 + progress * (effect.kind === "score" ? 2.7 : 1.1)); + effect.object.traverse((child) => { + if (child instanceof THREE.Mesh && child.material instanceof THREE.Material) { + child.material.opacity = 1 - progress; + child.material.transparent = true; + } + if (child instanceof THREE.Light) child.intensity = (1 - progress) * 8; + }); + return true; + }); +} + +function buildProperty(scene: THREE.Scene): void { + const ground = new THREE.Mesh( + new THREE.PlaneGeometry(100, 62), + new THREE.MeshStandardMaterial({ color: 0x59615c, roughness: 0.94 }), + ); + ground.rotation.x = -Math.PI / 2; + ground.receiveShadow = true; + scene.add(ground); + + const lawn = new THREE.Mesh( + new THREE.PlaneGeometry(58, 42), + new THREE.MeshStandardMaterial({ color: 0x82986a, roughness: 0.96 }), + ); + lawn.rotation.x = -Math.PI / 2; + lawn.position.y = 0.025; + lawn.receiveShadow = true; + scene.add(lawn); + + const floor = new THREE.Mesh( + new THREE.BoxGeometry(49, 0.28, 31), + new THREE.MeshStandardMaterial({ color: 0xd8c4a0, roughness: 0.82 }), + ); + floor.position.y = 0.15; + floor.receiveShadow = true; + scene.add(floor); + + const rug = new THREE.Mesh( + new THREE.PlaneGeometry(13, 9), + new THREE.MeshStandardMaterial({ color: 0x9e3d35, roughness: 0.88 }), + ); + rug.rotation.x = -Math.PI / 2; + rug.position.set(0, 0.31, 0); + scene.add(rug); + + const wallMaterial = new THREE.MeshStandardMaterial({ color: 0xf0e4cd, roughness: 0.8 }); + const wallTrim = new THREE.MeshStandardMaterial({ color: 0x6b4832, roughness: 0.68 }); + for (const definition of MOVERS_WALLS) { + const wall = new THREE.Mesh(new THREE.BoxGeometry(definition.width, 2.15, definition.depth), wallMaterial.clone()); + wall.position.set(definition.x, 1.18, definition.z); + wall.castShadow = true; + wall.receiveShadow = true; + scene.add(wall); + const trim = new THREE.Mesh(new THREE.BoxGeometry(definition.width + 0.08, 0.16, definition.depth + 0.08), wallTrim.clone()); + trim.position.set(definition.x, 2.23, definition.z); + trim.castShadow = true; + scene.add(trim); + } + + const stripeMaterial = new THREE.MeshBasicMaterial({ color: 0xf4d45d }); + for (let z = -20; z <= 20; z += 6) { + for (const x of [-27.5, 27.5]) { + const stripe = new THREE.Mesh(new THREE.PlaneGeometry(3.2, 0.28), stripeMaterial.clone()); + stripe.rotation.x = -Math.PI / 2; + stripe.position.set(x, 0.04, z); + scene.add(stripe); + } + } + + for (let index = 0; index < 12; index += 1) { + const cone = buildCone(index % 2 === 0 ? yellow : 0xff6b2c); + const side = index % 2 === 0 ? -1 : 1; + cone.position.set(side * (26 + (index % 3) * 2.1), 0, -18 + Math.floor(index / 2) * 6.8); + scene.add(cone); + } + + const demolitionSign = new THREE.Group(); + const board = new THREE.Mesh( + new THREE.BoxGeometry(12, 3, 0.3), + new THREE.MeshStandardMaterial({ color: 0xffc52e, roughness: 0.72 }), + ); + board.position.y = 2.8; + demolitionSign.add(board); + for (const x of [-4.5, 4.5]) { + const leg = new THREE.Mesh(new THREE.BoxGeometry(0.35, 4, 0.35), wallTrim.clone()); + leg.position.set(x, 1.2, 0); + demolitionSign.add(leg); + } + demolitionSign.position.set(0, 0, -25.5); + scene.add(demolitionSign); +} + +function buildTruck(team: MoversTeam): TruckRig { + const color = team === "yellow" ? yellow : blue; + const direction = team === "yellow" ? -1 : 1; + const group = new THREE.Group(); + group.position.x = direction * 35; + const bodyMaterial = new THREE.MeshStandardMaterial({ color, roughness: 0.42, metalness: 0.28 }); + const dark = new THREE.MeshStandardMaterial({ color: 0x222729, roughness: 0.66, metalness: 0.38 }); + const cargoFloor = new THREE.Mesh(new THREE.BoxGeometry(11, 0.5, 10), dark); + cargoFloor.position.y = 0.55; + cargoFloor.receiveShadow = true; + group.add(cargoFloor); + for (const z of [-5, 5]) { + const side = new THREE.Mesh(new THREE.BoxGeometry(11, 4.4, 0.38), bodyMaterial.clone()); + side.position.set(0, 2.7, z); + side.castShadow = true; + group.add(side); + } + const roof = new THREE.Mesh(new THREE.BoxGeometry(11, 0.38, 10.3), bodyMaterial.clone()); + roof.position.y = 4.8; + roof.castShadow = true; + group.add(roof); + const outerWall = new THREE.Mesh(new THREE.BoxGeometry(0.4, 4.4, 10), bodyMaterial.clone()); + outerWall.position.set(direction * 5.3, 2.7, 0); + outerWall.castShadow = true; + group.add(outerWall); + + const cab = new THREE.Mesh(new THREE.BoxGeometry(5.2, 3.8, 8), bodyMaterial.clone()); + cab.position.set(direction * 7.6, 2.05, 0); + cab.castShadow = true; + group.add(cab); + const windshield = new THREE.Mesh( + new THREE.BoxGeometry(0.16, 1.35, 5.8), + new THREE.MeshStandardMaterial({ color: 0x8fd2e4, roughness: 0.18, metalness: 0.45 }), + ); + windshield.position.set(direction * 4.95, 2.5, 0); + group.add(windshield); + for (const x of [direction * 6.3, direction * 9]) { + for (const z of [-3.4, 3.4]) { + const wheel = new THREE.Mesh(new THREE.CylinderGeometry(0.9, 0.9, 0.55, 14), dark.clone()); + wheel.rotation.x = Math.PI / 2; + wheel.position.set(x, 0.75, z); + wheel.castShadow = true; + group.add(wheel); + } + } + + const doorMaterial = new THREE.MeshStandardMaterial({ color: 0xe8e7dd, roughness: 0.52, metalness: 0.34 }); + const openingX = direction * -5.25; + const firstDoor = new THREE.Mesh(new THREE.BoxGeometry(0.32, 4.2, 4.75), doorMaterial.clone()); + const secondDoor = firstDoor.clone(); + firstDoor.position.set(openingX, 2.65, -5.4); + secondDoor.position.set(openingX, 2.65, 5.4); + firstDoor.castShadow = true; + secondDoor.castShadow = true; + group.add(firstDoor, secondDoor); + const light = new THREE.PointLight(color, 0.8, 18, 2); + light.position.set(openingX - direction * 1.5, 3.5, 0); + group.add(light); + return { group, doors: [firstDoor, secondDoor], light }; +} + +function buildMover(player: MoversPlayerView, local: boolean): THREE.Group { + const group = new THREE.Group(); + const color = player.team === "yellow" ? yellow : blue; + const uniform = new THREE.MeshStandardMaterial({ color, roughness: 0.48, metalness: 0.08 }); + const skin = new THREE.MeshStandardMaterial({ color: 0xd99b72, roughness: 0.72 }); + const trousers = new THREE.MeshStandardMaterial({ color: 0x29333a, roughness: 0.78 }); + const body = new THREE.Group(); + body.name = "body"; + body.position.y = 1.12; + const torso = new THREE.Mesh(new THREE.CapsuleGeometry(0.46, 0.8, 3, 8), uniform); + torso.position.y = 0.68; + torso.castShadow = true; + body.add(torso); + const head = new THREE.Mesh(new THREE.SphereGeometry(0.34, 12, 9), skin); + head.position.y = 1.56; + head.castShadow = true; + body.add(head); + const cap = new THREE.Mesh(new THREE.CylinderGeometry(0.38, 0.34, 0.18, 10), uniform.clone()); + cap.position.y = 1.84; + body.add(cap); + for (const x of [-0.28, 0.28]) { + const leg = new THREE.Mesh(new THREE.CapsuleGeometry(0.15, 0.58, 3, 7), trousers.clone()); + leg.position.set(x, -0.34, 0); + leg.castShadow = true; + body.add(leg); + const arm = new THREE.Mesh(new THREE.CapsuleGeometry(0.12, 0.72, 3, 7), skin.clone()); + arm.position.set(x * 1.85, 0.75, 0.26); + arm.rotation.x = -0.72; + arm.castShadow = true; + body.add(arm); + } + const patch = new THREE.Mesh( + new THREE.BoxGeometry(0.42, 0.32, 0.04), + new THREE.MeshBasicMaterial({ color: 0xffffff }), + ); + patch.position.set(0, 0.83, 0.47); + body.add(patch); + group.add(body); + const sweat = new THREE.PointLight(0xb9f5ff, 0, 4, 2); + sweat.name = "sweat"; + sweat.position.set(0, 2.8, 0); + group.add(sweat); + if (local) { + const ring = new THREE.Mesh( + new THREE.RingGeometry(0.74, 0.9, 24, 1, 0, Math.PI * 1.7), + new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.88, side: THREE.DoubleSide }), + ); + ring.name = "local-ring"; + ring.rotation.x = -Math.PI / 2; + ring.position.y = 0.06; + group.add(ring); + } + return group; +} + +function buildFurniture(kind: FurnitureKind): THREE.Group { + const group = new THREE.Group(); + const add = (mesh: THREE.Mesh) => { + mesh.castShadow = true; + mesh.receiveShadow = true; + if (mesh.material instanceof THREE.MeshStandardMaterial) { + mesh.userData.baseColor = mesh.material.color.getHex(); + } + group.add(mesh); + return mesh; + }; + let baseY = 0.6; + const wood = new THREE.MeshStandardMaterial({ color: 0x5d3425, roughness: 0.58, metalness: 0.08 }); + const cream = new THREE.MeshStandardMaterial({ color: 0xe5d5b4, roughness: 0.75 }); + const dark = new THREE.MeshStandardMaterial({ color: 0x242829, roughness: 0.42, metalness: 0.42 }); + + switch (kind) { + case "piano": { + baseY = 0.85; + add(new THREE.Mesh(new THREE.BoxGeometry(3.1, 1.45, 1.55), wood)); + const lid = add(new THREE.Mesh(new THREE.BoxGeometry(3.2, 0.14, 1.68), dark)); + lid.position.y = 0.8; + const keys = add(new THREE.Mesh(new THREE.BoxGeometry(2.45, 0.15, 0.62), cream)); + keys.position.set(0, 0.35, 0.92); + break; + } + case "aquarium": { + baseY = 0.85; + const glass = new THREE.MeshStandardMaterial({ color: 0x72cce8, transparent: true, opacity: 0.62, roughness: 0.1, metalness: 0.18 }); + add(new THREE.Mesh(new THREE.BoxGeometry(2.3, 1.55, 1.2), glass)); + const water = add(new THREE.Mesh(new THREE.BoxGeometry(2.15, 1.05, 1.05), new THREE.MeshStandardMaterial({ color: 0x2386b2, transparent: true, opacity: 0.72 }))); + water.position.y = -0.18; + break; + } + case "safe": + baseY = 0.85; + add(new THREE.Mesh(new THREE.BoxGeometry(1.65, 1.7, 1.55), dark)); + { + const dial = add(new THREE.Mesh(new THREE.TorusGeometry(0.3, 0.09, 8, 16), cream)); + dial.rotation.x = Math.PI / 2; + dial.position.z = 0.82; + } + break; + case "sofa": { + baseY = 0.65; + const fabric = new THREE.MeshStandardMaterial({ color: 0xb84e35, roughness: 0.9 }); + add(new THREE.Mesh(new THREE.BoxGeometry(3.2, 0.75, 1.45), fabric)); + const back = add(new THREE.Mesh(new THREE.BoxGeometry(3.2, 1.2, 0.48), fabric.clone())); + back.position.set(0, 0.55, -0.55); + break; + } + case "television": { + baseY = 0.95; + add(new THREE.Mesh(new THREE.BoxGeometry(2.2, 1.55, 0.35), dark)); + const screen = add(new THREE.Mesh(new THREE.BoxGeometry(1.92, 1.28, 0.04), new THREE.MeshStandardMaterial({ color: 0x7ee8f2, emissive: 0x16596a, emissiveIntensity: 0.75, roughness: 0.15 }))); + screen.position.z = 0.2; + break; + } + case "mattress": + baseY = 0.34; + add(new THREE.Mesh(new THREE.BoxGeometry(3.2, 0.65, 2.15), cream)); + break; + case "urn": { + baseY = 0.6; + add(new THREE.Mesh(new THREE.CylinderGeometry(0.28, 0.48, 1.15, 12), new THREE.MeshStandardMaterial({ color: 0xe4bd45, roughness: 0.28, metalness: 0.62 }))); + const lid = add(new THREE.Mesh(new THREE.SphereGeometry(0.31, 12, 8), wood.clone())); + lid.position.y = 0.62; + break; + } + case "refrigerator": { + baseY = 1.15; + add(new THREE.Mesh(new THREE.BoxGeometry(1.65, 2.3, 1.5), new THREE.MeshStandardMaterial({ color: 0xe7eee9, roughness: 0.42, metalness: 0.32 }))); + const handle = add(new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.82, 0.12), dark.clone())); + handle.position.set(0.55, 0.35, 0.81); + break; + } + case "plant": { + baseY = 0.55; + add(new THREE.Mesh(new THREE.CylinderGeometry(0.42, 0.58, 0.85, 10), new THREE.MeshStandardMaterial({ color: 0xb96c3d, roughness: 0.86 }))); + for (let index = 0; index < 5; index += 1) { + const leaf = add(new THREE.Mesh(new THREE.SphereGeometry(0.5, 8, 6), new THREE.MeshStandardMaterial({ color: 0x4e883f, roughness: 0.92 }))); + const angle = index * Math.PI * 0.4; + leaf.scale.set(0.45, 1.25, 0.35); + leaf.position.set(Math.cos(angle) * 0.36, 0.7 + (index % 2) * 0.28, Math.sin(angle) * 0.36); + } + break; + } + case "mystery-box": { + baseY = 0.68; + add(new THREE.Mesh(new THREE.BoxGeometry(1.4, 1.3, 1.4), new THREE.MeshStandardMaterial({ color: 0xb88a4f, roughness: 0.92 }))); + for (const x of [-0.28, 0.28]) { + const ear = add(new THREE.Mesh(new THREE.ConeGeometry(0.17, 0.4, 4), new THREE.MeshStandardMaterial({ color: 0x3d3934, roughness: 0.9 }))); + ear.position.set(x, 0.82, 0); + } + break; + } + } + const secured = new THREE.PointLight(0xffffff, 0, 7, 2); + secured.name = "secured"; + secured.position.y = 1.4; + group.add(secured); + group.userData.baseY = baseY; + return group; +} + +function updateDamageAppearance(group: THREE.Group, damage: number): void { + const shade = 1 - damage / 175; + group.traverse((object) => { + if (!(object instanceof THREE.Mesh) || !(object.material instanceof THREE.MeshStandardMaterial)) return; + const baseColor = object.userData.baseColor as number | undefined; + if (baseColor === undefined) return; + object.material.color.setHex(baseColor).multiplyScalar(shade); + object.material.roughness = Math.min(1, object.material.roughness + damage / 400); + }); +} + +function spawnScoreEffect(runtime: MoversRuntime, x: number, z: number, team: MoversTeam, time: number): void { + const group = new THREE.Group(); + group.position.set(x, 1.2, z); + const ring = new THREE.Mesh( + new THREE.TorusGeometry(1.1, 0.12, 8, 24), + new THREE.MeshBasicMaterial({ color: team === "yellow" ? yellow : blue }), + ); + ring.rotation.x = Math.PI / 2; + group.add(ring, new THREE.PointLight(team === "yellow" ? yellow : blue, 8, 12, 2)); + runtime.scene.add(group); + runtime.effects.push({ object: group, startedAt: time, expiresAt: time + 900, kind: "score" }); +} + +function spawnDamageEffect(runtime: MoversRuntime, x: number, z: number, time: number): void { + const group = new THREE.Group(); + group.position.set(x, 0.8, z); + for (let index = 0; index < 6; index += 1) { + const chip = new THREE.Mesh( + new THREE.BoxGeometry(0.16, 0.16, 0.16), + new THREE.MeshBasicMaterial({ color: index % 2 === 0 ? 0xff714d : 0x5d3425 }), + ); + const angle = (index / 6) * Math.PI * 2; + chip.position.set(Math.cos(angle) * 0.55, Math.sin(index) * 0.2, Math.sin(angle) * 0.55); + group.add(chip); + } + runtime.scene.add(group); + runtime.effects.push({ object: group, startedAt: time, expiresAt: time + 650, kind: "damage" }); +} + +function spawnImpactEffect(runtime: MoversRuntime, x: number, z: number, team: MoversTeam, time: number): void { + const ring = new THREE.Mesh( + new THREE.RingGeometry(0.5, 0.68, 18), + new THREE.MeshBasicMaterial({ color: team === "yellow" ? yellow : blue, side: THREE.DoubleSide }), + ); + ring.position.set(x, 0.15, z); + ring.rotation.x = -Math.PI / 2; + runtime.scene.add(ring); + runtime.effects.push({ object: ring, startedAt: time, expiresAt: time + 500, kind: "impact" }); +} + +function buildCone(color: number): THREE.Group { + const group = new THREE.Group(); + const base = new THREE.Mesh( + new THREE.CylinderGeometry(0.48, 0.58, 0.14, 10), + new THREE.MeshStandardMaterial({ color: 0x252728, roughness: 0.8 }), + ); + base.position.y = 0.07; + group.add(base); + const cone = new THREE.Mesh( + new THREE.ConeGeometry(0.34, 1.05, 10), + new THREE.MeshStandardMaterial({ color, roughness: 0.65 }), + ); + cone.position.y = 0.65; + cone.castShadow = true; + group.add(cone); + return group; +} + +function resize(runtime: MoversRuntime, host: HTMLElement): void { + const width = Math.max(1, host.clientWidth); + const height = Math.max(1, host.clientHeight); + runtime.renderer.setSize(width, height, false); + runtime.camera.aspect = width / height; + runtime.camera.updateProjectionMatrix(); +} + +function lerpAngle(current: number, target: number, alpha: number): number { + const difference = Math.atan2(Math.sin(target - current), Math.cos(target - current)); + return current + difference * alpha; +} + +function disposeObject(object: THREE.Object3D): void { + if (!(object instanceof THREE.Mesh)) return; + object.geometry.dispose(); + const materials = Array.isArray(object.material) ? object.material : [object.material]; + materials.forEach((material) => material.dispose()); +} diff --git a/apps/web/src/MoversGame.tsx b/apps/web/src/MoversGame.tsx new file mode 100644 index 0000000..05da215 --- /dev/null +++ b/apps/web/src/MoversGame.tsx @@ -0,0 +1,224 @@ +import { useState } from "react"; +import { + FURNITURE, + MOVERS_PHYSICS_BACKEND, + MOVERS_MATCH_TICKS, + MOVERS_TICK_RATE, + type FurnitureKind, + type MoversFurnitureView, + type MoversPerception, + type MoversPlayerView, + type MoversTeam, +} from "@syncer/shared"; +import { Movers3D } from "./Movers3D.js"; +import { useMoversClient, type MoversControl } from "./useMoversClient.js"; + +export function MoversGame() { + const client = useMoversClient(); + const [started, setStarted] = useState(false); + const local = client.world.players.find((player) => player.id === client.playerId); + const carried = local?.carryingId === null || local?.carryingId === undefined + ? undefined + : client.world.furniture.find((item) => item.id === local.carryingId); + const remainingTicks = Math.max(0, MOVERS_MATCH_TICKS - client.world.elapsedTicks); + const recent = [...client.world.events] + .reverse() + .find((entry) => client.tick - entry.receivedTick < 100); + const remaining = client.world.furniture + .filter((item) => item.securedBy === null) + .sort((left, right) => remainingValue(right) - remainingValue(left)); + const team = local?.team ?? "yellow"; + + const pulse = (control: MoversControl) => { + client.setControl(control, true); + window.setTimeout(() => client.setControl(control, false), 80); + }; + + return ( +
+ +
+ ); +} + +function ManifestItem({ item }: { item: MoversFurnitureView }) { + const value = remainingValue(item); + return ( +
45 ? "is-damaged" : ""}> + {icon(item.kind)} + {FURNITURE[item.kind].name}{Math.round(item.damage)}% DAMAGE + ${value.toLocaleString()} +
+ ); +} + +function Crew({ team, players, localId }: { team: MoversTeam; players: MoversPlayerView[]; localId: number | null }) { + return ( +
+
{team} crew{players.filter((player) => player.team === team).length}
+ {players.filter((player) => player.team === team).map((player) => ( +
+ + {player.bot ? `TEMP ${player.id % 100}` : `MOVER ${player.id}`} + {player.carryingId ? "LIFTING" : player.sprinting ? "RUNNING" : "AVAILABLE"} +
+ ))} +
+ ); +} + +function EventToast({ event }: { event: MoversPerception }) { + let text: string; + let team: MoversTeam | "neutral" = "neutral"; + switch (event.type) { + case "grabbed": + text = `${event.team.toUpperCase()} CREW GRABBED ${event.itemId === 7 ? "GRANDMA" : "FURNITURE"}`; + team = event.team; + break; + case "thrown": + text = `MOVER ${event.playerId} THREW COMPANY PROPERTY`; + team = event.team; + break; + case "damaged": + text = `CRUNCH · $${event.remainingValue.toLocaleString()} LEFT`; + break; + case "secured": + text = `+ $${event.value.toLocaleString()} · ${FURNITURE[event.kind].name.toUpperCase()} SECURED`; + team = event.team; + break; + case "doors": + text = `${event.team.toUpperCase()} TRUCK DOORS CLOSING`; + team = event.team; + break; + case "demolition": + text = "DEMOLITION STARTED · THIS WAS NOT IN THE CONTRACT"; + break; + case "winner": + text = event.team === "draw" ? "EVERYONE GETS FIRED" : `${event.team.toUpperCase()} CREW WINS THE CONTRACT`; + team = event.team === "draw" ? "neutral" : event.team; + break; + } + return
{text}
; +} + +function remainingValue(item: Pick): number { + return Math.max(25, Math.round(FURNITURE[item.kind].value * (1 - item.damage / 100))); +} + +function formatTime(ticks: number): string { + const seconds = Math.ceil(ticks / MOVERS_TICK_RATE); + return `${Math.floor(seconds / 60).toString().padStart(2, "0")}:${(seconds % 60).toString().padStart(2, "0")}`; +} + +function icon(kind: FurnitureKind): string { + switch (kind) { + case "piano": return "♫"; + case "aquarium": return "≈"; + case "safe": return "$"; + case "sofa": return "▰"; + case "television": return "▣"; + case "mattress": return "▱"; + case "urn": return "♙"; + case "refrigerator": return "▥"; + case "plant": return "♣"; + case "mystery-box": return "?"; + } +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index d51dd06..2743c50 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -568,6 +568,208 @@ button { font: inherit; } .royale-weapon strong i { color: #81907d; font-size: .72rem; font-style: normal; } .royale-weapon span { color: #6e8069; font-family: "IBM Plex Mono", monospace; font-size: .45rem; letter-spacing: .1em; } +.movers-game { + --crew: #ffc629; + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + color: #202426; + background: #83b9c7; + user-select: none; +} +.movers-game--blue { --crew: #2997ff; } +.movers-viewport, +.movers-viewport canvas { position: absolute; inset: 0; width: 100%; height: 100%; } +.movers-viewport canvas { display: block; } +.movers-grade { + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgb(24 31 32 / 48%), transparent 19%, transparent 69%, rgb(22 21 17 / 72%)), + radial-gradient(circle at 50% 48%, transparent 48%, rgb(30 23 15 / 31%)); + pointer-events: none; +} +.movers-game--demolition .movers-grade { + background: + linear-gradient(180deg, rgb(68 15 8 / 48%), transparent 24%, transparent 66%, rgb(48 9 5 / 73%)), + radial-gradient(circle, transparent 42%, rgb(109 17 8 / 29%)); + animation: movers-alarm .8s ease-in-out infinite alternate; +} +@keyframes movers-alarm { to { opacity: .72; } } + +.movers-start { + position: absolute; + z-index: 20; + top: 50%; + left: 50%; + display: flex; + width: min(500px, calc(100% - 36px)); + flex-direction: column; + gap: 8px; + padding: 25px 34px 27px; + border: 0; + border-top: 7px solid #ffc629; + color: #f9f5e9; + background: + repeating-linear-gradient(135deg, rgb(255 198 41 / 9%) 0 10px, transparent 10px 20px), + rgb(28 31 31 / 94%); + box-shadow: 0 24px 80px rgb(31 19 5 / 42%); + cursor: pointer; + transform: translate(-50%, -50%) rotate(-.7deg); +} +.movers-start small, +.movers-start span { color: #aaa99e; font-family: "IBM Plex Mono", monospace; font-size: .49rem; letter-spacing: .1em; } +.movers-start strong { color: #ffc629; font-size: 1.72rem; letter-spacing: .11em; } +.movers-start:hover { background-color: rgb(37 40 38 / 98%); transform: translate(-50%, -50%) rotate(0) scale(1.015); } + +.movers-header { + position: absolute; + z-index: 5; + top: 0; + left: 0; + display: grid; + width: 100%; + grid-template-columns: 1fr auto 1fr; + align-items: start; + padding: 20px 26px; + pointer-events: none; +} +.movers-brand { display: flex; align-items: center; gap: 10px; color: #fff9e8; } +.movers-physics { + border: 1px solid rgb(255 255 255 / 24%); + border-radius: 999px; + color: #b9f5ff; + font: 700 .58rem/1 "IBM Plex Mono", monospace; + letter-spacing: .08em; + margin-left: .25rem; + padding: .38rem .5rem; + white-space: nowrap; +} +.movers-brand > i { + display: grid; + width: 47px; + height: 43px; + place-items: center; + color: #262a29; + background: #ffc629; + box-shadow: 6px 6px 0 rgb(20 23 23 / 82%); + font-size: 1.1rem; + font-style: normal; + font-weight: 800; + transform: rotate(-4deg); +} +.movers-brand small { display: block; color: #aeb9b7; font-family: "IBM Plex Mono", monospace; font-size: .43rem; letter-spacing: .11em; } +.movers-brand strong { display: block; margin-top: 1px; font-size: 1.12rem; letter-spacing: .14em; text-shadow: 0 2px 8px rgb(0 0 0 / 35%); } + +.movers-score { display: grid; grid-template-columns: 150px 105px 150px; align-items: stretch; filter: drop-shadow(0 7px 14px rgb(0 0 0 / 24%)); } +.movers-score > div { padding: 8px 13px; background: rgb(246 242 226 / 91%); } +.movers-score > div:first-child { border-top: 4px solid #ffc629; text-align: right; } +.movers-score > div:last-child { border-top: 4px solid #2997ff; } +.movers-score small { display: block; color: #777a73; font-family: "IBM Plex Mono", monospace; font-size: .42rem; letter-spacing: .08em; } +.movers-score strong { color: #242827; font-family: "IBM Plex Mono", monospace; font-size: .94rem; font-weight: 600; } +.movers-score > span { display: grid; place-items: center; padding: 5px 10px; color: #f8f2df; background: #292d2d; text-align: center; } +.movers-score > span small { color: #ff6d4a; } +.movers-score > span b { font-family: "IBM Plex Mono", monospace; font-size: 1.05rem; letter-spacing: .05em; } + +.movers-live { justify-self: end; display: grid; grid-template-columns: auto auto; align-items: center; column-gap: 8px; padding: 7px 9px; color: #f2f6f1; background: rgb(28 33 33 / 72%); text-align: right; } +.movers-live i { grid-row: 1 / span 2; width: 7px; height: 7px; border-radius: 50%; background: #88e56c; box-shadow: 0 0 10px #72d957; } +.movers-live span { font-size: .61rem; font-weight: 800; letter-spacing: .13em; text-transform: uppercase; } +.movers-live small { color: #9fa9a7; font-family: "IBM Plex Mono", monospace; font-size: .42rem; } +.movers-live--connecting i, +.movers-live--reconnecting i { background: #ffb13b; box-shadow: 0 0 10px #ff9b29; } + +.movers-manifest { + position: absolute; + z-index: 4; + top: 100px; + left: 25px; + width: 210px; + padding: 8px; + color: #202423; + background: rgb(245 240 220 / 91%); + box-shadow: 7px 9px 0 rgb(29 29 25 / 28%); + transform: rotate(-.6deg); + pointer-events: none; +} +.movers-manifest > header { display: flex; justify-content: space-between; padding: 4px 5px 8px; border-bottom: 2px solid #292d2c; } +.movers-manifest > header small, +.movers-manifest > header b { font-family: "IBM Plex Mono", monospace; font-size: .43rem; letter-spacing: .08em; } +.movers-manifest > div { display: grid; grid-template-columns: 24px 1fr auto; align-items: center; gap: 6px; padding: 6px 4px; border-bottom: 1px dashed rgb(55 58 55 / 25%); } +.movers-manifest > div > i { display: grid; width: 21px; height: 21px; place-items: center; border: 1px solid rgb(43 47 45 / 25%); font-family: Georgia, serif; font-style: normal; font-weight: 700; } +.movers-manifest span b { display: block; overflow: hidden; max-width: 95px; font-size: .54rem; letter-spacing: .04em; text-overflow: ellipsis; white-space: nowrap; } +.movers-manifest span small { display: block; color: #8a8174; font-family: "IBM Plex Mono", monospace; font-size: .34rem; } +.movers-manifest > div > strong { font-family: "IBM Plex Mono", monospace; font-size: .48rem; } +.movers-manifest > div.is-damaged { color: #a53125; background: rgb(199 48 32 / 7%); } +.movers-manifest p { margin: 7px 4px 2px; color: #826e4d; font-family: "IBM Plex Mono", monospace; font-size: .35rem; line-height: 1.4; } + +.movers-roster { position: absolute; z-index: 4; top: 105px; right: 25px; display: flex; width: 178px; flex-direction: column; gap: 9px; pointer-events: none; } +.movers-crew { padding: 7px; border-top: 4px solid; color: #e8ece7; background: rgb(30 34 34 / 83%); box-shadow: 5px 6px 0 rgb(20 21 20 / 22%); } +.movers-crew--yellow { border-color: #ffc629; } +.movers-crew--blue { border-color: #2997ff; } +.movers-crew header { display: flex; justify-content: space-between; padding: 0 3px 5px; text-transform: uppercase; } +.movers-crew header small, +.movers-crew header b { color: #a9afaa; font-family: "IBM Plex Mono", monospace; font-size: .4rem; letter-spacing: .09em; } +.movers-crew > div { display: grid; grid-template-columns: 7px 1fr auto; align-items: center; gap: 5px; padding: 4px 3px; border-top: 1px solid rgb(255 255 255 / 7%); font-family: "IBM Plex Mono", monospace; font-size: .39rem; } +.movers-crew > div i { width: 5px; height: 5px; border-radius: 50%; background: #ffc629; } +.movers-crew--blue > div i { background: #2997ff; } +.movers-crew > div b { color: #858e8a; font-size: .33rem; font-weight: 500; } +.movers-crew > div.is-local { color: #fff; background: rgb(255 255 255 / 8%); } + +.movers-event { + position: absolute; + z-index: 7; + top: 29%; + left: 50%; + padding: 8px 20px; + border-top: 4px solid #ff6848; + color: #f9f5e8; + background: rgb(34 36 34 / 91%); + box-shadow: 6px 7px 0 rgb(20 19 16 / 27%); + font-family: "IBM Plex Mono", monospace; + font-size: .51rem; + letter-spacing: .09em; + transform: translateX(-50%) rotate(-.4deg); + pointer-events: none; +} +.movers-event--yellow { border-color: #ffc629; } +.movers-event--blue { border-color: #2997ff; } +.movers-warning { position: absolute; z-index: 5; top: 42%; left: 50%; display: flex; flex-direction: column; align-items: center; padding: 10px 60px; border-block: 2px solid rgb(255 85 50 / 72%); color: #ffe8d7; background: linear-gradient(90deg, transparent, rgb(86 14 7 / 84%) 22%, rgb(86 14 7 / 84%) 78%, transparent); transform: translate(-50%, -50%); pointer-events: none; } +.movers-warning small { color: #ff8b69; font-family: "IBM Plex Mono", monospace; font-size: .43rem; letter-spacing: .15em; } +.movers-warning strong { font-size: .94rem; letter-spacing: .16em; } +.movers-winner { position: absolute; z-index: 9; top: 50%; left: 50%; display: flex; width: min(650px, calc(100% - 40px)); flex-direction: column; align-items: center; gap: 4px; padding: 22px; border-top: 8px solid #ffc629; color: #f9f3df; background: rgb(35 37 35 / 94%); box-shadow: 12px 14px 0 rgb(25 20 13 / 35%); transform: translate(-50%, -50%) rotate(-.4deg); pointer-events: none; } +.movers-winner--blue { border-color: #2997ff; } +.movers-winner--draw { border-color: #ff6848; } +.movers-winner small, +.movers-winner span { color: #aaa99f; font-family: "IBM Plex Mono", monospace; font-size: .47rem; letter-spacing: .11em; } +.movers-winner strong { color: #fff8df; font-size: 1.55rem; letter-spacing: .14em; } + +.movers-hud { position: absolute; z-index: 6; right: 25px; bottom: 20px; left: 25px; display: grid; grid-template-columns: 1fr 1.15fr auto 1fr; align-items: end; gap: 16px; pointer-events: none; } +.movers-contract, +.movers-carry, +.movers-stamina { padding: 9px 11px; border-top: 4px solid var(--crew); color: #eeeade; background: rgb(30 34 33 / 84%); box-shadow: 6px 7px 0 rgb(17 18 16 / 24%); } +.movers-contract small, +.movers-carry small, +.movers-stamina small { display: block; color: #959e99; font-family: "IBM Plex Mono", monospace; font-size: .4rem; letter-spacing: .1em; } +.movers-contract strong, +.movers-carry strong { display: block; overflow: hidden; margin: 2px 0; color: #fff8df; font-size: .7rem; letter-spacing: .08em; text-overflow: ellipsis; white-space: nowrap; } +.movers-contract span, +.movers-carry span { display: block; color: #8e9792; font-family: "IBM Plex Mono", monospace; font-size: .36rem; } +.movers-carry { border-color: #9a9f97; text-align: center; transform: rotate(.35deg); } +.movers-carry--active { border-color: #ff6848; background: rgb(58 36 28 / 90%); } +.movers-actions { display: flex; gap: 4px; pointer-events: auto; } +.movers-actions button { display: flex; min-width: 57px; flex-direction: column; align-items: center; padding: 8px 7px; border: 1px solid rgb(255 255 255 / 15%); color: #d6dad5; background: rgb(29 33 32 / 88%); cursor: pointer; } +.movers-actions button:hover { border-color: var(--crew); color: #fff; } +.movers-actions button:disabled { opacity: .35; cursor: default; } +.movers-actions button b { color: var(--crew); font-family: "IBM Plex Mono", monospace; font-size: .53rem; } +.movers-actions button span { font-size: .43rem; letter-spacing: .08em; white-space: nowrap; } +.movers-actions .movers-action--grab { border-color: color-mix(in srgb, var(--crew) 58%, transparent); } +.movers-stamina > div { display: flex; align-items: end; justify-content: space-between; } +.movers-stamina strong { color: var(--crew); font-family: "IBM Plex Mono", monospace; font-size: .9rem; font-weight: 500; } +.movers-stamina > span { display: block; overflow: hidden; height: 5px; margin: 5px 0; background: rgb(255 255 255 / 13%); transform: skewX(-20deg); } +.movers-stamina > span i { display: block; height: 100%; background: var(--crew); box-shadow: 0 0 10px var(--crew); } +.movers-stamina em { display: block; color: #828c87; font-family: "IBM Plex Mono", monospace; font-size: .34rem; font-style: normal; letter-spacing: .05em; } + @media (max-width: 760px) { .game-switcher { top: 70px; } .topbar { padding: 16px; grid-template-columns: 1fr auto; } @@ -599,4 +801,19 @@ button { font: inherit; } .royale-mission { display: none; } .royale-weapon { min-width: 0; } .royale-drop { right: 16px; } + .movers-header { padding: 13px; grid-template-columns: 1fr auto; } + .movers-score { position: absolute; top: 64px; left: 50%; grid-template-columns: 110px 90px 110px; transform: translateX(-50%); } + .movers-score > div { padding: 6px 8px; } + .movers-live small, + .movers-brand small { display: none; } + .movers-manifest { top: 118px; left: 12px; width: 165px; } + .movers-manifest > div:nth-of-type(n+5) { display: none; } + .movers-roster { display: none; } + .movers-warning { width: 100%; padding-inline: 12px; } + .movers-hud { right: 12px; bottom: 10px; left: 12px; grid-template-columns: 1fr auto; gap: 7px; } + .movers-contract, + .movers-stamina { display: none; } + .movers-carry { grid-column: 1 / -1; grid-row: 1; } + .movers-actions { grid-column: 1 / -1; justify-content: center; } + .movers-actions button { min-width: 82px; } } diff --git a/apps/web/src/useMoversClient.ts b/apps/web/src/useMoversClient.ts new file mode 100644 index 0000000..b3b51b4 --- /dev/null +++ b/apps/web/src/useMoversClient.ts @@ -0,0 +1,275 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + createInputStateStream, + FixedStepClock, + type NetworkStats, +} from "@syncer/engine"; +import { + MOVERS_SOCKET_PATH, + moversGame, + type MoversClientState, + type MoversInput, +} from "@syncer/shared"; +import type { ConnectionStatus, ValidationStatus } from "./useGameClient.js"; + +export type MoversControl = "grab" | "throwItem" | "closeDoors"; + +export interface MoversClientView { + connection: ConnectionStatus; + validation: ValidationStatus; + playerId: number | null; + tick: number; + inputLeadTicks: number; + world: MoversClientState; + network: NetworkStats; + setControl(control: MoversControl, active: boolean): void; +} + +const emptyNetworkStats: NetworkStats = { + roundTripTime: 0, + jitter: 0, + clockOffset: 0, + samples: 0, +}; + +function neutralInput(): MoversInput { + return { + forward: 0, + strafe: 0, + sprint: false, + grab: false, + throwItem: false, + closeDoors: false, + }; +} + +function socketUrls(): string[] { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const proxied = `${protocol}//${window.location.host}${MOVERS_SOCKET_PATH}`; + if (window.location.protocol !== "http:" || window.location.port !== "5173") return [proxied]; + return [`ws://${window.location.hostname}:3001${MOVERS_SOCKET_PATH}`, proxied]; +} + +export function useMoversClient(): MoversClientView { + const engine = useMemo(() => moversGame.createClient(), []); + const protocol = moversGame.protocol; + const clock = useMemo( + () => new FixedStepClock({ rateHz: moversGame.tickRateHz, maxCatchUpSteps: 5 }), + [], + ); + const controlRef = useRef<(control: MoversControl, active: boolean) => void>(() => undefined); + const setControl = useCallback( + (control: MoversControl, active: boolean) => controlRef.current(control, active), + [], + ); + const [view, setView] = useState>({ + connection: "connecting", + validation: "waiting", + playerId: null, + tick: 0, + inputLeadTicks: 1, + world: moversGame.client.createInitialState(), + network: emptyNetworkStats, + }); + + useEffect(() => { + let active = true; + let socket: WebSocket | undefined; + let retryTimer: number | undefined; + let animationFrame: number | undefined; + let connection: ConnectionStatus = "connecting"; + let validation: ValidationStatus = "waiting"; + let input = neutralInput(); + const pressed = new Set(); + const inputStream = createInputStateStream(moversGame); + let lastInputFrame: ArrayBuffer | null = null; + let socketUrlIndex = 0; + const connectionUrls = socketUrls(); + + const publish = () => { + if (!active) return; + setView({ + connection, + validation, + playerId: engine.localPlayerId, + tick: engine.tick, + inputLeadTicks: engine.networkClock.recommendedInputLeadTicks(moversGame.tickRateHz), + world: moversGame.client.cloneState(engine.currentState as MoversClientState), + network: engine.networkClock.stats, + }); + }; + + const send = (frame: ArrayBuffer) => { + if (socket?.readyState === WebSocket.OPEN) socket.send(frame); + }; + + const sendInput = (force = false) => { + if (!engine.initialized) return; + inputStream.update(input); + const emission = inputStream.consume(performance.now(), force); + if (!emission) return; + if (emission.kind === "state" || !lastInputFrame) { + lastInputFrame = protocol.encodeClient({ + kind: "input", + packet: engine.createInput(emission.input), + }); + } + send(lastInputFrame); + }; + + const updateMovement = () => { + input = { + ...input, + forward: Number(pressed.has("w")) - Number(pressed.has("s")), + strafe: Number(pressed.has("d")) - Number(pressed.has("a")), + sprint: pressed.has("shift"), + }; + sendInput(); + }; + + const updateControl = (control: MoversControl, value: boolean) => { + if (input[control] === value) return; + input = { ...input, [control]: value }; + sendInput(true); + }; + controlRef.current = updateControl; + + const connect = () => { + let opened = false; + connection = engine.initialized ? "reconnecting" : "connecting"; + publish(); + socket = new WebSocket(connectionUrls[socketUrlIndex]!); + socket.binaryType = "arraybuffer"; + socket.addEventListener("open", () => { + if (!active) return; + opened = true; + connection = "live"; + publish(); + }); + socket.addEventListener("message", (event: MessageEvent) => { + if (!active || !(event.data instanceof ArrayBuffer)) return; + try { + const message = protocol.decodeServer(event.data); + switch (message.kind) { + case "welcome": + engine.initialize(message.playerId, message.snapshot); + clock.reset(performance.now()); + input = neutralInput(); + pressed.clear(); + inputStream.reset(input); + lastInputFrame = null; + validation = "waiting"; + sendInput(true); + break; + case "snapshot": + engine.reconcile(message.snapshot); + break; + case "acknowledge": + engine.acknowledge(message.sequence); + break; + case "pong": + engine.networkClock.receivePong(message.pong, performance.now()); + break; + case "validation": + validation = message.valid ? "valid" : "invalid"; + break; + case "reject-input": + engine.reject(message.sequence); + inputStream.invalidate(); + lastInputFrame = null; + validation = "invalid"; + sendInput(true); + break; + case "event": + engine.receiveEvent(message.event, message.tick); + break; + case "replay-start": + case "replay-frame": + case "replay-end": + break; + } + publish(); + } catch { + socket?.close(1003, "Invalid moving manifest"); + } + }); + socket.addEventListener("close", () => { + if (!active) return; + if (!opened && connectionUrls.length > 1) { + socketUrlIndex = (socketUrlIndex + 1) % connectionUrls.length; + } else if (opened) socketUrlIndex = 0; + connection = "reconnecting"; + publish(); + retryTimer = window.setTimeout(connect, 1_000); + }); + socket.addEventListener("error", () => socket?.close()); + }; + + const keyDown = (event: KeyboardEvent) => { + const key = event.key.toLowerCase(); + if (["w", "a", "s", "d", "shift", " ", "e", "f"].includes(key)) event.preventDefault(); + if (["w", "a", "s", "d", "shift"].includes(key)) { + pressed.add(key); + updateMovement(); + } else if (key === " ") updateControl("grab", true); + else if (key === "e" && !event.repeat) updateControl("throwItem", true); + else if (key === "f" && !event.repeat) updateControl("closeDoors", true); + }; + const keyUp = (event: KeyboardEvent) => { + const key = event.key.toLowerCase(); + if (["w", "a", "s", "d", "shift"].includes(key)) { + pressed.delete(key); + updateMovement(); + } else if (key === " ") updateControl("grab", false); + else if (key === "e") updateControl("throwItem", false); + else if (key === "f") updateControl("closeDoors", false); + }; + const release = () => { + pressed.clear(); + input = neutralInput(); + sendInput(true); + }; + + window.addEventListener("keydown", keyDown); + window.addEventListener("keyup", keyUp); + window.addEventListener("blur", release); + + const pingTimer = window.setInterval(() => { + send(protocol.encodeClient({ + kind: "ping", + ping: engine.networkClock.createPing(performance.now()), + })); + }, 1_000); + const validationTimer = window.setInterval(() => { + if (!engine.initialized) return; + send(protocol.encodeClient({ + kind: "state-report", + report: engine.createStateReport(), + })); + }, 2_000); + const animate = (now: number) => { + sendInput(); + clock.advance(now, () => engine.step()); + publish(); + animationFrame = window.requestAnimationFrame(animate); + }; + + connect(); + animationFrame = window.requestAnimationFrame(animate); + return () => { + active = false; + controlRef.current = () => undefined; + window.clearTimeout(retryTimer); + window.clearInterval(pingTimer); + window.clearInterval(validationTimer); + if (animationFrame !== undefined) window.cancelAnimationFrame(animationFrame); + window.removeEventListener("keydown", keyDown); + window.removeEventListener("keyup", keyUp); + window.removeEventListener("blur", release); + socket?.close(); + }; + }, [clock, engine, protocol]); + + return { ...view, setControl }; +} + diff --git a/package-lock.json b/package-lock.json index f56e50e..3f0b7ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1276,6 +1276,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/box3d-wasm": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/box3d-wasm/-/box3d-wasm-0.2.0.tgz", + "integrity": "sha512-cvju1RYCTeChr+CUc2Sh+EsFaypP+6SSlNNSuf4Y3+e4nUFu1O25xQLZTBVUqp1+Wn4Xa2fA4cxrtLzLoWOk4A==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -2195,7 +2204,8 @@ "name": "@syncer/shared", "version": "0.0.0", "dependencies": { - "@syncer/engine": "0.0.0" + "@syncer/engine": "0.0.0", + "box3d-wasm": "^0.2.0" }, "devDependencies": { "typescript": "^7.0.2" diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index e74309c..9dc4acf 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -55,6 +55,11 @@ export type { SnapshotBatch, } from "./networked-types.js"; export { NetworkClock, type NetworkStats } from "./network-clock.js"; +export { + definePhysicsBackend, + type PhysicsBackend, + type PhysicsBackendMetadata, +} from "./physics-backend.js"; export { createBinaryProtocol, type BinaryProtocol, diff --git a/packages/engine/src/physics-backend.ts b/packages/engine/src/physics-backend.ts new file mode 100644 index 0000000..ed6dbe7 --- /dev/null +++ b/packages/engine/src/physics-backend.ts @@ -0,0 +1,32 @@ +/** Runtime information a game may expose without coupling itself to an SDK. */ +export interface PhysicsBackendMetadata { + readonly name: string; + readonly runtime: string; + readonly version?: string; +} + +/** + * A pluggable deterministic physics runtime for a serializable game state. + * + * Native/WASM handles stay inside the backend. The engine only checkpoints the + * plain State value, so a backend must be able to initialize from that value + * and reconcile an existing runtime to a fresh snapshot. + */ +export interface PhysicsBackend { + readonly metadata: PhysicsBackendMetadata; + initialize(state: State): void; + step(state: State, deltaSeconds: number): StepResult; + reconcile(predicted: State, snapshot: State): void; + reset(state: State): void; + dispose?(state: State): void; +} + +/** + * Developer-facing HOF used to define a typed physics plug-in once and retain + * any game-specific methods added by that plug-in. + */ +export function definePhysicsBackend() { + return >( + backend: Backend, + ): Readonly => Object.freeze(backend); +} diff --git a/packages/shared/package.json b/packages/shared/package.json index a2cc2d5..be573ac 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -15,7 +15,8 @@ "dist" ], "dependencies": { - "@syncer/engine": "0.0.0" + "@syncer/engine": "0.0.0", + "box3d-wasm": "^0.2.0" }, "scripts": { "dev": "tsc --watch --preserveWatchOutput", diff --git a/packages/shared/src/box3d-wasm.d.ts b/packages/shared/src/box3d-wasm.d.ts new file mode 100644 index 0000000..f9a517a --- /dev/null +++ b/packages/shared/src/box3d-wasm.d.ts @@ -0,0 +1,124 @@ +declare module "box3d-wasm/standard" { + export interface Vec3 { + x: number; + y: number; + z: number; + } + + export interface Quat extends Vec3 { + w: number; + } + + export interface Transform { + position: Vec3; + rotation: Quat; + } + + export type BodyType = "static" | "kinematic" | "dynamic"; + + export interface MotionLocks { + linearX?: boolean; + linearY?: boolean; + linearZ?: boolean; + angularX?: boolean; + angularY?: boolean; + angularZ?: boolean; + } + + export interface BodyDefinition { + type: BodyType; + position?: Vec3; + rotation?: Quat; + linearVelocity?: Vec3; + angularVelocity?: Vec3; + linearDamping?: number; + angularDamping?: number; + gravityScale?: number; + motionLocks?: MotionLocks; + isBullet?: boolean; + userData?: number; + } + + export interface ShapeDefinition { + density?: number; + friction?: number; + restitution?: number; + isSensor?: boolean; + enableContactEvents?: boolean; + enableHitEvents?: boolean; + enableSensorEvents?: boolean; + userData?: number; + } + + export interface Shape { + destroy(): void; + delete(): void; + } + + export interface Body { + createBox(options: ShapeDefinition & { halfExtents: Vec3 }): Shape; + createCapsule(options: ShapeDefinition & { height: number; radius: number }): Shape; + getPosition(): Vec3; + getRotation(): Quat; + getLinearVelocity(): Vec3; + getAngularVelocity(): Vec3; + getType(): BodyType; + setType(type: BodyType): void; + setTransform(position: Vec3, rotation: Quat): void; + setTargetTransform(transform: Transform, deltaSeconds: number, wake: boolean): void; + setLinearVelocity(velocity: Vec3): void; + setAngularVelocity(velocity: Vec3): void; + setAwake(awake: boolean): void; + setBullet(bullet: boolean): void; + applyLinearImpulseToCenter(impulse: Vec3, wake: boolean): void; + destroy(): void; + delete(): void; + } + + export interface ContactHitEvent { + shapeUserDataA: number; + shapeUserDataB: number; + point: Vec3; + normal: Vec3; + approachSpeed: number; + } + + export interface ContactEvents { + begin: unknown[]; + end: unknown[]; + hit: ContactHitEvent[]; + } + + export interface WorldProfile { + [name: string]: number; + } + + export class World { + constructor(options: { + gravity: Vec3; + enableSleep?: boolean; + enableContinuous?: boolean; + workerCount?: number; + }); + createBody(definition: BodyDefinition): Body; + step(deltaSeconds: number, subStepCount: number): void; + explode(options: { + position: Vec3; + radius: number; + falloff: number; + impulsePerArea: number; + }): void; + getContactEvents(): ContactEvents; + getProfile(): WorldProfile; + destroy(): void; + delete(): void; + } + + export interface Box3DModule { + World: typeof World; + readonly threaded: boolean; + readonly maxWorkers: number; + } + + export default function Box3D(options?: unknown): Promise; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9fa6062..66ad9bd 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -9,6 +9,8 @@ export interface ApiMessage { export * from "./arena.js"; export * from "./flux-types.js"; export * from "./flux-game.js"; +export * from "./movers-types.js"; +export * from "./movers-game.js"; export * from "./royale-types.js"; export * from "./royale-map.js"; export * from "./royale-game.js"; diff --git a/packages/shared/src/movers-box3d.ts b/packages/shared/src/movers-box3d.ts new file mode 100644 index 0000000..6680303 --- /dev/null +++ b/packages/shared/src/movers-box3d.ts @@ -0,0 +1,448 @@ +import { definePhysicsBackend } from "@syncer/engine"; +import Box3D, { + type Body, + type BodyType, + type Quat, + type Vec3, + type World, + type WorldProfile, +} from "box3d-wasm/standard"; +import { + FURNITURE, + MOVERS_ARENA_HALF_DEPTH, + MOVERS_ARENA_HALF_WIDTH, + MOVERS_WALLS, +} from "./movers-config.js"; +import type { + FurnitureKind, + MoversAuthorityState, + MoversClientState, +} from "./movers-types.js"; + +type MoversPhysicsState = MoversAuthorityState | MoversClientState; + +export interface MoversPhysicsImpact { + itemId: number; + approachSpeed: number; +} + +interface FurnitureBody { + body: Body; + kind: FurnitureKind; + mode: BodyType; +} + +interface PhysicsRuntime { + world: World; + players: Map; + furniture: Map; + pendingExplosions: Array<{ + position: Vec3; + radius: number; + falloff: number; + impulsePerArea: number; + }>; + round: number; +} + +const box3d = await Box3D(); +const runtimes = new WeakMap(); +const furnitureShapeTagBase = 10_000; +const playerShapeTagBase = 100_000; +const identityRotation: Quat = { x: 0, y: 0, z: 0, w: 1 }; + +export const MOVERS_PHYSICS_BACKEND = { + name: "Box3D", + version: "0.1.0", + bindingVersion: "0.2.0", + runtime: "WebAssembly SIMD", + solver: "single-threaded deterministic", + subSteps: 4, +} as const; + +export const moversPhysics = definePhysicsBackend< + MoversPhysicsState, + MoversPhysicsImpact[] +>()({ + metadata: MOVERS_PHYSICS_BACKEND, + + initialize(state) { + ensureRuntime(state); + }, + + step(state, deltaSeconds) { + const runtime = ensureRuntime(state); + syncStructure(runtime, state); + if (runtime.round !== state.round) { + forceState(runtime, state); + runtime.round = state.round; + } + + preparePlayers(runtime, state); + prepareFurniture(runtime, state, deltaSeconds); + for (const explosion of runtime.pendingExplosions.splice(0)) { + runtime.world.explode(explosion); + } + runtime.world.step(deltaSeconds, MOVERS_PHYSICS_BACKEND.subSteps); + syncStateFromWorld(runtime, state); + return collectImpacts(runtime, state); + }, + + reconcile(predicted, snapshot) { + const runtime = runtimes.get(predicted); + if (!runtime) { + ensureRuntime(snapshot); + return; + } + if (predicted !== snapshot) { + runtimes.delete(predicted); + runtimes.set(snapshot, runtime); + } + syncStructure(runtime, snapshot); + forceState(runtime, snapshot); + runtime.round = snapshot.round; + }, + + reset(state) { + const runtime = ensureRuntime(state); + syncStructure(runtime, state); + forceState(runtime, state); + runtime.round = state.round; + }, + + dispose(state) { + const runtime = runtimes.get(state); + if (!runtime) return; + for (const body of runtime.players.values()) body.delete(); + for (const record of runtime.furniture.values()) record.body.delete(); + runtime.world.destroy(); + runtime.world.delete(); + runtimes.delete(state); + }, + + explode( + state: MoversPhysicsState, + options: { position: Vec3; radius: number; falloff: number; impulsePerArea: number }, + ) { + ensureRuntime(state).pendingExplosions.push(options); + }, + + profile(state: MoversPhysicsState): WorldProfile { + return ensureRuntime(state).world.getProfile(); + }, +}); + +function ensureRuntime(state: MoversPhysicsState): PhysicsRuntime { + const existing = runtimes.get(state); + if (existing) return existing; + + const world = new box3d.World({ + gravity: { x: 0, y: -18, z: 0 }, + enableSleep: true, + enableContinuous: true, + workerCount: 1, + }); + createStaticWorld(world); + const runtime: PhysicsRuntime = { + world, + players: new Map(), + furniture: new Map(), + pendingExplosions: [], + round: state.round, + }; + runtimes.set(state, runtime); + syncStructure(runtime, state); + forceState(runtime, state); + return runtime; +} + +function createStaticWorld(world: World): void { + createStaticBox(world, { x: 0, y: -0.5, z: 0 }, { + x: MOVERS_ARENA_HALF_WIDTH + 4, + y: 0.5, + z: MOVERS_ARENA_HALF_DEPTH + 4, + }, 1); + + const wallHeight = 3; + for (const [index, wall] of MOVERS_WALLS.entries()) { + createStaticBox( + world, + { x: wall.x, y: wallHeight / 2, z: wall.z }, + { x: wall.width / 2, y: wallHeight / 2, z: wall.depth / 2 }, + 100 + index, + ); + } + + const edgeThickness = 0.8; + createStaticBox(world, { + x: -MOVERS_ARENA_HALF_WIDTH - edgeThickness / 2, + y: wallHeight / 2, + z: 0, + }, { x: edgeThickness / 2, y: wallHeight / 2, z: MOVERS_ARENA_HALF_DEPTH + 1 }, 201); + createStaticBox(world, { + x: MOVERS_ARENA_HALF_WIDTH + edgeThickness / 2, + y: wallHeight / 2, + z: 0, + }, { x: edgeThickness / 2, y: wallHeight / 2, z: MOVERS_ARENA_HALF_DEPTH + 1 }, 202); + createStaticBox(world, { + x: 0, + y: wallHeight / 2, + z: -MOVERS_ARENA_HALF_DEPTH - edgeThickness / 2, + }, { x: MOVERS_ARENA_HALF_WIDTH + 1, y: wallHeight / 2, z: edgeThickness / 2 }, 203); + createStaticBox(world, { + x: 0, + y: wallHeight / 2, + z: MOVERS_ARENA_HALF_DEPTH + edgeThickness / 2, + }, { x: MOVERS_ARENA_HALF_WIDTH + 1, y: wallHeight / 2, z: edgeThickness / 2 }, 204); +} + +function createStaticBox(world: World, position: Vec3, halfExtents: Vec3, tag: number): void { + const body = world.createBody({ type: "static", position, userData: tag }); + const shape = body.createBox({ + halfExtents, + friction: 0.78, + restitution: 0.04, + enableHitEvents: true, + userData: tag, + }); + shape.delete(); + body.delete(); +} + +function syncStructure(runtime: PhysicsRuntime, state: MoversPhysicsState): void { + const playerIds = new Set(state.players.map((player) => player.id)); + for (const [id, body] of runtime.players) { + if (playerIds.has(id)) continue; + body.destroy(); + body.delete(); + runtime.players.delete(id); + } + for (const player of state.players) { + if (runtime.players.has(player.id)) continue; + runtime.players.set(player.id, createPlayerBody(runtime.world, player)); + } + + const itemIds = new Set(state.furniture.map((item) => item.id)); + for (const [id, record] of runtime.furniture) { + const item = state.furniture.find((candidate) => candidate.id === id); + if (itemIds.has(id) && item?.kind === record.kind) continue; + record.body.destroy(); + record.body.delete(); + runtime.furniture.delete(id); + } + for (const item of state.furniture) { + if (runtime.furniture.has(item.id)) continue; + runtime.furniture.set(item.id, createFurnitureBody(runtime.world, item)); + } +} + +function createPlayerBody( + world: World, + player: MoversPhysicsState["players"][number], +): Body { + const body = world.createBody({ + type: "dynamic", + position: { x: player.x, y: 1.05, z: player.z }, + linearVelocity: { x: player.velocityX, y: 0, z: player.velocityZ }, + gravityScale: 0, + linearDamping: 0.25, + motionLocks: { + linearY: true, + angularX: true, + angularY: true, + angularZ: true, + }, + userData: playerShapeTagBase + player.id, + }); + const shape = body.createCapsule({ + height: 1.15, + radius: 0.47, + density: 1.2, + friction: 0.12, + restitution: 0.02, + enableHitEvents: true, + userData: playerShapeTagBase + player.id, + }); + shape.delete(); + return body; +} + +function createFurnitureBody( + world: World, + item: MoversPhysicsState["furniture"][number], +): FurnitureBody { + const definition = FURNITURE[item.kind]; + const mode = bodyMode(item); + const body = world.createBody({ + type: mode, + position: positionOf(item), + rotation: rotationOf(item), + linearVelocity: velocityOf(item), + angularVelocity: angularVelocityOf(item), + linearDamping: 0.5 + definition.weight * 0.14, + angularDamping: 0.72, + isBullet: Math.hypot(item.velocityX, item.velocityY, item.velocityZ) > 9, + userData: furnitureShapeTagBase + item.id, + }); + const shape = body.createBox({ + halfExtents: definition.halfExtents, + density: definition.density, + friction: 0.62, + restitution: item.kind === "mattress" ? 0.18 : 0.06, + enableHitEvents: true, + userData: furnitureShapeTagBase + item.id, + }); + shape.delete(); + return { body, kind: item.kind, mode }; +} + +function preparePlayers(runtime: PhysicsRuntime, state: MoversPhysicsState): void { + for (const player of state.players) { + const body = runtime.players.get(player.id); + if (!body) continue; + body.setLinearVelocity({ x: player.velocityX, y: 0, z: player.velocityZ }); + body.setAwake(true); + } +} + +function prepareFurniture( + runtime: PhysicsRuntime, + state: MoversPhysicsState, + deltaSeconds: number, +): void { + for (const item of state.furniture) { + const record = runtime.furniture.get(item.id); + if (!record) continue; + const nextMode = bodyMode(item); + const changedMode = record.mode !== nextMode; + if (changedMode) { + record.body.setType(nextMode); + record.body.setTransform(positionOf(item), rotationOf(item)); + record.mode = nextMode; + } + if (nextMode === "kinematic") { + record.body.setTargetTransform({ + position: positionOf(item), + rotation: rotationOf(item), + }, deltaSeconds, true); + continue; + } + if (nextMode === "static") { + if (changedMode) record.body.setTransform(positionOf(item), rotationOf(item)); + continue; + } + record.body.setBullet(Math.hypot(item.velocityX, item.velocityY, item.velocityZ) > 9); + record.body.setLinearVelocity(velocityOf(item)); + record.body.setAngularVelocity(angularVelocityOf(item)); + } +} + +function forceState(runtime: PhysicsRuntime, state: MoversPhysicsState): void { + for (const player of state.players) { + const body = runtime.players.get(player.id); + if (!body) continue; + body.setTransform({ x: player.x, y: 1.05, z: player.z }, identityRotation); + body.setLinearVelocity({ x: player.velocityX, y: 0, z: player.velocityZ }); + } + for (const item of state.furniture) { + const record = runtime.furniture.get(item.id); + if (!record) continue; + const mode = bodyMode(item); + if (record.body.getType() !== mode) record.body.setType(mode); + record.mode = mode; + record.body.setTransform(positionOf(item), rotationOf(item)); + if (mode === "dynamic") { + record.body.setLinearVelocity(velocityOf(item)); + record.body.setAngularVelocity(angularVelocityOf(item)); + } + } +} + +function syncStateFromWorld(runtime: PhysicsRuntime, state: MoversPhysicsState): void { + for (const player of state.players) { + const body = runtime.players.get(player.id); + if (!body) continue; + const position = body.getPosition(); + const velocity = body.getLinearVelocity(); + player.x = position.x; + player.z = position.z; + player.velocityX = velocity.x; + player.velocityZ = velocity.z; + } + for (const item of state.furniture) { + const body = runtime.furniture.get(item.id)?.body; + if (!body) continue; + const position = body.getPosition(); + const rotation = body.getRotation(); + const velocity = body.getLinearVelocity(); + const angularVelocity = body.getAngularVelocity(); + item.x = position.x; + item.y = position.y; + item.z = position.z; + item.velocityX = velocity.x; + item.velocityY = velocity.y; + item.velocityZ = velocity.z; + item.rotationX = rotation.x; + item.rotationY = rotation.y; + item.rotationZ = rotation.z; + item.rotationW = rotation.w; + item.angularVelocityX = angularVelocity.x; + item.angularVelocityY = angularVelocity.y; + item.angularVelocityZ = angularVelocity.z; + item.yaw = yawFromQuaternion(rotation); + } +} + +function collectImpacts( + runtime: PhysicsRuntime, + state: MoversPhysicsState, +): MoversPhysicsImpact[] { + const maximumByItem = new Map(); + for (const hit of runtime.world.getContactEvents().hit) { + for (const tag of [hit.shapeUserDataA, hit.shapeUserDataB]) { + const itemId = tag - furnitureShapeTagBase; + const item = state.furniture.find((candidate) => candidate.id === itemId); + if (!item || item.carriedBy !== null || item.securedBy !== null) continue; + maximumByItem.set(itemId, Math.max(maximumByItem.get(itemId) ?? 0, hit.approachSpeed)); + } + } + return [...maximumByItem].map(([itemId, approachSpeed]) => ({ itemId, approachSpeed })); +} + +function bodyMode(item: MoversPhysicsState["furniture"][number]): BodyType { + if (item.securedBy !== null) return "static"; + if (item.carriedBy !== null) return "kinematic"; + return "dynamic"; +} + +function positionOf(item: MoversPhysicsState["furniture"][number]): Vec3 { + return { x: item.x, y: item.y, z: item.z }; +} + +function rotationOf(item: MoversPhysicsState["furniture"][number]): Quat { + return { + x: item.rotationX, + y: item.rotationY, + z: item.rotationZ, + w: item.rotationW, + }; +} + +function velocityOf(item: MoversPhysicsState["furniture"][number]): Vec3 { + return { x: item.velocityX, y: item.velocityY, z: item.velocityZ }; +} + +function angularVelocityOf(item: MoversPhysicsState["furniture"][number]): Vec3 { + return { + x: item.angularVelocityX, + y: item.angularVelocityY, + z: item.angularVelocityZ, + }; +} + +function yawFromQuaternion(rotation: Quat): number { + return Math.atan2( + 2 * (rotation.w * rotation.y + rotation.x * rotation.z), + 1 - 2 * (rotation.y * rotation.y + rotation.z * rotation.z), + ); +} diff --git a/packages/shared/src/movers-config.ts b/packages/shared/src/movers-config.ts new file mode 100644 index 0000000..fad3e48 --- /dev/null +++ b/packages/shared/src/movers-config.ts @@ -0,0 +1,121 @@ +import type { FurnitureKind } from "./movers-types.js"; + +export interface FurnitureDefinition { + name: string; + value: number; + weight: number; + radius: number; + fragile: number; + halfExtents: { x: number; y: number; z: number }; + density: number; +} + +export const FURNITURE: Record = { + piano: { + name: "Grand Piano", + value: 1_400, + weight: 3.2, + radius: 1.65, + fragile: 0.72, + halfExtents: { x: 1.55, y: 0.72, z: 0.78 }, + density: 1.8, + }, + aquarium: { + name: "Live Aquarium", + value: 1_100, + weight: 2.1, + radius: 1.15, + fragile: 1.45, + halfExtents: { x: 1.15, y: 0.78, z: 0.6 }, + density: 1.45, + }, + safe: { + name: "Suspicious Safe", + value: 1_800, + weight: 3.8, + radius: 0.9, + fragile: 0.28, + halfExtents: { x: 0.82, y: 0.85, z: 0.78 }, + density: 5.2, + }, + sofa: { + name: "Designer Sofa", + value: 850, + weight: 2.2, + radius: 1.55, + fragile: 0.58, + halfExtents: { x: 1.6, y: 0.62, z: 0.72 }, + density: 0.72, + }, + television: { + name: "Giant Television", + value: 1_250, + weight: 1.35, + radius: 1.05, + fragile: 1.7, + halfExtents: { x: 1.1, y: 0.78, z: 0.22 }, + density: 1.05, + }, + mattress: { + name: "King Mattress", + value: 420, + weight: 1.1, + radius: 1.55, + fragile: 0.35, + halfExtents: { x: 1.6, y: 0.32, z: 1.08 }, + density: 0.28, + }, + urn: { + name: "Grandma's Urn", + value: 2_200, + weight: 0.65, + radius: 0.48, + fragile: 2.2, + halfExtents: { x: 0.43, y: 0.6, z: 0.43 }, + density: 1.4, + }, + refrigerator: { + name: "Full Refrigerator", + value: 720, + weight: 2.65, + radius: 1.05, + fragile: 0.62, + halfExtents: { x: 0.82, y: 1.15, z: 0.75 }, + density: 2.4, + }, + plant: { + name: "Rare Houseplant", + value: 560, + weight: 0.7, + radius: 0.62, + fragile: 1.25, + halfExtents: { x: 0.52, y: 0.7, z: 0.52 }, + density: 0.55, + }, + "mystery-box": { + name: "Box (Probably Cat)", + value: 900, + weight: 0.8, + radius: 0.72, + fragile: 0.82, + halfExtents: { x: 0.7, y: 0.65, z: 0.7 }, + density: 0.68, + }, +}; + +export const MOVERS_WALLS = [ + { x: -19, z: -10, width: 9, depth: 0.7 }, + { x: -7, z: -10, width: 8, depth: 0.7 }, + { x: 7, z: -10, width: 8, depth: 0.7 }, + { x: 19, z: -10, width: 9, depth: 0.7 }, + { x: -17, z: 10, width: 15, depth: 0.7 }, + { x: 0, z: 10, width: 13, depth: 0.7 }, + { x: 17, z: 10, width: 15, depth: 0.7 }, + { x: -12, z: 0, width: 0.7, depth: 11 }, + { x: 12, z: 1, width: 0.7, depth: 10 }, + { x: 0, z: -6, width: 0.7, depth: 8 }, +] as const; + +export const MOVERS_ARENA_HALF_WIDTH = 42; +export const MOVERS_ARENA_HALF_DEPTH = 23; + diff --git a/packages/shared/src/movers-game.ts b/packages/shared/src/movers-game.ts new file mode 100644 index 0000000..a88128c --- /dev/null +++ b/packages/shared/src/movers-game.ts @@ -0,0 +1,827 @@ +import { + createJsonCodec, + defineMultiplayerGame, + withInputStream, +} from "@syncer/engine"; +import { FURNITURE } from "./movers-config.js"; +import { moversPhysics } from "./movers-box3d.js"; +import type { + FurnitureKind, + MoversAuthorityEvent, + MoversAuthorityFurniture, + MoversAuthorityPlayer, + MoversAuthorityState, + MoversClientState, + MoversFurnitureView, + MoversGameContract, + MoversInput, + MoversPerception, + MoversPlayerView, + MoversTeam, +} from "./movers-types.js"; + +export { FURNITURE, MOVERS_WALLS } from "./movers-config.js"; +export { MOVERS_PHYSICS_BACKEND } from "./movers-box3d.js"; + +export const MOVERS_SOCKET_PATH = "/ws/movers"; +export const MOVERS_TICK_RATE = 30; +export const MOVERS_SNAPSHOT_RATE = 15; +export const MOVERS_MATCH_TICKS = MOVERS_TICK_RATE * 150; +export const MOVERS_DEMOLITION_TICKS = MOVERS_TICK_RATE * 30; +export const MOVERS_BOT_IDS = [40_001, 40_002, 40_003, 40_004] as const; + +const inputCodec = createJsonCodec(); +const stateCodec = createJsonCodec(); +const eventCodec = createJsonCodec(); +const eventLifetimeTicks = MOVERS_TICK_RATE * 6; +const maximumStamina = 100; + +const baseMoversGame = defineMultiplayerGame({ + clock: { + ticksPerSecond: MOVERS_TICK_RATE, + snapshotsPerSecond: MOVERS_SNAPSHOT_RATE, + }, + + authority: { + createInitialState: createAuthorityState, + cloneState: cloneAuthorityState, + addPlayer(state, { playerId }) { + const team = leastPopulatedTeam(state); + state.players.push(createPlayer(playerId, team, false, state.players.length)); + state.players.sort((left, right) => left.id - right.id); + }, + removePlayer(state, { playerId }) { + const player = findPlayer(state, playerId); + if (player?.carryingId !== null && player?.carryingId !== undefined) { + const item = findFurniture(state, player.carryingId); + if (item) item.carriedBy = null; + } + state.players = state.players.filter((candidate) => candidate.id !== playerId); + }, + applyInput(state, input, { playerId }) { + const player = findPlayer(state, playerId); + if (!player || player.bot) return; + applyCommand(player, input); + }, + step(state, { tick, deltaSeconds, emit }) { + if (state.resetTicks > 0) { + state.resetTicks -= 1; + if (state.resetTicks === 0) resetMatch(state); + return; + } + + state.elapsedTicks += 1; + state.yellowDoorTicks = Math.max(0, state.yellowDoorTicks - 1); + state.blueDoorTicks = Math.max(0, state.blueDoorTicks - 1); + + if (!state.demolitionStarted && state.elapsedTicks >= MOVERS_MATCH_TICKS - MOVERS_DEMOLITION_TICKS) { + state.demolitionStarted = true; + emit({ id: state.nextEventId++, type: "demolition" }); + } + + for (const player of state.players) { + if (player.bot) updateBot(state, player, tick); + const carrying = player.carryingId === null ? null : findFurniture(state, player.carryingId) ?? null; + updateAuthorityPlayerIntent(player, carrying, deltaSeconds); + } + + updateCarrying(state, emit); + for (const player of state.players) { + if (player.closeDoorsRequested) tryCloseTruck(state, player, emit); + player.throwRequested = false; + player.closeDoorsRequested = false; + } + + if (state.demolitionStarted && tick % 75 === 0) demolitionPulse(state, tick, emit); + + const impacts = moversPhysics.step(state, deltaSeconds); + for (const impact of impacts) { + if (impact.approachSpeed <= 4.8) continue; + const item = findFurniture(state, impact.itemId); + if (item) damageFurniture(state, item, (impact.approachSpeed - 4.8) * 1.35, tick, emit); + } + resolveCarrierTackles(state, tick, emit); + + if ( + state.elapsedTicks >= MOVERS_MATCH_TICKS || + state.furniture.every((item) => item.securedBy !== null) + ) { + finishMatch(state, emit); + } + }, + validateState(state) { + return ( + Number.isFinite(state.yellowScore + state.blueScore) && + state.players.every( + (player) => + Number.isFinite(player.x + player.z + player.velocityX + player.velocityZ) && + player.stamina >= 0 && + player.stamina <= maximumStamina, + ) && + state.furniture.every( + (item) => + Number.isFinite( + item.x + item.y + item.z + + item.velocityX + item.velocityY + item.velocityZ + + item.rotationX + item.rotationY + item.rotationZ + item.rotationW + + item.angularVelocityX + item.angularVelocityY + item.angularVelocityZ + + item.damage, + ) && + item.damage >= 0 && + item.damage <= 100, + ) + ); + }, + }, + + prediction: { + createInitialState: createClientState, + cloneState: cloneClientState, + applyInput(state, input, { playerId }) { + const player = state.players.find((candidate) => candidate.id === playerId); + if (!player) return; + player.inputForward = input.forward; + player.inputStrafe = input.strafe; + player.sprinting = input.sprint; + player.grabbing = input.grab; + }, + step(state, { tick, deltaSeconds }) { + if (state.resetTicks > 0) state.resetTicks -= 1; + else state.elapsedTicks += 1; + state.yellowDoorTicks = Math.max(0, state.yellowDoorTicks - 1); + state.blueDoorTicks = Math.max(0, state.blueDoorTicks - 1); + + for (const player of state.players) { + const carrying = player.carryingId === null + ? null + : state.furniture.find((item) => item.id === player.carryingId) ?? null; + updateVisiblePlayerIntent(player, carrying, deltaSeconds); + } + updateVisibleCarrying(state); + moversPhysics.step(state, deltaSeconds); + state.events = state.events.filter( + (entry) => entry.receivedTick >= tick - eventLifetimeTicks, + ); + }, + mergeSnapshot(predicted, snapshot, { tick }) { + const merged = cloneClientState(snapshot); + merged.events = predicted.events + .filter((entry) => entry.receivedTick >= tick - eventLifetimeTicks) + .map((entry) => ({ receivedTick: entry.receivedTick, event: { ...entry.event } })); + moversPhysics.reconcile(predicted, merged); + return merged; + }, + applyEvent(state, event, { tick }) { + if (state.events.some((entry) => entry.event.id === event.id)) return; + state.events.push({ receivedTick: tick, event: { ...event } }); + if (state.events.length > 64) state.events.shift(); + }, + validateState(state) { + return ( + state.players.every( + (player) => + Number.isFinite(player.x + player.z) && + (player.stamina === null || (player.stamina >= 0 && player.stamina <= maximumStamina)), + ) && + state.furniture.every((item) => Number.isFinite( + item.x + item.y + item.z + item.rotationX + item.rotationY + item.rotationZ + item.rotationW, + )) + ); + }, + }, + + visibility: { + createSnapshot(authority, { playerId }) { + return { + players: authority.players.map((player) => playerView(player, player.id === playerId)), + furniture: authority.furniture.map(furnitureView), + yellowScore: authority.yellowScore, + blueScore: authority.blueScore, + yellowDoorTicks: authority.yellowDoorTicks, + blueDoorTicks: authority.blueDoorTicks, + elapsedTicks: authority.elapsedTicks, + round: authority.round, + resetTicks: authority.resetTicks, + winner: authority.winner, + demolitionStarted: authority.demolitionStarted, + events: [], + }; + }, + validateClientState(authority, candidate, { playerId }) { + const expected = findPlayer(authority, playerId); + const reported = candidate.players.find((player) => player.id === playerId); + return Boolean( + expected && + reported && + reported.stamina !== null && + Math.hypot(reported.x - expected.x, reported.z - expected.z) <= 4.5 && + Math.abs(reported.stamina - expected.stamina) <= 18, + ); + }, + perceive(_authority, event) { + return { ...event }; + }, + }, + + input: { + validate(input) { + return ( + typeof input === "object" && input !== null && + Number.isFinite(input.forward) && Number.isFinite(input.strafe) && + Math.abs(input.forward) <= 1 && Math.abs(input.strafe) <= 1 && + typeof input.sprint === "boolean" && typeof input.grab === "boolean" && + typeof input.throwItem === "boolean" && typeof input.closeDoors === "boolean" + ); + }, + }, + + encoding: { + input: inputCodec, + clientState: stateCodec, + perception: eventCodec, + }, +}); + +export const moversGame = withInputStream(baseMoversGame, { + heartbeatRateHz: 20, + timeoutMs: 400, + inputsEqual(left, right) { + return ( + left.forward === right.forward && left.strafe === right.strafe && + left.sprint === right.sprint && left.grab === right.grab && + left.throwItem === right.throwItem && left.closeDoors === right.closeDoors + ); + }, + neutralize: neutralInput, + resume(lastInput) { + return { ...lastInput, throwItem: false, closeDoors: false }; + }, +}); + +function neutralInput(): MoversInput { + return { + forward: 0, + strafe: 0, + sprint: false, + grab: false, + throwItem: false, + closeDoors: false, + }; +} + +function createAuthorityState(): MoversAuthorityState { + return { + players: [ + createPlayer(MOVERS_BOT_IDS[0], "yellow", true, 0), + createPlayer(MOVERS_BOT_IDS[1], "yellow", true, 1), + createPlayer(MOVERS_BOT_IDS[2], "blue", true, 2), + createPlayer(MOVERS_BOT_IDS[3], "blue", true, 3), + ], + furniture: createFurniture(), + yellowScore: 0, + blueScore: 0, + yellowDoorTicks: 0, + blueDoorTicks: 0, + elapsedTicks: 0, + round: 1, + resetTicks: 0, + winner: null, + demolitionStarted: false, + nextEventId: 1, + }; +} + +function createClientState(): MoversClientState { + return { + players: [], + furniture: [], + yellowScore: 0, + blueScore: 0, + yellowDoorTicks: 0, + blueDoorTicks: 0, + elapsedTicks: 0, + round: 1, + resetTicks: 0, + winner: null, + demolitionStarted: false, + events: [], + }; +} + +function createPlayer(id: number, team: MoversTeam, bot: boolean, slot: number): MoversAuthorityPlayer { + const spawnX = team === "yellow" ? -34 : 34; + return { + id, + team, + bot, + x: spawnX, + z: ((slot % 3) - 1) * 3, + velocityX: 0, + velocityZ: 0, + yaw: team === "yellow" ? Math.PI / 2 : -Math.PI / 2, + stamina: maximumStamina, + carryingId: null, + inputForward: 0, + inputStrafe: 0, + sprinting: false, + grabbing: false, + throwing: false, + closingDoors: false, + throwRequested: false, + closeDoorsRequested: false, + botTargetId: null, + }; +} + +function createFurniture(): MoversAuthorityFurniture[] { + const layout: Array<[FurnitureKind, number, number, number]> = [ + ["piano", -7, -5, 0.12], + ["aquarium", 7, -7, -0.24], + ["safe", 1, 5, 0], + ["sofa", -10, 7, Math.PI / 2], + ["television", 10, 4, 0.15], + ["mattress", -2, -9, -0.3], + ["urn", 0, -1, 0], + ["refrigerator", 13, -2, 0.1], + ["plant", -13, 0, -0.2], + ["mystery-box", 4, 9, 0.35], + ]; + return layout.map(([kind, x, z, yaw], index) => { + const rotation = quaternionFromYaw(yaw); + return { + id: index + 1, + kind, + x, + y: FURNITURE[kind].halfExtents.y + 0.08, + z, + velocityX: 0, + velocityY: 0, + velocityZ: 0, + yaw, + rotationX: rotation.x, + rotationY: rotation.y, + rotationZ: rotation.z, + rotationW: rotation.w, + angularVelocityX: 0, + angularVelocityY: 0, + angularVelocityZ: 0, + damage: 0, + carriedBy: null, + securedBy: null, + lastDamageTick: -1_000, + }; + }); +} + +function applyCommand(player: MoversAuthorityPlayer, input: MoversInput): void { + player.inputForward = input.forward; + player.inputStrafe = input.strafe; + player.sprinting = input.sprint; + player.grabbing = input.grab; + if (input.throwItem && !player.throwing) player.throwRequested = true; + if (input.closeDoors && !player.closingDoors) player.closeDoorsRequested = true; + player.throwing = input.throwItem; + player.closingDoors = input.closeDoors; +} + +function updateBot(state: MoversAuthorityState, player: MoversAuthorityPlayer, tick: number): void { + let targetX = 0; + let targetZ = 0; + let grab = false; + let closeDoors = false; + if (player.carryingId !== null) { + targetX = player.team === "yellow" ? -34 : 34; + targetZ = ((player.id % 3) - 1) * 3; + const reached = inTruckZone(player.team, player.x, player.z); + grab = !reached; + closeDoors = reached; + } else { + const target = chooseBotTarget(state, player); + player.botTargetId = target?.id ?? null; + if (target) { + targetX = target.x; + targetZ = target.z; + grab = Math.hypot(targetX - player.x, targetZ - player.z) < 2.6; + } else { + targetX = Math.sin(tick * 0.014 + player.id) * 8; + targetZ = Math.cos(tick * 0.011 + player.id) * 6; + } + } + const dx = targetX - player.x; + const dz = targetZ - player.z; + const distance = Math.hypot(dx, dz); + player.inputStrafe = distance > 0.3 ? clamp(dx / distance, -1, 1) : 0; + player.inputForward = distance > 0.3 ? clamp(-dz / distance, -1, 1) : 0; + player.sprinting = distance > 8 && player.stamina > 18; + player.grabbing = grab; + if (closeDoors && !player.closingDoors) player.closeDoorsRequested = true; + player.closingDoors = closeDoors; +} + +function chooseBotTarget(state: MoversAuthorityState, player: MoversAuthorityPlayer): MoversAuthorityFurniture | null { + let nearest: MoversAuthorityFurniture | null = null; + let best = Number.POSITIVE_INFINITY; + for (const item of state.furniture) { + if (item.securedBy !== null || item.carriedBy !== null) continue; + const distance = Math.hypot(item.x - player.x, item.z - player.z); + const score = distance - FURNITURE[item.kind].value / 4_000; + if (score >= best) continue; + nearest = item; + best = score; + } + return nearest; +} + +function updateAuthorityPlayerIntent( + player: MoversAuthorityPlayer, + carrying: MoversAuthorityFurniture | null, + deltaSeconds: number, +): void { + const definition = carrying ? FURNITURE[carrying.kind] : null; + const carryingScale = definition ? 1 / (0.72 + definition.weight * 0.19) : 1; + const moving = Math.hypot(player.inputStrafe, player.inputForward) > 0.05; + const canSprint = player.sprinting && moving && player.stamina > 0 && !carrying; + updateMovementIntent(player, (canSprint ? 10.8 : 7.1) * carryingScale, deltaSeconds); + player.stamina = clamp(player.stamina + (canSprint ? -34 : 23) * deltaSeconds, 0, maximumStamina); +} + +function updateVisiblePlayerIntent( + player: MoversPlayerView, + carrying: MoversFurnitureView | null, + deltaSeconds: number, +): void { + const definition = carrying ? FURNITURE[carrying.kind] : null; + const carryingScale = definition ? 1 / (0.72 + definition.weight * 0.19) : 1; + const moving = Math.hypot(player.inputStrafe, player.inputForward) > 0.05; + const stamina = player.stamina ?? maximumStamina; + const canSprint = player.sprinting && moving && stamina > 0 && !carrying; + updateMovementIntent(player, (canSprint ? 10.8 : 7.1) * carryingScale, deltaSeconds); + if (player.stamina !== null) { + player.stamina = clamp(player.stamina + (canSprint ? -34 : 23) * deltaSeconds, 0, maximumStamina); + } +} + +function updateMovementIntent( + player: Pick, + speed: number, + deltaSeconds: number, +): void { + let directionX = player.inputStrafe; + let directionZ = -player.inputForward; + const magnitude = Math.hypot(directionX, directionZ); + if (magnitude > 1) { + directionX /= magnitude; + directionZ /= magnitude; + } + const acceleration = 42 * deltaSeconds; + player.velocityX = approach(player.velocityX, directionX * speed, acceleration); + player.velocityZ = approach(player.velocityZ, directionZ * speed, acceleration); + if (magnitude < 0.05) { + const damping = Math.exp(-11 * deltaSeconds); + player.velocityX *= damping; + player.velocityZ *= damping; + } else { + player.yaw = rotateToward(player.yaw, Math.atan2(directionX, directionZ), 9 * deltaSeconds); + } +} + +function updateCarrying(state: MoversAuthorityState, emit: (event: MoversAuthorityEvent) => void): void { + for (const player of state.players) { + if (player.carryingId === null) continue; + const item = findFurniture(state, player.carryingId); + if (!item || item.securedBy !== null) { + player.carryingId = null; + continue; + } + if (player.throwRequested) { + releaseFurniture(item, player, true); + player.carryingId = null; + emit({ id: state.nextEventId++, type: "thrown", playerId: player.id, itemId: item.id, team: player.team }); + } else if (!player.grabbing) { + releaseFurniture(item, player, false); + player.carryingId = null; + } else { + anchorFurniture(item, player); + } + } + + for (const player of state.players) { + if (player.carryingId !== null || !player.grabbing) continue; + let nearest: MoversAuthorityFurniture | null = null; + let nearestDistance = 2.65; + for (const item of state.furniture) { + if (item.carriedBy !== null || item.securedBy !== null) continue; + const distance = Math.hypot(item.x - player.x, item.z - player.z); + if (distance >= nearestDistance) continue; + nearest = item; + nearestDistance = distance; + } + if (!nearest) continue; + nearest.carriedBy = player.id; + player.carryingId = nearest.id; + anchorFurniture(nearest, player); + emit({ id: state.nextEventId++, type: "grabbed", playerId: player.id, itemId: nearest.id, team: player.team }); + } +} + +function updateVisibleCarrying(state: MoversClientState): void { + for (const player of state.players) { + if (player.carryingId === null) continue; + const item = state.furniture.find((candidate) => candidate.id === player.carryingId); + if (item) anchorFurniture(item, player); + } +} + +function anchorFurniture( + item: MoversAuthorityFurniture | MoversFurnitureView, + player: Pick, +): void { + const definition = FURNITURE[item.kind]; + const distance = 1.05 + definition.radius * 0.62; + item.x = player.x + Math.sin(player.yaw) * distance; + item.y = Math.max(definition.halfExtents.y + 0.1, 1.45); + item.z = player.z + Math.cos(player.yaw) * distance; + item.velocityX = player.velocityX; + item.velocityY = 0; + item.velocityZ = player.velocityZ; + item.yaw = player.yaw; + const rotation = quaternionFromYaw(player.yaw); + item.rotationX = rotation.x; + item.rotationY = rotation.y; + item.rotationZ = rotation.z; + item.rotationW = rotation.w; + item.angularVelocityX = 0; + item.angularVelocityY = 0; + item.angularVelocityZ = 0; +} + +function releaseFurniture(item: MoversAuthorityFurniture, player: MoversAuthorityPlayer, thrown: boolean): void { + const forwardX = Math.sin(player.yaw); + const forwardZ = Math.cos(player.yaw); + item.carriedBy = null; + item.velocityX = player.velocityX + forwardX * (thrown ? 12 : 0); + item.velocityY = thrown ? 4.2 : 0; + item.velocityZ = player.velocityZ + forwardZ * (thrown ? 12 : 0); + item.angularVelocityX = thrown ? (player.id % 2 === 0 ? 5.2 : -5.2) : 0; + item.angularVelocityY = thrown ? 2.4 : 0; + item.angularVelocityZ = thrown ? (player.id % 2 === 0 ? -3.6 : 3.6) : 0; +} + +function resolveCarrierTackles( + state: MoversAuthorityState, + tick: number, + emit: (event: MoversAuthorityEvent) => void, +): void { + for (let leftIndex = 0; leftIndex < state.players.length; leftIndex += 1) { + const left = state.players[leftIndex]!; + for (let rightIndex = leftIndex + 1; rightIndex < state.players.length; rightIndex += 1) { + const right = state.players[rightIndex]!; + if (left.team === right.team || Math.hypot(right.x - left.x, right.z - left.z) > 1.3) continue; + const speed = Math.hypot(left.velocityX - right.velocityX, left.velocityZ - right.velocityZ); + if (speed < 8.2) continue; + const carrier = left.carryingId !== null ? left : right.carryingId !== null ? right : null; + if (!carrier || tick % 2 !== carrier.id % 2) continue; + const item = findFurniture(state, carrier.carryingId!); + if (!item) continue; + carrier.carryingId = null; + carrier.grabbing = false; + releaseFurniture(item, carrier, true); + emit({ id: state.nextEventId++, type: "thrown", playerId: carrier.id, itemId: item.id, team: carrier.team }); + } + } +} + +function tryCloseTruck( + state: MoversAuthorityState, + player: MoversAuthorityPlayer, + emit: (event: MoversAuthorityEvent) => void, +): void { + if (!nearOwnTruck(player) || doorTicks(state, player.team) > 0) return; + if (player.carryingId !== null) { + const held = findFurniture(state, player.carryingId); + if (held && inTruckZone(player.team, held.x, held.z)) { + held.carriedBy = null; + player.carryingId = null; + } + } + const cargo = state.furniture.filter( + (item) => item.securedBy === null && item.carriedBy === null && inTruckZone(player.team, item.x, item.z), + ); + if (cargo.length === 0) return; + setDoorTicks(state, player.team, MOVERS_TICK_RATE * 2); + emit({ id: state.nextEventId++, type: "doors", team: player.team }); + cargo.forEach((item, index) => { + item.securedBy = player.team; + item.velocityX = 0; + item.velocityY = 0; + item.velocityZ = 0; + item.angularVelocityX = 0; + item.angularVelocityY = 0; + item.angularVelocityZ = 0; + item.x = player.team === "yellow" ? -36 - (index % 2) * 2.2 : 36 + (index % 2) * 2.2; + item.y = FURNITURE[item.kind].halfExtents.y + 0.08; + item.z = -3 + Math.floor(index / 2) * 2.5; + item.yaw = 0; + item.rotationX = 0; + item.rotationY = 0; + item.rotationZ = 0; + item.rotationW = 1; + const value = remainingValue(item); + if (player.team === "yellow") state.yellowScore += value; + else state.blueScore += value; + emit({ + id: state.nextEventId++, + type: "secured", + playerId: player.id, + itemId: item.id, + kind: item.kind, + team: player.team, + value, + }); + }); +} + +function demolitionPulse( + state: MoversAuthorityState, + tick: number, + emit: (event: MoversAuthorityEvent) => void, +): void { + const angle = tick * 0.031; + moversPhysics.explode(state, { + position: { x: Math.cos(angle) * 6, y: 0.4, z: Math.sin(angle * 1.3) * 5 }, + radius: 22, + falloff: 3, + impulsePerArea: 5.5, + }); + for (const item of state.furniture) { + if (item.securedBy !== null || item.carriedBy !== null) continue; + damageFurniture(state, item, 3.5, tick, emit); + } +} + +function damageFurniture( + state: MoversAuthorityState, + item: MoversAuthorityFurniture, + rawAmount: number, + tick: number, + emit: (event: MoversAuthorityEvent) => void, +): void { + const amount = Math.min(100 - item.damage, rawAmount * FURNITURE[item.kind].fragile); + if (amount <= 0.1) return; + item.damage = clamp(item.damage + amount, 0, 100); + if (tick - item.lastDamageTick < 5) return; + item.lastDamageTick = tick; + emit({ + id: state.nextEventId++, + type: "damaged", + itemId: item.id, + amount: Math.round(amount), + remainingValue: remainingValue(item), + }); +} + +function finishMatch(state: MoversAuthorityState, emit: (event: MoversAuthorityEvent) => void): void { + if (state.resetTicks > 0) return; + state.winner = state.yellowScore === state.blueScore + ? "draw" + : state.yellowScore > state.blueScore ? "yellow" : "blue"; + state.resetTicks = MOVERS_TICK_RATE * 7; + emit({ id: state.nextEventId++, type: "winner", team: state.winner, round: state.round }); +} + +function resetMatch(state: MoversAuthorityState): void { + state.round += 1; + state.yellowScore = 0; + state.blueScore = 0; + state.yellowDoorTicks = 0; + state.blueDoorTicks = 0; + state.elapsedTicks = 0; + state.winner = null; + state.demolitionStarted = false; + state.furniture = createFurniture(); + state.players.forEach((player, index) => { + Object.assign(player, createPlayer(player.id, player.team, player.bot, index)); + }); + moversPhysics.reset(state); +} + +function playerView(player: MoversAuthorityPlayer, owner: boolean): MoversPlayerView { + return { + id: player.id, + team: player.team, + bot: player.bot, + x: player.x, + z: player.z, + velocityX: player.velocityX, + velocityZ: player.velocityZ, + yaw: player.yaw, + stamina: owner ? player.stamina : null, + carryingId: player.carryingId, + inputForward: player.inputForward, + inputStrafe: player.inputStrafe, + sprinting: player.sprinting, + grabbing: player.grabbing, + }; +} + +function furnitureView(item: MoversAuthorityFurniture): MoversFurnitureView { + return { + id: item.id, + kind: item.kind, + x: item.x, + y: item.y, + z: item.z, + velocityX: item.velocityX, + velocityY: item.velocityY, + velocityZ: item.velocityZ, + yaw: item.yaw, + rotationX: item.rotationX, + rotationY: item.rotationY, + rotationZ: item.rotationZ, + rotationW: item.rotationW, + angularVelocityX: item.angularVelocityX, + angularVelocityY: item.angularVelocityY, + angularVelocityZ: item.angularVelocityZ, + damage: item.damage, + carriedBy: item.carriedBy, + securedBy: item.securedBy, + }; +} + +function cloneAuthorityState(state: MoversAuthorityState): MoversAuthorityState { + return { + ...state, + players: state.players.map((player) => ({ ...player })), + furniture: state.furniture.map((item) => ({ ...item })), + }; +} + +function cloneClientState(state: MoversClientState): MoversClientState { + return { + ...state, + players: state.players.map((player) => ({ ...player })), + furniture: state.furniture.map((item) => ({ ...item })), + events: state.events.map((entry) => ({ receivedTick: entry.receivedTick, event: { ...entry.event } })), + }; +} + +function findPlayer(state: MoversAuthorityState, id: number): MoversAuthorityPlayer | undefined { + return state.players.find((player) => player.id === id); +} + +function findFurniture(state: MoversAuthorityState, id: number): MoversAuthorityFurniture | undefined { + return state.furniture.find((item) => item.id === id); +} + +function leastPopulatedTeam(state: MoversAuthorityState): MoversTeam { + const yellow = state.players.filter((player) => player.team === "yellow").length; + return yellow <= state.players.length - yellow ? "yellow" : "blue"; +} + +function nearOwnTruck(player: Pick): boolean { + return player.team === "yellow" + ? player.x < -27 && Math.abs(player.z) < 10 + : player.x > 27 && Math.abs(player.z) < 10; +} + +function inTruckZone(team: MoversTeam, x: number, z: number): boolean { + return team === "yellow" + ? x < -29 && x > -41 && Math.abs(z) < 8 + : x > 29 && x < 41 && Math.abs(z) < 8; +} + +function doorTicks(state: MoversAuthorityState, team: MoversTeam): number { + return team === "yellow" ? state.yellowDoorTicks : state.blueDoorTicks; +} + +function setDoorTicks(state: MoversAuthorityState, team: MoversTeam, ticks: number): void { + if (team === "yellow") state.yellowDoorTicks = ticks; + else state.blueDoorTicks = ticks; +} + +function remainingValue(item: Pick): number { + return Math.max(25, Math.round(FURNITURE[item.kind].value * (1 - item.damage / 100))); +} + +function quaternionFromYaw(yaw: number): { x: number; y: number; z: number; w: number } { + return { x: 0, y: Math.sin(yaw / 2), z: 0, w: Math.cos(yaw / 2) }; +} + +function rotateToward(current: number, target: number, maximum: number): number { + const difference = normalizeAngle(target - current); + return normalizeAngle(current + clamp(difference, -maximum, maximum)); +} + +function normalizeAngle(value: number): number { + return Math.atan2(Math.sin(value), Math.cos(value)); +} + +function approach(value: number, target: number, maximumDelta: number): number { + return value < target + ? Math.min(target, value + maximumDelta) + : Math.max(target, value - maximumDelta); +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)); +} diff --git a/packages/shared/src/movers-types.ts b/packages/shared/src/movers-types.ts new file mode 100644 index 0000000..f9662f6 --- /dev/null +++ b/packages/shared/src/movers-types.ts @@ -0,0 +1,161 @@ +export type MoversTeam = "yellow" | "blue"; + +export type FurnitureKind = + | "piano" + | "aquarium" + | "safe" + | "sofa" + | "television" + | "mattress" + | "urn" + | "refrigerator" + | "plant" + | "mystery-box"; + +export interface MoversInput { + forward: number; + strafe: number; + sprint: boolean; + grab: boolean; + throwItem: boolean; + closeDoors: boolean; +} + +export interface MoversAuthorityPlayer { + id: number; + team: MoversTeam; + bot: boolean; + x: number; + z: number; + velocityX: number; + velocityZ: number; + yaw: number; + stamina: number; + carryingId: number | null; + inputForward: number; + inputStrafe: number; + sprinting: boolean; + grabbing: boolean; + throwing: boolean; + closingDoors: boolean; + throwRequested: boolean; + closeDoorsRequested: boolean; + botTargetId: number | null; +} + +export interface MoversAuthorityFurniture { + id: number; + kind: FurnitureKind; + x: number; + y: number; + z: number; + velocityX: number; + velocityY: number; + velocityZ: number; + yaw: number; + rotationX: number; + rotationY: number; + rotationZ: number; + rotationW: number; + angularVelocityX: number; + angularVelocityY: number; + angularVelocityZ: number; + damage: number; + carriedBy: number | null; + securedBy: MoversTeam | null; + lastDamageTick: number; +} + +export interface MoversAuthorityState { + players: MoversAuthorityPlayer[]; + furniture: MoversAuthorityFurniture[]; + yellowScore: number; + blueScore: number; + yellowDoorTicks: number; + blueDoorTicks: number; + elapsedTicks: number; + round: number; + resetTicks: number; + winner: MoversTeam | "draw" | null; + demolitionStarted: boolean; + nextEventId: number; +} + +export interface MoversPlayerView { + id: number; + team: MoversTeam; + bot: boolean; + x: number; + z: number; + velocityX: number; + velocityZ: number; + yaw: number; + /** Exact stamina is private to its owning player. */ + stamina: number | null; + carryingId: number | null; + inputForward: number; + inputStrafe: number; + sprinting: boolean; + grabbing: boolean; +} + +export interface MoversFurnitureView { + id: number; + kind: FurnitureKind; + x: number; + y: number; + z: number; + velocityX: number; + velocityY: number; + velocityZ: number; + yaw: number; + rotationX: number; + rotationY: number; + rotationZ: number; + rotationW: number; + angularVelocityX: number; + angularVelocityY: number; + angularVelocityZ: number; + damage: number; + carriedBy: number | null; + securedBy: MoversTeam | null; +} + +export interface MoversClientState { + players: MoversPlayerView[]; + furniture: MoversFurnitureView[]; + yellowScore: number; + blueScore: number; + yellowDoorTicks: number; + blueDoorTicks: number; + elapsedTicks: number; + round: number; + resetTicks: number; + winner: MoversTeam | "draw" | null; + demolitionStarted: boolean; + events: MoversPresentationEvent[]; +} + +export type MoversAuthorityEvent = + | { id: number; type: "grabbed"; playerId: number; itemId: number; team: MoversTeam } + | { id: number; type: "thrown"; playerId: number; itemId: number; team: MoversTeam } + | { id: number; type: "damaged"; itemId: number; amount: number; remainingValue: number } + | { id: number; type: "secured"; playerId: number; itemId: number; kind: FurnitureKind; team: MoversTeam; value: number } + | { id: number; type: "doors"; team: MoversTeam } + | { id: number; type: "demolition" } + | { id: number; type: "winner"; team: MoversTeam | "draw"; round: number }; + +export type MoversPerception = MoversAuthorityEvent; + +export interface MoversPresentationEvent { + receivedTick: number; + event: MoversPerception; +} + +export interface MoversGameContract { + authority: MoversAuthorityState; + client: MoversClientState; + input: MoversInput; + authorityEvent: MoversAuthorityEvent; + perceptionEvent: MoversPerception; +} diff --git a/packages/shared/test/movers.test.mjs b/packages/shared/test/movers.test.mjs new file mode 100644 index 0000000..c9e4fe8 --- /dev/null +++ b/packages/shared/test/movers.test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + FURNITURE, + MOVERS_BOT_IDS, + MOVERS_PHYSICS_BACKEND, + MOVERS_SNAPSHOT_RATE, + MOVERS_TICK_RATE, + moversGame, +} from "../dist/index.js"; + +function inputPacket(sequence, targetTick, input = {}) { + return { + sequence, + targetTick, + observedTick: Math.max(0, targetTick - 1), + input: { + forward: 0, + strafe: 0, + sprint: false, + grab: false, + throwItem: false, + closeDoors: false, + ...input, + }, + }; +} + +test("Bad Movers is a fourth complete game built through defineMultiplayerGame", () => { + assert.equal(moversGame.tickRateHz, MOVERS_TICK_RATE); + assert.equal(moversGame.snapshotRateHz, MOVERS_SNAPSHOT_RATE); + const server = moversGame.createServer(); + assert.equal(server.currentState.players.filter((player) => player.bot).length, MOVERS_BOT_IDS.length); + assert.equal(server.currentState.furniture.length, Object.keys(FURNITURE).length); + assert.deepEqual(MOVERS_PHYSICS_BACKEND, { + name: "Box3D", + version: "0.1.0", + bindingVersion: "0.2.0", + runtime: "WebAssembly SIMD", + solver: "single-threaded deterministic", + subSteps: 4, + }); + + server.addPlayer(1); + server.addPlayer(2); + assert.equal(server.currentState.players.find((player) => player.id === 1).team, "yellow"); + assert.equal(server.currentState.players.find((player) => player.id === 2).team, "blue"); +}); + +test("real Box3D gravity advances full 3D furniture transforms", () => { + const server = moversGame.createServer(); + const box = server.currentState.furniture.find((item) => item.kind === "mystery-box"); + Object.assign(box, { + x: 0, + y: 8, + z: 0, + velocityX: 0, + velocityY: 0, + velocityZ: 0, + }); + for (let tick = 0; tick < 12; tick += 1) server.step(); + assert.ok(box.y < 7, `expected Box3D gravity to drop the box, got y=${box.y}`); + assert.ok(box.velocityY < 0); + assert.ok(Number.isFinite(box.rotationX + box.rotationY + box.rotationZ + box.rotationW)); +}); + +test("independent Box3D authorities produce the same deterministic match", () => { + const first = moversGame.createServer(); + const second = moversGame.createServer(); + for (let tick = 0; tick < 600; tick += 1) { + first.step(); + second.step(); + } + assert.deepEqual(first.currentState, second.currentState); +}); + +test("only the owning mover receives exact stamina", () => { + const server = moversGame.createServer(); + server.addPlayer(1); + server.addPlayer(2); + const yellowView = server.createSnapshot(1, 0).state; + const blueView = server.createSnapshot(2, 0).state; + assert.equal(yellowView.players.find((player) => player.id === 1).stamina, 100); + assert.equal(yellowView.players.find((player) => player.id === 2).stamina, null); + assert.equal(blueView.players.find((player) => player.id === 1).stamina, null); + assert.equal(blueView.players.find((player) => player.id === 2).stamina, 100); + assert.equal("nextEventId" in yellowView, false); +}); + +test("furniture only scores when a mover closes their own truck doors", () => { + const server = moversGame.createServer(); + server.addPlayer(1); + const player = server.currentState.players.find((candidate) => candidate.id === 1); + const urn = server.currentState.furniture.find((item) => item.kind === "urn"); + Object.assign(player, { x: -34, z: 0 }); + Object.assign(player, { carryingId: urn.id, grabbing: false }); + Object.assign(urn, { x: -35, z: 0, damage: 10, carriedBy: player.id }); + assert.equal(server.currentState.yellowScore, 0); + + assert.equal(server.submitInput(1, inputPacket(1, 1, { closeDoors: true })).accepted, true); + const events = server.step().events; + assert.equal(urn.securedBy, "yellow"); + assert.equal(server.currentState.yellowScore, Math.round(FURNITURE.urn.value * 0.9)); + assert.ok(events.some((event) => event.type === "doors" && event.team === "yellow")); + assert.ok(events.some((event) => event.type === "secured" && event.itemId === urn.id)); +}); + +test("Bad Movers bots can complete physical deliveries headlessly", () => { + const server = moversGame.createServer(); + let secured = 0; + for (let tick = 0; tick < 2_400; tick += 1) { + for (const event of server.step().events) { + if (event.type === "secured") secured += 1; + } + } + assert.ok(secured >= 2, `expected bot deliveries, saw ${secured}`); + assert.ok(server.currentState.yellowScore + server.currentState.blueScore > 0); + assert.ok(server.currentState.furniture.every((item) => Number.isFinite(item.x + item.z + item.damage))); +}); + +test("Bad Movers input and state round-trip through the generic protocol", () => { + const server = moversGame.createServer(); + server.addPlayer(7); + const client = moversGame.createClient(); + client.initialize(7, server.createSnapshot(7, 10)); + const packet = client.createInput({ + forward: 1, + strafe: -0.5, + sprint: true, + grab: true, + throwItem: false, + closeDoors: false, + }, 1); + const decoded = moversGame.protocol.decodeClient( + moversGame.protocol.encodeClient({ kind: "input", packet }), + ); + assert.equal(decoded.kind, "input"); + assert.deepEqual(decoded.packet, packet); + assert.equal(server.submitInput(7, decoded.packet).accepted, true); + server.step(); + client.step(); + client.reconcile(server.createSnapshot(7, 20)); + assert.ok(client.currentState.players.some((player) => player.id === 7)); +});