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:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user