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