1201 lines
36 KiB
TypeScript
1201 lines
36 KiB
TypeScript
import {
|
|
defineNetworkedGame,
|
|
deterministicHash,
|
|
withLagCompensation,
|
|
withReplayTransport,
|
|
withTimeTravel,
|
|
} from "@syncer/engine";
|
|
import {
|
|
ARENA_SPAWNS,
|
|
BOT_PATROL_POINTS,
|
|
PLAYER_EYE_HEIGHT,
|
|
collidesWithArena,
|
|
hasLineOfSight,
|
|
rayBlockDistance,
|
|
} from "./arena.js";
|
|
import {
|
|
shooterEventCodec,
|
|
shooterInputCodec,
|
|
shooterWorldCodec,
|
|
} from "./shooter-codecs.js";
|
|
import {
|
|
WEAPONS,
|
|
Weapon,
|
|
type AuthoritativePlayer,
|
|
type AuthoritativeShooterState,
|
|
type DistanceBand,
|
|
type ImpactKind,
|
|
type LoadoutState,
|
|
type PickupKind,
|
|
type PickupState,
|
|
type PlayerKind,
|
|
type PlayerState,
|
|
type ShooterAuthorityEvent,
|
|
type ShooterInput,
|
|
type ShooterPerception,
|
|
type ShooterWorldState,
|
|
type WeaponId,
|
|
} from "./shooter-types.js";
|
|
|
|
export const SHOOTER_TICK_RATE = 60;
|
|
export const SHOOTER_SNAPSHOT_RATE = 20;
|
|
export const SHOOTER_SCORE_LIMIT = 20;
|
|
export const BOT_COUNT = 6;
|
|
export const BOT_ID_BASE = 10_000;
|
|
|
|
export interface ShooterGameOptions {
|
|
/** Deterministic authority-controlled opponents created with the match. */
|
|
botCount?: number;
|
|
}
|
|
|
|
const deltaSeconds = 1 / SHOOTER_TICK_RATE;
|
|
const normalSpeed = 4.7;
|
|
const sprintSpeed = 6.45;
|
|
const movementAcceleration = 31;
|
|
const movementFriction = 11;
|
|
const visibilityRange = 31;
|
|
const fieldOfViewRadians = Math.PI * 0.62;
|
|
const footstepHearingRange = 14;
|
|
const gunshotHearingRange = 38;
|
|
const respawnDelayTicks = 180;
|
|
const spawnProtectionTicks = 75;
|
|
const pickupRespawnTicks = 720;
|
|
const presentationEventLifetime = 240;
|
|
|
|
interface TraceResult {
|
|
distance: number;
|
|
target: ShooterLagPlayer | null;
|
|
critical: boolean;
|
|
impact: ImpactKind;
|
|
}
|
|
|
|
interface ShooterLagPlayer {
|
|
id: number;
|
|
alive: boolean;
|
|
x: number;
|
|
z: number;
|
|
spawnProtectionTicks: number;
|
|
}
|
|
|
|
interface ShooterLagState {
|
|
players: Map<number, ShooterLagPlayer>;
|
|
}
|
|
|
|
type ShooterLagAction = { type: "hitscan" };
|
|
|
|
export function createShooterGame(options: ShooterGameOptions = {}) {
|
|
const botCount = Math.max(0, Math.floor(options.botCount ?? BOT_COUNT));
|
|
const shooterDefinition = defineNetworkedGame<
|
|
AuthoritativeShooterState,
|
|
ShooterWorldState,
|
|
ShooterInput,
|
|
ShooterAuthorityEvent,
|
|
ShooterPerception
|
|
>({
|
|
tickRateHz: SHOOTER_TICK_RATE,
|
|
snapshotRateHz: SHOOTER_SNAPSHOT_RATE,
|
|
|
|
server: {
|
|
createInitialState: () => createAuthoritativeWorld(botCount),
|
|
cloneState: cloneAuthoritativeWorld,
|
|
addPlayer(state, { playerId }) {
|
|
const spawn = safestSpawn(state, playerId);
|
|
state.players.set(playerId, createPlayer(playerId, "human", spawn));
|
|
},
|
|
removePlayer(state, { playerId }) {
|
|
state.players.delete(playerId);
|
|
},
|
|
applyInput(state, input, { playerId }) {
|
|
const player = state.players.get(playerId);
|
|
if (!player || player.kind !== "human" || !player.alive) return;
|
|
applyPlayerCommand(player, input, true);
|
|
},
|
|
step(state, { tick, emit }) {
|
|
state.elapsedTicks += 1;
|
|
updatePickups(state);
|
|
|
|
for (const player of state.players.values()) {
|
|
updatePlayerTimers(state, player, emit);
|
|
}
|
|
for (const player of state.players.values()) {
|
|
if (player.alive && player.bot) updateBot(state, player, tick);
|
|
}
|
|
for (const player of [...state.players.values()].sort((a, b) => a.id - b.id)) {
|
|
if (!player.alive) continue;
|
|
simulateMovement(player, deltaSeconds);
|
|
emitFootstep(state, player, tick, emit);
|
|
if (player.bot) tryFireWeapon(state, player, emit);
|
|
collectNearbyPickup(state, player, emit);
|
|
player.lastFirePressed = player.firing;
|
|
}
|
|
},
|
|
validateState: validateAuthoritativeState,
|
|
},
|
|
|
|
client: {
|
|
createInitialState: createClientWorld,
|
|
cloneState: cloneClientWorld,
|
|
applyInput(state, input, { playerId }) {
|
|
const player = state.players.get(playerId);
|
|
if (!player || !player.alive) return;
|
|
applyPlayerCommand(player, input, false);
|
|
},
|
|
step(state, { tick }) {
|
|
state.match.elapsedTicks += 1;
|
|
for (const player of state.players.values()) {
|
|
if (!player.alive) continue;
|
|
if (player.cooldownTicks > 0) player.cooldownTicks -= 1;
|
|
if (player.reloadTicks > 0) player.reloadTicks -= 1;
|
|
if (player.spawnProtectionTicks > 0) player.spawnProtectionTicks -= 1;
|
|
simulateMovement(player, deltaSeconds);
|
|
}
|
|
state.events = state.events.filter(
|
|
(entry) => entry.receivedTick >= tick - presentationEventLifetime,
|
|
);
|
|
},
|
|
mergeSnapshot(predicted, snapshot, { tick }) {
|
|
const events = predicted.events
|
|
.filter((entry) => entry.receivedTick >= tick - presentationEventLifetime)
|
|
.map((entry) => ({
|
|
receivedTick: entry.receivedTick,
|
|
event: { ...entry.event },
|
|
}));
|
|
const merged = cloneClientWorld(snapshot);
|
|
merged.events = events;
|
|
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 > 48) state.events.shift();
|
|
},
|
|
validateState: validateClientWorld,
|
|
},
|
|
|
|
replication: {
|
|
createSnapshot(authoritative, { playerId }) {
|
|
const viewer = authoritative.players.get(playerId);
|
|
const players = new Map<number, PlayerState>();
|
|
if (viewer) {
|
|
players.set(viewer.id, clonePublicPlayer(viewer));
|
|
for (const target of authoritative.players.values()) {
|
|
if (target.id !== viewer.id && canViewerSee(viewer, target)) {
|
|
players.set(target.id, cloneVisibleOpponent(target));
|
|
}
|
|
}
|
|
}
|
|
|
|
const scoreboard = [...authoritative.players.values()]
|
|
.map((player) => ({
|
|
id: player.id,
|
|
kind: player.kind,
|
|
kills: player.kills,
|
|
deaths: player.deaths,
|
|
}))
|
|
.sort((a, b) => b.kills - a.kills || a.deaths - b.deaths || a.id - b.id);
|
|
return {
|
|
players,
|
|
pickups: clonePickups(authoritative.pickups),
|
|
scoreboard,
|
|
match: {
|
|
elapsedTicks: authoritative.elapsedTicks,
|
|
scoreLimit: SHOOTER_SCORE_LIMIT,
|
|
leaderId: scoreboard[0]?.id ?? 0,
|
|
},
|
|
events: [],
|
|
};
|
|
},
|
|
validateClientState(authoritative, candidate, { playerId }) {
|
|
const expected = authoritative.players.get(playerId);
|
|
const reported = candidate.players.get(playerId);
|
|
if (!expected || !reported) return false;
|
|
if (!finitePlayer(reported)) return false;
|
|
if (expected.alive !== reported.alive) return false;
|
|
return Math.hypot(expected.x - reported.x, expected.z - reported.z) <= 1.15;
|
|
},
|
|
perceive(authoritative, event, { playerId }) {
|
|
return perceiveEvent(authoritative, event, playerId);
|
|
},
|
|
},
|
|
|
|
validateInput(input) {
|
|
return (
|
|
Number.isFinite(input.strafe) &&
|
|
Number.isFinite(input.forward) &&
|
|
Number.isFinite(input.yaw) &&
|
|
Number.isFinite(input.pitch) &&
|
|
Math.abs(input.strafe) <= 1 &&
|
|
Math.abs(input.forward) <= 1 &&
|
|
input.pitch >= -1.35 &&
|
|
input.pitch <= 1.35 &&
|
|
(input.weapon === 0 || input.weapon === 1 || input.weapon === 2) &&
|
|
typeof input.fire === "boolean" &&
|
|
typeof input.sprint === "boolean" &&
|
|
typeof input.reload === "boolean"
|
|
);
|
|
},
|
|
|
|
codecs: {
|
|
input: shooterInputCodec,
|
|
state: shooterWorldCodec,
|
|
event: shooterEventCodec,
|
|
},
|
|
});
|
|
|
|
const lagCompensatedShooterDefinition = withLagCompensation(shooterDefinition, {
|
|
historySeconds: 0.5,
|
|
captureState(state) {
|
|
return {
|
|
players: new Map(
|
|
[...state.players].map(([id, player]) => [
|
|
id,
|
|
{
|
|
id,
|
|
alive: player.alive,
|
|
x: player.x,
|
|
z: player.z,
|
|
spawnProtectionTicks: player.spawnProtectionTicks,
|
|
},
|
|
]),
|
|
),
|
|
};
|
|
},
|
|
cloneHistoricalState: cloneShooterLagState,
|
|
classifyAction(input): ShooterLagAction | null {
|
|
return input.fire ? { type: "hitscan" } : null;
|
|
},
|
|
cloneAction: (action) => ({ ...action }),
|
|
actions: {
|
|
hitscan: {
|
|
mode: "rewind",
|
|
maximumRewindMs: 250,
|
|
maximumFutureMs: 25,
|
|
outOfWindow: "clamp",
|
|
validate({ currentState, playerId }) {
|
|
const player = currentState.players.get(playerId);
|
|
return Boolean(player?.alive && player.kind === "human");
|
|
},
|
|
resolve({
|
|
currentState,
|
|
historicalState,
|
|
playerId,
|
|
emit,
|
|
}) {
|
|
const player = currentState.players.get(playerId);
|
|
if (player) {
|
|
tryFireWeapon(currentState, player, emit, historicalState, true);
|
|
}
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const timeTravelShooterGame = withTimeTravel(lagCompensatedShooterDefinition, {
|
|
createSeed: () => 0,
|
|
hashState: deterministicHash,
|
|
checkpointIntervalTicks: SHOOTER_TICK_RATE * 2,
|
|
verificationIntervalTicks: 1,
|
|
});
|
|
|
|
return withReplayTransport(timeTravelShooterGame, {
|
|
historySeconds: 30,
|
|
captureRateHz: SHOOTER_SNAPSHOT_RATE,
|
|
listPerspectives: (state) => state.players.keys(),
|
|
createTickets(event, { tick, connectedPlayerIds }) {
|
|
if (
|
|
event.type !== "elimination" ||
|
|
!connectedPlayerIds.includes(event.victimId)
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
requesterId: event.victimId,
|
|
perspectiveId: event.killerId,
|
|
fromTick: Math.max(0, tick - SHOOTER_TICK_RATE * 4),
|
|
toTick: tick,
|
|
playbackRate: 0.72,
|
|
};
|
|
},
|
|
authorizeReplay({ requesterId, perspectiveId, authorityState }) {
|
|
return (
|
|
authorityState.players.has(requesterId) &&
|
|
authorityState.players.has(perspectiveId)
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
export const shooterGame = createShooterGame();
|
|
|
|
function createAuthoritativeWorld(botCount: number): AuthoritativeShooterState {
|
|
const state: AuthoritativeShooterState = {
|
|
players: new Map(),
|
|
pickups: createPickups(),
|
|
elapsedTicks: 0,
|
|
nextEventId: 1,
|
|
};
|
|
for (let index = 0; index < botCount; index += 1) {
|
|
const id = BOT_ID_BASE + index;
|
|
const bot = createPlayer(id, "bot", ARENA_SPAWNS[index % ARENA_SPAWNS.length]!);
|
|
bot.bot = {
|
|
targetId: null,
|
|
patrolIndex: (index * 2) % BOT_PATROL_POINTS.length,
|
|
nextThinkTick: index,
|
|
strafeSign: index % 2 === 0 ? 1 : -1,
|
|
};
|
|
state.players.set(id, bot);
|
|
}
|
|
return state;
|
|
}
|
|
|
|
function createClientWorld(): ShooterWorldState {
|
|
return {
|
|
players: new Map(),
|
|
pickups: new Map(),
|
|
scoreboard: [],
|
|
match: { elapsedTicks: 0, scoreLimit: SHOOTER_SCORE_LIMIT, leaderId: 0 },
|
|
events: [],
|
|
};
|
|
}
|
|
|
|
function createPlayer(
|
|
id: number,
|
|
kind: PlayerKind,
|
|
spawn: { x: number; z: number; yaw: number },
|
|
): AuthoritativePlayer {
|
|
return {
|
|
id,
|
|
kind,
|
|
alive: true,
|
|
x: spawn.x,
|
|
z: spawn.z,
|
|
velocityX: 0,
|
|
velocityZ: 0,
|
|
yaw: spawn.yaw,
|
|
pitch: 0,
|
|
health: 100,
|
|
armor: 25,
|
|
weapon: Weapon.PulseRifle,
|
|
ammo: createLoadout(),
|
|
cooldownTicks: 0,
|
|
reloadTicks: 0,
|
|
respawnTicks: 0,
|
|
spawnProtectionTicks,
|
|
inputStrafe: 0,
|
|
inputForward: 0,
|
|
firing: false,
|
|
sprinting: false,
|
|
kills: 0,
|
|
deaths: 0,
|
|
lastFirePressed: false,
|
|
lastReloadPressed: false,
|
|
lastFootstepTick: -100,
|
|
bot: null,
|
|
};
|
|
}
|
|
|
|
function createLoadout(): LoadoutState {
|
|
return [
|
|
{ magazine: WEAPONS[0].magazineSize, reserve: WEAPONS[0].startingReserve },
|
|
{ magazine: WEAPONS[1].magazineSize, reserve: WEAPONS[1].startingReserve },
|
|
{ magazine: WEAPONS[2].magazineSize, reserve: WEAPONS[2].startingReserve },
|
|
];
|
|
}
|
|
|
|
function createPickups(): Map<number, PickupState> {
|
|
const definitions: Array<Omit<PickupState, "active" | "respawnTicks">> = [
|
|
{ id: 1, kind: "health", weapon: Weapon.PulseRifle, x: 0, z: -7.4 },
|
|
{ id: 2, kind: "health", weapon: Weapon.PulseRifle, x: 0, z: 7.4 },
|
|
{ id: 3, kind: "ammo", weapon: Weapon.PulseRifle, x: -12.8, z: 0 },
|
|
{ id: 4, kind: "ammo", weapon: Weapon.PulseRifle, x: 12.8, z: 0 },
|
|
{ id: 5, kind: "weapon", weapon: Weapon.Scattergun, x: -11.8, z: -11.8 },
|
|
{ id: 6, kind: "weapon", weapon: Weapon.RailRifle, x: 11.8, z: 11.8 },
|
|
];
|
|
return new Map(
|
|
definitions.map((pickup) => [pickup.id, { ...pickup, active: true, respawnTicks: 0 }]),
|
|
);
|
|
}
|
|
|
|
function cloneAuthoritativeWorld(state: AuthoritativeShooterState): AuthoritativeShooterState {
|
|
return {
|
|
players: new Map(
|
|
[...state.players].map(([id, player]) => [id, {
|
|
...player,
|
|
ammo: cloneLoadout(player.ammo),
|
|
bot: player.bot ? { ...player.bot } : null,
|
|
}]),
|
|
),
|
|
pickups: clonePickups(state.pickups),
|
|
elapsedTicks: state.elapsedTicks,
|
|
nextEventId: state.nextEventId,
|
|
};
|
|
}
|
|
|
|
function cloneClientWorld(state: ShooterWorldState): ShooterWorldState {
|
|
return {
|
|
players: new Map(
|
|
[...state.players].map(([id, player]) => [id, clonePublicPlayer(player)]),
|
|
),
|
|
pickups: clonePickups(state.pickups),
|
|
scoreboard: state.scoreboard.map((score) => ({ ...score })),
|
|
match: { ...state.match },
|
|
events: state.events.map((entry) => ({
|
|
receivedTick: entry.receivedTick,
|
|
event: { ...entry.event },
|
|
})),
|
|
};
|
|
}
|
|
|
|
function clonePublicPlayer(player: PlayerState): PlayerState {
|
|
return {
|
|
id: player.id,
|
|
kind: player.kind,
|
|
alive: player.alive,
|
|
x: player.x,
|
|
z: player.z,
|
|
velocityX: player.velocityX,
|
|
velocityZ: player.velocityZ,
|
|
yaw: player.yaw,
|
|
pitch: player.pitch,
|
|
health: player.health,
|
|
armor: player.armor,
|
|
weapon: player.weapon,
|
|
ammo: cloneLoadout(player.ammo),
|
|
cooldownTicks: player.cooldownTicks,
|
|
reloadTicks: player.reloadTicks,
|
|
respawnTicks: player.respawnTicks,
|
|
spawnProtectionTicks: player.spawnProtectionTicks,
|
|
inputStrafe: player.inputStrafe,
|
|
inputForward: player.inputForward,
|
|
firing: player.firing,
|
|
sprinting: player.sprinting,
|
|
};
|
|
}
|
|
|
|
function cloneVisibleOpponent(player: PlayerState): PlayerState {
|
|
return {
|
|
...clonePublicPlayer(player),
|
|
// Exact health is present only because this player projection already
|
|
// passed the viewer's FOV and line-of-sight gate. Armor, inventory, and
|
|
// server timers remain redacted.
|
|
armor: 0,
|
|
ammo: [
|
|
{ magazine: 0, reserve: 0 },
|
|
{ magazine: 0, reserve: 0 },
|
|
{ magazine: 0, reserve: 0 },
|
|
],
|
|
cooldownTicks: 0,
|
|
reloadTicks: 0,
|
|
respawnTicks: 0,
|
|
spawnProtectionTicks: 0,
|
|
};
|
|
}
|
|
|
|
function cloneLoadout(loadout: LoadoutState): LoadoutState {
|
|
return [
|
|
{ ...loadout[0] },
|
|
{ ...loadout[1] },
|
|
{ ...loadout[2] },
|
|
];
|
|
}
|
|
|
|
function cloneShooterLagState(state: Readonly<ShooterLagState>): ShooterLagState {
|
|
return {
|
|
players: new Map(
|
|
[...state.players].map(([id, player]) => [id, { ...player }]),
|
|
),
|
|
};
|
|
}
|
|
|
|
function clonePickups(pickups: ReadonlyMap<number, PickupState>): Map<number, PickupState> {
|
|
return new Map([...pickups].map(([id, pickup]) => [id, { ...pickup }]));
|
|
}
|
|
|
|
function applyPlayerCommand(
|
|
player: PlayerState | AuthoritativePlayer,
|
|
input: ShooterInput,
|
|
authoritative: boolean,
|
|
): void {
|
|
player.inputStrafe = clamp(input.strafe, -1, 1);
|
|
player.inputForward = clamp(input.forward, -1, 1);
|
|
player.yaw = normalizeAngle(input.yaw);
|
|
player.pitch = clamp(input.pitch, -1.25, 1.25);
|
|
player.firing = input.fire;
|
|
player.sprinting = input.sprint;
|
|
|
|
if (player.weapon !== input.weapon && player.reloadTicks === 0) {
|
|
player.weapon = input.weapon;
|
|
player.cooldownTicks = Math.max(player.cooldownTicks, 8);
|
|
}
|
|
|
|
if (authoritative) {
|
|
const authority = player as AuthoritativePlayer;
|
|
if (input.reload && !authority.lastReloadPressed) startReload(authority);
|
|
authority.lastReloadPressed = input.reload;
|
|
} else if (input.reload && player.reloadTicks === 0) {
|
|
const ammo = player.ammo[player.weapon];
|
|
if (ammo.magazine < WEAPONS[player.weapon].magazineSize && ammo.reserve > 0) {
|
|
player.reloadTicks = WEAPONS[player.weapon].reloadTicks;
|
|
}
|
|
}
|
|
}
|
|
|
|
function simulateMovement(player: PlayerState, dt: number): void {
|
|
const magnitude = Math.hypot(player.inputStrafe, player.inputForward);
|
|
const inputScale = magnitude > 1 ? 1 / magnitude : 1;
|
|
const strafe = player.inputStrafe * inputScale;
|
|
const forward = player.inputForward * inputScale;
|
|
const forwardX = Math.sin(player.yaw);
|
|
const forwardZ = -Math.cos(player.yaw);
|
|
const rightX = Math.cos(player.yaw);
|
|
const rightZ = Math.sin(player.yaw);
|
|
const speed = player.sprinting ? sprintSpeed : normalSpeed;
|
|
const targetX = (rightX * strafe + forwardX * forward) * speed;
|
|
const targetZ = (rightZ * strafe + forwardZ * forward) * speed;
|
|
const acceleration = magnitude > 0.01 ? movementAcceleration : movementFriction;
|
|
player.velocityX = approach(player.velocityX, targetX, acceleration * dt);
|
|
player.velocityZ = approach(player.velocityZ, targetZ, acceleration * dt);
|
|
|
|
const nextX = player.x + player.velocityX * dt;
|
|
if (!collidesWithArena(nextX, player.z)) {
|
|
player.x = nextX;
|
|
} else {
|
|
player.velocityX = 0;
|
|
}
|
|
const nextZ = player.z + player.velocityZ * dt;
|
|
if (!collidesWithArena(player.x, nextZ)) {
|
|
player.z = nextZ;
|
|
} else {
|
|
player.velocityZ = 0;
|
|
}
|
|
}
|
|
|
|
function updatePlayerTimers(
|
|
state: AuthoritativeShooterState,
|
|
player: AuthoritativePlayer,
|
|
emit: (event: ShooterAuthorityEvent) => void,
|
|
): void {
|
|
if (!player.alive) {
|
|
player.respawnTicks = Math.max(0, player.respawnTicks - 1);
|
|
if (player.respawnTicks === 0) {
|
|
respawnPlayer(state, player);
|
|
emit({ id: nextEventId(state), type: "respawn", playerId: player.id });
|
|
}
|
|
return;
|
|
}
|
|
|
|
player.cooldownTicks = Math.max(0, player.cooldownTicks - 1);
|
|
player.spawnProtectionTicks = Math.max(0, player.spawnProtectionTicks - 1);
|
|
if (player.reloadTicks > 0) {
|
|
player.reloadTicks -= 1;
|
|
if (player.reloadTicks === 0) finishReload(player);
|
|
}
|
|
}
|
|
|
|
function updatePickups(state: AuthoritativeShooterState): void {
|
|
for (const pickup of state.pickups.values()) {
|
|
if (pickup.active) continue;
|
|
pickup.respawnTicks = Math.max(0, pickup.respawnTicks - 1);
|
|
if (pickup.respawnTicks === 0) pickup.active = true;
|
|
}
|
|
}
|
|
|
|
function emitFootstep(
|
|
state: AuthoritativeShooterState,
|
|
player: AuthoritativePlayer,
|
|
tick: number,
|
|
emit: (event: ShooterAuthorityEvent) => void,
|
|
): void {
|
|
const speed = Math.hypot(player.velocityX, player.velocityZ);
|
|
const interval = player.sprinting ? 15 : 21;
|
|
if (speed > 1.2 && tick - player.lastFootstepTick >= interval) {
|
|
player.lastFootstepTick = tick;
|
|
emit({ id: nextEventId(state), type: "footstep", sourceId: player.id });
|
|
}
|
|
}
|
|
|
|
function tryFireWeapon(
|
|
state: AuthoritativeShooterState,
|
|
player: AuthoritativePlayer,
|
|
emit: (event: ShooterAuthorityEvent) => void,
|
|
historicalState?: Readonly<ShooterLagState>,
|
|
triggerAction = false,
|
|
): void {
|
|
const weapon = WEAPONS[player.weapon];
|
|
const canRepeat =
|
|
triggerAction || weapon.automatic || player.bot !== null || !player.lastFirePressed;
|
|
if (
|
|
!player.firing ||
|
|
!canRepeat ||
|
|
player.cooldownTicks > 0 ||
|
|
player.reloadTicks > 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const ammo = player.ammo[player.weapon];
|
|
if (ammo.magazine <= 0) {
|
|
startReload(player);
|
|
return;
|
|
}
|
|
|
|
ammo.magazine -= 1;
|
|
player.cooldownTicks = weapon.cooldownTicks;
|
|
const originX = player.x;
|
|
const originY = PLAYER_EYE_HEIGHT;
|
|
const originZ = player.z;
|
|
const shotId = nextEventId(state);
|
|
const centralDirection = directionFromAngles(player.yaw, player.pitch);
|
|
const candidates = (historicalState?.players ?? state.players).values();
|
|
const centralTrace = traceShot(candidates, player, centralDirection, weapon.range);
|
|
const endX = originX + centralDirection.x * centralTrace.distance;
|
|
const endY = originY + centralDirection.y * centralTrace.distance;
|
|
const endZ = originZ + centralDirection.z * centralTrace.distance;
|
|
|
|
emit({
|
|
id: shotId,
|
|
type: "shot",
|
|
sourceId: player.id,
|
|
weapon: player.weapon,
|
|
originX,
|
|
originY,
|
|
originZ,
|
|
endX,
|
|
endY,
|
|
endZ,
|
|
impact: centralTrace.impact,
|
|
});
|
|
|
|
const damageByPlayer = new Map<number, { amount: number; critical: boolean }>();
|
|
for (let pellet = 0; pellet < weapon.pellets; pellet += 1) {
|
|
const yawOffset = signedNoise(shotId, pellet * 2 + 1) * weapon.spread;
|
|
const pitchOffset = signedNoise(shotId, pellet * 2 + 2) * weapon.spread;
|
|
const direction = directionFromAngles(player.yaw + yawOffset, player.pitch + pitchOffset);
|
|
const trace = traceShot(
|
|
(historicalState?.players ?? state.players).values(),
|
|
player,
|
|
direction,
|
|
weapon.range,
|
|
);
|
|
if (!trace.target) continue;
|
|
const damage = Math.round(weapon.damage * (trace.critical ? 1.55 : 1));
|
|
const previous = damageByPlayer.get(trace.target.id);
|
|
damageByPlayer.set(trace.target.id, {
|
|
amount: (previous?.amount ?? 0) + damage,
|
|
critical: Boolean(previous?.critical || trace.critical),
|
|
});
|
|
}
|
|
|
|
for (const [targetId, damage] of damageByPlayer) {
|
|
const target = state.players.get(targetId);
|
|
if (target?.alive) applyDamage(state, player, target, damage.amount, damage.critical, emit);
|
|
}
|
|
}
|
|
|
|
function traceShot(
|
|
candidates: Iterable<ShooterLagPlayer>,
|
|
shooter: AuthoritativePlayer,
|
|
direction: { x: number; y: number; z: number },
|
|
maximumDistance: number,
|
|
): TraceResult {
|
|
const wallDistance = rayBlockDistance(
|
|
shooter.x,
|
|
PLAYER_EYE_HEIGHT,
|
|
shooter.z,
|
|
direction.x,
|
|
direction.y,
|
|
direction.z,
|
|
maximumDistance,
|
|
);
|
|
let distance = wallDistance ?? maximumDistance;
|
|
let target: ShooterLagPlayer | null = null;
|
|
let critical = false;
|
|
|
|
for (const candidate of candidates) {
|
|
if (
|
|
candidate.id === shooter.id ||
|
|
!candidate.alive ||
|
|
candidate.spawnProtectionTicks > 0
|
|
) continue;
|
|
|
|
const bodyDistance = raySphereDistance(
|
|
shooter.x,
|
|
PLAYER_EYE_HEIGHT,
|
|
shooter.z,
|
|
direction,
|
|
candidate.x,
|
|
0.83,
|
|
candidate.z,
|
|
0.49,
|
|
);
|
|
const headDistance = raySphereDistance(
|
|
shooter.x,
|
|
PLAYER_EYE_HEIGHT,
|
|
shooter.z,
|
|
direction,
|
|
candidate.x,
|
|
1.55,
|
|
candidate.z,
|
|
0.24,
|
|
);
|
|
const candidateDistance = minimumNullable(bodyDistance, headDistance);
|
|
if (candidateDistance !== null && candidateDistance < distance) {
|
|
distance = candidateDistance;
|
|
target = candidate;
|
|
critical = headDistance !== null && headDistance <= candidateDistance + 0.0001;
|
|
}
|
|
}
|
|
|
|
return {
|
|
distance,
|
|
target,
|
|
critical,
|
|
impact: target ? "player" : wallDistance !== null ? "wall" : "miss",
|
|
};
|
|
}
|
|
|
|
function raySphereDistance(
|
|
originX: number,
|
|
originY: number,
|
|
originZ: number,
|
|
direction: { x: number; y: number; z: number },
|
|
centerX: number,
|
|
centerY: number,
|
|
centerZ: number,
|
|
radius: number,
|
|
): number | null {
|
|
const offsetX = originX - centerX;
|
|
const offsetY = originY - centerY;
|
|
const offsetZ = originZ - centerZ;
|
|
const projection =
|
|
offsetX * direction.x + offsetY * direction.y + offsetZ * direction.z;
|
|
const constant =
|
|
offsetX * offsetX + offsetY * offsetY + offsetZ * offsetZ - radius * radius;
|
|
const discriminant = projection * projection - constant;
|
|
if (discriminant < 0) return null;
|
|
const near = -projection - Math.sqrt(discriminant);
|
|
const far = -projection + Math.sqrt(discriminant);
|
|
if (near >= 0) return near;
|
|
return far >= 0 ? far : null;
|
|
}
|
|
|
|
function applyDamage(
|
|
state: AuthoritativeShooterState,
|
|
attacker: AuthoritativePlayer,
|
|
target: AuthoritativePlayer,
|
|
amount: number,
|
|
critical: boolean,
|
|
emit: (event: ShooterAuthorityEvent) => void,
|
|
): void {
|
|
const absorbed = Math.min(target.armor, Math.round(amount * 0.3));
|
|
target.armor -= absorbed;
|
|
const healthDamage = Math.max(1, amount - absorbed);
|
|
target.health = Math.max(0, target.health - healthDamage);
|
|
emit({
|
|
id: nextEventId(state),
|
|
type: "damage",
|
|
sourceId: attacker.id,
|
|
targetId: target.id,
|
|
amount,
|
|
health: target.health,
|
|
critical,
|
|
});
|
|
|
|
if (target.health > 0) return;
|
|
target.alive = false;
|
|
target.deaths += 1;
|
|
target.respawnTicks = respawnDelayTicks;
|
|
target.velocityX = 0;
|
|
target.velocityZ = 0;
|
|
target.inputForward = 0;
|
|
target.inputStrafe = 0;
|
|
target.firing = false;
|
|
attacker.kills += 1;
|
|
emit({
|
|
id: nextEventId(state),
|
|
type: "elimination",
|
|
killerId: attacker.id,
|
|
victimId: target.id,
|
|
weapon: attacker.weapon,
|
|
critical,
|
|
});
|
|
}
|
|
|
|
function startReload(player: PlayerState): void {
|
|
const definition = WEAPONS[player.weapon];
|
|
const ammo = player.ammo[player.weapon];
|
|
if (
|
|
player.reloadTicks === 0 &&
|
|
ammo.magazine < definition.magazineSize &&
|
|
ammo.reserve > 0
|
|
) {
|
|
player.reloadTicks = definition.reloadTicks;
|
|
}
|
|
}
|
|
|
|
function finishReload(player: PlayerState): void {
|
|
const definition = WEAPONS[player.weapon];
|
|
const ammo = player.ammo[player.weapon];
|
|
const needed = definition.magazineSize - ammo.magazine;
|
|
const moved = Math.min(needed, ammo.reserve);
|
|
ammo.magazine += moved;
|
|
ammo.reserve -= moved;
|
|
}
|
|
|
|
function respawnPlayer(state: AuthoritativeShooterState, player: AuthoritativePlayer): void {
|
|
const spawn = safestSpawn(state, player.id);
|
|
player.alive = true;
|
|
player.x = spawn.x;
|
|
player.z = spawn.z;
|
|
player.yaw = spawn.yaw;
|
|
player.pitch = 0;
|
|
player.health = 100;
|
|
player.armor = 25;
|
|
player.velocityX = 0;
|
|
player.velocityZ = 0;
|
|
player.inputForward = 0;
|
|
player.inputStrafe = 0;
|
|
player.firing = false;
|
|
player.sprinting = false;
|
|
player.cooldownTicks = 0;
|
|
player.reloadTicks = 0;
|
|
player.respawnTicks = 0;
|
|
player.spawnProtectionTicks = spawnProtectionTicks;
|
|
player.lastFirePressed = false;
|
|
player.lastReloadPressed = false;
|
|
for (const weapon of [Weapon.PulseRifle, Weapon.Scattergun, Weapon.RailRifle] as const) {
|
|
const ammo = player.ammo[weapon];
|
|
ammo.magazine = WEAPONS[weapon].magazineSize;
|
|
ammo.reserve = Math.max(ammo.reserve, Math.ceil(WEAPONS[weapon].startingReserve / 2));
|
|
}
|
|
}
|
|
|
|
function collectNearbyPickup(
|
|
state: AuthoritativeShooterState,
|
|
player: AuthoritativePlayer,
|
|
emit: (event: ShooterAuthorityEvent) => void,
|
|
): void {
|
|
for (const pickup of state.pickups.values()) {
|
|
if (!pickup.active || Math.hypot(player.x - pickup.x, player.z - pickup.z) > 0.9) {
|
|
continue;
|
|
}
|
|
const amount = applyPickup(player, pickup);
|
|
if (amount <= 0) continue;
|
|
pickup.active = false;
|
|
pickup.respawnTicks = pickupRespawnTicks;
|
|
emit({
|
|
id: nextEventId(state),
|
|
type: "pickup",
|
|
playerId: player.id,
|
|
pickup: pickup.kind,
|
|
weapon: pickup.weapon,
|
|
amount,
|
|
});
|
|
}
|
|
}
|
|
|
|
function applyPickup(player: AuthoritativePlayer, pickup: PickupState): number {
|
|
if (pickup.kind === "health") {
|
|
const gained = Math.min(45, 100 - player.health);
|
|
player.health += gained;
|
|
return gained;
|
|
}
|
|
if (pickup.kind === "ammo") {
|
|
let gained = 0;
|
|
for (const weapon of [Weapon.PulseRifle, Weapon.Scattergun, Weapon.RailRifle] as const) {
|
|
const amount = weapon === Weapon.PulseRifle ? 30 : weapon === Weapon.Scattergun ? 8 : 4;
|
|
player.ammo[weapon].reserve += amount;
|
|
gained += amount;
|
|
}
|
|
return gained;
|
|
}
|
|
const definition = WEAPONS[pickup.weapon];
|
|
player.weapon = pickup.weapon;
|
|
player.ammo[pickup.weapon].reserve += definition.magazineSize * 2;
|
|
return definition.magazineSize * 2;
|
|
}
|
|
|
|
function updateBot(
|
|
state: AuthoritativeShooterState,
|
|
bot: AuthoritativePlayer,
|
|
tick: number,
|
|
): void {
|
|
const mind = bot.bot!;
|
|
if (tick >= mind.nextThinkTick) {
|
|
mind.nextThinkTick = tick + 7 + (bot.id % 4);
|
|
let target: AuthoritativePlayer | null = null;
|
|
let bestDistance = Infinity;
|
|
for (const candidate of state.players.values()) {
|
|
if (candidate.id === bot.id || !candidate.alive) continue;
|
|
const distance = Math.hypot(candidate.x - bot.x, candidate.z - bot.z);
|
|
if (
|
|
distance < bestDistance &&
|
|
distance <= visibilityRange &&
|
|
hasLineOfSight(bot.x, bot.z, candidate.x, candidate.z)
|
|
) {
|
|
target = candidate;
|
|
bestDistance = distance;
|
|
}
|
|
}
|
|
mind.targetId = target?.id ?? null;
|
|
if (signedNoise(bot.id, Math.floor(tick / 90)) > 0.65) mind.strafeSign *= -1;
|
|
}
|
|
|
|
const target = mind.targetId === null ? null : state.players.get(mind.targetId);
|
|
if (target?.alive && hasLineOfSight(bot.x, bot.z, target.x, target.z)) {
|
|
const dx = target.x - bot.x;
|
|
const dz = target.z - bot.z;
|
|
const distance = Math.hypot(dx, dz);
|
|
const aimNoise = signedNoise(bot.id, Math.floor(tick / 16)) * 0.045;
|
|
const desiredYaw = Math.atan2(dx, -dz) + aimNoise;
|
|
bot.yaw = rotateToward(bot.yaw, desiredYaw, 0.095);
|
|
bot.pitch = rotateToward(bot.pitch, -0.035 + aimNoise * 0.25, 0.035);
|
|
bot.weapon = distance < 8.5 ? Weapon.Scattergun : distance > 19 ? Weapon.RailRifle : Weapon.PulseRifle;
|
|
if (bot.ammo[bot.weapon].magazine === 0) startReload(bot);
|
|
bot.firing = Math.abs(normalizeAngle(desiredYaw - bot.yaw)) < 0.19;
|
|
bot.inputStrafe = mind.strafeSign * (distance < 15 ? 0.72 : 0.25);
|
|
bot.inputForward = distance > 11 ? 0.72 : distance < 5 ? -0.55 : 0.12;
|
|
bot.sprinting = false;
|
|
return;
|
|
}
|
|
|
|
mind.targetId = null;
|
|
const patrol = BOT_PATROL_POINTS[mind.patrolIndex % BOT_PATROL_POINTS.length]!;
|
|
let dx = patrol.x - bot.x;
|
|
let dz = patrol.z - bot.z;
|
|
if (Math.hypot(dx, dz) < 1.15) {
|
|
mind.patrolIndex = (mind.patrolIndex + 1 + (bot.id % 3)) % BOT_PATROL_POINTS.length;
|
|
const next = BOT_PATROL_POINTS[mind.patrolIndex]!;
|
|
dx = next.x - bot.x;
|
|
dz = next.z - bot.z;
|
|
}
|
|
let desiredYaw = Math.atan2(dx, -dz);
|
|
const lookAheadX = bot.x + Math.sin(desiredYaw) * 1.1;
|
|
const lookAheadZ = bot.z - Math.cos(desiredYaw) * 1.1;
|
|
if (collidesWithArena(lookAheadX, lookAheadZ, 0.55)) {
|
|
desiredYaw += mind.strafeSign * Math.PI * 0.48;
|
|
}
|
|
bot.yaw = rotateToward(bot.yaw, desiredYaw, 0.08);
|
|
bot.pitch = rotateToward(bot.pitch, 0, 0.03);
|
|
bot.inputForward = 1;
|
|
bot.inputStrafe = 0;
|
|
bot.firing = false;
|
|
bot.sprinting = true;
|
|
}
|
|
|
|
function perceiveEvent(
|
|
state: AuthoritativeShooterState,
|
|
event: ShooterAuthorityEvent,
|
|
viewerId: number,
|
|
): ShooterPerception | null {
|
|
const viewer = state.players.get(viewerId);
|
|
if (!viewer) return null;
|
|
|
|
if (event.type === "elimination") {
|
|
return { ...event };
|
|
}
|
|
if (event.type === "damage") {
|
|
if (event.sourceId === viewerId) {
|
|
return {
|
|
id: event.id,
|
|
type: "hit",
|
|
amount: event.amount,
|
|
eliminated: event.health === 0,
|
|
critical: event.critical,
|
|
};
|
|
}
|
|
if (event.targetId !== viewerId) return null;
|
|
const source = state.players.get(event.sourceId);
|
|
const bearing = source
|
|
? normalizeAngle(Math.atan2(source.x - viewer.x, -(source.z - viewer.z)) - viewer.yaw)
|
|
: 0;
|
|
return {
|
|
id: event.id,
|
|
type: "damage",
|
|
amount: event.amount,
|
|
health: event.health,
|
|
bearingRadians: bearing,
|
|
critical: event.critical,
|
|
};
|
|
}
|
|
if (event.type === "pickup") {
|
|
if (event.playerId !== viewerId) return null;
|
|
return {
|
|
id: event.id,
|
|
type: "pickup",
|
|
pickup: event.pickup,
|
|
weapon: event.weapon,
|
|
amount: event.amount,
|
|
};
|
|
}
|
|
if (event.type === "respawn") {
|
|
return event.playerId === viewerId ? { id: event.id, type: "respawn" } : null;
|
|
}
|
|
|
|
const sourceId = event.type === "shot" ? event.sourceId : event.sourceId;
|
|
const source = state.players.get(sourceId);
|
|
if (!source) return null;
|
|
const distance = Math.hypot(source.x - viewer.x, source.z - viewer.z);
|
|
const bearing = normalizeAngle(
|
|
Math.atan2(source.x - viewer.x, -(source.z - viewer.z)) - viewer.yaw,
|
|
);
|
|
|
|
if (event.type === "shot") {
|
|
if (source.id === viewerId) {
|
|
return {
|
|
id: event.id,
|
|
type: "shot",
|
|
weapon: event.weapon,
|
|
sourceId: event.sourceId,
|
|
originX: event.originX,
|
|
originY: event.originY,
|
|
originZ: event.originZ,
|
|
endX: event.endX,
|
|
endY: event.endY,
|
|
endZ: event.endZ,
|
|
impact: event.impact,
|
|
};
|
|
}
|
|
if (distance <= gunshotHearingRange) {
|
|
const occlusion = hasLineOfSight(viewer.x, viewer.z, source.x, source.z)
|
|
? 1
|
|
: 0.48;
|
|
return {
|
|
id: event.id,
|
|
type: "sound",
|
|
sound: "gunshot",
|
|
weapon: event.weapon,
|
|
bearingRadians: bearing,
|
|
intensity:
|
|
Math.max(0.08, 1 - distance / gunshotHearingRange) * occlusion,
|
|
distanceBand: distanceBand(distance, gunshotHearingRange),
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (distance > footstepHearingRange || source.id === viewerId) return null;
|
|
const occlusion = hasLineOfSight(viewer.x, viewer.z, source.x, source.z)
|
|
? 1
|
|
: 0.48;
|
|
return {
|
|
id: event.id,
|
|
type: "sound",
|
|
sound: "footstep",
|
|
weapon: Weapon.PulseRifle,
|
|
bearingRadians: bearing,
|
|
intensity: Math.max(0.05, 1 - distance / footstepHearingRange) * occlusion,
|
|
distanceBand: distanceBand(distance, footstepHearingRange),
|
|
};
|
|
}
|
|
|
|
function canViewerSee(viewer: AuthoritativePlayer, target: AuthoritativePlayer): boolean {
|
|
if (!target.alive) return false;
|
|
const dx = target.x - viewer.x;
|
|
const dz = target.z - viewer.z;
|
|
const distance = Math.hypot(dx, dz);
|
|
if (distance > visibilityRange) return false;
|
|
const angle = normalizeAngle(Math.atan2(dx, -dz) - viewer.yaw);
|
|
if (distance > 3.2 && Math.abs(angle) > fieldOfViewRadians) return false;
|
|
return hasLineOfSight(viewer.x, viewer.z, target.x, target.z);
|
|
}
|
|
|
|
function safestSpawn(state: AuthoritativeShooterState, playerId: number) {
|
|
let best = ARENA_SPAWNS[playerId % ARENA_SPAWNS.length]!;
|
|
let bestScore = -Infinity;
|
|
for (let offset = 0; offset < ARENA_SPAWNS.length; offset += 1) {
|
|
const spawn = ARENA_SPAWNS[(playerId + offset) % ARENA_SPAWNS.length]!;
|
|
let closest = 100;
|
|
for (const player of state.players.values()) {
|
|
if (player.id === playerId || !player.alive) continue;
|
|
closest = Math.min(closest, Math.hypot(spawn.x - player.x, spawn.z - player.z));
|
|
}
|
|
if (closest > bestScore) {
|
|
best = spawn;
|
|
bestScore = closest;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function validateAuthoritativeState(state: AuthoritativeShooterState): boolean {
|
|
return (
|
|
state.players.size <= 128 &&
|
|
state.pickups.size <= 64 &&
|
|
[...state.players.values()].every(
|
|
(player) => finitePlayer(player) && !collidesWithArena(player.x, player.z, 0.39),
|
|
)
|
|
);
|
|
}
|
|
|
|
function validateClientWorld(state: ShooterWorldState): boolean {
|
|
return (
|
|
state.players.size <= 128 &&
|
|
state.pickups.size <= 64 &&
|
|
state.scoreboard.length <= 128 &&
|
|
[...state.players.values()].every(finitePlayer)
|
|
);
|
|
}
|
|
|
|
function finitePlayer(player: PlayerState): boolean {
|
|
return (
|
|
Number.isFinite(player.x) &&
|
|
Number.isFinite(player.z) &&
|
|
Number.isFinite(player.velocityX) &&
|
|
Number.isFinite(player.velocityZ) &&
|
|
Number.isFinite(player.yaw) &&
|
|
Number.isFinite(player.pitch) &&
|
|
player.health >= 0 &&
|
|
player.health <= 100 &&
|
|
player.armor >= 0 &&
|
|
player.armor <= 100
|
|
);
|
|
}
|
|
|
|
function nextEventId(state: AuthoritativeShooterState): number {
|
|
const id = state.nextEventId;
|
|
state.nextEventId = (state.nextEventId + 1) >>> 0;
|
|
return id;
|
|
}
|
|
|
|
function directionFromAngles(yaw: number, pitch: number) {
|
|
const horizontal = Math.cos(pitch);
|
|
return {
|
|
x: Math.sin(yaw) * horizontal,
|
|
y: Math.sin(pitch),
|
|
z: -Math.cos(yaw) * horizontal,
|
|
};
|
|
}
|
|
|
|
function signedNoise(a: number, b: number): number {
|
|
let value = (a * 374_761_393 + b * 668_265_263) >>> 0;
|
|
value = ((value ^ (value >>> 13)) * 1_274_126_177) >>> 0;
|
|
return ((value ^ (value >>> 16)) / 4_294_967_295) * 2 - 1;
|
|
}
|
|
|
|
function distanceBand(distance: number, range: number): DistanceBand {
|
|
return distance < range / 3 ? "near" : distance < (range * 2) / 3 ? "medium" : "far";
|
|
}
|
|
|
|
function minimumNullable(left: number | null, right: number | null): number | null {
|
|
if (left === null) return right;
|
|
if (right === null) return left;
|
|
return Math.min(left, right);
|
|
}
|
|
|
|
function approach(value: number, target: number, amount: number): number {
|
|
return value < target ? Math.min(target, value + amount) : Math.max(target, value - amount);
|
|
}
|
|
|
|
function rotateToward(value: number, target: number, amount: number): number {
|
|
return normalizeAngle(value + clamp(normalizeAngle(target - value), -amount, 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));
|
|
}
|