This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
API_PATHS,
|
||||
DEAD_AIR_SOCKET_PATH,
|
||||
FLUX_SOCKET_PATH,
|
||||
GAME_SOCKET_PATH,
|
||||
MOVERS_SOCKET_PATH,
|
||||
ROYALE_SOCKET_PATH,
|
||||
createShooterGame,
|
||||
deadAirGame,
|
||||
fluxGame,
|
||||
moversGame,
|
||||
royaleGame,
|
||||
@@ -24,6 +26,7 @@ const clientProfiles = new ClientProfileStore();
|
||||
const staticSite = loadStaticSite(process.env.STATIC_ROOT);
|
||||
const gameLoops = [
|
||||
hostNetworkedGame(app, GAME_SOCKET_PATH, createShooterGame({ botCount: 0 })),
|
||||
hostNetworkedGame(app, DEAD_AIR_SOCKET_PATH, deadAirGame),
|
||||
hostNetworkedGame(app, FLUX_SOCKET_PATH, fluxGame),
|
||||
hostNetworkedGame(app, MOVERS_SOCKET_PATH, moversGame),
|
||||
hostNetworkedGame(app, ROYALE_SOCKET_PATH, royaleGame),
|
||||
|
||||
@@ -6,14 +6,17 @@ import {
|
||||
} from "@syncer/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Arena3D } from "./Arena3D.js";
|
||||
import { DeadAirGame } from "./DeadAirGame.js";
|
||||
import { FluxGame } from "./FluxGame.js";
|
||||
import { MoversGame } from "./MoversGame.js";
|
||||
import { RoyaleGame } from "./RoyaleGame.js";
|
||||
import { useGameClient } from "./useGameClient.js";
|
||||
|
||||
export function App() {
|
||||
const [demo, setDemo] = useState<"arena" | "flux" | "royale" | "movers">(() =>
|
||||
window.location.hash === "#movers"
|
||||
const [demo, setDemo] = useState<"arena" | "flux" | "royale" | "movers" | "dead-air">(() =>
|
||||
window.location.hash === "#dead-air"
|
||||
? "dead-air"
|
||||
: window.location.hash === "#movers"
|
||||
? "movers"
|
||||
: window.location.hash === "#royale"
|
||||
? "royale"
|
||||
@@ -28,7 +31,7 @@ export function App() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{demo === "arena" ? <ShooterGame /> : demo === "flux" ? <FluxGame /> : demo === "royale" ? <RoyaleGame /> : <MoversGame />}
|
||||
{demo === "arena" ? <ShooterGame /> : demo === "flux" ? <FluxGame /> : demo === "royale" ? <RoyaleGame /> : demo === "movers" ? <MoversGame /> : <DeadAirGame />}
|
||||
<nav className="game-switcher" aria-label="Example game selector">
|
||||
<button className={demo === "arena" ? "is-active" : ""} onClick={() => setDemo("arena")} type="button">
|
||||
ARENA
|
||||
@@ -42,6 +45,9 @@ export function App() {
|
||||
<button className={demo === "movers" ? "is-active" : ""} onClick={() => setDemo("movers")} type="button">
|
||||
BAD MOVERS
|
||||
</button>
|
||||
<button className={demo === "dead-air" ? "is-active" : ""} onClick={() => setDemo("dead-air")} type="button">
|
||||
DEAD AIR
|
||||
</button>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
|
||||
478
apps/web/src/DeadAir3D.tsx
Normal file
478
apps/web/src/DeadAir3D.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
import { useEffect, useRef, useState, type MutableRefObject } from "react";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
DEAD_AIR_EXTRACTION,
|
||||
DEAD_AIR_MAP_SIZE,
|
||||
DEAD_AIR_POWER_SWITCH,
|
||||
DEAD_AIR_TICK_RATE,
|
||||
DEAD_AIR_WALLS,
|
||||
type DeadAirClientState,
|
||||
type DeadAirPlayerView,
|
||||
} from "@syncer/shared";
|
||||
import type { DeadAirRenderSource } from "./useDeadAirClient.js";
|
||||
|
||||
interface DeadAir3DProps {
|
||||
source: DeadAirRenderSource;
|
||||
playerId: number | null;
|
||||
unlockAudio(): void;
|
||||
}
|
||||
|
||||
interface PlayerMesh extends THREE.Group {
|
||||
userData: {
|
||||
lamp?: THREE.PointLight;
|
||||
beam?: THREE.SpotLight;
|
||||
};
|
||||
}
|
||||
|
||||
interface TemporaryEffect {
|
||||
object: THREE.Object3D;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface DeadAirRuntime {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
scene: THREE.Scene;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
ambient: THREE.HemisphereLight;
|
||||
poweredLights: THREE.PointLight[];
|
||||
flashlight: THREE.SpotLight;
|
||||
flashlightLens: THREE.Mesh;
|
||||
weapon: THREE.Group;
|
||||
muzzle: THREE.PointLight;
|
||||
players: Map<number, PlayerMesh>;
|
||||
artifact: THREE.Group;
|
||||
effects: TemporaryEffect[];
|
||||
resizeObserver: ResizeObserver;
|
||||
animationFrame: number;
|
||||
}
|
||||
|
||||
export function DeadAir3D({ source, playerId, unlockAudio }: DeadAir3DProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const playerIdRef = useRef(playerId);
|
||||
const sourceRef = useRef(source);
|
||||
const runtimeRef = useRef<DeadAirRuntime | null>(null);
|
||||
const lastEventIdRef = useRef(0);
|
||||
const [locked, setLocked] = useState(false);
|
||||
playerIdRef.current = playerId;
|
||||
sourceRef.current = source;
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 0.82;
|
||||
host.append(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x010304);
|
||||
scene.fog = new THREE.FogExp2(0x020506, 0.026);
|
||||
const camera = new THREE.PerspectiveCamera(76, 1, 0.035, 110);
|
||||
camera.rotation.order = "YXZ";
|
||||
scene.add(camera);
|
||||
|
||||
const ambient = new THREE.HemisphereLight(0x68828a, 0x050506, 0.16);
|
||||
scene.add(ambient);
|
||||
const poweredLights = buildLevel(scene);
|
||||
const artifact = buildArtifact();
|
||||
artifact.visible = false;
|
||||
scene.add(artifact);
|
||||
|
||||
const flashlightTarget = new THREE.Object3D();
|
||||
flashlightTarget.position.set(0, -0.08, -12);
|
||||
camera.add(flashlightTarget);
|
||||
const flashlight = new THREE.SpotLight(0xdffaff, 112, 31, 0.42, 0.42, 1.5);
|
||||
flashlight.position.set(0.12, -0.05, -0.12);
|
||||
flashlight.target = flashlightTarget;
|
||||
flashlight.castShadow = true;
|
||||
flashlight.shadow.mapSize.set(512, 512);
|
||||
flashlight.shadow.bias = -0.0005;
|
||||
camera.add(flashlight);
|
||||
|
||||
const weapon = buildWeapon();
|
||||
camera.add(weapon);
|
||||
const flashlightLens = weapon.getObjectByName("flashlight-lens") as THREE.Mesh;
|
||||
const muzzle = new THREE.PointLight(0xffd19b, 0, 5, 2);
|
||||
muzzle.position.set(0.33, -0.2, -1.05);
|
||||
camera.add(muzzle);
|
||||
|
||||
const runtime: DeadAirRuntime = {
|
||||
renderer,
|
||||
scene,
|
||||
camera,
|
||||
ambient,
|
||||
poweredLights,
|
||||
flashlight,
|
||||
flashlightLens,
|
||||
weapon,
|
||||
muzzle,
|
||||
players: new Map(),
|
||||
artifact,
|
||||
effects: [],
|
||||
resizeObserver: new ResizeObserver(() => resize(runtime, host)),
|
||||
animationFrame: 0,
|
||||
};
|
||||
runtimeRef.current = runtime;
|
||||
runtime.resizeObserver.observe(host);
|
||||
resize(runtime, host);
|
||||
|
||||
const animate = (time: number) => {
|
||||
updateRuntime(runtime, sourceRef.current, playerIdRef.current, lastEventIdRef, time);
|
||||
renderer.render(scene, camera);
|
||||
runtime.animationFrame = window.requestAnimationFrame(animate);
|
||||
};
|
||||
runtime.animationFrame = window.requestAnimationFrame(animate);
|
||||
const pointerLockChanged = () => setLocked(document.pointerLockElement === renderer.domElement);
|
||||
document.addEventListener("pointerlockchange", pointerLockChanged);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("pointerlockchange", pointerLockChanged);
|
||||
runtime.resizeObserver.disconnect();
|
||||
window.cancelAnimationFrame(runtime.animationFrame);
|
||||
scene.traverse(disposeObject);
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
runtimeRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const enter = () => {
|
||||
unlockAudio();
|
||||
const canvas = runtimeRef.current?.renderer.domElement;
|
||||
if (canvas) void canvas.requestPointerLock().catch(() => setLocked(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dead-air-viewport" ref={hostRef}>
|
||||
{!locked ? (
|
||||
<button className="dead-air-enter" type="button" onClick={enter}>
|
||||
<small>HEADPHONES STRONGLY RECOMMENDED</small>
|
||||
<strong>ENTER THE DEAD AIR</strong>
|
||||
<span>STEAL THE SCREAMING MICROWAVE // TRUST YOUR EARS</span>
|
||||
<em>WASD · MOUSE · CLICK FIRE · E INTERACT · F LIGHT · Q DECOY · R RELOAD</em>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function updateRuntime(
|
||||
runtime: DeadAirRuntime,
|
||||
source: DeadAirRenderSource,
|
||||
playerId: number | null,
|
||||
lastEventId: MutableRefObject<number>,
|
||||
time: number,
|
||||
): void {
|
||||
const frame = source.current;
|
||||
const world = frame.state;
|
||||
const local = world.players.find((player) => player.id === playerId);
|
||||
const fractionalSeconds = frame.interpolationAlpha / DEAD_AIR_TICK_RATE;
|
||||
if (local) {
|
||||
const correctionLife = Math.max(0, 1 - (time - frame.localCorrection.updatedAt) / 130);
|
||||
runtime.camera.position.set(
|
||||
local.x + local.velocityX * fractionalSeconds + frame.localCorrection.x * correctionLife,
|
||||
1.62,
|
||||
local.z + local.velocityZ * fractionalSeconds + frame.localCorrection.z * correctionLife,
|
||||
);
|
||||
runtime.camera.rotation.set(local.pitch, Math.PI + local.yaw, 0);
|
||||
runtime.flashlight.intensity = local.alive && local.flashlight ? 112 : 0;
|
||||
runtime.flashlightLens.visible = local.flashlight;
|
||||
runtime.weapon.visible = local.alive;
|
||||
const speed = Math.hypot(local.velocityX, local.velocityZ);
|
||||
runtime.weapon.position.x = 0.31 + Math.cos(time * 0.008) * Math.min(0.012, speed * 0.0018);
|
||||
runtime.weapon.position.y = -0.26 + Math.sin(time * 0.015) * Math.min(0.025, speed * 0.0035);
|
||||
} else {
|
||||
runtime.flashlight.intensity = 0;
|
||||
runtime.weapon.visible = false;
|
||||
}
|
||||
|
||||
runtime.ambient.intensity = world.powerOn ? 0.18 : 0.025;
|
||||
for (const light of runtime.poweredLights) {
|
||||
light.intensity = world.powerOn ? 3.4 + Math.sin(time * 0.003 + light.position.x) * 0.22 : 0;
|
||||
}
|
||||
updatePlayers(runtime, world, playerId, fractionalSeconds);
|
||||
updateArtifact(runtime, world, time);
|
||||
updateEffects(runtime, world, lastEventId, time);
|
||||
}
|
||||
|
||||
function buildLevel(scene: THREE.Scene): THREE.PointLight[] {
|
||||
const floor = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(DEAD_AIR_MAP_SIZE, DEAD_AIR_MAP_SIZE),
|
||||
new THREE.MeshStandardMaterial({ color: 0x101619, roughness: 0.91, metalness: 0.08 }),
|
||||
);
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
floor.receiveShadow = true;
|
||||
scene.add(floor);
|
||||
|
||||
const grid = new THREE.GridHelper(DEAD_AIR_MAP_SIZE, 36, 0x26343a, 0x182125);
|
||||
grid.position.y = 0.012;
|
||||
const gridMaterials = Array.isArray(grid.material) ? grid.material : [grid.material];
|
||||
for (const material of gridMaterials) {
|
||||
material.transparent = true;
|
||||
material.opacity = 0.21;
|
||||
}
|
||||
scene.add(grid);
|
||||
|
||||
for (const wall of DEAD_AIR_WALLS) {
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(wall.width, wall.height, wall.depth),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: wall.id.includes("vault") ? 0x253036 : 0x172126,
|
||||
roughness: 0.78,
|
||||
metalness: wall.id.includes("vault") ? 0.55 : 0.22,
|
||||
}),
|
||||
);
|
||||
mesh.position.set(wall.x, wall.height / 2, wall.z);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
scene.add(mesh);
|
||||
if (!wall.id.includes("north") && !wall.id.includes("south") && wall.width > 4) {
|
||||
const stripe = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(Math.max(0.2, wall.width - 0.08), 0.12, wall.depth + 0.025),
|
||||
new THREE.MeshStandardMaterial({ color: 0x5c4d2d, emissive: 0x1f1705, emissiveIntensity: 0.2 }),
|
||||
);
|
||||
stripe.position.set(wall.x, 1.05, wall.z);
|
||||
scene.add(stripe);
|
||||
}
|
||||
}
|
||||
|
||||
const extraction = new THREE.Mesh(
|
||||
new THREE.RingGeometry(DEAD_AIR_EXTRACTION.radius - 0.18, DEAD_AIR_EXTRACTION.radius, 48),
|
||||
new THREE.MeshBasicMaterial({ color: 0x5be6bf, transparent: true, opacity: 0.48, side: THREE.DoubleSide }),
|
||||
);
|
||||
extraction.rotation.x = -Math.PI / 2;
|
||||
extraction.position.set(DEAD_AIR_EXTRACTION.x, 0.045, DEAD_AIR_EXTRACTION.z);
|
||||
scene.add(extraction);
|
||||
const extractionLight = new THREE.PointLight(0x3de0b1, 2.4, 11, 2);
|
||||
extractionLight.position.set(DEAD_AIR_EXTRACTION.x, 1.1, DEAD_AIR_EXTRACTION.z);
|
||||
scene.add(extractionLight);
|
||||
|
||||
const powerBox = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.8, 1.4, 0.5),
|
||||
new THREE.MeshStandardMaterial({ color: 0x36434a, emissive: 0x601508, emissiveIntensity: 0.7, metalness: 0.65 }),
|
||||
);
|
||||
powerBox.position.set(DEAD_AIR_POWER_SWITCH.x, 0.9, DEAD_AIR_POWER_SWITCH.z);
|
||||
scene.add(powerBox);
|
||||
|
||||
const cratePositions: ReadonlyArray<readonly [number, number]> = [
|
||||
[-29, -29], [0, -27], [28, -28], [-28, 8], [0, 13], [28, 8], [-27, 27], [27, 27],
|
||||
];
|
||||
for (const [index, position] of cratePositions.entries()) {
|
||||
const crate = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(index % 2 ? 2.2 : 1.7, 0.18, index % 3 ? 1.5 : 2.1),
|
||||
new THREE.MeshStandardMaterial({ color: 0x302a22, roughness: 0.9, metalness: 0.12 }),
|
||||
);
|
||||
crate.position.set(position[0], 0.09, position[1]);
|
||||
crate.rotation.y = index * 0.37;
|
||||
crate.castShadow = true;
|
||||
crate.receiveShadow = true;
|
||||
scene.add(crate);
|
||||
}
|
||||
|
||||
const poweredLights: THREE.PointLight[] = [];
|
||||
const ceilingLightPositions: ReadonlyArray<readonly [number, number]> = [
|
||||
[-25, -10], [0, -14], [25, -10], [-25, 23], [1, 2], [25, 23],
|
||||
];
|
||||
for (const position of ceilingLightPositions) {
|
||||
const fixture = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(2.6, 0.08, 0.32),
|
||||
new THREE.MeshBasicMaterial({ color: 0xbad8dc }),
|
||||
);
|
||||
fixture.position.set(position[0], 4.1, position[1]);
|
||||
scene.add(fixture);
|
||||
const light = new THREE.PointLight(0xb8e4e9, 3.4, 16, 1.8);
|
||||
light.position.set(position[0], 3.85, position[1]);
|
||||
scene.add(light);
|
||||
poweredLights.push(light);
|
||||
}
|
||||
|
||||
const emergencyLightPositions: ReadonlyArray<readonly [number, number]> = [
|
||||
[-34, -4], [34, -4], [-2, -34], [-2, 34],
|
||||
];
|
||||
for (const position of emergencyLightPositions) {
|
||||
const emergency = new THREE.PointLight(0xff2f1c, 1.7, 9, 2);
|
||||
emergency.position.set(position[0], 2.3, position[1]);
|
||||
scene.add(emergency);
|
||||
}
|
||||
return poweredLights;
|
||||
}
|
||||
|
||||
function buildWeapon(): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(0.31, -0.26, -0.65);
|
||||
const body = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.19, 0.2, 0.9),
|
||||
new THREE.MeshStandardMaterial({ color: 0x151c20, roughness: 0.34, metalness: 0.78 }),
|
||||
);
|
||||
body.position.z = -0.16;
|
||||
group.add(body);
|
||||
const canister = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.055, 0.055, 0.52, 10),
|
||||
new THREE.MeshStandardMaterial({ color: 0x6fd2c4, emissive: 0x173c38, emissiveIntensity: 0.7, metalness: 0.38 }),
|
||||
);
|
||||
canister.rotation.x = Math.PI / 2;
|
||||
canister.position.set(-0.12, 0.06, -0.25);
|
||||
group.add(canister);
|
||||
const lens = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.075, 0.075, 0.06, 12),
|
||||
new THREE.MeshBasicMaterial({ color: 0xdffaff }),
|
||||
);
|
||||
lens.name = "flashlight-lens";
|
||||
lens.rotation.x = Math.PI / 2;
|
||||
lens.position.set(0.13, -0.08, -0.59);
|
||||
group.add(lens);
|
||||
return group;
|
||||
}
|
||||
|
||||
function buildPlayer(player: DeadAirPlayerView): PlayerMesh {
|
||||
const group = new THREE.Group() as PlayerMesh;
|
||||
const color = player.bot ? 0x7d1714 : 0x1d5960;
|
||||
const body = new THREE.Mesh(
|
||||
new THREE.CapsuleGeometry(0.46, 0.86, 4, 8),
|
||||
new THREE.MeshStandardMaterial({ color, roughness: 0.54, metalness: 0.3 }),
|
||||
);
|
||||
body.position.y = 1.03;
|
||||
body.castShadow = true;
|
||||
group.add(body);
|
||||
const visor = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.56, 0.19, 0.08),
|
||||
new THREE.MeshStandardMaterial({ color: 0x090d0f, emissive: player.bot ? 0x710b07 : 0x083e44, emissiveIntensity: 1.2 }),
|
||||
);
|
||||
visor.position.set(0, 1.62, 0.37);
|
||||
group.add(visor);
|
||||
const lamp = new THREE.PointLight(player.bot ? 0xff4a37 : 0xa8f6ff, 0, 7, 2);
|
||||
lamp.position.set(0.2, 1.55, 0.42);
|
||||
group.add(lamp);
|
||||
const target = new THREE.Object3D();
|
||||
target.position.set(0, 1.35, 9);
|
||||
group.add(target);
|
||||
const beam = new THREE.SpotLight(player.bot ? 0xff7661 : 0xc9f8ff, 0, 19, 0.42, 0.55, 1.6);
|
||||
beam.position.set(0.2, 1.55, 0.3);
|
||||
beam.target = target;
|
||||
group.add(beam);
|
||||
group.userData = { lamp, beam };
|
||||
return group;
|
||||
}
|
||||
|
||||
function updatePlayers(
|
||||
runtime: DeadAirRuntime,
|
||||
world: Readonly<DeadAirClientState>,
|
||||
playerId: number | null,
|
||||
fractionalSeconds: number,
|
||||
): void {
|
||||
const visible = new Set(world.players.map((player) => player.id));
|
||||
for (const player of world.players) {
|
||||
if (player.id === playerId) continue;
|
||||
let group = runtime.players.get(player.id);
|
||||
const targetX = player.x + player.velocityX * fractionalSeconds;
|
||||
const targetZ = player.z + player.velocityZ * fractionalSeconds;
|
||||
if (!group) {
|
||||
group = buildPlayer(player);
|
||||
group.position.set(targetX, 0, targetZ);
|
||||
runtime.players.set(player.id, group);
|
||||
runtime.scene.add(group);
|
||||
} else {
|
||||
group.position.x += (targetX - group.position.x) * 0.34;
|
||||
group.position.z += (targetZ - group.position.z) * 0.34;
|
||||
}
|
||||
group.rotation.y = player.yaw;
|
||||
group.visible = player.alive;
|
||||
if (group.userData.lamp) group.userData.lamp.intensity = player.flashlight ? 3.6 : 0;
|
||||
if (group.userData.beam) group.userData.beam.intensity = player.flashlight ? 34 : 0;
|
||||
}
|
||||
for (const [id, group] of runtime.players) {
|
||||
if (visible.has(id)) continue;
|
||||
runtime.players.delete(id);
|
||||
runtime.scene.remove(group);
|
||||
group.traverse(disposeObject);
|
||||
}
|
||||
}
|
||||
|
||||
function buildArtifact(): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
const body = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(1.15, 0.75, 0.78),
|
||||
new THREE.MeshStandardMaterial({ color: 0xa78a31, roughness: 0.32, metalness: 0.82, emissive: 0x3c2300, emissiveIntensity: 0.6 }),
|
||||
);
|
||||
body.position.y = 0.58;
|
||||
body.castShadow = true;
|
||||
group.add(body);
|
||||
const door = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.78, 0.47, 0.035),
|
||||
new THREE.MeshStandardMaterial({ color: 0x15100b, emissive: 0xff3d0d, emissiveIntensity: 0.34, roughness: 0.18 }),
|
||||
);
|
||||
door.position.set(-0.12, 0.59, 0.405);
|
||||
group.add(door);
|
||||
const dial = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.075, 0.075, 0.06, 12),
|
||||
new THREE.MeshBasicMaterial({ color: 0xff8a35 }),
|
||||
);
|
||||
dial.rotation.x = Math.PI / 2;
|
||||
dial.position.set(0.42, 0.69, 0.43);
|
||||
group.add(dial);
|
||||
group.add(new THREE.PointLight(0xff4b17, 5, 8, 2));
|
||||
return group;
|
||||
}
|
||||
|
||||
function updateArtifact(runtime: DeadAirRuntime, world: Readonly<DeadAirClientState>, time: number): void {
|
||||
const artifact = world.artifact;
|
||||
runtime.artifact.visible = artifact.visible && artifact.x !== null && artifact.z !== null;
|
||||
if (!runtime.artifact.visible || artifact.x === null || artifact.z === null) return;
|
||||
runtime.artifact.position.set(artifact.x, 0.08 + Math.sin(time * 0.004) * 0.07, artifact.z);
|
||||
runtime.artifact.rotation.y = time * 0.00055;
|
||||
}
|
||||
|
||||
function updateEffects(
|
||||
runtime: DeadAirRuntime,
|
||||
world: Readonly<DeadAirClientState>,
|
||||
lastEventId: MutableRefObject<number>,
|
||||
time: number,
|
||||
): void {
|
||||
for (const entry of world.events) {
|
||||
if (entry.event.id <= lastEventId.current || entry.event.type !== "muzzle") continue;
|
||||
const event = entry.event;
|
||||
const direction = new THREE.Vector3(Math.sin(event.yaw), Math.sin(event.pitch), Math.cos(event.yaw)).normalize();
|
||||
const start = new THREE.Vector3(event.x, 1.45, event.z).addScaledVector(direction, 0.8);
|
||||
const end = start.clone().addScaledVector(direction, 27);
|
||||
const length = start.distanceTo(end);
|
||||
const tracer = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.018, 0.035, length, 5),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffc78b, transparent: true, opacity: 0.7 }),
|
||||
);
|
||||
tracer.position.copy(start).add(end).multiplyScalar(0.5);
|
||||
tracer.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction);
|
||||
runtime.scene.add(tracer);
|
||||
runtime.effects.push({ object: tracer, expiresAt: time + 95 });
|
||||
runtime.muzzle.intensity = 16;
|
||||
}
|
||||
lastEventId.current = world.events.reduce(
|
||||
(latest, entry) => Math.max(latest, entry.event.id),
|
||||
lastEventId.current,
|
||||
);
|
||||
runtime.muzzle.intensity *= 0.75;
|
||||
runtime.effects = runtime.effects.filter((effect) => {
|
||||
if (effect.expiresAt > time) return true;
|
||||
runtime.scene.remove(effect.object);
|
||||
effect.object.traverse(disposeObject);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function resize(runtime: DeadAirRuntime, host: HTMLDivElement): void {
|
||||
const width = Math.max(1, host.clientWidth);
|
||||
const height = Math.max(1, host.clientHeight);
|
||||
runtime.renderer.setSize(width, height, false);
|
||||
runtime.camera.aspect = width / height;
|
||||
runtime.camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
function disposeObject(object: THREE.Object3D): void {
|
||||
if (!(object instanceof THREE.Mesh || object instanceof THREE.Line)) return;
|
||||
object.geometry.dispose();
|
||||
const materials = Array.isArray(object.material) ? object.material : [object.material];
|
||||
for (const material of materials) material.dispose();
|
||||
}
|
||||
182
apps/web/src/DeadAirGame.tsx
Normal file
182
apps/web/src/DeadAirGame.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
DEAD_AIR_EXTRACTION,
|
||||
DEAD_AIR_POWER_SWITCH,
|
||||
DEAD_AIR_TICK_RATE,
|
||||
DEAD_AIR_WARDEN_ID_BASE,
|
||||
type DeadAirPerception,
|
||||
} from "@syncer/shared";
|
||||
import { DeadAir3D } from "./DeadAir3D.js";
|
||||
import { useDeadAirClient, type DeadAirAction } from "./useDeadAirClient.js";
|
||||
|
||||
export function DeadAirGame() {
|
||||
const client = useDeadAirClient();
|
||||
const local = client.world.players.find((player) => player.id === client.playerId);
|
||||
const recent = [...client.world.events].reverse();
|
||||
const sounds = recent
|
||||
.filter((entry) => entry.event.type === "sound" && client.tick - entry.receivedTick < 34)
|
||||
.slice(0, 7);
|
||||
const hit = recent.find((entry) => entry.event.type === "hit");
|
||||
const damage = recent.find((entry) => entry.event.type === "damage");
|
||||
const objective = recent.find((entry) => entry.event.type === "artifact");
|
||||
const recentHit = Boolean(hit && client.tick - hit.receivedTick < 12);
|
||||
const recentDamage = Boolean(damage && client.tick - damage.receivedTick < 20);
|
||||
const visibleWardens = client.world.players.filter((player) => player.bot && player.alive).length;
|
||||
const hint = interactionHint(client.world.artifact.visible, client.world.artifact.x, client.world.artifact.z, local);
|
||||
|
||||
const pulse = (action: DeadAirAction) => {
|
||||
client.unlockAudio();
|
||||
client.setAction(action, true);
|
||||
window.setTimeout(() => client.setAction(action, false), 80);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className={`dead-air-game${client.world.powerOn ? "" : " dead-air-game--blackout"}`}>
|
||||
<DeadAir3D source={client.renderSource} playerId={client.playerId} unlockAudio={client.unlockAudio} />
|
||||
<div className="dead-air-grade" aria-hidden="true" />
|
||||
<div className="dead-air-noise" aria-hidden="true" />
|
||||
{recentDamage ? <div className="dead-air-damage" aria-hidden="true" /> : null}
|
||||
|
||||
<header className="dead-air-header">
|
||||
<div className="dead-air-brand">
|
||||
<i>DA</i>
|
||||
<div><small>ACOUSTIC EXTRACTION PROTOCOL</small><strong>DEAD AIR</strong></div>
|
||||
</div>
|
||||
<section className="dead-air-objective">
|
||||
<small>ROUND {client.world.round} // OBJECTIVE</small>
|
||||
<strong>{objectiveText(local?.carryingArtifact ?? false, client.world.artifact.visible)}</strong>
|
||||
<span>{client.world.powerOn ? "FACILITY POWER ONLINE" : "BLACKOUT // FLASHLIGHTS ONLY"}</span>
|
||||
</section>
|
||||
<div className={`dead-air-live dead-air-live--${client.connection}`}>
|
||||
<i /><span>{client.connection}</span>
|
||||
<small>{client.network.roundTripTime.toFixed(0)}ms · {client.validation}</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="dead-air-security" aria-label="Secure replication status">
|
||||
<small>SECURE PERCEPTION</small>
|
||||
<div><span>VISIBLE ENTITIES</span><b>{visibleWardens}</b></div>
|
||||
<div><span>HIDDEN POSITIONS</span><b>REDACTED</b></div>
|
||||
<div><span>SOUND PACKETS</span><b>8-WAY / 3-BAND</b></div>
|
||||
<div><span>AUTHORITY TICK</span><b>{client.tick}</b></div>
|
||||
</section>
|
||||
|
||||
<section className="dead-air-scope" aria-label="Acoustic direction display">
|
||||
<div className={`dead-air-crosshair${recentHit ? " dead-air-crosshair--hit" : ""}`}><i /><i /><i /><i /></div>
|
||||
{sounds.map((entry, index) => {
|
||||
if (entry.event.type !== "sound") return null;
|
||||
return (
|
||||
<div
|
||||
className={`dead-air-bearing dead-air-bearing--${entry.event.distance}`}
|
||||
key={`${entry.event.id}:${index}`}
|
||||
style={{ transform: `translate(-50%, -50%) rotate(${entry.event.direction * 45}deg)` }}
|
||||
>
|
||||
<i style={{ opacity: 0.28 + entry.event.intensity * 0.22 }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="dead-air-listen" aria-live="polite">
|
||||
<small>ACOUSTIC CONTACTS</small>
|
||||
{sounds.length === 0 ? <span>THE BUILDING IS HOLDING ITS BREATH</span> : sounds.slice(0, 4).map((entry) => {
|
||||
if (entry.event.type !== "sound") return null;
|
||||
return <div key={entry.event.id}><b>{glyph(entry.event)}</b><span>{entry.event.cue}</span><em>{directionLabel(entry.event.direction)} · {entry.event.distance}</em></div>;
|
||||
})}
|
||||
</section>
|
||||
|
||||
{recentHit && hit?.event.type === "hit" ? (
|
||||
<div className="dead-air-hit">{hit.event.eliminated ? "WARDEN SILENCED" : `${hit.event.amount} // IMPACT`}</div>
|
||||
) : null}
|
||||
{objective && objective.event.type === "artifact" && client.tick - objective.receivedTick < 55 ? (
|
||||
<div className="dead-air-event">
|
||||
{artifactEventText(objective.event, client.playerId)}
|
||||
</div>
|
||||
) : null}
|
||||
{hint ? <div className="dead-air-prompt"><b>E</b><span>{hint}</span></div> : null}
|
||||
|
||||
{local && !local.alive ? (
|
||||
<section className="dead-air-dead">
|
||||
<small>YOUR SIGNAL WENT QUIET</small>
|
||||
<strong>RE-ENTERING IN {Math.ceil(local.respawnTicks / DEAD_AIR_TICK_RATE)}</strong>
|
||||
</section>
|
||||
) : null}
|
||||
{client.world.resetTicks > 0 ? (
|
||||
<section className="dead-air-extracted">
|
||||
<small>CURSED ASSET LEFT THE BUILDING</small>
|
||||
<strong>{client.world.lastExtractorId === client.playerId ? "YOU GOT THE MICROWAVE OUT" : "SOMEBODY ESCAPED IN THE DARK"}</strong>
|
||||
<span>NEXT FREQUENCY IN {Math.ceil(client.world.resetTicks / DEAD_AIR_TICK_RATE)}</span>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<footer className="dead-air-hud">
|
||||
<section className="dead-air-vitals">
|
||||
<small>HEARTBEAT</small>
|
||||
<strong>{String(local?.health ?? 0).padStart(3, "0")}</strong>
|
||||
<span><i style={{ width: `${local?.health ?? 0}%` }} /></span>
|
||||
<em>{local?.flashlight ? "F // LIGHT ACTIVE" : "F // LIGHT OFF"}</em>
|
||||
</section>
|
||||
|
||||
<section className={`dead-air-cargo${local?.carryingArtifact ? " is-carrying" : ""}`}>
|
||||
<small>{local?.carryingArtifact ? "THE BOX IS SCREAMING" : "CURSED ASSET"}</small>
|
||||
<strong>{local?.carryingArtifact ? "REACH THE GREEN EXTRACTION" : client.world.artifact.visible ? "MICROWAVE IN SIGHT" : "LOCATION UNKNOWN // LISTEN"}</strong>
|
||||
<span>{local?.extractions ?? 0} EXTRACTIONS · {visibleWardens} WARDENS VISIBLE</span>
|
||||
</section>
|
||||
|
||||
<section className="dead-air-actions">
|
||||
<button onClick={() => pulse("interact")} type="button"><b>E</b><span>INTERACT</span></button>
|
||||
<button onClick={() => pulse("toggleFlashlight")} type="button"><b>F</b><span>LIGHT</span></button>
|
||||
<button disabled={(local?.decoys ?? 0) <= 0} onClick={() => pulse("throwDecoy")} type="button"><b>Q</b><span>DECOY {local?.decoys ?? 0}</span></button>
|
||||
</section>
|
||||
|
||||
<section className="dead-air-weapon">
|
||||
<small>HUSH-8 DART PISTOL</small>
|
||||
<strong>{local?.magazine ?? 0}<i>/ {local?.reserve ?? 0}</i></strong>
|
||||
<span>{local?.reloadTicks ? "RELOADING" : "R // RELOAD · CLICK // FIRE"}</span>
|
||||
</section>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function objectiveText(carrying: boolean, visible: boolean): string {
|
||||
if (carrying) return "EXTRACT THE SCREAMING MICROWAVE";
|
||||
if (visible) return "TAKE THE CURSED ASSET";
|
||||
return "FIND IT BY SOUND // DO NOT GET SEEN";
|
||||
}
|
||||
|
||||
function interactionHint(
|
||||
artifactVisible: boolean,
|
||||
artifactX: number | null,
|
||||
artifactZ: number | null,
|
||||
local: { x: number; z: number; carryingArtifact: boolean } | undefined,
|
||||
): string | null {
|
||||
if (!local) return null;
|
||||
if (local.carryingArtifact) return "DROP THE CURSED ASSET";
|
||||
if (artifactVisible && artifactX !== null && artifactZ !== null && Math.hypot(local.x - artifactX, local.z - artifactZ) < 3) {
|
||||
return "GRAB THE SCREAMING MICROWAVE";
|
||||
}
|
||||
if (Math.hypot(local.x - DEAD_AIR_POWER_SWITCH.x, local.z - DEAD_AIR_POWER_SWITCH.z) < 3) return "CUT FACILITY POWER";
|
||||
if (Math.hypot(local.x - DEAD_AIR_EXTRACTION.x, local.z - DEAD_AIR_EXTRACTION.z) < DEAD_AIR_EXTRACTION.radius) return "EXTRACTION ZONE // BRING THE ASSET";
|
||||
return null;
|
||||
}
|
||||
|
||||
function artifactEventText(event: Extract<DeadAirPerception, { type: "artifact" }>, playerId: number | null): string {
|
||||
if (event.action === "extracted") return event.actorId === playerId ? "EXTRACTION CONFIRMED" : "THE SCREAMING STOPPED";
|
||||
if (event.action === "grabbed") return event.actorId === playerId ? "YOU PICKED UP THE CURSE" : event.actorId === null ? "SOMETHING TOOK THE ASSET" : "ASSET MOVING";
|
||||
return event.actorId === playerId ? "ASSET DROPPED" : "HEAVY METAL HIT THE FLOOR";
|
||||
}
|
||||
|
||||
function glyph(event: Extract<DeadAirPerception, { type: "sound" }>): string {
|
||||
switch (event.cue) {
|
||||
case "footstep": return "⌁";
|
||||
case "gunshot": return "×";
|
||||
case "impact": return "◆";
|
||||
case "decoy": return "◉";
|
||||
case "curse": return "∿";
|
||||
case "power": return "ϟ";
|
||||
}
|
||||
}
|
||||
|
||||
function directionLabel(direction: number): string {
|
||||
return ["FRONT", "FRONT-RIGHT", "RIGHT", "REAR-RIGHT", "BEHIND", "REAR-LEFT", "LEFT", "FRONT-LEFT"][direction] ?? "UNKNOWN";
|
||||
}
|
||||
141
apps/web/src/dead-air-audio.ts
Normal file
141
apps/web/src/dead-air-audio.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { DeadAirPerception, DeadAirSoundCue } from "@syncer/shared";
|
||||
|
||||
export class DeadAirAudio {
|
||||
private context: AudioContext | null = null;
|
||||
private master: GainNode | null = null;
|
||||
|
||||
unlock(): void {
|
||||
if (!this.context) {
|
||||
this.context = new AudioContext();
|
||||
this.master = this.context.createGain();
|
||||
this.master.gain.value = 0.32;
|
||||
this.master.connect(this.context.destination);
|
||||
}
|
||||
void this.context.resume();
|
||||
}
|
||||
|
||||
play(event: DeadAirPerception): void {
|
||||
if (!this.context || !this.master || this.context.state !== "running") return;
|
||||
switch (event.type) {
|
||||
case "sound":
|
||||
this.cue(event.cue, bucketBearing(event.direction), event.intensity / 3);
|
||||
break;
|
||||
case "muzzle":
|
||||
this.cue("gunshot", 0, 0.9);
|
||||
break;
|
||||
case "damage":
|
||||
this.tone(90, 38, 0.28, "sawtooth", 0.28);
|
||||
break;
|
||||
case "hit":
|
||||
this.tone(event.eliminated ? 520 : 820, event.eliminated ? 120 : 480, 0.09, "square", 0.12);
|
||||
break;
|
||||
case "artifact":
|
||||
this.tone(event.action === "extracted" ? 220 : 110, event.action === "extracted" ? 880 : 54, 0.42, "sine", 0.18);
|
||||
break;
|
||||
case "power":
|
||||
this.tone(event.on ? 90 : 180, event.on ? 260 : 42, 0.34, "sawtooth", 0.13);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
void this.context?.close();
|
||||
this.context = null;
|
||||
this.master = null;
|
||||
}
|
||||
|
||||
private cue(cue: DeadAirSoundCue, bearing: number, intensity: number): void {
|
||||
if (cue === "gunshot") {
|
||||
this.noise(0.17, 1_050, bearing, 0.46 * intensity);
|
||||
this.pulse(118, 42, 0.16, bearing, 0.28 * intensity);
|
||||
return;
|
||||
}
|
||||
if (cue === "footstep") {
|
||||
this.pulse(92, 42, 0.12, bearing, 0.2 * intensity);
|
||||
return;
|
||||
}
|
||||
if (cue === "decoy") {
|
||||
this.pulse(360, 95, 0.26, bearing, 0.18 * intensity);
|
||||
window.setTimeout(() => this.pulse(290, 72, 0.18, bearing, 0.12 * intensity), 110);
|
||||
return;
|
||||
}
|
||||
if (cue === "curse") {
|
||||
this.pulse(74, 38, 0.48, bearing, 0.2 * intensity);
|
||||
return;
|
||||
}
|
||||
this.pulse(cue === "power" ? 140 : 180, 48, 0.2, bearing, 0.14 * intensity);
|
||||
}
|
||||
|
||||
private noise(duration: number, frequency: number, bearing: number, volume: number): void {
|
||||
const context = this.context!;
|
||||
const now = context.currentTime;
|
||||
const source = context.createBufferSource();
|
||||
const buffer = context.createBuffer(1, Math.floor(context.sampleRate * duration), context.sampleRate);
|
||||
const samples = buffer.getChannelData(0);
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
samples[index] = (Math.random() * 2 - 1) * Math.exp(-index / (samples.length * 0.19));
|
||||
}
|
||||
source.buffer = buffer;
|
||||
const filter = context.createBiquadFilter();
|
||||
filter.type = "lowpass";
|
||||
filter.frequency.value = frequency;
|
||||
const gain = context.createGain();
|
||||
gain.gain.setValueAtTime(Math.max(0.01, volume), now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||||
source.connect(filter).connect(gain).connect(this.panner(bearing));
|
||||
source.start(now);
|
||||
}
|
||||
|
||||
private pulse(
|
||||
from: number,
|
||||
to: number,
|
||||
duration: number,
|
||||
bearing: number,
|
||||
volume: number,
|
||||
): void {
|
||||
const context = this.context!;
|
||||
const now = context.currentTime;
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
oscillator.type = "sine";
|
||||
oscillator.frequency.setValueAtTime(from, now);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(to, now + duration);
|
||||
gain.gain.setValueAtTime(Math.max(0.008, volume), now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||||
oscillator.connect(gain).connect(this.panner(bearing));
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + duration + 0.01);
|
||||
}
|
||||
|
||||
private tone(
|
||||
from: number,
|
||||
to: number,
|
||||
duration: number,
|
||||
type: OscillatorType,
|
||||
volume: number,
|
||||
): void {
|
||||
const context = this.context!;
|
||||
const now = context.currentTime;
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
oscillator.type = type;
|
||||
oscillator.frequency.setValueAtTime(from, now);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(to, now + duration);
|
||||
gain.gain.setValueAtTime(volume, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||||
oscillator.connect(gain).connect(this.master!);
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + duration + 0.01);
|
||||
}
|
||||
|
||||
private panner(bearing: number): StereoPannerNode {
|
||||
const panner = this.context!.createStereoPanner();
|
||||
panner.pan.value = Math.max(-1, Math.min(1, Math.sin(bearing)));
|
||||
panner.connect(this.master!);
|
||||
return panner;
|
||||
}
|
||||
}
|
||||
|
||||
function bucketBearing(direction: number): number {
|
||||
return direction * (Math.PI / 4);
|
||||
}
|
||||
@@ -817,3 +817,173 @@ button { font: inherit; }
|
||||
.movers-actions { grid-column: 1 / -1; justify-content: center; }
|
||||
.movers-actions button { min-width: 82px; }
|
||||
}
|
||||
|
||||
.dead-air-game {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 320px;
|
||||
overflow: hidden;
|
||||
color: #dce8e5;
|
||||
background: #010304;
|
||||
user-select: none;
|
||||
}
|
||||
.dead-air-viewport,
|
||||
.dead-air-viewport canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.dead-air-viewport canvas { display: block; }
|
||||
.dead-air-enter {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
width: min(580px, calc(100% - 36px));
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 28px 36px 30px;
|
||||
border: 1px solid rgb(137 236 219 / 24%);
|
||||
border-block-width: 1px 6px;
|
||||
color: #d9f2ed;
|
||||
background:
|
||||
repeating-linear-gradient(90deg, transparent 0 18px, rgb(94 231 202 / 2%) 18px 19px),
|
||||
rgb(2 8 9 / 92%);
|
||||
box-shadow: 0 0 100px rgb(18 75 67 / 26%);
|
||||
cursor: pointer;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.dead-air-enter::before { position: absolute; inset: 7px; border: 1px solid rgb(255 255 255 / 4%); content: ""; pointer-events: none; }
|
||||
.dead-air-enter small { color: #76928c; font: .47rem/1.2 "IBM Plex Mono", monospace; letter-spacing: .21em; }
|
||||
.dead-air-enter strong { color: #ebfffa; font-size: 1.8rem; letter-spacing: .18em; text-shadow: 0 0 18px rgb(102 239 211 / 38%); }
|
||||
.dead-air-enter span { color: #98c6bc; font-size: .65rem; letter-spacing: .12em; }
|
||||
.dead-air-enter em { margin-top: 5px; color: #56716c; font: normal .43rem/1.4 "IBM Plex Mono", monospace; letter-spacing: .08em; }
|
||||
.dead-air-enter:hover { border-bottom-color: #6aefce; background-color: rgb(4 14 15 / 97%); }
|
||||
|
||||
.dead-air-grade { position: absolute; inset: 0; z-index: 1; background: radial-gradient(circle at 50% 48%, transparent 34%, rgb(0 0 0 / 48%) 79%, rgb(0 0 0 / 78%)), linear-gradient(180deg, rgb(0 3 4 / 58%), transparent 19%, transparent 75%, rgb(0 2 3 / 77%)); pointer-events: none; }
|
||||
.dead-air-noise { position: absolute; inset: 0; z-index: 2; opacity: .11; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 140 140' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.32'/%3E%3C/svg%3E"); pointer-events: none; }
|
||||
.dead-air-game--blackout .dead-air-grade { background: radial-gradient(circle at 50% 49%, transparent 20%, rgb(0 0 0 / 67%) 69%, #000 100%), linear-gradient(180deg, rgb(0 0 0 / 72%), transparent 24%, transparent 68%, rgb(0 0 0 / 88%)); }
|
||||
.dead-air-damage { position: absolute; inset: 0; z-index: 3; background: radial-gradient(circle, transparent 22%, rgb(121 0 0 / 45%)); animation: dead-air-damage .38s ease-out forwards; pointer-events: none; }
|
||||
@keyframes dead-air-damage { to { opacity: 0; } }
|
||||
|
||||
.dead-air-header { position: absolute; z-index: 6; top: 0; left: 0; display: grid; width: 100%; grid-template-columns: 1fr auto 1fr; align-items: start; padding: 20px 25px; pointer-events: none; }
|
||||
.dead-air-brand { display: flex; align-items: center; gap: 10px; }
|
||||
.dead-air-brand > i { display: grid; width: 43px; height: 43px; place-items: center; border: 1px solid rgb(105 231 207 / 44%); color: #88e8d4; background: rgb(3 13 14 / 76%); box-shadow: inset 0 0 19px rgb(40 218 183 / 12%); font: normal 700 .82rem "IBM Plex Mono", monospace; clip-path: polygon(0 0, 80% 0, 100% 20%, 100% 100%, 20% 100%, 0 80%); }
|
||||
.dead-air-brand small { display: block; color: #5b7772; font: .42rem/1 "IBM Plex Mono", monospace; letter-spacing: .13em; }
|
||||
.dead-air-brand strong { display: block; margin-top: 3px; color: #ddf4ef; font-size: 1.05rem; letter-spacing: .22em; }
|
||||
.dead-air-objective { min-width: 355px; padding: 7px 28px 9px; border-top: 1px solid rgb(102 228 202 / 24%); background: linear-gradient(90deg, transparent, rgb(3 12 13 / 76%) 18%, rgb(3 12 13 / 76%) 82%, transparent); text-align: center; }
|
||||
.dead-air-objective small,
|
||||
.dead-air-objective span { display: block; color: #5d7772; font: .4rem/1.3 "IBM Plex Mono", monospace; letter-spacing: .13em; }
|
||||
.dead-air-objective strong { display: block; margin: 2px 0; color: #c9e6df; font-size: .71rem; letter-spacing: .14em; }
|
||||
.dead-air-game--blackout .dead-air-objective span { color: #ff604c; text-shadow: 0 0 8px #a91a0b; }
|
||||
.dead-air-live { justify-self: end; display: grid; grid-template-columns: auto auto; align-items: center; gap: 0 7px; padding: 7px 9px; border-right: 1px solid #62dfc5; background: linear-gradient(90deg, transparent, rgb(2 13 14 / 76%)); text-align: right; }
|
||||
.dead-air-live > i { grid-row: 1 / span 2; width: 6px; height: 6px; border-radius: 50%; background: #72e8cf; box-shadow: 0 0 10px #50dbbd; }
|
||||
.dead-air-live span { color: #8debd7; font-size: .57rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.dead-air-live small { color: #5f7671; font: .39rem/1 "IBM Plex Mono", monospace; }
|
||||
.dead-air-live--connecting > i,
|
||||
.dead-air-live--reconnecting > i { background: #ff704f; box-shadow: 0 0 10px #ff4f2d; }
|
||||
|
||||
.dead-air-security { position: absolute; z-index: 5; top: 93px; left: 25px; width: 190px; padding: 8px 0; border-top: 1px solid rgb(101 220 199 / 31%); color: #78918c; background: linear-gradient(90deg, rgb(2 10 11 / 78%), transparent); pointer-events: none; }
|
||||
.dead-air-security > small { display: block; padding: 0 7px 6px; color: #7edbc8; font: .42rem/1 "IBM Plex Mono", monospace; letter-spacing: .15em; }
|
||||
.dead-air-security div { display: flex; justify-content: space-between; padding: 3px 7px; font: .38rem/1.3 "IBM Plex Mono", monospace; letter-spacing: .05em; }
|
||||
.dead-air-security b { color: #a3bbb6; font-weight: 500; }
|
||||
|
||||
.dead-air-listen { position: absolute; z-index: 5; top: 98px; right: 25px; width: 200px; padding: 8px; border-top: 1px solid rgb(255 92 66 / 35%); background: linear-gradient(90deg, transparent, rgb(10 7 7 / 79%)); pointer-events: none; }
|
||||
.dead-air-listen > small { display: block; margin-bottom: 5px; color: #c46a5b; font: .42rem/1 "IBM Plex Mono", monospace; letter-spacing: .14em; text-align: right; }
|
||||
.dead-air-listen > span { display: block; color: #51615e; font: .37rem/1.4 "IBM Plex Mono", monospace; text-align: right; }
|
||||
.dead-air-listen > div { display: grid; grid-template-columns: 18px 1fr; padding: 3px 0; border-top: 1px solid rgb(255 255 255 / 4%); }
|
||||
.dead-air-listen b { grid-row: 1 / span 2; align-self: center; color: #ff6c55; font: .8rem/1 "IBM Plex Mono", monospace; }
|
||||
.dead-air-listen span { color: #9eaaa7; font-size: .49rem; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.dead-air-listen em { color: #596966; font: normal .33rem/1 "IBM Plex Mono", monospace; letter-spacing: .05em; }
|
||||
|
||||
.dead-air-scope { position: absolute; z-index: 4; top: 50%; left: 50%; width: 240px; height: 240px; border: 1px solid rgb(121 219 201 / 4%); border-radius: 50%; transform: translate(-50%, -50%); pointer-events: none; }
|
||||
.dead-air-crosshair { position: absolute; top: 50%; left: 50%; width: 40px; height: 40px; transform: translate(-50%, -50%); }
|
||||
.dead-air-crosshair::after { position: absolute; top: 19px; left: 19px; width: 3px; height: 3px; border-radius: 50%; background: #c9e5df; box-shadow: 0 0 5px #9ee5d7; content: ""; }
|
||||
.dead-air-crosshair i { position: absolute; background: rgb(190 221 215 / 77%); }
|
||||
.dead-air-crosshair i:nth-child(1), .dead-air-crosshair i:nth-child(2) { top: 20px; width: 8px; height: 1px; }
|
||||
.dead-air-crosshair i:nth-child(1) { left: 2px; }
|
||||
.dead-air-crosshair i:nth-child(2) { right: 2px; }
|
||||
.dead-air-crosshair i:nth-child(3), .dead-air-crosshair i:nth-child(4) { left: 20px; width: 1px; height: 8px; }
|
||||
.dead-air-crosshair i:nth-child(3) { top: 2px; }
|
||||
.dead-air-crosshair i:nth-child(4) { bottom: 2px; }
|
||||
.dead-air-crosshair--hit { animation: dead-air-hit .17s ease-out; }
|
||||
@keyframes dead-air-hit { 50% { transform: translate(-50%, -50%) scale(1.5); filter: sepia(1) saturate(4); } }
|
||||
.dead-air-bearing { position: absolute; top: 50%; left: 50%; width: 1px; height: 1px; transform-origin: 0 0; }
|
||||
.dead-air-bearing i { position: absolute; top: -98px; left: -10px; width: 20px; height: 12px; border-top: 2px solid #ff735c; clip-path: polygon(0 0, 50% 100%, 100% 0); filter: drop-shadow(0 0 5px #ff4e34); }
|
||||
.dead-air-bearing--mid i { top: -84px; border-color: #d99969; }
|
||||
.dead-air-bearing--far i { top: -70px; border-color: #8c9485; }
|
||||
|
||||
.dead-air-hit,
|
||||
.dead-air-event { position: absolute; z-index: 7; left: 50%; color: #daf7f0; font: .48rem/1 "IBM Plex Mono", monospace; letter-spacing: .15em; text-shadow: 0 0 9px #74e4ce; transform: translateX(-50%); pointer-events: none; }
|
||||
.dead-air-hit { top: calc(50% + 33px); }
|
||||
.dead-air-event { top: 29%; padding: 7px 18px; border-block: 1px solid rgb(255 104 78 / 38%); color: #ffc0af; background: linear-gradient(90deg, transparent, rgb(35 8 4 / 75%), transparent); text-shadow: 0 0 9px #ff5538; }
|
||||
.dead-air-prompt { position: absolute; z-index: 7; bottom: 145px; left: 50%; display: flex; align-items: center; gap: 7px; padding: 5px 12px; color: #c8e4de; background: rgb(2 11 12 / 78%); font: .45rem/1 "IBM Plex Mono", monospace; letter-spacing: .1em; transform: translateX(-50%); pointer-events: none; }
|
||||
.dead-air-prompt b { display: grid; width: 23px; height: 23px; place-items: center; border: 1px solid #68d8c1; color: #8cebd7; }
|
||||
|
||||
.dead-air-dead,
|
||||
.dead-air-extracted { position: absolute; z-index: 10; top: 50%; left: 50%; display: flex; width: min(620px, calc(100% - 40px)); flex-direction: column; align-items: center; gap: 5px; padding: 20px; border-block: 1px solid rgb(255 81 55 / 48%); background: linear-gradient(90deg, transparent, rgb(28 3 2 / 86%) 20%, rgb(28 3 2 / 86%) 80%, transparent); transform: translate(-50%, -50%); pointer-events: none; }
|
||||
.dead-air-dead small,
|
||||
.dead-air-extracted small,
|
||||
.dead-air-extracted span { color: #a65e52; font: .44rem/1 "IBM Plex Mono", monospace; letter-spacing: .17em; }
|
||||
.dead-air-dead strong,
|
||||
.dead-air-extracted strong { color: #ffd3c8; font-size: 1.28rem; letter-spacing: .18em; }
|
||||
.dead-air-extracted { border-color: rgb(94 234 201 / 47%); background: linear-gradient(90deg, transparent, rgb(2 24 20 / 88%) 20%, rgb(2 24 20 / 88%) 80%, transparent); }
|
||||
.dead-air-extracted small,
|
||||
.dead-air-extracted span { color: #6da597; }
|
||||
.dead-air-extracted strong { color: #cbfff3; }
|
||||
|
||||
.dead-air-hud { position: absolute; z-index: 6; right: 25px; bottom: 20px; left: 25px; display: grid; grid-template-columns: 200px 1fr auto 215px; align-items: end; gap: 18px; pointer-events: none; }
|
||||
.dead-air-vitals,
|
||||
.dead-air-cargo,
|
||||
.dead-air-weapon { padding: 8px 11px; border-top: 1px solid rgb(102 224 200 / 35%); background: linear-gradient(180deg, rgb(3 13 14 / 78%), rgb(2 7 8 / 89%)); }
|
||||
.dead-air-vitals small,
|
||||
.dead-air-cargo small,
|
||||
.dead-air-weapon small { display: block; color: #607873; font: .4rem/1 "IBM Plex Mono", monospace; letter-spacing: .13em; }
|
||||
.dead-air-vitals strong { display: block; color: #d6eee9; font: 1.8rem/.9 "IBM Plex Mono", monospace; font-weight: 500; }
|
||||
.dead-air-vitals > span { display: block; overflow: hidden; width: 150px; height: 3px; margin: 5px 0; background: rgb(255 255 255 / 8%); }
|
||||
.dead-air-vitals > span i { display: block; height: 100%; background: #66d8c0; box-shadow: 0 0 8px #51caae; }
|
||||
.dead-air-vitals em { color: #617e78; font: normal .37rem/1 "IBM Plex Mono", monospace; }
|
||||
.dead-air-cargo { justify-self: center; min-width: 330px; border-color: rgb(255 88 57 / 28%); text-align: center; }
|
||||
.dead-air-cargo strong { display: block; margin: 3px 0; color: #bdcbc8; font-size: .67rem; letter-spacing: .12em; }
|
||||
.dead-air-cargo span { color: #5e726e; font: .36rem/1 "IBM Plex Mono", monospace; letter-spacing: .07em; }
|
||||
.dead-air-cargo.is-carrying { border-color: #ff684c; background: linear-gradient(180deg, rgb(38 10 5 / 82%), rgb(13 5 3 / 91%)); box-shadow: 0 -8px 30px rgb(142 24 5 / 12%); }
|
||||
.dead-air-cargo.is-carrying strong { color: #ffc7b9; text-shadow: 0 0 8px #9a2916; }
|
||||
.dead-air-actions { display: flex; gap: 4px; pointer-events: auto; }
|
||||
.dead-air-actions button { display: flex; min-width: 58px; flex-direction: column; align-items: center; padding: 7px 6px; border: 1px solid rgb(113 216 195 / 16%); color: #81938f; background: rgb(2 10 11 / 84%); cursor: pointer; }
|
||||
.dead-air-actions button:hover { border-color: #70d9c3; color: #c9e6df; }
|
||||
.dead-air-actions button:disabled { opacity: .28; cursor: default; }
|
||||
.dead-air-actions b { color: #72d9c3; font: .5rem/1.2 "IBM Plex Mono", monospace; }
|
||||
.dead-air-actions span { font-size: .38rem; letter-spacing: .07em; white-space: nowrap; }
|
||||
.dead-air-weapon { text-align: right; }
|
||||
.dead-air-weapon strong { display: block; color: #edf9f6; font: 2rem/.9 "IBM Plex Mono", monospace; font-weight: 500; }
|
||||
.dead-air-weapon strong i { color: #5c6f6b; font-size: .68rem; font-style: normal; }
|
||||
.dead-air-weapon span { color: #60736f; font: .36rem/1 "IBM Plex Mono", monospace; letter-spacing: .06em; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.game-switcher { max-width: calc(100% - 20px); overflow-x: auto; }
|
||||
.game-switcher button { flex: 0 0 auto; }
|
||||
.dead-air-header { padding: 13px; grid-template-columns: 1fr auto; }
|
||||
.dead-air-objective { position: absolute; top: 62px; left: 50%; min-width: 320px; transform: translateX(-50%); }
|
||||
.dead-air-brand small,
|
||||
.dead-air-live small { display: none; }
|
||||
.dead-air-security { top: 108px; left: 13px; width: 160px; }
|
||||
.dead-air-listen { top: 108px; right: 13px; width: 160px; }
|
||||
.dead-air-hud { right: 12px; bottom: 10px; left: 12px; grid-template-columns: 1fr auto; gap: 6px; }
|
||||
.dead-air-vitals { grid-column: 1; }
|
||||
.dead-air-weapon { grid-column: 2; }
|
||||
.dead-air-cargo { grid-column: 1 / -1; grid-row: 1; min-width: 0; width: 100%; }
|
||||
.dead-air-actions { grid-column: 1 / -1; justify-content: center; }
|
||||
.dead-air-actions button { min-width: 88px; }
|
||||
.dead-air-prompt { bottom: 184px; }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.dead-air-security { display: none; }
|
||||
.dead-air-listen { width: 145px; }
|
||||
.dead-air-listen > div:nth-of-type(n+3) { display: none; }
|
||||
.dead-air-enter { padding-inline: 20px; }
|
||||
.dead-air-enter strong { font-size: 1.25rem; }
|
||||
.dead-air-enter em { max-width: 300px; }
|
||||
.dead-air-vitals > span { width: 110px; }
|
||||
.dead-air-weapon { min-width: 135px; }
|
||||
.dead-air-cargo span { display: none; }
|
||||
}
|
||||
|
||||
368
apps/web/src/useDeadAirClient.ts
Normal file
368
apps/web/src/useDeadAirClient.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
createInputStateStream,
|
||||
FixedStepClock,
|
||||
type NetworkStats,
|
||||
} from "@syncer/engine";
|
||||
import {
|
||||
DEAD_AIR_SOCKET_PATH,
|
||||
deadAirGame,
|
||||
type DeadAirClientState,
|
||||
type DeadAirInput,
|
||||
} from "@syncer/shared";
|
||||
import { DeadAirAudio } from "./dead-air-audio.js";
|
||||
import type { ConnectionStatus, ValidationStatus } from "./useGameClient.js";
|
||||
|
||||
export type DeadAirAction = "interact" | "toggleFlashlight" | "throwDecoy" | "reload";
|
||||
|
||||
export interface DeadAirCorrection {
|
||||
x: number;
|
||||
z: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface DeadAirRenderFrame {
|
||||
state: Readonly<DeadAirClientState>;
|
||||
interpolationAlpha: number;
|
||||
localCorrection: DeadAirCorrection;
|
||||
}
|
||||
|
||||
export interface DeadAirRenderSource {
|
||||
readonly current: DeadAirRenderFrame;
|
||||
}
|
||||
|
||||
export interface DeadAirClientView {
|
||||
connection: ConnectionStatus;
|
||||
validation: ValidationStatus;
|
||||
playerId: number | null;
|
||||
tick: number;
|
||||
inputLeadTicks: number;
|
||||
world: DeadAirClientState;
|
||||
network: NetworkStats;
|
||||
renderSource: DeadAirRenderSource;
|
||||
setAction(action: DeadAirAction, active: boolean): void;
|
||||
unlockAudio(): void;
|
||||
}
|
||||
|
||||
const emptyNetwork: NetworkStats = {
|
||||
roundTripTime: 0,
|
||||
jitter: 0,
|
||||
clockOffset: 0,
|
||||
samples: 0,
|
||||
};
|
||||
|
||||
function neutralInput(): DeadAirInput {
|
||||
return {
|
||||
forward: 0,
|
||||
strafe: 0,
|
||||
yaw: 0,
|
||||
pitch: 0,
|
||||
fire: false,
|
||||
sprint: false,
|
||||
reload: false,
|
||||
interact: false,
|
||||
toggleFlashlight: false,
|
||||
throwDecoy: false,
|
||||
};
|
||||
}
|
||||
|
||||
function socketUrls(): string[] {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const proxied = `${protocol}//${window.location.host}${DEAD_AIR_SOCKET_PATH}`;
|
||||
if (window.location.protocol !== "http:" || window.location.port !== "5173") return [proxied];
|
||||
return [`ws://${window.location.hostname}:3001${DEAD_AIR_SOCKET_PATH}`, proxied];
|
||||
}
|
||||
|
||||
export function useDeadAirClient(): DeadAirClientView {
|
||||
const engine = useMemo(() => deadAirGame.createClient(), []);
|
||||
const audio = useMemo(() => new DeadAirAudio(), []);
|
||||
const protocol = deadAirGame.protocol;
|
||||
const clock = useMemo(
|
||||
() => new FixedStepClock({ rateHz: deadAirGame.tickRateHz, maxCatchUpSteps: 5 }),
|
||||
[],
|
||||
);
|
||||
const renderSource = useRef<DeadAirRenderFrame>({
|
||||
state: deadAirGame.client.createInitialState(),
|
||||
interpolationAlpha: 0,
|
||||
localCorrection: { x: 0, z: 0, updatedAt: 0 },
|
||||
});
|
||||
const actionRef = useRef<(action: DeadAirAction, active: boolean) => void>(() => undefined);
|
||||
const setAction = useCallback(
|
||||
(action: DeadAirAction, active: boolean) => actionRef.current(action, active),
|
||||
[],
|
||||
);
|
||||
const unlockAudio = useCallback(() => audio.unlock(), [audio]);
|
||||
const [view, setView] = useState<Omit<DeadAirClientView, "renderSource" | "setAction" | "unlockAudio">>({
|
||||
connection: "connecting",
|
||||
validation: "waiting",
|
||||
playerId: null,
|
||||
tick: 0,
|
||||
inputLeadTicks: 1,
|
||||
world: deadAirGame.client.createInitialState(),
|
||||
network: emptyNetwork,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let socket: WebSocket | undefined;
|
||||
let retryTimer: number | undefined;
|
||||
let animationFrame: number | undefined;
|
||||
let connection: ConnectionStatus = "connecting";
|
||||
let validation: ValidationStatus = "waiting";
|
||||
let input = neutralInput();
|
||||
const pressed = new Set<string>();
|
||||
const stream = createInputStateStream<DeadAirInput>(deadAirGame);
|
||||
let lastInputFrame: ArrayBuffer | null = null;
|
||||
let socketUrlIndex = 0;
|
||||
let lastPublishedAt = Number.NEGATIVE_INFINITY;
|
||||
const urls = socketUrls();
|
||||
|
||||
const refreshRenderSource = () => {
|
||||
renderSource.current.state = engine.currentState as DeadAirClientState;
|
||||
renderSource.current.interpolationAlpha = clock.interpolationAlpha;
|
||||
};
|
||||
const publish = (force = false, now = performance.now()) => {
|
||||
if (!active || (!force && now - lastPublishedAt < 100)) return;
|
||||
lastPublishedAt = now;
|
||||
setView({
|
||||
connection,
|
||||
validation,
|
||||
playerId: engine.localPlayerId,
|
||||
tick: engine.tick,
|
||||
inputLeadTicks: engine.networkClock.recommendedInputLeadTicks(deadAirGame.tickRateHz),
|
||||
world: deadAirGame.client.cloneState(engine.currentState as DeadAirClientState),
|
||||
network: engine.networkClock.stats,
|
||||
});
|
||||
};
|
||||
const send = (frame: ArrayBuffer) => {
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send(frame);
|
||||
};
|
||||
const sendInput = (force = false) => {
|
||||
if (!engine.initialized) return;
|
||||
stream.update(input);
|
||||
const emission = stream.consume(performance.now(), force);
|
||||
if (!emission) return;
|
||||
if (emission.kind === "state" || !lastInputFrame) {
|
||||
lastInputFrame = protocol.encodeClient({
|
||||
kind: "input",
|
||||
packet: engine.createInput(emission.input),
|
||||
});
|
||||
}
|
||||
send(lastInputFrame);
|
||||
};
|
||||
const updateMovement = () => {
|
||||
input = {
|
||||
...input,
|
||||
forward: Number(pressed.has("w")) - Number(pressed.has("s")),
|
||||
strafe: Number(pressed.has("d")) - Number(pressed.has("a")),
|
||||
sprint: pressed.has("shift"),
|
||||
};
|
||||
sendInput();
|
||||
};
|
||||
const updateAction = (action: DeadAirAction, value: boolean) => {
|
||||
if (input[action] === value) return;
|
||||
input = { ...input, [action]: value };
|
||||
sendInput(true);
|
||||
};
|
||||
actionRef.current = updateAction;
|
||||
|
||||
const connect = () => {
|
||||
let opened = false;
|
||||
connection = engine.initialized ? "reconnecting" : "connecting";
|
||||
publish(true);
|
||||
socket = new WebSocket(urls[socketUrlIndex]!);
|
||||
socket.binaryType = "arraybuffer";
|
||||
socket.addEventListener("open", () => {
|
||||
if (!active) return;
|
||||
opened = true;
|
||||
connection = "live";
|
||||
publish(true);
|
||||
});
|
||||
socket.addEventListener("message", (message: MessageEvent<ArrayBuffer>) => {
|
||||
if (!active || !(message.data instanceof ArrayBuffer)) return;
|
||||
try {
|
||||
const decoded = protocol.decodeServer(message.data);
|
||||
switch (decoded.kind) {
|
||||
case "welcome": {
|
||||
engine.initialize(decoded.playerId, decoded.snapshot);
|
||||
clock.reset(performance.now());
|
||||
const local = decoded.snapshot.state.players.find((player) => player.id === decoded.playerId);
|
||||
input = { ...neutralInput(), yaw: local?.yaw ?? 0, pitch: local?.pitch ?? 0 };
|
||||
stream.reset(input);
|
||||
lastInputFrame = null;
|
||||
validation = "waiting";
|
||||
renderSource.current.localCorrection = { x: 0, z: 0, updatedAt: performance.now() };
|
||||
sendInput(true);
|
||||
break;
|
||||
}
|
||||
case "snapshot": {
|
||||
const localBefore = (engine.currentState as DeadAirClientState).players.find(
|
||||
(player) => player.id === engine.localPlayerId,
|
||||
);
|
||||
engine.reconcile(decoded.snapshot);
|
||||
const localAfter = (engine.currentState as DeadAirClientState).players.find(
|
||||
(player) => player.id === engine.localPlayerId,
|
||||
);
|
||||
if (localBefore && localAfter && decoded.snapshot.state.round === renderSource.current.state.round) {
|
||||
const correction = renderSource.current.localCorrection;
|
||||
correction.x += localBefore.x - localAfter.x;
|
||||
correction.z += localBefore.z - localAfter.z;
|
||||
correction.updatedAt = performance.now();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "acknowledge":
|
||||
engine.acknowledge(decoded.sequence);
|
||||
break;
|
||||
case "pong":
|
||||
engine.networkClock.receivePong(decoded.pong, performance.now());
|
||||
break;
|
||||
case "validation":
|
||||
validation = decoded.valid ? "valid" : "invalid";
|
||||
break;
|
||||
case "reject-input":
|
||||
engine.reject(decoded.sequence);
|
||||
stream.invalidate();
|
||||
lastInputFrame = null;
|
||||
validation = "invalid";
|
||||
sendInput(true);
|
||||
break;
|
||||
case "event":
|
||||
engine.receiveEvent(decoded.event, decoded.tick);
|
||||
audio.play(decoded.event);
|
||||
break;
|
||||
case "replay-start":
|
||||
case "replay-frame":
|
||||
case "replay-end":
|
||||
break;
|
||||
}
|
||||
refreshRenderSource();
|
||||
publish(decoded.kind !== "snapshot");
|
||||
} catch {
|
||||
socket?.close(1003, "Invalid acoustic frame");
|
||||
}
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
if (!active) return;
|
||||
if (!opened && urls.length > 1) socketUrlIndex = (socketUrlIndex + 1) % urls.length;
|
||||
else if (opened) socketUrlIndex = 0;
|
||||
connection = "reconnecting";
|
||||
publish(true);
|
||||
retryTimer = window.setTimeout(connect, 1_000);
|
||||
});
|
||||
socket.addEventListener("error", () => socket?.close());
|
||||
};
|
||||
|
||||
const keyDown = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
if (["w", "a", "s", "d", "shift", "e", "f", "q", "r"].includes(key)) event.preventDefault();
|
||||
audio.unlock();
|
||||
if (["w", "a", "s", "d", "shift"].includes(key)) {
|
||||
pressed.add(key);
|
||||
updateMovement();
|
||||
} else if (!event.repeat && key === "e") updateAction("interact", true);
|
||||
else if (!event.repeat && key === "f") updateAction("toggleFlashlight", true);
|
||||
else if (!event.repeat && key === "q") updateAction("throwDecoy", true);
|
||||
else if (!event.repeat && key === "r") updateAction("reload", true);
|
||||
};
|
||||
const keyUp = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
if (["w", "a", "s", "d", "shift"].includes(key)) {
|
||||
pressed.delete(key);
|
||||
updateMovement();
|
||||
} else if (key === "e") updateAction("interact", false);
|
||||
else if (key === "f") updateAction("toggleFlashlight", false);
|
||||
else if (key === "q") updateAction("throwDecoy", false);
|
||||
else if (key === "r") updateAction("reload", false);
|
||||
};
|
||||
const mouseMove = (event: MouseEvent) => {
|
||||
if (!document.pointerLockElement) return;
|
||||
input = {
|
||||
...input,
|
||||
yaw: normalizeAngle(input.yaw - event.movementX * 0.0026),
|
||||
pitch: clamp(input.pitch - event.movementY * 0.00215, -1.2, 1.2),
|
||||
};
|
||||
sendInput();
|
||||
};
|
||||
const mouseDown = (event: MouseEvent) => {
|
||||
audio.unlock();
|
||||
if (event.button !== 0 || !document.pointerLockElement) return;
|
||||
input = { ...input, fire: true };
|
||||
sendInput(true);
|
||||
};
|
||||
const mouseUp = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
input = { ...input, fire: false };
|
||||
sendInput(true);
|
||||
};
|
||||
const release = () => {
|
||||
pressed.clear();
|
||||
input = {
|
||||
...input,
|
||||
forward: 0,
|
||||
strafe: 0,
|
||||
sprint: false,
|
||||
fire: false,
|
||||
reload: false,
|
||||
interact: false,
|
||||
toggleFlashlight: false,
|
||||
throwDecoy: false,
|
||||
};
|
||||
sendInput(true);
|
||||
};
|
||||
const pointerLockChange = () => {
|
||||
if (!document.pointerLockElement) release();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", keyDown);
|
||||
window.addEventListener("keyup", keyUp);
|
||||
window.addEventListener("mousemove", mouseMove);
|
||||
window.addEventListener("mousedown", mouseDown);
|
||||
window.addEventListener("mouseup", mouseUp);
|
||||
window.addEventListener("blur", release);
|
||||
document.addEventListener("pointerlockchange", pointerLockChange);
|
||||
const pingTimer = window.setInterval(() => {
|
||||
send(protocol.encodeClient({ kind: "ping", ping: engine.networkClock.createPing(performance.now()) }));
|
||||
}, 1_000);
|
||||
const validationTimer = window.setInterval(() => {
|
||||
if (engine.initialized) send(protocol.encodeClient({ kind: "state-report", report: engine.createStateReport() }));
|
||||
}, 2_000);
|
||||
const animate = (now: number) => {
|
||||
sendInput();
|
||||
clock.advance(now, () => engine.step());
|
||||
refreshRenderSource();
|
||||
publish(false, now);
|
||||
animationFrame = window.requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
connect();
|
||||
animationFrame = window.requestAnimationFrame(animate);
|
||||
return () => {
|
||||
active = false;
|
||||
actionRef.current = () => undefined;
|
||||
window.clearTimeout(retryTimer);
|
||||
window.clearInterval(pingTimer);
|
||||
window.clearInterval(validationTimer);
|
||||
if (animationFrame !== undefined) window.cancelAnimationFrame(animationFrame);
|
||||
window.removeEventListener("keydown", keyDown);
|
||||
window.removeEventListener("keyup", keyUp);
|
||||
window.removeEventListener("mousemove", mouseMove);
|
||||
window.removeEventListener("mousedown", mouseDown);
|
||||
window.removeEventListener("mouseup", mouseUp);
|
||||
window.removeEventListener("blur", release);
|
||||
document.removeEventListener("pointerlockchange", pointerLockChange);
|
||||
socket?.close();
|
||||
audio.dispose();
|
||||
};
|
||||
}, [audio, clock, engine, protocol]);
|
||||
|
||||
return { ...view, renderSource, setAction, unlockAudio };
|
||||
}
|
||||
|
||||
function normalizeAngle(value: number): number {
|
||||
return Math.atan2(Math.sin(value), Math.cos(value));
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
Reference in New Issue
Block a user