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

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