This commit is contained in:
28
packages/shared/package.json
Normal file
28
packages/shared/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@syncer/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@syncer/engine": "0.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsc --watch --preserveWatchOutput",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
231
packages/shared/src/arena.ts
Normal file
231
packages/shared/src/arena.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
export interface ArenaBlock {
|
||||
id: string;
|
||||
x: number;
|
||||
z: number;
|
||||
width: number;
|
||||
depth: number;
|
||||
height: number;
|
||||
style: "reactor" | "wall" | "crate" | "column";
|
||||
}
|
||||
|
||||
export interface ArenaSpawn {
|
||||
x: number;
|
||||
z: number;
|
||||
yaw: number;
|
||||
}
|
||||
|
||||
export interface ArenaPoint {
|
||||
x: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export const ARENA_HALF_SIZE = 18;
|
||||
export const PLAYER_RADIUS = 0.44;
|
||||
export const PLAYER_EYE_HEIGHT = 1.58;
|
||||
export const PLAYER_HEIGHT = 1.8;
|
||||
|
||||
export const ARENA_BLOCKS: readonly ArenaBlock[] = [
|
||||
{ id: "reactor", x: 0, z: 0, width: 4.2, depth: 4.2, height: 4.8, style: "reactor" },
|
||||
{ id: "north-spine", x: 0, z: -10.2, width: 8.2, depth: 1.2, height: 2.8, style: "wall" },
|
||||
{ id: "south-spine", x: 0, z: 10.2, width: 8.2, depth: 1.2, height: 2.8, style: "wall" },
|
||||
{ id: "west-spine", x: -10.2, z: 0, width: 1.2, depth: 8.2, height: 2.8, style: "wall" },
|
||||
{ id: "east-spine", x: 10.2, z: 0, width: 1.2, depth: 8.2, height: 2.8, style: "wall" },
|
||||
{ id: "nw-cover-a", x: -7, z: -7, width: 3.2, depth: 1.5, height: 1.35, style: "crate" },
|
||||
{ id: "nw-cover-b", x: -7, z: -4.7, width: 1.5, depth: 3, height: 2.1, style: "crate" },
|
||||
{ id: "se-cover-a", x: 7, z: 7, width: 3.2, depth: 1.5, height: 1.35, style: "crate" },
|
||||
{ id: "se-cover-b", x: 7, z: 4.7, width: 1.5, depth: 3, height: 2.1, style: "crate" },
|
||||
{ id: "ne-cover-a", x: 7, z: -7, width: 1.5, depth: 3.2, height: 1.55, style: "crate" },
|
||||
{ id: "sw-cover-a", x: -7, z: 7, width: 1.5, depth: 3.2, height: 1.55, style: "crate" },
|
||||
{ id: "northwest-column", x: -14, z: -13, width: 1.8, depth: 1.8, height: 4.2, style: "column" },
|
||||
{ id: "northeast-column", x: 14, z: -13, width: 1.8, depth: 1.8, height: 4.2, style: "column" },
|
||||
{ id: "southwest-column", x: -14, z: 13, width: 1.8, depth: 1.8, height: 4.2, style: "column" },
|
||||
{ id: "southeast-column", x: 14, z: 13, width: 1.8, depth: 1.8, height: 4.2, style: "column" },
|
||||
] as const;
|
||||
|
||||
export const ARENA_SPAWNS: readonly ArenaSpawn[] = [
|
||||
{ x: -14.5, z: -9.5, yaw: 2.35 },
|
||||
{ x: 14.5, z: 9.5, yaw: -0.78 },
|
||||
{ x: 14.5, z: -9.5, yaw: -2.35 },
|
||||
{ x: -14.5, z: 9.5, yaw: 0.78 },
|
||||
{ x: 0, z: -15, yaw: Math.PI },
|
||||
{ x: 0, z: 15, yaw: 0 },
|
||||
{ x: -15, z: 0, yaw: Math.PI / 2 },
|
||||
{ x: 15, z: 0, yaw: -Math.PI / 2 },
|
||||
] as const;
|
||||
|
||||
export const BOT_PATROL_POINTS: readonly ArenaPoint[] = [
|
||||
{ x: -13, z: -8 },
|
||||
{ x: -5.5, z: -12.5 },
|
||||
{ x: 5.5, z: -12.5 },
|
||||
{ x: 13, z: -8 },
|
||||
{ x: 12.5, z: 4 },
|
||||
{ x: 8, z: 12.5 },
|
||||
{ x: -3.5, z: 13.5 },
|
||||
{ x: -12.5, z: 8 },
|
||||
{ x: -13.5, z: -3.5 },
|
||||
{ x: -5.8, z: -4.8 },
|
||||
{ x: 5.8, z: 4.8 },
|
||||
{ x: 5.5, z: -5.5 },
|
||||
] as const;
|
||||
|
||||
export function hasLineOfSight(
|
||||
fromX: number,
|
||||
fromZ: number,
|
||||
toX: number,
|
||||
toZ: number,
|
||||
): boolean {
|
||||
for (const block of ARENA_BLOCKS) {
|
||||
if (segmentIntersectsExpandedBlock(fromX, fromZ, toX, toZ, block, 0.08)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function collidesWithArena(x: number, z: number, radius = PLAYER_RADIUS): boolean {
|
||||
if (
|
||||
x - radius < -ARENA_HALF_SIZE ||
|
||||
x + radius > ARENA_HALF_SIZE ||
|
||||
z - radius < -ARENA_HALF_SIZE ||
|
||||
z + radius > ARENA_HALF_SIZE
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ARENA_BLOCKS.some((block) =>
|
||||
circleIntersectsBlock(x, z, radius, block),
|
||||
);
|
||||
}
|
||||
|
||||
export function rayBlockDistance(
|
||||
originX: number,
|
||||
originY: number,
|
||||
originZ: number,
|
||||
directionX: number,
|
||||
directionY: number,
|
||||
directionZ: number,
|
||||
maximumDistance: number,
|
||||
): number | null {
|
||||
let nearest: number | null = null;
|
||||
|
||||
for (const block of ARENA_BLOCKS) {
|
||||
const distance = rayAabbDistance(
|
||||
originX,
|
||||
originY,
|
||||
originZ,
|
||||
directionX,
|
||||
directionY,
|
||||
directionZ,
|
||||
block.x - block.width / 2,
|
||||
0,
|
||||
block.z - block.depth / 2,
|
||||
block.x + block.width / 2,
|
||||
block.height,
|
||||
block.z + block.depth / 2,
|
||||
);
|
||||
|
||||
if (
|
||||
distance !== null &&
|
||||
distance >= 0 &&
|
||||
distance <= maximumDistance &&
|
||||
(nearest === null || distance < nearest)
|
||||
) {
|
||||
nearest = distance;
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
function circleIntersectsBlock(
|
||||
x: number,
|
||||
z: number,
|
||||
radius: number,
|
||||
block: ArenaBlock,
|
||||
): boolean {
|
||||
const halfWidth = block.width / 2;
|
||||
const halfDepth = block.depth / 2;
|
||||
const nearestX = Math.max(block.x - halfWidth, Math.min(x, block.x + halfWidth));
|
||||
const nearestZ = Math.max(block.z - halfDepth, Math.min(z, block.z + halfDepth));
|
||||
const dx = x - nearestX;
|
||||
const dz = z - nearestZ;
|
||||
return dx * dx + dz * dz < radius * radius;
|
||||
}
|
||||
|
||||
function segmentIntersectsExpandedBlock(
|
||||
fromX: number,
|
||||
fromZ: number,
|
||||
toX: number,
|
||||
toZ: number,
|
||||
block: ArenaBlock,
|
||||
padding: number,
|
||||
): boolean {
|
||||
const directionX = toX - fromX;
|
||||
const directionZ = toZ - fromZ;
|
||||
const distance = Math.hypot(directionX, directionZ);
|
||||
if (distance < 0.0001) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hit = rayAabbDistance(
|
||||
fromX,
|
||||
1.2,
|
||||
fromZ,
|
||||
directionX / distance,
|
||||
0,
|
||||
directionZ / distance,
|
||||
block.x - block.width / 2 - padding,
|
||||
0,
|
||||
block.z - block.depth / 2 - padding,
|
||||
block.x + block.width / 2 + padding,
|
||||
block.height,
|
||||
block.z + block.depth / 2 + padding,
|
||||
);
|
||||
return hit !== null && hit > 0.05 && hit < distance - 0.05;
|
||||
}
|
||||
|
||||
function rayAabbDistance(
|
||||
originX: number,
|
||||
originY: number,
|
||||
originZ: number,
|
||||
directionX: number,
|
||||
directionY: number,
|
||||
directionZ: number,
|
||||
minimumX: number,
|
||||
minimumY: number,
|
||||
minimumZ: number,
|
||||
maximumX: number,
|
||||
maximumY: number,
|
||||
maximumZ: number,
|
||||
): number | null {
|
||||
let minimumDistance = -Infinity;
|
||||
let maximumDistance = Infinity;
|
||||
|
||||
for (const [origin, direction, minimum, maximum] of [
|
||||
[originX, directionX, minimumX, maximumX],
|
||||
[originY, directionY, minimumY, maximumY],
|
||||
[originZ, directionZ, minimumZ, maximumZ],
|
||||
] as const) {
|
||||
if (Math.abs(direction) < 0.000001) {
|
||||
if (origin < minimum || origin > maximum) {
|
||||
return null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const inverse = 1 / direction;
|
||||
let near = (minimum - origin) * inverse;
|
||||
let far = (maximum - origin) * inverse;
|
||||
if (near > far) {
|
||||
[near, far] = [far, near];
|
||||
}
|
||||
minimumDistance = Math.max(minimumDistance, near);
|
||||
maximumDistance = Math.min(maximumDistance, far);
|
||||
if (maximumDistance < minimumDistance) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return maximumDistance >= 0 ? Math.max(0, minimumDistance) : null;
|
||||
}
|
||||
|
||||
364
packages/shared/src/flux-game.ts
Normal file
364
packages/shared/src/flux-game.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import {
|
||||
createJsonCodec,
|
||||
defineMultiplayerGame,
|
||||
} from "@syncer/engine";
|
||||
import type {
|
||||
FluxAuthorityEvent,
|
||||
FluxAuthorityPlayer,
|
||||
FluxAuthorityState,
|
||||
FluxClientState,
|
||||
FluxGameContract,
|
||||
FluxInput,
|
||||
FluxPerception,
|
||||
FluxPlayerView,
|
||||
FluxTeam,
|
||||
} from "./flux-types.js";
|
||||
|
||||
export const FLUX_SOCKET_PATH = "/ws/flux";
|
||||
export const FLUX_TICK_RATE = 30;
|
||||
export const FLUX_SNAPSHOT_RATE = 10;
|
||||
export const FLUX_ROUND_RESET_TICKS = 60;
|
||||
export const FLUX_BOT_IDS = [20_001, 20_002] as const;
|
||||
|
||||
const maximumEnergy = 100;
|
||||
const energyDrainPerSecond = 31;
|
||||
const energyRecoveryPerSecond = 20;
|
||||
const coreAcceleration = 0.92;
|
||||
const coreDragPerSecond = 0.86;
|
||||
const eventLifetimeTicks = FLUX_TICK_RATE * 4;
|
||||
|
||||
const inputCodec = createJsonCodec<FluxInput>();
|
||||
const stateCodec = createJsonCodec<FluxClientState>();
|
||||
const perceptionCodec = createJsonCodec<FluxPerception>();
|
||||
|
||||
/**
|
||||
* A complete second game built through the public authoring API. Flux Relay is
|
||||
* a team tug-of-war: hold thrust, manage private energy, and pull the shared
|
||||
* core through your team's gate.
|
||||
*/
|
||||
export const fluxGame = defineMultiplayerGame<FluxGameContract>({
|
||||
clock: {
|
||||
ticksPerSecond: FLUX_TICK_RATE,
|
||||
snapshotsPerSecond: FLUX_SNAPSHOT_RATE,
|
||||
},
|
||||
|
||||
authority: {
|
||||
createInitialState: createAuthorityState,
|
||||
cloneState: cloneAuthorityState,
|
||||
addPlayer(state, { playerId }) {
|
||||
state.players.push(createPlayer(playerId, leastPopulatedTeam(state), false));
|
||||
sortPlayers(state.players);
|
||||
},
|
||||
removePlayer(state, { playerId }) {
|
||||
state.players = state.players.filter((player) => player.id !== playerId);
|
||||
},
|
||||
applyInput(state, input, { playerId }) {
|
||||
const player = state.players.find((candidate) => candidate.id === playerId);
|
||||
if (!player || player.bot) return;
|
||||
player.thrust = input.thrust && !player.overheated;
|
||||
},
|
||||
step(state, { tick, deltaSeconds, emit }) {
|
||||
state.elapsedTicks += 1;
|
||||
|
||||
if (state.resetTicks > 0) {
|
||||
state.resetTicks -= 1;
|
||||
state.core *= 0.88;
|
||||
state.velocity = 0;
|
||||
if (state.resetTicks === 0) state.core = 0;
|
||||
updatePlayers(state, tick, deltaSeconds, emit, false);
|
||||
return;
|
||||
}
|
||||
|
||||
updatePlayers(state, tick, deltaSeconds, emit, true);
|
||||
const cyanForce = activePlayerCount(state, "cyan");
|
||||
const orangeForce = activePlayerCount(state, "orange");
|
||||
const signedForce = orangeForce - cyanForce;
|
||||
state.velocity += signedForce * coreAcceleration * deltaSeconds;
|
||||
state.velocity *= Math.pow(coreDragPerSecond, deltaSeconds);
|
||||
state.core += state.velocity * deltaSeconds;
|
||||
|
||||
if (Math.abs(state.core) >= 1) {
|
||||
const team: FluxTeam = state.core < 0 ? "cyan" : "orange";
|
||||
state.core = team === "cyan" ? -1 : 1;
|
||||
state.velocity = 0;
|
||||
state.resetTicks = FLUX_ROUND_RESET_TICKS;
|
||||
state.round += 1;
|
||||
if (team === "cyan") state.cyanScore += 1;
|
||||
else state.orangeScore += 1;
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "round-won",
|
||||
team,
|
||||
round: state.round,
|
||||
});
|
||||
}
|
||||
},
|
||||
validateState(state) {
|
||||
return (
|
||||
Number.isFinite(state.core) &&
|
||||
Number.isFinite(state.velocity) &&
|
||||
Math.abs(state.core) <= 1.000_001 &&
|
||||
state.players.every(
|
||||
(player) =>
|
||||
Number.isFinite(player.energy) &&
|
||||
player.energy >= 0 &&
|
||||
player.energy <= maximumEnergy,
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
prediction: {
|
||||
createInitialState: createClientState,
|
||||
cloneState: cloneClientState,
|
||||
applyInput(state, input, { playerId }) {
|
||||
const player = state.players.find((candidate) => candidate.id === playerId);
|
||||
if (player && player.energy !== null) {
|
||||
player.thrust = input.thrust && player.energy > 0;
|
||||
}
|
||||
},
|
||||
step(state, { tick, deltaSeconds }) {
|
||||
state.elapsedTicks += 1;
|
||||
state.events = state.events.filter(
|
||||
(entry) => entry.receivedTick >= tick - eventLifetimeTicks,
|
||||
);
|
||||
|
||||
const ownPlayer = state.players.find((player) => player.energy !== null);
|
||||
if (ownPlayer) updateVisibleEnergy(ownPlayer, deltaSeconds);
|
||||
|
||||
if (state.resetTicks > 0) {
|
||||
state.resetTicks -= 1;
|
||||
state.core *= 0.88;
|
||||
state.velocity = 0;
|
||||
if (state.resetTicks === 0) state.core = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const cyanForce = visibleActiveCount(state, "cyan");
|
||||
const orangeForce = visibleActiveCount(state, "orange");
|
||||
state.velocity += (orangeForce - cyanForce) * coreAcceleration * deltaSeconds;
|
||||
state.velocity *= Math.pow(coreDragPerSecond, deltaSeconds);
|
||||
state.core = clamp(state.core + state.velocity * deltaSeconds, -1, 1);
|
||||
},
|
||||
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 },
|
||||
}));
|
||||
return merged;
|
||||
},
|
||||
applyEvent(state, event, { tick }) {
|
||||
if (state.events.some((entry) => entry.event.id === event.id)) return;
|
||||
state.events.push({ receivedTick: tick, event: { ...event } });
|
||||
},
|
||||
validateState(state) {
|
||||
const ownEnergy = state.players.find((player) => player.energy !== null)?.energy;
|
||||
return (
|
||||
Number.isFinite(state.core) &&
|
||||
Math.abs(state.core) <= 1.000_001 &&
|
||||
(ownEnergy === undefined ||
|
||||
(ownEnergy !== null && ownEnergy >= 0 && ownEnergy <= maximumEnergy))
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
visibility: {
|
||||
createSnapshot(authority, { playerId }) {
|
||||
return {
|
||||
core: authority.core,
|
||||
velocity: authority.velocity,
|
||||
round: authority.round,
|
||||
cyanScore: authority.cyanScore,
|
||||
orangeScore: authority.orangeScore,
|
||||
resetTicks: authority.resetTicks,
|
||||
elapsedTicks: authority.elapsedTicks,
|
||||
players: authority.players.map((player) => ({
|
||||
id: player.id,
|
||||
team: player.team,
|
||||
bot: player.bot,
|
||||
thrust: player.thrust,
|
||||
energy: player.id === playerId ? player.energy : null,
|
||||
})),
|
||||
events: [],
|
||||
};
|
||||
},
|
||||
validateClientState(authority, candidate, { playerId }) {
|
||||
const expected = authority.players.find((player) => player.id === playerId);
|
||||
const reported = candidate.players.find((player) => player.id === playerId);
|
||||
return Boolean(
|
||||
expected &&
|
||||
reported &&
|
||||
reported.energy !== null &&
|
||||
Math.abs(reported.energy - expected.energy) <= 12 &&
|
||||
Math.abs(candidate.core - authority.core) <= 0.3,
|
||||
);
|
||||
},
|
||||
perceive(_authority, event, { playerId }) {
|
||||
if (event.type === "round-won") return { ...event };
|
||||
return {
|
||||
id: event.id,
|
||||
type: "overheated",
|
||||
team: event.team,
|
||||
scope: event.playerId === playerId ? "self" : "field",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
input: {
|
||||
validate(input) {
|
||||
return typeof input === "object" && input !== null && typeof input.thrust === "boolean";
|
||||
},
|
||||
},
|
||||
|
||||
encoding: {
|
||||
input: inputCodec,
|
||||
clientState: stateCodec,
|
||||
perception: perceptionCodec,
|
||||
},
|
||||
});
|
||||
|
||||
function createAuthorityState(): FluxAuthorityState {
|
||||
return {
|
||||
core: 0,
|
||||
velocity: 0,
|
||||
round: 1,
|
||||
cyanScore: 0,
|
||||
orangeScore: 0,
|
||||
resetTicks: 0,
|
||||
elapsedTicks: 0,
|
||||
nextEventId: 1,
|
||||
players: [
|
||||
createPlayer(FLUX_BOT_IDS[0], "cyan", true),
|
||||
createPlayer(FLUX_BOT_IDS[1], "orange", true),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createClientState(): FluxClientState {
|
||||
return {
|
||||
core: 0,
|
||||
velocity: 0,
|
||||
round: 1,
|
||||
cyanScore: 0,
|
||||
orangeScore: 0,
|
||||
resetTicks: 0,
|
||||
elapsedTicks: 0,
|
||||
players: [],
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createPlayer(
|
||||
id: number,
|
||||
team: FluxTeam,
|
||||
bot: boolean,
|
||||
): FluxAuthorityPlayer {
|
||||
return {
|
||||
id,
|
||||
team,
|
||||
bot,
|
||||
thrust: false,
|
||||
energy: maximumEnergy,
|
||||
overheated: false,
|
||||
};
|
||||
}
|
||||
|
||||
function updatePlayers(
|
||||
state: FluxAuthorityState,
|
||||
tick: number,
|
||||
deltaSeconds: number,
|
||||
emit: (event: FluxAuthorityEvent) => void,
|
||||
allowThrust: boolean,
|
||||
): void {
|
||||
for (const player of state.players) {
|
||||
if (player.bot) {
|
||||
// Both bots use the same deterministic cadence, so they animate the field
|
||||
// without biasing a solo player's match.
|
||||
player.thrust = allowThrust && tick % 150 < 92 && !player.overheated;
|
||||
}
|
||||
|
||||
if (player.thrust && allowThrust && !player.overheated) {
|
||||
player.energy = clamp(
|
||||
player.energy - energyDrainPerSecond * deltaSeconds,
|
||||
0,
|
||||
maximumEnergy,
|
||||
);
|
||||
if (player.energy === 0) {
|
||||
player.thrust = false;
|
||||
player.overheated = true;
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "overheated",
|
||||
playerId: player.id,
|
||||
team: player.team,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
player.energy = clamp(
|
||||
player.energy + energyRecoveryPerSecond * deltaSeconds,
|
||||
0,
|
||||
maximumEnergy,
|
||||
);
|
||||
if (player.overheated && player.energy >= maximumEnergy * 0.34) {
|
||||
player.overheated = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateVisibleEnergy(player: FluxPlayerView, deltaSeconds: number): void {
|
||||
if (player.energy === null) return;
|
||||
player.energy = clamp(
|
||||
player.energy +
|
||||
(player.thrust ? -energyDrainPerSecond : energyRecoveryPerSecond) * deltaSeconds,
|
||||
0,
|
||||
maximumEnergy,
|
||||
);
|
||||
if (player.energy === 0) player.thrust = false;
|
||||
}
|
||||
|
||||
function activePlayerCount(state: FluxAuthorityState, team: FluxTeam): number {
|
||||
return state.players.filter(
|
||||
(player) => player.team === team && player.thrust && !player.overheated,
|
||||
).length;
|
||||
}
|
||||
|
||||
function visibleActiveCount(state: FluxClientState, team: FluxTeam): number {
|
||||
return state.players.filter((player) => player.team === team && player.thrust).length;
|
||||
}
|
||||
|
||||
function leastPopulatedTeam(state: FluxAuthorityState): FluxTeam {
|
||||
const humans = state.players.filter((player) => !player.bot);
|
||||
const cyan = humans.filter((player) => player.team === "cyan").length;
|
||||
const orange = humans.length - cyan;
|
||||
return cyan <= orange ? "cyan" : "orange";
|
||||
}
|
||||
|
||||
function cloneAuthorityState(state: FluxAuthorityState): FluxAuthorityState {
|
||||
return {
|
||||
...state,
|
||||
players: state.players.map((player) => ({ ...player })),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneClientState(state: FluxClientState): FluxClientState {
|
||||
return {
|
||||
...state,
|
||||
players: state.players.map((player) => ({ ...player })),
|
||||
events: state.events.map((entry) => ({
|
||||
receivedTick: entry.receivedTick,
|
||||
event: { ...entry.event },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function sortPlayers(players: FluxAuthorityPlayer[]): void {
|
||||
players.sort((left, right) => left.id - right.id);
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
88
packages/shared/src/flux-types.ts
Normal file
88
packages/shared/src/flux-types.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
export type FluxTeam = "cyan" | "orange";
|
||||
|
||||
export interface FluxInput {
|
||||
thrust: boolean;
|
||||
}
|
||||
|
||||
export interface FluxAuthorityPlayer {
|
||||
id: number;
|
||||
team: FluxTeam;
|
||||
bot: boolean;
|
||||
thrust: boolean;
|
||||
energy: number;
|
||||
overheated: boolean;
|
||||
}
|
||||
|
||||
export interface FluxAuthorityState {
|
||||
core: number;
|
||||
velocity: number;
|
||||
round: number;
|
||||
cyanScore: number;
|
||||
orangeScore: number;
|
||||
resetTicks: number;
|
||||
elapsedTicks: number;
|
||||
nextEventId: number;
|
||||
players: FluxAuthorityPlayer[];
|
||||
}
|
||||
|
||||
export interface FluxPlayerView {
|
||||
id: number;
|
||||
team: FluxTeam;
|
||||
bot: boolean;
|
||||
thrust: boolean;
|
||||
/** Exact energy is private: null for every player except the viewer. */
|
||||
energy: number | null;
|
||||
}
|
||||
|
||||
export type FluxPerception =
|
||||
| {
|
||||
id: number;
|
||||
type: "round-won";
|
||||
team: FluxTeam;
|
||||
round: number;
|
||||
}
|
||||
| {
|
||||
id: number;
|
||||
type: "overheated";
|
||||
team: FluxTeam;
|
||||
scope: "self" | "field";
|
||||
};
|
||||
|
||||
export interface FluxPresentationEvent {
|
||||
receivedTick: number;
|
||||
event: FluxPerception;
|
||||
}
|
||||
|
||||
export interface FluxClientState {
|
||||
core: number;
|
||||
velocity: number;
|
||||
round: number;
|
||||
cyanScore: number;
|
||||
orangeScore: number;
|
||||
resetTicks: number;
|
||||
elapsedTicks: number;
|
||||
players: FluxPlayerView[];
|
||||
events: FluxPresentationEvent[];
|
||||
}
|
||||
|
||||
export type FluxAuthorityEvent =
|
||||
| {
|
||||
id: number;
|
||||
type: "round-won";
|
||||
team: FluxTeam;
|
||||
round: number;
|
||||
}
|
||||
| {
|
||||
id: number;
|
||||
type: "overheated";
|
||||
playerId: number;
|
||||
team: FluxTeam;
|
||||
};
|
||||
|
||||
export interface FluxGameContract {
|
||||
authority: FluxAuthorityState;
|
||||
client: FluxClientState;
|
||||
input: FluxInput;
|
||||
authorityEvent: FluxAuthorityEvent;
|
||||
perceptionEvent: FluxPerception;
|
||||
}
|
||||
16
packages/shared/src/index.ts
Normal file
16
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const API_PATHS = { message: "/api/message" } as const;
|
||||
export const GAME_SOCKET_PATH = "/ws/game";
|
||||
|
||||
export interface ApiMessage {
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export * from "./arena.js";
|
||||
export * from "./flux-types.js";
|
||||
export * from "./flux-game.js";
|
||||
export * from "./royale-types.js";
|
||||
export * from "./royale-map.js";
|
||||
export * from "./royale-game.js";
|
||||
export * from "./shooter-types.js";
|
||||
export * from "./shooter-game.js";
|
||||
834
packages/shared/src/royale-game.ts
Normal file
834
packages/shared/src/royale-game.ts
Normal file
@@ -0,0 +1,834 @@
|
||||
import {
|
||||
createJsonCodec,
|
||||
defineMultiplayerGame,
|
||||
withSpatialReplication,
|
||||
} from "@syncer/engine";
|
||||
import {
|
||||
ROYALE_CHUNK_SIZE,
|
||||
ROYALE_MAP_SEED,
|
||||
ROYALE_WORLD_RADIUS,
|
||||
ROYALE_WORLD_SIZE,
|
||||
collidesWithRoyaleMap,
|
||||
hasRoyaleLineOfSight,
|
||||
royaleChunkCoordinate,
|
||||
royaleRandom,
|
||||
} from "./royale-map.js";
|
||||
import type {
|
||||
RoyaleAuthorityEvent,
|
||||
RoyaleAuthorityLoot,
|
||||
RoyaleAuthorityPlayer,
|
||||
RoyaleAuthorityState,
|
||||
RoyaleClientState,
|
||||
RoyaleGameContract,
|
||||
RoyaleInput,
|
||||
RoyaleLootKind,
|
||||
RoyalePerception,
|
||||
RoyalePlayerView,
|
||||
RoyaleWeapon,
|
||||
} from "./royale-types.js";
|
||||
|
||||
export const ROYALE_SOCKET_PATH = "/ws/royale";
|
||||
export const ROYALE_TICK_RATE = 30;
|
||||
export const ROYALE_SNAPSHOT_RATE = 10;
|
||||
export const ROYALE_BOT_COUNT = 31;
|
||||
export const ROYALE_BOT_ID_BASE = 30_000;
|
||||
|
||||
export const ROYALE_WEAPONS: Record<
|
||||
RoyaleWeapon,
|
||||
{
|
||||
name: string;
|
||||
damage: number;
|
||||
range: number;
|
||||
cooldownTicks: number;
|
||||
magazine: number;
|
||||
reserve: number;
|
||||
reloadTicks: number;
|
||||
}
|
||||
> = {
|
||||
pistol: { name: "Kestrel P9", damage: 19, range: 125, cooldownTicks: 8, magazine: 12, reserve: 48, reloadTicks: 38 },
|
||||
rifle: { name: "VX-4 Carbine", damage: 15, range: 205, cooldownTicks: 4, magazine: 30, reserve: 120, reloadTicks: 52 },
|
||||
marksman: { name: "Longwatch DMR", damage: 48, range: 340, cooldownTicks: 17, magazine: 8, reserve: 32, reloadTicks: 64 },
|
||||
};
|
||||
|
||||
const movementAcceleration = 52;
|
||||
const movementFriction = 10;
|
||||
const walkSpeed = 11.5;
|
||||
const sprintSpeed = 16.5;
|
||||
const parachuteSpeed = 22;
|
||||
const parachuteDescentPerSecond = 18;
|
||||
const playerEyeHeight = 1.55;
|
||||
const playerHitRadius = 1.25;
|
||||
const presentationEventLifetime = ROYALE_TICK_RATE * 6;
|
||||
const initialStormRadius = 900;
|
||||
const finalStormRadius = 78;
|
||||
const stormShrinkPerTick = 0.085;
|
||||
const lootCount = 260;
|
||||
|
||||
const inputCodec = createJsonCodec<RoyaleInput>();
|
||||
const stateCodec = createJsonCodec<RoyaleClientState>();
|
||||
const eventCodec = createJsonCodec<RoyalePerception>();
|
||||
|
||||
const baseRoyaleGame = defineMultiplayerGame<RoyaleGameContract>({
|
||||
clock: {
|
||||
ticksPerSecond: ROYALE_TICK_RATE,
|
||||
snapshotsPerSecond: ROYALE_SNAPSHOT_RATE,
|
||||
},
|
||||
|
||||
authority: {
|
||||
createInitialState: createAuthorityState,
|
||||
cloneState: cloneAuthorityState,
|
||||
addPlayer(state, { playerId }) {
|
||||
state.players.push(createPlayer(state, playerId, false, 110));
|
||||
state.players.sort((left, right) => left.id - right.id);
|
||||
},
|
||||
removePlayer(state, { playerId }) {
|
||||
state.players = state.players.filter((player) => player.id !== playerId);
|
||||
},
|
||||
applyInput(state, input, { playerId }) {
|
||||
const player = findPlayer(state, playerId);
|
||||
if (!player || player.bot || !player.alive) return;
|
||||
applyCommand(player, input);
|
||||
if (input.reload) beginReload(player);
|
||||
},
|
||||
step(state, { tick, deltaSeconds, emit }) {
|
||||
if (state.resetTicks > 0) {
|
||||
state.resetTicks -= 1;
|
||||
if (state.resetTicks === 0) resetMatch(state);
|
||||
return;
|
||||
}
|
||||
|
||||
state.elapsedTicks += 1;
|
||||
state.stormRadius = Math.max(
|
||||
finalStormRadius,
|
||||
initialStormRadius - state.elapsedTicks * stormShrinkPerTick,
|
||||
);
|
||||
|
||||
for (const player of state.players) {
|
||||
if (!player.alive) continue;
|
||||
updateTimers(player);
|
||||
if (player.botState) updateBot(state, player, tick);
|
||||
const landed = simulatePlayer(player, deltaSeconds);
|
||||
if (landed) {
|
||||
emit({ id: state.nextEventId++, type: "landed", playerId: player.id });
|
||||
}
|
||||
if (player.altitude <= 0) {
|
||||
collectLoot(state, player, emit);
|
||||
if (player.firing) tryFire(state, player, emit);
|
||||
applyStormDamage(state, player, tick, emit);
|
||||
}
|
||||
}
|
||||
|
||||
const alive = state.players.filter((player) => player.alive);
|
||||
if (state.elapsedTicks > ROYALE_TICK_RATE * 8 && alive.length <= 1 && state.players.length > 1) {
|
||||
const winner = alive[0] ?? null;
|
||||
state.winnerId = winner?.id ?? null;
|
||||
state.resetTicks = ROYALE_TICK_RATE * 5;
|
||||
if (winner) {
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "winner",
|
||||
playerId: winner.id,
|
||||
round: state.round,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
validateState(state) {
|
||||
return (
|
||||
Number.isFinite(state.stormRadius) &&
|
||||
state.stormRadius >= finalStormRadius &&
|
||||
state.players.every(
|
||||
(player) =>
|
||||
Number.isFinite(player.x + player.z + player.health + player.altitude) &&
|
||||
Number.isFinite(player.yaw + player.pitch) &&
|
||||
player.health >= 0 &&
|
||||
player.health <= 100 &&
|
||||
player.armor >= 0 &&
|
||||
player.armor <= 100,
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
prediction: {
|
||||
createInitialState: createClientState,
|
||||
cloneState: cloneClientState,
|
||||
applyInput(state, input, { playerId }) {
|
||||
const player = state.players.find((candidate) => candidate.id === playerId);
|
||||
if (!player || !player.alive) return;
|
||||
player.inputForward = input.forward;
|
||||
player.inputStrafe = input.strafe;
|
||||
player.yaw = normalizeAngle(input.yaw);
|
||||
player.pitch = clamp(input.pitch, -1.25, 1.25);
|
||||
player.firing = input.fire;
|
||||
player.sprinting = input.sprint;
|
||||
if (input.reload && player.reloadTicks === 0) {
|
||||
player.reloadTicks = ROYALE_WEAPONS[player.weapon].reloadTicks;
|
||||
}
|
||||
},
|
||||
step(state, { tick, deltaSeconds }) {
|
||||
state.elapsedTicks += 1;
|
||||
state.stormRadius = Math.max(
|
||||
finalStormRadius,
|
||||
initialStormRadius - state.elapsedTicks * stormShrinkPerTick,
|
||||
);
|
||||
for (const player of state.players) {
|
||||
if (!player.alive) continue;
|
||||
if (player.cooldownTicks > 0) player.cooldownTicks -= 1;
|
||||
if (player.reloadTicks > 0) player.reloadTicks -= 1;
|
||||
simulateVisiblePlayer(player, deltaSeconds);
|
||||
}
|
||||
state.events = state.events.filter(
|
||||
(entry) => entry.receivedTick >= tick - presentationEventLifetime,
|
||||
);
|
||||
},
|
||||
mergeSnapshot(predicted, snapshot, { tick }) {
|
||||
const merged = cloneClientState(snapshot);
|
||||
merged.events = predicted.events
|
||||
.filter((entry) => entry.receivedTick >= tick - presentationEventLifetime)
|
||||
.map((entry) => ({ receivedTick: entry.receivedTick, event: { ...entry.event } }));
|
||||
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 (
|
||||
Number.isFinite(state.stormRadius) &&
|
||||
state.players.every(
|
||||
(player) =>
|
||||
Number.isFinite(player.x + player.z + player.altitude) &&
|
||||
Number.isFinite(player.yaw + player.pitch) &&
|
||||
(player.health === null || (player.health >= 0 && player.health <= 100)),
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
visibility: {
|
||||
createSnapshot(authority, { playerId }) {
|
||||
const viewer = findPlayer(authority, playerId);
|
||||
const players = viewer
|
||||
? authority.players
|
||||
.filter((target) => target.id === playerId || canSeePlayer(viewer, target))
|
||||
.map((player) => clonePlayerView(player, player.id === playerId))
|
||||
: [];
|
||||
const loot = viewer
|
||||
? authority.loot
|
||||
.filter(
|
||||
(item) =>
|
||||
item.active && Math.hypot(item.x - viewer.x, item.z - viewer.z) <= 155,
|
||||
)
|
||||
.map(({ id, kind, weapon, amount, x, z }) => ({ id, kind, weapon, amount, x, z }))
|
||||
: [];
|
||||
return {
|
||||
mapSeed: ROYALE_MAP_SEED,
|
||||
worldSize: ROYALE_WORLD_SIZE,
|
||||
chunkSize: ROYALE_CHUNK_SIZE,
|
||||
players,
|
||||
loot,
|
||||
elapsedTicks: authority.elapsedTicks,
|
||||
round: authority.round,
|
||||
aliveCount: authority.players.filter((player) => player.alive).length,
|
||||
stormX: authority.stormX,
|
||||
stormZ: authority.stormZ,
|
||||
stormRadius: authority.stormRadius,
|
||||
resetTicks: authority.resetTicks,
|
||||
winnerId: authority.winnerId,
|
||||
stream: {
|
||||
candidateCount: players.length + loot.length,
|
||||
droppedCount: 0,
|
||||
usedBytes: 0,
|
||||
budgetBytes: 0,
|
||||
chunkX: viewer ? royaleChunkCoordinate(viewer.x) : 0,
|
||||
chunkZ: viewer ? royaleChunkCoordinate(viewer.z) : 0,
|
||||
},
|
||||
events: [],
|
||||
};
|
||||
},
|
||||
validateClientState(authority, candidate, { playerId }) {
|
||||
const expected = findPlayer(authority, playerId);
|
||||
const reported = candidate.players.find((player) => player.id === playerId);
|
||||
return Boolean(
|
||||
expected &&
|
||||
reported &&
|
||||
reported.health === expected.health &&
|
||||
Math.hypot(reported.x - expected.x, reported.z - expected.z) <= 5,
|
||||
);
|
||||
},
|
||||
perceive(authority, event, { playerId }) {
|
||||
return perceiveEvent(authority, event, playerId);
|
||||
},
|
||||
},
|
||||
|
||||
input: {
|
||||
validate(input) {
|
||||
return (
|
||||
typeof input === "object" &&
|
||||
input !== null &&
|
||||
Number.isFinite(input.forward) &&
|
||||
Number.isFinite(input.strafe) &&
|
||||
Number.isFinite(input.yaw) &&
|
||||
Number.isFinite(input.pitch) &&
|
||||
Math.abs(input.forward) <= 1 &&
|
||||
Math.abs(input.strafe) <= 1 &&
|
||||
input.pitch >= -1.35 &&
|
||||
input.pitch <= 1.35 &&
|
||||
typeof input.fire === "boolean" &&
|
||||
typeof input.sprint === "boolean" &&
|
||||
typeof input.reload === "boolean"
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
encoding: {
|
||||
input: inputCodec,
|
||||
clientState: stateCodec,
|
||||
perception: eventCodec,
|
||||
},
|
||||
});
|
||||
|
||||
export const royaleGame = withSpatialReplication(baseRoyaleGame, {
|
||||
cellSize: ROYALE_CHUNK_SIZE,
|
||||
bandwidthBudgetBytesPerSecond: 30_000,
|
||||
reservedBytesPerSnapshot: 300,
|
||||
viewerPosition(authority, playerId) {
|
||||
const viewer = findPlayer(authority, playerId);
|
||||
return viewer ? { x: viewer.x, z: viewer.z } : null;
|
||||
},
|
||||
sources: [
|
||||
{
|
||||
category: "players",
|
||||
maximumDistance: 360,
|
||||
entities: (authority) => authority.players.filter((player) => player.alive),
|
||||
estimatedBytes: 76,
|
||||
priority: (_entity, distance) => 1_000 - distance,
|
||||
required: (entity, playerId) => entity.id === playerId,
|
||||
},
|
||||
{
|
||||
category: "loot",
|
||||
maximumDistance: 165,
|
||||
entities: (authority) => authority.loot.filter((item) => item.active),
|
||||
estimatedBytes: 44,
|
||||
priority: (_entity, distance) => 300 - distance,
|
||||
},
|
||||
],
|
||||
projectSnapshot(snapshot, selection) {
|
||||
const visibleCandidates = snapshot.players.length + snapshot.loot.length;
|
||||
const players = snapshot.players.filter((player) => selection.has("players", player.id));
|
||||
const loot = snapshot.loot.filter((item) => selection.has("loot", item.id));
|
||||
return {
|
||||
...snapshot,
|
||||
players,
|
||||
loot,
|
||||
stream: {
|
||||
candidateCount: visibleCandidates,
|
||||
droppedCount: visibleCandidates - players.length - loot.length,
|
||||
usedBytes: players.length * 76 + loot.length * 44,
|
||||
budgetBytes: selection.budgetBytes,
|
||||
chunkX: royaleChunkCoordinate(selection.position.x),
|
||||
chunkZ: royaleChunkCoordinate(selection.position.z),
|
||||
},
|
||||
};
|
||||
},
|
||||
perceive(_authority, _event, perception, selection) {
|
||||
if (perception.type === "shot" && !selection.has("players", perception.sourceId)) {
|
||||
return null;
|
||||
}
|
||||
return perception;
|
||||
},
|
||||
});
|
||||
|
||||
function createAuthorityState(): RoyaleAuthorityState {
|
||||
const secretSeed = randomSeed();
|
||||
const state: RoyaleAuthorityState = {
|
||||
players: [],
|
||||
loot: [],
|
||||
elapsedTicks: 0,
|
||||
round: 1,
|
||||
stormX: (royaleRandom(secretSeed, 901) - 0.5) * 180,
|
||||
stormZ: (royaleRandom(secretSeed, 902) - 0.5) * 180,
|
||||
stormRadius: initialStormRadius,
|
||||
resetTicks: 0,
|
||||
winnerId: null,
|
||||
nextEventId: 1,
|
||||
secretSeed,
|
||||
};
|
||||
state.loot = createLoot(secretSeed);
|
||||
for (let index = 0; index < ROYALE_BOT_COUNT; index += 1) {
|
||||
const id = ROYALE_BOT_ID_BASE + index;
|
||||
state.players.push(createPlayer(state, id, true, 0));
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function createClientState(): RoyaleClientState {
|
||||
return {
|
||||
mapSeed: ROYALE_MAP_SEED,
|
||||
worldSize: ROYALE_WORLD_SIZE,
|
||||
chunkSize: ROYALE_CHUNK_SIZE,
|
||||
players: [],
|
||||
loot: [],
|
||||
elapsedTicks: 0,
|
||||
round: 1,
|
||||
aliveCount: 0,
|
||||
stormX: 0,
|
||||
stormZ: 0,
|
||||
stormRadius: initialStormRadius,
|
||||
resetTicks: 0,
|
||||
winnerId: null,
|
||||
stream: { candidateCount: 0, droppedCount: 0, usedBytes: 0, budgetBytes: 0, chunkX: 0, chunkZ: 0 },
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createPlayer(
|
||||
state: RoyaleAuthorityState,
|
||||
id: number,
|
||||
bot: boolean,
|
||||
altitude: number,
|
||||
): RoyaleAuthorityPlayer {
|
||||
const spawn = findSpawn(state.secretSeed, id + state.round * 101);
|
||||
const weapon = bot && id % 5 === 0 ? "rifle" : "pistol";
|
||||
const config = ROYALE_WEAPONS[weapon];
|
||||
return {
|
||||
id,
|
||||
bot,
|
||||
alive: true,
|
||||
x: spawn.x,
|
||||
z: spawn.z,
|
||||
velocityX: 0,
|
||||
velocityZ: 0,
|
||||
yaw: spawn.yaw,
|
||||
pitch: 0,
|
||||
altitude,
|
||||
health: 100,
|
||||
armor: bot ? 20 : 0,
|
||||
weapon,
|
||||
magazine: config.magazine,
|
||||
reserve: config.reserve,
|
||||
cooldownTicks: 0,
|
||||
reloadTicks: 0,
|
||||
kills: 0,
|
||||
inputForward: 0,
|
||||
inputStrafe: 0,
|
||||
firing: false,
|
||||
sprinting: false,
|
||||
botState: bot ? { phase: id % 360, targetId: null } : null,
|
||||
};
|
||||
}
|
||||
|
||||
function applyCommand(player: RoyaleAuthorityPlayer, input: RoyaleInput): void {
|
||||
player.inputForward = input.forward;
|
||||
player.inputStrafe = input.strafe;
|
||||
player.yaw = normalizeAngle(input.yaw);
|
||||
player.pitch = clamp(input.pitch, -1.25, 1.25);
|
||||
player.firing = input.fire;
|
||||
player.sprinting = input.sprint;
|
||||
}
|
||||
|
||||
function updateBot(state: RoyaleAuthorityState, bot: RoyaleAuthorityPlayer, tick: number): void {
|
||||
const target = nearestTarget(state, bot, 280);
|
||||
bot.botState!.targetId = target?.id ?? null;
|
||||
let targetX = state.stormX;
|
||||
let targetZ = state.stormZ;
|
||||
if (target) {
|
||||
targetX = target.x;
|
||||
targetZ = target.z;
|
||||
} else {
|
||||
const angle = (bot.botState!.phase + tick * 0.18) * (Math.PI / 180);
|
||||
targetX += Math.cos(angle) * Math.min(190, state.stormRadius * 0.45);
|
||||
targetZ += Math.sin(angle) * Math.min(190, state.stormRadius * 0.45);
|
||||
}
|
||||
const distance = Math.hypot(targetX - bot.x, targetZ - bot.z);
|
||||
bot.yaw = Math.atan2(targetX - bot.x, targetZ - bot.z) + Math.sin(tick * 0.17 + bot.id) * 0.035;
|
||||
bot.pitch = target
|
||||
? clamp(Math.atan2(target.altitude - bot.altitude, Math.max(0.01, distance)), -1.25, 1.25)
|
||||
: 0;
|
||||
bot.inputForward = distance > 45 ? 1 : 0.24;
|
||||
bot.inputStrafe = target ? Math.sin(tick * 0.08 + bot.id) * 0.7 : 0;
|
||||
bot.sprinting = distance > 90;
|
||||
bot.firing = Boolean(
|
||||
target &&
|
||||
distance <= ROYALE_WEAPONS[bot.weapon].range &&
|
||||
hasRoyaleLineOfSight(bot.x, bot.z, target.x, target.z),
|
||||
);
|
||||
if (bot.magazine === 0) beginReload(bot);
|
||||
}
|
||||
|
||||
function simulatePlayer(player: RoyaleAuthorityPlayer, deltaSeconds: number): boolean {
|
||||
const wasAirborne = player.altitude > 0;
|
||||
const speed =
|
||||
player.altitude > 0 ? parachuteSpeed : player.sprinting ? sprintSpeed : walkSpeed;
|
||||
const sin = Math.sin(player.yaw);
|
||||
const cos = Math.cos(player.yaw);
|
||||
let directionX = sin * player.inputForward - cos * player.inputStrafe;
|
||||
let directionZ = cos * player.inputForward + sin * player.inputStrafe;
|
||||
const magnitude = Math.hypot(directionX, directionZ);
|
||||
if (magnitude > 1) {
|
||||
directionX /= magnitude;
|
||||
directionZ /= magnitude;
|
||||
}
|
||||
player.velocityX = approach(player.velocityX, directionX * speed, movementAcceleration * deltaSeconds);
|
||||
player.velocityZ = approach(player.velocityZ, directionZ * speed, movementAcceleration * deltaSeconds);
|
||||
if (magnitude < 0.01) {
|
||||
player.velocityX *= Math.max(0, 1 - movementFriction * deltaSeconds);
|
||||
player.velocityZ *= Math.max(0, 1 - movementFriction * deltaSeconds);
|
||||
}
|
||||
const nextX = player.x + player.velocityX * deltaSeconds;
|
||||
const nextZ = player.z + player.velocityZ * deltaSeconds;
|
||||
if (player.altitude > 0 || !collidesWithRoyaleMap(nextX, player.z)) player.x = nextX;
|
||||
else player.velocityX = 0;
|
||||
if (player.altitude > 0 || !collidesWithRoyaleMap(player.x, nextZ)) player.z = nextZ;
|
||||
else player.velocityZ = 0;
|
||||
if (player.altitude > 0) {
|
||||
player.altitude = Math.max(0, player.altitude - parachuteDescentPerSecond * deltaSeconds);
|
||||
}
|
||||
return wasAirborne && player.altitude === 0;
|
||||
}
|
||||
|
||||
function simulateVisiblePlayer(player: RoyalePlayerView & { inputForward?: number; inputStrafe?: number }, deltaSeconds: number): void {
|
||||
const inputForward = player.inputForward ?? 0;
|
||||
const inputStrafe = player.inputStrafe ?? 0;
|
||||
const speed = player.altitude > 0 ? parachuteSpeed : player.sprinting ? sprintSpeed : walkSpeed;
|
||||
const sin = Math.sin(player.yaw);
|
||||
const cos = Math.cos(player.yaw);
|
||||
let directionX = sin * inputForward - cos * inputStrafe;
|
||||
let directionZ = cos * inputForward + sin * inputStrafe;
|
||||
const magnitude = Math.hypot(directionX, directionZ);
|
||||
if (magnitude > 1) {
|
||||
directionX /= magnitude;
|
||||
directionZ /= magnitude;
|
||||
}
|
||||
player.velocityX = approach(player.velocityX, directionX * speed, movementAcceleration * deltaSeconds);
|
||||
player.velocityZ = approach(player.velocityZ, directionZ * speed, movementAcceleration * deltaSeconds);
|
||||
const nextX = player.x + player.velocityX * deltaSeconds;
|
||||
const nextZ = player.z + player.velocityZ * deltaSeconds;
|
||||
if (player.altitude > 0 || !collidesWithRoyaleMap(nextX, player.z)) player.x = nextX;
|
||||
if (player.altitude > 0 || !collidesWithRoyaleMap(player.x, nextZ)) player.z = nextZ;
|
||||
if (player.altitude > 0) player.altitude = Math.max(0, player.altitude - parachuteDescentPerSecond * deltaSeconds);
|
||||
}
|
||||
|
||||
function tryFire(
|
||||
state: RoyaleAuthorityState,
|
||||
shooter: RoyaleAuthorityPlayer,
|
||||
emit: (event: RoyaleAuthorityEvent) => void,
|
||||
): void {
|
||||
if (shooter.cooldownTicks > 0 || shooter.reloadTicks > 0 || shooter.magazine <= 0) return;
|
||||
const weapon = ROYALE_WEAPONS[shooter.weapon];
|
||||
shooter.magazine -= 1;
|
||||
shooter.cooldownTicks = weapon.cooldownTicks;
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "shot",
|
||||
sourceId: shooter.id,
|
||||
x: shooter.x,
|
||||
y: shooter.altitude + playerEyeHeight,
|
||||
z: shooter.z,
|
||||
yaw: shooter.yaw,
|
||||
pitch: shooter.pitch,
|
||||
});
|
||||
|
||||
const horizontal = Math.cos(shooter.pitch);
|
||||
const directionX = Math.sin(shooter.yaw) * horizontal;
|
||||
const directionY = Math.sin(shooter.pitch);
|
||||
const directionZ = Math.cos(shooter.yaw) * horizontal;
|
||||
const originY = shooter.altitude + playerEyeHeight;
|
||||
let nearest: RoyaleAuthorityPlayer | null = null;
|
||||
let nearestDistance = weapon.range;
|
||||
for (const target of state.players) {
|
||||
if (!target.alive || target.id === shooter.id) continue;
|
||||
const offsetX = target.x - shooter.x;
|
||||
const offsetY = target.altitude + playerEyeHeight - originY;
|
||||
const offsetZ = target.z - shooter.z;
|
||||
const forward =
|
||||
offsetX * directionX + offsetY * directionY + offsetZ * directionZ;
|
||||
if (forward <= 0 || forward >= nearestDistance) continue;
|
||||
const offsetSquared = offsetX * offsetX + offsetY * offsetY + offsetZ * offsetZ;
|
||||
const missDistance = Math.sqrt(Math.max(0, offsetSquared - forward * forward));
|
||||
if (
|
||||
missDistance > playerHitRadius ||
|
||||
!hasRoyaleLineOfSight(shooter.x, shooter.z, target.x, target.z)
|
||||
) continue;
|
||||
nearest = target;
|
||||
nearestDistance = forward;
|
||||
}
|
||||
if (nearest) damagePlayer(state, shooter.id, nearest, weapon.damage, emit);
|
||||
if (shooter.magazine === 0) beginReload(shooter);
|
||||
}
|
||||
|
||||
function damagePlayer(
|
||||
state: RoyaleAuthorityState,
|
||||
sourceId: number,
|
||||
target: RoyaleAuthorityPlayer,
|
||||
amount: number,
|
||||
emit: (event: RoyaleAuthorityEvent) => void,
|
||||
): void {
|
||||
const absorbed = Math.min(target.armor, Math.ceil(amount * 0.55));
|
||||
target.armor -= absorbed;
|
||||
const healthDamage = Math.min(target.health, amount - absorbed);
|
||||
target.health -= healthDamage;
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "damage",
|
||||
sourceId,
|
||||
targetId: target.id,
|
||||
amount: healthDamage,
|
||||
health: target.health,
|
||||
});
|
||||
if (target.health > 0) return;
|
||||
target.alive = false;
|
||||
target.firing = false;
|
||||
target.velocityX = 0;
|
||||
target.velocityZ = 0;
|
||||
const killer = findPlayer(state, sourceId);
|
||||
if (killer) killer.kills += 1;
|
||||
emit({ id: state.nextEventId++, type: "elimination", killerId: sourceId, victimId: target.id });
|
||||
}
|
||||
|
||||
function applyStormDamage(
|
||||
state: RoyaleAuthorityState,
|
||||
player: RoyaleAuthorityPlayer,
|
||||
tick: number,
|
||||
emit: (event: RoyaleAuthorityEvent) => void,
|
||||
): void {
|
||||
if (tick % 15 !== 0) return;
|
||||
if (Math.hypot(player.x - state.stormX, player.z - state.stormZ) <= state.stormRadius) return;
|
||||
damagePlayer(state, 0, player, 3, emit);
|
||||
}
|
||||
|
||||
function collectLoot(
|
||||
state: RoyaleAuthorityState,
|
||||
player: RoyaleAuthorityPlayer,
|
||||
emit: (event: RoyaleAuthorityEvent) => void,
|
||||
): void {
|
||||
const item = state.loot.find(
|
||||
(candidate) => candidate.active && Math.hypot(candidate.x - player.x, candidate.z - player.z) < 2.4,
|
||||
);
|
||||
if (!item) return;
|
||||
item.active = false;
|
||||
if (item.kind === "weapon") {
|
||||
player.weapon = item.weapon;
|
||||
player.magazine = ROYALE_WEAPONS[item.weapon].magazine;
|
||||
player.reserve = Math.max(player.reserve, ROYALE_WEAPONS[item.weapon].reserve / 2);
|
||||
} else if (item.kind === "ammo") {
|
||||
player.reserve = Math.min(240, player.reserve + item.amount);
|
||||
} else if (item.kind === "armor") {
|
||||
player.armor = Math.min(100, player.armor + item.amount);
|
||||
} else {
|
||||
player.health = Math.min(100, player.health + item.amount);
|
||||
}
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "pickup",
|
||||
playerId: player.id,
|
||||
kind: item.kind,
|
||||
weapon: item.weapon,
|
||||
amount: item.amount,
|
||||
});
|
||||
}
|
||||
|
||||
function perceiveEvent(
|
||||
authority: RoyaleAuthorityState,
|
||||
event: RoyaleAuthorityEvent,
|
||||
playerId: number,
|
||||
): RoyalePerception | null {
|
||||
const viewer = findPlayer(authority, playerId);
|
||||
if (!viewer) return null;
|
||||
switch (event.type) {
|
||||
case "shot": {
|
||||
const source = findPlayer(authority, event.sourceId);
|
||||
if (source && (source.id === viewer.id || canSeePlayer(viewer, source))) {
|
||||
return { ...event };
|
||||
}
|
||||
const dx = event.x - viewer.x;
|
||||
const dz = event.z - viewer.z;
|
||||
const distance = Math.hypot(dx, dz);
|
||||
if (distance > 560) return null;
|
||||
return {
|
||||
id: event.id,
|
||||
type: "gunfire",
|
||||
bearing: normalizeAngle(Math.atan2(dx, dz) - viewer.yaw),
|
||||
intensity: clamp(1 - distance / 560, 0.08, 1),
|
||||
};
|
||||
}
|
||||
case "damage":
|
||||
if (event.targetId === playerId) {
|
||||
return { id: event.id, type: "damage", amount: event.amount, health: event.health };
|
||||
}
|
||||
if (event.sourceId === playerId) {
|
||||
return { id: event.id, type: "hit", amount: event.amount, eliminated: event.health === 0 };
|
||||
}
|
||||
return null;
|
||||
case "elimination":
|
||||
return { ...event };
|
||||
case "pickup":
|
||||
return event.playerId === playerId
|
||||
? { id: event.id, type: "pickup", kind: event.kind, weapon: event.weapon, amount: event.amount }
|
||||
: null;
|
||||
case "landed":
|
||||
return event.playerId === playerId ? { id: event.id, type: "landed" } : null;
|
||||
case "winner":
|
||||
return { ...event };
|
||||
}
|
||||
}
|
||||
|
||||
function canSeePlayer(viewer: RoyaleAuthorityPlayer, target: RoyaleAuthorityPlayer): boolean {
|
||||
if (!target.alive || target.id === viewer.id) return false;
|
||||
const distance = Math.hypot(target.x - viewer.x, target.z - viewer.z);
|
||||
return distance <= 225 && hasRoyaleLineOfSight(viewer.x, viewer.z, target.x, target.z);
|
||||
}
|
||||
|
||||
function clonePlayerView(player: RoyaleAuthorityPlayer, owner: boolean): RoyalePlayerView {
|
||||
return {
|
||||
id: player.id,
|
||||
bot: player.bot,
|
||||
alive: player.alive,
|
||||
x: player.x,
|
||||
z: player.z,
|
||||
velocityX: player.velocityX,
|
||||
velocityZ: player.velocityZ,
|
||||
yaw: player.yaw,
|
||||
pitch: player.pitch,
|
||||
altitude: player.altitude,
|
||||
health: owner ? player.health : null,
|
||||
armor: owner ? player.armor : null,
|
||||
weapon: player.weapon,
|
||||
magazine: owner ? player.magazine : null,
|
||||
reserve: owner ? player.reserve : null,
|
||||
cooldownTicks: player.cooldownTicks,
|
||||
reloadTicks: player.reloadTicks,
|
||||
kills: player.kills,
|
||||
firing: player.firing,
|
||||
sprinting: player.sprinting,
|
||||
inputForward: owner ? player.inputForward : 0,
|
||||
inputStrafe: owner ? player.inputStrafe : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createLoot(seed: number): RoyaleAuthorityLoot[] {
|
||||
const loot: RoyaleAuthorityLoot[] = [];
|
||||
for (let id = 1; id <= lootCount; id += 1) {
|
||||
let position = findSpawn(seed ^ 0xa51c, id * 31);
|
||||
const roll = royaleRandom(seed ^ 0xb771, id);
|
||||
const kind: RoyaleLootKind = roll > 0.82 ? "weapon" : roll > 0.55 ? "ammo" : roll > 0.28 ? "armor" : "medkit";
|
||||
const weapon: RoyaleWeapon = royaleRandom(seed ^ 0x991a, id) > 0.78 ? "marksman" : "rifle";
|
||||
if (collidesWithRoyaleMap(position.x, position.z, 1.2)) position = findSpawn(seed ^ 0x4411, id * 67);
|
||||
loot.push({
|
||||
id,
|
||||
kind,
|
||||
weapon,
|
||||
amount: kind === "ammo" ? 30 : kind === "armor" ? 35 : kind === "medkit" ? 30 : 1,
|
||||
x: position.x,
|
||||
z: position.z,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
return loot;
|
||||
}
|
||||
|
||||
function findSpawn(seed: number, index: number): { x: number; z: number; yaw: number } {
|
||||
for (let attempt = 0; attempt < 24; attempt += 1) {
|
||||
const radius = 120 + Math.sqrt(royaleRandom(seed, index + attempt * 3)) * 690;
|
||||
const angle = royaleRandom(seed ^ 0x7f4a, index + attempt * 3 + 1) * Math.PI * 2;
|
||||
const x = Math.cos(angle) * radius;
|
||||
const z = Math.sin(angle) * radius;
|
||||
if (!collidesWithRoyaleMap(x, z, 1.2)) return { x, z, yaw: angle + Math.PI };
|
||||
}
|
||||
return { x: 0, z: 0, yaw: 0 };
|
||||
}
|
||||
|
||||
function nearestTarget(
|
||||
state: RoyaleAuthorityState,
|
||||
source: RoyaleAuthorityPlayer,
|
||||
maximumDistance: number,
|
||||
): RoyaleAuthorityPlayer | null {
|
||||
let nearest: RoyaleAuthorityPlayer | null = null;
|
||||
let distance = maximumDistance;
|
||||
for (const candidate of state.players) {
|
||||
if (!candidate.alive || candidate.id === source.id || candidate.altitude > 0) continue;
|
||||
const candidateDistance = Math.hypot(candidate.x - source.x, candidate.z - source.z);
|
||||
if (candidateDistance < distance) {
|
||||
nearest = candidate;
|
||||
distance = candidateDistance;
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
function beginReload(player: RoyaleAuthorityPlayer): void {
|
||||
const weapon = ROYALE_WEAPONS[player.weapon];
|
||||
if (player.reloadTicks > 0 || player.magazine >= weapon.magazine || player.reserve <= 0) return;
|
||||
player.reloadTicks = weapon.reloadTicks;
|
||||
player.firing = false;
|
||||
}
|
||||
|
||||
function updateTimers(player: RoyaleAuthorityPlayer): void {
|
||||
if (player.cooldownTicks > 0) player.cooldownTicks -= 1;
|
||||
if (player.reloadTicks > 0) {
|
||||
player.reloadTicks -= 1;
|
||||
if (player.reloadTicks === 0) {
|
||||
const capacity = ROYALE_WEAPONS[player.weapon].magazine;
|
||||
const loaded = Math.min(capacity - player.magazine, player.reserve);
|
||||
player.magazine += loaded;
|
||||
player.reserve -= loaded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetMatch(state: RoyaleAuthorityState): void {
|
||||
state.elapsedTicks = 0;
|
||||
state.round += 1;
|
||||
state.winnerId = null;
|
||||
state.stormRadius = initialStormRadius;
|
||||
state.stormX = (royaleRandom(state.secretSeed, 901 + state.round) - 0.5) * 180;
|
||||
state.stormZ = (royaleRandom(state.secretSeed, 902 + state.round) - 0.5) * 180;
|
||||
state.loot = createLoot(state.secretSeed ^ state.round * 0x9e37);
|
||||
for (const player of state.players) {
|
||||
const fresh = createPlayer(state, player.id, player.bot, player.bot ? 0 : 110);
|
||||
Object.assign(player, fresh, { kills: player.kills });
|
||||
}
|
||||
}
|
||||
|
||||
function cloneAuthorityState(state: RoyaleAuthorityState): RoyaleAuthorityState {
|
||||
return {
|
||||
...state,
|
||||
players: state.players.map((player) => ({
|
||||
...player,
|
||||
botState: player.botState ? { ...player.botState } : null,
|
||||
})),
|
||||
loot: state.loot.map((item) => ({ ...item })),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneClientState(state: RoyaleClientState): RoyaleClientState {
|
||||
return {
|
||||
...state,
|
||||
players: state.players.map((player) => ({ ...player })),
|
||||
loot: state.loot.map((item) => ({ ...item })),
|
||||
stream: { ...state.stream },
|
||||
events: state.events.map((entry) => ({ receivedTick: entry.receivedTick, event: { ...entry.event } })),
|
||||
};
|
||||
}
|
||||
|
||||
function findPlayer(state: RoyaleAuthorityState, playerId: number): RoyaleAuthorityPlayer | undefined {
|
||||
return state.players.find((player) => player.id === playerId);
|
||||
}
|
||||
|
||||
function randomSeed(): number {
|
||||
return Math.floor(Math.random() * 0x1_0000_0000) >>> 0;
|
||||
}
|
||||
|
||||
function approach(value: number, target: number, amount: number): number {
|
||||
return value < target ? Math.min(target, value + amount) : Math.max(target, value - amount);
|
||||
}
|
||||
|
||||
function normalizeAngle(value: number): number {
|
||||
return Math.atan2(Math.sin(value), Math.cos(value));
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
143
packages/shared/src/royale-map.ts
Normal file
143
packages/shared/src/royale-map.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
export const ROYALE_MAP_SEED = 0x51_9c_ae_27;
|
||||
export const ROYALE_WORLD_SIZE = 2_000;
|
||||
export const ROYALE_WORLD_RADIUS = 960;
|
||||
export const ROYALE_CHUNK_SIZE = 100;
|
||||
|
||||
export type RoyaleBiome = "meadow" | "forest" | "highland" | "coast";
|
||||
export type RoyaleObstacleKind = "building" | "rock" | "tree";
|
||||
|
||||
export interface RoyaleObstacle {
|
||||
id: string;
|
||||
kind: RoyaleObstacleKind;
|
||||
x: number;
|
||||
z: number;
|
||||
width: number;
|
||||
depth: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface RoyaleMapChunk {
|
||||
x: number;
|
||||
z: number;
|
||||
biome: RoyaleBiome;
|
||||
tint: number;
|
||||
elevation: number;
|
||||
roadX: boolean;
|
||||
roadZ: boolean;
|
||||
obstacles: RoyaleObstacle[];
|
||||
}
|
||||
|
||||
const chunkCache = new Map<string, RoyaleMapChunk>();
|
||||
|
||||
/** Public, deterministic static world generation shared by server and clients. */
|
||||
export function generateRoyaleChunk(chunkX: number, chunkZ: number): RoyaleMapChunk {
|
||||
const key = `${chunkX}:${chunkZ}`;
|
||||
const cached = chunkCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const centerX = (chunkX + 0.5) * ROYALE_CHUNK_SIZE;
|
||||
const centerZ = (chunkZ + 0.5) * ROYALE_CHUNK_SIZE;
|
||||
const normalizedRadius = Math.hypot(centerX, centerZ) / ROYALE_WORLD_RADIUS;
|
||||
const biomeNoise = random01(PROCEDURAL_SEED, chunkX, chunkZ, 1);
|
||||
const biome: RoyaleBiome =
|
||||
normalizedRadius > 0.84
|
||||
? "coast"
|
||||
: biomeNoise > 0.68
|
||||
? "forest"
|
||||
: biomeNoise < 0.2
|
||||
? "highland"
|
||||
: "meadow";
|
||||
const roadX = Math.abs(chunkZ) % 5 === 0;
|
||||
const roadZ = Math.abs(chunkX + 2) % 6 === 0;
|
||||
const obstacleCount = biome === "coast" ? 2 : 4 + Math.floor(random01(PROCEDURAL_SEED, chunkX, chunkZ, 2) * 5);
|
||||
const obstacles: RoyaleObstacle[] = [];
|
||||
|
||||
for (let index = 0; index < obstacleCount; index += 1) {
|
||||
const localX = (random01(PROCEDURAL_SEED, chunkX, chunkZ, 10 + index * 5) - 0.5) * 82;
|
||||
const localZ = (random01(PROCEDURAL_SEED, chunkX, chunkZ, 11 + index * 5) - 0.5) * 82;
|
||||
if ((roadZ && Math.abs(localX) < 11) || (roadX && Math.abs(localZ) < 11)) continue;
|
||||
const kindRoll = random01(PROCEDURAL_SEED, chunkX, chunkZ, 12 + index * 5);
|
||||
const kind: RoyaleObstacleKind =
|
||||
kindRoll > 0.72 ? "building" : kindRoll > 0.38 ? "tree" : "rock";
|
||||
const scale = random01(PROCEDURAL_SEED, chunkX, chunkZ, 13 + index * 5);
|
||||
const width = kind === "building" ? 10 + scale * 15 : kind === "tree" ? 3.2 : 4 + scale * 7;
|
||||
const depth = kind === "building" ? 9 + random01(PROCEDURAL_SEED, chunkX, chunkZ, 14 + index * 5) * 14 : width;
|
||||
obstacles.push({
|
||||
id: `${chunkX}:${chunkZ}:${index}`,
|
||||
kind,
|
||||
x: chunkX * ROYALE_CHUNK_SIZE + ROYALE_CHUNK_SIZE / 2 + localX,
|
||||
z: chunkZ * ROYALE_CHUNK_SIZE + ROYALE_CHUNK_SIZE / 2 + localZ,
|
||||
width,
|
||||
depth,
|
||||
height: kind === "building" ? 7 + scale * 13 : kind === "tree" ? 8 + scale * 7 : 2.5 + scale * 5,
|
||||
});
|
||||
}
|
||||
|
||||
const chunk: RoyaleMapChunk = {
|
||||
x: chunkX,
|
||||
z: chunkZ,
|
||||
biome,
|
||||
tint:
|
||||
biome === "coast" ? 0xb9a56e : biome === "forest" ? 0x315d3f : biome === "highland" ? 0x697064 : 0x52764b,
|
||||
elevation: Math.round(random01(PROCEDURAL_SEED, chunkX, chunkZ, 3) * 3) * 0.16,
|
||||
roadX,
|
||||
roadZ,
|
||||
obstacles,
|
||||
};
|
||||
chunkCache.set(key, chunk);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
export function collidesWithRoyaleMap(x: number, z: number, radius = 0.72): boolean {
|
||||
if (Math.hypot(x, z) + radius > ROYALE_WORLD_RADIUS) return true;
|
||||
const chunkX = Math.floor(x / ROYALE_CHUNK_SIZE);
|
||||
const chunkZ = Math.floor(z / ROYALE_CHUNK_SIZE);
|
||||
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
|
||||
for (let offsetZ = -1; offsetZ <= 1; offsetZ += 1) {
|
||||
for (const obstacle of generateRoyaleChunk(chunkX + offsetX, chunkZ + offsetZ).obstacles) {
|
||||
const nearestX = clamp(x, obstacle.x - obstacle.width / 2, obstacle.x + obstacle.width / 2);
|
||||
const nearestZ = clamp(z, obstacle.z - obstacle.depth / 2, obstacle.z + obstacle.depth / 2);
|
||||
const dx = x - nearestX;
|
||||
const dz = z - nearestZ;
|
||||
if (dx * dx + dz * dz < radius * radius) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasRoyaleLineOfSight(fromX: number, fromZ: number, toX: number, toZ: number): boolean {
|
||||
const distance = Math.hypot(toX - fromX, toZ - fromZ);
|
||||
const steps = Math.max(2, Math.ceil(distance / 4));
|
||||
for (let step = 1; step < steps; step += 1) {
|
||||
const ratio = step / steps;
|
||||
if (collidesWithRoyaleMap(fromX + (toX - fromX) * ratio, fromZ + (toZ - fromZ) * ratio, 0.12)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function royaleChunkCoordinate(value: number): number {
|
||||
return Math.floor(value / ROYALE_CHUNK_SIZE);
|
||||
}
|
||||
|
||||
export function royaleRandom(seed: number, index: number): number {
|
||||
return random01(seed, index, index * 17 + 3, index * 97 + 11);
|
||||
}
|
||||
|
||||
const PROCEDURAL_SEED = ROYALE_MAP_SEED;
|
||||
|
||||
function random01(seed: number, a: number, b: number, c: number): number {
|
||||
let value = seed ^ Math.imul(a, 0x9e3779b1) ^ Math.imul(b, 0x85ebca77) ^ Math.imul(c, 0xc2b2ae3d);
|
||||
value ^= value >>> 16;
|
||||
value = Math.imul(value, 0x7feb352d);
|
||||
value ^= value >>> 15;
|
||||
value = Math.imul(value, 0x846ca68b);
|
||||
value ^= value >>> 16;
|
||||
return (value >>> 0) / 0x1_0000_0000;
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
160
packages/shared/src/royale-types.ts
Normal file
160
packages/shared/src/royale-types.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
export type RoyaleWeapon = "pistol" | "rifle" | "marksman";
|
||||
export type RoyaleLootKind = "weapon" | "ammo" | "armor" | "medkit";
|
||||
|
||||
export interface RoyaleInput {
|
||||
forward: number;
|
||||
strafe: number;
|
||||
yaw: number;
|
||||
pitch: number;
|
||||
fire: boolean;
|
||||
sprint: boolean;
|
||||
reload: boolean;
|
||||
}
|
||||
|
||||
export interface RoyaleBotState {
|
||||
phase: number;
|
||||
targetId: number | null;
|
||||
}
|
||||
|
||||
export interface RoyaleAuthorityPlayer {
|
||||
id: number;
|
||||
bot: boolean;
|
||||
alive: boolean;
|
||||
x: number;
|
||||
z: number;
|
||||
velocityX: number;
|
||||
velocityZ: number;
|
||||
yaw: number;
|
||||
pitch: number;
|
||||
altitude: number;
|
||||
health: number;
|
||||
armor: number;
|
||||
weapon: RoyaleWeapon;
|
||||
magazine: number;
|
||||
reserve: number;
|
||||
cooldownTicks: number;
|
||||
reloadTicks: number;
|
||||
kills: number;
|
||||
inputForward: number;
|
||||
inputStrafe: number;
|
||||
firing: boolean;
|
||||
sprinting: boolean;
|
||||
botState: RoyaleBotState | null;
|
||||
}
|
||||
|
||||
export interface RoyaleAuthorityLoot {
|
||||
id: number;
|
||||
kind: RoyaleLootKind;
|
||||
weapon: RoyaleWeapon;
|
||||
amount: number;
|
||||
x: number;
|
||||
z: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface RoyaleAuthorityState {
|
||||
players: RoyaleAuthorityPlayer[];
|
||||
loot: RoyaleAuthorityLoot[];
|
||||
elapsedTicks: number;
|
||||
round: number;
|
||||
stormX: number;
|
||||
stormZ: number;
|
||||
stormRadius: number;
|
||||
resetTicks: number;
|
||||
winnerId: number | null;
|
||||
nextEventId: number;
|
||||
secretSeed: number;
|
||||
}
|
||||
|
||||
export interface RoyalePlayerView {
|
||||
id: number;
|
||||
bot: boolean;
|
||||
alive: boolean;
|
||||
x: number;
|
||||
z: number;
|
||||
velocityX: number;
|
||||
velocityZ: number;
|
||||
yaw: number;
|
||||
pitch: number;
|
||||
altitude: number;
|
||||
/** Exact combat state exists only on the owning player's projection. */
|
||||
health: number | null;
|
||||
armor: number | null;
|
||||
weapon: RoyaleWeapon;
|
||||
magazine: number | null;
|
||||
reserve: number | null;
|
||||
cooldownTicks: number;
|
||||
reloadTicks: number;
|
||||
kills: number;
|
||||
firing: boolean;
|
||||
sprinting: boolean;
|
||||
inputForward: number;
|
||||
inputStrafe: number;
|
||||
}
|
||||
|
||||
export interface RoyaleLootView {
|
||||
id: number;
|
||||
kind: RoyaleLootKind;
|
||||
weapon: RoyaleWeapon;
|
||||
amount: number;
|
||||
x: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface RoyaleStreamStats {
|
||||
candidateCount: number;
|
||||
droppedCount: number;
|
||||
usedBytes: number;
|
||||
budgetBytes: number;
|
||||
chunkX: number;
|
||||
chunkZ: number;
|
||||
}
|
||||
|
||||
export interface RoyaleClientState {
|
||||
mapSeed: number;
|
||||
worldSize: number;
|
||||
chunkSize: number;
|
||||
players: RoyalePlayerView[];
|
||||
loot: RoyaleLootView[];
|
||||
elapsedTicks: number;
|
||||
round: number;
|
||||
aliveCount: number;
|
||||
stormX: number;
|
||||
stormZ: number;
|
||||
stormRadius: number;
|
||||
resetTicks: number;
|
||||
winnerId: number | null;
|
||||
stream: RoyaleStreamStats;
|
||||
events: RoyalePresentationEvent[];
|
||||
}
|
||||
|
||||
export type RoyaleAuthorityEvent =
|
||||
| { id: number; type: "shot"; sourceId: number; x: number; y: number; z: number; yaw: number; pitch: number }
|
||||
| { id: number; type: "damage"; sourceId: number; targetId: number; amount: number; health: number }
|
||||
| { id: number; type: "elimination"; killerId: number; victimId: number }
|
||||
| { id: number; type: "pickup"; playerId: number; kind: RoyaleLootKind; weapon: RoyaleWeapon; amount: number }
|
||||
| { id: number; type: "landed"; playerId: number }
|
||||
| { id: number; type: "winner"; playerId: number; round: number };
|
||||
|
||||
export type RoyalePerception =
|
||||
| { id: number; type: "shot"; sourceId: number; x: number; y: number; z: number; yaw: number; pitch: number }
|
||||
| { id: number; type: "gunfire"; bearing: number; intensity: number }
|
||||
| { id: number; type: "damage"; amount: number; health: number }
|
||||
| { id: number; type: "hit"; amount: number; eliminated: boolean }
|
||||
| { id: number; type: "elimination"; killerId: number; victimId: number }
|
||||
| { id: number; type: "pickup"; kind: RoyaleLootKind; weapon: RoyaleWeapon; amount: number }
|
||||
| { id: number; type: "landed" }
|
||||
| { id: number; type: "winner"; playerId: number; round: number };
|
||||
|
||||
export interface RoyalePresentationEvent {
|
||||
receivedTick: number;
|
||||
event: RoyalePerception;
|
||||
}
|
||||
|
||||
export interface RoyaleGameContract {
|
||||
authority: RoyaleAuthorityState;
|
||||
client: RoyaleClientState;
|
||||
input: RoyaleInput;
|
||||
authorityEvent: RoyaleAuthorityEvent;
|
||||
perceptionEvent: RoyalePerception;
|
||||
}
|
||||
506
packages/shared/src/shooter-codecs.ts
Normal file
506
packages/shared/src/shooter-codecs.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
import type { BinaryCodec } from "@syncer/engine";
|
||||
import {
|
||||
isWeaponId,
|
||||
type DistanceBand,
|
||||
type ImpactKind,
|
||||
type LoadoutState,
|
||||
type PickupKind,
|
||||
type PlayerKind,
|
||||
type PlayerState,
|
||||
type PickupState,
|
||||
type ScoreEntry,
|
||||
type ShooterInput,
|
||||
type ShooterPerception,
|
||||
type ShooterWorldState,
|
||||
type WeaponId,
|
||||
} from "./shooter-types.js";
|
||||
|
||||
const INPUT_BYTES = 18;
|
||||
const WORLD_HEADER_BYTES = 17;
|
||||
const PLAYER_BYTES = 55;
|
||||
const SCORE_BYTES = 9;
|
||||
const PICKUP_BYTES = 15;
|
||||
|
||||
export const shooterInputCodec: BinaryCodec<ShooterInput> = {
|
||||
encode(input) {
|
||||
const payload = new Uint8Array(INPUT_BYTES);
|
||||
const view = new DataView(payload.buffer);
|
||||
view.setFloat32(0, input.strafe, true);
|
||||
view.setFloat32(4, input.forward, true);
|
||||
view.setFloat32(8, input.yaw, true);
|
||||
view.setFloat32(12, input.pitch, true);
|
||||
view.setUint8(
|
||||
16,
|
||||
(input.fire ? 1 : 0) | (input.sprint ? 2 : 0) | (input.reload ? 4 : 0),
|
||||
);
|
||||
view.setUint8(17, input.weapon);
|
||||
return payload;
|
||||
},
|
||||
decode(payload) {
|
||||
assertLength(payload, INPUT_BYTES, "Shooter input");
|
||||
const view = viewOf(payload);
|
||||
const weapon = view.getUint8(17);
|
||||
if (!isWeaponId(weapon)) {
|
||||
throw new RangeError("Unknown weapon in shooter input");
|
||||
}
|
||||
const flags = view.getUint8(16);
|
||||
return {
|
||||
strafe: view.getFloat32(0, true),
|
||||
forward: view.getFloat32(4, true),
|
||||
yaw: view.getFloat32(8, true),
|
||||
pitch: view.getFloat32(12, true),
|
||||
fire: Boolean(flags & 1),
|
||||
sprint: Boolean(flags & 2),
|
||||
reload: Boolean(flags & 4),
|
||||
weapon,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const shooterWorldCodec: BinaryCodec<ShooterWorldState> = {
|
||||
encode(state) {
|
||||
if (
|
||||
state.players.size > 65_535 ||
|
||||
state.scoreboard.length > 65_535 ||
|
||||
state.pickups.size > 65_535
|
||||
) {
|
||||
throw new RangeError("Shooter world exceeds the binary protocol limit");
|
||||
}
|
||||
|
||||
const length =
|
||||
WORLD_HEADER_BYTES +
|
||||
state.players.size * PLAYER_BYTES +
|
||||
state.scoreboard.length * SCORE_BYTES +
|
||||
state.pickups.size * PICKUP_BYTES;
|
||||
const payload = new Uint8Array(length);
|
||||
const view = new DataView(payload.buffer);
|
||||
let offset = 0;
|
||||
|
||||
view.setUint8(offset, 1);
|
||||
offset += 1;
|
||||
view.setUint32(offset, state.match.elapsedTicks, true);
|
||||
offset += 4;
|
||||
view.setUint16(offset, state.match.scoreLimit, true);
|
||||
offset += 2;
|
||||
view.setUint32(offset, state.match.leaderId, true);
|
||||
offset += 4;
|
||||
view.setUint16(offset, state.players.size, true);
|
||||
offset += 2;
|
||||
view.setUint16(offset, state.scoreboard.length, true);
|
||||
offset += 2;
|
||||
view.setUint16(offset, state.pickups.size, true);
|
||||
offset += 2;
|
||||
|
||||
const players = [...state.players.values()].sort((a, b) => a.id - b.id);
|
||||
for (const player of players) {
|
||||
offset = encodePlayer(view, offset, player);
|
||||
}
|
||||
for (const score of [...state.scoreboard].sort((a, b) => a.id - b.id)) {
|
||||
offset = encodeScore(view, offset, score);
|
||||
}
|
||||
for (const pickup of [...state.pickups.values()].sort((a, b) => a.id - b.id)) {
|
||||
view.setUint16(offset, pickup.id, true);
|
||||
view.setUint8(offset + 2, pickupKindToByte(pickup.kind));
|
||||
view.setUint8(offset + 3, pickup.weapon);
|
||||
view.setFloat32(offset + 4, pickup.x, true);
|
||||
view.setFloat32(offset + 8, pickup.z, true);
|
||||
view.setUint8(offset + 12, pickup.active ? 1 : 0);
|
||||
view.setUint16(offset + 13, clampUint16(pickup.respawnTicks), true);
|
||||
offset += PICKUP_BYTES;
|
||||
}
|
||||
|
||||
return payload;
|
||||
},
|
||||
decode(payload) {
|
||||
if (payload.byteLength < WORLD_HEADER_BYTES) {
|
||||
throw new RangeError("Shooter world is missing its header");
|
||||
}
|
||||
const view = viewOf(payload);
|
||||
let offset = 0;
|
||||
const version = view.getUint8(offset);
|
||||
offset += 1;
|
||||
if (version !== 1) {
|
||||
throw new RangeError(`Unsupported shooter world version ${version}`);
|
||||
}
|
||||
|
||||
const elapsedTicks = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
const scoreLimit = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
const leaderId = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
const playerCount = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
const scoreCount = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
const pickupCount = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
assertLength(
|
||||
payload,
|
||||
WORLD_HEADER_BYTES +
|
||||
playerCount * PLAYER_BYTES +
|
||||
scoreCount * SCORE_BYTES +
|
||||
pickupCount * PICKUP_BYTES,
|
||||
"Shooter world",
|
||||
);
|
||||
|
||||
const players = new Map<number, PlayerState>();
|
||||
for (let index = 0; index < playerCount; index += 1) {
|
||||
const decoded = decodePlayer(view, offset);
|
||||
players.set(decoded.player.id, decoded.player);
|
||||
offset = decoded.offset;
|
||||
}
|
||||
|
||||
const scoreboard: ScoreEntry[] = [];
|
||||
for (let index = 0; index < scoreCount; index += 1) {
|
||||
const id = view.getUint32(offset, true);
|
||||
const kind = byteToPlayerKind(view.getUint8(offset + 4));
|
||||
scoreboard.push({
|
||||
id,
|
||||
kind,
|
||||
kills: view.getUint16(offset + 5, true),
|
||||
deaths: view.getUint16(offset + 7, true),
|
||||
});
|
||||
offset += SCORE_BYTES;
|
||||
}
|
||||
|
||||
const pickups = new Map<number, PickupState>();
|
||||
for (let index = 0; index < pickupCount; index += 1) {
|
||||
const id = view.getUint16(offset, true);
|
||||
const weapon = view.getUint8(offset + 3);
|
||||
if (!isWeaponId(weapon)) {
|
||||
throw new RangeError("Unknown pickup weapon");
|
||||
}
|
||||
pickups.set(id, {
|
||||
id,
|
||||
kind: byteToPickupKind(view.getUint8(offset + 2)),
|
||||
weapon,
|
||||
x: view.getFloat32(offset + 4, true),
|
||||
z: view.getFloat32(offset + 8, true),
|
||||
active: Boolean(view.getUint8(offset + 12)),
|
||||
respawnTicks: view.getUint16(offset + 13, true),
|
||||
});
|
||||
offset += PICKUP_BYTES;
|
||||
}
|
||||
|
||||
return {
|
||||
players,
|
||||
pickups,
|
||||
scoreboard,
|
||||
match: { elapsedTicks, scoreLimit, leaderId },
|
||||
events: [],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const shooterEventCodec: BinaryCodec<ShooterPerception> = {
|
||||
encode(event) {
|
||||
const length = perceptionLength(event);
|
||||
const payload = new Uint8Array(length);
|
||||
const view = new DataView(payload.buffer);
|
||||
view.setUint32(0, event.id, true);
|
||||
view.setUint8(4, perceptionTypeToByte(event.type));
|
||||
|
||||
switch (event.type) {
|
||||
case "shot":
|
||||
view.setUint8(5, event.weapon);
|
||||
view.setUint32(6, event.sourceId, true);
|
||||
view.setFloat32(10, event.originX, true);
|
||||
view.setFloat32(14, event.originY, true);
|
||||
view.setFloat32(18, event.originZ, true);
|
||||
view.setFloat32(22, event.endX, true);
|
||||
view.setFloat32(26, event.endY, true);
|
||||
view.setFloat32(30, event.endZ, true);
|
||||
view.setUint8(34, impactToByte(event.impact));
|
||||
break;
|
||||
case "sound":
|
||||
view.setUint8(5, event.sound === "gunshot" ? 0 : 1);
|
||||
view.setUint8(6, event.weapon);
|
||||
view.setFloat32(7, event.bearingRadians, true);
|
||||
view.setFloat32(11, event.intensity, true);
|
||||
view.setUint8(15, distanceBandToByte(event.distanceBand));
|
||||
break;
|
||||
case "damage":
|
||||
view.setUint16(5, clampUint16(event.amount), true);
|
||||
view.setUint16(7, clampUint16(event.health), true);
|
||||
view.setFloat32(9, event.bearingRadians, true);
|
||||
view.setUint8(13, event.critical ? 1 : 0);
|
||||
break;
|
||||
case "hit":
|
||||
view.setUint16(5, clampUint16(event.amount), true);
|
||||
view.setUint8(7, event.eliminated ? 1 : 0);
|
||||
view.setUint8(8, event.critical ? 1 : 0);
|
||||
break;
|
||||
case "elimination":
|
||||
view.setUint32(5, event.killerId, true);
|
||||
view.setUint32(9, event.victimId, true);
|
||||
view.setUint8(13, event.weapon);
|
||||
view.setUint8(14, event.critical ? 1 : 0);
|
||||
break;
|
||||
case "pickup":
|
||||
view.setUint8(5, pickupKindToByte(event.pickup));
|
||||
view.setUint8(6, event.weapon);
|
||||
view.setUint16(7, clampUint16(event.amount), true);
|
||||
break;
|
||||
case "respawn":
|
||||
break;
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
decode(payload) {
|
||||
if (payload.byteLength < 5) {
|
||||
throw new RangeError("Shooter event is missing its header");
|
||||
}
|
||||
const view = viewOf(payload);
|
||||
const id = view.getUint32(0, true);
|
||||
const type = byteToPerceptionType(view.getUint8(4));
|
||||
|
||||
switch (type) {
|
||||
case "shot": {
|
||||
assertLength(payload, 35, "Shot event");
|
||||
const weapon = decodeWeapon(view.getUint8(5));
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
weapon,
|
||||
sourceId: view.getUint32(6, true),
|
||||
originX: view.getFloat32(10, true),
|
||||
originY: view.getFloat32(14, true),
|
||||
originZ: view.getFloat32(18, true),
|
||||
endX: view.getFloat32(22, true),
|
||||
endY: view.getFloat32(26, true),
|
||||
endZ: view.getFloat32(30, true),
|
||||
impact: byteToImpact(view.getUint8(34)),
|
||||
};
|
||||
}
|
||||
case "sound": {
|
||||
assertLength(payload, 16, "Sound event");
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
sound: view.getUint8(5) === 0 ? "gunshot" : "footstep",
|
||||
weapon: decodeWeapon(view.getUint8(6)),
|
||||
bearingRadians: view.getFloat32(7, true),
|
||||
intensity: view.getFloat32(11, true),
|
||||
distanceBand: byteToDistanceBand(view.getUint8(15)),
|
||||
};
|
||||
}
|
||||
case "damage":
|
||||
assertLength(payload, 14, "Damage event");
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
amount: view.getUint16(5, true),
|
||||
health: view.getUint16(7, true),
|
||||
bearingRadians: view.getFloat32(9, true),
|
||||
critical: Boolean(view.getUint8(13)),
|
||||
};
|
||||
case "hit":
|
||||
assertLength(payload, 9, "Hit event");
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
amount: view.getUint16(5, true),
|
||||
eliminated: Boolean(view.getUint8(7)),
|
||||
critical: Boolean(view.getUint8(8)),
|
||||
};
|
||||
case "elimination":
|
||||
assertLength(payload, 15, "Elimination event");
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
killerId: view.getUint32(5, true),
|
||||
victimId: view.getUint32(9, true),
|
||||
weapon: decodeWeapon(view.getUint8(13)),
|
||||
critical: Boolean(view.getUint8(14)),
|
||||
};
|
||||
case "pickup":
|
||||
assertLength(payload, 9, "Pickup event");
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
pickup: byteToPickupKind(view.getUint8(5)),
|
||||
weapon: decodeWeapon(view.getUint8(6)),
|
||||
amount: view.getUint16(7, true),
|
||||
};
|
||||
case "respawn":
|
||||
assertLength(payload, 5, "Respawn event");
|
||||
return { id, type };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function encodePlayer(view: DataView, offset: number, player: PlayerState): number {
|
||||
view.setUint32(offset, player.id, true);
|
||||
view.setUint8(
|
||||
offset + 4,
|
||||
(player.kind === "bot" ? 1 : 0) | (player.alive ? 2 : 0),
|
||||
);
|
||||
view.setFloat32(offset + 5, player.x, true);
|
||||
view.setFloat32(offset + 9, player.z, true);
|
||||
view.setFloat32(offset + 13, player.velocityX, true);
|
||||
view.setFloat32(offset + 17, player.velocityZ, true);
|
||||
view.setFloat32(offset + 21, player.yaw, true);
|
||||
view.setFloat32(offset + 25, player.pitch, true);
|
||||
view.setUint8(offset + 29, clampByte(player.health));
|
||||
view.setUint8(offset + 30, clampByte(player.armor));
|
||||
view.setUint8(offset + 31, player.weapon);
|
||||
let ammoOffset = offset + 32;
|
||||
for (const ammo of player.ammo) {
|
||||
view.setUint16(ammoOffset, clampUint16(ammo.magazine), true);
|
||||
view.setUint16(ammoOffset + 2, clampUint16(ammo.reserve), true);
|
||||
ammoOffset += 4;
|
||||
}
|
||||
view.setUint16(offset + 44, clampUint16(player.cooldownTicks), true);
|
||||
view.setUint16(offset + 46, clampUint16(player.reloadTicks), true);
|
||||
view.setUint16(offset + 48, clampUint16(player.respawnTicks), true);
|
||||
view.setUint16(offset + 50, clampUint16(player.spawnProtectionTicks), true);
|
||||
view.setInt8(offset + 52, Math.round(clamp(player.inputStrafe, -1, 1) * 127));
|
||||
view.setInt8(offset + 53, Math.round(clamp(player.inputForward, -1, 1) * 127));
|
||||
view.setUint8(offset + 54, (player.firing ? 1 : 0) | (player.sprinting ? 2 : 0));
|
||||
return offset + PLAYER_BYTES;
|
||||
}
|
||||
|
||||
function decodePlayer(view: DataView, offset: number): { player: PlayerState; offset: number } {
|
||||
const flags = view.getUint8(offset + 4);
|
||||
const weapon = decodeWeapon(view.getUint8(offset + 31));
|
||||
const ammo = [] as unknown as LoadoutState;
|
||||
let ammoOffset = offset + 32;
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
ammo[index] = {
|
||||
magazine: view.getUint16(ammoOffset, true),
|
||||
reserve: view.getUint16(ammoOffset + 2, true),
|
||||
};
|
||||
ammoOffset += 4;
|
||||
}
|
||||
const inputFlags = view.getUint8(offset + 54);
|
||||
return {
|
||||
player: {
|
||||
id: view.getUint32(offset, true),
|
||||
kind: flags & 1 ? "bot" : "human",
|
||||
alive: Boolean(flags & 2),
|
||||
x: view.getFloat32(offset + 5, true),
|
||||
z: view.getFloat32(offset + 9, true),
|
||||
velocityX: view.getFloat32(offset + 13, true),
|
||||
velocityZ: view.getFloat32(offset + 17, true),
|
||||
yaw: view.getFloat32(offset + 21, true),
|
||||
pitch: view.getFloat32(offset + 25, true),
|
||||
health: view.getUint8(offset + 29),
|
||||
armor: view.getUint8(offset + 30),
|
||||
weapon,
|
||||
ammo,
|
||||
cooldownTicks: view.getUint16(offset + 44, true),
|
||||
reloadTicks: view.getUint16(offset + 46, true),
|
||||
respawnTicks: view.getUint16(offset + 48, true),
|
||||
spawnProtectionTicks: view.getUint16(offset + 50, true),
|
||||
inputStrafe: view.getInt8(offset + 52) / 127,
|
||||
inputForward: view.getInt8(offset + 53) / 127,
|
||||
firing: Boolean(inputFlags & 1),
|
||||
sprinting: Boolean(inputFlags & 2),
|
||||
},
|
||||
offset: offset + PLAYER_BYTES,
|
||||
};
|
||||
}
|
||||
|
||||
function encodeScore(view: DataView, offset: number, score: ScoreEntry): number {
|
||||
view.setUint32(offset, score.id, true);
|
||||
view.setUint8(offset + 4, score.kind === "bot" ? 1 : 0);
|
||||
view.setUint16(offset + 5, clampUint16(score.kills), true);
|
||||
view.setUint16(offset + 7, clampUint16(score.deaths), true);
|
||||
return offset + SCORE_BYTES;
|
||||
}
|
||||
|
||||
function perceptionLength(event: ShooterPerception): number {
|
||||
switch (event.type) {
|
||||
case "shot": return 35;
|
||||
case "sound": return 16;
|
||||
case "damage": return 14;
|
||||
case "hit": return 9;
|
||||
case "elimination": return 15;
|
||||
case "pickup": return 9;
|
||||
case "respawn": return 5;
|
||||
}
|
||||
}
|
||||
|
||||
function perceptionTypeToByte(type: ShooterPerception["type"]): number {
|
||||
return type === "shot" ? 0 : type === "sound" ? 1 : type === "damage" ? 2 : type === "elimination" ? 3 : type === "pickup" ? 4 : type === "respawn" ? 5 : 6;
|
||||
}
|
||||
|
||||
function byteToPerceptionType(value: number): ShooterPerception["type"] {
|
||||
if (value === 0) return "shot";
|
||||
if (value === 1) return "sound";
|
||||
if (value === 2) return "damage";
|
||||
if (value === 3) return "elimination";
|
||||
if (value === 4) return "pickup";
|
||||
if (value === 5) return "respawn";
|
||||
if (value === 6) return "hit";
|
||||
throw new RangeError("Unknown shooter perception type");
|
||||
}
|
||||
|
||||
function pickupKindToByte(kind: PickupKind): number {
|
||||
return kind === "health" ? 0 : kind === "ammo" ? 1 : 2;
|
||||
}
|
||||
|
||||
function byteToPickupKind(value: number): PickupKind {
|
||||
if (value === 0) return "health";
|
||||
if (value === 1) return "ammo";
|
||||
if (value === 2) return "weapon";
|
||||
throw new RangeError("Unknown pickup kind");
|
||||
}
|
||||
|
||||
function distanceBandToByte(band: DistanceBand): number {
|
||||
return band === "near" ? 0 : band === "medium" ? 1 : 2;
|
||||
}
|
||||
|
||||
function byteToDistanceBand(value: number): DistanceBand {
|
||||
if (value === 0) return "near";
|
||||
if (value === 1) return "medium";
|
||||
if (value === 2) return "far";
|
||||
throw new RangeError("Unknown distance band");
|
||||
}
|
||||
|
||||
function impactToByte(impact: ImpactKind): number {
|
||||
return impact === "wall" ? 0 : impact === "player" ? 1 : 2;
|
||||
}
|
||||
|
||||
function byteToImpact(value: number): ImpactKind {
|
||||
if (value === 0) return "wall";
|
||||
if (value === 1) return "player";
|
||||
if (value === 2) return "miss";
|
||||
throw new RangeError("Unknown impact kind");
|
||||
}
|
||||
|
||||
function byteToPlayerKind(value: number): PlayerKind {
|
||||
if (value === 0) return "human";
|
||||
if (value === 1) return "bot";
|
||||
throw new RangeError("Unknown player kind");
|
||||
}
|
||||
|
||||
function decodeWeapon(value: number): WeaponId {
|
||||
if (!isWeaponId(value)) {
|
||||
throw new RangeError("Unknown weapon");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
|
||||
function clampByte(value: number): number {
|
||||
return Math.round(clamp(value, 0, 255));
|
||||
}
|
||||
|
||||
function clampUint16(value: number): number {
|
||||
return Math.round(clamp(value, 0, 65_535));
|
||||
}
|
||||
|
||||
function viewOf(payload: Uint8Array): DataView {
|
||||
return new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
|
||||
}
|
||||
|
||||
function assertLength(payload: Uint8Array, expected: number, name: string): void {
|
||||
if (payload.byteLength !== expected) {
|
||||
throw new RangeError(`${name} must be exactly ${expected} bytes`);
|
||||
}
|
||||
}
|
||||
1200
packages/shared/src/shooter-game.ts
Normal file
1200
packages/shared/src/shooter-game.ts
Normal file
File diff suppressed because it is too large
Load Diff
141
packages/shared/src/shooter-types.ts
Normal file
141
packages/shared/src/shooter-types.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
export const Weapon = {
|
||||
PulseRifle: 0,
|
||||
Scattergun: 1,
|
||||
RailRifle: 2,
|
||||
} as const;
|
||||
|
||||
export type WeaponId = (typeof Weapon)[keyof typeof Weapon];
|
||||
export type PlayerKind = "human" | "bot";
|
||||
export type PickupKind = "health" | "ammo" | "weapon";
|
||||
export type DistanceBand = "near" | "medium" | "far";
|
||||
|
||||
export interface WeaponDefinition {
|
||||
id: WeaponId;
|
||||
name: string;
|
||||
shortName: string;
|
||||
damage: number;
|
||||
pellets: number;
|
||||
spread: number;
|
||||
range: number;
|
||||
cooldownTicks: number;
|
||||
reloadTicks: number;
|
||||
magazineSize: number;
|
||||
startingReserve: number;
|
||||
automatic: boolean;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
export const WEAPONS: Readonly<Record<WeaponId, WeaponDefinition>> = {
|
||||
[Weapon.PulseRifle]: { id: Weapon.PulseRifle, name: "Nova-9 Pulse Rifle", shortName: "NOVA-9", damage: 18, pellets: 1, spread: 0.012, range: 42, cooldownTicks: 6, reloadTicks: 92, magazineSize: 30, startingReserve: 120, automatic: true, accent: "#5de4c7" },
|
||||
[Weapon.Scattergun]: { id: Weapon.Scattergun, name: "Arc-12 Scattergun", shortName: "ARC-12", damage: 10, pellets: 8, spread: 0.105, range: 16, cooldownTicks: 48, reloadTicks: 112, magazineSize: 8, startingReserve: 32, automatic: false, accent: "#ffad5c" },
|
||||
[Weapon.RailRifle]: { id: Weapon.RailRifle, name: "Vantage Rail Rifle", shortName: "VANTAGE", damage: 72, pellets: 1, spread: 0.002, range: 58, cooldownTicks: 76, reloadTicks: 138, magazineSize: 4, startingReserve: 16, automatic: false, accent: "#a98cff" },
|
||||
};
|
||||
|
||||
export interface ShooterInput {
|
||||
strafe: number;
|
||||
forward: number;
|
||||
yaw: number;
|
||||
pitch: number;
|
||||
fire: boolean;
|
||||
sprint: boolean;
|
||||
reload: boolean;
|
||||
weapon: WeaponId;
|
||||
}
|
||||
|
||||
export interface AmmoState { magazine: number; reserve: number }
|
||||
export type LoadoutState = [AmmoState, AmmoState, AmmoState];
|
||||
|
||||
export interface PlayerState {
|
||||
id: number;
|
||||
kind: PlayerKind;
|
||||
alive: boolean;
|
||||
x: number;
|
||||
z: number;
|
||||
velocityX: number;
|
||||
velocityZ: number;
|
||||
yaw: number;
|
||||
pitch: number;
|
||||
health: number;
|
||||
armor: number;
|
||||
weapon: WeaponId;
|
||||
ammo: LoadoutState;
|
||||
cooldownTicks: number;
|
||||
reloadTicks: number;
|
||||
respawnTicks: number;
|
||||
spawnProtectionTicks: number;
|
||||
inputStrafe: number;
|
||||
inputForward: number;
|
||||
firing: boolean;
|
||||
sprinting: boolean;
|
||||
}
|
||||
|
||||
export interface ScoreEntry { id: number; kind: PlayerKind; kills: number; deaths: number }
|
||||
|
||||
export interface PickupState {
|
||||
id: number;
|
||||
kind: PickupKind;
|
||||
weapon: WeaponId;
|
||||
x: number;
|
||||
z: number;
|
||||
active: boolean;
|
||||
respawnTicks: number;
|
||||
}
|
||||
|
||||
export interface MatchState { elapsedTicks: number; scoreLimit: number; leaderId: number }
|
||||
export type SoundKind = "gunshot" | "footstep";
|
||||
export type ImpactKind = "wall" | "player" | "miss";
|
||||
|
||||
export type ShooterPerception =
|
||||
| { id: number; type: "shot"; weapon: WeaponId; sourceId: number; originX: number; originY: number; originZ: number; endX: number; endY: number; endZ: number; impact: ImpactKind }
|
||||
| { id: number; type: "sound"; sound: SoundKind; weapon: WeaponId; bearingRadians: number; intensity: number; distanceBand: DistanceBand }
|
||||
| { id: number; type: "damage"; amount: number; health: number; bearingRadians: number; critical: boolean }
|
||||
| { id: number; type: "hit"; amount: number; eliminated: boolean; critical: boolean }
|
||||
| { id: number; type: "elimination"; killerId: number; victimId: number; weapon: WeaponId; critical: boolean }
|
||||
| { id: number; type: "pickup"; pickup: PickupKind; weapon: WeaponId; amount: number }
|
||||
| { id: number; type: "respawn" };
|
||||
|
||||
export interface TimedPerception { receivedTick: number; event: ShooterPerception }
|
||||
|
||||
export interface ShooterWorldState {
|
||||
players: Map<number, PlayerState>;
|
||||
pickups: Map<number, PickupState>;
|
||||
scoreboard: ScoreEntry[];
|
||||
match: MatchState;
|
||||
events: TimedPerception[];
|
||||
}
|
||||
|
||||
export interface BotMind { targetId: number | null; patrolIndex: number; nextThinkTick: number; strafeSign: number }
|
||||
|
||||
export interface AuthoritativePlayer extends PlayerState {
|
||||
kills: number;
|
||||
deaths: number;
|
||||
lastFirePressed: boolean;
|
||||
lastReloadPressed: boolean;
|
||||
lastFootstepTick: number;
|
||||
bot: BotMind | null;
|
||||
}
|
||||
|
||||
export interface AuthoritativeShooterState {
|
||||
players: Map<number, AuthoritativePlayer>;
|
||||
pickups: Map<number, PickupState>;
|
||||
elapsedTicks: number;
|
||||
nextEventId: number;
|
||||
}
|
||||
|
||||
export type ShooterAuthorityEvent =
|
||||
| { id: number; type: "shot"; sourceId: number; weapon: WeaponId; originX: number; originY: number; originZ: number; endX: number; endY: number; endZ: number; impact: ImpactKind }
|
||||
| { id: number; type: "damage"; sourceId: number; targetId: number; amount: number; health: number; critical: boolean }
|
||||
| { id: number; type: "elimination"; killerId: number; victimId: number; weapon: WeaponId; critical: boolean }
|
||||
| { id: number; type: "pickup"; playerId: number; pickup: PickupKind; weapon: WeaponId; amount: number }
|
||||
| { id: number; type: "respawn"; playerId: number }
|
||||
| { id: number; type: "footstep"; sourceId: number };
|
||||
|
||||
export function isWeaponId(value: number): value is WeaponId {
|
||||
return value === 0 || value === 1 || value === 2;
|
||||
}
|
||||
|
||||
export function playerDisplayName(id: number, kind: PlayerKind): string {
|
||||
if (kind === "human") return `RANGER ${id}`;
|
||||
const names = ["CIPHER", "ROOK", "MARA", "KESTREL", "VEX", "HALO"];
|
||||
return names[Math.abs(id) % names.length] ?? `BOT ${id}`;
|
||||
}
|
||||
111
packages/shared/test/flux.test.mjs
Normal file
111
packages/shared/test/flux.test.mjs
Normal file
@@ -0,0 +1,111 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
FLUX_BOT_IDS,
|
||||
FLUX_SNAPSHOT_RATE,
|
||||
FLUX_TICK_RATE,
|
||||
fluxGame,
|
||||
} from "../dist/index.js";
|
||||
|
||||
function createThrustPacket(sequence, targetTick, thrust = true) {
|
||||
return {
|
||||
sequence,
|
||||
targetTick,
|
||||
observedTick: Math.max(0, targetTick - 1),
|
||||
input: { thrust },
|
||||
};
|
||||
}
|
||||
|
||||
test("Flux Relay is a complete game defined through the public authoring API", () => {
|
||||
assert.equal(fluxGame.tickRateHz, FLUX_TICK_RATE);
|
||||
assert.equal(fluxGame.snapshotRateHz, FLUX_SNAPSHOT_RATE);
|
||||
|
||||
const server = fluxGame.createServer();
|
||||
server.addPlayer(1);
|
||||
assert.deepEqual(server.submitInput(1, createThrustPacket(1, 1)), {
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const events = [];
|
||||
for (let tick = 0; tick < 180; tick += 1) {
|
||||
events.push(...server.step().events);
|
||||
}
|
||||
|
||||
assert.ok(server.currentState.cyanScore >= 2);
|
||||
assert.equal(server.currentState.orangeScore, 0);
|
||||
assert.ok(
|
||||
events.some((event) => event.type === "round-won" && event.team === "cyan"),
|
||||
);
|
||||
assert.ok(server.currentState.players.some((player) => player.id === FLUX_BOT_IDS[0]));
|
||||
});
|
||||
|
||||
test("Flux snapshots disclose exact energy only to its owner", () => {
|
||||
const server = fluxGame.createServer();
|
||||
server.addPlayer(1);
|
||||
server.addPlayer(2);
|
||||
|
||||
const playerOneView = server.createSnapshot(1, 0).state;
|
||||
const playerTwoView = server.createSnapshot(2, 0).state;
|
||||
|
||||
assert.equal(playerOneView.players.find((player) => player.id === 1)?.energy, 100);
|
||||
assert.equal(playerOneView.players.find((player) => player.id === 2)?.energy, null);
|
||||
assert.equal(playerTwoView.players.find((player) => player.id === 1)?.energy, null);
|
||||
assert.equal(playerTwoView.players.find((player) => player.id === 2)?.energy, 100);
|
||||
assert.ok(
|
||||
playerOneView.players
|
||||
.filter((player) => player.bot)
|
||||
.every((player) => player.energy === null),
|
||||
);
|
||||
assert.equal("nextEventId" in playerOneView, false);
|
||||
});
|
||||
|
||||
test("the same private Flux event becomes a self cue or anonymous field cue", () => {
|
||||
const server = fluxGame.createServer();
|
||||
server.addPlayer(1);
|
||||
server.addPlayer(2);
|
||||
const player = server.currentState.players.find((candidate) => candidate.id === 1);
|
||||
player.energy = 0.2;
|
||||
|
||||
server.submitInput(1, createThrustPacket(1, 1));
|
||||
const result = server.step();
|
||||
const overheat = result.events.find((event) => event.type === "overheated");
|
||||
assert.ok(overheat);
|
||||
|
||||
const ownCue = server.createPerceptions(1, [overheat])[0];
|
||||
const fieldCue = server.createPerceptions(2, [overheat])[0];
|
||||
assert.equal(ownCue.scope, "self");
|
||||
assert.equal(fieldCue.scope, "field");
|
||||
assert.equal("playerId" in ownCue, false);
|
||||
assert.equal("playerId" in fieldCue, false);
|
||||
});
|
||||
|
||||
test("Flux uses the generic protocol, prediction, acknowledgement, and reconciliation", () => {
|
||||
const server = fluxGame.createServer();
|
||||
server.addPlayer(7);
|
||||
const client = fluxGame.createClient();
|
||||
client.initialize(7, server.createSnapshot(7, 10));
|
||||
|
||||
const packet = client.createInput({ thrust: true }, 1);
|
||||
const wirePacket = fluxGame.protocol.decodeClient(
|
||||
fluxGame.protocol.encodeClient({ kind: "input", packet }),
|
||||
);
|
||||
assert.equal(wirePacket.kind, "input");
|
||||
assert.deepEqual(wirePacket.packet, packet);
|
||||
assert.equal(server.submitInput(7, packet).accepted, true);
|
||||
|
||||
client.step();
|
||||
const result = server.step();
|
||||
client.acknowledge(result.acknowledgements[0].sequence);
|
||||
const wireSnapshot = fluxGame.protocol.decodeServer(
|
||||
fluxGame.protocol.encodeServer({
|
||||
kind: "snapshot",
|
||||
snapshot: server.createSnapshot(7, 20),
|
||||
}),
|
||||
);
|
||||
assert.equal(wireSnapshot.kind, "snapshot");
|
||||
client.reconcile(wireSnapshot.snapshot);
|
||||
|
||||
const local = client.currentState.players.find((player) => player.id === 7);
|
||||
assert.equal(local.thrust, true);
|
||||
assert.ok(local.energy < 100);
|
||||
});
|
||||
219
packages/shared/test/royale.test.mjs
Normal file
219
packages/shared/test/royale.test.mjs
Normal file
@@ -0,0 +1,219 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
ROYALE_BOT_COUNT,
|
||||
ROYALE_BOT_ID_BASE,
|
||||
ROYALE_CHUNK_SIZE,
|
||||
ROYALE_MAP_SEED,
|
||||
collidesWithRoyaleMap,
|
||||
generateRoyaleChunk,
|
||||
hasRoyaleLineOfSight,
|
||||
royaleGame,
|
||||
} from "../dist/index.js";
|
||||
|
||||
test("the public world seed deterministically generates a 2 km chunked island", () => {
|
||||
const first = generateRoyaleChunk(-3, 4);
|
||||
const second = generateRoyaleChunk(-3, 4);
|
||||
const neighbor = generateRoyaleChunk(-2, 4);
|
||||
assert.deepEqual(first, second);
|
||||
assert.notDeepEqual(first, neighbor);
|
||||
assert.equal(ROYAALE_MAP_SEED_FOR_TEST(), ROYALE_MAP_SEED);
|
||||
assert.equal(ROYAALE_CHUNK_SIZE_FOR_TEST(), ROYALE_CHUNK_SIZE);
|
||||
assert.ok(first.obstacles.every((obstacle) => obstacle.id.startsWith("-3:4:")));
|
||||
});
|
||||
|
||||
test("Syncer Royale simulates a complete authority battle headlessly", () => {
|
||||
const server = royaleGame.createServer();
|
||||
const totals = { shots: 0, damage: 0, eliminations: 0, winners: 0 };
|
||||
for (let tick = 0; tick < 6_000; tick += 1) {
|
||||
for (const event of server.step().events) {
|
||||
if (event.type === "shot") totals.shots += 1;
|
||||
if (event.type === "damage") totals.damage += 1;
|
||||
if (event.type === "elimination") totals.eliminations += 1;
|
||||
if (event.type === "winner") totals.winners += 1;
|
||||
}
|
||||
}
|
||||
assert.equal(server.currentState.players.length, ROYALE_BOT_COUNT);
|
||||
assert.ok(totals.shots > 150, `expected firefights, saw ${totals.shots}`);
|
||||
assert.ok(totals.damage > 50, `expected damage, saw ${totals.damage}`);
|
||||
assert.ok(totals.eliminations > 15, `expected eliminations, saw ${totals.eliminations}`);
|
||||
assert.ok(server.currentState.players.every((player) => Number.isFinite(player.x + player.z)));
|
||||
});
|
||||
|
||||
test("Royale projections hide authority secrets and distant combatants", () => {
|
||||
const server = royaleGame.createServer();
|
||||
server.addPlayer(1);
|
||||
const authority = server.currentState;
|
||||
const viewer = authority.players.find((player) => player.id === 1);
|
||||
const hidden = authority.players.find((player) => player.id === ROYALE_BOT_ID_BASE);
|
||||
viewer.altitude = 0;
|
||||
viewer.x = 0;
|
||||
viewer.z = 0;
|
||||
hidden.x = 430;
|
||||
hidden.z = 0;
|
||||
hidden.alive = true;
|
||||
|
||||
const snapshot = server.createSnapshot(1, 0).state;
|
||||
assert.equal(snapshot.mapSeed, ROYALE_MAP_SEED);
|
||||
assert.equal("secretSeed" in snapshot, false);
|
||||
assert.ok(snapshot.players.some((player) => player.id === 1));
|
||||
assert.ok(!snapshot.players.some((player) => player.id === hidden.id));
|
||||
assert.ok(
|
||||
snapshot.players
|
||||
.filter((player) => player.id !== 1)
|
||||
.every((player) => player.health === null && player.armor === null),
|
||||
);
|
||||
|
||||
const [sound] = server.createPerceptions(1, [
|
||||
{ id: 99, type: "shot", sourceId: hidden.id, x: hidden.x, y: 1.55, z: hidden.z, yaw: 0, pitch: 0 },
|
||||
]);
|
||||
assert.equal(sound.type, "gunfire");
|
||||
assert.equal("sourceId" in sound, false);
|
||||
assert.equal("x" in sound, false);
|
||||
|
||||
const [ownShot] = server.createPerceptions(1, [
|
||||
{
|
||||
id: 100,
|
||||
type: "shot",
|
||||
sourceId: viewer.id,
|
||||
x: viewer.x,
|
||||
y: 1.55,
|
||||
z: viewer.z,
|
||||
yaw: viewer.yaw,
|
||||
pitch: viewer.pitch,
|
||||
},
|
||||
]);
|
||||
assert.equal(ownShot.type, "shot");
|
||||
assert.equal(ownShot.sourceId, viewer.id);
|
||||
});
|
||||
|
||||
test("Royale enforces a viewer bandwidth budget after ordinary visibility", () => {
|
||||
const server = royaleGame.createServer();
|
||||
server.addPlayer(1);
|
||||
const authority = server.currentState;
|
||||
const viewer = authority.players.find((player) => player.id === 1);
|
||||
viewer.altitude = 0;
|
||||
viewer.x = 0;
|
||||
viewer.z = 0;
|
||||
for (const bot of authority.players.filter((player) => player.bot)) {
|
||||
bot.x = 800;
|
||||
bot.z = 0;
|
||||
}
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
const item = authority.loot[index];
|
||||
item.active = true;
|
||||
item.x = (index % 10) * 2 - 9;
|
||||
item.z = Math.floor(index / 10) * 2 - 9;
|
||||
}
|
||||
|
||||
const snapshot = server.createSnapshot(1, 0).state;
|
||||
assert.ok(snapshot.stream.droppedCount > 0);
|
||||
assert.ok(snapshot.stream.usedBytes <= snapshot.stream.budgetBytes);
|
||||
assert.ok(snapshot.loot.length < 100);
|
||||
assert.equal(snapshot.players.filter((player) => player.id === 1).length, 1);
|
||||
});
|
||||
|
||||
test("Royale input and projected state round-trip through the generic protocol", () => {
|
||||
const server = royaleGame.createServer();
|
||||
server.addPlayer(7);
|
||||
const client = royaleGame.createClient();
|
||||
const welcome = server.createSnapshot(7, 10);
|
||||
client.initialize(7, welcome);
|
||||
const packet = client.createInput({
|
||||
forward: 1,
|
||||
strafe: 0.25,
|
||||
yaw: 0.7,
|
||||
pitch: -0.2,
|
||||
fire: false,
|
||||
sprint: true,
|
||||
reload: false,
|
||||
}, 1);
|
||||
const decoded = royaleGame.protocol.decodeClient(
|
||||
royaleGame.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();
|
||||
assert.equal(server.currentState.players.find((player) => player.id === 7).pitch, -0.2);
|
||||
client.step();
|
||||
client.reconcile(server.createSnapshot(7, 20));
|
||||
assert.ok(client.currentState.players.some((player) => player.id === 7));
|
||||
});
|
||||
|
||||
test("Royale pitch drives the authoritative three-dimensional hit ray", () => {
|
||||
const server = royaleGame.createServer();
|
||||
server.addPlayer(7);
|
||||
server.addPlayer(8);
|
||||
const authority = server.currentState;
|
||||
for (const bot of authority.players.filter((player) => player.bot)) bot.alive = false;
|
||||
const shooter = authority.players.find((player) => player.id === 7);
|
||||
const target = authority.players.find((player) => player.id === 8);
|
||||
let origin = null;
|
||||
for (let x = -100; x <= 100 && !origin; x += 10) {
|
||||
for (let z = -100; z <= 60; z += 10) {
|
||||
if (
|
||||
!collidesWithRoyaleMap(x, z) &&
|
||||
!collidesWithRoyaleMap(x, z + 40) &&
|
||||
hasRoyaleLineOfSight(x, z, x, z + 40)
|
||||
) {
|
||||
origin = { x, z };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(origin, "expected an unobstructed public-map firing lane");
|
||||
Object.assign(shooter, { x: origin.x, z: origin.z, altitude: 0, yaw: 0 });
|
||||
Object.assign(target, { x: origin.x, z: origin.z + 40, altitude: 20, health: 100, armor: 0 });
|
||||
const pitch = Math.atan2(20, 40);
|
||||
assert.equal(server.submitInput(7, {
|
||||
sequence: 1,
|
||||
targetTick: 1,
|
||||
observedTick: 0,
|
||||
input: {
|
||||
forward: 0,
|
||||
strafe: 0,
|
||||
yaw: 0,
|
||||
pitch,
|
||||
fire: true,
|
||||
sprint: false,
|
||||
reload: false,
|
||||
},
|
||||
}).accepted, true);
|
||||
const result = server.step();
|
||||
assert.ok(result.events.some((event) => event.type === "damage" && event.targetId === 8));
|
||||
assert.ok(target.health < 100);
|
||||
});
|
||||
|
||||
test("Royale strafe-right matches the first-person camera's screen right", () => {
|
||||
const server = royaleGame.createServer();
|
||||
server.addPlayer(7);
|
||||
const authority = server.currentState;
|
||||
for (const bot of authority.players.filter((player) => player.bot)) bot.alive = false;
|
||||
const player = authority.players.find((candidate) => candidate.id === 7);
|
||||
Object.assign(player, { x: 0, z: 0, altitude: 20, yaw: 0 });
|
||||
assert.equal(server.submitInput(7, {
|
||||
sequence: 1,
|
||||
targetTick: 1,
|
||||
observedTick: 0,
|
||||
input: {
|
||||
forward: 0,
|
||||
strafe: 1,
|
||||
yaw: 0,
|
||||
pitch: 0,
|
||||
fire: false,
|
||||
sprint: false,
|
||||
reload: false,
|
||||
},
|
||||
}).accepted, true);
|
||||
server.step();
|
||||
assert.ok(player.velocityX < 0, "screen-right is world -X while facing world +Z");
|
||||
});
|
||||
|
||||
function ROYAALE_MAP_SEED_FOR_TEST() {
|
||||
return ROYALE_MAP_SEED;
|
||||
}
|
||||
|
||||
function ROYAALE_CHUNK_SIZE_FOR_TEST() {
|
||||
return ROYALE_CHUNK_SIZE;
|
||||
}
|
||||
282
packages/shared/test/shooter.test.mjs
Normal file
282
packages/shared/test/shooter.test.mjs
Normal file
@@ -0,0 +1,282 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
BOT_COUNT,
|
||||
BOT_ID_BASE,
|
||||
Weapon,
|
||||
shooterGame,
|
||||
} from "../dist/index.js";
|
||||
|
||||
function runHeadlessMatch(ticks) {
|
||||
const server = shooterGame.createServer();
|
||||
const totals = {
|
||||
shots: 0,
|
||||
damageEvents: 0,
|
||||
eliminations: 0,
|
||||
respawns: 0,
|
||||
};
|
||||
|
||||
for (let tick = 0; tick < ticks; tick += 1) {
|
||||
const result = server.step();
|
||||
for (const event of result.events) {
|
||||
if (event.type === "shot") totals.shots += 1;
|
||||
if (event.type === "damage") totals.damageEvents += 1;
|
||||
if (event.type === "elimination") totals.eliminations += 1;
|
||||
if (event.type === "respawn") totals.respawns += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...totals,
|
||||
tick: server.tick,
|
||||
scores: [...server.currentState.players.values()]
|
||||
.map((player) => ({
|
||||
id: player.id,
|
||||
kills: player.kills,
|
||||
deaths: player.deaths,
|
||||
alive: player.alive,
|
||||
x: Number(player.x.toFixed(5)),
|
||||
z: Number(player.z.toFixed(5)),
|
||||
}))
|
||||
.sort((a, b) => a.id - b.id),
|
||||
};
|
||||
}
|
||||
|
||||
test("the complete shooter match simulates headlessly on the backend", () => {
|
||||
const match = runHeadlessMatch(7_200);
|
||||
assert.equal(match.tick, 7_200);
|
||||
assert.equal(match.scores.length, BOT_COUNT);
|
||||
assert.ok(match.shots > 700, `expected active combat, saw ${match.shots} shots`);
|
||||
assert.ok(match.damageEvents > 50, `expected hits, saw ${match.damageEvents}`);
|
||||
assert.ok(match.eliminations >= 10, `expected kills, saw ${match.eliminations}`);
|
||||
assert.ok(match.respawns >= 8, `expected respawns, saw ${match.respawns}`);
|
||||
assert.equal(
|
||||
match.scores.reduce((sum, score) => sum + score.kills, 0),
|
||||
match.eliminations,
|
||||
);
|
||||
assert.ok(match.scores.every((score) => Number.isFinite(score.x + score.z)));
|
||||
});
|
||||
|
||||
test("backend simulation is deterministic", () => {
|
||||
assert.deepEqual(runHeadlessMatch(2_400), runHeadlessMatch(2_400));
|
||||
});
|
||||
|
||||
test("human hitscan damages the historical target without rewinding live positions", () => {
|
||||
const server = shooterGame.createServer();
|
||||
server.addPlayer(1);
|
||||
const shooter = server.currentState.players.get(1);
|
||||
const target = server.currentState.players.get(BOT_ID_BASE);
|
||||
|
||||
for (const bot of server.currentState.players.values()) {
|
||||
if (bot.id === BOT_ID_BASE || bot.id === 1) continue;
|
||||
bot.alive = false;
|
||||
bot.respawnTicks = 10_000;
|
||||
}
|
||||
shooter.x = -4;
|
||||
shooter.z = -15;
|
||||
shooter.yaw = Math.PI / 2;
|
||||
shooter.pitch = 0;
|
||||
shooter.spawnProtectionTicks = 0;
|
||||
target.x = 0;
|
||||
target.z = -15;
|
||||
target.velocityX = 0;
|
||||
target.velocityZ = 0;
|
||||
target.inputForward = 0;
|
||||
target.inputStrafe = 0;
|
||||
target.spawnProtectionTicks = 0;
|
||||
target.bot = null;
|
||||
|
||||
server.step();
|
||||
target.z = -12.5;
|
||||
const healthBefore = target.health;
|
||||
server.submitInput(1, {
|
||||
sequence: 1,
|
||||
targetTick: 2,
|
||||
observedTick: 1,
|
||||
input: {
|
||||
strafe: 0,
|
||||
forward: 0,
|
||||
yaw: Math.PI / 2,
|
||||
pitch: 0,
|
||||
fire: true,
|
||||
sprint: false,
|
||||
reload: false,
|
||||
weapon: Weapon.PulseRifle,
|
||||
},
|
||||
});
|
||||
const result = server.step();
|
||||
|
||||
assert.equal(target.z, -12.5, "the live target must not move backward");
|
||||
assert.ok(target.health < healthBefore, "the rewound hitbox should take damage");
|
||||
assert.ok(
|
||||
result.events.some(
|
||||
(event) => event.type === "damage" && event.targetId === BOT_ID_BASE,
|
||||
),
|
||||
);
|
||||
const shot = result.events.find(
|
||||
(event) => event.type === "shot" && event.sourceId === 1,
|
||||
);
|
||||
assert.ok(shot);
|
||||
assert.ok(Math.abs(shot.endZ + 15) < 0.01);
|
||||
});
|
||||
|
||||
test("shooter recordings seek through checkpoints with privacy intact", () => {
|
||||
const server = shooterGame.createServer({ replaySeed: 99 });
|
||||
server.addPlayer(1);
|
||||
for (let tick = 0; tick < 420; tick += 1) server.step();
|
||||
|
||||
const recording = server.exportRecording();
|
||||
assert.equal(recording.durationTicks, 420);
|
||||
assert.deepEqual(
|
||||
recording.checkpoints.map(({ tick }) => tick),
|
||||
[0, 120, 240, 360],
|
||||
);
|
||||
|
||||
const replay = shooterGame.createReplay(recording);
|
||||
replay.seek(135);
|
||||
const finalFrame = replay.seek(420);
|
||||
assert.deepEqual(finalFrame.state, server.currentState);
|
||||
|
||||
const playerView = replay.viewAs(1).state;
|
||||
assert.ok(playerView.players.has(1));
|
||||
assert.equal("nextEventId" in playerView, false);
|
||||
assert.ok(
|
||||
[...playerView.players.values()].every((player) => !("bot" in player)),
|
||||
);
|
||||
});
|
||||
|
||||
test("a human elimination creates a bounded attacker-perspective killcam", () => {
|
||||
const server = shooterGame.createServer();
|
||||
server.addPlayer(1);
|
||||
let ticket = null;
|
||||
for (let tick = 0; tick < 600 && !ticket; tick += 1) {
|
||||
server.step();
|
||||
ticket = server.drainReplayTickets(1)[0] ?? null;
|
||||
}
|
||||
|
||||
assert.ok(ticket, "expected the standing human to receive a bot killcam");
|
||||
assert.equal(ticket.requesterId, 1);
|
||||
assert.ok(ticket.perspectiveId >= BOT_ID_BASE);
|
||||
assert.equal(ticket.toTick, ticket.issuedAtTick);
|
||||
assert.ok(ticket.frames.length > 60);
|
||||
assert.ok(ticket.frames.length < 300);
|
||||
assert.ok(
|
||||
ticket.frames.every(({ state }) =>
|
||||
[...state.players.values()].every(
|
||||
(player) => !("bot" in player) && !("kills" in player),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("snapshots and perception events cross the binary protocol", () => {
|
||||
const server = shooterGame.createServer();
|
||||
server.addPlayer(1);
|
||||
const snapshot = server.createSnapshot(1, 123.5);
|
||||
const snapshotFrame = shooterGame.protocol.encodeServer({
|
||||
kind: "snapshot",
|
||||
snapshot,
|
||||
});
|
||||
const decodedSnapshot = shooterGame.protocol.decodeServer(snapshotFrame);
|
||||
assert.equal(decodedSnapshot.kind, "snapshot");
|
||||
assert.equal(decodedSnapshot.snapshot.state.players.get(1).ammo.length, 3);
|
||||
|
||||
const event = {
|
||||
id: 99,
|
||||
type: "shot",
|
||||
weapon: Weapon.RailRifle,
|
||||
sourceId: 1,
|
||||
originX: 1,
|
||||
originY: 1.58,
|
||||
originZ: 2,
|
||||
endX: 11,
|
||||
endY: 1.4,
|
||||
endZ: -4,
|
||||
impact: "wall",
|
||||
};
|
||||
const eventFrame = shooterGame.protocol.encodeServer({
|
||||
kind: "event",
|
||||
tick: 50,
|
||||
event,
|
||||
});
|
||||
const decodedEvent = shooterGame.protocol.decodeServer(eventFrame);
|
||||
assert.equal(decodedEvent.kind, "event");
|
||||
assert.equal(decodedEvent.tick, 50);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...decodedEvent.event,
|
||||
originY: Number(decodedEvent.event.originY.toFixed(2)),
|
||||
endY: Number(decodedEvent.event.endY.toFixed(2)),
|
||||
},
|
||||
event,
|
||||
);
|
||||
});
|
||||
|
||||
test("occluded combatants stay private while their gunfire becomes anonymous audio", () => {
|
||||
const server = shooterGame.createServer();
|
||||
server.addPlayer(1);
|
||||
const authority = server.currentState;
|
||||
const viewer = authority.players.get(1);
|
||||
const hiddenBot = authority.players.get(BOT_ID_BASE);
|
||||
viewer.x = 0;
|
||||
viewer.z = -6;
|
||||
viewer.yaw = 0;
|
||||
hiddenBot.x = 0;
|
||||
hiddenBot.z = 6;
|
||||
|
||||
const snapshot = server.createSnapshot(1, 0);
|
||||
assert.equal(snapshot.state.players.has(BOT_ID_BASE), false);
|
||||
|
||||
const perceptions = server.createPerceptions(1, [
|
||||
{
|
||||
id: 777,
|
||||
type: "shot",
|
||||
sourceId: BOT_ID_BASE,
|
||||
weapon: Weapon.PulseRifle,
|
||||
originX: hiddenBot.x,
|
||||
originY: 1.58,
|
||||
originZ: hiddenBot.z,
|
||||
endX: hiddenBot.x,
|
||||
endY: 1.58,
|
||||
endZ: hiddenBot.z - 10,
|
||||
impact: "wall",
|
||||
},
|
||||
]);
|
||||
assert.equal(perceptions.length, 1);
|
||||
assert.equal(perceptions[0].type, "sound");
|
||||
assert.equal("sourceId" in perceptions[0], false);
|
||||
assert.equal("originX" in perceptions[0], false);
|
||||
});
|
||||
|
||||
test("visible opponents expose exact health but redact armor and inventory", () => {
|
||||
const server = shooterGame.createServer();
|
||||
server.addPlayer(1);
|
||||
const viewer = server.currentState.players.get(1);
|
||||
const opponent = server.currentState.players.get(BOT_ID_BASE);
|
||||
viewer.x = -4;
|
||||
viewer.z = -15;
|
||||
viewer.yaw = Math.PI / 2;
|
||||
opponent.x = 0;
|
||||
opponent.z = -15;
|
||||
opponent.health = 37;
|
||||
opponent.armor = 19;
|
||||
opponent.ammo[Weapon.PulseRifle].magazine = 3;
|
||||
|
||||
const replicated = server.createSnapshot(1, 0).state.players.get(BOT_ID_BASE);
|
||||
assert.ok(replicated, "opponent should be visible in the clear corridor");
|
||||
assert.equal(replicated.health, 37);
|
||||
assert.equal(replicated.armor, 0);
|
||||
assert.ok(
|
||||
replicated.ammo.every(
|
||||
(ammo) => ammo.magazine === 0 && ammo.reserve === 0,
|
||||
),
|
||||
);
|
||||
assert.equal(replicated.x, opponent.x);
|
||||
assert.equal(replicated.weapon, opponent.weapon);
|
||||
|
||||
viewer.yaw = -Math.PI / 2;
|
||||
assert.equal(
|
||||
server.createSnapshot(1, 0).state.players.has(BOT_ID_BASE),
|
||||
false,
|
||||
);
|
||||
});
|
||||
14
packages/shared/tsconfig.json
Normal file
14
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user