From 147baf6b13a5d3cd7f3a529159f752552eec2cd0 Mon Sep 17 00:00:00 2001 From: Syncer Deploy Date: Fri, 28 Aug 2026 14:45:05 -0300 Subject: [PATCH] smooth arena aiming and fire effects --- apps/web/src/App.tsx | 2 + apps/web/src/Arena3D.tsx | 116 +++++++++++++++++++++++++++++++--- apps/web/src/useGameClient.ts | 15 +++++ 3 files changed, 123 insertions(+), 10 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 813b98b..bf24cd7 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -69,6 +69,8 @@ function ShooterGame() { diff --git a/apps/web/src/Arena3D.tsx b/apps/web/src/Arena3D.tsx index e89378c..0075385 100644 --- a/apps/web/src/Arena3D.tsx +++ b/apps/web/src/Arena3D.tsx @@ -16,6 +16,8 @@ import { ArenaAudio } from "./audio.js"; interface Arena3DProps { world: ShooterWorldState; playerId: number | null; + cameraYaw: number; + cameraPitch: number; presentationKey: string; interactive: boolean; } @@ -25,6 +27,7 @@ interface TemporaryEffect { expiresAt: number; createdAt?: number; floatDistance?: number; + fadeOpacity?: number; } interface SceneRuntime { @@ -39,7 +42,11 @@ interface SceneRuntime { weaponAccent: THREE.MeshStandardMaterial; muzzle: THREE.PointLight; muzzleCore: THREE.Mesh; - muzzleUntil: number; + muzzleEnergy: number; + recoil: number; + recoilVelocity: number; + cameraInitialized: boolean; + lastFrameTime: number; lastLocalImpact: { x: number; y: number; z: number; at: number } | null; audio: ArenaAudio; resizeObserver: ResizeObserver; @@ -49,12 +56,16 @@ interface SceneRuntime { export function Arena3D({ world, playerId, + cameraYaw, + cameraPitch, presentationKey, interactive, }: Arena3DProps) { const hostRef = useRef(null); const worldRef = useRef(world); const playerIdRef = useRef(playerId); + const cameraYawRef = useRef(cameraYaw); + const cameraPitchRef = useRef(cameraPitch); const lastEventIdRef = useRef(0); const presentationKeyRef = useRef("live"); const runtimeRef = useRef(null); @@ -62,6 +73,8 @@ export function Arena3D({ worldRef.current = world; playerIdRef.current = playerId; + cameraYawRef.current = cameraYaw; + cameraPitchRef.current = cameraPitch; if (presentationKeyRef.current !== presentationKey) { lastEventIdRef.current = presentationKey === "live" ? world.events.reduce( @@ -112,7 +125,11 @@ export function Arena3D({ weaponAccent: accent, muzzle, muzzleCore, - muzzleUntil: 0, + muzzleEnergy: 0, + recoil: 0, + recoilVelocity: 0, + cameraInitialized: false, + lastFrameTime: 0, lastLocalImpact: null, audio: new ArenaAudio(), resizeObserver: new ResizeObserver(() => resize(runtime, host)), @@ -127,6 +144,8 @@ export function Arena3D({ runtime, worldRef.current, playerIdRef.current, + cameraYawRef.current, + cameraPitchRef.current, lastEventIdRef, time, ); @@ -402,7 +421,12 @@ function buildWeapon(): { group.add(rail); const muzzleCore = new THREE.Mesh( new THREE.SphereGeometry(0.075, 10, 8), - new THREE.MeshBasicMaterial({ color: 0xc8ffff }), + new THREE.MeshBasicMaterial({ + color: 0xc8ffff, + transparent: true, + opacity: 0, + depthWrite: false, + }), ); muzzleCore.position.set(0, 0.025, -0.83); muzzleCore.visible = false; @@ -417,26 +441,70 @@ function updateRuntime( runtime: SceneRuntime, world: ShooterWorldState, playerId: number | null, + cameraYaw: number, + cameraPitch: number, lastEventId: MutableRefObject, time: number, ): void { + const deltaSeconds = runtime.lastFrameTime === 0 + ? 1 / 60 + : Math.min(0.05, Math.max(0.001, (time - runtime.lastFrameTime) / 1_000)); + runtime.lastFrameTime = time; + runtime.recoilVelocity += + (-190 * runtime.recoil - 24 * runtime.recoilVelocity) * deltaSeconds; + runtime.recoil += runtime.recoilVelocity * deltaSeconds; + if (Math.abs(runtime.recoil) < 0.0001 && Math.abs(runtime.recoilVelocity) < 0.001) { + runtime.recoil = 0; + runtime.recoilVelocity = 0; + } + runtime.muzzleEnergy *= Math.exp(-32 * deltaSeconds); + const local = playerId === null ? undefined : world.players.get(playerId); if (local) { - runtime.camera.position.set(local.x, PLAYER_EYE_HEIGHT, local.z); - runtime.camera.rotation.set(local.pitch, -local.yaw, 0); + const correctionDistance = Math.hypot( + local.x - runtime.camera.position.x, + local.z - runtime.camera.position.z, + ); + if (!runtime.cameraInitialized || correctionDistance > 4) { + runtime.camera.position.set(local.x, PLAYER_EYE_HEIGHT, local.z); + runtime.cameraInitialized = true; + } else { + runtime.camera.position.x = THREE.MathUtils.damp( + runtime.camera.position.x, + local.x, + 32, + deltaSeconds, + ); + runtime.camera.position.z = THREE.MathUtils.damp( + runtime.camera.position.z, + local.z, + 32, + deltaSeconds, + ); + runtime.camera.position.y = PLAYER_EYE_HEIGHT; + } + runtime.camera.rotation.set(cameraPitch, -cameraYaw, 0); runtime.weapon.visible = local.alive; const moving = Math.hypot(local.velocityX, local.velocityZ); runtime.weapon.position.y = - -0.3 + Math.sin(time * 0.013) * Math.min(0.021, moving * 0.003); + -0.3 + + Math.sin(time * 0.013) * Math.min(0.021, moving * 0.003) - + runtime.recoil * 0.035; runtime.weapon.position.x = 0.36 + Math.cos(time * 0.0065) * Math.min(0.008, moving * 0.0012); + runtime.weapon.position.z = -0.56 + runtime.recoil * 0.24; + runtime.weapon.rotation.x = -runtime.recoil * 0.14; const accentColor = new THREE.Color(WEAPONS[local.weapon].accent); runtime.weaponAccent.color.copy(accentColor); runtime.weaponAccent.emissive.copy(accentColor).multiplyScalar(0.52); } - runtime.muzzle.intensity = time < runtime.muzzleUntil ? 14 : 0; - runtime.muzzleCore.visible = time < runtime.muzzleUntil; + const muzzleStrength = clamp01(runtime.muzzleEnergy); + runtime.muzzle.intensity = 16 * muzzleStrength; + runtime.muzzleCore.visible = muzzleStrength > 0.025; + runtime.muzzleCore.scale.setScalar(0.72 + muzzleStrength * 0.46); + const muzzleMaterial = runtime.muzzleCore.material as THREE.MeshBasicMaterial; + muzzleMaterial.opacity = muzzleStrength; const seen = new Set(); for (const player of world.players.values()) { @@ -483,6 +551,19 @@ function updateRuntime( : null; if (material) material.opacity = 1 - progress; } + if (effect.createdAt !== undefined && effect.fadeOpacity !== undefined) { + const progress = clamp01( + (time - effect.createdAt) / (effect.expiresAt - effect.createdAt), + ); + if (effect.object instanceof THREE.Line) { + const materials = Array.isArray(effect.object.material) + ? effect.object.material + : [effect.object.material]; + for (const material of materials) { + material.opacity = effect.fadeOpacity * (1 - progress); + } + } + } if (time < effect.expiresAt) continue; runtime.scene.remove(effect.object); effect.object.traverse((child) => { @@ -561,7 +642,12 @@ function handlePerception( }), ); runtime.scene.add(tracer); - runtime.effects.push({ object: tracer, expiresAt: time + (event.weapon === Weapon.RailRifle ? 150 : 75) }); + runtime.effects.push({ + object: tracer, + createdAt: time, + expiresAt: time + (event.weapon === Weapon.RailRifle ? 165 : 95), + fadeOpacity: event.weapon === Weapon.RailRifle ? 0.95 : 0.72, + }); if (event.impact !== "miss") { const impact = new THREE.Mesh( @@ -574,7 +660,17 @@ function handlePerception( } if (event.sourceId === playerId) { - runtime.muzzleUntil = time + 52; + const recoilImpulse = + event.weapon === Weapon.Scattergun + ? 2.8 + : event.weapon === Weapon.RailRifle + ? 3.5 + : 1.65; + runtime.recoilVelocity = Math.min( + 5.5, + runtime.recoilVelocity + recoilImpulse, + ); + runtime.muzzleEnergy = Math.min(1.25, runtime.muzzleEnergy + 1); runtime.muzzle.color.copy(color); runtime.lastLocalImpact = { x: event.endX, diff --git a/apps/web/src/useGameClient.ts b/apps/web/src/useGameClient.ts index f45a493..2148af4 100644 --- a/apps/web/src/useGameClient.ts +++ b/apps/web/src/useGameClient.ts @@ -32,6 +32,8 @@ export interface GameClientView { validation: ValidationStatus; playerId: number | null; cameraPlayerId: number | null; + cameraYaw: number; + cameraPitch: number; presentationKey: string; tick: number; inputLeadTicks: number; @@ -93,6 +95,8 @@ export function useGameClient(): GameClientView { validation: "waiting", playerId: null, cameraPlayerId: null, + cameraYaw: 0, + cameraPitch: 0, presentationKey: "live", tick: 0, inputLeadTicks: 1, @@ -123,6 +127,8 @@ export function useGameClient(): GameClientView { ); let tick = engine.tick; let cameraPlayerId = engine.localPlayerId; + let cameraYaw = input.yaw; + let cameraPitch = input.pitch; let replayView: ReplayPlaybackView | null = null; if (activeReplay) { @@ -146,6 +152,9 @@ export function useGameClient(): GameClientView { })); tick = frame.tick; cameraPlayerId = activeReplay.perspectiveId; + const replayPlayer = world.players.get(cameraPlayerId); + cameraYaw = replayPlayer?.yaw ?? cameraYaw; + cameraPitch = replayPlayer?.pitch ?? cameraPitch; replayView = { ticketId: activeReplay.ticketId, perspectiveId: activeReplay.perspectiveId, @@ -164,6 +173,8 @@ export function useGameClient(): GameClientView { validation, playerId: engine.localPlayerId, cameraPlayerId, + cameraYaw, + cameraPitch, presentationKey: replayView ? `replay-${replayView.ticketId}` : "live", tick, inputLeadTicks: engine.networkClock.recommendedInputLeadTicks(shooterGame.tickRateHz), @@ -322,6 +333,10 @@ export function useGameClient(): GameClientView { }; const animate = (now: number) => { + // Flush changed state at display cadence while unchanged input keeps the + // lower policy heartbeat rate. Mouse look therefore stays locally fluid + // without flooding idle connections. + sendInput(); simulationClock.advance(now, () => engine.step()); publishView(); animationFrame = window.requestAnimationFrame(animate);