This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Syncer
|
# Syncer
|
||||||
|
|
||||||
Four playable browser games built on one generic TypeScript multiplayer higher-order configuration. The engine runs authoritative servers, predicted clients, clocks, reconciliation, validation, privacy-aware replication, pluggable physics, and bandwidth-budgeted interest management around game-supplied rules.
|
Five playable browser games built on one generic TypeScript multiplayer higher-order configuration. The engine runs authoritative servers, predicted clients, clocks, reconciliation, validation, privacy-aware replication, pluggable physics, and bandwidth-budgeted interest management around game-supplied rules.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ apps/
|
|||||||
web/ React + Vite predicted client
|
web/ React + Vite predicted client
|
||||||
packages/
|
packages/
|
||||||
engine/ Generic clocks, authority, prediction, validation, and protocol
|
engine/ Generic clocks, authority, prediction, validation, and protocol
|
||||||
shared/ Game definitions: Arena, Flux Relay, Royale, and Bad Movers
|
shared/ Game definitions: Arena, Flux Relay, Royale, Bad Movers, and DEAD AIR
|
||||||
```
|
```
|
||||||
|
|
||||||
## Play
|
## Play
|
||||||
@@ -20,14 +20,15 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:5173`. Use the selector to switch between four games:
|
Open `http://localhost:5173`. Use the selector to switch between five games:
|
||||||
|
|
||||||
- **Sync Arena** — click **Deploy**, then use `WASD`, `Shift`, mouse look, left click, `R`, and weapon keys `1`–`3`.
|
- **Sync Arena** — click **Deploy**, then use `WASD`, `Shift`, mouse look, left click, `R`, and weapon keys `1`–`3`.
|
||||||
- **Flux Relay** — hold `Space` or the on-screen thruster to pull the shared core toward your team's gate while managing private energy.
|
- **Flux Relay** — hold `Space` or the on-screen thruster to pull the shared core toward your team's gate while managing private energy.
|
||||||
- **Syncer Royale** — click **Drop In**, then use `WASD`, `Shift`, mouse look, left click, and `R`. Survive 31 server bots, loot automatically, and stay inside the shrinking circle on a streamed 2 km island. Open it directly at `http://localhost:5173/#royale`.
|
- **Syncer Royale** — click **Drop In**, then use `WASD`, `Shift`, mouse look, left click, and `R`. Survive 31 server bots, loot automatically, and stay inside the shrinking circle on a streamed 2 km island. Open it directly at `http://localhost:5173/#royale`.
|
||||||
- **Bad Movers** — use `WASD`, `Shift`, hold `Space` to grab, `E` to throw, and `F` to close your truck. Real Box3D rigid bodies run in WebAssembly on both the authority and predicted client. Open it directly at `http://localhost:5173/#movers`.
|
- **Bad Movers** — use `WASD`, `Shift`, hold `Space` to grab, `E` to throw, and `F` to close your truck. Real Box3D rigid bodies run in WebAssembly on both the authority and predicted client. Open it directly at `http://localhost:5173/#movers`.
|
||||||
|
- **DEAD AIR** — use `WASD`, mouse look, left click, `E`, `F`, `Q`, and `R` to steal a screaming cursed microwave from five authority wardens. Hidden enemies are omitted from snapshots; only anonymous 8-way, 3-distance-band sound cues cross the wire. Open it directly at `http://localhost:5173/#dead-air`.
|
||||||
|
|
||||||
The arena includes pickups, armor, three weapons, headshots, reloads, respawns, a scoreboard, filtered spatial sound, and killcams; its server bots are disabled for player-only office matches. Flux Relay proves the API is not shooter-specific. Royale proves it can drive a much larger generated world. Bad Movers proves a developer can attach a third-party deterministic physics backend while keeping plain serializable state, authority, prediction, reconciliation, and transport generic.
|
The arena includes pickups, armor, three weapons, headshots, reloads, respawns, a scoreboard, filtered spatial sound, and killcams; its server bots are disabled for player-only office matches. Flux Relay proves the API is not shooter-specific. Royale proves it can drive a much larger generated world. Bad Movers proves a developer can attach a third-party deterministic physics backend while keeping plain serializable state, authority, prediction, reconciliation, and transport generic. DEAD AIR proves secure multimodal perception: exact visible entities and deliberately lossy audio-only knowledge share one generic authority boundary without leaking hidden coordinates or identities.
|
||||||
|
|
||||||
## Define a game
|
## Define a game
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
API_PATHS,
|
API_PATHS,
|
||||||
|
DEAD_AIR_SOCKET_PATH,
|
||||||
FLUX_SOCKET_PATH,
|
FLUX_SOCKET_PATH,
|
||||||
GAME_SOCKET_PATH,
|
GAME_SOCKET_PATH,
|
||||||
MOVERS_SOCKET_PATH,
|
MOVERS_SOCKET_PATH,
|
||||||
ROYALE_SOCKET_PATH,
|
ROYALE_SOCKET_PATH,
|
||||||
createShooterGame,
|
createShooterGame,
|
||||||
|
deadAirGame,
|
||||||
fluxGame,
|
fluxGame,
|
||||||
moversGame,
|
moversGame,
|
||||||
royaleGame,
|
royaleGame,
|
||||||
@@ -24,6 +26,7 @@ const clientProfiles = new ClientProfileStore();
|
|||||||
const staticSite = loadStaticSite(process.env.STATIC_ROOT);
|
const staticSite = loadStaticSite(process.env.STATIC_ROOT);
|
||||||
const gameLoops = [
|
const gameLoops = [
|
||||||
hostNetworkedGame(app, GAME_SOCKET_PATH, createShooterGame({ botCount: 0 })),
|
hostNetworkedGame(app, GAME_SOCKET_PATH, createShooterGame({ botCount: 0 })),
|
||||||
|
hostNetworkedGame(app, DEAD_AIR_SOCKET_PATH, deadAirGame),
|
||||||
hostNetworkedGame(app, FLUX_SOCKET_PATH, fluxGame),
|
hostNetworkedGame(app, FLUX_SOCKET_PATH, fluxGame),
|
||||||
hostNetworkedGame(app, MOVERS_SOCKET_PATH, moversGame),
|
hostNetworkedGame(app, MOVERS_SOCKET_PATH, moversGame),
|
||||||
hostNetworkedGame(app, ROYALE_SOCKET_PATH, royaleGame),
|
hostNetworkedGame(app, ROYALE_SOCKET_PATH, royaleGame),
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ import {
|
|||||||
} from "@syncer/shared";
|
} from "@syncer/shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Arena3D } from "./Arena3D.js";
|
import { Arena3D } from "./Arena3D.js";
|
||||||
|
import { DeadAirGame } from "./DeadAirGame.js";
|
||||||
import { FluxGame } from "./FluxGame.js";
|
import { FluxGame } from "./FluxGame.js";
|
||||||
import { MoversGame } from "./MoversGame.js";
|
import { MoversGame } from "./MoversGame.js";
|
||||||
import { RoyaleGame } from "./RoyaleGame.js";
|
import { RoyaleGame } from "./RoyaleGame.js";
|
||||||
import { useGameClient } from "./useGameClient.js";
|
import { useGameClient } from "./useGameClient.js";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [demo, setDemo] = useState<"arena" | "flux" | "royale" | "movers">(() =>
|
const [demo, setDemo] = useState<"arena" | "flux" | "royale" | "movers" | "dead-air">(() =>
|
||||||
window.location.hash === "#movers"
|
window.location.hash === "#dead-air"
|
||||||
|
? "dead-air"
|
||||||
|
: window.location.hash === "#movers"
|
||||||
? "movers"
|
? "movers"
|
||||||
: window.location.hash === "#royale"
|
: window.location.hash === "#royale"
|
||||||
? "royale"
|
? "royale"
|
||||||
@@ -28,7 +31,7 @@ export function App() {
|
|||||||
|
|
||||||
return (
|
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">
|
<nav className="game-switcher" aria-label="Example game selector">
|
||||||
<button className={demo === "arena" ? "is-active" : ""} onClick={() => setDemo("arena")} type="button">
|
<button className={demo === "arena" ? "is-active" : ""} onClick={() => setDemo("arena")} type="button">
|
||||||
ARENA
|
ARENA
|
||||||
@@ -42,6 +45,9 @@ export function App() {
|
|||||||
<button className={demo === "movers" ? "is-active" : ""} onClick={() => setDemo("movers")} type="button">
|
<button className={demo === "movers" ? "is-active" : ""} onClick={() => setDemo("movers")} type="button">
|
||||||
BAD MOVERS
|
BAD MOVERS
|
||||||
</button>
|
</button>
|
||||||
|
<button className={demo === "dead-air" ? "is-active" : ""} onClick={() => setDemo("dead-air")} type="button">
|
||||||
|
DEAD AIR
|
||||||
|
</button>
|
||||||
</nav>
|
</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 { grid-column: 1 / -1; justify-content: center; }
|
||||||
.movers-actions button { min-width: 82px; }
|
.movers-actions button { min-width: 82px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dead-air-game {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 320px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #dce8e5;
|
||||||
|
background: #010304;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.dead-air-viewport,
|
||||||
|
.dead-air-viewport canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||||
|
.dead-air-viewport canvas { display: block; }
|
||||||
|
.dead-air-enter {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
display: flex;
|
||||||
|
width: min(580px, calc(100% - 36px));
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 28px 36px 30px;
|
||||||
|
border: 1px solid rgb(137 236 219 / 24%);
|
||||||
|
border-block-width: 1px 6px;
|
||||||
|
color: #d9f2ed;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(90deg, transparent 0 18px, rgb(94 231 202 / 2%) 18px 19px),
|
||||||
|
rgb(2 8 9 / 92%);
|
||||||
|
box-shadow: 0 0 100px rgb(18 75 67 / 26%);
|
||||||
|
cursor: pointer;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
.dead-air-enter::before { position: absolute; inset: 7px; border: 1px solid rgb(255 255 255 / 4%); content: ""; pointer-events: none; }
|
||||||
|
.dead-air-enter small { color: #76928c; font: .47rem/1.2 "IBM Plex Mono", monospace; letter-spacing: .21em; }
|
||||||
|
.dead-air-enter strong { color: #ebfffa; font-size: 1.8rem; letter-spacing: .18em; text-shadow: 0 0 18px rgb(102 239 211 / 38%); }
|
||||||
|
.dead-air-enter span { color: #98c6bc; font-size: .65rem; letter-spacing: .12em; }
|
||||||
|
.dead-air-enter em { margin-top: 5px; color: #56716c; font: normal .43rem/1.4 "IBM Plex Mono", monospace; letter-spacing: .08em; }
|
||||||
|
.dead-air-enter:hover { border-bottom-color: #6aefce; background-color: rgb(4 14 15 / 97%); }
|
||||||
|
|
||||||
|
.dead-air-grade { position: absolute; inset: 0; z-index: 1; background: radial-gradient(circle at 50% 48%, transparent 34%, rgb(0 0 0 / 48%) 79%, rgb(0 0 0 / 78%)), linear-gradient(180deg, rgb(0 3 4 / 58%), transparent 19%, transparent 75%, rgb(0 2 3 / 77%)); pointer-events: none; }
|
||||||
|
.dead-air-noise { position: absolute; inset: 0; z-index: 2; opacity: .11; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 140 140' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.32'/%3E%3C/svg%3E"); pointer-events: none; }
|
||||||
|
.dead-air-game--blackout .dead-air-grade { background: radial-gradient(circle at 50% 49%, transparent 20%, rgb(0 0 0 / 67%) 69%, #000 100%), linear-gradient(180deg, rgb(0 0 0 / 72%), transparent 24%, transparent 68%, rgb(0 0 0 / 88%)); }
|
||||||
|
.dead-air-damage { position: absolute; inset: 0; z-index: 3; background: radial-gradient(circle, transparent 22%, rgb(121 0 0 / 45%)); animation: dead-air-damage .38s ease-out forwards; pointer-events: none; }
|
||||||
|
@keyframes dead-air-damage { to { opacity: 0; } }
|
||||||
|
|
||||||
|
.dead-air-header { position: absolute; z-index: 6; top: 0; left: 0; display: grid; width: 100%; grid-template-columns: 1fr auto 1fr; align-items: start; padding: 20px 25px; pointer-events: none; }
|
||||||
|
.dead-air-brand { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.dead-air-brand > i { display: grid; width: 43px; height: 43px; place-items: center; border: 1px solid rgb(105 231 207 / 44%); color: #88e8d4; background: rgb(3 13 14 / 76%); box-shadow: inset 0 0 19px rgb(40 218 183 / 12%); font: normal 700 .82rem "IBM Plex Mono", monospace; clip-path: polygon(0 0, 80% 0, 100% 20%, 100% 100%, 20% 100%, 0 80%); }
|
||||||
|
.dead-air-brand small { display: block; color: #5b7772; font: .42rem/1 "IBM Plex Mono", monospace; letter-spacing: .13em; }
|
||||||
|
.dead-air-brand strong { display: block; margin-top: 3px; color: #ddf4ef; font-size: 1.05rem; letter-spacing: .22em; }
|
||||||
|
.dead-air-objective { min-width: 355px; padding: 7px 28px 9px; border-top: 1px solid rgb(102 228 202 / 24%); background: linear-gradient(90deg, transparent, rgb(3 12 13 / 76%) 18%, rgb(3 12 13 / 76%) 82%, transparent); text-align: center; }
|
||||||
|
.dead-air-objective small,
|
||||||
|
.dead-air-objective span { display: block; color: #5d7772; font: .4rem/1.3 "IBM Plex Mono", monospace; letter-spacing: .13em; }
|
||||||
|
.dead-air-objective strong { display: block; margin: 2px 0; color: #c9e6df; font-size: .71rem; letter-spacing: .14em; }
|
||||||
|
.dead-air-game--blackout .dead-air-objective span { color: #ff604c; text-shadow: 0 0 8px #a91a0b; }
|
||||||
|
.dead-air-live { justify-self: end; display: grid; grid-template-columns: auto auto; align-items: center; gap: 0 7px; padding: 7px 9px; border-right: 1px solid #62dfc5; background: linear-gradient(90deg, transparent, rgb(2 13 14 / 76%)); text-align: right; }
|
||||||
|
.dead-air-live > i { grid-row: 1 / span 2; width: 6px; height: 6px; border-radius: 50%; background: #72e8cf; box-shadow: 0 0 10px #50dbbd; }
|
||||||
|
.dead-air-live span { color: #8debd7; font-size: .57rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
|
||||||
|
.dead-air-live small { color: #5f7671; font: .39rem/1 "IBM Plex Mono", monospace; }
|
||||||
|
.dead-air-live--connecting > i,
|
||||||
|
.dead-air-live--reconnecting > i { background: #ff704f; box-shadow: 0 0 10px #ff4f2d; }
|
||||||
|
|
||||||
|
.dead-air-security { position: absolute; z-index: 5; top: 93px; left: 25px; width: 190px; padding: 8px 0; border-top: 1px solid rgb(101 220 199 / 31%); color: #78918c; background: linear-gradient(90deg, rgb(2 10 11 / 78%), transparent); pointer-events: none; }
|
||||||
|
.dead-air-security > small { display: block; padding: 0 7px 6px; color: #7edbc8; font: .42rem/1 "IBM Plex Mono", monospace; letter-spacing: .15em; }
|
||||||
|
.dead-air-security div { display: flex; justify-content: space-between; padding: 3px 7px; font: .38rem/1.3 "IBM Plex Mono", monospace; letter-spacing: .05em; }
|
||||||
|
.dead-air-security b { color: #a3bbb6; font-weight: 500; }
|
||||||
|
|
||||||
|
.dead-air-listen { position: absolute; z-index: 5; top: 98px; right: 25px; width: 200px; padding: 8px; border-top: 1px solid rgb(255 92 66 / 35%); background: linear-gradient(90deg, transparent, rgb(10 7 7 / 79%)); pointer-events: none; }
|
||||||
|
.dead-air-listen > small { display: block; margin-bottom: 5px; color: #c46a5b; font: .42rem/1 "IBM Plex Mono", monospace; letter-spacing: .14em; text-align: right; }
|
||||||
|
.dead-air-listen > span { display: block; color: #51615e; font: .37rem/1.4 "IBM Plex Mono", monospace; text-align: right; }
|
||||||
|
.dead-air-listen > div { display: grid; grid-template-columns: 18px 1fr; padding: 3px 0; border-top: 1px solid rgb(255 255 255 / 4%); }
|
||||||
|
.dead-air-listen b { grid-row: 1 / span 2; align-self: center; color: #ff6c55; font: .8rem/1 "IBM Plex Mono", monospace; }
|
||||||
|
.dead-air-listen span { color: #9eaaa7; font-size: .49rem; letter-spacing: .1em; text-transform: uppercase; }
|
||||||
|
.dead-air-listen em { color: #596966; font: normal .33rem/1 "IBM Plex Mono", monospace; letter-spacing: .05em; }
|
||||||
|
|
||||||
|
.dead-air-scope { position: absolute; z-index: 4; top: 50%; left: 50%; width: 240px; height: 240px; border: 1px solid rgb(121 219 201 / 4%); border-radius: 50%; transform: translate(-50%, -50%); pointer-events: none; }
|
||||||
|
.dead-air-crosshair { position: absolute; top: 50%; left: 50%; width: 40px; height: 40px; transform: translate(-50%, -50%); }
|
||||||
|
.dead-air-crosshair::after { position: absolute; top: 19px; left: 19px; width: 3px; height: 3px; border-radius: 50%; background: #c9e5df; box-shadow: 0 0 5px #9ee5d7; content: ""; }
|
||||||
|
.dead-air-crosshair i { position: absolute; background: rgb(190 221 215 / 77%); }
|
||||||
|
.dead-air-crosshair i:nth-child(1), .dead-air-crosshair i:nth-child(2) { top: 20px; width: 8px; height: 1px; }
|
||||||
|
.dead-air-crosshair i:nth-child(1) { left: 2px; }
|
||||||
|
.dead-air-crosshair i:nth-child(2) { right: 2px; }
|
||||||
|
.dead-air-crosshair i:nth-child(3), .dead-air-crosshair i:nth-child(4) { left: 20px; width: 1px; height: 8px; }
|
||||||
|
.dead-air-crosshair i:nth-child(3) { top: 2px; }
|
||||||
|
.dead-air-crosshair i:nth-child(4) { bottom: 2px; }
|
||||||
|
.dead-air-crosshair--hit { animation: dead-air-hit .17s ease-out; }
|
||||||
|
@keyframes dead-air-hit { 50% { transform: translate(-50%, -50%) scale(1.5); filter: sepia(1) saturate(4); } }
|
||||||
|
.dead-air-bearing { position: absolute; top: 50%; left: 50%; width: 1px; height: 1px; transform-origin: 0 0; }
|
||||||
|
.dead-air-bearing i { position: absolute; top: -98px; left: -10px; width: 20px; height: 12px; border-top: 2px solid #ff735c; clip-path: polygon(0 0, 50% 100%, 100% 0); filter: drop-shadow(0 0 5px #ff4e34); }
|
||||||
|
.dead-air-bearing--mid i { top: -84px; border-color: #d99969; }
|
||||||
|
.dead-air-bearing--far i { top: -70px; border-color: #8c9485; }
|
||||||
|
|
||||||
|
.dead-air-hit,
|
||||||
|
.dead-air-event { position: absolute; z-index: 7; left: 50%; color: #daf7f0; font: .48rem/1 "IBM Plex Mono", monospace; letter-spacing: .15em; text-shadow: 0 0 9px #74e4ce; transform: translateX(-50%); pointer-events: none; }
|
||||||
|
.dead-air-hit { top: calc(50% + 33px); }
|
||||||
|
.dead-air-event { top: 29%; padding: 7px 18px; border-block: 1px solid rgb(255 104 78 / 38%); color: #ffc0af; background: linear-gradient(90deg, transparent, rgb(35 8 4 / 75%), transparent); text-shadow: 0 0 9px #ff5538; }
|
||||||
|
.dead-air-prompt { position: absolute; z-index: 7; bottom: 145px; left: 50%; display: flex; align-items: center; gap: 7px; padding: 5px 12px; color: #c8e4de; background: rgb(2 11 12 / 78%); font: .45rem/1 "IBM Plex Mono", monospace; letter-spacing: .1em; transform: translateX(-50%); pointer-events: none; }
|
||||||
|
.dead-air-prompt b { display: grid; width: 23px; height: 23px; place-items: center; border: 1px solid #68d8c1; color: #8cebd7; }
|
||||||
|
|
||||||
|
.dead-air-dead,
|
||||||
|
.dead-air-extracted { position: absolute; z-index: 10; top: 50%; left: 50%; display: flex; width: min(620px, calc(100% - 40px)); flex-direction: column; align-items: center; gap: 5px; padding: 20px; border-block: 1px solid rgb(255 81 55 / 48%); background: linear-gradient(90deg, transparent, rgb(28 3 2 / 86%) 20%, rgb(28 3 2 / 86%) 80%, transparent); transform: translate(-50%, -50%); pointer-events: none; }
|
||||||
|
.dead-air-dead small,
|
||||||
|
.dead-air-extracted small,
|
||||||
|
.dead-air-extracted span { color: #a65e52; font: .44rem/1 "IBM Plex Mono", monospace; letter-spacing: .17em; }
|
||||||
|
.dead-air-dead strong,
|
||||||
|
.dead-air-extracted strong { color: #ffd3c8; font-size: 1.28rem; letter-spacing: .18em; }
|
||||||
|
.dead-air-extracted { border-color: rgb(94 234 201 / 47%); background: linear-gradient(90deg, transparent, rgb(2 24 20 / 88%) 20%, rgb(2 24 20 / 88%) 80%, transparent); }
|
||||||
|
.dead-air-extracted small,
|
||||||
|
.dead-air-extracted span { color: #6da597; }
|
||||||
|
.dead-air-extracted strong { color: #cbfff3; }
|
||||||
|
|
||||||
|
.dead-air-hud { position: absolute; z-index: 6; right: 25px; bottom: 20px; left: 25px; display: grid; grid-template-columns: 200px 1fr auto 215px; align-items: end; gap: 18px; pointer-events: none; }
|
||||||
|
.dead-air-vitals,
|
||||||
|
.dead-air-cargo,
|
||||||
|
.dead-air-weapon { padding: 8px 11px; border-top: 1px solid rgb(102 224 200 / 35%); background: linear-gradient(180deg, rgb(3 13 14 / 78%), rgb(2 7 8 / 89%)); }
|
||||||
|
.dead-air-vitals small,
|
||||||
|
.dead-air-cargo small,
|
||||||
|
.dead-air-weapon small { display: block; color: #607873; font: .4rem/1 "IBM Plex Mono", monospace; letter-spacing: .13em; }
|
||||||
|
.dead-air-vitals strong { display: block; color: #d6eee9; font: 1.8rem/.9 "IBM Plex Mono", monospace; font-weight: 500; }
|
||||||
|
.dead-air-vitals > span { display: block; overflow: hidden; width: 150px; height: 3px; margin: 5px 0; background: rgb(255 255 255 / 8%); }
|
||||||
|
.dead-air-vitals > span i { display: block; height: 100%; background: #66d8c0; box-shadow: 0 0 8px #51caae; }
|
||||||
|
.dead-air-vitals em { color: #617e78; font: normal .37rem/1 "IBM Plex Mono", monospace; }
|
||||||
|
.dead-air-cargo { justify-self: center; min-width: 330px; border-color: rgb(255 88 57 / 28%); text-align: center; }
|
||||||
|
.dead-air-cargo strong { display: block; margin: 3px 0; color: #bdcbc8; font-size: .67rem; letter-spacing: .12em; }
|
||||||
|
.dead-air-cargo span { color: #5e726e; font: .36rem/1 "IBM Plex Mono", monospace; letter-spacing: .07em; }
|
||||||
|
.dead-air-cargo.is-carrying { border-color: #ff684c; background: linear-gradient(180deg, rgb(38 10 5 / 82%), rgb(13 5 3 / 91%)); box-shadow: 0 -8px 30px rgb(142 24 5 / 12%); }
|
||||||
|
.dead-air-cargo.is-carrying strong { color: #ffc7b9; text-shadow: 0 0 8px #9a2916; }
|
||||||
|
.dead-air-actions { display: flex; gap: 4px; pointer-events: auto; }
|
||||||
|
.dead-air-actions button { display: flex; min-width: 58px; flex-direction: column; align-items: center; padding: 7px 6px; border: 1px solid rgb(113 216 195 / 16%); color: #81938f; background: rgb(2 10 11 / 84%); cursor: pointer; }
|
||||||
|
.dead-air-actions button:hover { border-color: #70d9c3; color: #c9e6df; }
|
||||||
|
.dead-air-actions button:disabled { opacity: .28; cursor: default; }
|
||||||
|
.dead-air-actions b { color: #72d9c3; font: .5rem/1.2 "IBM Plex Mono", monospace; }
|
||||||
|
.dead-air-actions span { font-size: .38rem; letter-spacing: .07em; white-space: nowrap; }
|
||||||
|
.dead-air-weapon { text-align: right; }
|
||||||
|
.dead-air-weapon strong { display: block; color: #edf9f6; font: 2rem/.9 "IBM Plex Mono", monospace; font-weight: 500; }
|
||||||
|
.dead-air-weapon strong i { color: #5c6f6b; font-size: .68rem; font-style: normal; }
|
||||||
|
.dead-air-weapon span { color: #60736f; font: .36rem/1 "IBM Plex Mono", monospace; letter-spacing: .06em; }
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.game-switcher { max-width: calc(100% - 20px); overflow-x: auto; }
|
||||||
|
.game-switcher button { flex: 0 0 auto; }
|
||||||
|
.dead-air-header { padding: 13px; grid-template-columns: 1fr auto; }
|
||||||
|
.dead-air-objective { position: absolute; top: 62px; left: 50%; min-width: 320px; transform: translateX(-50%); }
|
||||||
|
.dead-air-brand small,
|
||||||
|
.dead-air-live small { display: none; }
|
||||||
|
.dead-air-security { top: 108px; left: 13px; width: 160px; }
|
||||||
|
.dead-air-listen { top: 108px; right: 13px; width: 160px; }
|
||||||
|
.dead-air-hud { right: 12px; bottom: 10px; left: 12px; grid-template-columns: 1fr auto; gap: 6px; }
|
||||||
|
.dead-air-vitals { grid-column: 1; }
|
||||||
|
.dead-air-weapon { grid-column: 2; }
|
||||||
|
.dead-air-cargo { grid-column: 1 / -1; grid-row: 1; min-width: 0; width: 100%; }
|
||||||
|
.dead-air-actions { grid-column: 1 / -1; justify-content: center; }
|
||||||
|
.dead-air-actions button { min-width: 88px; }
|
||||||
|
.dead-air-prompt { bottom: 184px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.dead-air-security { display: none; }
|
||||||
|
.dead-air-listen { width: 145px; }
|
||||||
|
.dead-air-listen > div:nth-of-type(n+3) { display: none; }
|
||||||
|
.dead-air-enter { padding-inline: 20px; }
|
||||||
|
.dead-air-enter strong { font-size: 1.25rem; }
|
||||||
|
.dead-air-enter em { max-width: 300px; }
|
||||||
|
.dead-air-vitals > span { width: 110px; }
|
||||||
|
.dead-air-weapon { min-width: 135px; }
|
||||||
|
.dead-air-cargo span { display: none; }
|
||||||
|
}
|
||||||
|
|||||||
368
apps/web/src/useDeadAirClient.ts
Normal file
368
apps/web/src/useDeadAirClient.ts
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
createInputStateStream,
|
||||||
|
FixedStepClock,
|
||||||
|
type NetworkStats,
|
||||||
|
} from "@syncer/engine";
|
||||||
|
import {
|
||||||
|
DEAD_AIR_SOCKET_PATH,
|
||||||
|
deadAirGame,
|
||||||
|
type DeadAirClientState,
|
||||||
|
type DeadAirInput,
|
||||||
|
} from "@syncer/shared";
|
||||||
|
import { DeadAirAudio } from "./dead-air-audio.js";
|
||||||
|
import type { ConnectionStatus, ValidationStatus } from "./useGameClient.js";
|
||||||
|
|
||||||
|
export type DeadAirAction = "interact" | "toggleFlashlight" | "throwDecoy" | "reload";
|
||||||
|
|
||||||
|
export interface DeadAirCorrection {
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeadAirRenderFrame {
|
||||||
|
state: Readonly<DeadAirClientState>;
|
||||||
|
interpolationAlpha: number;
|
||||||
|
localCorrection: DeadAirCorrection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeadAirRenderSource {
|
||||||
|
readonly current: DeadAirRenderFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeadAirClientView {
|
||||||
|
connection: ConnectionStatus;
|
||||||
|
validation: ValidationStatus;
|
||||||
|
playerId: number | null;
|
||||||
|
tick: number;
|
||||||
|
inputLeadTicks: number;
|
||||||
|
world: DeadAirClientState;
|
||||||
|
network: NetworkStats;
|
||||||
|
renderSource: DeadAirRenderSource;
|
||||||
|
setAction(action: DeadAirAction, active: boolean): void;
|
||||||
|
unlockAudio(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyNetwork: NetworkStats = {
|
||||||
|
roundTripTime: 0,
|
||||||
|
jitter: 0,
|
||||||
|
clockOffset: 0,
|
||||||
|
samples: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function neutralInput(): DeadAirInput {
|
||||||
|
return {
|
||||||
|
forward: 0,
|
||||||
|
strafe: 0,
|
||||||
|
yaw: 0,
|
||||||
|
pitch: 0,
|
||||||
|
fire: false,
|
||||||
|
sprint: false,
|
||||||
|
reload: false,
|
||||||
|
interact: false,
|
||||||
|
toggleFlashlight: false,
|
||||||
|
throwDecoy: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function socketUrls(): string[] {
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const proxied = `${protocol}//${window.location.host}${DEAD_AIR_SOCKET_PATH}`;
|
||||||
|
if (window.location.protocol !== "http:" || window.location.port !== "5173") return [proxied];
|
||||||
|
return [`ws://${window.location.hostname}:3001${DEAD_AIR_SOCKET_PATH}`, proxied];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeadAirClient(): DeadAirClientView {
|
||||||
|
const engine = useMemo(() => deadAirGame.createClient(), []);
|
||||||
|
const audio = useMemo(() => new DeadAirAudio(), []);
|
||||||
|
const protocol = deadAirGame.protocol;
|
||||||
|
const clock = useMemo(
|
||||||
|
() => new FixedStepClock({ rateHz: deadAirGame.tickRateHz, maxCatchUpSteps: 5 }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const renderSource = useRef<DeadAirRenderFrame>({
|
||||||
|
state: deadAirGame.client.createInitialState(),
|
||||||
|
interpolationAlpha: 0,
|
||||||
|
localCorrection: { x: 0, z: 0, updatedAt: 0 },
|
||||||
|
});
|
||||||
|
const actionRef = useRef<(action: DeadAirAction, active: boolean) => void>(() => undefined);
|
||||||
|
const setAction = useCallback(
|
||||||
|
(action: DeadAirAction, active: boolean) => actionRef.current(action, active),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const unlockAudio = useCallback(() => audio.unlock(), [audio]);
|
||||||
|
const [view, setView] = useState<Omit<DeadAirClientView, "renderSource" | "setAction" | "unlockAudio">>({
|
||||||
|
connection: "connecting",
|
||||||
|
validation: "waiting",
|
||||||
|
playerId: null,
|
||||||
|
tick: 0,
|
||||||
|
inputLeadTicks: 1,
|
||||||
|
world: deadAirGame.client.createInitialState(),
|
||||||
|
network: emptyNetwork,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
let socket: WebSocket | undefined;
|
||||||
|
let retryTimer: number | undefined;
|
||||||
|
let animationFrame: number | undefined;
|
||||||
|
let connection: ConnectionStatus = "connecting";
|
||||||
|
let validation: ValidationStatus = "waiting";
|
||||||
|
let input = neutralInput();
|
||||||
|
const pressed = new Set<string>();
|
||||||
|
const stream = createInputStateStream<DeadAirInput>(deadAirGame);
|
||||||
|
let lastInputFrame: ArrayBuffer | null = null;
|
||||||
|
let socketUrlIndex = 0;
|
||||||
|
let lastPublishedAt = Number.NEGATIVE_INFINITY;
|
||||||
|
const urls = socketUrls();
|
||||||
|
|
||||||
|
const refreshRenderSource = () => {
|
||||||
|
renderSource.current.state = engine.currentState as DeadAirClientState;
|
||||||
|
renderSource.current.interpolationAlpha = clock.interpolationAlpha;
|
||||||
|
};
|
||||||
|
const publish = (force = false, now = performance.now()) => {
|
||||||
|
if (!active || (!force && now - lastPublishedAt < 100)) return;
|
||||||
|
lastPublishedAt = now;
|
||||||
|
setView({
|
||||||
|
connection,
|
||||||
|
validation,
|
||||||
|
playerId: engine.localPlayerId,
|
||||||
|
tick: engine.tick,
|
||||||
|
inputLeadTicks: engine.networkClock.recommendedInputLeadTicks(deadAirGame.tickRateHz),
|
||||||
|
world: deadAirGame.client.cloneState(engine.currentState as DeadAirClientState),
|
||||||
|
network: engine.networkClock.stats,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const send = (frame: ArrayBuffer) => {
|
||||||
|
if (socket?.readyState === WebSocket.OPEN) socket.send(frame);
|
||||||
|
};
|
||||||
|
const sendInput = (force = false) => {
|
||||||
|
if (!engine.initialized) return;
|
||||||
|
stream.update(input);
|
||||||
|
const emission = stream.consume(performance.now(), force);
|
||||||
|
if (!emission) return;
|
||||||
|
if (emission.kind === "state" || !lastInputFrame) {
|
||||||
|
lastInputFrame = protocol.encodeClient({
|
||||||
|
kind: "input",
|
||||||
|
packet: engine.createInput(emission.input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
send(lastInputFrame);
|
||||||
|
};
|
||||||
|
const updateMovement = () => {
|
||||||
|
input = {
|
||||||
|
...input,
|
||||||
|
forward: Number(pressed.has("w")) - Number(pressed.has("s")),
|
||||||
|
strafe: Number(pressed.has("d")) - Number(pressed.has("a")),
|
||||||
|
sprint: pressed.has("shift"),
|
||||||
|
};
|
||||||
|
sendInput();
|
||||||
|
};
|
||||||
|
const updateAction = (action: DeadAirAction, value: boolean) => {
|
||||||
|
if (input[action] === value) return;
|
||||||
|
input = { ...input, [action]: value };
|
||||||
|
sendInput(true);
|
||||||
|
};
|
||||||
|
actionRef.current = updateAction;
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
let opened = false;
|
||||||
|
connection = engine.initialized ? "reconnecting" : "connecting";
|
||||||
|
publish(true);
|
||||||
|
socket = new WebSocket(urls[socketUrlIndex]!);
|
||||||
|
socket.binaryType = "arraybuffer";
|
||||||
|
socket.addEventListener("open", () => {
|
||||||
|
if (!active) return;
|
||||||
|
opened = true;
|
||||||
|
connection = "live";
|
||||||
|
publish(true);
|
||||||
|
});
|
||||||
|
socket.addEventListener("message", (message: MessageEvent<ArrayBuffer>) => {
|
||||||
|
if (!active || !(message.data instanceof ArrayBuffer)) return;
|
||||||
|
try {
|
||||||
|
const decoded = protocol.decodeServer(message.data);
|
||||||
|
switch (decoded.kind) {
|
||||||
|
case "welcome": {
|
||||||
|
engine.initialize(decoded.playerId, decoded.snapshot);
|
||||||
|
clock.reset(performance.now());
|
||||||
|
const local = decoded.snapshot.state.players.find((player) => player.id === decoded.playerId);
|
||||||
|
input = { ...neutralInput(), yaw: local?.yaw ?? 0, pitch: local?.pitch ?? 0 };
|
||||||
|
stream.reset(input);
|
||||||
|
lastInputFrame = null;
|
||||||
|
validation = "waiting";
|
||||||
|
renderSource.current.localCorrection = { x: 0, z: 0, updatedAt: performance.now() };
|
||||||
|
sendInput(true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "snapshot": {
|
||||||
|
const localBefore = (engine.currentState as DeadAirClientState).players.find(
|
||||||
|
(player) => player.id === engine.localPlayerId,
|
||||||
|
);
|
||||||
|
engine.reconcile(decoded.snapshot);
|
||||||
|
const localAfter = (engine.currentState as DeadAirClientState).players.find(
|
||||||
|
(player) => player.id === engine.localPlayerId,
|
||||||
|
);
|
||||||
|
if (localBefore && localAfter && decoded.snapshot.state.round === renderSource.current.state.round) {
|
||||||
|
const correction = renderSource.current.localCorrection;
|
||||||
|
correction.x += localBefore.x - localAfter.x;
|
||||||
|
correction.z += localBefore.z - localAfter.z;
|
||||||
|
correction.updatedAt = performance.now();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "acknowledge":
|
||||||
|
engine.acknowledge(decoded.sequence);
|
||||||
|
break;
|
||||||
|
case "pong":
|
||||||
|
engine.networkClock.receivePong(decoded.pong, performance.now());
|
||||||
|
break;
|
||||||
|
case "validation":
|
||||||
|
validation = decoded.valid ? "valid" : "invalid";
|
||||||
|
break;
|
||||||
|
case "reject-input":
|
||||||
|
engine.reject(decoded.sequence);
|
||||||
|
stream.invalidate();
|
||||||
|
lastInputFrame = null;
|
||||||
|
validation = "invalid";
|
||||||
|
sendInput(true);
|
||||||
|
break;
|
||||||
|
case "event":
|
||||||
|
engine.receiveEvent(decoded.event, decoded.tick);
|
||||||
|
audio.play(decoded.event);
|
||||||
|
break;
|
||||||
|
case "replay-start":
|
||||||
|
case "replay-frame":
|
||||||
|
case "replay-end":
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
refreshRenderSource();
|
||||||
|
publish(decoded.kind !== "snapshot");
|
||||||
|
} catch {
|
||||||
|
socket?.close(1003, "Invalid acoustic frame");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.addEventListener("close", () => {
|
||||||
|
if (!active) return;
|
||||||
|
if (!opened && urls.length > 1) socketUrlIndex = (socketUrlIndex + 1) % urls.length;
|
||||||
|
else if (opened) socketUrlIndex = 0;
|
||||||
|
connection = "reconnecting";
|
||||||
|
publish(true);
|
||||||
|
retryTimer = window.setTimeout(connect, 1_000);
|
||||||
|
});
|
||||||
|
socket.addEventListener("error", () => socket?.close());
|
||||||
|
};
|
||||||
|
|
||||||
|
const keyDown = (event: KeyboardEvent) => {
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if (["w", "a", "s", "d", "shift", "e", "f", "q", "r"].includes(key)) event.preventDefault();
|
||||||
|
audio.unlock();
|
||||||
|
if (["w", "a", "s", "d", "shift"].includes(key)) {
|
||||||
|
pressed.add(key);
|
||||||
|
updateMovement();
|
||||||
|
} else if (!event.repeat && key === "e") updateAction("interact", true);
|
||||||
|
else if (!event.repeat && key === "f") updateAction("toggleFlashlight", true);
|
||||||
|
else if (!event.repeat && key === "q") updateAction("throwDecoy", true);
|
||||||
|
else if (!event.repeat && key === "r") updateAction("reload", true);
|
||||||
|
};
|
||||||
|
const keyUp = (event: KeyboardEvent) => {
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if (["w", "a", "s", "d", "shift"].includes(key)) {
|
||||||
|
pressed.delete(key);
|
||||||
|
updateMovement();
|
||||||
|
} else if (key === "e") updateAction("interact", false);
|
||||||
|
else if (key === "f") updateAction("toggleFlashlight", false);
|
||||||
|
else if (key === "q") updateAction("throwDecoy", false);
|
||||||
|
else if (key === "r") updateAction("reload", false);
|
||||||
|
};
|
||||||
|
const mouseMove = (event: MouseEvent) => {
|
||||||
|
if (!document.pointerLockElement) return;
|
||||||
|
input = {
|
||||||
|
...input,
|
||||||
|
yaw: normalizeAngle(input.yaw - event.movementX * 0.0026),
|
||||||
|
pitch: clamp(input.pitch - event.movementY * 0.00215, -1.2, 1.2),
|
||||||
|
};
|
||||||
|
sendInput();
|
||||||
|
};
|
||||||
|
const mouseDown = (event: MouseEvent) => {
|
||||||
|
audio.unlock();
|
||||||
|
if (event.button !== 0 || !document.pointerLockElement) return;
|
||||||
|
input = { ...input, fire: true };
|
||||||
|
sendInput(true);
|
||||||
|
};
|
||||||
|
const mouseUp = (event: MouseEvent) => {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
input = { ...input, fire: false };
|
||||||
|
sendInput(true);
|
||||||
|
};
|
||||||
|
const release = () => {
|
||||||
|
pressed.clear();
|
||||||
|
input = {
|
||||||
|
...input,
|
||||||
|
forward: 0,
|
||||||
|
strafe: 0,
|
||||||
|
sprint: false,
|
||||||
|
fire: false,
|
||||||
|
reload: false,
|
||||||
|
interact: false,
|
||||||
|
toggleFlashlight: false,
|
||||||
|
throwDecoy: false,
|
||||||
|
};
|
||||||
|
sendInput(true);
|
||||||
|
};
|
||||||
|
const pointerLockChange = () => {
|
||||||
|
if (!document.pointerLockElement) release();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", keyDown);
|
||||||
|
window.addEventListener("keyup", keyUp);
|
||||||
|
window.addEventListener("mousemove", mouseMove);
|
||||||
|
window.addEventListener("mousedown", mouseDown);
|
||||||
|
window.addEventListener("mouseup", mouseUp);
|
||||||
|
window.addEventListener("blur", release);
|
||||||
|
document.addEventListener("pointerlockchange", pointerLockChange);
|
||||||
|
const pingTimer = window.setInterval(() => {
|
||||||
|
send(protocol.encodeClient({ kind: "ping", ping: engine.networkClock.createPing(performance.now()) }));
|
||||||
|
}, 1_000);
|
||||||
|
const validationTimer = window.setInterval(() => {
|
||||||
|
if (engine.initialized) send(protocol.encodeClient({ kind: "state-report", report: engine.createStateReport() }));
|
||||||
|
}, 2_000);
|
||||||
|
const animate = (now: number) => {
|
||||||
|
sendInput();
|
||||||
|
clock.advance(now, () => engine.step());
|
||||||
|
refreshRenderSource();
|
||||||
|
publish(false, now);
|
||||||
|
animationFrame = window.requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
animationFrame = window.requestAnimationFrame(animate);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
actionRef.current = () => undefined;
|
||||||
|
window.clearTimeout(retryTimer);
|
||||||
|
window.clearInterval(pingTimer);
|
||||||
|
window.clearInterval(validationTimer);
|
||||||
|
if (animationFrame !== undefined) window.cancelAnimationFrame(animationFrame);
|
||||||
|
window.removeEventListener("keydown", keyDown);
|
||||||
|
window.removeEventListener("keyup", keyUp);
|
||||||
|
window.removeEventListener("mousemove", mouseMove);
|
||||||
|
window.removeEventListener("mousedown", mouseDown);
|
||||||
|
window.removeEventListener("mouseup", mouseUp);
|
||||||
|
window.removeEventListener("blur", release);
|
||||||
|
document.removeEventListener("pointerlockchange", pointerLockChange);
|
||||||
|
socket?.close();
|
||||||
|
audio.dispose();
|
||||||
|
};
|
||||||
|
}, [audio, clock, engine, protocol]);
|
||||||
|
|
||||||
|
return { ...view, renderSource, setAction, unlockAudio };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAngle(value: number): number {
|
||||||
|
return Math.atan2(Math.sin(value), Math.cos(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, minimum: number, maximum: number): number {
|
||||||
|
return Math.max(minimum, Math.min(maximum, value));
|
||||||
|
}
|
||||||
905
packages/shared/src/dead-air-game.ts
Normal file
905
packages/shared/src/dead-air-game.ts
Normal file
@@ -0,0 +1,905 @@
|
|||||||
|
import {
|
||||||
|
createJsonCodec,
|
||||||
|
defineMultiplayerGame,
|
||||||
|
withInputStream,
|
||||||
|
} from "@syncer/engine";
|
||||||
|
import {
|
||||||
|
DEAD_AIR_ARTIFACT_SPAWN,
|
||||||
|
DEAD_AIR_EXTRACTION,
|
||||||
|
DEAD_AIR_POWER_SWITCH,
|
||||||
|
DEAD_AIR_SPAWNS,
|
||||||
|
DEAD_AIR_WARDEN_SPAWNS,
|
||||||
|
collidesWithDeadAirMap,
|
||||||
|
hasDeadAirLineOfSight,
|
||||||
|
} from "./dead-air-map.js";
|
||||||
|
import type {
|
||||||
|
DeadAirAuthorityEvent,
|
||||||
|
DeadAirAuthorityPlayer,
|
||||||
|
DeadAirAuthorityState,
|
||||||
|
DeadAirClientState,
|
||||||
|
DeadAirDistanceBand,
|
||||||
|
DeadAirGameContract,
|
||||||
|
DeadAirInput,
|
||||||
|
DeadAirPerception,
|
||||||
|
DeadAirPlayerView,
|
||||||
|
DeadAirSoundCue,
|
||||||
|
} from "./dead-air-types.js";
|
||||||
|
|
||||||
|
export const DEAD_AIR_SOCKET_PATH = "/ws/dead-air";
|
||||||
|
export const DEAD_AIR_TICK_RATE = 30;
|
||||||
|
export const DEAD_AIR_SNAPSHOT_RATE = 10;
|
||||||
|
export const DEAD_AIR_WARDEN_COUNT = 5;
|
||||||
|
export const DEAD_AIR_WARDEN_ID_BASE = 50_000;
|
||||||
|
export const DEAD_AIR_MAGAZINE_SIZE = 8;
|
||||||
|
|
||||||
|
const walkSpeed = 5.2;
|
||||||
|
const sprintSpeed = 7.8;
|
||||||
|
const movementAcceleration = 36;
|
||||||
|
const movementFriction = 12;
|
||||||
|
const shotRange = 34;
|
||||||
|
const shotDamage = 34;
|
||||||
|
const shotCooldownTicks = 10;
|
||||||
|
const reloadDurationTicks = 42;
|
||||||
|
const presentationLifetimeTicks = DEAD_AIR_TICK_RATE * 7;
|
||||||
|
|
||||||
|
const inputCodec = createJsonCodec<DeadAirInput>();
|
||||||
|
const stateCodec = createJsonCodec<DeadAirClientState>();
|
||||||
|
const eventCodec = createJsonCodec<DeadAirPerception>();
|
||||||
|
|
||||||
|
const baseDeadAirGame = defineMultiplayerGame<DeadAirGameContract>({
|
||||||
|
clock: {
|
||||||
|
ticksPerSecond: DEAD_AIR_TICK_RATE,
|
||||||
|
snapshotsPerSecond: DEAD_AIR_SNAPSHOT_RATE,
|
||||||
|
},
|
||||||
|
|
||||||
|
authority: {
|
||||||
|
createInitialState: createAuthorityState,
|
||||||
|
cloneState: cloneAuthorityState,
|
||||||
|
addPlayer(state, { playerId }) {
|
||||||
|
state.players.push(createPlayer(playerId, false, state.round));
|
||||||
|
state.players.sort((left, right) => left.id - right.id);
|
||||||
|
},
|
||||||
|
removePlayer(state, { playerId, emit }) {
|
||||||
|
const player = findPlayer(state, playerId);
|
||||||
|
if (player?.carryingArtifact) dropArtifact(state, player, emit);
|
||||||
|
state.players = state.players.filter((candidate) => candidate.id !== playerId);
|
||||||
|
},
|
||||||
|
applyInput(state, input, { playerId, emit }) {
|
||||||
|
const player = findPlayer(state, playerId);
|
||||||
|
if (!player || player.bot || !player.alive || state.resetTicks > 0) return;
|
||||||
|
applyCommand(player, input);
|
||||||
|
if (input.reload) beginReload(player);
|
||||||
|
if (input.toggleFlashlight) player.flashlight = !player.flashlight;
|
||||||
|
if (input.interact) interact(state, player, emit);
|
||||||
|
if (input.throwDecoy) throwDecoy(state, player, emit);
|
||||||
|
},
|
||||||
|
step(state, { tick, deltaSeconds, emit }) {
|
||||||
|
if (state.resetTicks > 0) {
|
||||||
|
state.resetTicks -= 1;
|
||||||
|
if (state.resetTicks === 0) resetRound(state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.elapsedTicks += 1;
|
||||||
|
if (state.elapsedTicks % 75 === 0) {
|
||||||
|
emit({
|
||||||
|
id: state.nextEventId++,
|
||||||
|
type: "noise",
|
||||||
|
sourceId: null,
|
||||||
|
cue: "curse",
|
||||||
|
x: state.artifactX,
|
||||||
|
z: state.artifactZ,
|
||||||
|
loudness: 27,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const player of state.players) {
|
||||||
|
if (!player.alive) {
|
||||||
|
updateRespawn(state, player);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
updateTimers(player);
|
||||||
|
if (player.botState) updateWarden(state, player, tick);
|
||||||
|
simulatePlayer(player, deltaSeconds);
|
||||||
|
emitFootsteps(state, player, emit);
|
||||||
|
if (player.firing) fire(state, player, emit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const carrier = state.artifactCarrierId === null
|
||||||
|
? null
|
||||||
|
: findPlayer(state, state.artifactCarrierId);
|
||||||
|
if (carrier?.alive) {
|
||||||
|
state.artifactX = carrier.x;
|
||||||
|
state.artifactZ = carrier.z;
|
||||||
|
if (
|
||||||
|
!carrier.bot &&
|
||||||
|
Math.hypot(carrier.x - DEAD_AIR_EXTRACTION.x, carrier.z - DEAD_AIR_EXTRACTION.z) <=
|
||||||
|
DEAD_AIR_EXTRACTION.radius
|
||||||
|
) {
|
||||||
|
carrier.extractions += 1;
|
||||||
|
state.lastExtractorId = carrier.id;
|
||||||
|
state.artifactCarrierId = null;
|
||||||
|
carrier.carryingArtifact = false;
|
||||||
|
state.resetTicks = DEAD_AIR_TICK_RATE * 4;
|
||||||
|
emit({
|
||||||
|
id: state.nextEventId++,
|
||||||
|
type: "artifact",
|
||||||
|
action: "extracted",
|
||||||
|
actorId: carrier.id,
|
||||||
|
x: carrier.x,
|
||||||
|
z: carrier.z,
|
||||||
|
round: state.round,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
validateState(state) {
|
||||||
|
return (
|
||||||
|
Number.isInteger(state.round) &&
|
||||||
|
state.round > 0 &&
|
||||||
|
Number.isFinite(state.artifactX + state.artifactZ) &&
|
||||||
|
state.players.every(
|
||||||
|
(player) =>
|
||||||
|
Number.isFinite(
|
||||||
|
player.x + player.z + player.velocityX + player.velocityZ + player.yaw + player.pitch,
|
||||||
|
) &&
|
||||||
|
player.health >= 0 &&
|
||||||
|
player.health <= 100 &&
|
||||||
|
player.magazine >= 0 &&
|
||||||
|
player.reserve >= 0,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
prediction: {
|
||||||
|
createInitialState: createClientState,
|
||||||
|
cloneState: cloneClientState,
|
||||||
|
applyInput(state, input, { playerId }) {
|
||||||
|
const player = state.players.find((candidate) => candidate.id === playerId);
|
||||||
|
if (!player || !player.alive || state.resetTicks > 0) return;
|
||||||
|
player.inputForward = input.forward;
|
||||||
|
player.inputStrafe = input.strafe;
|
||||||
|
player.yaw = normalizeAngle(input.yaw);
|
||||||
|
player.pitch = clamp(input.pitch, -1.2, 1.2);
|
||||||
|
player.firing = input.fire;
|
||||||
|
player.sprinting = input.sprint;
|
||||||
|
if (input.reload && player.reloadTicks === 0 && (player.magazine ?? 0) < DEAD_AIR_MAGAZINE_SIZE) {
|
||||||
|
player.reloadTicks = reloadDurationTicks;
|
||||||
|
}
|
||||||
|
if (input.toggleFlashlight) player.flashlight = !player.flashlight;
|
||||||
|
},
|
||||||
|
step(state, { tick, deltaSeconds }) {
|
||||||
|
if (state.resetTicks > 0) state.resetTicks -= 1;
|
||||||
|
state.elapsedTicks += 1;
|
||||||
|
for (const player of state.players) {
|
||||||
|
if (!player.alive) {
|
||||||
|
if (player.respawnTicks > 0) player.respawnTicks -= 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (player.cooldownTicks > 0) player.cooldownTicks -= 1;
|
||||||
|
if (player.reloadTicks > 0) player.reloadTicks -= 1;
|
||||||
|
simulateVisiblePlayer(player, deltaSeconds);
|
||||||
|
}
|
||||||
|
state.events = state.events.filter(
|
||||||
|
(entry) => entry.receivedTick >= tick - presentationLifetimeTicks,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
mergeSnapshot(predicted, snapshot, { tick }) {
|
||||||
|
const merged = cloneClientState(snapshot);
|
||||||
|
merged.events = predicted.events
|
||||||
|
.filter((entry) => entry.receivedTick >= tick - presentationLifetimeTicks)
|
||||||
|
.map((entry) => ({ receivedTick: entry.receivedTick, event: { ...entry.event } }));
|
||||||
|
return merged;
|
||||||
|
},
|
||||||
|
applyEvent(state, event, { tick }) {
|
||||||
|
if (state.events.some((entry) => entry.event.id === event.id)) return;
|
||||||
|
state.events.push({ receivedTick: tick, event: { ...event } });
|
||||||
|
if (state.events.length > 72) state.events.shift();
|
||||||
|
},
|
||||||
|
validateState(state) {
|
||||||
|
return (
|
||||||
|
Number.isInteger(state.round) &&
|
||||||
|
state.players.every(
|
||||||
|
(player) =>
|
||||||
|
Number.isFinite(player.x + player.z + player.yaw + player.pitch) &&
|
||||||
|
(player.health === null || (player.health >= 0 && player.health <= 100)),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
visibility: {
|
||||||
|
createSnapshot(authority, { playerId }) {
|
||||||
|
const viewer = findPlayer(authority, playerId);
|
||||||
|
const visiblePlayers = viewer
|
||||||
|
? authority.players
|
||||||
|
.filter(
|
||||||
|
(target) => target.id === viewer.id || canSeePlayer(authority, viewer, target),
|
||||||
|
)
|
||||||
|
.map((player) => playerView(player, player.id === viewer.id))
|
||||||
|
: [];
|
||||||
|
const carrier = authority.artifactCarrierId === null
|
||||||
|
? null
|
||||||
|
: findPlayer(authority, authority.artifactCarrierId);
|
||||||
|
const visibleCarrier = carrier && visiblePlayers.some((player) => player.id === carrier.id);
|
||||||
|
const artifactVisible = Boolean(
|
||||||
|
viewer &&
|
||||||
|
(carrier?.id === viewer.id ||
|
||||||
|
visibleCarrier ||
|
||||||
|
(!carrier && canSeePoint(authority, viewer, authority.artifactX, authority.artifactZ))),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
players: visiblePlayers,
|
||||||
|
artifact: {
|
||||||
|
visible: artifactVisible,
|
||||||
|
x: artifactVisible ? authority.artifactX : null,
|
||||||
|
z: artifactVisible ? authority.artifactZ : null,
|
||||||
|
carrierId: artifactVisible ? authority.artifactCarrierId : null,
|
||||||
|
},
|
||||||
|
powerOn: authority.powerOn,
|
||||||
|
elapsedTicks: authority.elapsedTicks,
|
||||||
|
round: authority.round,
|
||||||
|
resetTicks: authority.resetTicks,
|
||||||
|
lastExtractorId: authority.lastExtractorId === playerId ? playerId : null,
|
||||||
|
events: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
validateClientState(authority, candidate, { playerId }) {
|
||||||
|
const expected = findPlayer(authority, playerId);
|
||||||
|
const reported = candidate.players.find((player) => player.id === playerId);
|
||||||
|
return Boolean(
|
||||||
|
expected &&
|
||||||
|
reported &&
|
||||||
|
reported.health === expected.health &&
|
||||||
|
reported.magazine === expected.magazine &&
|
||||||
|
Math.hypot(reported.x - expected.x, reported.z - expected.z) <= 3.5,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
perceive(authority, event, { playerId }) {
|
||||||
|
return perceive(authority, event, playerId);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
input: {
|
||||||
|
validate(input) {
|
||||||
|
return (
|
||||||
|
typeof input === "object" &&
|
||||||
|
input !== null &&
|
||||||
|
Number.isFinite(input.forward) &&
|
||||||
|
Number.isFinite(input.strafe) &&
|
||||||
|
Number.isFinite(input.yaw) &&
|
||||||
|
Number.isFinite(input.pitch) &&
|
||||||
|
Math.abs(input.forward) <= 1 &&
|
||||||
|
Math.abs(input.strafe) <= 1 &&
|
||||||
|
input.pitch >= -1.25 &&
|
||||||
|
input.pitch <= 1.25 &&
|
||||||
|
typeof input.fire === "boolean" &&
|
||||||
|
typeof input.sprint === "boolean" &&
|
||||||
|
typeof input.reload === "boolean" &&
|
||||||
|
typeof input.interact === "boolean" &&
|
||||||
|
typeof input.toggleFlashlight === "boolean" &&
|
||||||
|
typeof input.throwDecoy === "boolean"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
encoding: {
|
||||||
|
input: inputCodec,
|
||||||
|
clientState: stateCodec,
|
||||||
|
perception: eventCodec,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deadAirGame = withInputStream(baseDeadAirGame, {
|
||||||
|
heartbeatRateHz: 20,
|
||||||
|
timeoutMs: 400,
|
||||||
|
inputsEqual(left, right) {
|
||||||
|
return (
|
||||||
|
left.forward === right.forward &&
|
||||||
|
left.strafe === right.strafe &&
|
||||||
|
left.yaw === right.yaw &&
|
||||||
|
left.pitch === right.pitch &&
|
||||||
|
left.fire === right.fire &&
|
||||||
|
left.sprint === right.sprint &&
|
||||||
|
left.reload === right.reload &&
|
||||||
|
left.interact === right.interact &&
|
||||||
|
left.toggleFlashlight === right.toggleFlashlight &&
|
||||||
|
left.throwDecoy === right.throwDecoy
|
||||||
|
);
|
||||||
|
},
|
||||||
|
neutralize(lastInput) {
|
||||||
|
return {
|
||||||
|
...lastInput,
|
||||||
|
forward: 0,
|
||||||
|
strafe: 0,
|
||||||
|
fire: false,
|
||||||
|
sprint: false,
|
||||||
|
reload: false,
|
||||||
|
interact: false,
|
||||||
|
toggleFlashlight: false,
|
||||||
|
throwDecoy: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
resume(lastInput) {
|
||||||
|
return {
|
||||||
|
...lastInput,
|
||||||
|
reload: false,
|
||||||
|
interact: false,
|
||||||
|
toggleFlashlight: false,
|
||||||
|
throwDecoy: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function createAuthorityState(): DeadAirAuthorityState {
|
||||||
|
const players: DeadAirAuthorityPlayer[] = [];
|
||||||
|
for (let index = 0; index < DEAD_AIR_WARDEN_COUNT; index += 1) {
|
||||||
|
players.push(createPlayer(DEAD_AIR_WARDEN_ID_BASE + index, true, 1));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
players,
|
||||||
|
artifactX: DEAD_AIR_ARTIFACT_SPAWN.x,
|
||||||
|
artifactZ: DEAD_AIR_ARTIFACT_SPAWN.z,
|
||||||
|
artifactCarrierId: null,
|
||||||
|
powerOn: true,
|
||||||
|
elapsedTicks: 0,
|
||||||
|
round: 1,
|
||||||
|
resetTicks: 0,
|
||||||
|
lastExtractorId: null,
|
||||||
|
nextEventId: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createClientState(): DeadAirClientState {
|
||||||
|
return {
|
||||||
|
players: [],
|
||||||
|
artifact: { visible: false, x: null, z: null, carrierId: null },
|
||||||
|
powerOn: true,
|
||||||
|
elapsedTicks: 0,
|
||||||
|
round: 1,
|
||||||
|
resetTicks: 0,
|
||||||
|
lastExtractorId: null,
|
||||||
|
events: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlayer(id: number, bot: boolean, round: number): DeadAirAuthorityPlayer {
|
||||||
|
const spawn = bot
|
||||||
|
? DEAD_AIR_WARDEN_SPAWNS[(id - DEAD_AIR_WARDEN_ID_BASE) % DEAD_AIR_WARDEN_SPAWNS.length]!
|
||||||
|
: DEAD_AIR_SPAWNS[(id + round) % DEAD_AIR_SPAWNS.length]!;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
bot,
|
||||||
|
alive: true,
|
||||||
|
x: spawn.x,
|
||||||
|
z: spawn.z,
|
||||||
|
velocityX: 0,
|
||||||
|
velocityZ: 0,
|
||||||
|
yaw: spawn.yaw,
|
||||||
|
pitch: 0,
|
||||||
|
health: 100,
|
||||||
|
flashlight: true,
|
||||||
|
carryingArtifact: false,
|
||||||
|
firing: false,
|
||||||
|
sprinting: false,
|
||||||
|
inputForward: 0,
|
||||||
|
inputStrafe: 0,
|
||||||
|
cooldownTicks: 0,
|
||||||
|
reloadTicks: 0,
|
||||||
|
respawnTicks: 0,
|
||||||
|
magazine: DEAD_AIR_MAGAZINE_SIZE,
|
||||||
|
reserve: 32,
|
||||||
|
decoys: bot ? 0 : 3,
|
||||||
|
extractions: 0,
|
||||||
|
footstepTicks: 0,
|
||||||
|
botState: bot
|
||||||
|
? { phase: id * 0.71, targetId: null, lastKnownX: spawn.x, lastKnownZ: spawn.z }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCommand(player: DeadAirAuthorityPlayer, input: DeadAirInput): void {
|
||||||
|
player.inputForward = input.forward;
|
||||||
|
player.inputStrafe = input.strafe;
|
||||||
|
player.yaw = normalizeAngle(input.yaw);
|
||||||
|
player.pitch = clamp(input.pitch, -1.2, 1.2);
|
||||||
|
player.firing = input.fire;
|
||||||
|
player.sprinting = input.sprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTimers(player: DeadAirAuthorityPlayer): void {
|
||||||
|
if (player.cooldownTicks > 0) player.cooldownTicks -= 1;
|
||||||
|
if (player.footstepTicks > 0) player.footstepTicks -= 1;
|
||||||
|
if (player.reloadTicks > 0) {
|
||||||
|
player.reloadTicks -= 1;
|
||||||
|
if (player.reloadTicks === 0) completeReload(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulatePlayer(player: DeadAirAuthorityPlayer, deltaSeconds: number): void {
|
||||||
|
const speed = player.sprinting && !player.carryingArtifact ? sprintSpeed : walkSpeed;
|
||||||
|
const sin = Math.sin(player.yaw);
|
||||||
|
const cos = Math.cos(player.yaw);
|
||||||
|
let desiredX = (sin * player.inputForward + cos * player.inputStrafe) * speed;
|
||||||
|
let desiredZ = (cos * player.inputForward - sin * player.inputStrafe) * speed;
|
||||||
|
const inputLength = Math.hypot(player.inputForward, player.inputStrafe);
|
||||||
|
if (inputLength > 1) {
|
||||||
|
desiredX /= inputLength;
|
||||||
|
desiredZ /= inputLength;
|
||||||
|
}
|
||||||
|
const blend = Math.min(1, movementAcceleration * deltaSeconds);
|
||||||
|
player.velocityX += (desiredX - player.velocityX) * blend;
|
||||||
|
player.velocityZ += (desiredZ - player.velocityZ) * blend;
|
||||||
|
if (inputLength < 0.01) {
|
||||||
|
const damping = Math.max(0, 1 - movementFriction * deltaSeconds);
|
||||||
|
player.velocityX *= damping;
|
||||||
|
player.velocityZ *= damping;
|
||||||
|
}
|
||||||
|
moveWithCollision(player, deltaSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulateVisiblePlayer(player: DeadAirPlayerView, deltaSeconds: number): void {
|
||||||
|
const speed = player.sprinting && !player.carryingArtifact ? sprintSpeed : walkSpeed;
|
||||||
|
const sin = Math.sin(player.yaw);
|
||||||
|
const cos = Math.cos(player.yaw);
|
||||||
|
let desiredX = (sin * player.inputForward + cos * player.inputStrafe) * speed;
|
||||||
|
let desiredZ = (cos * player.inputForward - sin * player.inputStrafe) * speed;
|
||||||
|
const inputLength = Math.hypot(player.inputForward, player.inputStrafe);
|
||||||
|
if (inputLength > 1) {
|
||||||
|
desiredX /= inputLength;
|
||||||
|
desiredZ /= inputLength;
|
||||||
|
}
|
||||||
|
const blend = Math.min(1, movementAcceleration * deltaSeconds);
|
||||||
|
player.velocityX += (desiredX - player.velocityX) * blend;
|
||||||
|
player.velocityZ += (desiredZ - player.velocityZ) * blend;
|
||||||
|
if (inputLength < 0.01) {
|
||||||
|
const damping = Math.max(0, 1 - movementFriction * deltaSeconds);
|
||||||
|
player.velocityX *= damping;
|
||||||
|
player.velocityZ *= damping;
|
||||||
|
}
|
||||||
|
moveWithCollision(player, deltaSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveWithCollision(
|
||||||
|
player: Pick<DeadAirPlayerView, "x" | "z" | "velocityX" | "velocityZ">,
|
||||||
|
deltaSeconds: number,
|
||||||
|
): void {
|
||||||
|
const nextX = player.x + player.velocityX * deltaSeconds;
|
||||||
|
if (!collidesWithDeadAirMap(nextX, player.z)) player.x = nextX;
|
||||||
|
else player.velocityX = 0;
|
||||||
|
const nextZ = player.z + player.velocityZ * deltaSeconds;
|
||||||
|
if (!collidesWithDeadAirMap(player.x, nextZ)) player.z = nextZ;
|
||||||
|
else player.velocityZ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitFootsteps(
|
||||||
|
state: DeadAirAuthorityState,
|
||||||
|
player: DeadAirAuthorityPlayer,
|
||||||
|
emit: (event: DeadAirAuthorityEvent) => void,
|
||||||
|
): void {
|
||||||
|
if (Math.hypot(player.velocityX, player.velocityZ) < 1.2 || player.footstepTicks > 0) return;
|
||||||
|
player.footstepTicks = player.sprinting ? 7 : 12;
|
||||||
|
emit({
|
||||||
|
id: state.nextEventId++,
|
||||||
|
type: "noise",
|
||||||
|
sourceId: player.id,
|
||||||
|
cue: "footstep",
|
||||||
|
x: player.x,
|
||||||
|
z: player.z,
|
||||||
|
loudness: player.sprinting ? 20 : player.carryingArtifact ? 14 : 9,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fire(
|
||||||
|
state: DeadAirAuthorityState,
|
||||||
|
shooter: DeadAirAuthorityPlayer,
|
||||||
|
emit: (event: DeadAirAuthorityEvent) => void,
|
||||||
|
): void {
|
||||||
|
if (shooter.cooldownTicks > 0 || shooter.reloadTicks > 0 || shooter.magazine <= 0) {
|
||||||
|
if (shooter.magazine <= 0) beginReload(shooter);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shooter.magazine -= 1;
|
||||||
|
shooter.cooldownTicks = shotCooldownTicks;
|
||||||
|
emit({
|
||||||
|
id: state.nextEventId++,
|
||||||
|
type: "shot",
|
||||||
|
sourceId: shooter.id,
|
||||||
|
x: shooter.x,
|
||||||
|
z: shooter.z,
|
||||||
|
yaw: shooter.yaw,
|
||||||
|
pitch: shooter.pitch,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Math.abs(shooter.pitch) > 0.48) return;
|
||||||
|
const directionX = Math.sin(shooter.yaw);
|
||||||
|
const directionZ = Math.cos(shooter.yaw);
|
||||||
|
let hit: DeadAirAuthorityPlayer | null = null;
|
||||||
|
let hitDistance = shotRange;
|
||||||
|
for (const target of state.players) {
|
||||||
|
if (!target.alive || target.id === shooter.id || target.bot === shooter.bot) continue;
|
||||||
|
const offsetX = target.x - shooter.x;
|
||||||
|
const offsetZ = target.z - shooter.z;
|
||||||
|
const forwardDistance = offsetX * directionX + offsetZ * directionZ;
|
||||||
|
const crossDistance = Math.abs(offsetX * directionZ - offsetZ * directionX);
|
||||||
|
if (
|
||||||
|
forwardDistance <= 0 ||
|
||||||
|
forwardDistance >= hitDistance ||
|
||||||
|
crossDistance > 0.78 ||
|
||||||
|
!hasDeadAirLineOfSight(shooter.x, shooter.z, target.x, target.z)
|
||||||
|
) continue;
|
||||||
|
hit = target;
|
||||||
|
hitDistance = forwardDistance;
|
||||||
|
}
|
||||||
|
if (!hit) return;
|
||||||
|
hit.health = Math.max(0, hit.health - shotDamage);
|
||||||
|
const eliminated = hit.health === 0;
|
||||||
|
if (eliminated) eliminate(state, hit, emit);
|
||||||
|
emit({
|
||||||
|
id: state.nextEventId++,
|
||||||
|
type: "damage",
|
||||||
|
sourceId: shooter.id,
|
||||||
|
targetId: hit.id,
|
||||||
|
amount: shotDamage,
|
||||||
|
health: hit.health,
|
||||||
|
eliminated,
|
||||||
|
x: hit.x,
|
||||||
|
z: hit.z,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function eliminate(
|
||||||
|
state: DeadAirAuthorityState,
|
||||||
|
player: DeadAirAuthorityPlayer,
|
||||||
|
emit: (event: DeadAirAuthorityEvent) => void,
|
||||||
|
): void {
|
||||||
|
player.alive = false;
|
||||||
|
player.respawnTicks = DEAD_AIR_TICK_RATE * 3;
|
||||||
|
player.velocityX = 0;
|
||||||
|
player.velocityZ = 0;
|
||||||
|
player.firing = false;
|
||||||
|
if (player.carryingArtifact) dropArtifact(state, player, emit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRespawn(state: DeadAirAuthorityState, player: DeadAirAuthorityPlayer): void {
|
||||||
|
if (player.respawnTicks > 0) player.respawnTicks -= 1;
|
||||||
|
if (player.respawnTicks > 0) return;
|
||||||
|
const fresh = createPlayer(player.id, player.bot, state.round);
|
||||||
|
const extractions = player.extractions;
|
||||||
|
Object.assign(player, fresh, { extractions });
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginReload(player: DeadAirAuthorityPlayer): void {
|
||||||
|
if (
|
||||||
|
player.reloadTicks > 0 ||
|
||||||
|
player.magazine >= DEAD_AIR_MAGAZINE_SIZE ||
|
||||||
|
player.reserve <= 0
|
||||||
|
) return;
|
||||||
|
player.reloadTicks = reloadDurationTicks;
|
||||||
|
player.firing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeReload(player: DeadAirAuthorityPlayer): void {
|
||||||
|
const needed = DEAD_AIR_MAGAZINE_SIZE - player.magazine;
|
||||||
|
const loaded = Math.min(needed, player.reserve);
|
||||||
|
player.magazine += loaded;
|
||||||
|
player.reserve -= loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interact(
|
||||||
|
state: DeadAirAuthorityState,
|
||||||
|
player: DeadAirAuthorityPlayer,
|
||||||
|
emit: (event: DeadAirAuthorityEvent) => void,
|
||||||
|
): void {
|
||||||
|
if (player.carryingArtifact) {
|
||||||
|
dropArtifact(state, player, emit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
state.artifactCarrierId === null &&
|
||||||
|
Math.hypot(player.x - state.artifactX, player.z - state.artifactZ) <= 2.3
|
||||||
|
) {
|
||||||
|
state.artifactCarrierId = player.id;
|
||||||
|
player.carryingArtifact = true;
|
||||||
|
emit({
|
||||||
|
id: state.nextEventId++,
|
||||||
|
type: "artifact",
|
||||||
|
action: "grabbed",
|
||||||
|
actorId: player.id,
|
||||||
|
x: player.x,
|
||||||
|
z: player.z,
|
||||||
|
round: state.round,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Math.hypot(player.x - DEAD_AIR_POWER_SWITCH.x, player.z - DEAD_AIR_POWER_SWITCH.z) <= 2.5
|
||||||
|
) {
|
||||||
|
state.powerOn = !state.powerOn;
|
||||||
|
emit({
|
||||||
|
id: state.nextEventId++,
|
||||||
|
type: "power",
|
||||||
|
on: state.powerOn,
|
||||||
|
actorId: player.id,
|
||||||
|
x: player.x,
|
||||||
|
z: player.z,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropArtifact(
|
||||||
|
state: DeadAirAuthorityState,
|
||||||
|
player: DeadAirAuthorityPlayer,
|
||||||
|
emit: (event: DeadAirAuthorityEvent) => void,
|
||||||
|
): void {
|
||||||
|
player.carryingArtifact = false;
|
||||||
|
state.artifactCarrierId = null;
|
||||||
|
state.artifactX = player.x;
|
||||||
|
state.artifactZ = player.z;
|
||||||
|
emit({
|
||||||
|
id: state.nextEventId++,
|
||||||
|
type: "artifact",
|
||||||
|
action: "dropped",
|
||||||
|
actorId: player.id,
|
||||||
|
x: player.x,
|
||||||
|
z: player.z,
|
||||||
|
round: state.round,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwDecoy(
|
||||||
|
state: DeadAirAuthorityState,
|
||||||
|
player: DeadAirAuthorityPlayer,
|
||||||
|
emit: (event: DeadAirAuthorityEvent) => void,
|
||||||
|
): void {
|
||||||
|
if (player.decoys <= 0) return;
|
||||||
|
player.decoys -= 1;
|
||||||
|
const candidateX = player.x + Math.sin(player.yaw) * 8;
|
||||||
|
const candidateZ = player.z + Math.cos(player.yaw) * 8;
|
||||||
|
emit({
|
||||||
|
id: state.nextEventId++,
|
||||||
|
type: "noise",
|
||||||
|
sourceId: null,
|
||||||
|
cue: "decoy",
|
||||||
|
x: collidesWithDeadAirMap(candidateX, candidateZ, 0.1) ? player.x : candidateX,
|
||||||
|
z: collidesWithDeadAirMap(candidateX, candidateZ, 0.1) ? player.z : candidateZ,
|
||||||
|
loudness: 32,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWarden(
|
||||||
|
state: DeadAirAuthorityState,
|
||||||
|
warden: DeadAirAuthorityPlayer,
|
||||||
|
tick: number,
|
||||||
|
): void {
|
||||||
|
const visibleTargets = state.players.filter(
|
||||||
|
(target) => !target.bot && target.alive && canSeePlayer(state, warden, target),
|
||||||
|
);
|
||||||
|
visibleTargets.sort(
|
||||||
|
(left, right) =>
|
||||||
|
Math.hypot(left.x - warden.x, left.z - warden.z) -
|
||||||
|
Math.hypot(right.x - warden.x, right.z - warden.z),
|
||||||
|
);
|
||||||
|
const target = visibleTargets[0] ?? null;
|
||||||
|
const botState = warden.botState!;
|
||||||
|
botState.targetId = target?.id ?? null;
|
||||||
|
if (target) {
|
||||||
|
botState.lastKnownX = target.x;
|
||||||
|
botState.lastKnownZ = target.z;
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetX: number;
|
||||||
|
let targetZ: number;
|
||||||
|
if (target) {
|
||||||
|
targetX = target.x;
|
||||||
|
targetZ = target.z;
|
||||||
|
} else if (state.artifactCarrierId !== null) {
|
||||||
|
targetX = state.artifactX;
|
||||||
|
targetZ = state.artifactZ;
|
||||||
|
} else {
|
||||||
|
const angle = botState.phase + tick * 0.008;
|
||||||
|
targetX = DEAD_AIR_ARTIFACT_SPAWN.x + Math.sin(angle) * 22;
|
||||||
|
targetZ = DEAD_AIR_ARTIFACT_SPAWN.z + Math.cos(angle * 0.83) * 22;
|
||||||
|
if (collidesWithDeadAirMap(targetX, targetZ, 0.8)) {
|
||||||
|
targetX = DEAD_AIR_ARTIFACT_SPAWN.x;
|
||||||
|
targetZ = DEAD_AIR_ARTIFACT_SPAWN.z;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const desiredYaw = Math.atan2(targetX - warden.x, targetZ - warden.z);
|
||||||
|
warden.yaw = rotateToward(warden.yaw, desiredYaw, target ? 0.14 : 0.055);
|
||||||
|
const distance = Math.hypot(targetX - warden.x, targetZ - warden.z);
|
||||||
|
warden.inputForward = distance > (target ? 6 : 3) ? 0.76 : 0;
|
||||||
|
warden.inputStrafe = target ? Math.sin(tick * 0.045 + warden.id) * 0.25 : 0;
|
||||||
|
warden.sprinting = false;
|
||||||
|
warden.pitch = 0;
|
||||||
|
warden.firing = Boolean(target && distance < shotRange - 2 && angleDifference(warden.yaw, desiredYaw) < 0.13);
|
||||||
|
if (warden.magazine === 0) beginReload(warden);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canSeePlayer(
|
||||||
|
state: DeadAirAuthorityState,
|
||||||
|
viewer: DeadAirAuthorityPlayer,
|
||||||
|
target: DeadAirAuthorityPlayer,
|
||||||
|
): boolean {
|
||||||
|
if (!target.alive || !viewer.alive || target.id === viewer.id) return target.id === viewer.id;
|
||||||
|
const distance = Math.hypot(target.x - viewer.x, target.z - viewer.z);
|
||||||
|
if (distance > 38 || !hasDeadAirLineOfSight(viewer.x, viewer.z, target.x, target.z)) return false;
|
||||||
|
if (distance <= 2.2) return true;
|
||||||
|
if (target.flashlight && distance <= 34) return true;
|
||||||
|
if (target.carryingArtifact && distance <= 15) return true;
|
||||||
|
return canSeePoint(state, viewer, target.x, target.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canSeePoint(
|
||||||
|
state: DeadAirAuthorityState,
|
||||||
|
viewer: DeadAirAuthorityPlayer,
|
||||||
|
x: number,
|
||||||
|
z: number,
|
||||||
|
): boolean {
|
||||||
|
const distance = Math.hypot(x - viewer.x, z - viewer.z);
|
||||||
|
if (!hasDeadAirLineOfSight(viewer.x, viewer.z, x, z)) return false;
|
||||||
|
if (distance <= 2.2 || (state.powerOn && distance <= 9.5)) return true;
|
||||||
|
if (!viewer.flashlight || distance > 28) return false;
|
||||||
|
const bearing = Math.atan2(x - viewer.x, z - viewer.z);
|
||||||
|
return angleDifference(viewer.yaw, bearing) <= 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
function perceive(
|
||||||
|
state: DeadAirAuthorityState,
|
||||||
|
event: DeadAirAuthorityEvent,
|
||||||
|
playerId: number,
|
||||||
|
): DeadAirPerception | null {
|
||||||
|
const viewer = findPlayer(state, playerId);
|
||||||
|
if (!viewer) return null;
|
||||||
|
switch (event.type) {
|
||||||
|
case "noise":
|
||||||
|
if (event.sourceId === playerId) return null;
|
||||||
|
return soundPerception(viewer, event.id, event.cue, event.x, event.z, event.loudness);
|
||||||
|
case "shot": {
|
||||||
|
const source = findPlayer(state, event.sourceId);
|
||||||
|
if (
|
||||||
|
event.sourceId === playerId ||
|
||||||
|
(source && canSeePlayer(state, viewer, source)) ||
|
||||||
|
canSeePoint(state, viewer, event.x, event.z)
|
||||||
|
) {
|
||||||
|
return { id: event.id, type: "muzzle", x: event.x, z: event.z, yaw: event.yaw, pitch: event.pitch };
|
||||||
|
}
|
||||||
|
return soundPerception(viewer, event.id, "gunshot", event.x, event.z, 52);
|
||||||
|
}
|
||||||
|
case "damage":
|
||||||
|
if (event.targetId === playerId) {
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
type: "damage",
|
||||||
|
amount: event.amount,
|
||||||
|
health: event.health,
|
||||||
|
eliminated: event.eliminated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (event.sourceId === playerId) {
|
||||||
|
return { id: event.id, type: "hit", amount: event.amount, eliminated: event.eliminated };
|
||||||
|
}
|
||||||
|
return soundPerception(viewer, event.id, "impact", event.x, event.z, 13);
|
||||||
|
case "artifact": {
|
||||||
|
const actor = findPlayer(state, event.actorId);
|
||||||
|
const actorVisible = event.actorId === playerId || Boolean(actor && canSeePlayer(state, viewer, actor));
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
type: "artifact",
|
||||||
|
action: event.action,
|
||||||
|
actorId: actorVisible ? event.actorId : null,
|
||||||
|
round: event.round,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "power":
|
||||||
|
return { id: event.id, type: "power", on: event.on };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function soundPerception(
|
||||||
|
viewer: DeadAirAuthorityPlayer,
|
||||||
|
id: number,
|
||||||
|
cue: DeadAirSoundCue,
|
||||||
|
x: number,
|
||||||
|
z: number,
|
||||||
|
loudness: number,
|
||||||
|
): DeadAirPerception | null {
|
||||||
|
const distance = Math.hypot(x - viewer.x, z - viewer.z);
|
||||||
|
if (distance > loudness) return null;
|
||||||
|
const relative = normalizeAngle(Math.atan2(x - viewer.x, z - viewer.z) - viewer.yaw);
|
||||||
|
const direction = ((Math.round(relative / (Math.PI / 4)) % 8) + 8) % 8;
|
||||||
|
const band: DeadAirDistanceBand = distance < loudness * 0.28 ? "near" : distance < loudness * 0.64 ? "mid" : "far";
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: "sound",
|
||||||
|
cue,
|
||||||
|
direction,
|
||||||
|
distance: band,
|
||||||
|
intensity: clamp(Math.round((1 - distance / loudness) * 3) + 1, 1, 3),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetRound(state: DeadAirAuthorityState): void {
|
||||||
|
state.round += 1;
|
||||||
|
state.elapsedTicks = 0;
|
||||||
|
state.lastExtractorId = null;
|
||||||
|
state.artifactX = DEAD_AIR_ARTIFACT_SPAWN.x;
|
||||||
|
state.artifactZ = DEAD_AIR_ARTIFACT_SPAWN.z;
|
||||||
|
state.artifactCarrierId = null;
|
||||||
|
state.powerOn = true;
|
||||||
|
for (const player of state.players) {
|
||||||
|
const fresh = createPlayer(player.id, player.bot, state.round);
|
||||||
|
Object.assign(player, fresh, { extractions: player.extractions });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playerView(player: DeadAirAuthorityPlayer, own: boolean): DeadAirPlayerView {
|
||||||
|
return {
|
||||||
|
id: player.id,
|
||||||
|
bot: player.bot,
|
||||||
|
alive: player.alive,
|
||||||
|
x: player.x,
|
||||||
|
z: player.z,
|
||||||
|
velocityX: player.velocityX,
|
||||||
|
velocityZ: player.velocityZ,
|
||||||
|
yaw: player.yaw,
|
||||||
|
pitch: player.pitch,
|
||||||
|
health: own ? player.health : null,
|
||||||
|
flashlight: player.flashlight,
|
||||||
|
carryingArtifact: player.carryingArtifact,
|
||||||
|
firing: player.firing,
|
||||||
|
sprinting: player.sprinting,
|
||||||
|
cooldownTicks: player.cooldownTicks,
|
||||||
|
reloadTicks: player.reloadTicks,
|
||||||
|
respawnTicks: player.respawnTicks,
|
||||||
|
magazine: own ? player.magazine : null,
|
||||||
|
reserve: own ? player.reserve : null,
|
||||||
|
decoys: own ? player.decoys : null,
|
||||||
|
extractions: player.extractions,
|
||||||
|
inputForward: own ? player.inputForward : 0,
|
||||||
|
inputStrafe: own ? player.inputStrafe : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneAuthorityState(state: DeadAirAuthorityState): DeadAirAuthorityState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
players: state.players.map((player) => ({
|
||||||
|
...player,
|
||||||
|
botState: player.botState ? { ...player.botState } : null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneClientState(state: DeadAirClientState): DeadAirClientState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
players: state.players.map((player) => ({ ...player })),
|
||||||
|
artifact: { ...state.artifact },
|
||||||
|
events: state.events.map((entry) => ({
|
||||||
|
receivedTick: entry.receivedTick,
|
||||||
|
event: { ...entry.event },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPlayer(state: DeadAirAuthorityState, id: number): DeadAirAuthorityPlayer | undefined {
|
||||||
|
return state.players.find((player) => player.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function angleDifference(left: number, right: number): number {
|
||||||
|
return Math.abs(normalizeAngle(left - right));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateToward(current: number, target: number, maximum: number): number {
|
||||||
|
const difference = normalizeAngle(target - current);
|
||||||
|
return normalizeAngle(current + clamp(difference, -maximum, maximum));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAngle(value: number): number {
|
||||||
|
return Math.atan2(Math.sin(value), Math.cos(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, minimum: number, maximum: number): number {
|
||||||
|
return Math.max(minimum, Math.min(maximum, value));
|
||||||
|
}
|
||||||
127
packages/shared/src/dead-air-map.ts
Normal file
127
packages/shared/src/dead-air-map.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
export const DEAD_AIR_MAP_SIZE = 72;
|
||||||
|
export const DEAD_AIR_PLAYER_RADIUS = 0.56;
|
||||||
|
export const DEAD_AIR_EXTRACTION = Object.freeze({ x: -29, z: 29, radius: 4.2 });
|
||||||
|
export const DEAD_AIR_ARTIFACT_SPAWN = Object.freeze({ x: 2, z: -1 });
|
||||||
|
export const DEAD_AIR_POWER_SWITCH = Object.freeze({ x: 29, z: -29 });
|
||||||
|
|
||||||
|
export interface DeadAirWall {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
width: number;
|
||||||
|
depth: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public static geometry. Secret state is never encoded into the level. */
|
||||||
|
export const DEAD_AIR_WALLS: readonly DeadAirWall[] = Object.freeze([
|
||||||
|
{ id: "north", x: 0, z: -36, width: 73, depth: 1.2, height: 4.8 },
|
||||||
|
{ id: "south", x: 0, z: 36, width: 73, depth: 1.2, height: 4.8 },
|
||||||
|
{ id: "west", x: -36, z: 0, width: 1.2, depth: 73, height: 4.8 },
|
||||||
|
{ id: "east", x: 36, z: 0, width: 1.2, depth: 73, height: 4.8 },
|
||||||
|
|
||||||
|
{ id: "loading-a", x: -27, z: -20, width: 17, depth: 1, height: 4.2 },
|
||||||
|
{ id: "loading-b", x: -5, z: -20, width: 13, depth: 1, height: 4.2 },
|
||||||
|
{ id: "loading-c", x: 21.5, z: -20, width: 25, depth: 1, height: 4.2 },
|
||||||
|
|
||||||
|
{ id: "west-a", x: -16, z: -28, width: 1, depth: 15, height: 4.2 },
|
||||||
|
{ id: "west-b", x: -16, z: -7, width: 1, depth: 21, height: 4.2 },
|
||||||
|
{ id: "west-c", x: -16, z: 27, width: 1, depth: 18, height: 4.2 },
|
||||||
|
|
||||||
|
{ id: "east-a", x: 16, z: -27, width: 1, depth: 17, height: 4.2 },
|
||||||
|
{ id: "east-b", x: 16, z: 0, width: 1, depth: 21, height: 4.2 },
|
||||||
|
{ id: "east-c", x: 16, z: 27, width: 1, depth: 17, height: 4.2 },
|
||||||
|
|
||||||
|
{ id: "office-a", x: -27, z: 15, width: 17, depth: 1, height: 4.2 },
|
||||||
|
{ id: "office-b", x: -6, z: 15, width: 11, depth: 1, height: 4.2 },
|
||||||
|
{ id: "office-c", x: 23, z: 15, width: 25, depth: 1, height: 4.2 },
|
||||||
|
|
||||||
|
{ id: "archive-a", x: -27, z: -2, width: 17, depth: 1, height: 3.8 },
|
||||||
|
{ id: "archive-b", x: 27, z: -2, width: 17, depth: 1, height: 3.8 },
|
||||||
|
{ id: "vault-north", x: 1, z: -9, width: 15, depth: 1, height: 4.4 },
|
||||||
|
{ id: "vault-south-a", x: -4, z: 7, width: 5, depth: 1, height: 4.4 },
|
||||||
|
{ id: "vault-south-b", x: 7, z: 7, width: 5, depth: 1, height: 4.4 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const DEAD_AIR_SPAWNS = Object.freeze([
|
||||||
|
{ x: -30, z: 29, yaw: Math.PI * 0.75 },
|
||||||
|
{ x: -27, z: 31, yaw: Math.PI },
|
||||||
|
{ x: -31, z: 25, yaw: Math.PI * 0.5 },
|
||||||
|
{ x: -25, z: 27, yaw: Math.PI * 0.85 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const DEAD_AIR_WARDEN_SPAWNS = Object.freeze([
|
||||||
|
{ x: 28, z: -28, yaw: 0 },
|
||||||
|
{ x: -27, z: -28, yaw: 0 },
|
||||||
|
{ x: 28, z: 27, yaw: Math.PI },
|
||||||
|
{ x: 3, z: -14, yaw: 0 },
|
||||||
|
{ x: 4, z: 11, yaw: Math.PI },
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function collidesWithDeadAirMap(
|
||||||
|
x: number,
|
||||||
|
z: number,
|
||||||
|
radius = DEAD_AIR_PLAYER_RADIUS,
|
||||||
|
): boolean {
|
||||||
|
for (const wall of DEAD_AIR_WALLS) {
|
||||||
|
if (
|
||||||
|
Math.abs(x - wall.x) < wall.width / 2 + radius &&
|
||||||
|
Math.abs(z - wall.z) < wall.depth / 2 + radius
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasDeadAirLineOfSight(
|
||||||
|
fromX: number,
|
||||||
|
fromZ: number,
|
||||||
|
toX: number,
|
||||||
|
toZ: number,
|
||||||
|
): boolean {
|
||||||
|
return !DEAD_AIR_WALLS.some((wall) =>
|
||||||
|
segmentIntersectsRectangle(fromX, fromZ, toX, toZ, wall, 0.06),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentIntersectsRectangle(
|
||||||
|
fromX: number,
|
||||||
|
fromZ: number,
|
||||||
|
toX: number,
|
||||||
|
toZ: number,
|
||||||
|
wall: DeadAirWall,
|
||||||
|
padding: number,
|
||||||
|
): boolean {
|
||||||
|
const minimumX = wall.x - wall.width / 2 - padding;
|
||||||
|
const maximumX = wall.x + wall.width / 2 + padding;
|
||||||
|
const minimumZ = wall.z - wall.depth / 2 - padding;
|
||||||
|
const maximumZ = wall.z + wall.depth / 2 + padding;
|
||||||
|
const deltaX = toX - fromX;
|
||||||
|
const deltaZ = toZ - fromZ;
|
||||||
|
let near = 0;
|
||||||
|
let far = 1;
|
||||||
|
|
||||||
|
const clip = (denominator: number, numerator: number): boolean => {
|
||||||
|
if (denominator === 0) return numerator >= 0;
|
||||||
|
const ratio = numerator / denominator;
|
||||||
|
if (denominator < 0) {
|
||||||
|
if (ratio > far) return false;
|
||||||
|
if (ratio > near) near = ratio;
|
||||||
|
} else {
|
||||||
|
if (ratio < near) return false;
|
||||||
|
if (ratio < far) far = ratio;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
clip(-deltaX, fromX - minimumX) &&
|
||||||
|
clip(deltaX, maximumX - fromX) &&
|
||||||
|
clip(-deltaZ, fromZ - minimumZ) &&
|
||||||
|
clip(deltaZ, maximumZ - fromZ) &&
|
||||||
|
near <= far &&
|
||||||
|
far > 0.001 &&
|
||||||
|
near < 0.999
|
||||||
|
);
|
||||||
|
}
|
||||||
190
packages/shared/src/dead-air-types.ts
Normal file
190
packages/shared/src/dead-air-types.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
export type DeadAirSoundCue =
|
||||||
|
| "footstep"
|
||||||
|
| "gunshot"
|
||||||
|
| "impact"
|
||||||
|
| "decoy"
|
||||||
|
| "curse"
|
||||||
|
| "power";
|
||||||
|
|
||||||
|
export type DeadAirDistanceBand = "near" | "mid" | "far";
|
||||||
|
|
||||||
|
export interface DeadAirInput {
|
||||||
|
forward: number;
|
||||||
|
strafe: number;
|
||||||
|
yaw: number;
|
||||||
|
pitch: number;
|
||||||
|
fire: boolean;
|
||||||
|
sprint: boolean;
|
||||||
|
reload: boolean;
|
||||||
|
interact: boolean;
|
||||||
|
toggleFlashlight: boolean;
|
||||||
|
throwDecoy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeadAirBotState {
|
||||||
|
phase: number;
|
||||||
|
targetId: number | null;
|
||||||
|
lastKnownX: number;
|
||||||
|
lastKnownZ: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeadAirAuthorityPlayer {
|
||||||
|
id: number;
|
||||||
|
bot: boolean;
|
||||||
|
alive: boolean;
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
velocityX: number;
|
||||||
|
velocityZ: number;
|
||||||
|
yaw: number;
|
||||||
|
pitch: number;
|
||||||
|
health: number;
|
||||||
|
flashlight: boolean;
|
||||||
|
carryingArtifact: boolean;
|
||||||
|
firing: boolean;
|
||||||
|
sprinting: boolean;
|
||||||
|
inputForward: number;
|
||||||
|
inputStrafe: number;
|
||||||
|
cooldownTicks: number;
|
||||||
|
reloadTicks: number;
|
||||||
|
respawnTicks: number;
|
||||||
|
magazine: number;
|
||||||
|
reserve: number;
|
||||||
|
decoys: number;
|
||||||
|
extractions: number;
|
||||||
|
footstepTicks: number;
|
||||||
|
botState: DeadAirBotState | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeadAirAuthorityState {
|
||||||
|
players: DeadAirAuthorityPlayer[];
|
||||||
|
artifactX: number;
|
||||||
|
artifactZ: number;
|
||||||
|
artifactCarrierId: number | null;
|
||||||
|
powerOn: boolean;
|
||||||
|
elapsedTicks: number;
|
||||||
|
round: number;
|
||||||
|
resetTicks: number;
|
||||||
|
lastExtractorId: number | null;
|
||||||
|
nextEventId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeadAirPlayerView {
|
||||||
|
id: number;
|
||||||
|
bot: boolean;
|
||||||
|
alive: boolean;
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
velocityX: number;
|
||||||
|
velocityZ: number;
|
||||||
|
yaw: number;
|
||||||
|
pitch: number;
|
||||||
|
health: number | null;
|
||||||
|
flashlight: boolean;
|
||||||
|
carryingArtifact: boolean;
|
||||||
|
firing: boolean;
|
||||||
|
sprinting: boolean;
|
||||||
|
cooldownTicks: number;
|
||||||
|
reloadTicks: number;
|
||||||
|
respawnTicks: number;
|
||||||
|
magazine: number | null;
|
||||||
|
reserve: number | null;
|
||||||
|
decoys: number | null;
|
||||||
|
extractions: number;
|
||||||
|
inputForward: number;
|
||||||
|
inputStrafe: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeadAirArtifactView {
|
||||||
|
visible: boolean;
|
||||||
|
x: number | null;
|
||||||
|
z: number | null;
|
||||||
|
carrierId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeadAirClientState {
|
||||||
|
players: DeadAirPlayerView[];
|
||||||
|
artifact: DeadAirArtifactView;
|
||||||
|
powerOn: boolean;
|
||||||
|
elapsedTicks: number;
|
||||||
|
round: number;
|
||||||
|
resetTicks: number;
|
||||||
|
lastExtractorId: number | null;
|
||||||
|
events: DeadAirPresentationEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeadAirAuthorityEvent =
|
||||||
|
| {
|
||||||
|
id: number;
|
||||||
|
type: "noise";
|
||||||
|
sourceId: number | null;
|
||||||
|
cue: DeadAirSoundCue;
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
loudness: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: number;
|
||||||
|
type: "shot";
|
||||||
|
sourceId: number;
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
yaw: number;
|
||||||
|
pitch: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: number;
|
||||||
|
type: "damage";
|
||||||
|
sourceId: number;
|
||||||
|
targetId: number;
|
||||||
|
amount: number;
|
||||||
|
health: number;
|
||||||
|
eliminated: boolean;
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: number;
|
||||||
|
type: "artifact";
|
||||||
|
action: "grabbed" | "dropped" | "extracted";
|
||||||
|
actorId: number;
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
round: number;
|
||||||
|
}
|
||||||
|
| { id: number; type: "power"; on: boolean; actorId: number; x: number; z: number };
|
||||||
|
|
||||||
|
/** Hidden sounds deliberately contain no entity identifier or world position. */
|
||||||
|
export type DeadAirPerception =
|
||||||
|
| {
|
||||||
|
id: number;
|
||||||
|
type: "sound";
|
||||||
|
cue: DeadAirSoundCue;
|
||||||
|
direction: number;
|
||||||
|
distance: DeadAirDistanceBand;
|
||||||
|
intensity: number;
|
||||||
|
}
|
||||||
|
| { id: number; type: "muzzle"; x: number; z: number; yaw: number; pitch: number }
|
||||||
|
| { id: number; type: "damage"; amount: number; health: number; eliminated: boolean }
|
||||||
|
| { id: number; type: "hit"; amount: number; eliminated: boolean }
|
||||||
|
| {
|
||||||
|
id: number;
|
||||||
|
type: "artifact";
|
||||||
|
action: "grabbed" | "dropped" | "extracted";
|
||||||
|
actorId: number | null;
|
||||||
|
round: number;
|
||||||
|
}
|
||||||
|
| { id: number; type: "power"; on: boolean };
|
||||||
|
|
||||||
|
export interface DeadAirPresentationEvent {
|
||||||
|
receivedTick: number;
|
||||||
|
event: DeadAirPerception;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeadAirGameContract {
|
||||||
|
authority: DeadAirAuthorityState;
|
||||||
|
client: DeadAirClientState;
|
||||||
|
input: DeadAirInput;
|
||||||
|
authorityEvent: DeadAirAuthorityEvent;
|
||||||
|
perceptionEvent: DeadAirPerception;
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@ export interface ApiMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export * from "./arena.js";
|
export * from "./arena.js";
|
||||||
|
export * from "./dead-air-types.js";
|
||||||
|
export * from "./dead-air-map.js";
|
||||||
|
export * from "./dead-air-game.js";
|
||||||
export * from "./flux-types.js";
|
export * from "./flux-types.js";
|
||||||
export * from "./flux-game.js";
|
export * from "./flux-game.js";
|
||||||
export * from "./movers-types.js";
|
export * from "./movers-types.js";
|
||||||
|
|||||||
128
packages/shared/test/dead-air.test.mjs
Normal file
128
packages/shared/test/dead-air.test.mjs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
DEAD_AIR_ARTIFACT_SPAWN,
|
||||||
|
DEAD_AIR_EXTRACTION,
|
||||||
|
DEAD_AIR_SNAPSHOT_RATE,
|
||||||
|
DEAD_AIR_TICK_RATE,
|
||||||
|
DEAD_AIR_WARDEN_ID_BASE,
|
||||||
|
deadAirGame,
|
||||||
|
} from "../dist/index.js";
|
||||||
|
|
||||||
|
function input(overrides = {}) {
|
||||||
|
return {
|
||||||
|
forward: 0,
|
||||||
|
strafe: 0,
|
||||||
|
yaw: 0,
|
||||||
|
pitch: 0,
|
||||||
|
fire: false,
|
||||||
|
sprint: false,
|
||||||
|
reload: false,
|
||||||
|
interact: false,
|
||||||
|
toggleFlashlight: false,
|
||||||
|
throwDecoy: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("DEAD AIR runs through the generic multiplayer contract", () => {
|
||||||
|
assert.equal(deadAirGame.tickRateHz, DEAD_AIR_TICK_RATE);
|
||||||
|
assert.equal(deadAirGame.snapshotRateHz, DEAD_AIR_SNAPSHOT_RATE);
|
||||||
|
const server = deadAirGame.createServer();
|
||||||
|
server.addPlayer(1);
|
||||||
|
const player = server.currentState.players.find((candidate) => candidate.id === 1);
|
||||||
|
player.x = DEAD_AIR_ARTIFACT_SPAWN.x;
|
||||||
|
player.z = DEAD_AIR_ARTIFACT_SPAWN.z;
|
||||||
|
|
||||||
|
assert.deepEqual(server.submitInput(1, {
|
||||||
|
sequence: 1,
|
||||||
|
targetTick: 1,
|
||||||
|
observedTick: 0,
|
||||||
|
input: input({ interact: true }),
|
||||||
|
}), { accepted: true });
|
||||||
|
const grabbed = server.step();
|
||||||
|
assert.equal(player.carryingArtifact, true);
|
||||||
|
assert.ok(grabbed.events.some((event) => event.type === "artifact" && event.action === "grabbed"));
|
||||||
|
|
||||||
|
player.x = DEAD_AIR_EXTRACTION.x;
|
||||||
|
player.z = DEAD_AIR_EXTRACTION.z;
|
||||||
|
const extracted = server.step();
|
||||||
|
assert.equal(player.extractions, 1);
|
||||||
|
assert.ok(extracted.events.some((event) => event.type === "artifact" && event.action === "extracted"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an occluded warden never appears in snapshots and becomes anonymous sound", () => {
|
||||||
|
const server = deadAirGame.createServer();
|
||||||
|
server.addPlayer(1);
|
||||||
|
const viewer = server.currentState.players.find((player) => player.id === 1);
|
||||||
|
const hidden = server.currentState.players.find((player) => player.id === DEAD_AIR_WARDEN_ID_BASE);
|
||||||
|
for (const player of server.currentState.players) {
|
||||||
|
if (player.bot && player.id !== hidden.id) player.alive = false;
|
||||||
|
}
|
||||||
|
viewer.x = -25;
|
||||||
|
viewer.z = 0;
|
||||||
|
viewer.yaw = Math.PI / 2;
|
||||||
|
viewer.flashlight = true;
|
||||||
|
hidden.x = -10;
|
||||||
|
hidden.z = 0;
|
||||||
|
hidden.flashlight = false;
|
||||||
|
|
||||||
|
const snapshot = server.createSnapshot(1, 0).state;
|
||||||
|
assert.equal(snapshot.players.some((player) => player.id === hidden.id), false);
|
||||||
|
assert.equal(JSON.stringify(snapshot).includes(hidden.id.toString()), false);
|
||||||
|
|
||||||
|
const perception = server.createPerceptions(1, [{
|
||||||
|
id: 777,
|
||||||
|
type: "shot",
|
||||||
|
sourceId: hidden.id,
|
||||||
|
x: hidden.x,
|
||||||
|
z: hidden.z,
|
||||||
|
yaw: hidden.yaw,
|
||||||
|
pitch: 0,
|
||||||
|
}])[0];
|
||||||
|
assert.equal(perception.type, "sound");
|
||||||
|
assert.equal(perception.cue, "gunshot");
|
||||||
|
assert.equal("sourceId" in perception, false);
|
||||||
|
assert.equal("x" in perception, false);
|
||||||
|
assert.equal("z" in perception, false);
|
||||||
|
assert.ok(Number.isInteger(perception.direction));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("private objective coordinates are redacted outside sight", () => {
|
||||||
|
const server = deadAirGame.createServer();
|
||||||
|
server.addPlayer(1);
|
||||||
|
const viewer = server.currentState.players.find((player) => player.id === 1);
|
||||||
|
viewer.x = -28;
|
||||||
|
viewer.z = 28;
|
||||||
|
viewer.yaw = Math.PI;
|
||||||
|
viewer.flashlight = false;
|
||||||
|
const snapshot = server.createSnapshot(1, 0).state;
|
||||||
|
assert.deepEqual(snapshot.artifact, {
|
||||||
|
visible: false,
|
||||||
|
x: null,
|
||||||
|
z: null,
|
||||||
|
carrierId: null,
|
||||||
|
});
|
||||||
|
assert.equal("artifactX" in snapshot, false);
|
||||||
|
assert.equal("nextEventId" in snapshot, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("visible wardens expose pose but redact combat inventory", () => {
|
||||||
|
const server = deadAirGame.createServer();
|
||||||
|
server.addPlayer(1);
|
||||||
|
const viewer = server.currentState.players.find((player) => player.id === 1);
|
||||||
|
const visible = server.currentState.players.find((player) => player.id === DEAD_AIR_WARDEN_ID_BASE);
|
||||||
|
viewer.x = -30;
|
||||||
|
viewer.z = 30;
|
||||||
|
viewer.yaw = Math.PI / 2;
|
||||||
|
visible.x = -25;
|
||||||
|
visible.z = 30;
|
||||||
|
visible.health = 31;
|
||||||
|
visible.magazine = 2;
|
||||||
|
const replicated = server.createSnapshot(1, 0).state.players.find((player) => player.id === visible.id);
|
||||||
|
assert.ok(replicated);
|
||||||
|
assert.equal(replicated.x, visible.x);
|
||||||
|
assert.equal(replicated.health, null);
|
||||||
|
assert.equal(replicated.magazine, null);
|
||||||
|
assert.equal(replicated.reserve, null);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user