smooth arena aiming and fire effects
All checks were successful
build / image (push) Successful in 40s

This commit is contained in:
Syncer Deploy
2026-08-28 14:45:05 -03:00
parent a9a8375480
commit 147baf6b13
3 changed files with 123 additions and 10 deletions

View File

@@ -69,6 +69,8 @@ function ShooterGame() {
<Arena3D
world={client.world}
playerId={viewedId}
cameraYaw={client.cameraYaw}
cameraPitch={client.cameraPitch}
presentationKey={client.presentationKey}
interactive={!client.replay}
/>

View File

@@ -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<HTMLDivElement>(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<SceneRuntime | null>(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<number>,
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) {
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.camera.rotation.set(local.pitch, -local.yaw, 0);
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<number>();
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,

View File

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