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
|
<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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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) {
|
||||||
runtime.camera.position.set(local.x, PLAYER_EYE_HEIGHT, local.z);
|
const correctionDistance = Math.hypot(
|
||||||
runtime.camera.rotation.set(local.pitch, -local.yaw, 0);
|
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;
|
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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user