add Box3D-powered Bad Movers game
All checks were successful
build / image (push) Successful in 1m39s
All checks were successful
build / image (push) Successful in 1m39s
This commit is contained in:
27
README.md
27
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<State>()` 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<GameState>()({
|
||||
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
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" ? <ShooterGame /> : demo === "flux" ? <FluxGame /> : <RoyaleGame />}
|
||||
{demo === "arena" ? <ShooterGame /> : demo === "flux" ? <FluxGame /> : demo === "royale" ? <RoyaleGame /> : <MoversGame />}
|
||||
<nav className="game-switcher" aria-label="Example game selector">
|
||||
<button className={demo === "arena" ? "is-active" : ""} onClick={() => setDemo("arena")} type="button">
|
||||
ARENA
|
||||
@@ -36,6 +39,9 @@ export function App() {
|
||||
<button className={demo === "royale" ? "is-active" : ""} onClick={() => setDemo("royale")} type="button">
|
||||
SYNCER ROYALE
|
||||
</button>
|
||||
<button className={demo === "movers" ? "is-active" : ""} onClick={() => setDemo("movers")} type="button">
|
||||
BAD MOVERS
|
||||
</button>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
|
||||
692
apps/web/src/Movers3D.tsx
Normal file
692
apps/web/src/Movers3D.tsx
Normal file
@@ -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<number, THREE.Group>;
|
||||
furniture: Map<number, THREE.Group>;
|
||||
trucks: Record<MoversTeam, TruckRig>;
|
||||
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<HTMLDivElement>(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 <div className="movers-viewport" ref={hostRef} />;
|
||||
}
|
||||
|
||||
function updateRuntime(
|
||||
runtime: MoversRuntime,
|
||||
world: MoversClientState,
|
||||
playerId: number | null,
|
||||
lastEventIdRef: MutableRefObject<number>,
|
||||
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<number>,
|
||||
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());
|
||||
}
|
||||
224
apps/web/src/MoversGame.tsx
Normal file
224
apps/web/src/MoversGame.tsx
Normal file
@@ -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 (
|
||||
<main className={`movers-game movers-game--${team}${client.world.demolitionStarted ? " movers-game--demolition" : ""}`}>
|
||||
<Movers3D world={client.world} playerId={client.playerId} />
|
||||
<div className="movers-grade" aria-hidden="true" />
|
||||
|
||||
{!started ? (
|
||||
<button className="movers-start" onClick={() => setStarted(true)} type="button">
|
||||
<small>FURNITURE IS FRAGILE // FRIENDSHIPS AREN'T</small>
|
||||
<strong>START YOUR SHIFT</strong>
|
||||
<span>WASD MOVE · SHIFT SPRINT · HOLD SPACE GRAB · E THROW · F CLOSE TRUCK</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<header className="movers-header">
|
||||
<div className="movers-brand">
|
||||
<i>BM</i>
|
||||
<div>
|
||||
<small>FAST · FRAGILE · FULLY INSURED</small>
|
||||
<strong>BAD MOVERS</strong>
|
||||
</div>
|
||||
<span className="movers-physics">{MOVERS_PHYSICS_BACKEND.name} · WASM</span>
|
||||
</div>
|
||||
<section className="movers-score" aria-label="Moving company score">
|
||||
<div><small>YELLOW & SONS</small><strong>${client.world.yellowScore.toLocaleString()}</strong></div>
|
||||
<span>
|
||||
<small>{client.world.demolitionStarted ? "DEMOLITION" : `ROUND ${client.world.round}`}</small>
|
||||
<b>{formatTime(remainingTicks)}</b>
|
||||
</span>
|
||||
<div><small>BLUE CREW</small><strong>${client.world.blueScore.toLocaleString()}</strong></div>
|
||||
</section>
|
||||
<div className={`movers-live movers-live--${client.connection}`}>
|
||||
<i />
|
||||
<span>{client.connection}</span>
|
||||
<small>{client.network.roundTripTime.toFixed(0)} ms · {client.validation}</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="movers-manifest" aria-label="Unsecured furniture manifest">
|
||||
<header><small>UNSECURED MANIFEST</small><b>{remaining.length} LEFT</b></header>
|
||||
{remaining.slice(0, 6).map((item) => (
|
||||
<ManifestItem item={item} key={item.id} />
|
||||
))}
|
||||
<p>Nothing scores until the truck doors close.</p>
|
||||
</section>
|
||||
|
||||
<section className="movers-roster" aria-label="Crew roster">
|
||||
<Crew team="yellow" players={client.world.players} localId={client.playerId} />
|
||||
<Crew team="blue" players={client.world.players} localId={client.playerId} />
|
||||
</section>
|
||||
|
||||
{recent ? <EventToast event={recent.event} /> : null}
|
||||
|
||||
{client.world.demolitionStarted && !client.world.winner ? (
|
||||
<div className="movers-warning">
|
||||
<small>BUILDING CONDEMNED</small>
|
||||
<strong>GET THE GOOD STUFF OUT</strong>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{client.world.winner ? (
|
||||
<section className={`movers-winner movers-winner--${client.world.winner}`}>
|
||||
<small>FINAL INSURANCE ASSESSMENT</small>
|
||||
<strong>{client.world.winner === "draw" ? "EVERYONE GETS FIRED" : `${client.world.winner.toUpperCase()} CREW WINS`}</strong>
|
||||
<span>Next address in {Math.ceil(client.world.resetTicks / MOVERS_TICK_RATE)} seconds</span>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<footer className="movers-hud">
|
||||
<div className="movers-contract">
|
||||
<small>YOUR CREW</small>
|
||||
<strong>{local ? `${local.team.toUpperCase()} // ${local.bot ? "TEMP" : `MOVER ${local.id}`}` : "CLOCKING IN"}</strong>
|
||||
<span>Tick {client.tick} · lead {client.inputLeadTicks}t · jitter {client.network.jitter.toFixed(1)}ms</span>
|
||||
</div>
|
||||
|
||||
<div className={`movers-carry${carried ? " movers-carry--active" : ""}`}>
|
||||
<small>{carried ? "CURRENTLY ENDANGERING" : "HANDS EMPTY"}</small>
|
||||
<strong>{carried ? FURNITURE[carried.kind].name : "FIND SOMETHING EXPENSIVE"}</strong>
|
||||
<span>{carried ? `$${remainingValue(carried).toLocaleString()} remaining · ${Math.round(carried.damage)}% damaged` : "Hold SPACE near furniture to grab it"}</span>
|
||||
</div>
|
||||
|
||||
<div className="movers-actions">
|
||||
<button
|
||||
className="movers-action movers-action--grab"
|
||||
onPointerDown={() => client.setControl("grab", true)}
|
||||
onPointerUp={() => client.setControl("grab", false)}
|
||||
onPointerCancel={() => client.setControl("grab", false)}
|
||||
onPointerLeave={() => client.setControl("grab", false)}
|
||||
type="button"
|
||||
>
|
||||
<b>SPACE</b><span>{carried ? "HOLDING" : "GRAB"}</span>
|
||||
</button>
|
||||
<button disabled={!carried} onClick={() => pulse("throwItem")} type="button"><b>E</b><span>THROW</span></button>
|
||||
<button onClick={() => pulse("closeDoors")} type="button"><b>F</b><span>CLOSE TRUCK</span></button>
|
||||
</div>
|
||||
|
||||
<div className="movers-stamina">
|
||||
<div><small>WORKER'S COMP</small><strong>{Math.round(local?.stamina ?? 0)}%</strong></div>
|
||||
<span><i style={{ width: `${local?.stamina ?? 0}%` }} /></span>
|
||||
<em>SHIFT TO SPRINT · RIVAL TRUCKS ARE NOT SACRED</em>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ManifestItem({ item }: { item: MoversFurnitureView }) {
|
||||
const value = remainingValue(item);
|
||||
return (
|
||||
<div className={item.damage > 45 ? "is-damaged" : ""}>
|
||||
<i>{icon(item.kind)}</i>
|
||||
<span><b>{FURNITURE[item.kind].name}</b><small>{Math.round(item.damage)}% DAMAGE</small></span>
|
||||
<strong>${value.toLocaleString()}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Crew({ team, players, localId }: { team: MoversTeam; players: MoversPlayerView[]; localId: number | null }) {
|
||||
return (
|
||||
<div className={`movers-crew movers-crew--${team}`}>
|
||||
<header><small>{team} crew</small><b>{players.filter((player) => player.team === team).length}</b></header>
|
||||
{players.filter((player) => player.team === team).map((player) => (
|
||||
<div className={player.id === localId ? "is-local" : ""} key={player.id}>
|
||||
<i />
|
||||
<span>{player.bot ? `TEMP ${player.id % 100}` : `MOVER ${player.id}`}</span>
|
||||
<b>{player.carryingId ? "LIFTING" : player.sprinting ? "RUNNING" : "AVAILABLE"}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <div className={`movers-event movers-event--${team}`}>{text}</div>;
|
||||
}
|
||||
|
||||
function remainingValue(item: Pick<MoversFurnitureView, "kind" | "damage">): 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 "?";
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
275
apps/web/src/useMoversClient.ts
Normal file
275
apps/web/src/useMoversClient.ts
Normal file
@@ -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<Omit<MoversClientView, "setControl">>({
|
||||
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<string>();
|
||||
const inputStream = createInputStateStream<MoversInput>(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<ArrayBuffer>) => {
|
||||
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 };
|
||||
}
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
32
packages/engine/src/physics-backend.ts
Normal file
32
packages/engine/src/physics-backend.ts
Normal file
@@ -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<State, StepResult = void> {
|
||||
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<State, StepResult = void>() {
|
||||
return <Backend extends PhysicsBackend<State, StepResult>>(
|
||||
backend: Backend,
|
||||
): Readonly<Backend> => Object.freeze(backend);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
124
packages/shared/src/box3d-wasm.d.ts
vendored
Normal file
124
packages/shared/src/box3d-wasm.d.ts
vendored
Normal file
@@ -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<Box3DModule>;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
448
packages/shared/src/movers-box3d.ts
Normal file
448
packages/shared/src/movers-box3d.ts
Normal file
@@ -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<number, Body>;
|
||||
furniture: Map<number, FurnitureBody>;
|
||||
pendingExplosions: Array<{
|
||||
position: Vec3;
|
||||
radius: number;
|
||||
falloff: number;
|
||||
impulsePerArea: number;
|
||||
}>;
|
||||
round: number;
|
||||
}
|
||||
|
||||
const box3d = await Box3D();
|
||||
const runtimes = new WeakMap<MoversPhysicsState, PhysicsRuntime>();
|
||||
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<number, number>();
|
||||
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),
|
||||
);
|
||||
}
|
||||
121
packages/shared/src/movers-config.ts
Normal file
121
packages/shared/src/movers-config.ts
Normal file
@@ -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<FurnitureKind, FurnitureDefinition> = {
|
||||
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;
|
||||
|
||||
827
packages/shared/src/movers-game.ts
Normal file
827
packages/shared/src/movers-game.ts
Normal file
@@ -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<MoversInput>();
|
||||
const stateCodec = createJsonCodec<MoversClientState>();
|
||||
const eventCodec = createJsonCodec<MoversPerception>();
|
||||
const eventLifetimeTicks = MOVERS_TICK_RATE * 6;
|
||||
const maximumStamina = 100;
|
||||
|
||||
const baseMoversGame = defineMultiplayerGame<MoversGameContract>({
|
||||
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<MoversAuthorityPlayer, "velocityX" | "velocityZ" | "yaw" | "inputForward" | "inputStrafe">,
|
||||
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<MoversAuthorityPlayer, "x" | "z" | "velocityX" | "velocityZ" | "yaw">,
|
||||
): 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<MoversAuthorityPlayer, "team" | "x" | "z">): 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<MoversAuthorityFurniture, "kind" | "damage">): 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));
|
||||
}
|
||||
161
packages/shared/src/movers-types.ts
Normal file
161
packages/shared/src/movers-types.ts
Normal file
@@ -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;
|
||||
}
|
||||
144
packages/shared/test/movers.test.mjs
Normal file
144
packages/shared/test/movers.test.mjs
Normal file
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user