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