add secure acoustic DEAD AIR game
All checks were successful
build / image (push) Successful in 41s

This commit is contained in:
Syncer Deploy
2026-08-31 13:17:58 -04:00
parent f759a3138b
commit 6361ecbdd7
13 changed files with 2709 additions and 7 deletions

View File

@@ -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

View File

@@ -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),

View File

@@ -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
View 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();
}

View 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";
}

View 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);
}

View File

@@ -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; }
}

View 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));
}

View 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));
}

View 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
);
}

View 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;
}

View File

@@ -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";

View 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);
});