add Box3D-powered Bad Movers game
All checks were successful
build / image (push) Successful in 1m39s

This commit is contained in:
Syncer Deploy
2026-08-31 12:32:39 -04:00
parent 147baf6b13
commit 38ac419204
18 changed files with 3320 additions and 10 deletions

View File

@@ -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";

View File

@@ -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
View 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
View 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 &amp; 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 "?";
}
}

View File

@@ -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; }
}

View 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 };
}