smooth arena aiming and fire effects
All checks were successful
build / image (push) Successful in 40s
All checks were successful
build / image (push) Successful in 40s
This commit is contained in:
@@ -69,6 +69,8 @@ function ShooterGame() {
|
||||
<Arena3D
|
||||
world={client.world}
|
||||
playerId={viewedId}
|
||||
cameraYaw={client.cameraYaw}
|
||||
cameraPitch={client.cameraPitch}
|
||||
presentationKey={client.presentationKey}
|
||||
interactive={!client.replay}
|
||||
/>
|
||||
|
||||
@@ -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) {
|
||||
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<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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user