This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Syncer
|
||||
|
||||
Four playable browser games built on one generic TypeScript multiplayer higher-order configuration. The engine runs authoritative servers, predicted clients, clocks, reconciliation, validation, privacy-aware replication, pluggable physics, and bandwidth-budgeted interest management around game-supplied rules.
|
||||
Five playable browser games built on one generic TypeScript multiplayer higher-order configuration. The engine runs authoritative servers, predicted clients, clocks, reconciliation, validation, privacy-aware replication, pluggable physics, and bandwidth-budgeted interest management around game-supplied rules.
|
||||
|
||||
## Structure
|
||||
|
||||
@@ -10,7 +10,7 @@ apps/
|
||||
web/ React + Vite predicted client
|
||||
packages/
|
||||
engine/ Generic clocks, authority, prediction, validation, and protocol
|
||||
shared/ Game definitions: Arena, Flux Relay, Royale, and Bad Movers
|
||||
shared/ Game definitions: Arena, Flux Relay, Royale, Bad Movers, and DEAD AIR
|
||||
```
|
||||
|
||||
## Play
|
||||
@@ -20,14 +20,15 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:5173`. Use the selector to switch between four games:
|
||||
Open `http://localhost:5173`. Use the selector to switch between five games:
|
||||
|
||||
- **Sync Arena** — click **Deploy**, then use `WASD`, `Shift`, mouse look, left click, `R`, and weapon keys `1`–`3`.
|
||||
- **Flux Relay** — hold `Space` or the on-screen thruster to pull the shared core toward your team's gate while managing private energy.
|
||||
- **Syncer Royale** — click **Drop In**, then use `WASD`, `Shift`, mouse look, left click, and `R`. Survive 31 server bots, loot automatically, and stay inside the shrinking circle on a streamed 2 km island. Open it directly at `http://localhost:5173/#royale`.
|
||||
- **Bad Movers** — use `WASD`, `Shift`, hold `Space` to grab, `E` to throw, and `F` to close your truck. Real Box3D rigid bodies run in WebAssembly on both the authority and predicted client. Open it directly at `http://localhost:5173/#movers`.
|
||||
- **DEAD AIR** — use `WASD`, mouse look, left click, `E`, `F`, `Q`, and `R` to steal a screaming cursed microwave from five authority wardens. Hidden enemies are omitted from snapshots; only anonymous 8-way, 3-distance-band sound cues cross the wire. Open it directly at `http://localhost:5173/#dead-air`.
|
||||
|
||||
The arena includes pickups, armor, three weapons, headshots, reloads, respawns, a scoreboard, filtered spatial sound, and killcams; its server bots are disabled for player-only office matches. Flux Relay proves the API is not shooter-specific. Royale proves it can drive a much larger generated world. Bad Movers proves a developer can attach a third-party deterministic physics backend while keeping plain serializable state, authority, prediction, reconciliation, and transport generic.
|
||||
The arena includes pickups, armor, three weapons, headshots, reloads, respawns, a scoreboard, filtered spatial sound, and killcams; its server bots are disabled for player-only office matches. Flux Relay proves the API is not shooter-specific. Royale proves it can drive a much larger generated world. Bad Movers proves a developer can attach a third-party deterministic physics backend while keeping plain serializable state, authority, prediction, reconciliation, and transport generic. DEAD AIR proves secure multimodal perception: exact visible entities and deliberately lossy audio-only knowledge share one generic authority boundary without leaking hidden coordinates or identities.
|
||||
|
||||
## Define a game
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
API_PATHS,
|
||||
DEAD_AIR_SOCKET_PATH,
|
||||
FLUX_SOCKET_PATH,
|
||||
GAME_SOCKET_PATH,
|
||||
MOVERS_SOCKET_PATH,
|
||||
ROYALE_SOCKET_PATH,
|
||||
createShooterGame,
|
||||
deadAirGame,
|
||||
fluxGame,
|
||||
moversGame,
|
||||
royaleGame,
|
||||
@@ -24,6 +26,7 @@ const clientProfiles = new ClientProfileStore();
|
||||
const staticSite = loadStaticSite(process.env.STATIC_ROOT);
|
||||
const gameLoops = [
|
||||
hostNetworkedGame(app, GAME_SOCKET_PATH, createShooterGame({ botCount: 0 })),
|
||||
hostNetworkedGame(app, DEAD_AIR_SOCKET_PATH, deadAirGame),
|
||||
hostNetworkedGame(app, FLUX_SOCKET_PATH, fluxGame),
|
||||
hostNetworkedGame(app, MOVERS_SOCKET_PATH, moversGame),
|
||||
hostNetworkedGame(app, ROYALE_SOCKET_PATH, royaleGame),
|
||||
|
||||
@@ -6,14 +6,17 @@ import {
|
||||
} from "@syncer/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Arena3D } from "./Arena3D.js";
|
||||
import { DeadAirGame } from "./DeadAirGame.js";
|
||||
import { FluxGame } from "./FluxGame.js";
|
||||
import { MoversGame } from "./MoversGame.js";
|
||||
import { RoyaleGame } from "./RoyaleGame.js";
|
||||
import { useGameClient } from "./useGameClient.js";
|
||||
|
||||
export function App() {
|
||||
const [demo, setDemo] = useState<"arena" | "flux" | "royale" | "movers">(() =>
|
||||
window.location.hash === "#movers"
|
||||
const [demo, setDemo] = useState<"arena" | "flux" | "royale" | "movers" | "dead-air">(() =>
|
||||
window.location.hash === "#dead-air"
|
||||
? "dead-air"
|
||||
: window.location.hash === "#movers"
|
||||
? "movers"
|
||||
: window.location.hash === "#royale"
|
||||
? "royale"
|
||||
@@ -28,7 +31,7 @@ export function App() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{demo === "arena" ? <ShooterGame /> : demo === "flux" ? <FluxGame /> : demo === "royale" ? <RoyaleGame /> : <MoversGame />}
|
||||
{demo === "arena" ? <ShooterGame /> : demo === "flux" ? <FluxGame /> : demo === "royale" ? <RoyaleGame /> : demo === "movers" ? <MoversGame /> : <DeadAirGame />}
|
||||
<nav className="game-switcher" aria-label="Example game selector">
|
||||
<button className={demo === "arena" ? "is-active" : ""} onClick={() => setDemo("arena")} type="button">
|
||||
ARENA
|
||||
@@ -42,6 +45,9 @@ export function App() {
|
||||
<button className={demo === "movers" ? "is-active" : ""} onClick={() => setDemo("movers")} type="button">
|
||||
BAD MOVERS
|
||||
</button>
|
||||
<button className={demo === "dead-air" ? "is-active" : ""} onClick={() => setDemo("dead-air")} type="button">
|
||||
DEAD AIR
|
||||
</button>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
|
||||
478
apps/web/src/DeadAir3D.tsx
Normal file
478
apps/web/src/DeadAir3D.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
import { useEffect, useRef, useState, type MutableRefObject } from "react";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
DEAD_AIR_EXTRACTION,
|
||||
DEAD_AIR_MAP_SIZE,
|
||||
DEAD_AIR_POWER_SWITCH,
|
||||
DEAD_AIR_TICK_RATE,
|
||||
DEAD_AIR_WALLS,
|
||||
type DeadAirClientState,
|
||||
type DeadAirPlayerView,
|
||||
} from "@syncer/shared";
|
||||
import type { DeadAirRenderSource } from "./useDeadAirClient.js";
|
||||
|
||||
interface DeadAir3DProps {
|
||||
source: DeadAirRenderSource;
|
||||
playerId: number | null;
|
||||
unlockAudio(): void;
|
||||
}
|
||||
|
||||
interface PlayerMesh extends THREE.Group {
|
||||
userData: {
|
||||
lamp?: THREE.PointLight;
|
||||
beam?: THREE.SpotLight;
|
||||
};
|
||||
}
|
||||
|
||||
interface TemporaryEffect {
|
||||
object: THREE.Object3D;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface DeadAirRuntime {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
scene: THREE.Scene;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
ambient: THREE.HemisphereLight;
|
||||
poweredLights: THREE.PointLight[];
|
||||
flashlight: THREE.SpotLight;
|
||||
flashlightLens: THREE.Mesh;
|
||||
weapon: THREE.Group;
|
||||
muzzle: THREE.PointLight;
|
||||
players: Map<number, PlayerMesh>;
|
||||
artifact: THREE.Group;
|
||||
effects: TemporaryEffect[];
|
||||
resizeObserver: ResizeObserver;
|
||||
animationFrame: number;
|
||||
}
|
||||
|
||||
export function DeadAir3D({ source, playerId, unlockAudio }: DeadAir3DProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const playerIdRef = useRef(playerId);
|
||||
const sourceRef = useRef(source);
|
||||
const runtimeRef = useRef<DeadAirRuntime | null>(null);
|
||||
const lastEventIdRef = useRef(0);
|
||||
const [locked, setLocked] = useState(false);
|
||||
playerIdRef.current = playerId;
|
||||
sourceRef.current = source;
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 0.82;
|
||||
host.append(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x010304);
|
||||
scene.fog = new THREE.FogExp2(0x020506, 0.026);
|
||||
const camera = new THREE.PerspectiveCamera(76, 1, 0.035, 110);
|
||||
camera.rotation.order = "YXZ";
|
||||
scene.add(camera);
|
||||
|
||||
const ambient = new THREE.HemisphereLight(0x68828a, 0x050506, 0.16);
|
||||
scene.add(ambient);
|
||||
const poweredLights = buildLevel(scene);
|
||||
const artifact = buildArtifact();
|
||||
artifact.visible = false;
|
||||
scene.add(artifact);
|
||||
|
||||
const flashlightTarget = new THREE.Object3D();
|
||||
flashlightTarget.position.set(0, -0.08, -12);
|
||||
camera.add(flashlightTarget);
|
||||
const flashlight = new THREE.SpotLight(0xdffaff, 112, 31, 0.42, 0.42, 1.5);
|
||||
flashlight.position.set(0.12, -0.05, -0.12);
|
||||
flashlight.target = flashlightTarget;
|
||||
flashlight.castShadow = true;
|
||||
flashlight.shadow.mapSize.set(512, 512);
|
||||
flashlight.shadow.bias = -0.0005;
|
||||
camera.add(flashlight);
|
||||
|
||||
const weapon = buildWeapon();
|
||||
camera.add(weapon);
|
||||
const flashlightLens = weapon.getObjectByName("flashlight-lens") as THREE.Mesh;
|
||||
const muzzle = new THREE.PointLight(0xffd19b, 0, 5, 2);
|
||||
muzzle.position.set(0.33, -0.2, -1.05);
|
||||
camera.add(muzzle);
|
||||
|
||||
const runtime: DeadAirRuntime = {
|
||||
renderer,
|
||||
scene,
|
||||
camera,
|
||||
ambient,
|
||||
poweredLights,
|
||||
flashlight,
|
||||
flashlightLens,
|
||||
weapon,
|
||||
muzzle,
|
||||
players: new Map(),
|
||||
artifact,
|
||||
effects: [],
|
||||
resizeObserver: new ResizeObserver(() => resize(runtime, host)),
|
||||
animationFrame: 0,
|
||||
};
|
||||
runtimeRef.current = runtime;
|
||||
runtime.resizeObserver.observe(host);
|
||||
resize(runtime, host);
|
||||
|
||||
const animate = (time: number) => {
|
||||
updateRuntime(runtime, sourceRef.current, playerIdRef.current, lastEventIdRef, time);
|
||||
renderer.render(scene, camera);
|
||||
runtime.animationFrame = window.requestAnimationFrame(animate);
|
||||
};
|
||||
runtime.animationFrame = window.requestAnimationFrame(animate);
|
||||
const pointerLockChanged = () => setLocked(document.pointerLockElement === renderer.domElement);
|
||||
document.addEventListener("pointerlockchange", pointerLockChanged);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("pointerlockchange", pointerLockChanged);
|
||||
runtime.resizeObserver.disconnect();
|
||||
window.cancelAnimationFrame(runtime.animationFrame);
|
||||
scene.traverse(disposeObject);
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
runtimeRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const enter = () => {
|
||||
unlockAudio();
|
||||
const canvas = runtimeRef.current?.renderer.domElement;
|
||||
if (canvas) void canvas.requestPointerLock().catch(() => setLocked(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dead-air-viewport" ref={hostRef}>
|
||||
{!locked ? (
|
||||
<button className="dead-air-enter" type="button" onClick={enter}>
|
||||
<small>HEADPHONES STRONGLY RECOMMENDED</small>
|
||||
<strong>ENTER THE DEAD AIR</strong>
|
||||
<span>STEAL THE SCREAMING MICROWAVE // TRUST YOUR EARS</span>
|
||||
<em>WASD · MOUSE · CLICK FIRE · E INTERACT · F LIGHT · Q DECOY · R RELOAD</em>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function updateRuntime(
|
||||
runtime: DeadAirRuntime,
|
||||
source: DeadAirRenderSource,
|
||||
playerId: number | null,
|
||||
lastEventId: MutableRefObject<number>,
|
||||
time: number,
|
||||
): void {
|
||||
const frame = source.current;
|
||||
const world = frame.state;
|
||||
const local = world.players.find((player) => player.id === playerId);
|
||||
const fractionalSeconds = frame.interpolationAlpha / DEAD_AIR_TICK_RATE;
|
||||
if (local) {
|
||||
const correctionLife = Math.max(0, 1 - (time - frame.localCorrection.updatedAt) / 130);
|
||||
runtime.camera.position.set(
|
||||
local.x + local.velocityX * fractionalSeconds + frame.localCorrection.x * correctionLife,
|
||||
1.62,
|
||||
local.z + local.velocityZ * fractionalSeconds + frame.localCorrection.z * correctionLife,
|
||||
);
|
||||
runtime.camera.rotation.set(local.pitch, Math.PI + local.yaw, 0);
|
||||
runtime.flashlight.intensity = local.alive && local.flashlight ? 112 : 0;
|
||||
runtime.flashlightLens.visible = local.flashlight;
|
||||
runtime.weapon.visible = local.alive;
|
||||
const speed = Math.hypot(local.velocityX, local.velocityZ);
|
||||
runtime.weapon.position.x = 0.31 + Math.cos(time * 0.008) * Math.min(0.012, speed * 0.0018);
|
||||
runtime.weapon.position.y = -0.26 + Math.sin(time * 0.015) * Math.min(0.025, speed * 0.0035);
|
||||
} else {
|
||||
runtime.flashlight.intensity = 0;
|
||||
runtime.weapon.visible = false;
|
||||
}
|
||||
|
||||
runtime.ambient.intensity = world.powerOn ? 0.18 : 0.025;
|
||||
for (const light of runtime.poweredLights) {
|
||||
light.intensity = world.powerOn ? 3.4 + Math.sin(time * 0.003 + light.position.x) * 0.22 : 0;
|
||||
}
|
||||
updatePlayers(runtime, world, playerId, fractionalSeconds);
|
||||
updateArtifact(runtime, world, time);
|
||||
updateEffects(runtime, world, lastEventId, time);
|
||||
}
|
||||
|
||||
function buildLevel(scene: THREE.Scene): THREE.PointLight[] {
|
||||
const floor = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(DEAD_AIR_MAP_SIZE, DEAD_AIR_MAP_SIZE),
|
||||
new THREE.MeshStandardMaterial({ color: 0x101619, roughness: 0.91, metalness: 0.08 }),
|
||||
);
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
floor.receiveShadow = true;
|
||||
scene.add(floor);
|
||||
|
||||
const grid = new THREE.GridHelper(DEAD_AIR_MAP_SIZE, 36, 0x26343a, 0x182125);
|
||||
grid.position.y = 0.012;
|
||||
const gridMaterials = Array.isArray(grid.material) ? grid.material : [grid.material];
|
||||
for (const material of gridMaterials) {
|
||||
material.transparent = true;
|
||||
material.opacity = 0.21;
|
||||
}
|
||||
scene.add(grid);
|
||||
|
||||
for (const wall of DEAD_AIR_WALLS) {
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(wall.width, wall.height, wall.depth),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: wall.id.includes("vault") ? 0x253036 : 0x172126,
|
||||
roughness: 0.78,
|
||||
metalness: wall.id.includes("vault") ? 0.55 : 0.22,
|
||||
}),
|
||||
);
|
||||
mesh.position.set(wall.x, wall.height / 2, wall.z);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
scene.add(mesh);
|
||||
if (!wall.id.includes("north") && !wall.id.includes("south") && wall.width > 4) {
|
||||
const stripe = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(Math.max(0.2, wall.width - 0.08), 0.12, wall.depth + 0.025),
|
||||
new THREE.MeshStandardMaterial({ color: 0x5c4d2d, emissive: 0x1f1705, emissiveIntensity: 0.2 }),
|
||||
);
|
||||
stripe.position.set(wall.x, 1.05, wall.z);
|
||||
scene.add(stripe);
|
||||
}
|
||||
}
|
||||
|
||||
const extraction = new THREE.Mesh(
|
||||
new THREE.RingGeometry(DEAD_AIR_EXTRACTION.radius - 0.18, DEAD_AIR_EXTRACTION.radius, 48),
|
||||
new THREE.MeshBasicMaterial({ color: 0x5be6bf, transparent: true, opacity: 0.48, side: THREE.DoubleSide }),
|
||||
);
|
||||
extraction.rotation.x = -Math.PI / 2;
|
||||
extraction.position.set(DEAD_AIR_EXTRACTION.x, 0.045, DEAD_AIR_EXTRACTION.z);
|
||||
scene.add(extraction);
|
||||
const extractionLight = new THREE.PointLight(0x3de0b1, 2.4, 11, 2);
|
||||
extractionLight.position.set(DEAD_AIR_EXTRACTION.x, 1.1, DEAD_AIR_EXTRACTION.z);
|
||||
scene.add(extractionLight);
|
||||
|
||||
const powerBox = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.8, 1.4, 0.5),
|
||||
new THREE.MeshStandardMaterial({ color: 0x36434a, emissive: 0x601508, emissiveIntensity: 0.7, metalness: 0.65 }),
|
||||
);
|
||||
powerBox.position.set(DEAD_AIR_POWER_SWITCH.x, 0.9, DEAD_AIR_POWER_SWITCH.z);
|
||||
scene.add(powerBox);
|
||||
|
||||
const cratePositions: ReadonlyArray<readonly [number, number]> = [
|
||||
[-29, -29], [0, -27], [28, -28], [-28, 8], [0, 13], [28, 8], [-27, 27], [27, 27],
|
||||
];
|
||||
for (const [index, position] of cratePositions.entries()) {
|
||||
const crate = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(index % 2 ? 2.2 : 1.7, 0.18, index % 3 ? 1.5 : 2.1),
|
||||
new THREE.MeshStandardMaterial({ color: 0x302a22, roughness: 0.9, metalness: 0.12 }),
|
||||
);
|
||||
crate.position.set(position[0], 0.09, position[1]);
|
||||
crate.rotation.y = index * 0.37;
|
||||
crate.castShadow = true;
|
||||
crate.receiveShadow = true;
|
||||
scene.add(crate);
|
||||
}
|
||||
|
||||
const poweredLights: THREE.PointLight[] = [];
|
||||
const ceilingLightPositions: ReadonlyArray<readonly [number, number]> = [
|
||||
[-25, -10], [0, -14], [25, -10], [-25, 23], [1, 2], [25, 23],
|
||||
];
|
||||
for (const position of ceilingLightPositions) {
|
||||
const fixture = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(2.6, 0.08, 0.32),
|
||||
new THREE.MeshBasicMaterial({ color: 0xbad8dc }),
|
||||
);
|
||||
fixture.position.set(position[0], 4.1, position[1]);
|
||||
scene.add(fixture);
|
||||
const light = new THREE.PointLight(0xb8e4e9, 3.4, 16, 1.8);
|
||||
light.position.set(position[0], 3.85, position[1]);
|
||||
scene.add(light);
|
||||
poweredLights.push(light);
|
||||
}
|
||||
|
||||
const emergencyLightPositions: ReadonlyArray<readonly [number, number]> = [
|
||||
[-34, -4], [34, -4], [-2, -34], [-2, 34],
|
||||
];
|
||||
for (const position of emergencyLightPositions) {
|
||||
const emergency = new THREE.PointLight(0xff2f1c, 1.7, 9, 2);
|
||||
emergency.position.set(position[0], 2.3, position[1]);
|
||||
scene.add(emergency);
|
||||
}
|
||||
return poweredLights;
|
||||
}
|
||||
|
||||
function buildWeapon(): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(0.31, -0.26, -0.65);
|
||||
const body = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.19, 0.2, 0.9),
|
||||
new THREE.MeshStandardMaterial({ color: 0x151c20, roughness: 0.34, metalness: 0.78 }),
|
||||
);
|
||||
body.position.z = -0.16;
|
||||
group.add(body);
|
||||
const canister = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.055, 0.055, 0.52, 10),
|
||||
new THREE.MeshStandardMaterial({ color: 0x6fd2c4, emissive: 0x173c38, emissiveIntensity: 0.7, metalness: 0.38 }),
|
||||
);
|
||||
canister.rotation.x = Math.PI / 2;
|
||||
canister.position.set(-0.12, 0.06, -0.25);
|
||||
group.add(canister);
|
||||
const lens = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.075, 0.075, 0.06, 12),
|
||||
new THREE.MeshBasicMaterial({ color: 0xdffaff }),
|
||||
);
|
||||
lens.name = "flashlight-lens";
|
||||
lens.rotation.x = Math.PI / 2;
|
||||
lens.position.set(0.13, -0.08, -0.59);
|
||||
group.add(lens);
|
||||
return group;
|
||||
}
|
||||
|
||||
function buildPlayer(player: DeadAirPlayerView): PlayerMesh {
|
||||
const group = new THREE.Group() as PlayerMesh;
|
||||
const color = player.bot ? 0x7d1714 : 0x1d5960;
|
||||
const body = new THREE.Mesh(
|
||||
new THREE.CapsuleGeometry(0.46, 0.86, 4, 8),
|
||||
new THREE.MeshStandardMaterial({ color, roughness: 0.54, metalness: 0.3 }),
|
||||
);
|
||||
body.position.y = 1.03;
|
||||
body.castShadow = true;
|
||||
group.add(body);
|
||||
const visor = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.56, 0.19, 0.08),
|
||||
new THREE.MeshStandardMaterial({ color: 0x090d0f, emissive: player.bot ? 0x710b07 : 0x083e44, emissiveIntensity: 1.2 }),
|
||||
);
|
||||
visor.position.set(0, 1.62, 0.37);
|
||||
group.add(visor);
|
||||
const lamp = new THREE.PointLight(player.bot ? 0xff4a37 : 0xa8f6ff, 0, 7, 2);
|
||||
lamp.position.set(0.2, 1.55, 0.42);
|
||||
group.add(lamp);
|
||||
const target = new THREE.Object3D();
|
||||
target.position.set(0, 1.35, 9);
|
||||
group.add(target);
|
||||
const beam = new THREE.SpotLight(player.bot ? 0xff7661 : 0xc9f8ff, 0, 19, 0.42, 0.55, 1.6);
|
||||
beam.position.set(0.2, 1.55, 0.3);
|
||||
beam.target = target;
|
||||
group.add(beam);
|
||||
group.userData = { lamp, beam };
|
||||
return group;
|
||||
}
|
||||
|
||||
function updatePlayers(
|
||||
runtime: DeadAirRuntime,
|
||||
world: Readonly<DeadAirClientState>,
|
||||
playerId: number | null,
|
||||
fractionalSeconds: number,
|
||||
): void {
|
||||
const visible = new Set(world.players.map((player) => player.id));
|
||||
for (const player of world.players) {
|
||||
if (player.id === playerId) continue;
|
||||
let group = runtime.players.get(player.id);
|
||||
const targetX = player.x + player.velocityX * fractionalSeconds;
|
||||
const targetZ = player.z + player.velocityZ * fractionalSeconds;
|
||||
if (!group) {
|
||||
group = buildPlayer(player);
|
||||
group.position.set(targetX, 0, targetZ);
|
||||
runtime.players.set(player.id, group);
|
||||
runtime.scene.add(group);
|
||||
} else {
|
||||
group.position.x += (targetX - group.position.x) * 0.34;
|
||||
group.position.z += (targetZ - group.position.z) * 0.34;
|
||||
}
|
||||
group.rotation.y = player.yaw;
|
||||
group.visible = player.alive;
|
||||
if (group.userData.lamp) group.userData.lamp.intensity = player.flashlight ? 3.6 : 0;
|
||||
if (group.userData.beam) group.userData.beam.intensity = player.flashlight ? 34 : 0;
|
||||
}
|
||||
for (const [id, group] of runtime.players) {
|
||||
if (visible.has(id)) continue;
|
||||
runtime.players.delete(id);
|
||||
runtime.scene.remove(group);
|
||||
group.traverse(disposeObject);
|
||||
}
|
||||
}
|
||||
|
||||
function buildArtifact(): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
const body = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(1.15, 0.75, 0.78),
|
||||
new THREE.MeshStandardMaterial({ color: 0xa78a31, roughness: 0.32, metalness: 0.82, emissive: 0x3c2300, emissiveIntensity: 0.6 }),
|
||||
);
|
||||
body.position.y = 0.58;
|
||||
body.castShadow = true;
|
||||
group.add(body);
|
||||
const door = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.78, 0.47, 0.035),
|
||||
new THREE.MeshStandardMaterial({ color: 0x15100b, emissive: 0xff3d0d, emissiveIntensity: 0.34, roughness: 0.18 }),
|
||||
);
|
||||
door.position.set(-0.12, 0.59, 0.405);
|
||||
group.add(door);
|
||||
const dial = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.075, 0.075, 0.06, 12),
|
||||
new THREE.MeshBasicMaterial({ color: 0xff8a35 }),
|
||||
);
|
||||
dial.rotation.x = Math.PI / 2;
|
||||
dial.position.set(0.42, 0.69, 0.43);
|
||||
group.add(dial);
|
||||
group.add(new THREE.PointLight(0xff4b17, 5, 8, 2));
|
||||
return group;
|
||||
}
|
||||
|
||||
function updateArtifact(runtime: DeadAirRuntime, world: Readonly<DeadAirClientState>, time: number): void {
|
||||
const artifact = world.artifact;
|
||||
runtime.artifact.visible = artifact.visible && artifact.x !== null && artifact.z !== null;
|
||||
if (!runtime.artifact.visible || artifact.x === null || artifact.z === null) return;
|
||||
runtime.artifact.position.set(artifact.x, 0.08 + Math.sin(time * 0.004) * 0.07, artifact.z);
|
||||
runtime.artifact.rotation.y = time * 0.00055;
|
||||
}
|
||||
|
||||
function updateEffects(
|
||||
runtime: DeadAirRuntime,
|
||||
world: Readonly<DeadAirClientState>,
|
||||
lastEventId: MutableRefObject<number>,
|
||||
time: number,
|
||||
): void {
|
||||
for (const entry of world.events) {
|
||||
if (entry.event.id <= lastEventId.current || entry.event.type !== "muzzle") continue;
|
||||
const event = entry.event;
|
||||
const direction = new THREE.Vector3(Math.sin(event.yaw), Math.sin(event.pitch), Math.cos(event.yaw)).normalize();
|
||||
const start = new THREE.Vector3(event.x, 1.45, event.z).addScaledVector(direction, 0.8);
|
||||
const end = start.clone().addScaledVector(direction, 27);
|
||||
const length = start.distanceTo(end);
|
||||
const tracer = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.018, 0.035, length, 5),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffc78b, transparent: true, opacity: 0.7 }),
|
||||
);
|
||||
tracer.position.copy(start).add(end).multiplyScalar(0.5);
|
||||
tracer.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction);
|
||||
runtime.scene.add(tracer);
|
||||
runtime.effects.push({ object: tracer, expiresAt: time + 95 });
|
||||
runtime.muzzle.intensity = 16;
|
||||
}
|
||||
lastEventId.current = world.events.reduce(
|
||||
(latest, entry) => Math.max(latest, entry.event.id),
|
||||
lastEventId.current,
|
||||
);
|
||||
runtime.muzzle.intensity *= 0.75;
|
||||
runtime.effects = runtime.effects.filter((effect) => {
|
||||
if (effect.expiresAt > time) return true;
|
||||
runtime.scene.remove(effect.object);
|
||||
effect.object.traverse(disposeObject);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function resize(runtime: DeadAirRuntime, host: HTMLDivElement): void {
|
||||
const width = Math.max(1, host.clientWidth);
|
||||
const height = Math.max(1, host.clientHeight);
|
||||
runtime.renderer.setSize(width, height, false);
|
||||
runtime.camera.aspect = width / height;
|
||||
runtime.camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
function disposeObject(object: THREE.Object3D): void {
|
||||
if (!(object instanceof THREE.Mesh || object instanceof THREE.Line)) return;
|
||||
object.geometry.dispose();
|
||||
const materials = Array.isArray(object.material) ? object.material : [object.material];
|
||||
for (const material of materials) material.dispose();
|
||||
}
|
||||
182
apps/web/src/DeadAirGame.tsx
Normal file
182
apps/web/src/DeadAirGame.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
DEAD_AIR_EXTRACTION,
|
||||
DEAD_AIR_POWER_SWITCH,
|
||||
DEAD_AIR_TICK_RATE,
|
||||
DEAD_AIR_WARDEN_ID_BASE,
|
||||
type DeadAirPerception,
|
||||
} from "@syncer/shared";
|
||||
import { DeadAir3D } from "./DeadAir3D.js";
|
||||
import { useDeadAirClient, type DeadAirAction } from "./useDeadAirClient.js";
|
||||
|
||||
export function DeadAirGame() {
|
||||
const client = useDeadAirClient();
|
||||
const local = client.world.players.find((player) => player.id === client.playerId);
|
||||
const recent = [...client.world.events].reverse();
|
||||
const sounds = recent
|
||||
.filter((entry) => entry.event.type === "sound" && client.tick - entry.receivedTick < 34)
|
||||
.slice(0, 7);
|
||||
const hit = recent.find((entry) => entry.event.type === "hit");
|
||||
const damage = recent.find((entry) => entry.event.type === "damage");
|
||||
const objective = recent.find((entry) => entry.event.type === "artifact");
|
||||
const recentHit = Boolean(hit && client.tick - hit.receivedTick < 12);
|
||||
const recentDamage = Boolean(damage && client.tick - damage.receivedTick < 20);
|
||||
const visibleWardens = client.world.players.filter((player) => player.bot && player.alive).length;
|
||||
const hint = interactionHint(client.world.artifact.visible, client.world.artifact.x, client.world.artifact.z, local);
|
||||
|
||||
const pulse = (action: DeadAirAction) => {
|
||||
client.unlockAudio();
|
||||
client.setAction(action, true);
|
||||
window.setTimeout(() => client.setAction(action, false), 80);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className={`dead-air-game${client.world.powerOn ? "" : " dead-air-game--blackout"}`}>
|
||||
<DeadAir3D source={client.renderSource} playerId={client.playerId} unlockAudio={client.unlockAudio} />
|
||||
<div className="dead-air-grade" aria-hidden="true" />
|
||||
<div className="dead-air-noise" aria-hidden="true" />
|
||||
{recentDamage ? <div className="dead-air-damage" aria-hidden="true" /> : null}
|
||||
|
||||
<header className="dead-air-header">
|
||||
<div className="dead-air-brand">
|
||||
<i>DA</i>
|
||||
<div><small>ACOUSTIC EXTRACTION PROTOCOL</small><strong>DEAD AIR</strong></div>
|
||||
</div>
|
||||
<section className="dead-air-objective">
|
||||
<small>ROUND {client.world.round} // OBJECTIVE</small>
|
||||
<strong>{objectiveText(local?.carryingArtifact ?? false, client.world.artifact.visible)}</strong>
|
||||
<span>{client.world.powerOn ? "FACILITY POWER ONLINE" : "BLACKOUT // FLASHLIGHTS ONLY"}</span>
|
||||
</section>
|
||||
<div className={`dead-air-live dead-air-live--${client.connection}`}>
|
||||
<i /><span>{client.connection}</span>
|
||||
<small>{client.network.roundTripTime.toFixed(0)}ms · {client.validation}</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="dead-air-security" aria-label="Secure replication status">
|
||||
<small>SECURE PERCEPTION</small>
|
||||
<div><span>VISIBLE ENTITIES</span><b>{visibleWardens}</b></div>
|
||||
<div><span>HIDDEN POSITIONS</span><b>REDACTED</b></div>
|
||||
<div><span>SOUND PACKETS</span><b>8-WAY / 3-BAND</b></div>
|
||||
<div><span>AUTHORITY TICK</span><b>{client.tick}</b></div>
|
||||
</section>
|
||||
|
||||
<section className="dead-air-scope" aria-label="Acoustic direction display">
|
||||
<div className={`dead-air-crosshair${recentHit ? " dead-air-crosshair--hit" : ""}`}><i /><i /><i /><i /></div>
|
||||
{sounds.map((entry, index) => {
|
||||
if (entry.event.type !== "sound") return null;
|
||||
return (
|
||||
<div
|
||||
className={`dead-air-bearing dead-air-bearing--${entry.event.distance}`}
|
||||
key={`${entry.event.id}:${index}`}
|
||||
style={{ transform: `translate(-50%, -50%) rotate(${entry.event.direction * 45}deg)` }}
|
||||
>
|
||||
<i style={{ opacity: 0.28 + entry.event.intensity * 0.22 }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="dead-air-listen" aria-live="polite">
|
||||
<small>ACOUSTIC CONTACTS</small>
|
||||
{sounds.length === 0 ? <span>THE BUILDING IS HOLDING ITS BREATH</span> : sounds.slice(0, 4).map((entry) => {
|
||||
if (entry.event.type !== "sound") return null;
|
||||
return <div key={entry.event.id}><b>{glyph(entry.event)}</b><span>{entry.event.cue}</span><em>{directionLabel(entry.event.direction)} · {entry.event.distance}</em></div>;
|
||||
})}
|
||||
</section>
|
||||
|
||||
{recentHit && hit?.event.type === "hit" ? (
|
||||
<div className="dead-air-hit">{hit.event.eliminated ? "WARDEN SILENCED" : `${hit.event.amount} // IMPACT`}</div>
|
||||
) : null}
|
||||
{objective && objective.event.type === "artifact" && client.tick - objective.receivedTick < 55 ? (
|
||||
<div className="dead-air-event">
|
||||
{artifactEventText(objective.event, client.playerId)}
|
||||
</div>
|
||||
) : null}
|
||||
{hint ? <div className="dead-air-prompt"><b>E</b><span>{hint}</span></div> : null}
|
||||
|
||||
{local && !local.alive ? (
|
||||
<section className="dead-air-dead">
|
||||
<small>YOUR SIGNAL WENT QUIET</small>
|
||||
<strong>RE-ENTERING IN {Math.ceil(local.respawnTicks / DEAD_AIR_TICK_RATE)}</strong>
|
||||
</section>
|
||||
) : null}
|
||||
{client.world.resetTicks > 0 ? (
|
||||
<section className="dead-air-extracted">
|
||||
<small>CURSED ASSET LEFT THE BUILDING</small>
|
||||
<strong>{client.world.lastExtractorId === client.playerId ? "YOU GOT THE MICROWAVE OUT" : "SOMEBODY ESCAPED IN THE DARK"}</strong>
|
||||
<span>NEXT FREQUENCY IN {Math.ceil(client.world.resetTicks / DEAD_AIR_TICK_RATE)}</span>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<footer className="dead-air-hud">
|
||||
<section className="dead-air-vitals">
|
||||
<small>HEARTBEAT</small>
|
||||
<strong>{String(local?.health ?? 0).padStart(3, "0")}</strong>
|
||||
<span><i style={{ width: `${local?.health ?? 0}%` }} /></span>
|
||||
<em>{local?.flashlight ? "F // LIGHT ACTIVE" : "F // LIGHT OFF"}</em>
|
||||
</section>
|
||||
|
||||
<section className={`dead-air-cargo${local?.carryingArtifact ? " is-carrying" : ""}`}>
|
||||
<small>{local?.carryingArtifact ? "THE BOX IS SCREAMING" : "CURSED ASSET"}</small>
|
||||
<strong>{local?.carryingArtifact ? "REACH THE GREEN EXTRACTION" : client.world.artifact.visible ? "MICROWAVE IN SIGHT" : "LOCATION UNKNOWN // LISTEN"}</strong>
|
||||
<span>{local?.extractions ?? 0} EXTRACTIONS · {visibleWardens} WARDENS VISIBLE</span>
|
||||
</section>
|
||||
|
||||
<section className="dead-air-actions">
|
||||
<button onClick={() => pulse("interact")} type="button"><b>E</b><span>INTERACT</span></button>
|
||||
<button onClick={() => pulse("toggleFlashlight")} type="button"><b>F</b><span>LIGHT</span></button>
|
||||
<button disabled={(local?.decoys ?? 0) <= 0} onClick={() => pulse("throwDecoy")} type="button"><b>Q</b><span>DECOY {local?.decoys ?? 0}</span></button>
|
||||
</section>
|
||||
|
||||
<section className="dead-air-weapon">
|
||||
<small>HUSH-8 DART PISTOL</small>
|
||||
<strong>{local?.magazine ?? 0}<i>/ {local?.reserve ?? 0}</i></strong>
|
||||
<span>{local?.reloadTicks ? "RELOADING" : "R // RELOAD · CLICK // FIRE"}</span>
|
||||
</section>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function objectiveText(carrying: boolean, visible: boolean): string {
|
||||
if (carrying) return "EXTRACT THE SCREAMING MICROWAVE";
|
||||
if (visible) return "TAKE THE CURSED ASSET";
|
||||
return "FIND IT BY SOUND // DO NOT GET SEEN";
|
||||
}
|
||||
|
||||
function interactionHint(
|
||||
artifactVisible: boolean,
|
||||
artifactX: number | null,
|
||||
artifactZ: number | null,
|
||||
local: { x: number; z: number; carryingArtifact: boolean } | undefined,
|
||||
): string | null {
|
||||
if (!local) return null;
|
||||
if (local.carryingArtifact) return "DROP THE CURSED ASSET";
|
||||
if (artifactVisible && artifactX !== null && artifactZ !== null && Math.hypot(local.x - artifactX, local.z - artifactZ) < 3) {
|
||||
return "GRAB THE SCREAMING MICROWAVE";
|
||||
}
|
||||
if (Math.hypot(local.x - DEAD_AIR_POWER_SWITCH.x, local.z - DEAD_AIR_POWER_SWITCH.z) < 3) return "CUT FACILITY POWER";
|
||||
if (Math.hypot(local.x - DEAD_AIR_EXTRACTION.x, local.z - DEAD_AIR_EXTRACTION.z) < DEAD_AIR_EXTRACTION.radius) return "EXTRACTION ZONE // BRING THE ASSET";
|
||||
return null;
|
||||
}
|
||||
|
||||
function artifactEventText(event: Extract<DeadAirPerception, { type: "artifact" }>, playerId: number | null): string {
|
||||
if (event.action === "extracted") return event.actorId === playerId ? "EXTRACTION CONFIRMED" : "THE SCREAMING STOPPED";
|
||||
if (event.action === "grabbed") return event.actorId === playerId ? "YOU PICKED UP THE CURSE" : event.actorId === null ? "SOMETHING TOOK THE ASSET" : "ASSET MOVING";
|
||||
return event.actorId === playerId ? "ASSET DROPPED" : "HEAVY METAL HIT THE FLOOR";
|
||||
}
|
||||
|
||||
function glyph(event: Extract<DeadAirPerception, { type: "sound" }>): string {
|
||||
switch (event.cue) {
|
||||
case "footstep": return "⌁";
|
||||
case "gunshot": return "×";
|
||||
case "impact": return "◆";
|
||||
case "decoy": return "◉";
|
||||
case "curse": return "∿";
|
||||
case "power": return "ϟ";
|
||||
}
|
||||
}
|
||||
|
||||
function directionLabel(direction: number): string {
|
||||
return ["FRONT", "FRONT-RIGHT", "RIGHT", "REAR-RIGHT", "BEHIND", "REAR-LEFT", "LEFT", "FRONT-LEFT"][direction] ?? "UNKNOWN";
|
||||
}
|
||||
141
apps/web/src/dead-air-audio.ts
Normal file
141
apps/web/src/dead-air-audio.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { DeadAirPerception, DeadAirSoundCue } from "@syncer/shared";
|
||||
|
||||
export class DeadAirAudio {
|
||||
private context: AudioContext | null = null;
|
||||
private master: GainNode | null = null;
|
||||
|
||||
unlock(): void {
|
||||
if (!this.context) {
|
||||
this.context = new AudioContext();
|
||||
this.master = this.context.createGain();
|
||||
this.master.gain.value = 0.32;
|
||||
this.master.connect(this.context.destination);
|
||||
}
|
||||
void this.context.resume();
|
||||
}
|
||||
|
||||
play(event: DeadAirPerception): void {
|
||||
if (!this.context || !this.master || this.context.state !== "running") return;
|
||||
switch (event.type) {
|
||||
case "sound":
|
||||
this.cue(event.cue, bucketBearing(event.direction), event.intensity / 3);
|
||||
break;
|
||||
case "muzzle":
|
||||
this.cue("gunshot", 0, 0.9);
|
||||
break;
|
||||
case "damage":
|
||||
this.tone(90, 38, 0.28, "sawtooth", 0.28);
|
||||
break;
|
||||
case "hit":
|
||||
this.tone(event.eliminated ? 520 : 820, event.eliminated ? 120 : 480, 0.09, "square", 0.12);
|
||||
break;
|
||||
case "artifact":
|
||||
this.tone(event.action === "extracted" ? 220 : 110, event.action === "extracted" ? 880 : 54, 0.42, "sine", 0.18);
|
||||
break;
|
||||
case "power":
|
||||
this.tone(event.on ? 90 : 180, event.on ? 260 : 42, 0.34, "sawtooth", 0.13);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
void this.context?.close();
|
||||
this.context = null;
|
||||
this.master = null;
|
||||
}
|
||||
|
||||
private cue(cue: DeadAirSoundCue, bearing: number, intensity: number): void {
|
||||
if (cue === "gunshot") {
|
||||
this.noise(0.17, 1_050, bearing, 0.46 * intensity);
|
||||
this.pulse(118, 42, 0.16, bearing, 0.28 * intensity);
|
||||
return;
|
||||
}
|
||||
if (cue === "footstep") {
|
||||
this.pulse(92, 42, 0.12, bearing, 0.2 * intensity);
|
||||
return;
|
||||
}
|
||||
if (cue === "decoy") {
|
||||
this.pulse(360, 95, 0.26, bearing, 0.18 * intensity);
|
||||
window.setTimeout(() => this.pulse(290, 72, 0.18, bearing, 0.12 * intensity), 110);
|
||||
return;
|
||||
}
|
||||
if (cue === "curse") {
|
||||
this.pulse(74, 38, 0.48, bearing, 0.2 * intensity);
|
||||
return;
|
||||
}
|
||||
this.pulse(cue === "power" ? 140 : 180, 48, 0.2, bearing, 0.14 * intensity);
|
||||
}
|
||||
|
||||
private noise(duration: number, frequency: number, bearing: number, volume: number): void {
|
||||
const context = this.context!;
|
||||
const now = context.currentTime;
|
||||
const source = context.createBufferSource();
|
||||
const buffer = context.createBuffer(1, Math.floor(context.sampleRate * duration), context.sampleRate);
|
||||
const samples = buffer.getChannelData(0);
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
samples[index] = (Math.random() * 2 - 1) * Math.exp(-index / (samples.length * 0.19));
|
||||
}
|
||||
source.buffer = buffer;
|
||||
const filter = context.createBiquadFilter();
|
||||
filter.type = "lowpass";
|
||||
filter.frequency.value = frequency;
|
||||
const gain = context.createGain();
|
||||
gain.gain.setValueAtTime(Math.max(0.01, volume), now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||||
source.connect(filter).connect(gain).connect(this.panner(bearing));
|
||||
source.start(now);
|
||||
}
|
||||
|
||||
private pulse(
|
||||
from: number,
|
||||
to: number,
|
||||
duration: number,
|
||||
bearing: number,
|
||||
volume: number,
|
||||
): void {
|
||||
const context = this.context!;
|
||||
const now = context.currentTime;
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
oscillator.type = "sine";
|
||||
oscillator.frequency.setValueAtTime(from, now);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(to, now + duration);
|
||||
gain.gain.setValueAtTime(Math.max(0.008, volume), now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||||
oscillator.connect(gain).connect(this.panner(bearing));
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + duration + 0.01);
|
||||
}
|
||||
|
||||
private tone(
|
||||
from: number,
|
||||
to: number,
|
||||
duration: number,
|
||||
type: OscillatorType,
|
||||
volume: number,
|
||||
): void {
|
||||
const context = this.context!;
|
||||
const now = context.currentTime;
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
oscillator.type = type;
|
||||
oscillator.frequency.setValueAtTime(from, now);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(to, now + duration);
|
||||
gain.gain.setValueAtTime(volume, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||||
oscillator.connect(gain).connect(this.master!);
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + duration + 0.01);
|
||||
}
|
||||
|
||||
private panner(bearing: number): StereoPannerNode {
|
||||
const panner = this.context!.createStereoPanner();
|
||||
panner.pan.value = Math.max(-1, Math.min(1, Math.sin(bearing)));
|
||||
panner.connect(this.master!);
|
||||
return panner;
|
||||
}
|
||||
}
|
||||
|
||||
function bucketBearing(direction: number): number {
|
||||
return direction * (Math.PI / 4);
|
||||
}
|
||||
@@ -817,3 +817,173 @@ button { font: inherit; }
|
||||
.movers-actions { grid-column: 1 / -1; justify-content: center; }
|
||||
.movers-actions button { min-width: 82px; }
|
||||
}
|
||||
|
||||
.dead-air-game {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 320px;
|
||||
overflow: hidden;
|
||||
color: #dce8e5;
|
||||
background: #010304;
|
||||
user-select: none;
|
||||
}
|
||||
.dead-air-viewport,
|
||||
.dead-air-viewport canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.dead-air-viewport canvas { display: block; }
|
||||
.dead-air-enter {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
width: min(580px, calc(100% - 36px));
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 28px 36px 30px;
|
||||
border: 1px solid rgb(137 236 219 / 24%);
|
||||
border-block-width: 1px 6px;
|
||||
color: #d9f2ed;
|
||||
background:
|
||||
repeating-linear-gradient(90deg, transparent 0 18px, rgb(94 231 202 / 2%) 18px 19px),
|
||||
rgb(2 8 9 / 92%);
|
||||
box-shadow: 0 0 100px rgb(18 75 67 / 26%);
|
||||
cursor: pointer;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.dead-air-enter::before { position: absolute; inset: 7px; border: 1px solid rgb(255 255 255 / 4%); content: ""; pointer-events: none; }
|
||||
.dead-air-enter small { color: #76928c; font: .47rem/1.2 "IBM Plex Mono", monospace; letter-spacing: .21em; }
|
||||
.dead-air-enter strong { color: #ebfffa; font-size: 1.8rem; letter-spacing: .18em; text-shadow: 0 0 18px rgb(102 239 211 / 38%); }
|
||||
.dead-air-enter span { color: #98c6bc; font-size: .65rem; letter-spacing: .12em; }
|
||||
.dead-air-enter em { margin-top: 5px; color: #56716c; font: normal .43rem/1.4 "IBM Plex Mono", monospace; letter-spacing: .08em; }
|
||||
.dead-air-enter:hover { border-bottom-color: #6aefce; background-color: rgb(4 14 15 / 97%); }
|
||||
|
||||
.dead-air-grade { position: absolute; inset: 0; z-index: 1; background: radial-gradient(circle at 50% 48%, transparent 34%, rgb(0 0 0 / 48%) 79%, rgb(0 0 0 / 78%)), linear-gradient(180deg, rgb(0 3 4 / 58%), transparent 19%, transparent 75%, rgb(0 2 3 / 77%)); pointer-events: none; }
|
||||
.dead-air-noise { position: absolute; inset: 0; z-index: 2; opacity: .11; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 140 140' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.32'/%3E%3C/svg%3E"); pointer-events: none; }
|
||||
.dead-air-game--blackout .dead-air-grade { background: radial-gradient(circle at 50% 49%, transparent 20%, rgb(0 0 0 / 67%) 69%, #000 100%), linear-gradient(180deg, rgb(0 0 0 / 72%), transparent 24%, transparent 68%, rgb(0 0 0 / 88%)); }
|
||||
.dead-air-damage { position: absolute; inset: 0; z-index: 3; background: radial-gradient(circle, transparent 22%, rgb(121 0 0 / 45%)); animation: dead-air-damage .38s ease-out forwards; pointer-events: none; }
|
||||
@keyframes dead-air-damage { to { opacity: 0; } }
|
||||
|
||||
.dead-air-header { position: absolute; z-index: 6; top: 0; left: 0; display: grid; width: 100%; grid-template-columns: 1fr auto 1fr; align-items: start; padding: 20px 25px; pointer-events: none; }
|
||||
.dead-air-brand { display: flex; align-items: center; gap: 10px; }
|
||||
.dead-air-brand > i { display: grid; width: 43px; height: 43px; place-items: center; border: 1px solid rgb(105 231 207 / 44%); color: #88e8d4; background: rgb(3 13 14 / 76%); box-shadow: inset 0 0 19px rgb(40 218 183 / 12%); font: normal 700 .82rem "IBM Plex Mono", monospace; clip-path: polygon(0 0, 80% 0, 100% 20%, 100% 100%, 20% 100%, 0 80%); }
|
||||
.dead-air-brand small { display: block; color: #5b7772; font: .42rem/1 "IBM Plex Mono", monospace; letter-spacing: .13em; }
|
||||
.dead-air-brand strong { display: block; margin-top: 3px; color: #ddf4ef; font-size: 1.05rem; letter-spacing: .22em; }
|
||||
.dead-air-objective { min-width: 355px; padding: 7px 28px 9px; border-top: 1px solid rgb(102 228 202 / 24%); background: linear-gradient(90deg, transparent, rgb(3 12 13 / 76%) 18%, rgb(3 12 13 / 76%) 82%, transparent); text-align: center; }
|
||||
.dead-air-objective small,
|
||||
.dead-air-objective span { display: block; color: #5d7772; font: .4rem/1.3 "IBM Plex Mono", monospace; letter-spacing: .13em; }
|
||||
.dead-air-objective strong { display: block; margin: 2px 0; color: #c9e6df; font-size: .71rem; letter-spacing: .14em; }
|
||||
.dead-air-game--blackout .dead-air-objective span { color: #ff604c; text-shadow: 0 0 8px #a91a0b; }
|
||||
.dead-air-live { justify-self: end; display: grid; grid-template-columns: auto auto; align-items: center; gap: 0 7px; padding: 7px 9px; border-right: 1px solid #62dfc5; background: linear-gradient(90deg, transparent, rgb(2 13 14 / 76%)); text-align: right; }
|
||||
.dead-air-live > i { grid-row: 1 / span 2; width: 6px; height: 6px; border-radius: 50%; background: #72e8cf; box-shadow: 0 0 10px #50dbbd; }
|
||||
.dead-air-live span { color: #8debd7; font-size: .57rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.dead-air-live small { color: #5f7671; font: .39rem/1 "IBM Plex Mono", monospace; }
|
||||
.dead-air-live--connecting > i,
|
||||
.dead-air-live--reconnecting > i { background: #ff704f; box-shadow: 0 0 10px #ff4f2d; }
|
||||
|
||||
.dead-air-security { position: absolute; z-index: 5; top: 93px; left: 25px; width: 190px; padding: 8px 0; border-top: 1px solid rgb(101 220 199 / 31%); color: #78918c; background: linear-gradient(90deg, rgb(2 10 11 / 78%), transparent); pointer-events: none; }
|
||||
.dead-air-security > small { display: block; padding: 0 7px 6px; color: #7edbc8; font: .42rem/1 "IBM Plex Mono", monospace; letter-spacing: .15em; }
|
||||
.dead-air-security div { display: flex; justify-content: space-between; padding: 3px 7px; font: .38rem/1.3 "IBM Plex Mono", monospace; letter-spacing: .05em; }
|
||||
.dead-air-security b { color: #a3bbb6; font-weight: 500; }
|
||||
|
||||
.dead-air-listen { position: absolute; z-index: 5; top: 98px; right: 25px; width: 200px; padding: 8px; border-top: 1px solid rgb(255 92 66 / 35%); background: linear-gradient(90deg, transparent, rgb(10 7 7 / 79%)); pointer-events: none; }
|
||||
.dead-air-listen > small { display: block; margin-bottom: 5px; color: #c46a5b; font: .42rem/1 "IBM Plex Mono", monospace; letter-spacing: .14em; text-align: right; }
|
||||
.dead-air-listen > span { display: block; color: #51615e; font: .37rem/1.4 "IBM Plex Mono", monospace; text-align: right; }
|
||||
.dead-air-listen > div { display: grid; grid-template-columns: 18px 1fr; padding: 3px 0; border-top: 1px solid rgb(255 255 255 / 4%); }
|
||||
.dead-air-listen b { grid-row: 1 / span 2; align-self: center; color: #ff6c55; font: .8rem/1 "IBM Plex Mono", monospace; }
|
||||
.dead-air-listen span { color: #9eaaa7; font-size: .49rem; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.dead-air-listen em { color: #596966; font: normal .33rem/1 "IBM Plex Mono", monospace; letter-spacing: .05em; }
|
||||
|
||||
.dead-air-scope { position: absolute; z-index: 4; top: 50%; left: 50%; width: 240px; height: 240px; border: 1px solid rgb(121 219 201 / 4%); border-radius: 50%; transform: translate(-50%, -50%); pointer-events: none; }
|
||||
.dead-air-crosshair { position: absolute; top: 50%; left: 50%; width: 40px; height: 40px; transform: translate(-50%, -50%); }
|
||||
.dead-air-crosshair::after { position: absolute; top: 19px; left: 19px; width: 3px; height: 3px; border-radius: 50%; background: #c9e5df; box-shadow: 0 0 5px #9ee5d7; content: ""; }
|
||||
.dead-air-crosshair i { position: absolute; background: rgb(190 221 215 / 77%); }
|
||||
.dead-air-crosshair i:nth-child(1), .dead-air-crosshair i:nth-child(2) { top: 20px; width: 8px; height: 1px; }
|
||||
.dead-air-crosshair i:nth-child(1) { left: 2px; }
|
||||
.dead-air-crosshair i:nth-child(2) { right: 2px; }
|
||||
.dead-air-crosshair i:nth-child(3), .dead-air-crosshair i:nth-child(4) { left: 20px; width: 1px; height: 8px; }
|
||||
.dead-air-crosshair i:nth-child(3) { top: 2px; }
|
||||
.dead-air-crosshair i:nth-child(4) { bottom: 2px; }
|
||||
.dead-air-crosshair--hit { animation: dead-air-hit .17s ease-out; }
|
||||
@keyframes dead-air-hit { 50% { transform: translate(-50%, -50%) scale(1.5); filter: sepia(1) saturate(4); } }
|
||||
.dead-air-bearing { position: absolute; top: 50%; left: 50%; width: 1px; height: 1px; transform-origin: 0 0; }
|
||||
.dead-air-bearing i { position: absolute; top: -98px; left: -10px; width: 20px; height: 12px; border-top: 2px solid #ff735c; clip-path: polygon(0 0, 50% 100%, 100% 0); filter: drop-shadow(0 0 5px #ff4e34); }
|
||||
.dead-air-bearing--mid i { top: -84px; border-color: #d99969; }
|
||||
.dead-air-bearing--far i { top: -70px; border-color: #8c9485; }
|
||||
|
||||
.dead-air-hit,
|
||||
.dead-air-event { position: absolute; z-index: 7; left: 50%; color: #daf7f0; font: .48rem/1 "IBM Plex Mono", monospace; letter-spacing: .15em; text-shadow: 0 0 9px #74e4ce; transform: translateX(-50%); pointer-events: none; }
|
||||
.dead-air-hit { top: calc(50% + 33px); }
|
||||
.dead-air-event { top: 29%; padding: 7px 18px; border-block: 1px solid rgb(255 104 78 / 38%); color: #ffc0af; background: linear-gradient(90deg, transparent, rgb(35 8 4 / 75%), transparent); text-shadow: 0 0 9px #ff5538; }
|
||||
.dead-air-prompt { position: absolute; z-index: 7; bottom: 145px; left: 50%; display: flex; align-items: center; gap: 7px; padding: 5px 12px; color: #c8e4de; background: rgb(2 11 12 / 78%); font: .45rem/1 "IBM Plex Mono", monospace; letter-spacing: .1em; transform: translateX(-50%); pointer-events: none; }
|
||||
.dead-air-prompt b { display: grid; width: 23px; height: 23px; place-items: center; border: 1px solid #68d8c1; color: #8cebd7; }
|
||||
|
||||
.dead-air-dead,
|
||||
.dead-air-extracted { position: absolute; z-index: 10; top: 50%; left: 50%; display: flex; width: min(620px, calc(100% - 40px)); flex-direction: column; align-items: center; gap: 5px; padding: 20px; border-block: 1px solid rgb(255 81 55 / 48%); background: linear-gradient(90deg, transparent, rgb(28 3 2 / 86%) 20%, rgb(28 3 2 / 86%) 80%, transparent); transform: translate(-50%, -50%); pointer-events: none; }
|
||||
.dead-air-dead small,
|
||||
.dead-air-extracted small,
|
||||
.dead-air-extracted span { color: #a65e52; font: .44rem/1 "IBM Plex Mono", monospace; letter-spacing: .17em; }
|
||||
.dead-air-dead strong,
|
||||
.dead-air-extracted strong { color: #ffd3c8; font-size: 1.28rem; letter-spacing: .18em; }
|
||||
.dead-air-extracted { border-color: rgb(94 234 201 / 47%); background: linear-gradient(90deg, transparent, rgb(2 24 20 / 88%) 20%, rgb(2 24 20 / 88%) 80%, transparent); }
|
||||
.dead-air-extracted small,
|
||||
.dead-air-extracted span { color: #6da597; }
|
||||
.dead-air-extracted strong { color: #cbfff3; }
|
||||
|
||||
.dead-air-hud { position: absolute; z-index: 6; right: 25px; bottom: 20px; left: 25px; display: grid; grid-template-columns: 200px 1fr auto 215px; align-items: end; gap: 18px; pointer-events: none; }
|
||||
.dead-air-vitals,
|
||||
.dead-air-cargo,
|
||||
.dead-air-weapon { padding: 8px 11px; border-top: 1px solid rgb(102 224 200 / 35%); background: linear-gradient(180deg, rgb(3 13 14 / 78%), rgb(2 7 8 / 89%)); }
|
||||
.dead-air-vitals small,
|
||||
.dead-air-cargo small,
|
||||
.dead-air-weapon small { display: block; color: #607873; font: .4rem/1 "IBM Plex Mono", monospace; letter-spacing: .13em; }
|
||||
.dead-air-vitals strong { display: block; color: #d6eee9; font: 1.8rem/.9 "IBM Plex Mono", monospace; font-weight: 500; }
|
||||
.dead-air-vitals > span { display: block; overflow: hidden; width: 150px; height: 3px; margin: 5px 0; background: rgb(255 255 255 / 8%); }
|
||||
.dead-air-vitals > span i { display: block; height: 100%; background: #66d8c0; box-shadow: 0 0 8px #51caae; }
|
||||
.dead-air-vitals em { color: #617e78; font: normal .37rem/1 "IBM Plex Mono", monospace; }
|
||||
.dead-air-cargo { justify-self: center; min-width: 330px; border-color: rgb(255 88 57 / 28%); text-align: center; }
|
||||
.dead-air-cargo strong { display: block; margin: 3px 0; color: #bdcbc8; font-size: .67rem; letter-spacing: .12em; }
|
||||
.dead-air-cargo span { color: #5e726e; font: .36rem/1 "IBM Plex Mono", monospace; letter-spacing: .07em; }
|
||||
.dead-air-cargo.is-carrying { border-color: #ff684c; background: linear-gradient(180deg, rgb(38 10 5 / 82%), rgb(13 5 3 / 91%)); box-shadow: 0 -8px 30px rgb(142 24 5 / 12%); }
|
||||
.dead-air-cargo.is-carrying strong { color: #ffc7b9; text-shadow: 0 0 8px #9a2916; }
|
||||
.dead-air-actions { display: flex; gap: 4px; pointer-events: auto; }
|
||||
.dead-air-actions button { display: flex; min-width: 58px; flex-direction: column; align-items: center; padding: 7px 6px; border: 1px solid rgb(113 216 195 / 16%); color: #81938f; background: rgb(2 10 11 / 84%); cursor: pointer; }
|
||||
.dead-air-actions button:hover { border-color: #70d9c3; color: #c9e6df; }
|
||||
.dead-air-actions button:disabled { opacity: .28; cursor: default; }
|
||||
.dead-air-actions b { color: #72d9c3; font: .5rem/1.2 "IBM Plex Mono", monospace; }
|
||||
.dead-air-actions span { font-size: .38rem; letter-spacing: .07em; white-space: nowrap; }
|
||||
.dead-air-weapon { text-align: right; }
|
||||
.dead-air-weapon strong { display: block; color: #edf9f6; font: 2rem/.9 "IBM Plex Mono", monospace; font-weight: 500; }
|
||||
.dead-air-weapon strong i { color: #5c6f6b; font-size: .68rem; font-style: normal; }
|
||||
.dead-air-weapon span { color: #60736f; font: .36rem/1 "IBM Plex Mono", monospace; letter-spacing: .06em; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.game-switcher { max-width: calc(100% - 20px); overflow-x: auto; }
|
||||
.game-switcher button { flex: 0 0 auto; }
|
||||
.dead-air-header { padding: 13px; grid-template-columns: 1fr auto; }
|
||||
.dead-air-objective { position: absolute; top: 62px; left: 50%; min-width: 320px; transform: translateX(-50%); }
|
||||
.dead-air-brand small,
|
||||
.dead-air-live small { display: none; }
|
||||
.dead-air-security { top: 108px; left: 13px; width: 160px; }
|
||||
.dead-air-listen { top: 108px; right: 13px; width: 160px; }
|
||||
.dead-air-hud { right: 12px; bottom: 10px; left: 12px; grid-template-columns: 1fr auto; gap: 6px; }
|
||||
.dead-air-vitals { grid-column: 1; }
|
||||
.dead-air-weapon { grid-column: 2; }
|
||||
.dead-air-cargo { grid-column: 1 / -1; grid-row: 1; min-width: 0; width: 100%; }
|
||||
.dead-air-actions { grid-column: 1 / -1; justify-content: center; }
|
||||
.dead-air-actions button { min-width: 88px; }
|
||||
.dead-air-prompt { bottom: 184px; }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.dead-air-security { display: none; }
|
||||
.dead-air-listen { width: 145px; }
|
||||
.dead-air-listen > div:nth-of-type(n+3) { display: none; }
|
||||
.dead-air-enter { padding-inline: 20px; }
|
||||
.dead-air-enter strong { font-size: 1.25rem; }
|
||||
.dead-air-enter em { max-width: 300px; }
|
||||
.dead-air-vitals > span { width: 110px; }
|
||||
.dead-air-weapon { min-width: 135px; }
|
||||
.dead-air-cargo span { display: none; }
|
||||
}
|
||||
|
||||
368
apps/web/src/useDeadAirClient.ts
Normal file
368
apps/web/src/useDeadAirClient.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
createInputStateStream,
|
||||
FixedStepClock,
|
||||
type NetworkStats,
|
||||
} from "@syncer/engine";
|
||||
import {
|
||||
DEAD_AIR_SOCKET_PATH,
|
||||
deadAirGame,
|
||||
type DeadAirClientState,
|
||||
type DeadAirInput,
|
||||
} from "@syncer/shared";
|
||||
import { DeadAirAudio } from "./dead-air-audio.js";
|
||||
import type { ConnectionStatus, ValidationStatus } from "./useGameClient.js";
|
||||
|
||||
export type DeadAirAction = "interact" | "toggleFlashlight" | "throwDecoy" | "reload";
|
||||
|
||||
export interface DeadAirCorrection {
|
||||
x: number;
|
||||
z: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface DeadAirRenderFrame {
|
||||
state: Readonly<DeadAirClientState>;
|
||||
interpolationAlpha: number;
|
||||
localCorrection: DeadAirCorrection;
|
||||
}
|
||||
|
||||
export interface DeadAirRenderSource {
|
||||
readonly current: DeadAirRenderFrame;
|
||||
}
|
||||
|
||||
export interface DeadAirClientView {
|
||||
connection: ConnectionStatus;
|
||||
validation: ValidationStatus;
|
||||
playerId: number | null;
|
||||
tick: number;
|
||||
inputLeadTicks: number;
|
||||
world: DeadAirClientState;
|
||||
network: NetworkStats;
|
||||
renderSource: DeadAirRenderSource;
|
||||
setAction(action: DeadAirAction, active: boolean): void;
|
||||
unlockAudio(): void;
|
||||
}
|
||||
|
||||
const emptyNetwork: NetworkStats = {
|
||||
roundTripTime: 0,
|
||||
jitter: 0,
|
||||
clockOffset: 0,
|
||||
samples: 0,
|
||||
};
|
||||
|
||||
function neutralInput(): DeadAirInput {
|
||||
return {
|
||||
forward: 0,
|
||||
strafe: 0,
|
||||
yaw: 0,
|
||||
pitch: 0,
|
||||
fire: false,
|
||||
sprint: false,
|
||||
reload: false,
|
||||
interact: false,
|
||||
toggleFlashlight: false,
|
||||
throwDecoy: false,
|
||||
};
|
||||
}
|
||||
|
||||
function socketUrls(): string[] {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const proxied = `${protocol}//${window.location.host}${DEAD_AIR_SOCKET_PATH}`;
|
||||
if (window.location.protocol !== "http:" || window.location.port !== "5173") return [proxied];
|
||||
return [`ws://${window.location.hostname}:3001${DEAD_AIR_SOCKET_PATH}`, proxied];
|
||||
}
|
||||
|
||||
export function useDeadAirClient(): DeadAirClientView {
|
||||
const engine = useMemo(() => deadAirGame.createClient(), []);
|
||||
const audio = useMemo(() => new DeadAirAudio(), []);
|
||||
const protocol = deadAirGame.protocol;
|
||||
const clock = useMemo(
|
||||
() => new FixedStepClock({ rateHz: deadAirGame.tickRateHz, maxCatchUpSteps: 5 }),
|
||||
[],
|
||||
);
|
||||
const renderSource = useRef<DeadAirRenderFrame>({
|
||||
state: deadAirGame.client.createInitialState(),
|
||||
interpolationAlpha: 0,
|
||||
localCorrection: { x: 0, z: 0, updatedAt: 0 },
|
||||
});
|
||||
const actionRef = useRef<(action: DeadAirAction, active: boolean) => void>(() => undefined);
|
||||
const setAction = useCallback(
|
||||
(action: DeadAirAction, active: boolean) => actionRef.current(action, active),
|
||||
[],
|
||||
);
|
||||
const unlockAudio = useCallback(() => audio.unlock(), [audio]);
|
||||
const [view, setView] = useState<Omit<DeadAirClientView, "renderSource" | "setAction" | "unlockAudio">>({
|
||||
connection: "connecting",
|
||||
validation: "waiting",
|
||||
playerId: null,
|
||||
tick: 0,
|
||||
inputLeadTicks: 1,
|
||||
world: deadAirGame.client.createInitialState(),
|
||||
network: emptyNetwork,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let socket: WebSocket | undefined;
|
||||
let retryTimer: number | undefined;
|
||||
let animationFrame: number | undefined;
|
||||
let connection: ConnectionStatus = "connecting";
|
||||
let validation: ValidationStatus = "waiting";
|
||||
let input = neutralInput();
|
||||
const pressed = new Set<string>();
|
||||
const stream = createInputStateStream<DeadAirInput>(deadAirGame);
|
||||
let lastInputFrame: ArrayBuffer | null = null;
|
||||
let socketUrlIndex = 0;
|
||||
let lastPublishedAt = Number.NEGATIVE_INFINITY;
|
||||
const urls = socketUrls();
|
||||
|
||||
const refreshRenderSource = () => {
|
||||
renderSource.current.state = engine.currentState as DeadAirClientState;
|
||||
renderSource.current.interpolationAlpha = clock.interpolationAlpha;
|
||||
};
|
||||
const publish = (force = false, now = performance.now()) => {
|
||||
if (!active || (!force && now - lastPublishedAt < 100)) return;
|
||||
lastPublishedAt = now;
|
||||
setView({
|
||||
connection,
|
||||
validation,
|
||||
playerId: engine.localPlayerId,
|
||||
tick: engine.tick,
|
||||
inputLeadTicks: engine.networkClock.recommendedInputLeadTicks(deadAirGame.tickRateHz),
|
||||
world: deadAirGame.client.cloneState(engine.currentState as DeadAirClientState),
|
||||
network: engine.networkClock.stats,
|
||||
});
|
||||
};
|
||||
const send = (frame: ArrayBuffer) => {
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send(frame);
|
||||
};
|
||||
const sendInput = (force = false) => {
|
||||
if (!engine.initialized) return;
|
||||
stream.update(input);
|
||||
const emission = stream.consume(performance.now(), force);
|
||||
if (!emission) return;
|
||||
if (emission.kind === "state" || !lastInputFrame) {
|
||||
lastInputFrame = protocol.encodeClient({
|
||||
kind: "input",
|
||||
packet: engine.createInput(emission.input),
|
||||
});
|
||||
}
|
||||
send(lastInputFrame);
|
||||
};
|
||||
const updateMovement = () => {
|
||||
input = {
|
||||
...input,
|
||||
forward: Number(pressed.has("w")) - Number(pressed.has("s")),
|
||||
strafe: Number(pressed.has("d")) - Number(pressed.has("a")),
|
||||
sprint: pressed.has("shift"),
|
||||
};
|
||||
sendInput();
|
||||
};
|
||||
const updateAction = (action: DeadAirAction, value: boolean) => {
|
||||
if (input[action] === value) return;
|
||||
input = { ...input, [action]: value };
|
||||
sendInput(true);
|
||||
};
|
||||
actionRef.current = updateAction;
|
||||
|
||||
const connect = () => {
|
||||
let opened = false;
|
||||
connection = engine.initialized ? "reconnecting" : "connecting";
|
||||
publish(true);
|
||||
socket = new WebSocket(urls[socketUrlIndex]!);
|
||||
socket.binaryType = "arraybuffer";
|
||||
socket.addEventListener("open", () => {
|
||||
if (!active) return;
|
||||
opened = true;
|
||||
connection = "live";
|
||||
publish(true);
|
||||
});
|
||||
socket.addEventListener("message", (message: MessageEvent<ArrayBuffer>) => {
|
||||
if (!active || !(message.data instanceof ArrayBuffer)) return;
|
||||
try {
|
||||
const decoded = protocol.decodeServer(message.data);
|
||||
switch (decoded.kind) {
|
||||
case "welcome": {
|
||||
engine.initialize(decoded.playerId, decoded.snapshot);
|
||||
clock.reset(performance.now());
|
||||
const local = decoded.snapshot.state.players.find((player) => player.id === decoded.playerId);
|
||||
input = { ...neutralInput(), yaw: local?.yaw ?? 0, pitch: local?.pitch ?? 0 };
|
||||
stream.reset(input);
|
||||
lastInputFrame = null;
|
||||
validation = "waiting";
|
||||
renderSource.current.localCorrection = { x: 0, z: 0, updatedAt: performance.now() };
|
||||
sendInput(true);
|
||||
break;
|
||||
}
|
||||
case "snapshot": {
|
||||
const localBefore = (engine.currentState as DeadAirClientState).players.find(
|
||||
(player) => player.id === engine.localPlayerId,
|
||||
);
|
||||
engine.reconcile(decoded.snapshot);
|
||||
const localAfter = (engine.currentState as DeadAirClientState).players.find(
|
||||
(player) => player.id === engine.localPlayerId,
|
||||
);
|
||||
if (localBefore && localAfter && decoded.snapshot.state.round === renderSource.current.state.round) {
|
||||
const correction = renderSource.current.localCorrection;
|
||||
correction.x += localBefore.x - localAfter.x;
|
||||
correction.z += localBefore.z - localAfter.z;
|
||||
correction.updatedAt = performance.now();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "acknowledge":
|
||||
engine.acknowledge(decoded.sequence);
|
||||
break;
|
||||
case "pong":
|
||||
engine.networkClock.receivePong(decoded.pong, performance.now());
|
||||
break;
|
||||
case "validation":
|
||||
validation = decoded.valid ? "valid" : "invalid";
|
||||
break;
|
||||
case "reject-input":
|
||||
engine.reject(decoded.sequence);
|
||||
stream.invalidate();
|
||||
lastInputFrame = null;
|
||||
validation = "invalid";
|
||||
sendInput(true);
|
||||
break;
|
||||
case "event":
|
||||
engine.receiveEvent(decoded.event, decoded.tick);
|
||||
audio.play(decoded.event);
|
||||
break;
|
||||
case "replay-start":
|
||||
case "replay-frame":
|
||||
case "replay-end":
|
||||
break;
|
||||
}
|
||||
refreshRenderSource();
|
||||
publish(decoded.kind !== "snapshot");
|
||||
} catch {
|
||||
socket?.close(1003, "Invalid acoustic frame");
|
||||
}
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
if (!active) return;
|
||||
if (!opened && urls.length > 1) socketUrlIndex = (socketUrlIndex + 1) % urls.length;
|
||||
else if (opened) socketUrlIndex = 0;
|
||||
connection = "reconnecting";
|
||||
publish(true);
|
||||
retryTimer = window.setTimeout(connect, 1_000);
|
||||
});
|
||||
socket.addEventListener("error", () => socket?.close());
|
||||
};
|
||||
|
||||
const keyDown = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
if (["w", "a", "s", "d", "shift", "e", "f", "q", "r"].includes(key)) event.preventDefault();
|
||||
audio.unlock();
|
||||
if (["w", "a", "s", "d", "shift"].includes(key)) {
|
||||
pressed.add(key);
|
||||
updateMovement();
|
||||
} else if (!event.repeat && key === "e") updateAction("interact", true);
|
||||
else if (!event.repeat && key === "f") updateAction("toggleFlashlight", true);
|
||||
else if (!event.repeat && key === "q") updateAction("throwDecoy", true);
|
||||
else if (!event.repeat && key === "r") updateAction("reload", true);
|
||||
};
|
||||
const keyUp = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
if (["w", "a", "s", "d", "shift"].includes(key)) {
|
||||
pressed.delete(key);
|
||||
updateMovement();
|
||||
} else if (key === "e") updateAction("interact", false);
|
||||
else if (key === "f") updateAction("toggleFlashlight", false);
|
||||
else if (key === "q") updateAction("throwDecoy", false);
|
||||
else if (key === "r") updateAction("reload", false);
|
||||
};
|
||||
const mouseMove = (event: MouseEvent) => {
|
||||
if (!document.pointerLockElement) return;
|
||||
input = {
|
||||
...input,
|
||||
yaw: normalizeAngle(input.yaw - event.movementX * 0.0026),
|
||||
pitch: clamp(input.pitch - event.movementY * 0.00215, -1.2, 1.2),
|
||||
};
|
||||
sendInput();
|
||||
};
|
||||
const mouseDown = (event: MouseEvent) => {
|
||||
audio.unlock();
|
||||
if (event.button !== 0 || !document.pointerLockElement) return;
|
||||
input = { ...input, fire: true };
|
||||
sendInput(true);
|
||||
};
|
||||
const mouseUp = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
input = { ...input, fire: false };
|
||||
sendInput(true);
|
||||
};
|
||||
const release = () => {
|
||||
pressed.clear();
|
||||
input = {
|
||||
...input,
|
||||
forward: 0,
|
||||
strafe: 0,
|
||||
sprint: false,
|
||||
fire: false,
|
||||
reload: false,
|
||||
interact: false,
|
||||
toggleFlashlight: false,
|
||||
throwDecoy: false,
|
||||
};
|
||||
sendInput(true);
|
||||
};
|
||||
const pointerLockChange = () => {
|
||||
if (!document.pointerLockElement) release();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", keyDown);
|
||||
window.addEventListener("keyup", keyUp);
|
||||
window.addEventListener("mousemove", mouseMove);
|
||||
window.addEventListener("mousedown", mouseDown);
|
||||
window.addEventListener("mouseup", mouseUp);
|
||||
window.addEventListener("blur", release);
|
||||
document.addEventListener("pointerlockchange", pointerLockChange);
|
||||
const pingTimer = window.setInterval(() => {
|
||||
send(protocol.encodeClient({ kind: "ping", ping: engine.networkClock.createPing(performance.now()) }));
|
||||
}, 1_000);
|
||||
const validationTimer = window.setInterval(() => {
|
||||
if (engine.initialized) send(protocol.encodeClient({ kind: "state-report", report: engine.createStateReport() }));
|
||||
}, 2_000);
|
||||
const animate = (now: number) => {
|
||||
sendInput();
|
||||
clock.advance(now, () => engine.step());
|
||||
refreshRenderSource();
|
||||
publish(false, now);
|
||||
animationFrame = window.requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
connect();
|
||||
animationFrame = window.requestAnimationFrame(animate);
|
||||
return () => {
|
||||
active = false;
|
||||
actionRef.current = () => undefined;
|
||||
window.clearTimeout(retryTimer);
|
||||
window.clearInterval(pingTimer);
|
||||
window.clearInterval(validationTimer);
|
||||
if (animationFrame !== undefined) window.cancelAnimationFrame(animationFrame);
|
||||
window.removeEventListener("keydown", keyDown);
|
||||
window.removeEventListener("keyup", keyUp);
|
||||
window.removeEventListener("mousemove", mouseMove);
|
||||
window.removeEventListener("mousedown", mouseDown);
|
||||
window.removeEventListener("mouseup", mouseUp);
|
||||
window.removeEventListener("blur", release);
|
||||
document.removeEventListener("pointerlockchange", pointerLockChange);
|
||||
socket?.close();
|
||||
audio.dispose();
|
||||
};
|
||||
}, [audio, clock, engine, protocol]);
|
||||
|
||||
return { ...view, renderSource, setAction, unlockAudio };
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
905
packages/shared/src/dead-air-game.ts
Normal file
905
packages/shared/src/dead-air-game.ts
Normal file
@@ -0,0 +1,905 @@
|
||||
import {
|
||||
createJsonCodec,
|
||||
defineMultiplayerGame,
|
||||
withInputStream,
|
||||
} from "@syncer/engine";
|
||||
import {
|
||||
DEAD_AIR_ARTIFACT_SPAWN,
|
||||
DEAD_AIR_EXTRACTION,
|
||||
DEAD_AIR_POWER_SWITCH,
|
||||
DEAD_AIR_SPAWNS,
|
||||
DEAD_AIR_WARDEN_SPAWNS,
|
||||
collidesWithDeadAirMap,
|
||||
hasDeadAirLineOfSight,
|
||||
} from "./dead-air-map.js";
|
||||
import type {
|
||||
DeadAirAuthorityEvent,
|
||||
DeadAirAuthorityPlayer,
|
||||
DeadAirAuthorityState,
|
||||
DeadAirClientState,
|
||||
DeadAirDistanceBand,
|
||||
DeadAirGameContract,
|
||||
DeadAirInput,
|
||||
DeadAirPerception,
|
||||
DeadAirPlayerView,
|
||||
DeadAirSoundCue,
|
||||
} from "./dead-air-types.js";
|
||||
|
||||
export const DEAD_AIR_SOCKET_PATH = "/ws/dead-air";
|
||||
export const DEAD_AIR_TICK_RATE = 30;
|
||||
export const DEAD_AIR_SNAPSHOT_RATE = 10;
|
||||
export const DEAD_AIR_WARDEN_COUNT = 5;
|
||||
export const DEAD_AIR_WARDEN_ID_BASE = 50_000;
|
||||
export const DEAD_AIR_MAGAZINE_SIZE = 8;
|
||||
|
||||
const walkSpeed = 5.2;
|
||||
const sprintSpeed = 7.8;
|
||||
const movementAcceleration = 36;
|
||||
const movementFriction = 12;
|
||||
const shotRange = 34;
|
||||
const shotDamage = 34;
|
||||
const shotCooldownTicks = 10;
|
||||
const reloadDurationTicks = 42;
|
||||
const presentationLifetimeTicks = DEAD_AIR_TICK_RATE * 7;
|
||||
|
||||
const inputCodec = createJsonCodec<DeadAirInput>();
|
||||
const stateCodec = createJsonCodec<DeadAirClientState>();
|
||||
const eventCodec = createJsonCodec<DeadAirPerception>();
|
||||
|
||||
const baseDeadAirGame = defineMultiplayerGame<DeadAirGameContract>({
|
||||
clock: {
|
||||
ticksPerSecond: DEAD_AIR_TICK_RATE,
|
||||
snapshotsPerSecond: DEAD_AIR_SNAPSHOT_RATE,
|
||||
},
|
||||
|
||||
authority: {
|
||||
createInitialState: createAuthorityState,
|
||||
cloneState: cloneAuthorityState,
|
||||
addPlayer(state, { playerId }) {
|
||||
state.players.push(createPlayer(playerId, false, state.round));
|
||||
state.players.sort((left, right) => left.id - right.id);
|
||||
},
|
||||
removePlayer(state, { playerId, emit }) {
|
||||
const player = findPlayer(state, playerId);
|
||||
if (player?.carryingArtifact) dropArtifact(state, player, emit);
|
||||
state.players = state.players.filter((candidate) => candidate.id !== playerId);
|
||||
},
|
||||
applyInput(state, input, { playerId, emit }) {
|
||||
const player = findPlayer(state, playerId);
|
||||
if (!player || player.bot || !player.alive || state.resetTicks > 0) return;
|
||||
applyCommand(player, input);
|
||||
if (input.reload) beginReload(player);
|
||||
if (input.toggleFlashlight) player.flashlight = !player.flashlight;
|
||||
if (input.interact) interact(state, player, emit);
|
||||
if (input.throwDecoy) throwDecoy(state, player, emit);
|
||||
},
|
||||
step(state, { tick, deltaSeconds, emit }) {
|
||||
if (state.resetTicks > 0) {
|
||||
state.resetTicks -= 1;
|
||||
if (state.resetTicks === 0) resetRound(state);
|
||||
return;
|
||||
}
|
||||
|
||||
state.elapsedTicks += 1;
|
||||
if (state.elapsedTicks % 75 === 0) {
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "noise",
|
||||
sourceId: null,
|
||||
cue: "curse",
|
||||
x: state.artifactX,
|
||||
z: state.artifactZ,
|
||||
loudness: 27,
|
||||
});
|
||||
}
|
||||
for (const player of state.players) {
|
||||
if (!player.alive) {
|
||||
updateRespawn(state, player);
|
||||
continue;
|
||||
}
|
||||
updateTimers(player);
|
||||
if (player.botState) updateWarden(state, player, tick);
|
||||
simulatePlayer(player, deltaSeconds);
|
||||
emitFootsteps(state, player, emit);
|
||||
if (player.firing) fire(state, player, emit);
|
||||
}
|
||||
|
||||
const carrier = state.artifactCarrierId === null
|
||||
? null
|
||||
: findPlayer(state, state.artifactCarrierId);
|
||||
if (carrier?.alive) {
|
||||
state.artifactX = carrier.x;
|
||||
state.artifactZ = carrier.z;
|
||||
if (
|
||||
!carrier.bot &&
|
||||
Math.hypot(carrier.x - DEAD_AIR_EXTRACTION.x, carrier.z - DEAD_AIR_EXTRACTION.z) <=
|
||||
DEAD_AIR_EXTRACTION.radius
|
||||
) {
|
||||
carrier.extractions += 1;
|
||||
state.lastExtractorId = carrier.id;
|
||||
state.artifactCarrierId = null;
|
||||
carrier.carryingArtifact = false;
|
||||
state.resetTicks = DEAD_AIR_TICK_RATE * 4;
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "artifact",
|
||||
action: "extracted",
|
||||
actorId: carrier.id,
|
||||
x: carrier.x,
|
||||
z: carrier.z,
|
||||
round: state.round,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
validateState(state) {
|
||||
return (
|
||||
Number.isInteger(state.round) &&
|
||||
state.round > 0 &&
|
||||
Number.isFinite(state.artifactX + state.artifactZ) &&
|
||||
state.players.every(
|
||||
(player) =>
|
||||
Number.isFinite(
|
||||
player.x + player.z + player.velocityX + player.velocityZ + player.yaw + player.pitch,
|
||||
) &&
|
||||
player.health >= 0 &&
|
||||
player.health <= 100 &&
|
||||
player.magazine >= 0 &&
|
||||
player.reserve >= 0,
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
prediction: {
|
||||
createInitialState: createClientState,
|
||||
cloneState: cloneClientState,
|
||||
applyInput(state, input, { playerId }) {
|
||||
const player = state.players.find((candidate) => candidate.id === playerId);
|
||||
if (!player || !player.alive || state.resetTicks > 0) return;
|
||||
player.inputForward = input.forward;
|
||||
player.inputStrafe = input.strafe;
|
||||
player.yaw = normalizeAngle(input.yaw);
|
||||
player.pitch = clamp(input.pitch, -1.2, 1.2);
|
||||
player.firing = input.fire;
|
||||
player.sprinting = input.sprint;
|
||||
if (input.reload && player.reloadTicks === 0 && (player.magazine ?? 0) < DEAD_AIR_MAGAZINE_SIZE) {
|
||||
player.reloadTicks = reloadDurationTicks;
|
||||
}
|
||||
if (input.toggleFlashlight) player.flashlight = !player.flashlight;
|
||||
},
|
||||
step(state, { tick, deltaSeconds }) {
|
||||
if (state.resetTicks > 0) state.resetTicks -= 1;
|
||||
state.elapsedTicks += 1;
|
||||
for (const player of state.players) {
|
||||
if (!player.alive) {
|
||||
if (player.respawnTicks > 0) player.respawnTicks -= 1;
|
||||
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 - presentationLifetimeTicks,
|
||||
);
|
||||
},
|
||||
mergeSnapshot(predicted, snapshot, { tick }) {
|
||||
const merged = cloneClientState(snapshot);
|
||||
merged.events = predicted.events
|
||||
.filter((entry) => entry.receivedTick >= tick - presentationLifetimeTicks)
|
||||
.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 > 72) state.events.shift();
|
||||
},
|
||||
validateState(state) {
|
||||
return (
|
||||
Number.isInteger(state.round) &&
|
||||
state.players.every(
|
||||
(player) =>
|
||||
Number.isFinite(player.x + player.z + player.yaw + player.pitch) &&
|
||||
(player.health === null || (player.health >= 0 && player.health <= 100)),
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
visibility: {
|
||||
createSnapshot(authority, { playerId }) {
|
||||
const viewer = findPlayer(authority, playerId);
|
||||
const visiblePlayers = viewer
|
||||
? authority.players
|
||||
.filter(
|
||||
(target) => target.id === viewer.id || canSeePlayer(authority, viewer, target),
|
||||
)
|
||||
.map((player) => playerView(player, player.id === viewer.id))
|
||||
: [];
|
||||
const carrier = authority.artifactCarrierId === null
|
||||
? null
|
||||
: findPlayer(authority, authority.artifactCarrierId);
|
||||
const visibleCarrier = carrier && visiblePlayers.some((player) => player.id === carrier.id);
|
||||
const artifactVisible = Boolean(
|
||||
viewer &&
|
||||
(carrier?.id === viewer.id ||
|
||||
visibleCarrier ||
|
||||
(!carrier && canSeePoint(authority, viewer, authority.artifactX, authority.artifactZ))),
|
||||
);
|
||||
return {
|
||||
players: visiblePlayers,
|
||||
artifact: {
|
||||
visible: artifactVisible,
|
||||
x: artifactVisible ? authority.artifactX : null,
|
||||
z: artifactVisible ? authority.artifactZ : null,
|
||||
carrierId: artifactVisible ? authority.artifactCarrierId : null,
|
||||
},
|
||||
powerOn: authority.powerOn,
|
||||
elapsedTicks: authority.elapsedTicks,
|
||||
round: authority.round,
|
||||
resetTicks: authority.resetTicks,
|
||||
lastExtractorId: authority.lastExtractorId === playerId ? playerId : null,
|
||||
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 &&
|
||||
reported.magazine === expected.magazine &&
|
||||
Math.hypot(reported.x - expected.x, reported.z - expected.z) <= 3.5,
|
||||
);
|
||||
},
|
||||
perceive(authority, event, { playerId }) {
|
||||
return perceive(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.25 &&
|
||||
input.pitch <= 1.25 &&
|
||||
typeof input.fire === "boolean" &&
|
||||
typeof input.sprint === "boolean" &&
|
||||
typeof input.reload === "boolean" &&
|
||||
typeof input.interact === "boolean" &&
|
||||
typeof input.toggleFlashlight === "boolean" &&
|
||||
typeof input.throwDecoy === "boolean"
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
encoding: {
|
||||
input: inputCodec,
|
||||
clientState: stateCodec,
|
||||
perception: eventCodec,
|
||||
},
|
||||
});
|
||||
|
||||
export const deadAirGame = withInputStream(baseDeadAirGame, {
|
||||
heartbeatRateHz: 20,
|
||||
timeoutMs: 400,
|
||||
inputsEqual(left, right) {
|
||||
return (
|
||||
left.forward === right.forward &&
|
||||
left.strafe === right.strafe &&
|
||||
left.yaw === right.yaw &&
|
||||
left.pitch === right.pitch &&
|
||||
left.fire === right.fire &&
|
||||
left.sprint === right.sprint &&
|
||||
left.reload === right.reload &&
|
||||
left.interact === right.interact &&
|
||||
left.toggleFlashlight === right.toggleFlashlight &&
|
||||
left.throwDecoy === right.throwDecoy
|
||||
);
|
||||
},
|
||||
neutralize(lastInput) {
|
||||
return {
|
||||
...lastInput,
|
||||
forward: 0,
|
||||
strafe: 0,
|
||||
fire: false,
|
||||
sprint: false,
|
||||
reload: false,
|
||||
interact: false,
|
||||
toggleFlashlight: false,
|
||||
throwDecoy: false,
|
||||
};
|
||||
},
|
||||
resume(lastInput) {
|
||||
return {
|
||||
...lastInput,
|
||||
reload: false,
|
||||
interact: false,
|
||||
toggleFlashlight: false,
|
||||
throwDecoy: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function createAuthorityState(): DeadAirAuthorityState {
|
||||
const players: DeadAirAuthorityPlayer[] = [];
|
||||
for (let index = 0; index < DEAD_AIR_WARDEN_COUNT; index += 1) {
|
||||
players.push(createPlayer(DEAD_AIR_WARDEN_ID_BASE + index, true, 1));
|
||||
}
|
||||
return {
|
||||
players,
|
||||
artifactX: DEAD_AIR_ARTIFACT_SPAWN.x,
|
||||
artifactZ: DEAD_AIR_ARTIFACT_SPAWN.z,
|
||||
artifactCarrierId: null,
|
||||
powerOn: true,
|
||||
elapsedTicks: 0,
|
||||
round: 1,
|
||||
resetTicks: 0,
|
||||
lastExtractorId: null,
|
||||
nextEventId: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function createClientState(): DeadAirClientState {
|
||||
return {
|
||||
players: [],
|
||||
artifact: { visible: false, x: null, z: null, carrierId: null },
|
||||
powerOn: true,
|
||||
elapsedTicks: 0,
|
||||
round: 1,
|
||||
resetTicks: 0,
|
||||
lastExtractorId: null,
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createPlayer(id: number, bot: boolean, round: number): DeadAirAuthorityPlayer {
|
||||
const spawn = bot
|
||||
? DEAD_AIR_WARDEN_SPAWNS[(id - DEAD_AIR_WARDEN_ID_BASE) % DEAD_AIR_WARDEN_SPAWNS.length]!
|
||||
: DEAD_AIR_SPAWNS[(id + round) % DEAD_AIR_SPAWNS.length]!;
|
||||
return {
|
||||
id,
|
||||
bot,
|
||||
alive: true,
|
||||
x: spawn.x,
|
||||
z: spawn.z,
|
||||
velocityX: 0,
|
||||
velocityZ: 0,
|
||||
yaw: spawn.yaw,
|
||||
pitch: 0,
|
||||
health: 100,
|
||||
flashlight: true,
|
||||
carryingArtifact: false,
|
||||
firing: false,
|
||||
sprinting: false,
|
||||
inputForward: 0,
|
||||
inputStrafe: 0,
|
||||
cooldownTicks: 0,
|
||||
reloadTicks: 0,
|
||||
respawnTicks: 0,
|
||||
magazine: DEAD_AIR_MAGAZINE_SIZE,
|
||||
reserve: 32,
|
||||
decoys: bot ? 0 : 3,
|
||||
extractions: 0,
|
||||
footstepTicks: 0,
|
||||
botState: bot
|
||||
? { phase: id * 0.71, targetId: null, lastKnownX: spawn.x, lastKnownZ: spawn.z }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function applyCommand(player: DeadAirAuthorityPlayer, input: DeadAirInput): void {
|
||||
player.inputForward = input.forward;
|
||||
player.inputStrafe = input.strafe;
|
||||
player.yaw = normalizeAngle(input.yaw);
|
||||
player.pitch = clamp(input.pitch, -1.2, 1.2);
|
||||
player.firing = input.fire;
|
||||
player.sprinting = input.sprint;
|
||||
}
|
||||
|
||||
function updateTimers(player: DeadAirAuthorityPlayer): void {
|
||||
if (player.cooldownTicks > 0) player.cooldownTicks -= 1;
|
||||
if (player.footstepTicks > 0) player.footstepTicks -= 1;
|
||||
if (player.reloadTicks > 0) {
|
||||
player.reloadTicks -= 1;
|
||||
if (player.reloadTicks === 0) completeReload(player);
|
||||
}
|
||||
}
|
||||
|
||||
function simulatePlayer(player: DeadAirAuthorityPlayer, deltaSeconds: number): void {
|
||||
const speed = player.sprinting && !player.carryingArtifact ? sprintSpeed : walkSpeed;
|
||||
const sin = Math.sin(player.yaw);
|
||||
const cos = Math.cos(player.yaw);
|
||||
let desiredX = (sin * player.inputForward + cos * player.inputStrafe) * speed;
|
||||
let desiredZ = (cos * player.inputForward - sin * player.inputStrafe) * speed;
|
||||
const inputLength = Math.hypot(player.inputForward, player.inputStrafe);
|
||||
if (inputLength > 1) {
|
||||
desiredX /= inputLength;
|
||||
desiredZ /= inputLength;
|
||||
}
|
||||
const blend = Math.min(1, movementAcceleration * deltaSeconds);
|
||||
player.velocityX += (desiredX - player.velocityX) * blend;
|
||||
player.velocityZ += (desiredZ - player.velocityZ) * blend;
|
||||
if (inputLength < 0.01) {
|
||||
const damping = Math.max(0, 1 - movementFriction * deltaSeconds);
|
||||
player.velocityX *= damping;
|
||||
player.velocityZ *= damping;
|
||||
}
|
||||
moveWithCollision(player, deltaSeconds);
|
||||
}
|
||||
|
||||
function simulateVisiblePlayer(player: DeadAirPlayerView, deltaSeconds: number): void {
|
||||
const speed = player.sprinting && !player.carryingArtifact ? sprintSpeed : walkSpeed;
|
||||
const sin = Math.sin(player.yaw);
|
||||
const cos = Math.cos(player.yaw);
|
||||
let desiredX = (sin * player.inputForward + cos * player.inputStrafe) * speed;
|
||||
let desiredZ = (cos * player.inputForward - sin * player.inputStrafe) * speed;
|
||||
const inputLength = Math.hypot(player.inputForward, player.inputStrafe);
|
||||
if (inputLength > 1) {
|
||||
desiredX /= inputLength;
|
||||
desiredZ /= inputLength;
|
||||
}
|
||||
const blend = Math.min(1, movementAcceleration * deltaSeconds);
|
||||
player.velocityX += (desiredX - player.velocityX) * blend;
|
||||
player.velocityZ += (desiredZ - player.velocityZ) * blend;
|
||||
if (inputLength < 0.01) {
|
||||
const damping = Math.max(0, 1 - movementFriction * deltaSeconds);
|
||||
player.velocityX *= damping;
|
||||
player.velocityZ *= damping;
|
||||
}
|
||||
moveWithCollision(player, deltaSeconds);
|
||||
}
|
||||
|
||||
function moveWithCollision(
|
||||
player: Pick<DeadAirPlayerView, "x" | "z" | "velocityX" | "velocityZ">,
|
||||
deltaSeconds: number,
|
||||
): void {
|
||||
const nextX = player.x + player.velocityX * deltaSeconds;
|
||||
if (!collidesWithDeadAirMap(nextX, player.z)) player.x = nextX;
|
||||
else player.velocityX = 0;
|
||||
const nextZ = player.z + player.velocityZ * deltaSeconds;
|
||||
if (!collidesWithDeadAirMap(player.x, nextZ)) player.z = nextZ;
|
||||
else player.velocityZ = 0;
|
||||
}
|
||||
|
||||
function emitFootsteps(
|
||||
state: DeadAirAuthorityState,
|
||||
player: DeadAirAuthorityPlayer,
|
||||
emit: (event: DeadAirAuthorityEvent) => void,
|
||||
): void {
|
||||
if (Math.hypot(player.velocityX, player.velocityZ) < 1.2 || player.footstepTicks > 0) return;
|
||||
player.footstepTicks = player.sprinting ? 7 : 12;
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "noise",
|
||||
sourceId: player.id,
|
||||
cue: "footstep",
|
||||
x: player.x,
|
||||
z: player.z,
|
||||
loudness: player.sprinting ? 20 : player.carryingArtifact ? 14 : 9,
|
||||
});
|
||||
}
|
||||
|
||||
function fire(
|
||||
state: DeadAirAuthorityState,
|
||||
shooter: DeadAirAuthorityPlayer,
|
||||
emit: (event: DeadAirAuthorityEvent) => void,
|
||||
): void {
|
||||
if (shooter.cooldownTicks > 0 || shooter.reloadTicks > 0 || shooter.magazine <= 0) {
|
||||
if (shooter.magazine <= 0) beginReload(shooter);
|
||||
return;
|
||||
}
|
||||
shooter.magazine -= 1;
|
||||
shooter.cooldownTicks = shotCooldownTicks;
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "shot",
|
||||
sourceId: shooter.id,
|
||||
x: shooter.x,
|
||||
z: shooter.z,
|
||||
yaw: shooter.yaw,
|
||||
pitch: shooter.pitch,
|
||||
});
|
||||
|
||||
if (Math.abs(shooter.pitch) > 0.48) return;
|
||||
const directionX = Math.sin(shooter.yaw);
|
||||
const directionZ = Math.cos(shooter.yaw);
|
||||
let hit: DeadAirAuthorityPlayer | null = null;
|
||||
let hitDistance = shotRange;
|
||||
for (const target of state.players) {
|
||||
if (!target.alive || target.id === shooter.id || target.bot === shooter.bot) continue;
|
||||
const offsetX = target.x - shooter.x;
|
||||
const offsetZ = target.z - shooter.z;
|
||||
const forwardDistance = offsetX * directionX + offsetZ * directionZ;
|
||||
const crossDistance = Math.abs(offsetX * directionZ - offsetZ * directionX);
|
||||
if (
|
||||
forwardDistance <= 0 ||
|
||||
forwardDistance >= hitDistance ||
|
||||
crossDistance > 0.78 ||
|
||||
!hasDeadAirLineOfSight(shooter.x, shooter.z, target.x, target.z)
|
||||
) continue;
|
||||
hit = target;
|
||||
hitDistance = forwardDistance;
|
||||
}
|
||||
if (!hit) return;
|
||||
hit.health = Math.max(0, hit.health - shotDamage);
|
||||
const eliminated = hit.health === 0;
|
||||
if (eliminated) eliminate(state, hit, emit);
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "damage",
|
||||
sourceId: shooter.id,
|
||||
targetId: hit.id,
|
||||
amount: shotDamage,
|
||||
health: hit.health,
|
||||
eliminated,
|
||||
x: hit.x,
|
||||
z: hit.z,
|
||||
});
|
||||
}
|
||||
|
||||
function eliminate(
|
||||
state: DeadAirAuthorityState,
|
||||
player: DeadAirAuthorityPlayer,
|
||||
emit: (event: DeadAirAuthorityEvent) => void,
|
||||
): void {
|
||||
player.alive = false;
|
||||
player.respawnTicks = DEAD_AIR_TICK_RATE * 3;
|
||||
player.velocityX = 0;
|
||||
player.velocityZ = 0;
|
||||
player.firing = false;
|
||||
if (player.carryingArtifact) dropArtifact(state, player, emit);
|
||||
}
|
||||
|
||||
function updateRespawn(state: DeadAirAuthorityState, player: DeadAirAuthorityPlayer): void {
|
||||
if (player.respawnTicks > 0) player.respawnTicks -= 1;
|
||||
if (player.respawnTicks > 0) return;
|
||||
const fresh = createPlayer(player.id, player.bot, state.round);
|
||||
const extractions = player.extractions;
|
||||
Object.assign(player, fresh, { extractions });
|
||||
}
|
||||
|
||||
function beginReload(player: DeadAirAuthorityPlayer): void {
|
||||
if (
|
||||
player.reloadTicks > 0 ||
|
||||
player.magazine >= DEAD_AIR_MAGAZINE_SIZE ||
|
||||
player.reserve <= 0
|
||||
) return;
|
||||
player.reloadTicks = reloadDurationTicks;
|
||||
player.firing = false;
|
||||
}
|
||||
|
||||
function completeReload(player: DeadAirAuthorityPlayer): void {
|
||||
const needed = DEAD_AIR_MAGAZINE_SIZE - player.magazine;
|
||||
const loaded = Math.min(needed, player.reserve);
|
||||
player.magazine += loaded;
|
||||
player.reserve -= loaded;
|
||||
}
|
||||
|
||||
function interact(
|
||||
state: DeadAirAuthorityState,
|
||||
player: DeadAirAuthorityPlayer,
|
||||
emit: (event: DeadAirAuthorityEvent) => void,
|
||||
): void {
|
||||
if (player.carryingArtifact) {
|
||||
dropArtifact(state, player, emit);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
state.artifactCarrierId === null &&
|
||||
Math.hypot(player.x - state.artifactX, player.z - state.artifactZ) <= 2.3
|
||||
) {
|
||||
state.artifactCarrierId = player.id;
|
||||
player.carryingArtifact = true;
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "artifact",
|
||||
action: "grabbed",
|
||||
actorId: player.id,
|
||||
x: player.x,
|
||||
z: player.z,
|
||||
round: state.round,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
Math.hypot(player.x - DEAD_AIR_POWER_SWITCH.x, player.z - DEAD_AIR_POWER_SWITCH.z) <= 2.5
|
||||
) {
|
||||
state.powerOn = !state.powerOn;
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "power",
|
||||
on: state.powerOn,
|
||||
actorId: player.id,
|
||||
x: player.x,
|
||||
z: player.z,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function dropArtifact(
|
||||
state: DeadAirAuthorityState,
|
||||
player: DeadAirAuthorityPlayer,
|
||||
emit: (event: DeadAirAuthorityEvent) => void,
|
||||
): void {
|
||||
player.carryingArtifact = false;
|
||||
state.artifactCarrierId = null;
|
||||
state.artifactX = player.x;
|
||||
state.artifactZ = player.z;
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "artifact",
|
||||
action: "dropped",
|
||||
actorId: player.id,
|
||||
x: player.x,
|
||||
z: player.z,
|
||||
round: state.round,
|
||||
});
|
||||
}
|
||||
|
||||
function throwDecoy(
|
||||
state: DeadAirAuthorityState,
|
||||
player: DeadAirAuthorityPlayer,
|
||||
emit: (event: DeadAirAuthorityEvent) => void,
|
||||
): void {
|
||||
if (player.decoys <= 0) return;
|
||||
player.decoys -= 1;
|
||||
const candidateX = player.x + Math.sin(player.yaw) * 8;
|
||||
const candidateZ = player.z + Math.cos(player.yaw) * 8;
|
||||
emit({
|
||||
id: state.nextEventId++,
|
||||
type: "noise",
|
||||
sourceId: null,
|
||||
cue: "decoy",
|
||||
x: collidesWithDeadAirMap(candidateX, candidateZ, 0.1) ? player.x : candidateX,
|
||||
z: collidesWithDeadAirMap(candidateX, candidateZ, 0.1) ? player.z : candidateZ,
|
||||
loudness: 32,
|
||||
});
|
||||
}
|
||||
|
||||
function updateWarden(
|
||||
state: DeadAirAuthorityState,
|
||||
warden: DeadAirAuthorityPlayer,
|
||||
tick: number,
|
||||
): void {
|
||||
const visibleTargets = state.players.filter(
|
||||
(target) => !target.bot && target.alive && canSeePlayer(state, warden, target),
|
||||
);
|
||||
visibleTargets.sort(
|
||||
(left, right) =>
|
||||
Math.hypot(left.x - warden.x, left.z - warden.z) -
|
||||
Math.hypot(right.x - warden.x, right.z - warden.z),
|
||||
);
|
||||
const target = visibleTargets[0] ?? null;
|
||||
const botState = warden.botState!;
|
||||
botState.targetId = target?.id ?? null;
|
||||
if (target) {
|
||||
botState.lastKnownX = target.x;
|
||||
botState.lastKnownZ = target.z;
|
||||
}
|
||||
|
||||
let targetX: number;
|
||||
let targetZ: number;
|
||||
if (target) {
|
||||
targetX = target.x;
|
||||
targetZ = target.z;
|
||||
} else if (state.artifactCarrierId !== null) {
|
||||
targetX = state.artifactX;
|
||||
targetZ = state.artifactZ;
|
||||
} else {
|
||||
const angle = botState.phase + tick * 0.008;
|
||||
targetX = DEAD_AIR_ARTIFACT_SPAWN.x + Math.sin(angle) * 22;
|
||||
targetZ = DEAD_AIR_ARTIFACT_SPAWN.z + Math.cos(angle * 0.83) * 22;
|
||||
if (collidesWithDeadAirMap(targetX, targetZ, 0.8)) {
|
||||
targetX = DEAD_AIR_ARTIFACT_SPAWN.x;
|
||||
targetZ = DEAD_AIR_ARTIFACT_SPAWN.z;
|
||||
}
|
||||
}
|
||||
const desiredYaw = Math.atan2(targetX - warden.x, targetZ - warden.z);
|
||||
warden.yaw = rotateToward(warden.yaw, desiredYaw, target ? 0.14 : 0.055);
|
||||
const distance = Math.hypot(targetX - warden.x, targetZ - warden.z);
|
||||
warden.inputForward = distance > (target ? 6 : 3) ? 0.76 : 0;
|
||||
warden.inputStrafe = target ? Math.sin(tick * 0.045 + warden.id) * 0.25 : 0;
|
||||
warden.sprinting = false;
|
||||
warden.pitch = 0;
|
||||
warden.firing = Boolean(target && distance < shotRange - 2 && angleDifference(warden.yaw, desiredYaw) < 0.13);
|
||||
if (warden.magazine === 0) beginReload(warden);
|
||||
}
|
||||
|
||||
function canSeePlayer(
|
||||
state: DeadAirAuthorityState,
|
||||
viewer: DeadAirAuthorityPlayer,
|
||||
target: DeadAirAuthorityPlayer,
|
||||
): boolean {
|
||||
if (!target.alive || !viewer.alive || target.id === viewer.id) return target.id === viewer.id;
|
||||
const distance = Math.hypot(target.x - viewer.x, target.z - viewer.z);
|
||||
if (distance > 38 || !hasDeadAirLineOfSight(viewer.x, viewer.z, target.x, target.z)) return false;
|
||||
if (distance <= 2.2) return true;
|
||||
if (target.flashlight && distance <= 34) return true;
|
||||
if (target.carryingArtifact && distance <= 15) return true;
|
||||
return canSeePoint(state, viewer, target.x, target.z);
|
||||
}
|
||||
|
||||
function canSeePoint(
|
||||
state: DeadAirAuthorityState,
|
||||
viewer: DeadAirAuthorityPlayer,
|
||||
x: number,
|
||||
z: number,
|
||||
): boolean {
|
||||
const distance = Math.hypot(x - viewer.x, z - viewer.z);
|
||||
if (!hasDeadAirLineOfSight(viewer.x, viewer.z, x, z)) return false;
|
||||
if (distance <= 2.2 || (state.powerOn && distance <= 9.5)) return true;
|
||||
if (!viewer.flashlight || distance > 28) return false;
|
||||
const bearing = Math.atan2(x - viewer.x, z - viewer.z);
|
||||
return angleDifference(viewer.yaw, bearing) <= 0.62;
|
||||
}
|
||||
|
||||
function perceive(
|
||||
state: DeadAirAuthorityState,
|
||||
event: DeadAirAuthorityEvent,
|
||||
playerId: number,
|
||||
): DeadAirPerception | null {
|
||||
const viewer = findPlayer(state, playerId);
|
||||
if (!viewer) return null;
|
||||
switch (event.type) {
|
||||
case "noise":
|
||||
if (event.sourceId === playerId) return null;
|
||||
return soundPerception(viewer, event.id, event.cue, event.x, event.z, event.loudness);
|
||||
case "shot": {
|
||||
const source = findPlayer(state, event.sourceId);
|
||||
if (
|
||||
event.sourceId === playerId ||
|
||||
(source && canSeePlayer(state, viewer, source)) ||
|
||||
canSeePoint(state, viewer, event.x, event.z)
|
||||
) {
|
||||
return { id: event.id, type: "muzzle", x: event.x, z: event.z, yaw: event.yaw, pitch: event.pitch };
|
||||
}
|
||||
return soundPerception(viewer, event.id, "gunshot", event.x, event.z, 52);
|
||||
}
|
||||
case "damage":
|
||||
if (event.targetId === playerId) {
|
||||
return {
|
||||
id: event.id,
|
||||
type: "damage",
|
||||
amount: event.amount,
|
||||
health: event.health,
|
||||
eliminated: event.eliminated,
|
||||
};
|
||||
}
|
||||
if (event.sourceId === playerId) {
|
||||
return { id: event.id, type: "hit", amount: event.amount, eliminated: event.eliminated };
|
||||
}
|
||||
return soundPerception(viewer, event.id, "impact", event.x, event.z, 13);
|
||||
case "artifact": {
|
||||
const actor = findPlayer(state, event.actorId);
|
||||
const actorVisible = event.actorId === playerId || Boolean(actor && canSeePlayer(state, viewer, actor));
|
||||
return {
|
||||
id: event.id,
|
||||
type: "artifact",
|
||||
action: event.action,
|
||||
actorId: actorVisible ? event.actorId : null,
|
||||
round: event.round,
|
||||
};
|
||||
}
|
||||
case "power":
|
||||
return { id: event.id, type: "power", on: event.on };
|
||||
}
|
||||
}
|
||||
|
||||
function soundPerception(
|
||||
viewer: DeadAirAuthorityPlayer,
|
||||
id: number,
|
||||
cue: DeadAirSoundCue,
|
||||
x: number,
|
||||
z: number,
|
||||
loudness: number,
|
||||
): DeadAirPerception | null {
|
||||
const distance = Math.hypot(x - viewer.x, z - viewer.z);
|
||||
if (distance > loudness) return null;
|
||||
const relative = normalizeAngle(Math.atan2(x - viewer.x, z - viewer.z) - viewer.yaw);
|
||||
const direction = ((Math.round(relative / (Math.PI / 4)) % 8) + 8) % 8;
|
||||
const band: DeadAirDistanceBand = distance < loudness * 0.28 ? "near" : distance < loudness * 0.64 ? "mid" : "far";
|
||||
return {
|
||||
id,
|
||||
type: "sound",
|
||||
cue,
|
||||
direction,
|
||||
distance: band,
|
||||
intensity: clamp(Math.round((1 - distance / loudness) * 3) + 1, 1, 3),
|
||||
};
|
||||
}
|
||||
|
||||
function resetRound(state: DeadAirAuthorityState): void {
|
||||
state.round += 1;
|
||||
state.elapsedTicks = 0;
|
||||
state.lastExtractorId = null;
|
||||
state.artifactX = DEAD_AIR_ARTIFACT_SPAWN.x;
|
||||
state.artifactZ = DEAD_AIR_ARTIFACT_SPAWN.z;
|
||||
state.artifactCarrierId = null;
|
||||
state.powerOn = true;
|
||||
for (const player of state.players) {
|
||||
const fresh = createPlayer(player.id, player.bot, state.round);
|
||||
Object.assign(player, fresh, { extractions: player.extractions });
|
||||
}
|
||||
}
|
||||
|
||||
function playerView(player: DeadAirAuthorityPlayer, own: boolean): DeadAirPlayerView {
|
||||
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,
|
||||
health: own ? player.health : null,
|
||||
flashlight: player.flashlight,
|
||||
carryingArtifact: player.carryingArtifact,
|
||||
firing: player.firing,
|
||||
sprinting: player.sprinting,
|
||||
cooldownTicks: player.cooldownTicks,
|
||||
reloadTicks: player.reloadTicks,
|
||||
respawnTicks: player.respawnTicks,
|
||||
magazine: own ? player.magazine : null,
|
||||
reserve: own ? player.reserve : null,
|
||||
decoys: own ? player.decoys : null,
|
||||
extractions: player.extractions,
|
||||
inputForward: own ? player.inputForward : 0,
|
||||
inputStrafe: own ? player.inputStrafe : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneAuthorityState(state: DeadAirAuthorityState): DeadAirAuthorityState {
|
||||
return {
|
||||
...state,
|
||||
players: state.players.map((player) => ({
|
||||
...player,
|
||||
botState: player.botState ? { ...player.botState } : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneClientState(state: DeadAirClientState): DeadAirClientState {
|
||||
return {
|
||||
...state,
|
||||
players: state.players.map((player) => ({ ...player })),
|
||||
artifact: { ...state.artifact },
|
||||
events: state.events.map((entry) => ({
|
||||
receivedTick: entry.receivedTick,
|
||||
event: { ...entry.event },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function findPlayer(state: DeadAirAuthorityState, id: number): DeadAirAuthorityPlayer | undefined {
|
||||
return state.players.find((player) => player.id === id);
|
||||
}
|
||||
|
||||
function angleDifference(left: number, right: number): number {
|
||||
return Math.abs(normalizeAngle(left - right));
|
||||
}
|
||||
|
||||
function rotateToward(current: number, target: number, maximum: number): number {
|
||||
const difference = normalizeAngle(target - current);
|
||||
return normalizeAngle(current + clamp(difference, -maximum, maximum));
|
||||
}
|
||||
|
||||
function normalizeAngle(value: number): number {
|
||||
return Math.atan2(Math.sin(value), Math.cos(value));
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
127
packages/shared/src/dead-air-map.ts
Normal file
127
packages/shared/src/dead-air-map.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
export const DEAD_AIR_MAP_SIZE = 72;
|
||||
export const DEAD_AIR_PLAYER_RADIUS = 0.56;
|
||||
export const DEAD_AIR_EXTRACTION = Object.freeze({ x: -29, z: 29, radius: 4.2 });
|
||||
export const DEAD_AIR_ARTIFACT_SPAWN = Object.freeze({ x: 2, z: -1 });
|
||||
export const DEAD_AIR_POWER_SWITCH = Object.freeze({ x: 29, z: -29 });
|
||||
|
||||
export interface DeadAirWall {
|
||||
id: string;
|
||||
x: number;
|
||||
z: number;
|
||||
width: number;
|
||||
depth: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Public static geometry. Secret state is never encoded into the level. */
|
||||
export const DEAD_AIR_WALLS: readonly DeadAirWall[] = Object.freeze([
|
||||
{ id: "north", x: 0, z: -36, width: 73, depth: 1.2, height: 4.8 },
|
||||
{ id: "south", x: 0, z: 36, width: 73, depth: 1.2, height: 4.8 },
|
||||
{ id: "west", x: -36, z: 0, width: 1.2, depth: 73, height: 4.8 },
|
||||
{ id: "east", x: 36, z: 0, width: 1.2, depth: 73, height: 4.8 },
|
||||
|
||||
{ id: "loading-a", x: -27, z: -20, width: 17, depth: 1, height: 4.2 },
|
||||
{ id: "loading-b", x: -5, z: -20, width: 13, depth: 1, height: 4.2 },
|
||||
{ id: "loading-c", x: 21.5, z: -20, width: 25, depth: 1, height: 4.2 },
|
||||
|
||||
{ id: "west-a", x: -16, z: -28, width: 1, depth: 15, height: 4.2 },
|
||||
{ id: "west-b", x: -16, z: -7, width: 1, depth: 21, height: 4.2 },
|
||||
{ id: "west-c", x: -16, z: 27, width: 1, depth: 18, height: 4.2 },
|
||||
|
||||
{ id: "east-a", x: 16, z: -27, width: 1, depth: 17, height: 4.2 },
|
||||
{ id: "east-b", x: 16, z: 0, width: 1, depth: 21, height: 4.2 },
|
||||
{ id: "east-c", x: 16, z: 27, width: 1, depth: 17, height: 4.2 },
|
||||
|
||||
{ id: "office-a", x: -27, z: 15, width: 17, depth: 1, height: 4.2 },
|
||||
{ id: "office-b", x: -6, z: 15, width: 11, depth: 1, height: 4.2 },
|
||||
{ id: "office-c", x: 23, z: 15, width: 25, depth: 1, height: 4.2 },
|
||||
|
||||
{ id: "archive-a", x: -27, z: -2, width: 17, depth: 1, height: 3.8 },
|
||||
{ id: "archive-b", x: 27, z: -2, width: 17, depth: 1, height: 3.8 },
|
||||
{ id: "vault-north", x: 1, z: -9, width: 15, depth: 1, height: 4.4 },
|
||||
{ id: "vault-south-a", x: -4, z: 7, width: 5, depth: 1, height: 4.4 },
|
||||
{ id: "vault-south-b", x: 7, z: 7, width: 5, depth: 1, height: 4.4 },
|
||||
]);
|
||||
|
||||
export const DEAD_AIR_SPAWNS = Object.freeze([
|
||||
{ x: -30, z: 29, yaw: Math.PI * 0.75 },
|
||||
{ x: -27, z: 31, yaw: Math.PI },
|
||||
{ x: -31, z: 25, yaw: Math.PI * 0.5 },
|
||||
{ x: -25, z: 27, yaw: Math.PI * 0.85 },
|
||||
]);
|
||||
|
||||
export const DEAD_AIR_WARDEN_SPAWNS = Object.freeze([
|
||||
{ x: 28, z: -28, yaw: 0 },
|
||||
{ x: -27, z: -28, yaw: 0 },
|
||||
{ x: 28, z: 27, yaw: Math.PI },
|
||||
{ x: 3, z: -14, yaw: 0 },
|
||||
{ x: 4, z: 11, yaw: Math.PI },
|
||||
]);
|
||||
|
||||
export function collidesWithDeadAirMap(
|
||||
x: number,
|
||||
z: number,
|
||||
radius = DEAD_AIR_PLAYER_RADIUS,
|
||||
): boolean {
|
||||
for (const wall of DEAD_AIR_WALLS) {
|
||||
if (
|
||||
Math.abs(x - wall.x) < wall.width / 2 + radius &&
|
||||
Math.abs(z - wall.z) < wall.depth / 2 + radius
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasDeadAirLineOfSight(
|
||||
fromX: number,
|
||||
fromZ: number,
|
||||
toX: number,
|
||||
toZ: number,
|
||||
): boolean {
|
||||
return !DEAD_AIR_WALLS.some((wall) =>
|
||||
segmentIntersectsRectangle(fromX, fromZ, toX, toZ, wall, 0.06),
|
||||
);
|
||||
}
|
||||
|
||||
function segmentIntersectsRectangle(
|
||||
fromX: number,
|
||||
fromZ: number,
|
||||
toX: number,
|
||||
toZ: number,
|
||||
wall: DeadAirWall,
|
||||
padding: number,
|
||||
): boolean {
|
||||
const minimumX = wall.x - wall.width / 2 - padding;
|
||||
const maximumX = wall.x + wall.width / 2 + padding;
|
||||
const minimumZ = wall.z - wall.depth / 2 - padding;
|
||||
const maximumZ = wall.z + wall.depth / 2 + padding;
|
||||
const deltaX = toX - fromX;
|
||||
const deltaZ = toZ - fromZ;
|
||||
let near = 0;
|
||||
let far = 1;
|
||||
|
||||
const clip = (denominator: number, numerator: number): boolean => {
|
||||
if (denominator === 0) return numerator >= 0;
|
||||
const ratio = numerator / denominator;
|
||||
if (denominator < 0) {
|
||||
if (ratio > far) return false;
|
||||
if (ratio > near) near = ratio;
|
||||
} else {
|
||||
if (ratio < near) return false;
|
||||
if (ratio < far) far = ratio;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
clip(-deltaX, fromX - minimumX) &&
|
||||
clip(deltaX, maximumX - fromX) &&
|
||||
clip(-deltaZ, fromZ - minimumZ) &&
|
||||
clip(deltaZ, maximumZ - fromZ) &&
|
||||
near <= far &&
|
||||
far > 0.001 &&
|
||||
near < 0.999
|
||||
);
|
||||
}
|
||||
190
packages/shared/src/dead-air-types.ts
Normal file
190
packages/shared/src/dead-air-types.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
export type DeadAirSoundCue =
|
||||
| "footstep"
|
||||
| "gunshot"
|
||||
| "impact"
|
||||
| "decoy"
|
||||
| "curse"
|
||||
| "power";
|
||||
|
||||
export type DeadAirDistanceBand = "near" | "mid" | "far";
|
||||
|
||||
export interface DeadAirInput {
|
||||
forward: number;
|
||||
strafe: number;
|
||||
yaw: number;
|
||||
pitch: number;
|
||||
fire: boolean;
|
||||
sprint: boolean;
|
||||
reload: boolean;
|
||||
interact: boolean;
|
||||
toggleFlashlight: boolean;
|
||||
throwDecoy: boolean;
|
||||
}
|
||||
|
||||
export interface DeadAirBotState {
|
||||
phase: number;
|
||||
targetId: number | null;
|
||||
lastKnownX: number;
|
||||
lastKnownZ: number;
|
||||
}
|
||||
|
||||
export interface DeadAirAuthorityPlayer {
|
||||
id: number;
|
||||
bot: boolean;
|
||||
alive: boolean;
|
||||
x: number;
|
||||
z: number;
|
||||
velocityX: number;
|
||||
velocityZ: number;
|
||||
yaw: number;
|
||||
pitch: number;
|
||||
health: number;
|
||||
flashlight: boolean;
|
||||
carryingArtifact: boolean;
|
||||
firing: boolean;
|
||||
sprinting: boolean;
|
||||
inputForward: number;
|
||||
inputStrafe: number;
|
||||
cooldownTicks: number;
|
||||
reloadTicks: number;
|
||||
respawnTicks: number;
|
||||
magazine: number;
|
||||
reserve: number;
|
||||
decoys: number;
|
||||
extractions: number;
|
||||
footstepTicks: number;
|
||||
botState: DeadAirBotState | null;
|
||||
}
|
||||
|
||||
export interface DeadAirAuthorityState {
|
||||
players: DeadAirAuthorityPlayer[];
|
||||
artifactX: number;
|
||||
artifactZ: number;
|
||||
artifactCarrierId: number | null;
|
||||
powerOn: boolean;
|
||||
elapsedTicks: number;
|
||||
round: number;
|
||||
resetTicks: number;
|
||||
lastExtractorId: number | null;
|
||||
nextEventId: number;
|
||||
}
|
||||
|
||||
export interface DeadAirPlayerView {
|
||||
id: number;
|
||||
bot: boolean;
|
||||
alive: boolean;
|
||||
x: number;
|
||||
z: number;
|
||||
velocityX: number;
|
||||
velocityZ: number;
|
||||
yaw: number;
|
||||
pitch: number;
|
||||
health: number | null;
|
||||
flashlight: boolean;
|
||||
carryingArtifact: boolean;
|
||||
firing: boolean;
|
||||
sprinting: boolean;
|
||||
cooldownTicks: number;
|
||||
reloadTicks: number;
|
||||
respawnTicks: number;
|
||||
magazine: number | null;
|
||||
reserve: number | null;
|
||||
decoys: number | null;
|
||||
extractions: number;
|
||||
inputForward: number;
|
||||
inputStrafe: number;
|
||||
}
|
||||
|
||||
export interface DeadAirArtifactView {
|
||||
visible: boolean;
|
||||
x: number | null;
|
||||
z: number | null;
|
||||
carrierId: number | null;
|
||||
}
|
||||
|
||||
export interface DeadAirClientState {
|
||||
players: DeadAirPlayerView[];
|
||||
artifact: DeadAirArtifactView;
|
||||
powerOn: boolean;
|
||||
elapsedTicks: number;
|
||||
round: number;
|
||||
resetTicks: number;
|
||||
lastExtractorId: number | null;
|
||||
events: DeadAirPresentationEvent[];
|
||||
}
|
||||
|
||||
export type DeadAirAuthorityEvent =
|
||||
| {
|
||||
id: number;
|
||||
type: "noise";
|
||||
sourceId: number | null;
|
||||
cue: DeadAirSoundCue;
|
||||
x: number;
|
||||
z: number;
|
||||
loudness: number;
|
||||
}
|
||||
| {
|
||||
id: number;
|
||||
type: "shot";
|
||||
sourceId: number;
|
||||
x: number;
|
||||
z: number;
|
||||
yaw: number;
|
||||
pitch: number;
|
||||
}
|
||||
| {
|
||||
id: number;
|
||||
type: "damage";
|
||||
sourceId: number;
|
||||
targetId: number;
|
||||
amount: number;
|
||||
health: number;
|
||||
eliminated: boolean;
|
||||
x: number;
|
||||
z: number;
|
||||
}
|
||||
| {
|
||||
id: number;
|
||||
type: "artifact";
|
||||
action: "grabbed" | "dropped" | "extracted";
|
||||
actorId: number;
|
||||
x: number;
|
||||
z: number;
|
||||
round: number;
|
||||
}
|
||||
| { id: number; type: "power"; on: boolean; actorId: number; x: number; z: number };
|
||||
|
||||
/** Hidden sounds deliberately contain no entity identifier or world position. */
|
||||
export type DeadAirPerception =
|
||||
| {
|
||||
id: number;
|
||||
type: "sound";
|
||||
cue: DeadAirSoundCue;
|
||||
direction: number;
|
||||
distance: DeadAirDistanceBand;
|
||||
intensity: number;
|
||||
}
|
||||
| { id: number; type: "muzzle"; x: number; z: number; yaw: number; pitch: number }
|
||||
| { id: number; type: "damage"; amount: number; health: number; eliminated: boolean }
|
||||
| { id: number; type: "hit"; amount: number; eliminated: boolean }
|
||||
| {
|
||||
id: number;
|
||||
type: "artifact";
|
||||
action: "grabbed" | "dropped" | "extracted";
|
||||
actorId: number | null;
|
||||
round: number;
|
||||
}
|
||||
| { id: number; type: "power"; on: boolean };
|
||||
|
||||
export interface DeadAirPresentationEvent {
|
||||
receivedTick: number;
|
||||
event: DeadAirPerception;
|
||||
}
|
||||
|
||||
export interface DeadAirGameContract {
|
||||
authority: DeadAirAuthorityState;
|
||||
client: DeadAirClientState;
|
||||
input: DeadAirInput;
|
||||
authorityEvent: DeadAirAuthorityEvent;
|
||||
perceptionEvent: DeadAirPerception;
|
||||
}
|
||||
@@ -7,6 +7,9 @@ export interface ApiMessage {
|
||||
}
|
||||
|
||||
export * from "./arena.js";
|
||||
export * from "./dead-air-types.js";
|
||||
export * from "./dead-air-map.js";
|
||||
export * from "./dead-air-game.js";
|
||||
export * from "./flux-types.js";
|
||||
export * from "./flux-game.js";
|
||||
export * from "./movers-types.js";
|
||||
|
||||
128
packages/shared/test/dead-air.test.mjs
Normal file
128
packages/shared/test/dead-air.test.mjs
Normal file
@@ -0,0 +1,128 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
DEAD_AIR_ARTIFACT_SPAWN,
|
||||
DEAD_AIR_EXTRACTION,
|
||||
DEAD_AIR_SNAPSHOT_RATE,
|
||||
DEAD_AIR_TICK_RATE,
|
||||
DEAD_AIR_WARDEN_ID_BASE,
|
||||
deadAirGame,
|
||||
} from "../dist/index.js";
|
||||
|
||||
function input(overrides = {}) {
|
||||
return {
|
||||
forward: 0,
|
||||
strafe: 0,
|
||||
yaw: 0,
|
||||
pitch: 0,
|
||||
fire: false,
|
||||
sprint: false,
|
||||
reload: false,
|
||||
interact: false,
|
||||
toggleFlashlight: false,
|
||||
throwDecoy: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("DEAD AIR runs through the generic multiplayer contract", () => {
|
||||
assert.equal(deadAirGame.tickRateHz, DEAD_AIR_TICK_RATE);
|
||||
assert.equal(deadAirGame.snapshotRateHz, DEAD_AIR_SNAPSHOT_RATE);
|
||||
const server = deadAirGame.createServer();
|
||||
server.addPlayer(1);
|
||||
const player = server.currentState.players.find((candidate) => candidate.id === 1);
|
||||
player.x = DEAD_AIR_ARTIFACT_SPAWN.x;
|
||||
player.z = DEAD_AIR_ARTIFACT_SPAWN.z;
|
||||
|
||||
assert.deepEqual(server.submitInput(1, {
|
||||
sequence: 1,
|
||||
targetTick: 1,
|
||||
observedTick: 0,
|
||||
input: input({ interact: true }),
|
||||
}), { accepted: true });
|
||||
const grabbed = server.step();
|
||||
assert.equal(player.carryingArtifact, true);
|
||||
assert.ok(grabbed.events.some((event) => event.type === "artifact" && event.action === "grabbed"));
|
||||
|
||||
player.x = DEAD_AIR_EXTRACTION.x;
|
||||
player.z = DEAD_AIR_EXTRACTION.z;
|
||||
const extracted = server.step();
|
||||
assert.equal(player.extractions, 1);
|
||||
assert.ok(extracted.events.some((event) => event.type === "artifact" && event.action === "extracted"));
|
||||
});
|
||||
|
||||
test("an occluded warden never appears in snapshots and becomes anonymous sound", () => {
|
||||
const server = deadAirGame.createServer();
|
||||
server.addPlayer(1);
|
||||
const viewer = server.currentState.players.find((player) => player.id === 1);
|
||||
const hidden = server.currentState.players.find((player) => player.id === DEAD_AIR_WARDEN_ID_BASE);
|
||||
for (const player of server.currentState.players) {
|
||||
if (player.bot && player.id !== hidden.id) player.alive = false;
|
||||
}
|
||||
viewer.x = -25;
|
||||
viewer.z = 0;
|
||||
viewer.yaw = Math.PI / 2;
|
||||
viewer.flashlight = true;
|
||||
hidden.x = -10;
|
||||
hidden.z = 0;
|
||||
hidden.flashlight = false;
|
||||
|
||||
const snapshot = server.createSnapshot(1, 0).state;
|
||||
assert.equal(snapshot.players.some((player) => player.id === hidden.id), false);
|
||||
assert.equal(JSON.stringify(snapshot).includes(hidden.id.toString()), false);
|
||||
|
||||
const perception = server.createPerceptions(1, [{
|
||||
id: 777,
|
||||
type: "shot",
|
||||
sourceId: hidden.id,
|
||||
x: hidden.x,
|
||||
z: hidden.z,
|
||||
yaw: hidden.yaw,
|
||||
pitch: 0,
|
||||
}])[0];
|
||||
assert.equal(perception.type, "sound");
|
||||
assert.equal(perception.cue, "gunshot");
|
||||
assert.equal("sourceId" in perception, false);
|
||||
assert.equal("x" in perception, false);
|
||||
assert.equal("z" in perception, false);
|
||||
assert.ok(Number.isInteger(perception.direction));
|
||||
});
|
||||
|
||||
test("private objective coordinates are redacted outside sight", () => {
|
||||
const server = deadAirGame.createServer();
|
||||
server.addPlayer(1);
|
||||
const viewer = server.currentState.players.find((player) => player.id === 1);
|
||||
viewer.x = -28;
|
||||
viewer.z = 28;
|
||||
viewer.yaw = Math.PI;
|
||||
viewer.flashlight = false;
|
||||
const snapshot = server.createSnapshot(1, 0).state;
|
||||
assert.deepEqual(snapshot.artifact, {
|
||||
visible: false,
|
||||
x: null,
|
||||
z: null,
|
||||
carrierId: null,
|
||||
});
|
||||
assert.equal("artifactX" in snapshot, false);
|
||||
assert.equal("nextEventId" in snapshot, false);
|
||||
});
|
||||
|
||||
test("visible wardens expose pose but redact combat inventory", () => {
|
||||
const server = deadAirGame.createServer();
|
||||
server.addPlayer(1);
|
||||
const viewer = server.currentState.players.find((player) => player.id === 1);
|
||||
const visible = server.currentState.players.find((player) => player.id === DEAD_AIR_WARDEN_ID_BASE);
|
||||
viewer.x = -30;
|
||||
viewer.z = 30;
|
||||
viewer.yaw = Math.PI / 2;
|
||||
visible.x = -25;
|
||||
visible.z = 30;
|
||||
visible.health = 31;
|
||||
visible.magazine = 2;
|
||||
const replicated = server.createSnapshot(1, 0).state.players.find((player) => player.id === visible.id);
|
||||
assert.ok(replicated);
|
||||
assert.equal(replicated.x, visible.x);
|
||||
assert.equal(replicated.health, null);
|
||||
assert.equal(replicated.magazine, null);
|
||||
assert.equal(replicated.reserve, null);
|
||||
});
|
||||
Reference in New Issue
Block a user