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:
@@ -55,6 +55,11 @@ export type {
|
||||
SnapshotBatch,
|
||||
} from "./networked-types.js";
|
||||
export { NetworkClock, type NetworkStats } from "./network-clock.js";
|
||||
export {
|
||||
definePhysicsBackend,
|
||||
type PhysicsBackend,
|
||||
type PhysicsBackendMetadata,
|
||||
} from "./physics-backend.js";
|
||||
export {
|
||||
createBinaryProtocol,
|
||||
type BinaryProtocol,
|
||||
|
||||
32
packages/engine/src/physics-backend.ts
Normal file
32
packages/engine/src/physics-backend.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/** Runtime information a game may expose without coupling itself to an SDK. */
|
||||
export interface PhysicsBackendMetadata {
|
||||
readonly name: string;
|
||||
readonly runtime: string;
|
||||
readonly version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pluggable deterministic physics runtime for a serializable game state.
|
||||
*
|
||||
* Native/WASM handles stay inside the backend. The engine only checkpoints the
|
||||
* plain State value, so a backend must be able to initialize from that value
|
||||
* and reconcile an existing runtime to a fresh snapshot.
|
||||
*/
|
||||
export interface PhysicsBackend<State, StepResult = void> {
|
||||
readonly metadata: PhysicsBackendMetadata;
|
||||
initialize(state: State): void;
|
||||
step(state: State, deltaSeconds: number): StepResult;
|
||||
reconcile(predicted: State, snapshot: State): void;
|
||||
reset(state: State): void;
|
||||
dispose?(state: State): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Developer-facing HOF used to define a typed physics plug-in once and retain
|
||||
* any game-specific methods added by that plug-in.
|
||||
*/
|
||||
export function definePhysicsBackend<State, StepResult = void>() {
|
||||
return <Backend extends PhysicsBackend<State, StepResult>>(
|
||||
backend: Backend,
|
||||
): Readonly<Backend> => Object.freeze(backend);
|
||||
}
|
||||
@@ -15,7 +15,8 @@
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@syncer/engine": "0.0.0"
|
||||
"@syncer/engine": "0.0.0",
|
||||
"box3d-wasm": "^0.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsc --watch --preserveWatchOutput",
|
||||
|
||||
124
packages/shared/src/box3d-wasm.d.ts
vendored
Normal file
124
packages/shared/src/box3d-wasm.d.ts
vendored
Normal file
@@ -0,0 +1,124 @@
|
||||
declare module "box3d-wasm/standard" {
|
||||
export interface Vec3 {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface Quat extends Vec3 {
|
||||
w: number;
|
||||
}
|
||||
|
||||
export interface Transform {
|
||||
position: Vec3;
|
||||
rotation: Quat;
|
||||
}
|
||||
|
||||
export type BodyType = "static" | "kinematic" | "dynamic";
|
||||
|
||||
export interface MotionLocks {
|
||||
linearX?: boolean;
|
||||
linearY?: boolean;
|
||||
linearZ?: boolean;
|
||||
angularX?: boolean;
|
||||
angularY?: boolean;
|
||||
angularZ?: boolean;
|
||||
}
|
||||
|
||||
export interface BodyDefinition {
|
||||
type: BodyType;
|
||||
position?: Vec3;
|
||||
rotation?: Quat;
|
||||
linearVelocity?: Vec3;
|
||||
angularVelocity?: Vec3;
|
||||
linearDamping?: number;
|
||||
angularDamping?: number;
|
||||
gravityScale?: number;
|
||||
motionLocks?: MotionLocks;
|
||||
isBullet?: boolean;
|
||||
userData?: number;
|
||||
}
|
||||
|
||||
export interface ShapeDefinition {
|
||||
density?: number;
|
||||
friction?: number;
|
||||
restitution?: number;
|
||||
isSensor?: boolean;
|
||||
enableContactEvents?: boolean;
|
||||
enableHitEvents?: boolean;
|
||||
enableSensorEvents?: boolean;
|
||||
userData?: number;
|
||||
}
|
||||
|
||||
export interface Shape {
|
||||
destroy(): void;
|
||||
delete(): void;
|
||||
}
|
||||
|
||||
export interface Body {
|
||||
createBox(options: ShapeDefinition & { halfExtents: Vec3 }): Shape;
|
||||
createCapsule(options: ShapeDefinition & { height: number; radius: number }): Shape;
|
||||
getPosition(): Vec3;
|
||||
getRotation(): Quat;
|
||||
getLinearVelocity(): Vec3;
|
||||
getAngularVelocity(): Vec3;
|
||||
getType(): BodyType;
|
||||
setType(type: BodyType): void;
|
||||
setTransform(position: Vec3, rotation: Quat): void;
|
||||
setTargetTransform(transform: Transform, deltaSeconds: number, wake: boolean): void;
|
||||
setLinearVelocity(velocity: Vec3): void;
|
||||
setAngularVelocity(velocity: Vec3): void;
|
||||
setAwake(awake: boolean): void;
|
||||
setBullet(bullet: boolean): void;
|
||||
applyLinearImpulseToCenter(impulse: Vec3, wake: boolean): void;
|
||||
destroy(): void;
|
||||
delete(): void;
|
||||
}
|
||||
|
||||
export interface ContactHitEvent {
|
||||
shapeUserDataA: number;
|
||||
shapeUserDataB: number;
|
||||
point: Vec3;
|
||||
normal: Vec3;
|
||||
approachSpeed: number;
|
||||
}
|
||||
|
||||
export interface ContactEvents {
|
||||
begin: unknown[];
|
||||
end: unknown[];
|
||||
hit: ContactHitEvent[];
|
||||
}
|
||||
|
||||
export interface WorldProfile {
|
||||
[name: string]: number;
|
||||
}
|
||||
|
||||
export class World {
|
||||
constructor(options: {
|
||||
gravity: Vec3;
|
||||
enableSleep?: boolean;
|
||||
enableContinuous?: boolean;
|
||||
workerCount?: number;
|
||||
});
|
||||
createBody(definition: BodyDefinition): Body;
|
||||
step(deltaSeconds: number, subStepCount: number): void;
|
||||
explode(options: {
|
||||
position: Vec3;
|
||||
radius: number;
|
||||
falloff: number;
|
||||
impulsePerArea: number;
|
||||
}): void;
|
||||
getContactEvents(): ContactEvents;
|
||||
getProfile(): WorldProfile;
|
||||
destroy(): void;
|
||||
delete(): void;
|
||||
}
|
||||
|
||||
export interface Box3DModule {
|
||||
World: typeof World;
|
||||
readonly threaded: boolean;
|
||||
readonly maxWorkers: number;
|
||||
}
|
||||
|
||||
export default function Box3D(options?: unknown): Promise<Box3DModule>;
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export interface ApiMessage {
|
||||
export * from "./arena.js";
|
||||
export * from "./flux-types.js";
|
||||
export * from "./flux-game.js";
|
||||
export * from "./movers-types.js";
|
||||
export * from "./movers-game.js";
|
||||
export * from "./royale-types.js";
|
||||
export * from "./royale-map.js";
|
||||
export * from "./royale-game.js";
|
||||
|
||||
448
packages/shared/src/movers-box3d.ts
Normal file
448
packages/shared/src/movers-box3d.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
import { definePhysicsBackend } from "@syncer/engine";
|
||||
import Box3D, {
|
||||
type Body,
|
||||
type BodyType,
|
||||
type Quat,
|
||||
type Vec3,
|
||||
type World,
|
||||
type WorldProfile,
|
||||
} from "box3d-wasm/standard";
|
||||
import {
|
||||
FURNITURE,
|
||||
MOVERS_ARENA_HALF_DEPTH,
|
||||
MOVERS_ARENA_HALF_WIDTH,
|
||||
MOVERS_WALLS,
|
||||
} from "./movers-config.js";
|
||||
import type {
|
||||
FurnitureKind,
|
||||
MoversAuthorityState,
|
||||
MoversClientState,
|
||||
} from "./movers-types.js";
|
||||
|
||||
type MoversPhysicsState = MoversAuthorityState | MoversClientState;
|
||||
|
||||
export interface MoversPhysicsImpact {
|
||||
itemId: number;
|
||||
approachSpeed: number;
|
||||
}
|
||||
|
||||
interface FurnitureBody {
|
||||
body: Body;
|
||||
kind: FurnitureKind;
|
||||
mode: BodyType;
|
||||
}
|
||||
|
||||
interface PhysicsRuntime {
|
||||
world: World;
|
||||
players: Map<number, Body>;
|
||||
furniture: Map<number, FurnitureBody>;
|
||||
pendingExplosions: Array<{
|
||||
position: Vec3;
|
||||
radius: number;
|
||||
falloff: number;
|
||||
impulsePerArea: number;
|
||||
}>;
|
||||
round: number;
|
||||
}
|
||||
|
||||
const box3d = await Box3D();
|
||||
const runtimes = new WeakMap<MoversPhysicsState, PhysicsRuntime>();
|
||||
const furnitureShapeTagBase = 10_000;
|
||||
const playerShapeTagBase = 100_000;
|
||||
const identityRotation: Quat = { x: 0, y: 0, z: 0, w: 1 };
|
||||
|
||||
export const MOVERS_PHYSICS_BACKEND = {
|
||||
name: "Box3D",
|
||||
version: "0.1.0",
|
||||
bindingVersion: "0.2.0",
|
||||
runtime: "WebAssembly SIMD",
|
||||
solver: "single-threaded deterministic",
|
||||
subSteps: 4,
|
||||
} as const;
|
||||
|
||||
export const moversPhysics = definePhysicsBackend<
|
||||
MoversPhysicsState,
|
||||
MoversPhysicsImpact[]
|
||||
>()({
|
||||
metadata: MOVERS_PHYSICS_BACKEND,
|
||||
|
||||
initialize(state) {
|
||||
ensureRuntime(state);
|
||||
},
|
||||
|
||||
step(state, deltaSeconds) {
|
||||
const runtime = ensureRuntime(state);
|
||||
syncStructure(runtime, state);
|
||||
if (runtime.round !== state.round) {
|
||||
forceState(runtime, state);
|
||||
runtime.round = state.round;
|
||||
}
|
||||
|
||||
preparePlayers(runtime, state);
|
||||
prepareFurniture(runtime, state, deltaSeconds);
|
||||
for (const explosion of runtime.pendingExplosions.splice(0)) {
|
||||
runtime.world.explode(explosion);
|
||||
}
|
||||
runtime.world.step(deltaSeconds, MOVERS_PHYSICS_BACKEND.subSteps);
|
||||
syncStateFromWorld(runtime, state);
|
||||
return collectImpacts(runtime, state);
|
||||
},
|
||||
|
||||
reconcile(predicted, snapshot) {
|
||||
const runtime = runtimes.get(predicted);
|
||||
if (!runtime) {
|
||||
ensureRuntime(snapshot);
|
||||
return;
|
||||
}
|
||||
if (predicted !== snapshot) {
|
||||
runtimes.delete(predicted);
|
||||
runtimes.set(snapshot, runtime);
|
||||
}
|
||||
syncStructure(runtime, snapshot);
|
||||
forceState(runtime, snapshot);
|
||||
runtime.round = snapshot.round;
|
||||
},
|
||||
|
||||
reset(state) {
|
||||
const runtime = ensureRuntime(state);
|
||||
syncStructure(runtime, state);
|
||||
forceState(runtime, state);
|
||||
runtime.round = state.round;
|
||||
},
|
||||
|
||||
dispose(state) {
|
||||
const runtime = runtimes.get(state);
|
||||
if (!runtime) return;
|
||||
for (const body of runtime.players.values()) body.delete();
|
||||
for (const record of runtime.furniture.values()) record.body.delete();
|
||||
runtime.world.destroy();
|
||||
runtime.world.delete();
|
||||
runtimes.delete(state);
|
||||
},
|
||||
|
||||
explode(
|
||||
state: MoversPhysicsState,
|
||||
options: { position: Vec3; radius: number; falloff: number; impulsePerArea: number },
|
||||
) {
|
||||
ensureRuntime(state).pendingExplosions.push(options);
|
||||
},
|
||||
|
||||
profile(state: MoversPhysicsState): WorldProfile {
|
||||
return ensureRuntime(state).world.getProfile();
|
||||
},
|
||||
});
|
||||
|
||||
function ensureRuntime(state: MoversPhysicsState): PhysicsRuntime {
|
||||
const existing = runtimes.get(state);
|
||||
if (existing) return existing;
|
||||
|
||||
const world = new box3d.World({
|
||||
gravity: { x: 0, y: -18, z: 0 },
|
||||
enableSleep: true,
|
||||
enableContinuous: true,
|
||||
workerCount: 1,
|
||||
});
|
||||
createStaticWorld(world);
|
||||
const runtime: PhysicsRuntime = {
|
||||
world,
|
||||
players: new Map(),
|
||||
furniture: new Map(),
|
||||
pendingExplosions: [],
|
||||
round: state.round,
|
||||
};
|
||||
runtimes.set(state, runtime);
|
||||
syncStructure(runtime, state);
|
||||
forceState(runtime, state);
|
||||
return runtime;
|
||||
}
|
||||
|
||||
function createStaticWorld(world: World): void {
|
||||
createStaticBox(world, { x: 0, y: -0.5, z: 0 }, {
|
||||
x: MOVERS_ARENA_HALF_WIDTH + 4,
|
||||
y: 0.5,
|
||||
z: MOVERS_ARENA_HALF_DEPTH + 4,
|
||||
}, 1);
|
||||
|
||||
const wallHeight = 3;
|
||||
for (const [index, wall] of MOVERS_WALLS.entries()) {
|
||||
createStaticBox(
|
||||
world,
|
||||
{ x: wall.x, y: wallHeight / 2, z: wall.z },
|
||||
{ x: wall.width / 2, y: wallHeight / 2, z: wall.depth / 2 },
|
||||
100 + index,
|
||||
);
|
||||
}
|
||||
|
||||
const edgeThickness = 0.8;
|
||||
createStaticBox(world, {
|
||||
x: -MOVERS_ARENA_HALF_WIDTH - edgeThickness / 2,
|
||||
y: wallHeight / 2,
|
||||
z: 0,
|
||||
}, { x: edgeThickness / 2, y: wallHeight / 2, z: MOVERS_ARENA_HALF_DEPTH + 1 }, 201);
|
||||
createStaticBox(world, {
|
||||
x: MOVERS_ARENA_HALF_WIDTH + edgeThickness / 2,
|
||||
y: wallHeight / 2,
|
||||
z: 0,
|
||||
}, { x: edgeThickness / 2, y: wallHeight / 2, z: MOVERS_ARENA_HALF_DEPTH + 1 }, 202);
|
||||
createStaticBox(world, {
|
||||
x: 0,
|
||||
y: wallHeight / 2,
|
||||
z: -MOVERS_ARENA_HALF_DEPTH - edgeThickness / 2,
|
||||
}, { x: MOVERS_ARENA_HALF_WIDTH + 1, y: wallHeight / 2, z: edgeThickness / 2 }, 203);
|
||||
createStaticBox(world, {
|
||||
x: 0,
|
||||
y: wallHeight / 2,
|
||||
z: MOVERS_ARENA_HALF_DEPTH + edgeThickness / 2,
|
||||
}, { x: MOVERS_ARENA_HALF_WIDTH + 1, y: wallHeight / 2, z: edgeThickness / 2 }, 204);
|
||||
}
|
||||
|
||||
function createStaticBox(world: World, position: Vec3, halfExtents: Vec3, tag: number): void {
|
||||
const body = world.createBody({ type: "static", position, userData: tag });
|
||||
const shape = body.createBox({
|
||||
halfExtents,
|
||||
friction: 0.78,
|
||||
restitution: 0.04,
|
||||
enableHitEvents: true,
|
||||
userData: tag,
|
||||
});
|
||||
shape.delete();
|
||||
body.delete();
|
||||
}
|
||||
|
||||
function syncStructure(runtime: PhysicsRuntime, state: MoversPhysicsState): void {
|
||||
const playerIds = new Set(state.players.map((player) => player.id));
|
||||
for (const [id, body] of runtime.players) {
|
||||
if (playerIds.has(id)) continue;
|
||||
body.destroy();
|
||||
body.delete();
|
||||
runtime.players.delete(id);
|
||||
}
|
||||
for (const player of state.players) {
|
||||
if (runtime.players.has(player.id)) continue;
|
||||
runtime.players.set(player.id, createPlayerBody(runtime.world, player));
|
||||
}
|
||||
|
||||
const itemIds = new Set(state.furniture.map((item) => item.id));
|
||||
for (const [id, record] of runtime.furniture) {
|
||||
const item = state.furniture.find((candidate) => candidate.id === id);
|
||||
if (itemIds.has(id) && item?.kind === record.kind) continue;
|
||||
record.body.destroy();
|
||||
record.body.delete();
|
||||
runtime.furniture.delete(id);
|
||||
}
|
||||
for (const item of state.furniture) {
|
||||
if (runtime.furniture.has(item.id)) continue;
|
||||
runtime.furniture.set(item.id, createFurnitureBody(runtime.world, item));
|
||||
}
|
||||
}
|
||||
|
||||
function createPlayerBody(
|
||||
world: World,
|
||||
player: MoversPhysicsState["players"][number],
|
||||
): Body {
|
||||
const body = world.createBody({
|
||||
type: "dynamic",
|
||||
position: { x: player.x, y: 1.05, z: player.z },
|
||||
linearVelocity: { x: player.velocityX, y: 0, z: player.velocityZ },
|
||||
gravityScale: 0,
|
||||
linearDamping: 0.25,
|
||||
motionLocks: {
|
||||
linearY: true,
|
||||
angularX: true,
|
||||
angularY: true,
|
||||
angularZ: true,
|
||||
},
|
||||
userData: playerShapeTagBase + player.id,
|
||||
});
|
||||
const shape = body.createCapsule({
|
||||
height: 1.15,
|
||||
radius: 0.47,
|
||||
density: 1.2,
|
||||
friction: 0.12,
|
||||
restitution: 0.02,
|
||||
enableHitEvents: true,
|
||||
userData: playerShapeTagBase + player.id,
|
||||
});
|
||||
shape.delete();
|
||||
return body;
|
||||
}
|
||||
|
||||
function createFurnitureBody(
|
||||
world: World,
|
||||
item: MoversPhysicsState["furniture"][number],
|
||||
): FurnitureBody {
|
||||
const definition = FURNITURE[item.kind];
|
||||
const mode = bodyMode(item);
|
||||
const body = world.createBody({
|
||||
type: mode,
|
||||
position: positionOf(item),
|
||||
rotation: rotationOf(item),
|
||||
linearVelocity: velocityOf(item),
|
||||
angularVelocity: angularVelocityOf(item),
|
||||
linearDamping: 0.5 + definition.weight * 0.14,
|
||||
angularDamping: 0.72,
|
||||
isBullet: Math.hypot(item.velocityX, item.velocityY, item.velocityZ) > 9,
|
||||
userData: furnitureShapeTagBase + item.id,
|
||||
});
|
||||
const shape = body.createBox({
|
||||
halfExtents: definition.halfExtents,
|
||||
density: definition.density,
|
||||
friction: 0.62,
|
||||
restitution: item.kind === "mattress" ? 0.18 : 0.06,
|
||||
enableHitEvents: true,
|
||||
userData: furnitureShapeTagBase + item.id,
|
||||
});
|
||||
shape.delete();
|
||||
return { body, kind: item.kind, mode };
|
||||
}
|
||||
|
||||
function preparePlayers(runtime: PhysicsRuntime, state: MoversPhysicsState): void {
|
||||
for (const player of state.players) {
|
||||
const body = runtime.players.get(player.id);
|
||||
if (!body) continue;
|
||||
body.setLinearVelocity({ x: player.velocityX, y: 0, z: player.velocityZ });
|
||||
body.setAwake(true);
|
||||
}
|
||||
}
|
||||
|
||||
function prepareFurniture(
|
||||
runtime: PhysicsRuntime,
|
||||
state: MoversPhysicsState,
|
||||
deltaSeconds: number,
|
||||
): void {
|
||||
for (const item of state.furniture) {
|
||||
const record = runtime.furniture.get(item.id);
|
||||
if (!record) continue;
|
||||
const nextMode = bodyMode(item);
|
||||
const changedMode = record.mode !== nextMode;
|
||||
if (changedMode) {
|
||||
record.body.setType(nextMode);
|
||||
record.body.setTransform(positionOf(item), rotationOf(item));
|
||||
record.mode = nextMode;
|
||||
}
|
||||
if (nextMode === "kinematic") {
|
||||
record.body.setTargetTransform({
|
||||
position: positionOf(item),
|
||||
rotation: rotationOf(item),
|
||||
}, deltaSeconds, true);
|
||||
continue;
|
||||
}
|
||||
if (nextMode === "static") {
|
||||
if (changedMode) record.body.setTransform(positionOf(item), rotationOf(item));
|
||||
continue;
|
||||
}
|
||||
record.body.setBullet(Math.hypot(item.velocityX, item.velocityY, item.velocityZ) > 9);
|
||||
record.body.setLinearVelocity(velocityOf(item));
|
||||
record.body.setAngularVelocity(angularVelocityOf(item));
|
||||
}
|
||||
}
|
||||
|
||||
function forceState(runtime: PhysicsRuntime, state: MoversPhysicsState): void {
|
||||
for (const player of state.players) {
|
||||
const body = runtime.players.get(player.id);
|
||||
if (!body) continue;
|
||||
body.setTransform({ x: player.x, y: 1.05, z: player.z }, identityRotation);
|
||||
body.setLinearVelocity({ x: player.velocityX, y: 0, z: player.velocityZ });
|
||||
}
|
||||
for (const item of state.furniture) {
|
||||
const record = runtime.furniture.get(item.id);
|
||||
if (!record) continue;
|
||||
const mode = bodyMode(item);
|
||||
if (record.body.getType() !== mode) record.body.setType(mode);
|
||||
record.mode = mode;
|
||||
record.body.setTransform(positionOf(item), rotationOf(item));
|
||||
if (mode === "dynamic") {
|
||||
record.body.setLinearVelocity(velocityOf(item));
|
||||
record.body.setAngularVelocity(angularVelocityOf(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncStateFromWorld(runtime: PhysicsRuntime, state: MoversPhysicsState): void {
|
||||
for (const player of state.players) {
|
||||
const body = runtime.players.get(player.id);
|
||||
if (!body) continue;
|
||||
const position = body.getPosition();
|
||||
const velocity = body.getLinearVelocity();
|
||||
player.x = position.x;
|
||||
player.z = position.z;
|
||||
player.velocityX = velocity.x;
|
||||
player.velocityZ = velocity.z;
|
||||
}
|
||||
for (const item of state.furniture) {
|
||||
const body = runtime.furniture.get(item.id)?.body;
|
||||
if (!body) continue;
|
||||
const position = body.getPosition();
|
||||
const rotation = body.getRotation();
|
||||
const velocity = body.getLinearVelocity();
|
||||
const angularVelocity = body.getAngularVelocity();
|
||||
item.x = position.x;
|
||||
item.y = position.y;
|
||||
item.z = position.z;
|
||||
item.velocityX = velocity.x;
|
||||
item.velocityY = velocity.y;
|
||||
item.velocityZ = velocity.z;
|
||||
item.rotationX = rotation.x;
|
||||
item.rotationY = rotation.y;
|
||||
item.rotationZ = rotation.z;
|
||||
item.rotationW = rotation.w;
|
||||
item.angularVelocityX = angularVelocity.x;
|
||||
item.angularVelocityY = angularVelocity.y;
|
||||
item.angularVelocityZ = angularVelocity.z;
|
||||
item.yaw = yawFromQuaternion(rotation);
|
||||
}
|
||||
}
|
||||
|
||||
function collectImpacts(
|
||||
runtime: PhysicsRuntime,
|
||||
state: MoversPhysicsState,
|
||||
): MoversPhysicsImpact[] {
|
||||
const maximumByItem = new Map<number, number>();
|
||||
for (const hit of runtime.world.getContactEvents().hit) {
|
||||
for (const tag of [hit.shapeUserDataA, hit.shapeUserDataB]) {
|
||||
const itemId = tag - furnitureShapeTagBase;
|
||||
const item = state.furniture.find((candidate) => candidate.id === itemId);
|
||||
if (!item || item.carriedBy !== null || item.securedBy !== null) continue;
|
||||
maximumByItem.set(itemId, Math.max(maximumByItem.get(itemId) ?? 0, hit.approachSpeed));
|
||||
}
|
||||
}
|
||||
return [...maximumByItem].map(([itemId, approachSpeed]) => ({ itemId, approachSpeed }));
|
||||
}
|
||||
|
||||
function bodyMode(item: MoversPhysicsState["furniture"][number]): BodyType {
|
||||
if (item.securedBy !== null) return "static";
|
||||
if (item.carriedBy !== null) return "kinematic";
|
||||
return "dynamic";
|
||||
}
|
||||
|
||||
function positionOf(item: MoversPhysicsState["furniture"][number]): Vec3 {
|
||||
return { x: item.x, y: item.y, z: item.z };
|
||||
}
|
||||
|
||||
function rotationOf(item: MoversPhysicsState["furniture"][number]): Quat {
|
||||
return {
|
||||
x: item.rotationX,
|
||||
y: item.rotationY,
|
||||
z: item.rotationZ,
|
||||
w: item.rotationW,
|
||||
};
|
||||
}
|
||||
|
||||
function velocityOf(item: MoversPhysicsState["furniture"][number]): Vec3 {
|
||||
return { x: item.velocityX, y: item.velocityY, z: item.velocityZ };
|
||||
}
|
||||
|
||||
function angularVelocityOf(item: MoversPhysicsState["furniture"][number]): Vec3 {
|
||||
return {
|
||||
x: item.angularVelocityX,
|
||||
y: item.angularVelocityY,
|
||||
z: item.angularVelocityZ,
|
||||
};
|
||||
}
|
||||
|
||||
function yawFromQuaternion(rotation: Quat): number {
|
||||
return Math.atan2(
|
||||
2 * (rotation.w * rotation.y + rotation.x * rotation.z),
|
||||
1 - 2 * (rotation.y * rotation.y + rotation.z * rotation.z),
|
||||
);
|
||||
}
|
||||
121
packages/shared/src/movers-config.ts
Normal file
121
packages/shared/src/movers-config.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { FurnitureKind } from "./movers-types.js";
|
||||
|
||||
export interface FurnitureDefinition {
|
||||
name: string;
|
||||
value: number;
|
||||
weight: number;
|
||||
radius: number;
|
||||
fragile: number;
|
||||
halfExtents: { x: number; y: number; z: number };
|
||||
density: number;
|
||||
}
|
||||
|
||||
export const FURNITURE: Record<FurnitureKind, FurnitureDefinition> = {
|
||||
piano: {
|
||||
name: "Grand Piano",
|
||||
value: 1_400,
|
||||
weight: 3.2,
|
||||
radius: 1.65,
|
||||
fragile: 0.72,
|
||||
halfExtents: { x: 1.55, y: 0.72, z: 0.78 },
|
||||
density: 1.8,
|
||||
},
|
||||
aquarium: {
|
||||
name: "Live Aquarium",
|
||||
value: 1_100,
|
||||
weight: 2.1,
|
||||
radius: 1.15,
|
||||
fragile: 1.45,
|
||||
halfExtents: { x: 1.15, y: 0.78, z: 0.6 },
|
||||
density: 1.45,
|
||||
},
|
||||
safe: {
|
||||
name: "Suspicious Safe",
|
||||
value: 1_800,
|
||||
weight: 3.8,
|
||||
radius: 0.9,
|
||||
fragile: 0.28,
|
||||
halfExtents: { x: 0.82, y: 0.85, z: 0.78 },
|
||||
density: 5.2,
|
||||
},
|
||||
sofa: {
|
||||
name: "Designer Sofa",
|
||||
value: 850,
|
||||
weight: 2.2,
|
||||
radius: 1.55,
|
||||
fragile: 0.58,
|
||||
halfExtents: { x: 1.6, y: 0.62, z: 0.72 },
|
||||
density: 0.72,
|
||||
},
|
||||
television: {
|
||||
name: "Giant Television",
|
||||
value: 1_250,
|
||||
weight: 1.35,
|
||||
radius: 1.05,
|
||||
fragile: 1.7,
|
||||
halfExtents: { x: 1.1, y: 0.78, z: 0.22 },
|
||||
density: 1.05,
|
||||
},
|
||||
mattress: {
|
||||
name: "King Mattress",
|
||||
value: 420,
|
||||
weight: 1.1,
|
||||
radius: 1.55,
|
||||
fragile: 0.35,
|
||||
halfExtents: { x: 1.6, y: 0.32, z: 1.08 },
|
||||
density: 0.28,
|
||||
},
|
||||
urn: {
|
||||
name: "Grandma's Urn",
|
||||
value: 2_200,
|
||||
weight: 0.65,
|
||||
radius: 0.48,
|
||||
fragile: 2.2,
|
||||
halfExtents: { x: 0.43, y: 0.6, z: 0.43 },
|
||||
density: 1.4,
|
||||
},
|
||||
refrigerator: {
|
||||
name: "Full Refrigerator",
|
||||
value: 720,
|
||||
weight: 2.65,
|
||||
radius: 1.05,
|
||||
fragile: 0.62,
|
||||
halfExtents: { x: 0.82, y: 1.15, z: 0.75 },
|
||||
density: 2.4,
|
||||
},
|
||||
plant: {
|
||||
name: "Rare Houseplant",
|
||||
value: 560,
|
||||
weight: 0.7,
|
||||
radius: 0.62,
|
||||
fragile: 1.25,
|
||||
halfExtents: { x: 0.52, y: 0.7, z: 0.52 },
|
||||
density: 0.55,
|
||||
},
|
||||
"mystery-box": {
|
||||
name: "Box (Probably Cat)",
|
||||
value: 900,
|
||||
weight: 0.8,
|
||||
radius: 0.72,
|
||||
fragile: 0.82,
|
||||
halfExtents: { x: 0.7, y: 0.65, z: 0.7 },
|
||||
density: 0.68,
|
||||
},
|
||||
};
|
||||
|
||||
export const MOVERS_WALLS = [
|
||||
{ x: -19, z: -10, width: 9, depth: 0.7 },
|
||||
{ x: -7, z: -10, width: 8, depth: 0.7 },
|
||||
{ x: 7, z: -10, width: 8, depth: 0.7 },
|
||||
{ x: 19, z: -10, width: 9, depth: 0.7 },
|
||||
{ x: -17, z: 10, width: 15, depth: 0.7 },
|
||||
{ x: 0, z: 10, width: 13, depth: 0.7 },
|
||||
{ x: 17, z: 10, width: 15, depth: 0.7 },
|
||||
{ x: -12, z: 0, width: 0.7, depth: 11 },
|
||||
{ x: 12, z: 1, width: 0.7, depth: 10 },
|
||||
{ x: 0, z: -6, width: 0.7, depth: 8 },
|
||||
] as const;
|
||||
|
||||
export const MOVERS_ARENA_HALF_WIDTH = 42;
|
||||
export const MOVERS_ARENA_HALF_DEPTH = 23;
|
||||
|
||||
827
packages/shared/src/movers-game.ts
Normal file
827
packages/shared/src/movers-game.ts
Normal file
@@ -0,0 +1,827 @@
|
||||
import {
|
||||
createJsonCodec,
|
||||
defineMultiplayerGame,
|
||||
withInputStream,
|
||||
} from "@syncer/engine";
|
||||
import { FURNITURE } from "./movers-config.js";
|
||||
import { moversPhysics } from "./movers-box3d.js";
|
||||
import type {
|
||||
FurnitureKind,
|
||||
MoversAuthorityEvent,
|
||||
MoversAuthorityFurniture,
|
||||
MoversAuthorityPlayer,
|
||||
MoversAuthorityState,
|
||||
MoversClientState,
|
||||
MoversFurnitureView,
|
||||
MoversGameContract,
|
||||
MoversInput,
|
||||
MoversPerception,
|
||||
MoversPlayerView,
|
||||
MoversTeam,
|
||||
} from "./movers-types.js";
|
||||
|
||||
export { FURNITURE, MOVERS_WALLS } from "./movers-config.js";
|
||||
export { MOVERS_PHYSICS_BACKEND } from "./movers-box3d.js";
|
||||
|
||||
export const MOVERS_SOCKET_PATH = "/ws/movers";
|
||||
export const MOVERS_TICK_RATE = 30;
|
||||
export const MOVERS_SNAPSHOT_RATE = 15;
|
||||
export const MOVERS_MATCH_TICKS = MOVERS_TICK_RATE * 150;
|
||||
export const MOVERS_DEMOLITION_TICKS = MOVERS_TICK_RATE * 30;
|
||||
export const MOVERS_BOT_IDS = [40_001, 40_002, 40_003, 40_004] as const;
|
||||
|
||||
const inputCodec = createJsonCodec<MoversInput>();
|
||||
const stateCodec = createJsonCodec<MoversClientState>();
|
||||
const eventCodec = createJsonCodec<MoversPerception>();
|
||||
const eventLifetimeTicks = MOVERS_TICK_RATE * 6;
|
||||
const maximumStamina = 100;
|
||||
|
||||
const baseMoversGame = defineMultiplayerGame<MoversGameContract>({
|
||||
clock: {
|
||||
ticksPerSecond: MOVERS_TICK_RATE,
|
||||
snapshotsPerSecond: MOVERS_SNAPSHOT_RATE,
|
||||
},
|
||||
|
||||
authority: {
|
||||
createInitialState: createAuthorityState,
|
||||
cloneState: cloneAuthorityState,
|
||||
addPlayer(state, { playerId }) {
|
||||
const team = leastPopulatedTeam(state);
|
||||
state.players.push(createPlayer(playerId, team, false, state.players.length));
|
||||
state.players.sort((left, right) => left.id - right.id);
|
||||
},
|
||||
removePlayer(state, { playerId }) {
|
||||
const player = findPlayer(state, playerId);
|
||||
if (player?.carryingId !== null && player?.carryingId !== undefined) {
|
||||
const item = findFurniture(state, player.carryingId);
|
||||
if (item) item.carriedBy = null;
|
||||
}
|
||||
state.players = state.players.filter((candidate) => candidate.id !== playerId);
|
||||
},
|
||||
applyInput(state, input, { playerId }) {
|
||||
const player = findPlayer(state, playerId);
|
||||
if (!player || player.bot) return;
|
||||
applyCommand(player, input);
|
||||
},
|
||||
step(state, { tick, deltaSeconds, emit }) {
|
||||
if (state.resetTicks > 0) {
|
||||
state.resetTicks -= 1;
|
||||
if (state.resetTicks === 0) resetMatch(state);
|
||||
return;
|
||||
}
|
||||
|
||||
state.elapsedTicks += 1;
|
||||
state.yellowDoorTicks = Math.max(0, state.yellowDoorTicks - 1);
|
||||
state.blueDoorTicks = Math.max(0, state.blueDoorTicks - 1);
|
||||
|
||||
if (!state.demolitionStarted && state.elapsedTicks >= MOVERS_MATCH_TICKS - MOVERS_DEMOLITION_TICKS) {
|
||||
state.demolitionStarted = true;
|
||||
emit({ id: state.nextEventId++, type: "demolition" });
|
||||
}
|
||||
|
||||
for (const player of state.players) {
|
||||
if (player.bot) updateBot(state, player, tick);
|
||||
const carrying = player.carryingId === null ? null : findFurniture(state, player.carryingId) ?? null;
|
||||
updateAuthorityPlayerIntent(player, carrying, deltaSeconds);
|
||||
}
|
||||
|
||||
updateCarrying(state, emit);
|
||||
for (const player of state.players) {
|
||||
if (player.closeDoorsRequested) tryCloseTruck(state, player, emit);
|
||||
player.throwRequested = false;
|
||||
player.closeDoorsRequested = false;
|
||||
}
|
||||
|
||||
if (state.demolitionStarted && tick % 75 === 0) demolitionPulse(state, tick, emit);
|
||||
|
||||
const impacts = moversPhysics.step(state, deltaSeconds);
|
||||
for (const impact of impacts) {
|
||||
if (impact.approachSpeed <= 4.8) continue;
|
||||
const item = findFurniture(state, impact.itemId);
|
||||
if (item) damageFurniture(state, item, (impact.approachSpeed - 4.8) * 1.35, tick, emit);
|
||||
}
|
||||
resolveCarrierTackles(state, tick, emit);
|
||||
|
||||
if (
|
||||
state.elapsedTicks >= MOVERS_MATCH_TICKS ||
|
||||
state.furniture.every((item) => item.securedBy !== null)
|
||||
) {
|
||||
finishMatch(state, emit);
|
||||
}
|
||||
},
|
||||
validateState(state) {
|
||||
return (
|
||||
Number.isFinite(state.yellowScore + state.blueScore) &&
|
||||
state.players.every(
|
||||
(player) =>
|
||||
Number.isFinite(player.x + player.z + player.velocityX + player.velocityZ) &&
|
||||
player.stamina >= 0 &&
|
||||
player.stamina <= maximumStamina,
|
||||
) &&
|
||||
state.furniture.every(
|
||||
(item) =>
|
||||
Number.isFinite(
|
||||
item.x + item.y + item.z +
|
||||
item.velocityX + item.velocityY + item.velocityZ +
|
||||
item.rotationX + item.rotationY + item.rotationZ + item.rotationW +
|
||||
item.angularVelocityX + item.angularVelocityY + item.angularVelocityZ +
|
||||
item.damage,
|
||||
) &&
|
||||
item.damage >= 0 &&
|
||||
item.damage <= 100,
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
prediction: {
|
||||
createInitialState: createClientState,
|
||||
cloneState: cloneClientState,
|
||||
applyInput(state, input, { playerId }) {
|
||||
const player = state.players.find((candidate) => candidate.id === playerId);
|
||||
if (!player) return;
|
||||
player.inputForward = input.forward;
|
||||
player.inputStrafe = input.strafe;
|
||||
player.sprinting = input.sprint;
|
||||
player.grabbing = input.grab;
|
||||
},
|
||||
step(state, { tick, deltaSeconds }) {
|
||||
if (state.resetTicks > 0) state.resetTicks -= 1;
|
||||
else state.elapsedTicks += 1;
|
||||
state.yellowDoorTicks = Math.max(0, state.yellowDoorTicks - 1);
|
||||
state.blueDoorTicks = Math.max(0, state.blueDoorTicks - 1);
|
||||
|
||||
for (const player of state.players) {
|
||||
const carrying = player.carryingId === null
|
||||
? null
|
||||
: state.furniture.find((item) => item.id === player.carryingId) ?? null;
|
||||
updateVisiblePlayerIntent(player, carrying, deltaSeconds);
|
||||
}
|
||||
updateVisibleCarrying(state);
|
||||
moversPhysics.step(state, deltaSeconds);
|
||||
state.events = state.events.filter(
|
||||
(entry) => entry.receivedTick >= tick - eventLifetimeTicks,
|
||||
);
|
||||
},
|
||||
mergeSnapshot(predicted, snapshot, { tick }) {
|
||||
const merged = cloneClientState(snapshot);
|
||||
merged.events = predicted.events
|
||||
.filter((entry) => entry.receivedTick >= tick - eventLifetimeTicks)
|
||||
.map((entry) => ({ receivedTick: entry.receivedTick, event: { ...entry.event } }));
|
||||
moversPhysics.reconcile(predicted, merged);
|
||||
return merged;
|
||||
},
|
||||
applyEvent(state, event, { tick }) {
|
||||
if (state.events.some((entry) => entry.event.id === event.id)) return;
|
||||
state.events.push({ receivedTick: tick, event: { ...event } });
|
||||
if (state.events.length > 64) state.events.shift();
|
||||
},
|
||||
validateState(state) {
|
||||
return (
|
||||
state.players.every(
|
||||
(player) =>
|
||||
Number.isFinite(player.x + player.z) &&
|
||||
(player.stamina === null || (player.stamina >= 0 && player.stamina <= maximumStamina)),
|
||||
) &&
|
||||
state.furniture.every((item) => Number.isFinite(
|
||||
item.x + item.y + item.z + item.rotationX + item.rotationY + item.rotationZ + item.rotationW,
|
||||
))
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
visibility: {
|
||||
createSnapshot(authority, { playerId }) {
|
||||
return {
|
||||
players: authority.players.map((player) => playerView(player, player.id === playerId)),
|
||||
furniture: authority.furniture.map(furnitureView),
|
||||
yellowScore: authority.yellowScore,
|
||||
blueScore: authority.blueScore,
|
||||
yellowDoorTicks: authority.yellowDoorTicks,
|
||||
blueDoorTicks: authority.blueDoorTicks,
|
||||
elapsedTicks: authority.elapsedTicks,
|
||||
round: authority.round,
|
||||
resetTicks: authority.resetTicks,
|
||||
winner: authority.winner,
|
||||
demolitionStarted: authority.demolitionStarted,
|
||||
events: [],
|
||||
};
|
||||
},
|
||||
validateClientState(authority, candidate, { playerId }) {
|
||||
const expected = findPlayer(authority, playerId);
|
||||
const reported = candidate.players.find((player) => player.id === playerId);
|
||||
return Boolean(
|
||||
expected &&
|
||||
reported &&
|
||||
reported.stamina !== null &&
|
||||
Math.hypot(reported.x - expected.x, reported.z - expected.z) <= 4.5 &&
|
||||
Math.abs(reported.stamina - expected.stamina) <= 18,
|
||||
);
|
||||
},
|
||||
perceive(_authority, event) {
|
||||
return { ...event };
|
||||
},
|
||||
},
|
||||
|
||||
input: {
|
||||
validate(input) {
|
||||
return (
|
||||
typeof input === "object" && input !== null &&
|
||||
Number.isFinite(input.forward) && Number.isFinite(input.strafe) &&
|
||||
Math.abs(input.forward) <= 1 && Math.abs(input.strafe) <= 1 &&
|
||||
typeof input.sprint === "boolean" && typeof input.grab === "boolean" &&
|
||||
typeof input.throwItem === "boolean" && typeof input.closeDoors === "boolean"
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
encoding: {
|
||||
input: inputCodec,
|
||||
clientState: stateCodec,
|
||||
perception: eventCodec,
|
||||
},
|
||||
});
|
||||
|
||||
export const moversGame = withInputStream(baseMoversGame, {
|
||||
heartbeatRateHz: 20,
|
||||
timeoutMs: 400,
|
||||
inputsEqual(left, right) {
|
||||
return (
|
||||
left.forward === right.forward && left.strafe === right.strafe &&
|
||||
left.sprint === right.sprint && left.grab === right.grab &&
|
||||
left.throwItem === right.throwItem && left.closeDoors === right.closeDoors
|
||||
);
|
||||
},
|
||||
neutralize: neutralInput,
|
||||
resume(lastInput) {
|
||||
return { ...lastInput, throwItem: false, closeDoors: false };
|
||||
},
|
||||
});
|
||||
|
||||
function neutralInput(): MoversInput {
|
||||
return {
|
||||
forward: 0,
|
||||
strafe: 0,
|
||||
sprint: false,
|
||||
grab: false,
|
||||
throwItem: false,
|
||||
closeDoors: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createAuthorityState(): MoversAuthorityState {
|
||||
return {
|
||||
players: [
|
||||
createPlayer(MOVERS_BOT_IDS[0], "yellow", true, 0),
|
||||
createPlayer(MOVERS_BOT_IDS[1], "yellow", true, 1),
|
||||
createPlayer(MOVERS_BOT_IDS[2], "blue", true, 2),
|
||||
createPlayer(MOVERS_BOT_IDS[3], "blue", true, 3),
|
||||
],
|
||||
furniture: createFurniture(),
|
||||
yellowScore: 0,
|
||||
blueScore: 0,
|
||||
yellowDoorTicks: 0,
|
||||
blueDoorTicks: 0,
|
||||
elapsedTicks: 0,
|
||||
round: 1,
|
||||
resetTicks: 0,
|
||||
winner: null,
|
||||
demolitionStarted: false,
|
||||
nextEventId: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function createClientState(): MoversClientState {
|
||||
return {
|
||||
players: [],
|
||||
furniture: [],
|
||||
yellowScore: 0,
|
||||
blueScore: 0,
|
||||
yellowDoorTicks: 0,
|
||||
blueDoorTicks: 0,
|
||||
elapsedTicks: 0,
|
||||
round: 1,
|
||||
resetTicks: 0,
|
||||
winner: null,
|
||||
demolitionStarted: false,
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createPlayer(id: number, team: MoversTeam, bot: boolean, slot: number): MoversAuthorityPlayer {
|
||||
const spawnX = team === "yellow" ? -34 : 34;
|
||||
return {
|
||||
id,
|
||||
team,
|
||||
bot,
|
||||
x: spawnX,
|
||||
z: ((slot % 3) - 1) * 3,
|
||||
velocityX: 0,
|
||||
velocityZ: 0,
|
||||
yaw: team === "yellow" ? Math.PI / 2 : -Math.PI / 2,
|
||||
stamina: maximumStamina,
|
||||
carryingId: null,
|
||||
inputForward: 0,
|
||||
inputStrafe: 0,
|
||||
sprinting: false,
|
||||
grabbing: false,
|
||||
throwing: false,
|
||||
closingDoors: false,
|
||||
throwRequested: false,
|
||||
closeDoorsRequested: false,
|
||||
botTargetId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createFurniture(): MoversAuthorityFurniture[] {
|
||||
const layout: Array<[FurnitureKind, number, number, number]> = [
|
||||
["piano", -7, -5, 0.12],
|
||||
["aquarium", 7, -7, -0.24],
|
||||
["safe", 1, 5, 0],
|
||||
["sofa", -10, 7, Math.PI / 2],
|
||||
["television", 10, 4, 0.15],
|
||||
["mattress", -2, -9, -0.3],
|
||||
["urn", 0, -1, 0],
|
||||
["refrigerator", 13, -2, 0.1],
|
||||
["plant", -13, 0, -0.2],
|
||||
["mystery-box", 4, 9, 0.35],
|
||||
];
|
||||
return layout.map(([kind, x, z, yaw], index) => {
|
||||
const rotation = quaternionFromYaw(yaw);
|
||||
return {
|
||||
id: index + 1,
|
||||
kind,
|
||||
x,
|
||||
y: FURNITURE[kind].halfExtents.y + 0.08,
|
||||
z,
|
||||
velocityX: 0,
|
||||
velocityY: 0,
|
||||
velocityZ: 0,
|
||||
yaw,
|
||||
rotationX: rotation.x,
|
||||
rotationY: rotation.y,
|
||||
rotationZ: rotation.z,
|
||||
rotationW: rotation.w,
|
||||
angularVelocityX: 0,
|
||||
angularVelocityY: 0,
|
||||
angularVelocityZ: 0,
|
||||
damage: 0,
|
||||
carriedBy: null,
|
||||
securedBy: null,
|
||||
lastDamageTick: -1_000,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function applyCommand(player: MoversAuthorityPlayer, input: MoversInput): void {
|
||||
player.inputForward = input.forward;
|
||||
player.inputStrafe = input.strafe;
|
||||
player.sprinting = input.sprint;
|
||||
player.grabbing = input.grab;
|
||||
if (input.throwItem && !player.throwing) player.throwRequested = true;
|
||||
if (input.closeDoors && !player.closingDoors) player.closeDoorsRequested = true;
|
||||
player.throwing = input.throwItem;
|
||||
player.closingDoors = input.closeDoors;
|
||||
}
|
||||
|
||||
function updateBot(state: MoversAuthorityState, player: MoversAuthorityPlayer, tick: number): void {
|
||||
let targetX = 0;
|
||||
let targetZ = 0;
|
||||
let grab = false;
|
||||
let closeDoors = false;
|
||||
if (player.carryingId !== null) {
|
||||
targetX = player.team === "yellow" ? -34 : 34;
|
||||
targetZ = ((player.id % 3) - 1) * 3;
|
||||
const reached = inTruckZone(player.team, player.x, player.z);
|
||||
grab = !reached;
|
||||
closeDoors = reached;
|
||||
} else {
|
||||
const target = chooseBotTarget(state, player);
|
||||
player.botTargetId = target?.id ?? null;
|
||||
if (target) {
|
||||
targetX = target.x;
|
||||
targetZ = target.z;
|
||||
grab = Math.hypot(targetX - player.x, targetZ - player.z) < 2.6;
|
||||
} else {
|
||||
targetX = Math.sin(tick * 0.014 + player.id) * 8;
|
||||
targetZ = Math.cos(tick * 0.011 + player.id) * 6;
|
||||
}
|
||||
}
|
||||
const dx = targetX - player.x;
|
||||
const dz = targetZ - player.z;
|
||||
const distance = Math.hypot(dx, dz);
|
||||
player.inputStrafe = distance > 0.3 ? clamp(dx / distance, -1, 1) : 0;
|
||||
player.inputForward = distance > 0.3 ? clamp(-dz / distance, -1, 1) : 0;
|
||||
player.sprinting = distance > 8 && player.stamina > 18;
|
||||
player.grabbing = grab;
|
||||
if (closeDoors && !player.closingDoors) player.closeDoorsRequested = true;
|
||||
player.closingDoors = closeDoors;
|
||||
}
|
||||
|
||||
function chooseBotTarget(state: MoversAuthorityState, player: MoversAuthorityPlayer): MoversAuthorityFurniture | null {
|
||||
let nearest: MoversAuthorityFurniture | null = null;
|
||||
let best = Number.POSITIVE_INFINITY;
|
||||
for (const item of state.furniture) {
|
||||
if (item.securedBy !== null || item.carriedBy !== null) continue;
|
||||
const distance = Math.hypot(item.x - player.x, item.z - player.z);
|
||||
const score = distance - FURNITURE[item.kind].value / 4_000;
|
||||
if (score >= best) continue;
|
||||
nearest = item;
|
||||
best = score;
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
function updateAuthorityPlayerIntent(
|
||||
player: MoversAuthorityPlayer,
|
||||
carrying: MoversAuthorityFurniture | null,
|
||||
deltaSeconds: number,
|
||||
): void {
|
||||
const definition = carrying ? FURNITURE[carrying.kind] : null;
|
||||
const carryingScale = definition ? 1 / (0.72 + definition.weight * 0.19) : 1;
|
||||
const moving = Math.hypot(player.inputStrafe, player.inputForward) > 0.05;
|
||||
const canSprint = player.sprinting && moving && player.stamina > 0 && !carrying;
|
||||
updateMovementIntent(player, (canSprint ? 10.8 : 7.1) * carryingScale, deltaSeconds);
|
||||
player.stamina = clamp(player.stamina + (canSprint ? -34 : 23) * deltaSeconds, 0, maximumStamina);
|
||||
}
|
||||
|
||||
function updateVisiblePlayerIntent(
|
||||
player: MoversPlayerView,
|
||||
carrying: MoversFurnitureView | null,
|
||||
deltaSeconds: number,
|
||||
): void {
|
||||
const definition = carrying ? FURNITURE[carrying.kind] : null;
|
||||
const carryingScale = definition ? 1 / (0.72 + definition.weight * 0.19) : 1;
|
||||
const moving = Math.hypot(player.inputStrafe, player.inputForward) > 0.05;
|
||||
const stamina = player.stamina ?? maximumStamina;
|
||||
const canSprint = player.sprinting && moving && stamina > 0 && !carrying;
|
||||
updateMovementIntent(player, (canSprint ? 10.8 : 7.1) * carryingScale, deltaSeconds);
|
||||
if (player.stamina !== null) {
|
||||
player.stamina = clamp(player.stamina + (canSprint ? -34 : 23) * deltaSeconds, 0, maximumStamina);
|
||||
}
|
||||
}
|
||||
|
||||
function updateMovementIntent(
|
||||
player: Pick<MoversAuthorityPlayer, "velocityX" | "velocityZ" | "yaw" | "inputForward" | "inputStrafe">,
|
||||
speed: number,
|
||||
deltaSeconds: number,
|
||||
): void {
|
||||
let directionX = player.inputStrafe;
|
||||
let directionZ = -player.inputForward;
|
||||
const magnitude = Math.hypot(directionX, directionZ);
|
||||
if (magnitude > 1) {
|
||||
directionX /= magnitude;
|
||||
directionZ /= magnitude;
|
||||
}
|
||||
const acceleration = 42 * deltaSeconds;
|
||||
player.velocityX = approach(player.velocityX, directionX * speed, acceleration);
|
||||
player.velocityZ = approach(player.velocityZ, directionZ * speed, acceleration);
|
||||
if (magnitude < 0.05) {
|
||||
const damping = Math.exp(-11 * deltaSeconds);
|
||||
player.velocityX *= damping;
|
||||
player.velocityZ *= damping;
|
||||
} else {
|
||||
player.yaw = rotateToward(player.yaw, Math.atan2(directionX, directionZ), 9 * deltaSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
function updateCarrying(state: MoversAuthorityState, emit: (event: MoversAuthorityEvent) => void): void {
|
||||
for (const player of state.players) {
|
||||
if (player.carryingId === null) continue;
|
||||
const item = findFurniture(state, player.carryingId);
|
||||
if (!item || item.securedBy !== null) {
|
||||
player.carryingId = null;
|
||||
continue;
|
||||
}
|
||||
if (player.throwRequested) {
|
||||
releaseFurniture(item, player, true);
|
||||
player.carryingId = null;
|
||||
emit({ id: state.nextEventId++, type: "thrown", playerId: player.id, itemId: item.id, team: player.team });
|
||||
} else if (!player.grabbing) {
|
||||
releaseFurniture(item, player, false);
|
||||
player.carryingId = null;
|
||||
} else {
|
||||
anchorFurniture(item, player);
|
||||
}
|
||||
}
|
||||
|
||||
for (const player of state.players) {
|
||||
if (player.carryingId !== null || !player.grabbing) continue;
|
||||
let nearest: MoversAuthorityFurniture | null = null;
|
||||
let nearestDistance = 2.65;
|
||||
for (const item of state.furniture) {
|
||||
if (item.carriedBy !== null || item.securedBy !== null) continue;
|
||||
const distance = Math.hypot(item.x - player.x, item.z - player.z);
|
||||
if (distance >= nearestDistance) continue;
|
||||
nearest = item;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
if (!nearest) continue;
|
||||
nearest.carriedBy = player.id;
|
||||
player.carryingId = nearest.id;
|
||||
anchorFurniture(nearest, player);
|
||||
emit({ id: state.nextEventId++, type: "grabbed", playerId: player.id, itemId: nearest.id, team: player.team });
|
||||
}
|
||||
}
|
||||
|
||||
function updateVisibleCarrying(state: MoversClientState): void {
|
||||
for (const player of state.players) {
|
||||
if (player.carryingId === null) continue;
|
||||
const item = state.furniture.find((candidate) => candidate.id === player.carryingId);
|
||||
if (item) anchorFurniture(item, player);
|
||||
}
|
||||
}
|
||||
|
||||
function anchorFurniture(
|
||||
item: MoversAuthorityFurniture | MoversFurnitureView,
|
||||
player: Pick<MoversAuthorityPlayer, "x" | "z" | "velocityX" | "velocityZ" | "yaw">,
|
||||
): void {
|
||||
const definition = FURNITURE[item.kind];
|
||||
const distance = 1.05 + definition.radius * 0.62;
|
||||
item.x = player.x + Math.sin(player.yaw) * distance;
|
||||
item.y = Math.max(definition.halfExtents.y + 0.1, 1.45);
|
||||
item.z = player.z + Math.cos(player.yaw) * distance;
|
||||
item.velocityX = player.velocityX;
|
||||
item.velocityY = 0;
|
||||
item.velocityZ = player.velocityZ;
|
||||
item.yaw = player.yaw;
|
||||
const rotation = quaternionFromYaw(player.yaw);
|
||||
item.rotationX = rotation.x;
|
||||
item.rotationY = rotation.y;
|
||||
item.rotationZ = rotation.z;
|
||||
item.rotationW = rotation.w;
|
||||
item.angularVelocityX = 0;
|
||||
item.angularVelocityY = 0;
|
||||
item.angularVelocityZ = 0;
|
||||
}
|
||||
|
||||
function releaseFurniture(item: MoversAuthorityFurniture, player: MoversAuthorityPlayer, thrown: boolean): void {
|
||||
const forwardX = Math.sin(player.yaw);
|
||||
const forwardZ = Math.cos(player.yaw);
|
||||
item.carriedBy = null;
|
||||
item.velocityX = player.velocityX + forwardX * (thrown ? 12 : 0);
|
||||
item.velocityY = thrown ? 4.2 : 0;
|
||||
item.velocityZ = player.velocityZ + forwardZ * (thrown ? 12 : 0);
|
||||
item.angularVelocityX = thrown ? (player.id % 2 === 0 ? 5.2 : -5.2) : 0;
|
||||
item.angularVelocityY = thrown ? 2.4 : 0;
|
||||
item.angularVelocityZ = thrown ? (player.id % 2 === 0 ? -3.6 : 3.6) : 0;
|
||||
}
|
||||
|
||||
function resolveCarrierTackles(
|
||||
state: MoversAuthorityState,
|
||||
tick: number,
|
||||
emit: (event: MoversAuthorityEvent) => void,
|
||||
): void {
|
||||
for (let leftIndex = 0; leftIndex < state.players.length; leftIndex += 1) {
|
||||
const left = state.players[leftIndex]!;
|
||||
for (let rightIndex = leftIndex + 1; rightIndex < state.players.length; rightIndex += 1) {
|
||||
const right = state.players[rightIndex]!;
|
||||
if (left.team === right.team || Math.hypot(right.x - left.x, right.z - left.z) > 1.3) continue;
|
||||
const speed = Math.hypot(left.velocityX - right.velocityX, left.velocityZ - right.velocityZ);
|
||||
if (speed < 8.2) continue;
|
||||
const carrier = left.carryingId !== null ? left : right.carryingId !== null ? right : null;
|
||||
if (!carrier || tick % 2 !== carrier.id % 2) continue;
|
||||
const item = findFurniture(state, carrier.carryingId!);
|
||||
if (!item) continue;
|
||||
carrier.carryingId = null;
|
||||
carrier.grabbing = false;
|
||||
releaseFurniture(item, carrier, true);
|
||||
emit({ id: state.nextEventId++, type: "thrown", playerId: carrier.id, itemId: item.id, team: carrier.team });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tryCloseTruck(
|
||||
state: MoversAuthorityState,
|
||||
player: MoversAuthorityPlayer,
|
||||
emit: (event: MoversAuthorityEvent) => void,
|
||||
): void {
|
||||
if (!nearOwnTruck(player) || doorTicks(state, player.team) > 0) return;
|
||||
if (player.carryingId !== null) {
|
||||
const held = findFurniture(state, player.carryingId);
|
||||
if (held && inTruckZone(player.team, held.x, held.z)) {
|
||||
held.carriedBy = null;
|
||||
player.carryingId = null;
|
||||
}
|
||||
}
|
||||
const cargo = state.furniture.filter(
|
||||
(item) => item.securedBy === null && item.carriedBy === null && inTruckZone(player.team, item.x, item.z),
|
||||
);
|
||||
if (cargo.length === 0) return;
|
||||
setDoorTicks(state, player.team, MOVERS_TICK_RATE * 2);
|
||||
emit({ id: state.nextEventId++, type: "doors", team: player.team });
|
||||
cargo.forEach((item, index) => {
|
||||
item.securedBy = player.team;
|
||||
item.velocityX = 0;
|
||||
item.velocityY = 0;
|
||||
item.velocityZ = 0;
|
||||
item.angularVelocityX = 0;
|
||||
item.angularVelocityY = 0;
|
||||
item.angularVelocityZ = 0;
|
||||
item.x = player.team === "yellow" ? -36 - (index % 2) * 2.2 : 36 + (index % 2) * 2.2;
|
||||
item.y = FURNITURE[item.kind].halfExtents.y + 0.08;
|
||||
item.z = -3 + Math.floor(index / 2) * 2.5;
|
||||
item.yaw = 0;
|
||||
item.rotationX = 0;
|
||||
item.rotationY = 0;
|
||||
item.rotationZ = 0;
|
||||
item.rotationW = 1;
|
||||
const value = remainingValue(item);
|
||||
if (player.team === "yellow") state.yellowScore += value;
|
||||
else state.blueScore += value;
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "secured",
|
||||
playerId: player.id,
|
||||
itemId: item.id,
|
||||
kind: item.kind,
|
||||
team: player.team,
|
||||
value,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function demolitionPulse(
|
||||
state: MoversAuthorityState,
|
||||
tick: number,
|
||||
emit: (event: MoversAuthorityEvent) => void,
|
||||
): void {
|
||||
const angle = tick * 0.031;
|
||||
moversPhysics.explode(state, {
|
||||
position: { x: Math.cos(angle) * 6, y: 0.4, z: Math.sin(angle * 1.3) * 5 },
|
||||
radius: 22,
|
||||
falloff: 3,
|
||||
impulsePerArea: 5.5,
|
||||
});
|
||||
for (const item of state.furniture) {
|
||||
if (item.securedBy !== null || item.carriedBy !== null) continue;
|
||||
damageFurniture(state, item, 3.5, tick, emit);
|
||||
}
|
||||
}
|
||||
|
||||
function damageFurniture(
|
||||
state: MoversAuthorityState,
|
||||
item: MoversAuthorityFurniture,
|
||||
rawAmount: number,
|
||||
tick: number,
|
||||
emit: (event: MoversAuthorityEvent) => void,
|
||||
): void {
|
||||
const amount = Math.min(100 - item.damage, rawAmount * FURNITURE[item.kind].fragile);
|
||||
if (amount <= 0.1) return;
|
||||
item.damage = clamp(item.damage + amount, 0, 100);
|
||||
if (tick - item.lastDamageTick < 5) return;
|
||||
item.lastDamageTick = tick;
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "damaged",
|
||||
itemId: item.id,
|
||||
amount: Math.round(amount),
|
||||
remainingValue: remainingValue(item),
|
||||
});
|
||||
}
|
||||
|
||||
function finishMatch(state: MoversAuthorityState, emit: (event: MoversAuthorityEvent) => void): void {
|
||||
if (state.resetTicks > 0) return;
|
||||
state.winner = state.yellowScore === state.blueScore
|
||||
? "draw"
|
||||
: state.yellowScore > state.blueScore ? "yellow" : "blue";
|
||||
state.resetTicks = MOVERS_TICK_RATE * 7;
|
||||
emit({ id: state.nextEventId++, type: "winner", team: state.winner, round: state.round });
|
||||
}
|
||||
|
||||
function resetMatch(state: MoversAuthorityState): void {
|
||||
state.round += 1;
|
||||
state.yellowScore = 0;
|
||||
state.blueScore = 0;
|
||||
state.yellowDoorTicks = 0;
|
||||
state.blueDoorTicks = 0;
|
||||
state.elapsedTicks = 0;
|
||||
state.winner = null;
|
||||
state.demolitionStarted = false;
|
||||
state.furniture = createFurniture();
|
||||
state.players.forEach((player, index) => {
|
||||
Object.assign(player, createPlayer(player.id, player.team, player.bot, index));
|
||||
});
|
||||
moversPhysics.reset(state);
|
||||
}
|
||||
|
||||
function playerView(player: MoversAuthorityPlayer, owner: boolean): MoversPlayerView {
|
||||
return {
|
||||
id: player.id,
|
||||
team: player.team,
|
||||
bot: player.bot,
|
||||
x: player.x,
|
||||
z: player.z,
|
||||
velocityX: player.velocityX,
|
||||
velocityZ: player.velocityZ,
|
||||
yaw: player.yaw,
|
||||
stamina: owner ? player.stamina : null,
|
||||
carryingId: player.carryingId,
|
||||
inputForward: player.inputForward,
|
||||
inputStrafe: player.inputStrafe,
|
||||
sprinting: player.sprinting,
|
||||
grabbing: player.grabbing,
|
||||
};
|
||||
}
|
||||
|
||||
function furnitureView(item: MoversAuthorityFurniture): MoversFurnitureView {
|
||||
return {
|
||||
id: item.id,
|
||||
kind: item.kind,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
z: item.z,
|
||||
velocityX: item.velocityX,
|
||||
velocityY: item.velocityY,
|
||||
velocityZ: item.velocityZ,
|
||||
yaw: item.yaw,
|
||||
rotationX: item.rotationX,
|
||||
rotationY: item.rotationY,
|
||||
rotationZ: item.rotationZ,
|
||||
rotationW: item.rotationW,
|
||||
angularVelocityX: item.angularVelocityX,
|
||||
angularVelocityY: item.angularVelocityY,
|
||||
angularVelocityZ: item.angularVelocityZ,
|
||||
damage: item.damage,
|
||||
carriedBy: item.carriedBy,
|
||||
securedBy: item.securedBy,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneAuthorityState(state: MoversAuthorityState): MoversAuthorityState {
|
||||
return {
|
||||
...state,
|
||||
players: state.players.map((player) => ({ ...player })),
|
||||
furniture: state.furniture.map((item) => ({ ...item })),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneClientState(state: MoversClientState): MoversClientState {
|
||||
return {
|
||||
...state,
|
||||
players: state.players.map((player) => ({ ...player })),
|
||||
furniture: state.furniture.map((item) => ({ ...item })),
|
||||
events: state.events.map((entry) => ({ receivedTick: entry.receivedTick, event: { ...entry.event } })),
|
||||
};
|
||||
}
|
||||
|
||||
function findPlayer(state: MoversAuthorityState, id: number): MoversAuthorityPlayer | undefined {
|
||||
return state.players.find((player) => player.id === id);
|
||||
}
|
||||
|
||||
function findFurniture(state: MoversAuthorityState, id: number): MoversAuthorityFurniture | undefined {
|
||||
return state.furniture.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
function leastPopulatedTeam(state: MoversAuthorityState): MoversTeam {
|
||||
const yellow = state.players.filter((player) => player.team === "yellow").length;
|
||||
return yellow <= state.players.length - yellow ? "yellow" : "blue";
|
||||
}
|
||||
|
||||
function nearOwnTruck(player: Pick<MoversAuthorityPlayer, "team" | "x" | "z">): boolean {
|
||||
return player.team === "yellow"
|
||||
? player.x < -27 && Math.abs(player.z) < 10
|
||||
: player.x > 27 && Math.abs(player.z) < 10;
|
||||
}
|
||||
|
||||
function inTruckZone(team: MoversTeam, x: number, z: number): boolean {
|
||||
return team === "yellow"
|
||||
? x < -29 && x > -41 && Math.abs(z) < 8
|
||||
: x > 29 && x < 41 && Math.abs(z) < 8;
|
||||
}
|
||||
|
||||
function doorTicks(state: MoversAuthorityState, team: MoversTeam): number {
|
||||
return team === "yellow" ? state.yellowDoorTicks : state.blueDoorTicks;
|
||||
}
|
||||
|
||||
function setDoorTicks(state: MoversAuthorityState, team: MoversTeam, ticks: number): void {
|
||||
if (team === "yellow") state.yellowDoorTicks = ticks;
|
||||
else state.blueDoorTicks = ticks;
|
||||
}
|
||||
|
||||
function remainingValue(item: Pick<MoversAuthorityFurniture, "kind" | "damage">): number {
|
||||
return Math.max(25, Math.round(FURNITURE[item.kind].value * (1 - item.damage / 100)));
|
||||
}
|
||||
|
||||
function quaternionFromYaw(yaw: number): { x: number; y: number; z: number; w: number } {
|
||||
return { x: 0, y: Math.sin(yaw / 2), z: 0, w: Math.cos(yaw / 2) };
|
||||
}
|
||||
|
||||
function rotateToward(current: number, target: number, maximum: number): number {
|
||||
const difference = normalizeAngle(target - current);
|
||||
return normalizeAngle(current + clamp(difference, -maximum, maximum));
|
||||
}
|
||||
|
||||
function normalizeAngle(value: number): number {
|
||||
return Math.atan2(Math.sin(value), Math.cos(value));
|
||||
}
|
||||
|
||||
function approach(value: number, target: number, maximumDelta: number): number {
|
||||
return value < target
|
||||
? Math.min(target, value + maximumDelta)
|
||||
: Math.max(target, value - maximumDelta);
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
161
packages/shared/src/movers-types.ts
Normal file
161
packages/shared/src/movers-types.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
export type MoversTeam = "yellow" | "blue";
|
||||
|
||||
export type FurnitureKind =
|
||||
| "piano"
|
||||
| "aquarium"
|
||||
| "safe"
|
||||
| "sofa"
|
||||
| "television"
|
||||
| "mattress"
|
||||
| "urn"
|
||||
| "refrigerator"
|
||||
| "plant"
|
||||
| "mystery-box";
|
||||
|
||||
export interface MoversInput {
|
||||
forward: number;
|
||||
strafe: number;
|
||||
sprint: boolean;
|
||||
grab: boolean;
|
||||
throwItem: boolean;
|
||||
closeDoors: boolean;
|
||||
}
|
||||
|
||||
export interface MoversAuthorityPlayer {
|
||||
id: number;
|
||||
team: MoversTeam;
|
||||
bot: boolean;
|
||||
x: number;
|
||||
z: number;
|
||||
velocityX: number;
|
||||
velocityZ: number;
|
||||
yaw: number;
|
||||
stamina: number;
|
||||
carryingId: number | null;
|
||||
inputForward: number;
|
||||
inputStrafe: number;
|
||||
sprinting: boolean;
|
||||
grabbing: boolean;
|
||||
throwing: boolean;
|
||||
closingDoors: boolean;
|
||||
throwRequested: boolean;
|
||||
closeDoorsRequested: boolean;
|
||||
botTargetId: number | null;
|
||||
}
|
||||
|
||||
export interface MoversAuthorityFurniture {
|
||||
id: number;
|
||||
kind: FurnitureKind;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
velocityX: number;
|
||||
velocityY: number;
|
||||
velocityZ: number;
|
||||
yaw: number;
|
||||
rotationX: number;
|
||||
rotationY: number;
|
||||
rotationZ: number;
|
||||
rotationW: number;
|
||||
angularVelocityX: number;
|
||||
angularVelocityY: number;
|
||||
angularVelocityZ: number;
|
||||
damage: number;
|
||||
carriedBy: number | null;
|
||||
securedBy: MoversTeam | null;
|
||||
lastDamageTick: number;
|
||||
}
|
||||
|
||||
export interface MoversAuthorityState {
|
||||
players: MoversAuthorityPlayer[];
|
||||
furniture: MoversAuthorityFurniture[];
|
||||
yellowScore: number;
|
||||
blueScore: number;
|
||||
yellowDoorTicks: number;
|
||||
blueDoorTicks: number;
|
||||
elapsedTicks: number;
|
||||
round: number;
|
||||
resetTicks: number;
|
||||
winner: MoversTeam | "draw" | null;
|
||||
demolitionStarted: boolean;
|
||||
nextEventId: number;
|
||||
}
|
||||
|
||||
export interface MoversPlayerView {
|
||||
id: number;
|
||||
team: MoversTeam;
|
||||
bot: boolean;
|
||||
x: number;
|
||||
z: number;
|
||||
velocityX: number;
|
||||
velocityZ: number;
|
||||
yaw: number;
|
||||
/** Exact stamina is private to its owning player. */
|
||||
stamina: number | null;
|
||||
carryingId: number | null;
|
||||
inputForward: number;
|
||||
inputStrafe: number;
|
||||
sprinting: boolean;
|
||||
grabbing: boolean;
|
||||
}
|
||||
|
||||
export interface MoversFurnitureView {
|
||||
id: number;
|
||||
kind: FurnitureKind;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
velocityX: number;
|
||||
velocityY: number;
|
||||
velocityZ: number;
|
||||
yaw: number;
|
||||
rotationX: number;
|
||||
rotationY: number;
|
||||
rotationZ: number;
|
||||
rotationW: number;
|
||||
angularVelocityX: number;
|
||||
angularVelocityY: number;
|
||||
angularVelocityZ: number;
|
||||
damage: number;
|
||||
carriedBy: number | null;
|
||||
securedBy: MoversTeam | null;
|
||||
}
|
||||
|
||||
export interface MoversClientState {
|
||||
players: MoversPlayerView[];
|
||||
furniture: MoversFurnitureView[];
|
||||
yellowScore: number;
|
||||
blueScore: number;
|
||||
yellowDoorTicks: number;
|
||||
blueDoorTicks: number;
|
||||
elapsedTicks: number;
|
||||
round: number;
|
||||
resetTicks: number;
|
||||
winner: MoversTeam | "draw" | null;
|
||||
demolitionStarted: boolean;
|
||||
events: MoversPresentationEvent[];
|
||||
}
|
||||
|
||||
export type MoversAuthorityEvent =
|
||||
| { id: number; type: "grabbed"; playerId: number; itemId: number; team: MoversTeam }
|
||||
| { id: number; type: "thrown"; playerId: number; itemId: number; team: MoversTeam }
|
||||
| { id: number; type: "damaged"; itemId: number; amount: number; remainingValue: number }
|
||||
| { id: number; type: "secured"; playerId: number; itemId: number; kind: FurnitureKind; team: MoversTeam; value: number }
|
||||
| { id: number; type: "doors"; team: MoversTeam }
|
||||
| { id: number; type: "demolition" }
|
||||
| { id: number; type: "winner"; team: MoversTeam | "draw"; round: number };
|
||||
|
||||
export type MoversPerception = MoversAuthorityEvent;
|
||||
|
||||
export interface MoversPresentationEvent {
|
||||
receivedTick: number;
|
||||
event: MoversPerception;
|
||||
}
|
||||
|
||||
export interface MoversGameContract {
|
||||
authority: MoversAuthorityState;
|
||||
client: MoversClientState;
|
||||
input: MoversInput;
|
||||
authorityEvent: MoversAuthorityEvent;
|
||||
perceptionEvent: MoversPerception;
|
||||
}
|
||||
144
packages/shared/test/movers.test.mjs
Normal file
144
packages/shared/test/movers.test.mjs
Normal file
@@ -0,0 +1,144 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
FURNITURE,
|
||||
MOVERS_BOT_IDS,
|
||||
MOVERS_PHYSICS_BACKEND,
|
||||
MOVERS_SNAPSHOT_RATE,
|
||||
MOVERS_TICK_RATE,
|
||||
moversGame,
|
||||
} from "../dist/index.js";
|
||||
|
||||
function inputPacket(sequence, targetTick, input = {}) {
|
||||
return {
|
||||
sequence,
|
||||
targetTick,
|
||||
observedTick: Math.max(0, targetTick - 1),
|
||||
input: {
|
||||
forward: 0,
|
||||
strafe: 0,
|
||||
sprint: false,
|
||||
grab: false,
|
||||
throwItem: false,
|
||||
closeDoors: false,
|
||||
...input,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("Bad Movers is a fourth complete game built through defineMultiplayerGame", () => {
|
||||
assert.equal(moversGame.tickRateHz, MOVERS_TICK_RATE);
|
||||
assert.equal(moversGame.snapshotRateHz, MOVERS_SNAPSHOT_RATE);
|
||||
const server = moversGame.createServer();
|
||||
assert.equal(server.currentState.players.filter((player) => player.bot).length, MOVERS_BOT_IDS.length);
|
||||
assert.equal(server.currentState.furniture.length, Object.keys(FURNITURE).length);
|
||||
assert.deepEqual(MOVERS_PHYSICS_BACKEND, {
|
||||
name: "Box3D",
|
||||
version: "0.1.0",
|
||||
bindingVersion: "0.2.0",
|
||||
runtime: "WebAssembly SIMD",
|
||||
solver: "single-threaded deterministic",
|
||||
subSteps: 4,
|
||||
});
|
||||
|
||||
server.addPlayer(1);
|
||||
server.addPlayer(2);
|
||||
assert.equal(server.currentState.players.find((player) => player.id === 1).team, "yellow");
|
||||
assert.equal(server.currentState.players.find((player) => player.id === 2).team, "blue");
|
||||
});
|
||||
|
||||
test("real Box3D gravity advances full 3D furniture transforms", () => {
|
||||
const server = moversGame.createServer();
|
||||
const box = server.currentState.furniture.find((item) => item.kind === "mystery-box");
|
||||
Object.assign(box, {
|
||||
x: 0,
|
||||
y: 8,
|
||||
z: 0,
|
||||
velocityX: 0,
|
||||
velocityY: 0,
|
||||
velocityZ: 0,
|
||||
});
|
||||
for (let tick = 0; tick < 12; tick += 1) server.step();
|
||||
assert.ok(box.y < 7, `expected Box3D gravity to drop the box, got y=${box.y}`);
|
||||
assert.ok(box.velocityY < 0);
|
||||
assert.ok(Number.isFinite(box.rotationX + box.rotationY + box.rotationZ + box.rotationW));
|
||||
});
|
||||
|
||||
test("independent Box3D authorities produce the same deterministic match", () => {
|
||||
const first = moversGame.createServer();
|
||||
const second = moversGame.createServer();
|
||||
for (let tick = 0; tick < 600; tick += 1) {
|
||||
first.step();
|
||||
second.step();
|
||||
}
|
||||
assert.deepEqual(first.currentState, second.currentState);
|
||||
});
|
||||
|
||||
test("only the owning mover receives exact stamina", () => {
|
||||
const server = moversGame.createServer();
|
||||
server.addPlayer(1);
|
||||
server.addPlayer(2);
|
||||
const yellowView = server.createSnapshot(1, 0).state;
|
||||
const blueView = server.createSnapshot(2, 0).state;
|
||||
assert.equal(yellowView.players.find((player) => player.id === 1).stamina, 100);
|
||||
assert.equal(yellowView.players.find((player) => player.id === 2).stamina, null);
|
||||
assert.equal(blueView.players.find((player) => player.id === 1).stamina, null);
|
||||
assert.equal(blueView.players.find((player) => player.id === 2).stamina, 100);
|
||||
assert.equal("nextEventId" in yellowView, false);
|
||||
});
|
||||
|
||||
test("furniture only scores when a mover closes their own truck doors", () => {
|
||||
const server = moversGame.createServer();
|
||||
server.addPlayer(1);
|
||||
const player = server.currentState.players.find((candidate) => candidate.id === 1);
|
||||
const urn = server.currentState.furniture.find((item) => item.kind === "urn");
|
||||
Object.assign(player, { x: -34, z: 0 });
|
||||
Object.assign(player, { carryingId: urn.id, grabbing: false });
|
||||
Object.assign(urn, { x: -35, z: 0, damage: 10, carriedBy: player.id });
|
||||
assert.equal(server.currentState.yellowScore, 0);
|
||||
|
||||
assert.equal(server.submitInput(1, inputPacket(1, 1, { closeDoors: true })).accepted, true);
|
||||
const events = server.step().events;
|
||||
assert.equal(urn.securedBy, "yellow");
|
||||
assert.equal(server.currentState.yellowScore, Math.round(FURNITURE.urn.value * 0.9));
|
||||
assert.ok(events.some((event) => event.type === "doors" && event.team === "yellow"));
|
||||
assert.ok(events.some((event) => event.type === "secured" && event.itemId === urn.id));
|
||||
});
|
||||
|
||||
test("Bad Movers bots can complete physical deliveries headlessly", () => {
|
||||
const server = moversGame.createServer();
|
||||
let secured = 0;
|
||||
for (let tick = 0; tick < 2_400; tick += 1) {
|
||||
for (const event of server.step().events) {
|
||||
if (event.type === "secured") secured += 1;
|
||||
}
|
||||
}
|
||||
assert.ok(secured >= 2, `expected bot deliveries, saw ${secured}`);
|
||||
assert.ok(server.currentState.yellowScore + server.currentState.blueScore > 0);
|
||||
assert.ok(server.currentState.furniture.every((item) => Number.isFinite(item.x + item.z + item.damage)));
|
||||
});
|
||||
|
||||
test("Bad Movers input and state round-trip through the generic protocol", () => {
|
||||
const server = moversGame.createServer();
|
||||
server.addPlayer(7);
|
||||
const client = moversGame.createClient();
|
||||
client.initialize(7, server.createSnapshot(7, 10));
|
||||
const packet = client.createInput({
|
||||
forward: 1,
|
||||
strafe: -0.5,
|
||||
sprint: true,
|
||||
grab: true,
|
||||
throwItem: false,
|
||||
closeDoors: false,
|
||||
}, 1);
|
||||
const decoded = moversGame.protocol.decodeClient(
|
||||
moversGame.protocol.encodeClient({ kind: "input", packet }),
|
||||
);
|
||||
assert.equal(decoded.kind, "input");
|
||||
assert.deepEqual(decoded.packet, packet);
|
||||
assert.equal(server.submitInput(7, decoded.packet).accepted, true);
|
||||
server.step();
|
||||
client.step();
|
||||
client.reconcile(server.createSnapshot(7, 20));
|
||||
assert.ok(client.currentState.players.some((player) => player.id === 7));
|
||||
});
|
||||
Reference in New Issue
Block a user