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