diff --git a/README.md b/README.md index 9d3c826..ccccb61 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Syncer -Four playable browser games built on one generic TypeScript multiplayer higher-order configuration. The engine runs authoritative servers, predicted clients, clocks, reconciliation, validation, privacy-aware replication, pluggable physics, and bandwidth-budgeted interest management around game-supplied rules. +Five playable browser games built on one generic TypeScript multiplayer higher-order configuration. The engine runs authoritative servers, predicted clients, clocks, reconciliation, validation, privacy-aware replication, pluggable physics, and bandwidth-budgeted interest management around game-supplied rules. ## Structure @@ -10,7 +10,7 @@ apps/ web/ React + Vite predicted client packages/ engine/ Generic clocks, authority, prediction, validation, and protocol - shared/ Game definitions: Arena, Flux Relay, Royale, and Bad Movers + shared/ Game definitions: Arena, Flux Relay, Royale, Bad Movers, and DEAD AIR ``` ## Play @@ -20,14 +20,15 @@ npm install npm run dev ``` -Open `http://localhost:5173`. Use the selector to switch between four games: +Open `http://localhost:5173`. Use the selector to switch between five games: - **Sync Arena** — click **Deploy**, then use `WASD`, `Shift`, mouse look, left click, `R`, and weapon keys `1`–`3`. - **Flux Relay** — hold `Space` or the on-screen thruster to pull the shared core toward your team's gate while managing private energy. - **Syncer Royale** — click **Drop In**, then use `WASD`, `Shift`, mouse look, left click, and `R`. Survive 31 server bots, loot automatically, and stay inside the shrinking circle on a streamed 2 km island. Open it directly at `http://localhost:5173/#royale`. - **Bad Movers** — use `WASD`, `Shift`, hold `Space` to grab, `E` to throw, and `F` to close your truck. Real Box3D rigid bodies run in WebAssembly on both the authority and predicted client. Open it directly at `http://localhost:5173/#movers`. +- **DEAD AIR** — use `WASD`, mouse look, left click, `E`, `F`, `Q`, and `R` to steal a screaming cursed microwave from five authority wardens. Hidden enemies are omitted from snapshots; only anonymous 8-way, 3-distance-band sound cues cross the wire. Open it directly at `http://localhost:5173/#dead-air`. -The arena includes pickups, armor, three weapons, headshots, reloads, respawns, a scoreboard, filtered spatial sound, and killcams; its server bots are disabled for player-only office matches. Flux Relay proves the API is not shooter-specific. Royale proves it can drive a much larger generated world. Bad Movers proves a developer can attach a third-party deterministic physics backend while keeping plain serializable state, authority, prediction, reconciliation, and transport generic. +The arena includes pickups, armor, three weapons, headshots, reloads, respawns, a scoreboard, filtered spatial sound, and killcams; its server bots are disabled for player-only office matches. Flux Relay proves the API is not shooter-specific. Royale proves it can drive a much larger generated world. Bad Movers proves a developer can attach a third-party deterministic physics backend while keeping plain serializable state, authority, prediction, reconciliation, and transport generic. DEAD AIR proves secure multimodal perception: exact visible entities and deliberately lossy audio-only knowledge share one generic authority boundary without leaking hidden coordinates or identities. ## Define a game diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 36e3484..940656d 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,10 +1,12 @@ import { API_PATHS, + DEAD_AIR_SOCKET_PATH, FLUX_SOCKET_PATH, GAME_SOCKET_PATH, MOVERS_SOCKET_PATH, ROYALE_SOCKET_PATH, createShooterGame, + deadAirGame, fluxGame, moversGame, royaleGame, @@ -24,6 +26,7 @@ const clientProfiles = new ClientProfileStore(); const staticSite = loadStaticSite(process.env.STATIC_ROOT); const gameLoops = [ hostNetworkedGame(app, GAME_SOCKET_PATH, createShooterGame({ botCount: 0 })), + hostNetworkedGame(app, DEAD_AIR_SOCKET_PATH, deadAirGame), hostNetworkedGame(app, FLUX_SOCKET_PATH, fluxGame), hostNetworkedGame(app, MOVERS_SOCKET_PATH, moversGame), hostNetworkedGame(app, ROYALE_SOCKET_PATH, royaleGame), diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d07ca69..b167846 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -6,14 +6,17 @@ import { } from "@syncer/shared"; import { useEffect, useState } from "react"; import { Arena3D } from "./Arena3D.js"; +import { DeadAirGame } from "./DeadAirGame.js"; import { FluxGame } from "./FluxGame.js"; import { MoversGame } from "./MoversGame.js"; import { RoyaleGame } from "./RoyaleGame.js"; import { useGameClient } from "./useGameClient.js"; export function App() { - const [demo, setDemo] = useState<"arena" | "flux" | "royale" | "movers">(() => - window.location.hash === "#movers" + const [demo, setDemo] = useState<"arena" | "flux" | "royale" | "movers" | "dead-air">(() => + window.location.hash === "#dead-air" + ? "dead-air" + : window.location.hash === "#movers" ? "movers" : window.location.hash === "#royale" ? "royale" @@ -28,7 +31,7 @@ export function App() { return ( <> - {demo === "arena" ? : demo === "flux" ? : demo === "royale" ? : } + {demo === "arena" ? : demo === "flux" ? : demo === "royale" ? : demo === "movers" ? : } ); diff --git a/apps/web/src/DeadAir3D.tsx b/apps/web/src/DeadAir3D.tsx new file mode 100644 index 0000000..5959d1d --- /dev/null +++ b/apps/web/src/DeadAir3D.tsx @@ -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; + artifact: THREE.Group; + effects: TemporaryEffect[]; + resizeObserver: ResizeObserver; + animationFrame: number; +} + +export function DeadAir3D({ source, playerId, unlockAudio }: DeadAir3DProps) { + const hostRef = useRef(null); + const playerIdRef = useRef(playerId); + const sourceRef = useRef(source); + const runtimeRef = useRef(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 ( +
+ {!locked ? ( + + ) : null} +
+ ); +} + +function updateRuntime( + runtime: DeadAirRuntime, + source: DeadAirRenderSource, + playerId: number | null, + lastEventId: MutableRefObject, + 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 = [ + [-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 = [ + [-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 = [ + [-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, + 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, 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, + lastEventId: MutableRefObject, + 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(); +} diff --git a/apps/web/src/DeadAirGame.tsx b/apps/web/src/DeadAirGame.tsx new file mode 100644 index 0000000..d6d8895 --- /dev/null +++ b/apps/web/src/DeadAirGame.tsx @@ -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 ( +
+ +
+ ); +} + +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, 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): 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"; +} diff --git a/apps/web/src/dead-air-audio.ts b/apps/web/src/dead-air-audio.ts new file mode 100644 index 0000000..c3ad922 --- /dev/null +++ b/apps/web/src/dead-air-audio.ts @@ -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); +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 2743c50..d28ba0b 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -817,3 +817,173 @@ button { font: inherit; } .movers-actions { grid-column: 1 / -1; justify-content: center; } .movers-actions button { min-width: 82px; } } + +.dead-air-game { + position: relative; + width: 100%; + height: 100%; + min-width: 320px; + overflow: hidden; + color: #dce8e5; + background: #010304; + user-select: none; +} +.dead-air-viewport, +.dead-air-viewport canvas { position: absolute; inset: 0; width: 100%; height: 100%; } +.dead-air-viewport canvas { display: block; } +.dead-air-enter { + position: absolute; + z-index: 20; + top: 50%; + left: 50%; + display: flex; + width: min(580px, calc(100% - 36px)); + flex-direction: column; + align-items: center; + gap: 7px; + padding: 28px 36px 30px; + border: 1px solid rgb(137 236 219 / 24%); + border-block-width: 1px 6px; + color: #d9f2ed; + background: + repeating-linear-gradient(90deg, transparent 0 18px, rgb(94 231 202 / 2%) 18px 19px), + rgb(2 8 9 / 92%); + box-shadow: 0 0 100px rgb(18 75 67 / 26%); + cursor: pointer; + transform: translate(-50%, -50%); +} +.dead-air-enter::before { position: absolute; inset: 7px; border: 1px solid rgb(255 255 255 / 4%); content: ""; pointer-events: none; } +.dead-air-enter small { color: #76928c; font: .47rem/1.2 "IBM Plex Mono", monospace; letter-spacing: .21em; } +.dead-air-enter strong { color: #ebfffa; font-size: 1.8rem; letter-spacing: .18em; text-shadow: 0 0 18px rgb(102 239 211 / 38%); } +.dead-air-enter span { color: #98c6bc; font-size: .65rem; letter-spacing: .12em; } +.dead-air-enter em { margin-top: 5px; color: #56716c; font: normal .43rem/1.4 "IBM Plex Mono", monospace; letter-spacing: .08em; } +.dead-air-enter:hover { border-bottom-color: #6aefce; background-color: rgb(4 14 15 / 97%); } + +.dead-air-grade { position: absolute; inset: 0; z-index: 1; background: radial-gradient(circle at 50% 48%, transparent 34%, rgb(0 0 0 / 48%) 79%, rgb(0 0 0 / 78%)), linear-gradient(180deg, rgb(0 3 4 / 58%), transparent 19%, transparent 75%, rgb(0 2 3 / 77%)); pointer-events: none; } +.dead-air-noise { position: absolute; inset: 0; z-index: 2; opacity: .11; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 140 140' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.32'/%3E%3C/svg%3E"); pointer-events: none; } +.dead-air-game--blackout .dead-air-grade { background: radial-gradient(circle at 50% 49%, transparent 20%, rgb(0 0 0 / 67%) 69%, #000 100%), linear-gradient(180deg, rgb(0 0 0 / 72%), transparent 24%, transparent 68%, rgb(0 0 0 / 88%)); } +.dead-air-damage { position: absolute; inset: 0; z-index: 3; background: radial-gradient(circle, transparent 22%, rgb(121 0 0 / 45%)); animation: dead-air-damage .38s ease-out forwards; pointer-events: none; } +@keyframes dead-air-damage { to { opacity: 0; } } + +.dead-air-header { position: absolute; z-index: 6; top: 0; left: 0; display: grid; width: 100%; grid-template-columns: 1fr auto 1fr; align-items: start; padding: 20px 25px; pointer-events: none; } +.dead-air-brand { display: flex; align-items: center; gap: 10px; } +.dead-air-brand > i { display: grid; width: 43px; height: 43px; place-items: center; border: 1px solid rgb(105 231 207 / 44%); color: #88e8d4; background: rgb(3 13 14 / 76%); box-shadow: inset 0 0 19px rgb(40 218 183 / 12%); font: normal 700 .82rem "IBM Plex Mono", monospace; clip-path: polygon(0 0, 80% 0, 100% 20%, 100% 100%, 20% 100%, 0 80%); } +.dead-air-brand small { display: block; color: #5b7772; font: .42rem/1 "IBM Plex Mono", monospace; letter-spacing: .13em; } +.dead-air-brand strong { display: block; margin-top: 3px; color: #ddf4ef; font-size: 1.05rem; letter-spacing: .22em; } +.dead-air-objective { min-width: 355px; padding: 7px 28px 9px; border-top: 1px solid rgb(102 228 202 / 24%); background: linear-gradient(90deg, transparent, rgb(3 12 13 / 76%) 18%, rgb(3 12 13 / 76%) 82%, transparent); text-align: center; } +.dead-air-objective small, +.dead-air-objective span { display: block; color: #5d7772; font: .4rem/1.3 "IBM Plex Mono", monospace; letter-spacing: .13em; } +.dead-air-objective strong { display: block; margin: 2px 0; color: #c9e6df; font-size: .71rem; letter-spacing: .14em; } +.dead-air-game--blackout .dead-air-objective span { color: #ff604c; text-shadow: 0 0 8px #a91a0b; } +.dead-air-live { justify-self: end; display: grid; grid-template-columns: auto auto; align-items: center; gap: 0 7px; padding: 7px 9px; border-right: 1px solid #62dfc5; background: linear-gradient(90deg, transparent, rgb(2 13 14 / 76%)); text-align: right; } +.dead-air-live > i { grid-row: 1 / span 2; width: 6px; height: 6px; border-radius: 50%; background: #72e8cf; box-shadow: 0 0 10px #50dbbd; } +.dead-air-live span { color: #8debd7; font-size: .57rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; } +.dead-air-live small { color: #5f7671; font: .39rem/1 "IBM Plex Mono", monospace; } +.dead-air-live--connecting > i, +.dead-air-live--reconnecting > i { background: #ff704f; box-shadow: 0 0 10px #ff4f2d; } + +.dead-air-security { position: absolute; z-index: 5; top: 93px; left: 25px; width: 190px; padding: 8px 0; border-top: 1px solid rgb(101 220 199 / 31%); color: #78918c; background: linear-gradient(90deg, rgb(2 10 11 / 78%), transparent); pointer-events: none; } +.dead-air-security > small { display: block; padding: 0 7px 6px; color: #7edbc8; font: .42rem/1 "IBM Plex Mono", monospace; letter-spacing: .15em; } +.dead-air-security div { display: flex; justify-content: space-between; padding: 3px 7px; font: .38rem/1.3 "IBM Plex Mono", monospace; letter-spacing: .05em; } +.dead-air-security b { color: #a3bbb6; font-weight: 500; } + +.dead-air-listen { position: absolute; z-index: 5; top: 98px; right: 25px; width: 200px; padding: 8px; border-top: 1px solid rgb(255 92 66 / 35%); background: linear-gradient(90deg, transparent, rgb(10 7 7 / 79%)); pointer-events: none; } +.dead-air-listen > small { display: block; margin-bottom: 5px; color: #c46a5b; font: .42rem/1 "IBM Plex Mono", monospace; letter-spacing: .14em; text-align: right; } +.dead-air-listen > span { display: block; color: #51615e; font: .37rem/1.4 "IBM Plex Mono", monospace; text-align: right; } +.dead-air-listen > div { display: grid; grid-template-columns: 18px 1fr; padding: 3px 0; border-top: 1px solid rgb(255 255 255 / 4%); } +.dead-air-listen b { grid-row: 1 / span 2; align-self: center; color: #ff6c55; font: .8rem/1 "IBM Plex Mono", monospace; } +.dead-air-listen span { color: #9eaaa7; font-size: .49rem; letter-spacing: .1em; text-transform: uppercase; } +.dead-air-listen em { color: #596966; font: normal .33rem/1 "IBM Plex Mono", monospace; letter-spacing: .05em; } + +.dead-air-scope { position: absolute; z-index: 4; top: 50%; left: 50%; width: 240px; height: 240px; border: 1px solid rgb(121 219 201 / 4%); border-radius: 50%; transform: translate(-50%, -50%); pointer-events: none; } +.dead-air-crosshair { position: absolute; top: 50%; left: 50%; width: 40px; height: 40px; transform: translate(-50%, -50%); } +.dead-air-crosshair::after { position: absolute; top: 19px; left: 19px; width: 3px; height: 3px; border-radius: 50%; background: #c9e5df; box-shadow: 0 0 5px #9ee5d7; content: ""; } +.dead-air-crosshair i { position: absolute; background: rgb(190 221 215 / 77%); } +.dead-air-crosshair i:nth-child(1), .dead-air-crosshair i:nth-child(2) { top: 20px; width: 8px; height: 1px; } +.dead-air-crosshair i:nth-child(1) { left: 2px; } +.dead-air-crosshair i:nth-child(2) { right: 2px; } +.dead-air-crosshair i:nth-child(3), .dead-air-crosshair i:nth-child(4) { left: 20px; width: 1px; height: 8px; } +.dead-air-crosshair i:nth-child(3) { top: 2px; } +.dead-air-crosshair i:nth-child(4) { bottom: 2px; } +.dead-air-crosshair--hit { animation: dead-air-hit .17s ease-out; } +@keyframes dead-air-hit { 50% { transform: translate(-50%, -50%) scale(1.5); filter: sepia(1) saturate(4); } } +.dead-air-bearing { position: absolute; top: 50%; left: 50%; width: 1px; height: 1px; transform-origin: 0 0; } +.dead-air-bearing i { position: absolute; top: -98px; left: -10px; width: 20px; height: 12px; border-top: 2px solid #ff735c; clip-path: polygon(0 0, 50% 100%, 100% 0); filter: drop-shadow(0 0 5px #ff4e34); } +.dead-air-bearing--mid i { top: -84px; border-color: #d99969; } +.dead-air-bearing--far i { top: -70px; border-color: #8c9485; } + +.dead-air-hit, +.dead-air-event { position: absolute; z-index: 7; left: 50%; color: #daf7f0; font: .48rem/1 "IBM Plex Mono", monospace; letter-spacing: .15em; text-shadow: 0 0 9px #74e4ce; transform: translateX(-50%); pointer-events: none; } +.dead-air-hit { top: calc(50% + 33px); } +.dead-air-event { top: 29%; padding: 7px 18px; border-block: 1px solid rgb(255 104 78 / 38%); color: #ffc0af; background: linear-gradient(90deg, transparent, rgb(35 8 4 / 75%), transparent); text-shadow: 0 0 9px #ff5538; } +.dead-air-prompt { position: absolute; z-index: 7; bottom: 145px; left: 50%; display: flex; align-items: center; gap: 7px; padding: 5px 12px; color: #c8e4de; background: rgb(2 11 12 / 78%); font: .45rem/1 "IBM Plex Mono", monospace; letter-spacing: .1em; transform: translateX(-50%); pointer-events: none; } +.dead-air-prompt b { display: grid; width: 23px; height: 23px; place-items: center; border: 1px solid #68d8c1; color: #8cebd7; } + +.dead-air-dead, +.dead-air-extracted { position: absolute; z-index: 10; top: 50%; left: 50%; display: flex; width: min(620px, calc(100% - 40px)); flex-direction: column; align-items: center; gap: 5px; padding: 20px; border-block: 1px solid rgb(255 81 55 / 48%); background: linear-gradient(90deg, transparent, rgb(28 3 2 / 86%) 20%, rgb(28 3 2 / 86%) 80%, transparent); transform: translate(-50%, -50%); pointer-events: none; } +.dead-air-dead small, +.dead-air-extracted small, +.dead-air-extracted span { color: #a65e52; font: .44rem/1 "IBM Plex Mono", monospace; letter-spacing: .17em; } +.dead-air-dead strong, +.dead-air-extracted strong { color: #ffd3c8; font-size: 1.28rem; letter-spacing: .18em; } +.dead-air-extracted { border-color: rgb(94 234 201 / 47%); background: linear-gradient(90deg, transparent, rgb(2 24 20 / 88%) 20%, rgb(2 24 20 / 88%) 80%, transparent); } +.dead-air-extracted small, +.dead-air-extracted span { color: #6da597; } +.dead-air-extracted strong { color: #cbfff3; } + +.dead-air-hud { position: absolute; z-index: 6; right: 25px; bottom: 20px; left: 25px; display: grid; grid-template-columns: 200px 1fr auto 215px; align-items: end; gap: 18px; pointer-events: none; } +.dead-air-vitals, +.dead-air-cargo, +.dead-air-weapon { padding: 8px 11px; border-top: 1px solid rgb(102 224 200 / 35%); background: linear-gradient(180deg, rgb(3 13 14 / 78%), rgb(2 7 8 / 89%)); } +.dead-air-vitals small, +.dead-air-cargo small, +.dead-air-weapon small { display: block; color: #607873; font: .4rem/1 "IBM Plex Mono", monospace; letter-spacing: .13em; } +.dead-air-vitals strong { display: block; color: #d6eee9; font: 1.8rem/.9 "IBM Plex Mono", monospace; font-weight: 500; } +.dead-air-vitals > span { display: block; overflow: hidden; width: 150px; height: 3px; margin: 5px 0; background: rgb(255 255 255 / 8%); } +.dead-air-vitals > span i { display: block; height: 100%; background: #66d8c0; box-shadow: 0 0 8px #51caae; } +.dead-air-vitals em { color: #617e78; font: normal .37rem/1 "IBM Plex Mono", monospace; } +.dead-air-cargo { justify-self: center; min-width: 330px; border-color: rgb(255 88 57 / 28%); text-align: center; } +.dead-air-cargo strong { display: block; margin: 3px 0; color: #bdcbc8; font-size: .67rem; letter-spacing: .12em; } +.dead-air-cargo span { color: #5e726e; font: .36rem/1 "IBM Plex Mono", monospace; letter-spacing: .07em; } +.dead-air-cargo.is-carrying { border-color: #ff684c; background: linear-gradient(180deg, rgb(38 10 5 / 82%), rgb(13 5 3 / 91%)); box-shadow: 0 -8px 30px rgb(142 24 5 / 12%); } +.dead-air-cargo.is-carrying strong { color: #ffc7b9; text-shadow: 0 0 8px #9a2916; } +.dead-air-actions { display: flex; gap: 4px; pointer-events: auto; } +.dead-air-actions button { display: flex; min-width: 58px; flex-direction: column; align-items: center; padding: 7px 6px; border: 1px solid rgb(113 216 195 / 16%); color: #81938f; background: rgb(2 10 11 / 84%); cursor: pointer; } +.dead-air-actions button:hover { border-color: #70d9c3; color: #c9e6df; } +.dead-air-actions button:disabled { opacity: .28; cursor: default; } +.dead-air-actions b { color: #72d9c3; font: .5rem/1.2 "IBM Plex Mono", monospace; } +.dead-air-actions span { font-size: .38rem; letter-spacing: .07em; white-space: nowrap; } +.dead-air-weapon { text-align: right; } +.dead-air-weapon strong { display: block; color: #edf9f6; font: 2rem/.9 "IBM Plex Mono", monospace; font-weight: 500; } +.dead-air-weapon strong i { color: #5c6f6b; font-size: .68rem; font-style: normal; } +.dead-air-weapon span { color: #60736f; font: .36rem/1 "IBM Plex Mono", monospace; letter-spacing: .06em; } + +@media (max-width: 860px) { + .game-switcher { max-width: calc(100% - 20px); overflow-x: auto; } + .game-switcher button { flex: 0 0 auto; } + .dead-air-header { padding: 13px; grid-template-columns: 1fr auto; } + .dead-air-objective { position: absolute; top: 62px; left: 50%; min-width: 320px; transform: translateX(-50%); } + .dead-air-brand small, + .dead-air-live small { display: none; } + .dead-air-security { top: 108px; left: 13px; width: 160px; } + .dead-air-listen { top: 108px; right: 13px; width: 160px; } + .dead-air-hud { right: 12px; bottom: 10px; left: 12px; grid-template-columns: 1fr auto; gap: 6px; } + .dead-air-vitals { grid-column: 1; } + .dead-air-weapon { grid-column: 2; } + .dead-air-cargo { grid-column: 1 / -1; grid-row: 1; min-width: 0; width: 100%; } + .dead-air-actions { grid-column: 1 / -1; justify-content: center; } + .dead-air-actions button { min-width: 88px; } + .dead-air-prompt { bottom: 184px; } +} + +@media (max-width: 560px) { + .dead-air-security { display: none; } + .dead-air-listen { width: 145px; } + .dead-air-listen > div:nth-of-type(n+3) { display: none; } + .dead-air-enter { padding-inline: 20px; } + .dead-air-enter strong { font-size: 1.25rem; } + .dead-air-enter em { max-width: 300px; } + .dead-air-vitals > span { width: 110px; } + .dead-air-weapon { min-width: 135px; } + .dead-air-cargo span { display: none; } +} diff --git a/apps/web/src/useDeadAirClient.ts b/apps/web/src/useDeadAirClient.ts new file mode 100644 index 0000000..20390c3 --- /dev/null +++ b/apps/web/src/useDeadAirClient.ts @@ -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; + 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({ + 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>({ + 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(); + const stream = createInputStateStream(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) => { + 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)); +} diff --git a/packages/shared/src/dead-air-game.ts b/packages/shared/src/dead-air-game.ts new file mode 100644 index 0000000..1468029 --- /dev/null +++ b/packages/shared/src/dead-air-game.ts @@ -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(); +const stateCodec = createJsonCodec(); +const eventCodec = createJsonCodec(); + +const baseDeadAirGame = defineMultiplayerGame({ + 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, + 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)); +} diff --git a/packages/shared/src/dead-air-map.ts b/packages/shared/src/dead-air-map.ts new file mode 100644 index 0000000..fd63d17 --- /dev/null +++ b/packages/shared/src/dead-air-map.ts @@ -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 + ); +} diff --git a/packages/shared/src/dead-air-types.ts b/packages/shared/src/dead-air-types.ts new file mode 100644 index 0000000..30da7ef --- /dev/null +++ b/packages/shared/src/dead-air-types.ts @@ -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; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 66ad9bd..c344db5 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,6 +7,9 @@ export interface ApiMessage { } export * from "./arena.js"; +export * from "./dead-air-types.js"; +export * from "./dead-air-map.js"; +export * from "./dead-air-game.js"; export * from "./flux-types.js"; export * from "./flux-game.js"; export * from "./movers-types.js"; diff --git a/packages/shared/test/dead-air.test.mjs b/packages/shared/test/dead-air.test.mjs new file mode 100644 index 0000000..63c0d15 --- /dev/null +++ b/packages/shared/test/dead-air.test.mjs @@ -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); +});