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 <Arena3D
world={client.world} world={client.world}
playerId={viewedId} playerId={viewedId}
cameraYaw={client.cameraYaw}
cameraPitch={client.cameraPitch}
presentationKey={client.presentationKey} presentationKey={client.presentationKey}
interactive={!client.replay} interactive={!client.replay}
/> />

View File

@@ -16,6 +16,8 @@ import { ArenaAudio } from "./audio.js";
interface Arena3DProps { interface Arena3DProps {
world: ShooterWorldState; world: ShooterWorldState;
playerId: number | null; playerId: number | null;
cameraYaw: number;
cameraPitch: number;
presentationKey: string; presentationKey: string;
interactive: boolean; interactive: boolean;
} }
@@ -25,6 +27,7 @@ interface TemporaryEffect {
expiresAt: number; expiresAt: number;
createdAt?: number; createdAt?: number;
floatDistance?: number; floatDistance?: number;
fadeOpacity?: number;
} }
interface SceneRuntime { interface SceneRuntime {
@@ -39,7 +42,11 @@ interface SceneRuntime {
weaponAccent: THREE.MeshStandardMaterial; weaponAccent: THREE.MeshStandardMaterial;
muzzle: THREE.PointLight; muzzle: THREE.PointLight;
muzzleCore: THREE.Mesh; 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; lastLocalImpact: { x: number; y: number; z: number; at: number } | null;
audio: ArenaAudio; audio: ArenaAudio;
resizeObserver: ResizeObserver; resizeObserver: ResizeObserver;
@@ -49,12 +56,16 @@ interface SceneRuntime {
export function Arena3D({ export function Arena3D({
world, world,
playerId, playerId,
cameraYaw,
cameraPitch,
presentationKey, presentationKey,
interactive, interactive,
}: Arena3DProps) { }: Arena3DProps) {
const hostRef = useRef<HTMLDivElement>(null); const hostRef = useRef<HTMLDivElement>(null);
const worldRef = useRef(world); const worldRef = useRef(world);
const playerIdRef = useRef(playerId); const playerIdRef = useRef(playerId);
const cameraYawRef = useRef(cameraYaw);
const cameraPitchRef = useRef(cameraPitch);
const lastEventIdRef = useRef(0); const lastEventIdRef = useRef(0);
const presentationKeyRef = useRef("live"); const presentationKeyRef = useRef("live");
const runtimeRef = useRef<SceneRuntime | null>(null); const runtimeRef = useRef<SceneRuntime | null>(null);
@@ -62,6 +73,8 @@ export function Arena3D({
worldRef.current = world; worldRef.current = world;
playerIdRef.current = playerId; playerIdRef.current = playerId;
cameraYawRef.current = cameraYaw;
cameraPitchRef.current = cameraPitch;
if (presentationKeyRef.current !== presentationKey) { if (presentationKeyRef.current !== presentationKey) {
lastEventIdRef.current = presentationKey === "live" lastEventIdRef.current = presentationKey === "live"
? world.events.reduce( ? world.events.reduce(
@@ -112,7 +125,11 @@ export function Arena3D({
weaponAccent: accent, weaponAccent: accent,
muzzle, muzzle,
muzzleCore, muzzleCore,
muzzleUntil: 0, muzzleEnergy: 0,
recoil: 0,
recoilVelocity: 0,
cameraInitialized: false,
lastFrameTime: 0,
lastLocalImpact: null, lastLocalImpact: null,
audio: new ArenaAudio(), audio: new ArenaAudio(),
resizeObserver: new ResizeObserver(() => resize(runtime, host)), resizeObserver: new ResizeObserver(() => resize(runtime, host)),
@@ -127,6 +144,8 @@ export function Arena3D({
runtime, runtime,
worldRef.current, worldRef.current,
playerIdRef.current, playerIdRef.current,
cameraYawRef.current,
cameraPitchRef.current,
lastEventIdRef, lastEventIdRef,
time, time,
); );
@@ -402,7 +421,12 @@ function buildWeapon(): {
group.add(rail); group.add(rail);
const muzzleCore = new THREE.Mesh( const muzzleCore = new THREE.Mesh(
new THREE.SphereGeometry(0.075, 10, 8), 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.position.set(0, 0.025, -0.83);
muzzleCore.visible = false; muzzleCore.visible = false;
@@ -417,26 +441,70 @@ function updateRuntime(
runtime: SceneRuntime, runtime: SceneRuntime,
world: ShooterWorldState, world: ShooterWorldState,
playerId: number | null, playerId: number | null,
cameraYaw: number,
cameraPitch: number,
lastEventId: MutableRefObject<number>, lastEventId: MutableRefObject<number>,
time: number, time: number,
): void { ): 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); const local = playerId === null ? undefined : world.players.get(playerId);
if (local) { 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.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; runtime.weapon.visible = local.alive;
const moving = Math.hypot(local.velocityX, local.velocityZ); const moving = Math.hypot(local.velocityX, local.velocityZ);
runtime.weapon.position.y = 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 = runtime.weapon.position.x =
0.36 + Math.cos(time * 0.0065) * Math.min(0.008, moving * 0.0012); 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); const accentColor = new THREE.Color(WEAPONS[local.weapon].accent);
runtime.weaponAccent.color.copy(accentColor); runtime.weaponAccent.color.copy(accentColor);
runtime.weaponAccent.emissive.copy(accentColor).multiplyScalar(0.52); runtime.weaponAccent.emissive.copy(accentColor).multiplyScalar(0.52);
} }
runtime.muzzle.intensity = time < runtime.muzzleUntil ? 14 : 0; const muzzleStrength = clamp01(runtime.muzzleEnergy);
runtime.muzzleCore.visible = time < runtime.muzzleUntil; 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>(); const seen = new Set<number>();
for (const player of world.players.values()) { for (const player of world.players.values()) {
@@ -483,6 +551,19 @@ function updateRuntime(
: null; : null;
if (material) material.opacity = 1 - progress; 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; if (time < effect.expiresAt) continue;
runtime.scene.remove(effect.object); runtime.scene.remove(effect.object);
effect.object.traverse((child) => { effect.object.traverse((child) => {
@@ -561,7 +642,12 @@ function handlePerception(
}), }),
); );
runtime.scene.add(tracer); 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") { if (event.impact !== "miss") {
const impact = new THREE.Mesh( const impact = new THREE.Mesh(
@@ -574,7 +660,17 @@ function handlePerception(
} }
if (event.sourceId === playerId) { 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.muzzle.color.copy(color);
runtime.lastLocalImpact = { runtime.lastLocalImpact = {
x: event.endX, x: event.endX,

View File

@@ -32,6 +32,8 @@ export interface GameClientView {
validation: ValidationStatus; validation: ValidationStatus;
playerId: number | null; playerId: number | null;
cameraPlayerId: number | null; cameraPlayerId: number | null;
cameraYaw: number;
cameraPitch: number;
presentationKey: string; presentationKey: string;
tick: number; tick: number;
inputLeadTicks: number; inputLeadTicks: number;
@@ -93,6 +95,8 @@ export function useGameClient(): GameClientView {
validation: "waiting", validation: "waiting",
playerId: null, playerId: null,
cameraPlayerId: null, cameraPlayerId: null,
cameraYaw: 0,
cameraPitch: 0,
presentationKey: "live", presentationKey: "live",
tick: 0, tick: 0,
inputLeadTicks: 1, inputLeadTicks: 1,
@@ -123,6 +127,8 @@ export function useGameClient(): GameClientView {
); );
let tick = engine.tick; let tick = engine.tick;
let cameraPlayerId = engine.localPlayerId; let cameraPlayerId = engine.localPlayerId;
let cameraYaw = input.yaw;
let cameraPitch = input.pitch;
let replayView: ReplayPlaybackView | null = null; let replayView: ReplayPlaybackView | null = null;
if (activeReplay) { if (activeReplay) {
@@ -146,6 +152,9 @@ export function useGameClient(): GameClientView {
})); }));
tick = frame.tick; tick = frame.tick;
cameraPlayerId = activeReplay.perspectiveId; cameraPlayerId = activeReplay.perspectiveId;
const replayPlayer = world.players.get(cameraPlayerId);
cameraYaw = replayPlayer?.yaw ?? cameraYaw;
cameraPitch = replayPlayer?.pitch ?? cameraPitch;
replayView = { replayView = {
ticketId: activeReplay.ticketId, ticketId: activeReplay.ticketId,
perspectiveId: activeReplay.perspectiveId, perspectiveId: activeReplay.perspectiveId,
@@ -164,6 +173,8 @@ export function useGameClient(): GameClientView {
validation, validation,
playerId: engine.localPlayerId, playerId: engine.localPlayerId,
cameraPlayerId, cameraPlayerId,
cameraYaw,
cameraPitch,
presentationKey: replayView ? `replay-${replayView.ticketId}` : "live", presentationKey: replayView ? `replay-${replayView.ticketId}` : "live",
tick, tick,
inputLeadTicks: engine.networkClock.recommendedInputLeadTicks(shooterGame.tickRateHz), inputLeadTicks: engine.networkClock.recommendedInputLeadTicks(shooterGame.tickRateHz),
@@ -322,6 +333,10 @@ export function useGameClient(): GameClientView {
}; };
const animate = (now: number) => { 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()); simulationClock.advance(now, () => engine.step());
publishView(); publishView();
animationFrame = window.requestAnimationFrame(animate); animationFrame = window.requestAnimationFrame(animate);