This commit is contained in:
17
apps/web/index.html
Normal file
17
apps/web/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Sync Arena — an authoritative multiplayer 3D browser shooter"
|
||||
/>
|
||||
<meta name="theme-color" content="#071019" />
|
||||
<title>Sync Arena</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
27
apps/web/package.json
Normal file
27
apps/web/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@syncer/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@syncer/engine": "0.0.0",
|
||||
"@syncer/shared": "0.0.0",
|
||||
"@types/three": "^0.183.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"three": "^0.185.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.2"
|
||||
}
|
||||
}
|
||||
BIN
apps/web/public/assets/arena-panels.png
Normal file
BIN
apps/web/public/assets/arena-panels.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/web/public/assets/arena-wall.png
Normal file
BIN
apps/web/public/assets/arena-wall.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
241
apps/web/src/App.tsx
Normal file
241
apps/web/src/App.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import {
|
||||
WEAPONS,
|
||||
Weapon,
|
||||
playerDisplayName,
|
||||
type PlayerKind,
|
||||
} from "@syncer/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Arena3D } from "./Arena3D.js";
|
||||
import { FluxGame } from "./FluxGame.js";
|
||||
import { RoyaleGame } from "./RoyaleGame.js";
|
||||
import { useGameClient } from "./useGameClient.js";
|
||||
|
||||
export function App() {
|
||||
const [demo, setDemo] = useState<"arena" | "flux" | "royale">(() =>
|
||||
window.location.hash === "#royale"
|
||||
? "royale"
|
||||
: window.location.hash === "#flux"
|
||||
? "flux"
|
||||
: "arena",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.history.replaceState(null, "", `#${demo}`);
|
||||
}, [demo]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{demo === "arena" ? <ShooterGame /> : demo === "flux" ? <FluxGame /> : <RoyaleGame />}
|
||||
<nav className="game-switcher" aria-label="Example game selector">
|
||||
<button className={demo === "arena" ? "is-active" : ""} onClick={() => setDemo("arena")} type="button">
|
||||
ARENA
|
||||
</button>
|
||||
<button className={demo === "flux" ? "is-active" : ""} onClick={() => setDemo("flux")} type="button">
|
||||
FLUX RELAY
|
||||
</button>
|
||||
<button className={demo === "royale" ? "is-active" : ""} onClick={() => setDemo("royale")} type="button">
|
||||
SYNCER ROYALE
|
||||
</button>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ShooterGame() {
|
||||
const client = useGameClient();
|
||||
const viewedId = client.cameraPlayerId;
|
||||
const local = viewedId === null ? undefined : client.world.players.get(viewedId);
|
||||
const recentEntries = [...client.world.events].reverse();
|
||||
const damageEntry = recentEntries.find((entry) => entry.event.type === "damage");
|
||||
const damageEvent = damageEntry?.event.type === "damage" ? damageEntry.event : null;
|
||||
const hitEntry = recentEntries.find(
|
||||
(entry) => entry.event.type === "hit",
|
||||
);
|
||||
const soundEntry = recentEntries.find((entry) => entry.event.type === "sound");
|
||||
const eliminationEntries = recentEntries
|
||||
.filter((entry) => entry.event.type === "elimination")
|
||||
.slice(0, 4);
|
||||
const weapon = WEAPONS[local?.weapon ?? Weapon.PulseRifle];
|
||||
const ammo = local?.ammo[local.weapon] ?? { magazine: 0, reserve: 0 };
|
||||
const matchSeconds = Math.floor(client.world.match.elapsedTicks / 60);
|
||||
const recentDamage = Boolean(
|
||||
damageEntry && damageEvent && client.tick - damageEntry.receivedTick < 18,
|
||||
);
|
||||
const recentHit = Boolean(hitEntry && client.tick - hitEntry.receivedTick < 12);
|
||||
const recentSound = Boolean(soundEntry && client.tick - soundEntry.receivedTick < 32);
|
||||
|
||||
return (
|
||||
<main className="game">
|
||||
<Arena3D
|
||||
world={client.world}
|
||||
playerId={viewedId}
|
||||
presentationKey={client.presentationKey}
|
||||
interactive={!client.replay}
|
||||
/>
|
||||
<div className="vignette" aria-hidden="true" />
|
||||
{recentDamage && damageEvent && (
|
||||
<div className="damage-flash" key={damageEvent.id} aria-hidden="true" />
|
||||
)}
|
||||
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<span className="brand-mark">S//</span>
|
||||
<div>
|
||||
<strong>SYNC ARENA</strong>
|
||||
<small>AUTHORITATIVE 3D COMBAT</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="match-clock">
|
||||
<small>{client.replay ? "AUTHORIZED REPLAY" : "LIVE SIMULATION"}</small>
|
||||
<strong>{formatClock(matchSeconds)}</strong>
|
||||
</div>
|
||||
<div className={`connection connection--${client.connection}`}>
|
||||
<i />
|
||||
<span>{client.connection}</span>
|
||||
<b>{client.network.roundTripTime.toFixed(0)} MS</b>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="telemetry" aria-label="Network telemetry">
|
||||
<Telemetry label={client.replay ? "REPLAY" : "SERVER"} value={client.tick.toString().padStart(6, "0")} />
|
||||
<Telemetry label="JITTER" value={`${client.network.jitter.toFixed(1)} MS`} />
|
||||
<Telemetry label="LEAD" value={`${client.inputLeadTicks} T`} />
|
||||
<Telemetry label="STATE" value={client.validation.toUpperCase()} />
|
||||
<Telemetry label="VISIBLE" value={`${Math.max(0, client.world.players.size - 1)} HOSTILES`} />
|
||||
</section>
|
||||
|
||||
<section className="leaderboard" aria-label="Leaderboard">
|
||||
<small>ARENA LEADERS</small>
|
||||
{client.world.scoreboard.slice(0, 5).map((entry, index) => (
|
||||
<div
|
||||
className={entry.id === client.playerId ? "leaderboard__local" : undefined}
|
||||
key={entry.id}
|
||||
>
|
||||
<b>{index + 1}</b>
|
||||
<span>{playerDisplayName(entry.id, entry.kind)}</span>
|
||||
<strong>{entry.kills}</strong>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="kill-feed" aria-live="polite">
|
||||
{eliminationEntries.map((entry) => {
|
||||
if (entry.event.type !== "elimination") return null;
|
||||
return (
|
||||
<div key={entry.event.id}>
|
||||
<span>{labelFor(entry.event.killerId, client.world.scoreboard)}</span>
|
||||
<b>◆</b>
|
||||
<span>{labelFor(entry.event.victimId, client.world.scoreboard)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
{recentSound && soundEntry?.event.type === "sound" && (
|
||||
<div
|
||||
className="sound-indicator"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) rotate(${soundEntry.event.bearingRadians}rad)`,
|
||||
opacity: 0.35 + soundEntry.event.intensity * 0.65,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`crosshair${recentHit ? " crosshair--damage" : ""}`}>
|
||||
<i /><i /><i /><i />
|
||||
</div>
|
||||
|
||||
{recentHit && hitEntry?.event.type === "hit" && (
|
||||
<div className="hit-confirm" aria-hidden="true">
|
||||
{hitEntry.event.eliminated
|
||||
? "ELIMINATED"
|
||||
: hitEntry.event.critical
|
||||
? "CRITICAL"
|
||||
: "HIT"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{client.replay && (
|
||||
<section className="killcam-panel" aria-label="Killcam playback">
|
||||
<div className="killcam-panel__title">
|
||||
<small>SERVER-AUTHORIZED PERSPECTIVE</small>
|
||||
<strong>
|
||||
KILLCAM // {labelFor(client.replay.perspectiveId, client.world.scoreboard)}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="killcam-panel__timeline">
|
||||
<i style={{ width: `${client.replay.progress * 100}%` }} />
|
||||
</div>
|
||||
<div className="killcam-panel__meta">
|
||||
<span>TICK {client.replay.currentTick.toString().padStart(6, "0")}</span>
|
||||
<span>{client.replay.playbackRate.toFixed(2)}×</span>
|
||||
<span>PROJECTED VIEW · NO SERVER TRUTH</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!client.replay && local && !local.alive ? (
|
||||
<section className="respawn-panel">
|
||||
<small>NEURAL LINK INTERRUPTED</small>
|
||||
<strong>REDEPLOYING {Math.ceil(local.respawnTicks / 60)}</strong>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<footer className="hud">
|
||||
<div className="health-block">
|
||||
<small>INTEGRITY · ARMOR {local?.armor ?? 0}</small>
|
||||
<strong>{(local?.health ?? 0).toString().padStart(3, "0")}</strong>
|
||||
<div className="health-track">
|
||||
<i style={{ width: `${local?.health ?? 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mission-note">
|
||||
<span>FREE FOR ALL · FIRST TO {client.world.match.scoreLimit}</span>
|
||||
<small>SERVER BOTS · PRIVATE VISIBILITY · SPATIAL AUDIO</small>
|
||||
</div>
|
||||
|
||||
<div className="weapon-block">
|
||||
<div>
|
||||
<small>{weapon.name.toUpperCase()}</small>
|
||||
<span>{local?.reloadTicks ? "RELOADING" : `${local?.weapon === Weapon.PulseRifle ? "01" : local?.weapon === Weapon.Scattergun ? "02" : "03"} / ${weapon.shortName}`}</span>
|
||||
</div>
|
||||
<strong>{ammo.magazine}<i>/ {ammo.reserve}</i></strong>
|
||||
</div>
|
||||
|
||||
<div className="score-block">
|
||||
<span><small>K</small>{scoreFor(client.playerId, client.world.scoreboard)?.kills ?? 0}</span>
|
||||
<span><small>D</small>{scoreFor(client.playerId, client.world.scoreboard)?.deaths ?? 0}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Telemetry({ label, value }: { label: string; value: string }) {
|
||||
return <div><small>{label}</small><strong>{value}</strong></div>;
|
||||
}
|
||||
|
||||
function scoreFor(
|
||||
id: number | null,
|
||||
scoreboard: ReadonlyArray<{ id: number; kind: PlayerKind; kills: number; deaths: number }>,
|
||||
) {
|
||||
return id === null ? undefined : scoreboard.find((entry) => entry.id === id);
|
||||
}
|
||||
|
||||
function labelFor(
|
||||
id: number,
|
||||
scoreboard: ReadonlyArray<{ id: number; kind: PlayerKind }>,
|
||||
): string {
|
||||
const kind = scoreboard.find((entry) => entry.id === id)?.kind ?? "bot";
|
||||
return playerDisplayName(id, kind);
|
||||
}
|
||||
|
||||
function formatClock(totalSeconds: number): string {
|
||||
const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, "0");
|
||||
const seconds = (totalSeconds % 60).toString().padStart(2, "0");
|
||||
return `${minutes}:${seconds}`;
|
||||
}
|
||||
756
apps/web/src/Arena3D.tsx
Normal file
756
apps/web/src/Arena3D.tsx
Normal file
@@ -0,0 +1,756 @@
|
||||
import { useEffect, useRef, useState, type MutableRefObject } from "react";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
ARENA_BLOCKS,
|
||||
ARENA_HALF_SIZE,
|
||||
PLAYER_EYE_HEIGHT,
|
||||
WEAPONS,
|
||||
Weapon,
|
||||
type PickupState,
|
||||
type PlayerState,
|
||||
type ShooterPerception,
|
||||
type ShooterWorldState,
|
||||
} from "@syncer/shared";
|
||||
import { ArenaAudio } from "./audio.js";
|
||||
|
||||
interface Arena3DProps {
|
||||
world: ShooterWorldState;
|
||||
playerId: number | null;
|
||||
presentationKey: string;
|
||||
interactive: boolean;
|
||||
}
|
||||
|
||||
interface TemporaryEffect {
|
||||
object: THREE.Object3D;
|
||||
expiresAt: number;
|
||||
createdAt?: number;
|
||||
floatDistance?: number;
|
||||
}
|
||||
|
||||
interface SceneRuntime {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
scene: THREE.Scene;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
players: Map<number, THREE.Group>;
|
||||
pickups: Map<number, THREE.Group>;
|
||||
effects: TemporaryEffect[];
|
||||
animated: THREE.Object3D[];
|
||||
weapon: THREE.Group;
|
||||
weaponAccent: THREE.MeshStandardMaterial;
|
||||
muzzle: THREE.PointLight;
|
||||
muzzleCore: THREE.Mesh;
|
||||
muzzleUntil: number;
|
||||
lastLocalImpact: { x: number; y: number; z: number; at: number } | null;
|
||||
audio: ArenaAudio;
|
||||
resizeObserver: ResizeObserver;
|
||||
animationFrame: number;
|
||||
}
|
||||
|
||||
export function Arena3D({
|
||||
world,
|
||||
playerId,
|
||||
presentationKey,
|
||||
interactive,
|
||||
}: Arena3DProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const worldRef = useRef(world);
|
||||
const playerIdRef = useRef(playerId);
|
||||
const lastEventIdRef = useRef(0);
|
||||
const presentationKeyRef = useRef("live");
|
||||
const runtimeRef = useRef<SceneRuntime | null>(null);
|
||||
const [locked, setLocked] = useState(false);
|
||||
|
||||
worldRef.current = world;
|
||||
playerIdRef.current = playerId;
|
||||
if (presentationKeyRef.current !== presentationKey) {
|
||||
lastEventIdRef.current = presentationKey === "live"
|
||||
? world.events.reduce(
|
||||
(latest, entry) => Math.max(latest, entry.event.id),
|
||||
0,
|
||||
)
|
||||
: 0;
|
||||
presentationKeyRef.current = presentationKey;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.6));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFShadowMap;
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.38;
|
||||
host.append(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0b1822);
|
||||
scene.fog = new THREE.FogExp2(0x10212c, 0.014);
|
||||
const camera = new THREE.PerspectiveCamera(79, 1, 0.045, 100);
|
||||
camera.rotation.order = "YXZ";
|
||||
scene.add(camera);
|
||||
|
||||
buildLights(scene);
|
||||
const animated = buildArena(scene);
|
||||
const { group: weapon, muzzle, muzzleCore, accent } = buildWeapon();
|
||||
camera.add(weapon);
|
||||
|
||||
const runtime: SceneRuntime = {
|
||||
renderer,
|
||||
scene,
|
||||
camera,
|
||||
players: new Map(),
|
||||
pickups: new Map(),
|
||||
effects: [],
|
||||
animated,
|
||||
weapon,
|
||||
weaponAccent: accent,
|
||||
muzzle,
|
||||
muzzleCore,
|
||||
muzzleUntil: 0,
|
||||
lastLocalImpact: null,
|
||||
audio: new ArenaAudio(),
|
||||
resizeObserver: new ResizeObserver(() => resize(runtime, host)),
|
||||
animationFrame: 0,
|
||||
};
|
||||
runtimeRef.current = runtime;
|
||||
runtime.resizeObserver.observe(host);
|
||||
resize(runtime, host);
|
||||
|
||||
const animate = (time: number) => {
|
||||
updateRuntime(
|
||||
runtime,
|
||||
worldRef.current,
|
||||
playerIdRef.current,
|
||||
lastEventIdRef,
|
||||
time,
|
||||
);
|
||||
renderer.render(scene, camera);
|
||||
runtime.animationFrame = window.requestAnimationFrame(animate);
|
||||
};
|
||||
runtime.animationFrame = window.requestAnimationFrame(animate);
|
||||
|
||||
const pointerLockChanged = () =>
|
||||
setLocked(document.pointerLockElement === renderer.domElement);
|
||||
document.addEventListener("pointerlockchange", pointerLockChanged);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("pointerlockchange", pointerLockChanged);
|
||||
runtime.resizeObserver.disconnect();
|
||||
window.cancelAnimationFrame(runtime.animationFrame);
|
||||
runtime.audio.dispose();
|
||||
scene.traverse((object) => {
|
||||
if (object instanceof THREE.Sprite) {
|
||||
object.material.map?.dispose();
|
||||
object.material.dispose();
|
||||
return;
|
||||
}
|
||||
if (!(object instanceof THREE.Mesh || object instanceof THREE.Line)) return;
|
||||
object.geometry.dispose();
|
||||
const materials = Array.isArray(object.material)
|
||||
? object.material
|
||||
: [object.material];
|
||||
for (const material of materials) material.dispose();
|
||||
});
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
runtimeRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const deploy = () => {
|
||||
if (!interactive) return;
|
||||
const runtime = runtimeRef.current;
|
||||
const canvas = hostRef.current?.querySelector("canvas");
|
||||
if (!runtime || !canvas || !canvas.isConnected) return;
|
||||
runtime.audio.unlock();
|
||||
if (document.pointerLockElement) {
|
||||
setLocked(true);
|
||||
return;
|
||||
}
|
||||
void canvas.requestPointerLock().catch(() => setLocked(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="viewport" ref={hostRef}>
|
||||
{interactive && !locked && (
|
||||
<button className="deploy" type="button" onClick={deploy}>
|
||||
<span>CLICK TO DEPLOY</span>
|
||||
<small>WASD · SHIFT · MOUSE · FIRE · 1/2/3 WEAPONS · R RELOAD</small>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildLights(scene: THREE.Scene): void {
|
||||
scene.add(new THREE.AmbientLight(0x9bc8dc, 0.72));
|
||||
scene.add(new THREE.HemisphereLight(0xa8ddf4, 0x3a3028, 2.35));
|
||||
|
||||
const sun = new THREE.DirectionalLight(0xe8f6ff, 3.2);
|
||||
sun.position.set(-9, 19, 5);
|
||||
sun.castShadow = true;
|
||||
sun.shadow.mapSize.set(1_024, 1_024);
|
||||
sun.shadow.camera.left = -22;
|
||||
sun.shadow.camera.right = 22;
|
||||
sun.shadow.camera.top = 22;
|
||||
sun.shadow.camera.bottom = -22;
|
||||
scene.add(sun);
|
||||
|
||||
const fill = new THREE.DirectionalLight(0x78bcd6, 1.15);
|
||||
fill.position.set(11, 8, -12);
|
||||
scene.add(fill);
|
||||
|
||||
for (const [x, z] of [
|
||||
[0, -11],
|
||||
[0, 11],
|
||||
[-11, 0],
|
||||
[11, 0],
|
||||
] as const) {
|
||||
const light = new THREE.PointLight(0xb8e8ff, 18, 17, 1.8);
|
||||
light.position.set(x, 5.5, z);
|
||||
scene.add(light);
|
||||
}
|
||||
|
||||
for (const [x, z, color] of [
|
||||
[-15.5, -15.5, 0x17cfff],
|
||||
[15.5, 15.5, 0xff842b],
|
||||
[15.5, -15.5, 0x17cfff],
|
||||
[-15.5, 15.5, 0xff842b],
|
||||
] as const) {
|
||||
const light = new THREE.PointLight(color, 22, 16, 2);
|
||||
light.position.set(x, 2.2, z);
|
||||
scene.add(light);
|
||||
}
|
||||
}
|
||||
|
||||
function buildArena(scene: THREE.Scene): THREE.Object3D[] {
|
||||
const animated: THREE.Object3D[] = [];
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
const texture = textureLoader.load("/assets/arena-panels.png");
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
texture.wrapT = THREE.RepeatWrapping;
|
||||
texture.anisotropy = 8;
|
||||
texture.repeat.set(2.5, 2.5);
|
||||
|
||||
const floorTexture = textureLoader.load("/assets/arena-panels.png");
|
||||
floorTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
floorTexture.wrapS = THREE.RepeatWrapping;
|
||||
floorTexture.wrapT = THREE.RepeatWrapping;
|
||||
floorTexture.anisotropy = 8;
|
||||
floorTexture.repeat.set(5, 5);
|
||||
const floorMaterial = new THREE.MeshStandardMaterial({
|
||||
map: floorTexture,
|
||||
color: 0x6f7a80,
|
||||
roughness: 0.72,
|
||||
metalness: 0.48,
|
||||
});
|
||||
const floor = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(ARENA_HALF_SIZE * 2, ARENA_HALF_SIZE * 2),
|
||||
floorMaterial,
|
||||
);
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
floor.receiveShadow = true;
|
||||
scene.add(floor);
|
||||
|
||||
const grid = new THREE.GridHelper(
|
||||
ARENA_HALF_SIZE * 2,
|
||||
36,
|
||||
0x2d819a,
|
||||
0x18313b,
|
||||
);
|
||||
grid.position.y = 0.015;
|
||||
for (const material of Array.isArray(grid.material) ? grid.material : [grid.material]) {
|
||||
material.transparent = true;
|
||||
material.opacity = 0.3;
|
||||
}
|
||||
scene.add(grid);
|
||||
|
||||
const panelMaterial = new THREE.MeshStandardMaterial({
|
||||
map: texture,
|
||||
color: 0x97a4aa,
|
||||
roughness: 0.58,
|
||||
metalness: 0.54,
|
||||
});
|
||||
const coverMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0x35424b,
|
||||
roughness: 0.47,
|
||||
metalness: 0.72,
|
||||
});
|
||||
const reactorMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0x112933,
|
||||
emissive: 0x007c93,
|
||||
emissiveIntensity: 1.9,
|
||||
roughness: 0.3,
|
||||
metalness: 0.8,
|
||||
});
|
||||
|
||||
for (const block of ARENA_BLOCKS) {
|
||||
const material =
|
||||
block.style === "reactor"
|
||||
? reactorMaterial
|
||||
: block.style === "wall"
|
||||
? panelMaterial
|
||||
: coverMaterial;
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(block.width, block.height, block.depth),
|
||||
material,
|
||||
);
|
||||
mesh.position.set(block.x, block.height / 2, block.z);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
scene.add(mesh);
|
||||
|
||||
if (block.style === "reactor") {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const ring = new THREE.Mesh(
|
||||
new THREE.TorusGeometry(2.45 + index * 0.2, 0.035, 8, 36),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: index % 2 ? 0xffa240 : 0x31ddff,
|
||||
transparent: true,
|
||||
opacity: 0.78,
|
||||
}),
|
||||
);
|
||||
ring.position.set(0, 1.05 + index * 1.05, 0);
|
||||
ring.rotation.x = Math.PI / 2;
|
||||
ring.userData.spin = (index % 2 ? -1 : 1) * (0.00018 + index * 0.00004);
|
||||
scene.add(ring);
|
||||
animated.push(ring);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const boundaryGeometry = [
|
||||
[0, -ARENA_HALF_SIZE - 0.3, ARENA_HALF_SIZE * 2 + 1.2, 0.65],
|
||||
[0, ARENA_HALF_SIZE + 0.3, ARENA_HALF_SIZE * 2 + 1.2, 0.65],
|
||||
[-ARENA_HALF_SIZE - 0.3, 0, 0.65, ARENA_HALF_SIZE * 2 + 1.2],
|
||||
[ARENA_HALF_SIZE + 0.3, 0, 0.65, ARENA_HALF_SIZE * 2 + 1.2],
|
||||
] as const;
|
||||
for (const [x, z, width, depth] of boundaryGeometry) {
|
||||
const wall = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(width, 3.6, depth),
|
||||
panelMaterial,
|
||||
);
|
||||
wall.position.set(x, 1.8, z);
|
||||
wall.castShadow = true;
|
||||
wall.receiveShadow = true;
|
||||
scene.add(wall);
|
||||
}
|
||||
|
||||
const stripMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0xa1efff,
|
||||
emissive: 0x00a8cd,
|
||||
emissiveIntensity: 4,
|
||||
roughness: 0.2,
|
||||
});
|
||||
for (let index = -14; index <= 14; index += 7) {
|
||||
for (const z of [-17.75, 17.75]) {
|
||||
const strip = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(3.2, 0.05, 0.13),
|
||||
stripMaterial,
|
||||
);
|
||||
strip.position.set(index, 0.035, z);
|
||||
scene.add(strip);
|
||||
}
|
||||
}
|
||||
return animated;
|
||||
}
|
||||
|
||||
function buildWeapon(): {
|
||||
group: THREE.Group;
|
||||
muzzle: THREE.PointLight;
|
||||
muzzleCore: THREE.Mesh;
|
||||
accent: THREE.MeshStandardMaterial;
|
||||
} {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(0.36, -0.3, -0.56);
|
||||
const dark = new THREE.MeshStandardMaterial({
|
||||
color: 0x121920,
|
||||
roughness: 0.3,
|
||||
metalness: 0.9,
|
||||
});
|
||||
const accent = new THREE.MeshStandardMaterial({
|
||||
color: 0x5de4c7,
|
||||
emissive: 0x0aa68d,
|
||||
emissiveIntensity: 3.8,
|
||||
roughness: 0.2,
|
||||
});
|
||||
const body = new THREE.Mesh(new THREE.BoxGeometry(0.22, 0.2, 0.62), dark);
|
||||
body.position.z = -0.16;
|
||||
group.add(body);
|
||||
const housing = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.31, 0.12, 0.3),
|
||||
dark,
|
||||
);
|
||||
housing.position.set(0, -0.05, -0.33);
|
||||
group.add(housing);
|
||||
const barrel = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.036, 0.047, 0.5, 10),
|
||||
dark,
|
||||
);
|
||||
barrel.rotation.x = Math.PI / 2;
|
||||
barrel.position.set(0, 0.025, -0.57);
|
||||
group.add(barrel);
|
||||
const rail = new THREE.Mesh(new THREE.BoxGeometry(0.105, 0.045, 0.38), accent);
|
||||
rail.position.set(0, 0.116, -0.18);
|
||||
group.add(rail);
|
||||
const muzzleCore = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.075, 10, 8),
|
||||
new THREE.MeshBasicMaterial({ color: 0xc8ffff }),
|
||||
);
|
||||
muzzleCore.position.set(0, 0.025, -0.83);
|
||||
muzzleCore.visible = false;
|
||||
group.add(muzzleCore);
|
||||
const muzzle = new THREE.PointLight(0x8deeff, 0, 3.5);
|
||||
muzzle.position.copy(muzzleCore.position);
|
||||
group.add(muzzle);
|
||||
return { group, muzzle, muzzleCore, accent };
|
||||
}
|
||||
|
||||
function updateRuntime(
|
||||
runtime: SceneRuntime,
|
||||
world: ShooterWorldState,
|
||||
playerId: number | null,
|
||||
lastEventId: MutableRefObject<number>,
|
||||
time: number,
|
||||
): void {
|
||||
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);
|
||||
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);
|
||||
runtime.weapon.position.x =
|
||||
0.36 + Math.cos(time * 0.0065) * Math.min(0.008, moving * 0.0012);
|
||||
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 seen = new Set<number>();
|
||||
for (const player of world.players.values()) {
|
||||
if (player.id === playerId || !player.alive) continue;
|
||||
seen.add(player.id);
|
||||
let model = runtime.players.get(player.id);
|
||||
if (!model) {
|
||||
model = createPlayerModel(player);
|
||||
runtime.players.set(player.id, model);
|
||||
runtime.scene.add(model);
|
||||
}
|
||||
model.visible = true;
|
||||
model.position.x += (player.x - model.position.x) * 0.28;
|
||||
model.position.z += (player.z - model.position.z) * 0.28;
|
||||
model.rotation.y = -player.yaw;
|
||||
updatePlayerHealthBar(model, player.health);
|
||||
}
|
||||
for (const [id, model] of runtime.players) {
|
||||
if (!seen.has(id)) model.visible = false;
|
||||
}
|
||||
|
||||
updatePickupModels(runtime, world.pickups, time);
|
||||
|
||||
for (const entry of world.events) {
|
||||
if (entry.event.id <= lastEventId.current) continue;
|
||||
handlePerception(runtime, entry.event, playerId, time);
|
||||
lastEventId.current = Math.max(lastEventId.current, entry.event.id);
|
||||
}
|
||||
|
||||
for (const object of runtime.animated) {
|
||||
object.rotation.z = time * Number(object.userData.spin ?? 0);
|
||||
}
|
||||
for (let index = runtime.effects.length - 1; index >= 0; index -= 1) {
|
||||
const effect = runtime.effects[index]!;
|
||||
if (effect.createdAt !== undefined && effect.floatDistance !== undefined) {
|
||||
const progress = clamp01(
|
||||
(time - effect.createdAt) / (effect.expiresAt - effect.createdAt),
|
||||
);
|
||||
effect.object.position.y =
|
||||
Number(effect.object.userData.baseY ?? effect.object.position.y) +
|
||||
progress * effect.floatDistance;
|
||||
const material = effect.object instanceof THREE.Sprite
|
||||
? effect.object.material
|
||||
: null;
|
||||
if (material) material.opacity = 1 - progress;
|
||||
}
|
||||
if (time < effect.expiresAt) continue;
|
||||
runtime.scene.remove(effect.object);
|
||||
effect.object.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh || child instanceof THREE.Line)) return;
|
||||
child.geometry.dispose();
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||||
for (const material of materials) {
|
||||
if ("map" in material && material.map instanceof THREE.Texture) {
|
||||
material.map.dispose();
|
||||
}
|
||||
material.dispose();
|
||||
}
|
||||
});
|
||||
if (effect.object instanceof THREE.Sprite) {
|
||||
effect.object.material.map?.dispose();
|
||||
effect.object.material.dispose();
|
||||
}
|
||||
runtime.effects.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePickupModels(
|
||||
runtime: SceneRuntime,
|
||||
pickups: ReadonlyMap<number, PickupState>,
|
||||
time: number,
|
||||
): void {
|
||||
for (const pickup of pickups.values()) {
|
||||
let model = runtime.pickups.get(pickup.id);
|
||||
if (!model) {
|
||||
model = createPickupModel(pickup);
|
||||
runtime.pickups.set(pickup.id, model);
|
||||
runtime.scene.add(model);
|
||||
}
|
||||
model.visible = pickup.active;
|
||||
model.rotation.y = time * 0.0012 + pickup.id;
|
||||
model.position.y = 0.55 + Math.sin(time * 0.003 + pickup.id) * 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePerception(
|
||||
runtime: SceneRuntime,
|
||||
event: ShooterPerception,
|
||||
playerId: number | null,
|
||||
time: number,
|
||||
): void {
|
||||
runtime.audio.play(event);
|
||||
if (event.type === "hit") {
|
||||
const impact = runtime.lastLocalImpact;
|
||||
if (impact && time - impact.at < 500) {
|
||||
const popup = createDamagePopup(event.amount, event.critical, event.eliminated);
|
||||
popup.position.set(impact.x, Math.max(0.7, impact.y + 0.36), impact.z);
|
||||
popup.userData.baseY = popup.position.y;
|
||||
runtime.scene.add(popup);
|
||||
runtime.effects.push({
|
||||
object: popup,
|
||||
createdAt: time,
|
||||
expiresAt: time + 850,
|
||||
floatDistance: 0.72,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.type !== "shot") return;
|
||||
|
||||
const color = new THREE.Color(WEAPONS[event.weapon].accent);
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(event.originX, event.originY, event.originZ),
|
||||
new THREE.Vector3(event.endX, event.endY, event.endZ),
|
||||
]);
|
||||
const tracer = new THREE.Line(
|
||||
geometry,
|
||||
new THREE.LineBasicMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: event.weapon === Weapon.RailRifle ? 0.95 : 0.72,
|
||||
}),
|
||||
);
|
||||
runtime.scene.add(tracer);
|
||||
runtime.effects.push({ object: tracer, expiresAt: time + (event.weapon === Weapon.RailRifle ? 150 : 75) });
|
||||
|
||||
if (event.impact !== "miss") {
|
||||
const impact = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(event.weapon === Weapon.Scattergun ? 0.09 : 0.065, 8, 6),
|
||||
new THREE.MeshBasicMaterial({ color }),
|
||||
);
|
||||
impact.position.set(event.endX, event.endY, event.endZ);
|
||||
runtime.scene.add(impact);
|
||||
runtime.effects.push({ object: impact, expiresAt: time + 140 });
|
||||
}
|
||||
|
||||
if (event.sourceId === playerId) {
|
||||
runtime.muzzleUntil = time + 52;
|
||||
runtime.muzzle.color.copy(color);
|
||||
runtime.lastLocalImpact = {
|
||||
x: event.endX,
|
||||
y: event.endY,
|
||||
z: event.endZ,
|
||||
at: time,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function createPlayerModel(player: PlayerState): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(player.x, 0, player.z);
|
||||
const primary = new THREE.MeshStandardMaterial({
|
||||
color: player.kind === "bot" ? 0xcb6229 : 0x2f9dbb,
|
||||
roughness: 0.45,
|
||||
metalness: 0.58,
|
||||
});
|
||||
const dark = new THREE.MeshStandardMaterial({
|
||||
color: 0x10171d,
|
||||
roughness: 0.36,
|
||||
metalness: 0.76,
|
||||
});
|
||||
const visor = new THREE.MeshStandardMaterial({
|
||||
color: player.kind === "bot" ? 0xffc078 : 0x9ef3ff,
|
||||
emissive: player.kind === "bot" ? 0xb52d05 : 0x00a8d4,
|
||||
emissiveIntensity: 3.4,
|
||||
});
|
||||
|
||||
const torso = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.35, 0.43, 0.88, 8),
|
||||
primary,
|
||||
);
|
||||
torso.position.y = 0.91;
|
||||
torso.castShadow = true;
|
||||
group.add(torso);
|
||||
const chest = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.17, 0.16), dark);
|
||||
chest.position.set(0, 1.03, -0.35);
|
||||
group.add(chest);
|
||||
const head = new THREE.Mesh(new THREE.SphereGeometry(0.27, 12, 8), dark);
|
||||
head.position.y = 1.55;
|
||||
head.castShadow = true;
|
||||
group.add(head);
|
||||
const face = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.1, 0.06), visor);
|
||||
face.position.set(0, 1.57, -0.245);
|
||||
group.add(face);
|
||||
const weapon = new THREE.Mesh(new THREE.BoxGeometry(0.11, 0.13, 0.72), dark);
|
||||
weapon.position.set(0.34, 1.08, -0.38);
|
||||
group.add(weapon);
|
||||
|
||||
const healthBackground = new THREE.Sprite(
|
||||
new THREE.SpriteMaterial({
|
||||
color: 0x081015,
|
||||
opacity: 0.86,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
healthBackground.position.set(0, 2.04, 0);
|
||||
healthBackground.scale.set(0.9, 0.12, 1);
|
||||
healthBackground.renderOrder = 4;
|
||||
group.add(healthBackground);
|
||||
|
||||
const healthMaterial = new THREE.SpriteMaterial({
|
||||
color: 0x55ef9a,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
const healthFill = new THREE.Sprite(healthMaterial);
|
||||
healthFill.position.set(0, 2.04, 0.002);
|
||||
healthFill.scale.set(0.8, 0.065, 1);
|
||||
healthFill.renderOrder = 5;
|
||||
group.add(healthFill);
|
||||
group.userData.healthFill = healthFill;
|
||||
group.userData.healthMaterial = healthMaterial;
|
||||
return group;
|
||||
}
|
||||
|
||||
function updatePlayerHealthBar(model: THREE.Group, health: number): void {
|
||||
const fill = model.userData.healthFill as THREE.Sprite | undefined;
|
||||
const material = model.userData.healthMaterial as
|
||||
| THREE.SpriteMaterial
|
||||
| undefined;
|
||||
if (!fill || !material) return;
|
||||
const ratio = clamp01(health / 100);
|
||||
fill.scale.x = Math.max(0.012, 0.8 * ratio);
|
||||
material.color.setHex(
|
||||
ratio > 0.55 ? 0x55ef9a : ratio > 0.25 ? 0xffb04d : 0xff5c4f,
|
||||
);
|
||||
}
|
||||
|
||||
function createDamagePopup(
|
||||
amount: number,
|
||||
critical: boolean,
|
||||
eliminated: boolean,
|
||||
): THREE.Sprite {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 320;
|
||||
canvas.height = 112;
|
||||
const context = canvas.getContext("2d");
|
||||
if (context) {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.font = critical
|
||||
? "800 52px IBM Plex Mono, monospace"
|
||||
: "700 48px IBM Plex Mono, monospace";
|
||||
context.lineWidth = 10;
|
||||
context.strokeStyle = "rgba(2, 7, 10, 0.92)";
|
||||
const label = eliminated
|
||||
? `${amount} ELIM`
|
||||
: critical
|
||||
? `${amount} CRIT`
|
||||
: `${amount}`;
|
||||
context.strokeText(label, canvas.width / 2, canvas.height / 2);
|
||||
context.fillStyle = eliminated
|
||||
? "#ff774f"
|
||||
: critical
|
||||
? "#ffc05d"
|
||||
: "#dffaff";
|
||||
context.fillText(label, canvas.width / 2, canvas.height / 2);
|
||||
}
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
const sprite = new THREE.Sprite(
|
||||
new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
sprite.scale.set(1.75, 0.61, 1);
|
||||
sprite.renderOrder = 20;
|
||||
return sprite;
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function createPickupModel(pickup: PickupState): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(pickup.x, 0.55, pickup.z);
|
||||
const color =
|
||||
pickup.kind === "health"
|
||||
? 0x54ef9b
|
||||
: pickup.kind === "ammo"
|
||||
? 0xffb04d
|
||||
: new THREE.Color(WEAPONS[pickup.weapon].accent).getHex();
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color,
|
||||
emissive: color,
|
||||
emissiveIntensity: 1.4,
|
||||
roughness: 0.28,
|
||||
metalness: 0.68,
|
||||
});
|
||||
const core = new THREE.Mesh(
|
||||
pickup.kind === "health"
|
||||
? new THREE.OctahedronGeometry(0.3)
|
||||
: pickup.kind === "ammo"
|
||||
? new THREE.BoxGeometry(0.5, 0.28, 0.32)
|
||||
: new THREE.TorusKnotGeometry(0.2, 0.065, 40, 7),
|
||||
material,
|
||||
);
|
||||
core.castShadow = true;
|
||||
group.add(core);
|
||||
const light = new THREE.PointLight(color, 3.5, 3);
|
||||
group.add(light);
|
||||
return group;
|
||||
}
|
||||
|
||||
function resize(runtime: SceneRuntime, host: HTMLDivElement): void {
|
||||
const width = Math.max(1, host.clientWidth);
|
||||
const height = Math.max(1, host.clientHeight);
|
||||
runtime.renderer.setSize(width, height, false);
|
||||
runtime.camera.aspect = width / height;
|
||||
runtime.camera.updateProjectionMatrix();
|
||||
}
|
||||
111
apps/web/src/FluxGame.tsx
Normal file
111
apps/web/src/FluxGame.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { FluxPlayerView, FluxTeam } from "@syncer/shared";
|
||||
import { useFluxClient } from "./useFluxClient.js";
|
||||
|
||||
export function FluxGame() {
|
||||
const client = useFluxClient();
|
||||
const local = client.world.players.find((player) => player.id === client.playerId);
|
||||
const recent = [...client.world.events].reverse()[0];
|
||||
const energy = local?.energy ?? 0;
|
||||
const corePercent = (client.world.core + 1) * 50;
|
||||
|
||||
return (
|
||||
<main className={`flux-game flux-game--${local?.team ?? "cyan"}`}>
|
||||
<div className="flux-grid" aria-hidden="true" />
|
||||
<header className="flux-header">
|
||||
<div>
|
||||
<small>SYNCER // SECOND GAME</small>
|
||||
<strong>FLUX RELAY</strong>
|
||||
</div>
|
||||
<section className="flux-score" aria-label="Team score">
|
||||
<b>{client.world.cyanScore.toString().padStart(2, "0")}</b>
|
||||
<span>ROUND {client.world.round.toString().padStart(2, "0")}</span>
|
||||
<b>{client.world.orangeScore.toString().padStart(2, "0")}</b>
|
||||
</section>
|
||||
<div className={`flux-connection flux-connection--${client.connection}`}>
|
||||
<i />
|
||||
<span>{client.connection}</span>
|
||||
<small>{client.network.roundTripTime.toFixed(0)} ms</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="flux-arena" aria-label="Flux Relay arena">
|
||||
<TeamRoster team="cyan" players={client.world.players} localId={client.playerId} />
|
||||
<div className="flux-field">
|
||||
<div className="flux-gate flux-gate--cyan"><span>CYAN GATE</span></div>
|
||||
<div className="flux-lane">
|
||||
<div className="flux-midline" />
|
||||
<div
|
||||
className="flux-core"
|
||||
style={{ left: `${corePercent}%` }}
|
||||
aria-label={`Core position ${client.world.core.toFixed(2)}`}
|
||||
>
|
||||
<i /><i /><i />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flux-gate flux-gate--orange"><span>ORANGE GATE</span></div>
|
||||
</div>
|
||||
<TeamRoster team="orange" players={client.world.players} localId={client.playerId} />
|
||||
</section>
|
||||
|
||||
{recent && client.tick - recent.receivedTick < 80 ? (
|
||||
<div className={`flux-event flux-event--${recent.event.team}`}>
|
||||
{recent.event.type === "round-won"
|
||||
? `${recent.event.team.toUpperCase()} CAPTURED THE CORE`
|
||||
: recent.event.scope === "self"
|
||||
? "REACTOR OVERHEATED · RELEASE TO RECOVER"
|
||||
: `${recent.event.team.toUpperCase()} THRUSTER FELL SILENT`}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<footer className="flux-controls">
|
||||
<div className="flux-identity">
|
||||
<small>YOUR LINK</small>
|
||||
<strong>{local ? `${local.team.toUpperCase()} // P${local.id}` : "CONNECTING"}</strong>
|
||||
<span>Other players' exact energy is never replicated.</span>
|
||||
</div>
|
||||
<button
|
||||
className={`flux-thrust${local?.thrust ? " flux-thrust--active" : ""}`}
|
||||
onPointerDown={() => client.setThrust(true)}
|
||||
onPointerUp={() => client.setThrust(false)}
|
||||
onPointerCancel={() => client.setThrust(false)}
|
||||
onPointerLeave={() => client.setThrust(false)}
|
||||
type="button"
|
||||
>
|
||||
<small>HOLD SPACE OR PRESS</small>
|
||||
<strong>{local?.thrust ? "THRUSTING" : "ENGAGE THRUSTER"}</strong>
|
||||
</button>
|
||||
<div className="flux-energy">
|
||||
<div><small>PRIVATE ENERGY</small><strong>{Math.round(energy)}%</strong></div>
|
||||
<span><i style={{ width: `${energy}%` }} /></span>
|
||||
<em>Tick {client.tick} · lead {client.inputLeadTicks}t · {client.validation}</em>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamRoster({
|
||||
team,
|
||||
players,
|
||||
localId,
|
||||
}: {
|
||||
team: FluxTeam;
|
||||
players: FluxPlayerView[];
|
||||
localId: number | null;
|
||||
}) {
|
||||
return (
|
||||
<div className={`flux-roster flux-roster--${team}`}>
|
||||
<small>{team} links</small>
|
||||
{players.filter((player) => player.team === team).map((player) => (
|
||||
<div
|
||||
className={`${player.thrust ? "flux-player--active" : ""}${player.id === localId ? " flux-player--local" : ""}`}
|
||||
key={player.id}
|
||||
>
|
||||
<i />
|
||||
<span>{player.bot ? "BOT" : `P${player.id}`}</span>
|
||||
<b>{player.energy === null ? "PRIVATE" : `${Math.round(player.energy)}%`}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
540
apps/web/src/Royale3D.tsx
Normal file
540
apps/web/src/Royale3D.tsx
Normal file
@@ -0,0 +1,540 @@
|
||||
import { useEffect, useRef, useState, type MutableRefObject } from "react";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
ROYALE_CHUNK_SIZE,
|
||||
ROYALE_WORLD_RADIUS,
|
||||
generateRoyaleChunk,
|
||||
royaleChunkCoordinate,
|
||||
type RoyaleClientState,
|
||||
type RoyaleLootView,
|
||||
type RoyalePlayerView,
|
||||
} from "@syncer/shared";
|
||||
|
||||
interface Royale3DProps {
|
||||
world: RoyaleClientState;
|
||||
playerId: number | null;
|
||||
}
|
||||
|
||||
interface TemporaryEffect {
|
||||
object: THREE.Object3D;
|
||||
expiresAt: number;
|
||||
startedAt?: number;
|
||||
start?: THREE.Vector3;
|
||||
end?: THREE.Vector3;
|
||||
}
|
||||
|
||||
interface RoyaleRuntime {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
scene: THREE.Scene;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
viewWeapon: THREE.Group;
|
||||
viewMuzzle: THREE.PointLight;
|
||||
viewMuzzleCore: THREE.Mesh;
|
||||
muzzleUntil: number;
|
||||
chunks: Map<string, THREE.Group>;
|
||||
players: Map<number, THREE.Group>;
|
||||
loot: Map<number, THREE.Group>;
|
||||
storm: THREE.Mesh<THREE.RingGeometry, THREE.MeshBasicMaterial>;
|
||||
effects: TemporaryEffect[];
|
||||
resizeObserver: ResizeObserver;
|
||||
animationFrame: number;
|
||||
}
|
||||
|
||||
export function Royale3D({ world, playerId }: Royale3DProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const worldRef = useRef(world);
|
||||
const playerIdRef = useRef(playerId);
|
||||
const runtimeRef = useRef<RoyaleRuntime | null>(null);
|
||||
const lastEventIdRef = useRef(0);
|
||||
const [locked, setLocked] = useState(false);
|
||||
worldRef.current = world;
|
||||
playerIdRef.current = playerId;
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFShadowMap;
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.18;
|
||||
host.append(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x8bb4c2);
|
||||
scene.fog = new THREE.FogExp2(0x9cb9bc, 0.0022);
|
||||
const camera = new THREE.PerspectiveCamera(78, 1, 0.045, 1_100);
|
||||
camera.rotation.order = "YXZ";
|
||||
scene.add(camera);
|
||||
|
||||
const {
|
||||
group: viewWeapon,
|
||||
muzzle: viewMuzzle,
|
||||
muzzleCore: viewMuzzleCore,
|
||||
} = buildViewWeapon();
|
||||
camera.add(viewWeapon);
|
||||
|
||||
scene.add(new THREE.HemisphereLight(0xcbe8ed, 0x253426, 2.2));
|
||||
const sun = new THREE.DirectionalLight(0xfff0cf, 3.25);
|
||||
sun.position.set(-180, 280, 90);
|
||||
sun.castShadow = true;
|
||||
sun.shadow.mapSize.set(1_024, 1_024);
|
||||
sun.shadow.camera.left = -100;
|
||||
sun.shadow.camera.right = 100;
|
||||
sun.shadow.camera.top = 100;
|
||||
sun.shadow.camera.bottom = -100;
|
||||
scene.add(sun);
|
||||
|
||||
const water = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(2_500, 2_500),
|
||||
new THREE.MeshStandardMaterial({ color: 0x1a6276, roughness: 0.32, metalness: 0.22 }),
|
||||
);
|
||||
water.rotation.x = -Math.PI / 2;
|
||||
water.position.y = -1.2;
|
||||
scene.add(water);
|
||||
|
||||
const storm = new THREE.Mesh(
|
||||
new THREE.RingGeometry(0.994, 1, 160),
|
||||
new THREE.MeshBasicMaterial({ color: 0x68d8ff, transparent: true, opacity: 0.84, side: THREE.DoubleSide }),
|
||||
);
|
||||
storm.rotation.x = -Math.PI / 2;
|
||||
storm.position.y = 0.24;
|
||||
scene.add(storm);
|
||||
|
||||
const runtime: RoyaleRuntime = {
|
||||
renderer,
|
||||
scene,
|
||||
camera,
|
||||
viewWeapon,
|
||||
viewMuzzle,
|
||||
viewMuzzleCore,
|
||||
muzzleUntil: 0,
|
||||
chunks: new Map(),
|
||||
players: new Map(),
|
||||
loot: new Map(),
|
||||
storm,
|
||||
effects: [],
|
||||
resizeObserver: new ResizeObserver(() => resize(runtime, host)),
|
||||
animationFrame: 0,
|
||||
};
|
||||
runtimeRef.current = runtime;
|
||||
runtime.resizeObserver.observe(host);
|
||||
resize(runtime, host);
|
||||
|
||||
const animate = (time: number) => {
|
||||
updateRuntime(runtime, worldRef.current, playerIdRef.current, lastEventIdRef, time);
|
||||
renderer.render(scene, camera);
|
||||
runtime.animationFrame = window.requestAnimationFrame(animate);
|
||||
};
|
||||
runtime.animationFrame = window.requestAnimationFrame(animate);
|
||||
|
||||
const pointerLockChanged = () => setLocked(document.pointerLockElement === renderer.domElement);
|
||||
document.addEventListener("pointerlockchange", pointerLockChanged);
|
||||
return () => {
|
||||
document.removeEventListener("pointerlockchange", pointerLockChanged);
|
||||
runtime.resizeObserver.disconnect();
|
||||
window.cancelAnimationFrame(runtime.animationFrame);
|
||||
scene.traverse((object) => disposeObject(object));
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
runtimeRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const deploy = () => {
|
||||
const canvas = runtimeRef.current?.renderer.domElement;
|
||||
if (!canvas) return;
|
||||
void canvas.requestPointerLock().catch(() => setLocked(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="royale-viewport" ref={hostRef}>
|
||||
{!locked && (
|
||||
<button className="royale-deploy" type="button" onClick={deploy}>
|
||||
<small>PROCEDURAL ISLAND // LIVE AUTHORITY</small>
|
||||
<strong>DROP IN</strong>
|
||||
<span>WASD · MOUSE AIM · FIRE · SHIFT SPRINT · R RELOAD</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function updateRuntime(
|
||||
runtime: RoyaleRuntime,
|
||||
world: RoyaleClientState,
|
||||
playerId: number | null,
|
||||
lastEventIdRef: MutableRefObject<number>,
|
||||
time: number,
|
||||
): void {
|
||||
const local = world.players.find((player) => player.id === playerId);
|
||||
if (local) {
|
||||
updateChunks(runtime, local.x, local.z);
|
||||
runtime.camera.position.set(local.x, local.altitude + 1.55, local.z);
|
||||
runtime.camera.rotation.set(local.pitch, Math.PI + local.yaw, 0);
|
||||
runtime.viewWeapon.visible = local.alive;
|
||||
const moving = Math.hypot(local.velocityX, local.velocityZ);
|
||||
runtime.viewWeapon.position.x =
|
||||
0.36 + Math.cos(time * 0.0065) * Math.min(0.009, moving * 0.0012);
|
||||
runtime.viewWeapon.position.y =
|
||||
-0.31 + Math.sin(time * 0.013) * Math.min(0.024, moving * 0.003);
|
||||
} else runtime.viewWeapon.visible = false;
|
||||
runtime.viewMuzzle.intensity = time < runtime.muzzleUntil ? 14 : 0;
|
||||
runtime.viewMuzzleCore.visible = time < runtime.muzzleUntil;
|
||||
|
||||
runtime.storm.position.x = world.stormX;
|
||||
runtime.storm.position.z = world.stormZ;
|
||||
runtime.storm.scale.set(world.stormRadius, world.stormRadius, 1);
|
||||
runtime.storm.material.opacity = 0.62 + Math.sin(time * 0.003) * 0.18;
|
||||
|
||||
updatePlayers(runtime, world.players, playerId, time);
|
||||
updateLoot(runtime, world.loot, time);
|
||||
updateEffects(runtime, world, playerId, lastEventIdRef, time);
|
||||
}
|
||||
|
||||
function updateChunks(runtime: RoyaleRuntime, x: number, z: number): void {
|
||||
const centerX = royaleChunkCoordinate(x);
|
||||
const centerZ = royaleChunkCoordinate(z);
|
||||
const wanted = new Set<string>();
|
||||
const radius = 3;
|
||||
for (let offsetX = -radius; offsetX <= radius; offsetX += 1) {
|
||||
for (let offsetZ = -radius; offsetZ <= radius; offsetZ += 1) {
|
||||
const chunkX = centerX + offsetX;
|
||||
const chunkZ = centerZ + offsetZ;
|
||||
const chunkCenterX = (chunkX + 0.5) * ROYALE_CHUNK_SIZE;
|
||||
const chunkCenterZ = (chunkZ + 0.5) * ROYALE_CHUNK_SIZE;
|
||||
if (Math.hypot(chunkCenterX, chunkCenterZ) > ROYALE_WORLD_RADIUS + 90) continue;
|
||||
const key = `${chunkX}:${chunkZ}`;
|
||||
wanted.add(key);
|
||||
if (!runtime.chunks.has(key)) {
|
||||
const group = buildChunk(chunkX, chunkZ);
|
||||
runtime.chunks.set(key, group);
|
||||
runtime.scene.add(group);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, group] of runtime.chunks) {
|
||||
if (wanted.has(key)) continue;
|
||||
runtime.chunks.delete(key);
|
||||
runtime.scene.remove(group);
|
||||
group.traverse((object) => disposeObject(object));
|
||||
}
|
||||
}
|
||||
|
||||
function buildChunk(chunkX: number, chunkZ: number): THREE.Group {
|
||||
const chunk = generateRoyaleChunk(chunkX, chunkZ);
|
||||
const group = new THREE.Group();
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(ROYALE_CHUNK_SIZE, ROYALE_CHUNK_SIZE),
|
||||
new THREE.MeshStandardMaterial({ color: chunk.tint, roughness: 0.93, metalness: 0.02 }),
|
||||
);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.position.set(
|
||||
chunkX * ROYALE_CHUNK_SIZE + ROYALE_CHUNK_SIZE / 2,
|
||||
0,
|
||||
chunkZ * ROYALE_CHUNK_SIZE + ROYALE_CHUNK_SIZE / 2,
|
||||
);
|
||||
ground.receiveShadow = true;
|
||||
group.add(ground);
|
||||
|
||||
const roadMaterial = new THREE.MeshStandardMaterial({ color: 0x4b514c, roughness: 0.88 });
|
||||
if (chunk.roadX) {
|
||||
const road = new THREE.Mesh(new THREE.PlaneGeometry(ROYALE_CHUNK_SIZE, 18), roadMaterial);
|
||||
road.rotation.x = -Math.PI / 2;
|
||||
road.position.set(ground.position.x, 0.035, ground.position.z);
|
||||
group.add(road);
|
||||
}
|
||||
if (chunk.roadZ) {
|
||||
const road = new THREE.Mesh(new THREE.PlaneGeometry(18, ROYALE_CHUNK_SIZE), roadMaterial.clone());
|
||||
road.rotation.x = -Math.PI / 2;
|
||||
road.position.set(ground.position.x, 0.04, ground.position.z);
|
||||
group.add(road);
|
||||
}
|
||||
|
||||
for (const obstacle of chunk.obstacles) {
|
||||
if (obstacle.kind === "building") {
|
||||
const building = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(obstacle.width, obstacle.height, obstacle.depth),
|
||||
new THREE.MeshStandardMaterial({ color: 0x707b72, roughness: 0.78, metalness: 0.12 }),
|
||||
);
|
||||
building.position.set(obstacle.x, obstacle.height / 2, obstacle.z);
|
||||
building.castShadow = true;
|
||||
building.receiveShadow = true;
|
||||
group.add(building);
|
||||
const roof = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(obstacle.width + 0.6, 0.35, obstacle.depth + 0.6),
|
||||
new THREE.MeshStandardMaterial({ color: 0x30383a, roughness: 0.7 }),
|
||||
);
|
||||
roof.position.set(obstacle.x, obstacle.height + 0.18, obstacle.z);
|
||||
group.add(roof);
|
||||
} else if (obstacle.kind === "tree") {
|
||||
const trunk = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.65, 0.9, obstacle.height * 0.48, 7),
|
||||
new THREE.MeshStandardMaterial({ color: 0x58442d, roughness: 1 }),
|
||||
);
|
||||
trunk.position.set(obstacle.x, obstacle.height * 0.24, obstacle.z);
|
||||
trunk.castShadow = true;
|
||||
group.add(trunk);
|
||||
const crown = new THREE.Mesh(
|
||||
new THREE.ConeGeometry(obstacle.width * 1.25, obstacle.height * 0.72, 8),
|
||||
new THREE.MeshStandardMaterial({ color: 0x244f34, roughness: 0.92 }),
|
||||
);
|
||||
crown.position.set(obstacle.x, obstacle.height * 0.7, obstacle.z);
|
||||
crown.castShadow = true;
|
||||
group.add(crown);
|
||||
} else {
|
||||
const rock = new THREE.Mesh(
|
||||
new THREE.DodecahedronGeometry(obstacle.width * 0.48, 0),
|
||||
new THREE.MeshStandardMaterial({ color: 0x646a65, roughness: 0.96 }),
|
||||
);
|
||||
rock.scale.y = obstacle.height / obstacle.width;
|
||||
rock.position.set(obstacle.x, obstacle.height * 0.34, obstacle.z);
|
||||
rock.rotation.set(0.2, obstacle.x * 0.1, -0.12);
|
||||
rock.castShadow = true;
|
||||
group.add(rock);
|
||||
}
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
function updatePlayers(
|
||||
runtime: RoyaleRuntime,
|
||||
players: RoyalePlayerView[],
|
||||
playerId: number | null,
|
||||
time: number,
|
||||
): void {
|
||||
const visible = new Set(players.map((player) => player.id));
|
||||
for (const player of players) {
|
||||
let group = runtime.players.get(player.id);
|
||||
if (!group) {
|
||||
group = buildPlayer(player.id === playerId, player.bot);
|
||||
runtime.players.set(player.id, group);
|
||||
runtime.scene.add(group);
|
||||
}
|
||||
group.position.set(player.x, player.altitude, player.z);
|
||||
group.rotation.y = player.yaw;
|
||||
group.visible = player.alive && player.id !== playerId;
|
||||
const canopy = group.getObjectByName("canopy");
|
||||
if (canopy) canopy.visible = player.altitude > 0;
|
||||
const pulse = group.getObjectByName("pulse") as THREE.PointLight | undefined;
|
||||
if (pulse) pulse.intensity = player.firing ? 15 : 2 + Math.sin(time * 0.005 + player.id) * 0.8;
|
||||
}
|
||||
for (const [id, group] of runtime.players) {
|
||||
if (visible.has(id)) continue;
|
||||
runtime.players.delete(id);
|
||||
runtime.scene.remove(group);
|
||||
group.traverse((object) => disposeObject(object));
|
||||
}
|
||||
}
|
||||
|
||||
function buildPlayer(local: boolean, bot: boolean): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
const color = local ? 0x69efff : bot ? 0xff8257 : 0xf7e9bd;
|
||||
const body = new THREE.Mesh(
|
||||
new THREE.CapsuleGeometry(0.72, 1.35, 4, 8),
|
||||
new THREE.MeshStandardMaterial({ color, roughness: 0.45, metalness: 0.28 }),
|
||||
);
|
||||
body.position.y = 1.35;
|
||||
body.castShadow = true;
|
||||
group.add(body);
|
||||
const weapon = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.18, 0.18, 1.55),
|
||||
new THREE.MeshStandardMaterial({ color: 0x202a2d, metalness: 0.65, roughness: 0.35 }),
|
||||
);
|
||||
weapon.position.set(0.42, 1.45, 0.55);
|
||||
group.add(weapon);
|
||||
const pulse = new THREE.PointLight(color, 3, 12, 2);
|
||||
pulse.name = "pulse";
|
||||
pulse.position.y = 1.8;
|
||||
group.add(pulse);
|
||||
const canopy = new THREE.Group();
|
||||
canopy.name = "canopy";
|
||||
const wing = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(3.4, 16, 8, 0, Math.PI * 2, 0, Math.PI * 0.48),
|
||||
new THREE.MeshStandardMaterial({ color: local ? 0x4ddff8 : 0x3c4747, side: THREE.DoubleSide, roughness: 0.66 }),
|
||||
);
|
||||
wing.scale.z = 0.42;
|
||||
wing.position.y = 5.2;
|
||||
canopy.add(wing);
|
||||
group.add(canopy);
|
||||
return group;
|
||||
}
|
||||
|
||||
function buildViewWeapon(): {
|
||||
group: THREE.Group;
|
||||
muzzle: THREE.PointLight;
|
||||
muzzleCore: THREE.Mesh;
|
||||
} {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(0.36, -0.31, -0.62);
|
||||
group.visible = false;
|
||||
const body = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.18, 0.2, 0.9),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x263438,
|
||||
metalness: 0.72,
|
||||
roughness: 0.28,
|
||||
}),
|
||||
);
|
||||
body.position.z = -0.18;
|
||||
group.add(body);
|
||||
const rail = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.1, 0.06, 0.58),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x76e9ef,
|
||||
emissive: 0x1b8792,
|
||||
emissiveIntensity: 0.72,
|
||||
metalness: 0.48,
|
||||
roughness: 0.3,
|
||||
}),
|
||||
);
|
||||
rail.position.set(0, 0.12, -0.25);
|
||||
group.add(rail);
|
||||
const muzzleCore = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.07, 10, 8),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffe0a0 }),
|
||||
);
|
||||
muzzleCore.position.set(0, 0, -0.72);
|
||||
muzzleCore.visible = false;
|
||||
group.add(muzzleCore);
|
||||
const muzzle = new THREE.PointLight(0xffc66d, 0, 3.5, 2);
|
||||
muzzle.position.copy(muzzleCore.position);
|
||||
group.add(muzzle);
|
||||
return { group, muzzle, muzzleCore };
|
||||
}
|
||||
|
||||
function updateLoot(runtime: RoyaleRuntime, loot: RoyaleLootView[], time: number): void {
|
||||
const visible = new Set(loot.map((item) => item.id));
|
||||
for (const item of loot) {
|
||||
let group = runtime.loot.get(item.id);
|
||||
if (!group) {
|
||||
group = buildLoot(item);
|
||||
runtime.loot.set(item.id, group);
|
||||
runtime.scene.add(group);
|
||||
}
|
||||
group.position.set(item.x, 0.7 + Math.sin(time * 0.002 + item.id) * 0.18, item.z);
|
||||
group.rotation.y = time * 0.0006 + item.id;
|
||||
}
|
||||
for (const [id, group] of runtime.loot) {
|
||||
if (visible.has(id)) continue;
|
||||
runtime.loot.delete(id);
|
||||
runtime.scene.remove(group);
|
||||
group.traverse((object) => disposeObject(object));
|
||||
}
|
||||
}
|
||||
|
||||
function buildLoot(item: RoyaleLootView): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
const color = item.kind === "weapon" ? 0xffc75f : item.kind === "ammo" ? 0x8ceaff : item.kind === "armor" ? 0x9b8cff : 0x71ef9c;
|
||||
const mesh = new THREE.Mesh(
|
||||
item.kind === "weapon" ? new THREE.BoxGeometry(1.3, 0.28, 0.45) : new THREE.OctahedronGeometry(0.62),
|
||||
new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.7, metalness: 0.45, roughness: 0.3 }),
|
||||
);
|
||||
mesh.castShadow = true;
|
||||
group.add(mesh);
|
||||
group.add(new THREE.PointLight(color, 6, 9, 2));
|
||||
return group;
|
||||
}
|
||||
|
||||
function updateEffects(
|
||||
runtime: RoyaleRuntime,
|
||||
world: RoyaleClientState,
|
||||
playerId: number | null,
|
||||
lastEventIdRef: MutableRefObject<number>,
|
||||
time: number,
|
||||
): void {
|
||||
for (const entry of world.events) {
|
||||
if (entry.event.id <= lastEventIdRef.current || entry.event.type !== "shot") continue;
|
||||
const event = entry.event;
|
||||
if (event.sourceId === playerId) runtime.muzzleUntil = time + 85;
|
||||
const horizontal = Math.cos(event.pitch);
|
||||
const direction = new THREE.Vector3(
|
||||
Math.sin(event.yaw) * horizontal,
|
||||
Math.sin(event.pitch),
|
||||
Math.cos(event.yaw) * horizontal,
|
||||
).normalize();
|
||||
const start = new THREE.Vector3(event.x, event.y, event.z)
|
||||
.addScaledVector(direction, 1.35);
|
||||
const end = new THREE.Vector3(
|
||||
event.x + direction.x * 86,
|
||||
event.y + direction.y * 86,
|
||||
event.z + direction.z * 86,
|
||||
);
|
||||
const length = start.distanceTo(end);
|
||||
const trail = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.028, 0.055, length, 6),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: 0xffd27a,
|
||||
transparent: true,
|
||||
opacity: 0.72,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
trail.position.copy(start).add(end).multiplyScalar(0.5);
|
||||
trail.quaternion.setFromUnitVectors(
|
||||
new THREE.Vector3(0, 1, 0),
|
||||
direction,
|
||||
);
|
||||
runtime.scene.add(trail);
|
||||
runtime.effects.push({ object: trail, expiresAt: time + 165 });
|
||||
|
||||
const bullet = new THREE.Group();
|
||||
const core = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.13, 10, 8),
|
||||
new THREE.MeshBasicMaterial({ color: 0xfff1bd }),
|
||||
);
|
||||
bullet.add(core);
|
||||
bullet.add(new THREE.PointLight(0xffbd5c, 10, 5, 2));
|
||||
bullet.position.copy(start);
|
||||
runtime.scene.add(bullet);
|
||||
runtime.effects.push({
|
||||
object: bullet,
|
||||
startedAt: time,
|
||||
expiresAt: time + 230,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
}
|
||||
lastEventIdRef.current = world.events.reduce(
|
||||
(latest, entry) => Math.max(latest, entry.event.id),
|
||||
lastEventIdRef.current,
|
||||
);
|
||||
runtime.effects = runtime.effects.filter((effect) => {
|
||||
if (
|
||||
effect.startedAt !== undefined &&
|
||||
effect.start &&
|
||||
effect.end
|
||||
) {
|
||||
const progress = clamp01(
|
||||
(time - effect.startedAt) / (effect.expiresAt - effect.startedAt),
|
||||
);
|
||||
effect.object.position.lerpVectors(effect.start, effect.end, progress);
|
||||
}
|
||||
if (effect.expiresAt > time) return true;
|
||||
runtime.scene.remove(effect.object);
|
||||
effect.object.traverse((object) => disposeObject(object));
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function resize(runtime: RoyaleRuntime, host: HTMLDivElement): void {
|
||||
const width = Math.max(1, host.clientWidth);
|
||||
const height = Math.max(1, host.clientHeight);
|
||||
runtime.renderer.setSize(width, height, false);
|
||||
runtime.camera.aspect = width / height;
|
||||
runtime.camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
function disposeObject(object: THREE.Object3D): void {
|
||||
if (!(object instanceof THREE.Mesh || object instanceof THREE.Line)) return;
|
||||
object.geometry.dispose();
|
||||
const materials = Array.isArray(object.material) ? object.material : [object.material];
|
||||
for (const material of materials) material.dispose();
|
||||
}
|
||||
179
apps/web/src/RoyaleGame.tsx
Normal file
179
apps/web/src/RoyaleGame.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
ROYALE_WEAPONS,
|
||||
type RoyaleClientState,
|
||||
type RoyalePlayerView,
|
||||
} from "@syncer/shared";
|
||||
import { Royale3D } from "./Royale3D.js";
|
||||
import { useRoyaleClient } from "./useRoyaleClient.js";
|
||||
|
||||
export function RoyaleGame() {
|
||||
const client = useRoyaleClient();
|
||||
const local = client.world.players.find((player) => player.id === client.playerId);
|
||||
const recent = [...client.world.events].reverse();
|
||||
const damage = recent.find((entry) => entry.event.type === "damage");
|
||||
const hit = recent.find((entry) => entry.event.type === "hit");
|
||||
const pickup = recent.find((entry) => entry.event.type === "pickup");
|
||||
const eliminations = recent
|
||||
.filter((entry) => entry.event.type === "elimination")
|
||||
.slice(0, 4);
|
||||
const weapon = ROYALE_WEAPONS[local?.weapon ?? "pistol"];
|
||||
const stormDistance = local
|
||||
? Math.hypot(local.x - client.world.stormX, local.z - client.world.stormZ) -
|
||||
client.world.stormRadius
|
||||
: 0;
|
||||
const recentDamage = Boolean(
|
||||
damage && client.tick - damage.receivedTick < 18,
|
||||
);
|
||||
const recentHit = Boolean(hit && client.tick - hit.receivedTick < 12);
|
||||
|
||||
return (
|
||||
<main className="royale-game">
|
||||
<Royale3D world={client.world} playerId={client.playerId} />
|
||||
<div className="royale-grade" aria-hidden="true" />
|
||||
{recentDamage ? <div className="royale-damage" aria-hidden="true" /> : null}
|
||||
|
||||
<header className="royale-header">
|
||||
<div className="royale-brand">
|
||||
<small>SYNCER // LARGE WORLD PROOF</small>
|
||||
<strong>SYNCER ROYALE</strong>
|
||||
</div>
|
||||
<div className="royale-status">
|
||||
<span><b>{client.world.aliveCount}</b> ALIVE</span>
|
||||
<span>ROUND <b>{client.world.round}</b></span>
|
||||
<span className={stormDistance > 0 ? "is-danger" : ""}>
|
||||
{stormDistance > 0 ? `${Math.ceil(stormDistance)}M OUTSIDE` : `${Math.round(client.world.stormRadius)}M CIRCLE`}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`royale-live royale-live--${client.connection}`}>
|
||||
<i />
|
||||
<span>{client.connection}</span>
|
||||
<small>{client.network.roundTripTime.toFixed(0)} ms · {client.validation}</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="royale-stream" aria-label="Spatial stream telemetry">
|
||||
<small>SPATIAL STREAM</small>
|
||||
<div><span>CHUNK</span><b>{client.world.stream.chunkX}:{client.world.stream.chunkZ}</b></div>
|
||||
<div><span>VISIBLE</span><b>{client.world.players.length - 1} / {client.world.aliveCount - 1}</b></div>
|
||||
<div><span>ENTITIES</span><b>{client.world.stream.candidateCount - client.world.stream.droppedCount}</b></div>
|
||||
<div><span>BUDGET</span><b>{formatBytes(client.world.stream.usedBytes)} / {formatBytes(client.world.stream.budgetBytes)}</b></div>
|
||||
<div><span>TICK</span><b>{client.tick}</b></div>
|
||||
</section>
|
||||
|
||||
<Radar world={client.world} local={local} />
|
||||
|
||||
<section className="royale-feed" aria-live="polite">
|
||||
{eliminations.map((entry) => {
|
||||
if (entry.event.type !== "elimination") return null;
|
||||
return (
|
||||
<div key={entry.event.id}>
|
||||
<span>{combatant(entry.event.killerId)}</span>
|
||||
<b>ELIMINATED</b>
|
||||
<span>{combatant(entry.event.victimId)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<div className={`royale-crosshair${recentHit ? " royale-crosshair--hit" : ""}`}>
|
||||
<i /><i /><i /><i />
|
||||
</div>
|
||||
|
||||
{recentHit && hit?.event.type === "hit" ? (
|
||||
<div className="royale-hit">{hit.event.eliminated ? "ELIMINATION" : `${hit.event.amount} DAMAGE`}</div>
|
||||
) : null}
|
||||
{pickup &&
|
||||
pickup.event.type === "pickup" &&
|
||||
client.tick - pickup.receivedTick < 45 ? (
|
||||
<div className="royale-pickup">
|
||||
ACQUIRED // {pickup.event.kind === "weapon" ? ROYALE_WEAPONS[pickup.event.weapon].name : `${pickup.event.kind} +${pickup.event.amount}`}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{local?.altitude ? (
|
||||
<section className="royale-drop">
|
||||
<small>PARACHUTE INSERTION</small>
|
||||
<strong>{Math.ceil(local.altitude)} M</strong>
|
||||
<span><i style={{ height: `${Math.min(100, local.altitude / 1.1)}%` }} /></span>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{local && !local.alive ? (
|
||||
<section className="royale-eliminated">
|
||||
<small>OPERATOR ELIMINATED</small>
|
||||
<strong>{client.world.resetTicks > 0 ? `NEW ISLAND IN ${Math.ceil(client.world.resetTicks / 30)}` : "SPECTATING AUTHORITY"}</strong>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{client.world.winnerId !== null ? (
|
||||
<section className="royale-winner">
|
||||
<small>LAST OPERATOR STANDING</small>
|
||||
<strong>{combatant(client.world.winnerId)} WINS ROUND {client.world.round}</strong>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<footer className="royale-hud">
|
||||
<div className="royale-vitals">
|
||||
<small>HEALTH</small>
|
||||
<strong>{local?.health ?? 0}</strong>
|
||||
<span><i style={{ width: `${local?.health ?? 0}%` }} /></span>
|
||||
<em>ARMOR {local?.armor ?? 0}</em>
|
||||
</div>
|
||||
<div className="royale-mission">
|
||||
<strong>2 KM PROCEDURAL ISLAND</strong>
|
||||
<small>PUBLIC TERRAIN SEED · PRIVATE LOOT · 31 AUTHORITY BOTS</small>
|
||||
</div>
|
||||
<div className="royale-weapon">
|
||||
<small>{weapon.name}</small>
|
||||
<strong>{local?.magazine ?? 0}<i>/ {local?.reserve ?? 0}</i></strong>
|
||||
<span>{local?.reloadTicks ? "RELOADING" : local?.weapon.toUpperCase() ?? "PISTOL"}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Radar({ world, local }: { world: RoyaleClientState; local: RoyalePlayerView | undefined }) {
|
||||
const scale = 150;
|
||||
return (
|
||||
<section className="royale-radar" aria-label="Local spatial radar">
|
||||
<div className="royale-radar__ring" />
|
||||
<i className="royale-radar__self" />
|
||||
{local
|
||||
? world.players
|
||||
.filter((player) => player.id !== local.id)
|
||||
.map((player) => (
|
||||
<i
|
||||
className="royale-radar__enemy"
|
||||
key={player.id}
|
||||
style={{
|
||||
left: `${50 + ((player.x - local.x) / scale) * 50}%`,
|
||||
top: `${50 + ((player.z - local.z) / scale) * 50}%`,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{local
|
||||
? world.loot.map((item) => (
|
||||
<i
|
||||
className="royale-radar__loot"
|
||||
key={item.id}
|
||||
style={{
|
||||
left: `${50 + ((item.x - local.x) / scale) * 50}%`,
|
||||
top: `${50 + ((item.z - local.z) / scale) * 50}%`,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
<small>150 M LOCAL CELL</small>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function combatant(id: number): string {
|
||||
return id === 0 ? "THE STORM" : id >= 30_000 ? `BOT ${id - 29_999}` : `PLAYER ${id}`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
return bytes >= 1_000 ? `${(bytes / 1_000).toFixed(1)}K` : `${bytes}B`;
|
||||
}
|
||||
142
apps/web/src/audio.ts
Normal file
142
apps/web/src/audio.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Weapon, type ShooterPerception, type WeaponId } from "@syncer/shared";
|
||||
|
||||
export class ArenaAudio {
|
||||
private context: AudioContext | null = null;
|
||||
private master: GainNode | null = null;
|
||||
|
||||
unlock(): void {
|
||||
if (!this.context) {
|
||||
this.context = new AudioContext();
|
||||
this.master = this.context.createGain();
|
||||
this.master.gain.value = 0.26;
|
||||
this.master.connect(this.context.destination);
|
||||
}
|
||||
void this.context.resume();
|
||||
}
|
||||
|
||||
play(event: ShooterPerception): void {
|
||||
if (!this.context || !this.master || this.context.state !== "running") return;
|
||||
switch (event.type) {
|
||||
case "shot":
|
||||
this.shot(event.weapon, 0, 0.48);
|
||||
if (event.impact === "player") this.tone(760, 520, 0.07, "square");
|
||||
break;
|
||||
case "sound":
|
||||
if (event.sound === "footstep") this.footstep(event.bearingRadians, event.intensity);
|
||||
else this.shot(event.weapon, event.bearingRadians, event.intensity);
|
||||
break;
|
||||
case "hit":
|
||||
this.tone(
|
||||
event.eliminated ? 520 : event.critical ? 920 : 760,
|
||||
event.eliminated ? 180 : event.critical ? 620 : 520,
|
||||
0.08,
|
||||
event.critical ? "square" : "triangle",
|
||||
);
|
||||
break;
|
||||
case "damage":
|
||||
this.damage(event.bearingRadians);
|
||||
break;
|
||||
case "elimination":
|
||||
this.tone(510, 180, 0.12, "triangle");
|
||||
break;
|
||||
case "pickup":
|
||||
this.tone(440, 860, 0.15, "sine");
|
||||
break;
|
||||
case "respawn":
|
||||
this.tone(220, 660, 0.28, "sine");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
void this.context?.close();
|
||||
this.context = null;
|
||||
this.master = null;
|
||||
}
|
||||
|
||||
private footstep(bearing: number, intensity: number): void {
|
||||
const context = this.context!;
|
||||
const now = context.currentTime;
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
oscillator.type = "sine";
|
||||
oscillator.frequency.setValueAtTime(105, now);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(48, now + 0.09);
|
||||
gain.gain.setValueAtTime(Math.max(0.012, intensity * 0.2), now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.13);
|
||||
oscillator.connect(gain).connect(this.panner(bearing));
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + 0.14);
|
||||
}
|
||||
|
||||
private shot(weapon: WeaponId, bearing: number, intensity: number): void {
|
||||
const context = this.context!;
|
||||
const now = context.currentTime;
|
||||
const panner = this.panner(bearing);
|
||||
const duration = weapon === Weapon.RailRifle ? 0.23 : 0.16;
|
||||
const noise = context.createBufferSource();
|
||||
const buffer = context.createBuffer(1, Math.floor(context.sampleRate * duration), context.sampleRate);
|
||||
const samples = buffer.getChannelData(0);
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
samples[index] = (Math.random() * 2 - 1) * Math.exp(-index / (samples.length * 0.18));
|
||||
}
|
||||
noise.buffer = buffer;
|
||||
const filter = context.createBiquadFilter();
|
||||
filter.type = weapon === Weapon.PulseRifle ? "bandpass" : "lowpass";
|
||||
filter.frequency.value = weapon === Weapon.PulseRifle ? 1_500 : weapon === Weapon.Scattergun ? 760 : 2_200;
|
||||
filter.Q.value = weapon === Weapon.PulseRifle ? 0.8 : 1.25;
|
||||
const gain = context.createGain();
|
||||
gain.gain.setValueAtTime(Math.max(0.02, intensity * (weapon === Weapon.Scattergun ? 0.62 : 0.46)), now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||||
noise.connect(filter).connect(gain).connect(panner);
|
||||
noise.start(now);
|
||||
|
||||
const thump = context.createOscillator();
|
||||
const thumpGain = context.createGain();
|
||||
const base = weapon === Weapon.Scattergun ? 88 : weapon === Weapon.RailRifle ? 210 : 145;
|
||||
thump.frequency.setValueAtTime(base, now);
|
||||
thump.frequency.exponentialRampToValueAtTime(45, now + 0.11);
|
||||
thumpGain.gain.setValueAtTime(intensity * 0.24, now);
|
||||
thumpGain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
|
||||
thump.connect(thumpGain).connect(panner);
|
||||
thump.start(now);
|
||||
thump.stop(now + 0.13);
|
||||
}
|
||||
|
||||
private damage(bearing: number): void {
|
||||
const context = this.context!;
|
||||
const now = context.currentTime;
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
oscillator.type = "sawtooth";
|
||||
oscillator.frequency.setValueAtTime(72, now);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(34, now + 0.2);
|
||||
gain.gain.setValueAtTime(0.22, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.22);
|
||||
oscillator.connect(gain).connect(this.panner(bearing));
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + 0.23);
|
||||
}
|
||||
|
||||
private tone(from: number, to: number, duration: number, type: OscillatorType): void {
|
||||
const context = this.context!;
|
||||
const now = context.currentTime;
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
oscillator.type = type;
|
||||
oscillator.frequency.setValueAtTime(from, now);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(to, now + duration);
|
||||
gain.gain.setValueAtTime(0.09, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||||
oscillator.connect(gain).connect(this.master!);
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + duration + 0.01);
|
||||
}
|
||||
|
||||
private panner(bearing: number): StereoPannerNode {
|
||||
const panner = this.context!.createStereoPanner();
|
||||
panner.pan.value = Math.max(-1, Math.min(1, Math.sin(bearing)));
|
||||
panner.connect(this.master!);
|
||||
return panner;
|
||||
}
|
||||
}
|
||||
17
apps/web/src/main.tsx
Normal file
17
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App.js";
|
||||
import "./styles.css";
|
||||
|
||||
const root = document.querySelector<HTMLDivElement>("#root");
|
||||
|
||||
if (!root) {
|
||||
throw new Error("Root element was not found");
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
602
apps/web/src/styles.css
Normal file
602
apps/web/src/styles.css
Normal file
@@ -0,0 +1,602 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&display=swap");
|
||||
|
||||
:root {
|
||||
font-family: "Barlow Condensed", system-ui, sans-serif;
|
||||
color: #eaf7fb;
|
||||
background: #05080c;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #root { width: 100%; height: 100%; margin: 0; overflow: hidden; }
|
||||
button { font: inherit; }
|
||||
|
||||
.game-switcher {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
top: 78px;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
padding: 3px;
|
||||
border: 1px solid rgb(99 225 255 / 22%);
|
||||
background: rgb(3 11 16 / 78%);
|
||||
backdrop-filter: blur(10px);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.game-switcher button {
|
||||
padding: 5px 10px;
|
||||
border: 0;
|
||||
color: #5f7f8a;
|
||||
background: transparent;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: .48rem;
|
||||
letter-spacing: .12em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.game-switcher button:hover { color: #caeff7; }
|
||||
.game-switcher .is-active { color: #b9f4ff; background: rgb(75 213 243 / 14%); }
|
||||
|
||||
.game {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 320px;
|
||||
overflow: hidden;
|
||||
background: #05080c;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.viewport, .viewport canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.viewport canvas { display: block; }
|
||||
|
||||
.vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(3 7 11 / 52%), transparent 20%, transparent 78%, rgb(2 5 8 / 72%)),
|
||||
radial-gradient(circle, transparent 48%, rgb(0 0 0 / 44%) 100%);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: start;
|
||||
padding: 24px 28px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brand { display: flex; align-items: center; gap: 11px; }
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
width: 46px;
|
||||
height: 40px;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(104 225 255 / 55%);
|
||||
color: #7ce8ff;
|
||||
background: rgb(5 19 27 / 70%);
|
||||
box-shadow: inset 0 0 20px rgb(0 185 230 / 12%);
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
clip-path: polygon(0 0, 88% 0, 100% 24%, 100% 100%, 12% 100%, 0 76%);
|
||||
}
|
||||
.brand strong { display: block; font-size: 1rem; letter-spacing: .14em; }
|
||||
.brand small { color: #72909b; font-family: "IBM Plex Mono", monospace; font-size: .56rem; letter-spacing: .12em; }
|
||||
|
||||
.match-clock { min-width: 124px; padding: 6px 22px 9px; text-align: center; background: linear-gradient(90deg, transparent, rgb(7 20 28 / 78%) 18%, rgb(7 20 28 / 78%) 82%, transparent); }
|
||||
.match-clock small { display: block; color: #7698a4; font-family: "IBM Plex Mono", monospace; font-size: .53rem; letter-spacing: .15em; }
|
||||
.match-clock strong { color: #e8faff; font-family: "IBM Plex Mono", monospace; font-size: 1.32rem; letter-spacing: .12em; }
|
||||
|
||||
.connection { justify-self: end; display: grid; grid-template-columns: auto auto; align-items: center; column-gap: 8px; padding: 8px 12px; border-right: 2px solid #62e0ff; background: linear-gradient(90deg, transparent, rgb(4 19 27 / 75%)); text-align: right; }
|
||||
.connection i { grid-row: 1 / span 2; width: 7px; height: 7px; border-radius: 50%; background: #66e4ff; box-shadow: 0 0 12px #4bdcff; }
|
||||
.connection span { color: #8eeaff; font-size: .66rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.connection b { color: #7a939c; font-family: "IBM Plex Mono", monospace; font-size: .53rem; font-weight: 500; }
|
||||
.connection--connecting, .connection--reconnecting { border-color: #ffa84e; }
|
||||
.connection--connecting i, .connection--reconnecting i { background: #ffa84e; box-shadow: 0 0 12px #ffa84e; }
|
||||
.connection--connecting span, .connection--reconnecting span { color: #ffc17d; }
|
||||
|
||||
.telemetry { position: absolute; top: 104px; left: 28px; display: flex; flex-direction: column; gap: 1px; pointer-events: none; }
|
||||
.telemetry > div { display: grid; min-width: 112px; grid-template-columns: 45px 1fr; gap: 8px; padding: 5px 9px; border-left: 1px solid rgb(92 213 239 / 40%); background: linear-gradient(90deg, rgb(3 15 21 / 74%), transparent); }
|
||||
.telemetry small { color: #63818c; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .1em; }
|
||||
.telemetry strong { color: #a9c2ca; font-family: "IBM Plex Mono", monospace; font-size: .54rem; font-weight: 500; }
|
||||
|
||||
.kill-feed { position: absolute; top: 105px; right: 28px; display: flex; align-items: flex-end; flex-direction: column; gap: 5px; pointer-events: none; }
|
||||
.kill-feed div { display: flex; align-items: center; gap: 8px; padding: 5px 9px; border-right: 1px solid rgb(255 153 63 / 55%); background: linear-gradient(90deg, transparent, rgb(20 12 7 / 75%)); color: #afc1c7; font-family: "IBM Plex Mono", monospace; font-size: .58rem; }
|
||||
.kill-feed b { color: #ff9c45; font-size: .48rem; }
|
||||
|
||||
.leaderboard {
|
||||
position: absolute;
|
||||
top: 210px;
|
||||
right: 28px;
|
||||
width: 178px;
|
||||
padding: 9px 0 5px;
|
||||
border-top: 1px solid rgb(94 220 247 / 35%);
|
||||
background: linear-gradient(90deg, transparent, rgb(3 14 20 / 58%));
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaderboard > small {
|
||||
display: block;
|
||||
margin: 0 8px 6px;
|
||||
color: #6b8a95;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: .48rem;
|
||||
letter-spacing: .14em;
|
||||
text-align: right;
|
||||
}
|
||||
.leaderboard > div {
|
||||
display: grid;
|
||||
grid-template-columns: 17px 1fr 18px;
|
||||
gap: 6px;
|
||||
padding: 3px 8px;
|
||||
color: #8ca4ad;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: .52rem;
|
||||
}
|
||||
.leaderboard > div b { color: #506b75; font-weight: 500; }
|
||||
.leaderboard > div strong { color: #d7eef5; text-align: right; }
|
||||
.leaderboard .leaderboard__local {
|
||||
color: #9aeeff;
|
||||
background: linear-gradient(90deg, transparent, rgb(32 168 201 / 15%));
|
||||
}
|
||||
|
||||
.crosshair { position: absolute; top: 50%; left: 50%; width: 40px; height: 40px; transform: translate(-50%, -50%); pointer-events: none; }
|
||||
.crosshair::after { position: absolute; top: 18px; left: 18px; width: 4px; height: 4px; border: 1px solid rgb(225 248 255 / 90%); border-radius: 50%; content: ""; box-shadow: 0 0 8px rgb(80 223 255 / 80%); }
|
||||
.crosshair i { position: absolute; display: block; background: rgb(224 249 255 / 88%); box-shadow: 0 0 4px rgb(46 214 255 / 60%); }
|
||||
.crosshair i:nth-child(1), .crosshair i:nth-child(2) { top: 19px; width: 9px; height: 1px; }
|
||||
.crosshair i:nth-child(1) { left: 2px; }
|
||||
.crosshair i:nth-child(2) { right: 2px; }
|
||||
.crosshair i:nth-child(3), .crosshair i:nth-child(4) { left: 19px; width: 1px; height: 9px; }
|
||||
.crosshair i:nth-child(3) { top: 2px; }
|
||||
.crosshair i:nth-child(4) { bottom: 2px; }
|
||||
.crosshair--damage { animation: crosshair-hit .18s ease-out; }
|
||||
@keyframes crosshair-hit { 50% { transform: translate(-50%, -50%) scale(1.5); filter: sepia(1) saturate(4); } }
|
||||
|
||||
.hit-confirm {
|
||||
position: absolute;
|
||||
top: calc(50% + 32px);
|
||||
left: 50%;
|
||||
color: #d9f9ff;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: .48rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .18em;
|
||||
text-shadow: 0 0 9px #66e5ff;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sound-indicator {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 210px;
|
||||
height: 210px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sound-indicator i {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: calc(50% - 10px);
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
background: #ffb761;
|
||||
box-shadow: 0 0 10px #ff9e3e;
|
||||
}
|
||||
|
||||
.deploy {
|
||||
position: absolute;
|
||||
z-index: 8;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
width: min(400px, calc(100% - 40px));
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 21px 28px;
|
||||
border: 1px solid rgb(99 225 255 / 50%);
|
||||
color: #dff9ff;
|
||||
background: rgb(3 14 20 / 86%);
|
||||
box-shadow: 0 0 80px rgb(0 178 221 / 12%), inset 0 0 28px rgb(0 178 221 / 6%);
|
||||
clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
|
||||
cursor: crosshair;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.deploy span { font-size: 1.25rem; font-weight: 800; letter-spacing: .2em; }
|
||||
.deploy small { color: #6f939f; font-family: "IBM Plex Mono", monospace; font-size: .55rem; letter-spacing: .06em; }
|
||||
.deploy:hover { border-color: #abf1ff; background: rgb(6 27 37 / 92%); }
|
||||
|
||||
.respawn-panel { position: absolute; top: 50%; left: 50%; display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 16px 38px; border-block: 1px solid rgb(255 118 70 / 55%); background: linear-gradient(90deg, transparent, rgb(31 8 5 / 75%), transparent); transform: translate(-50%, -50%); pointer-events: none; }
|
||||
.respawn-panel small { color: #c06c53; font-family: "IBM Plex Mono", monospace; font-size: .58rem; letter-spacing: .18em; }
|
||||
.respawn-panel strong { color: #ffbea6; font-size: 1.4rem; letter-spacing: .15em; }
|
||||
|
||||
.killcam-panel {
|
||||
position: absolute;
|
||||
bottom: 126px;
|
||||
left: 50%;
|
||||
width: min(560px, calc(100% - 48px));
|
||||
padding: 12px 15px 10px;
|
||||
border-top: 1px solid rgb(255 164 79 / 62%);
|
||||
border-bottom: 1px solid rgb(100 222 247 / 28%);
|
||||
color: #e8f8fc;
|
||||
background: linear-gradient(90deg, transparent, rgb(7 17 23 / 90%) 11%, rgb(7 17 23 / 90%) 89%, transparent);
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.killcam-panel__title { display: flex; align-items: end; justify-content: space-between; gap: 18px; }
|
||||
.killcam-panel__title small { color: #8b6f5b; font-family: "IBM Plex Mono", monospace; font-size: .47rem; letter-spacing: .13em; }
|
||||
.killcam-panel__title strong { color: #ffc28c; font-size: .87rem; letter-spacing: .16em; text-align: right; text-shadow: 0 0 14px rgb(255 134 48 / 40%); }
|
||||
.killcam-panel__timeline { overflow: hidden; height: 3px; margin: 8px 0 6px; background: rgb(113 151 162 / 22%); transform: skewX(-22deg); }
|
||||
.killcam-panel__timeline i { display: block; height: 100%; background: linear-gradient(90deg, #ff8e48, #7be5ff); box-shadow: 0 0 12px rgb(99 223 255 / 55%); }
|
||||
.killcam-panel__meta { display: flex; justify-content: space-between; gap: 15px; color: #68838d; font-family: "IBM Plex Mono", monospace; font-size: .46rem; letter-spacing: .09em; }
|
||||
|
||||
.hud { position: absolute; right: 28px; bottom: 24px; left: 28px; display: grid; grid-template-columns: minmax(150px, .7fr) 1fr minmax(210px, .85fr) auto; align-items: end; gap: 30px; pointer-events: none; }
|
||||
.health-block small, .weapon-block small { color: #77919a; font-family: "IBM Plex Mono", monospace; font-size: .53rem; letter-spacing: .15em; }
|
||||
.health-block strong { display: block; margin-top: -2px; color: #d8f8ff; font-family: "IBM Plex Mono", monospace; font-size: 2.3rem; font-weight: 500; line-height: 1; }
|
||||
.health-track { overflow: hidden; width: 150px; height: 3px; margin-top: 7px; background: rgb(117 150 160 / 28%); transform: skewX(-24deg); }
|
||||
.health-track i { display: block; height: 100%; background: #65e2ff; box-shadow: 0 0 12px #42d8fb; transition: width .15s; }
|
||||
|
||||
.mission-note { align-self: end; padding-bottom: 1px; color: #92abb3; text-align: center; }
|
||||
.mission-note span { display: block; font-size: .72rem; font-weight: 700; letter-spacing: .16em; }
|
||||
.mission-note small { color: #5f7881; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .08em; }
|
||||
|
||||
.weapon-block { display: flex; align-items: end; justify-content: flex-end; gap: 18px; padding-right: 16px; border-right: 1px solid rgb(93 218 245 / 35%); text-align: right; }
|
||||
.weapon-block div > span { display: block; margin-top: 4px; color: #5f7c86; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .08em; }
|
||||
.weapon-block strong { color: #effcff; font-family: "IBM Plex Mono", monospace; font-size: 2.5rem; font-weight: 500; line-height: .9; }
|
||||
.weapon-block strong i { color: #75909a; font-size: .72rem; font-style: normal; }
|
||||
.score-block { display: flex; gap: 12px; }
|
||||
.score-block span { display: flex; flex-direction: column; color: #d5edf3; font-family: "IBM Plex Mono", monospace; font-size: 1.2rem; }
|
||||
.score-block small { color: #66838e; font-size: .5rem; }
|
||||
|
||||
.damage-flash { position: absolute; inset: 0; pointer-events: none; background: radial-gradient(circle, transparent 15%, rgb(158 22 7 / 28%)); animation: damage .3s ease-out forwards; }
|
||||
@keyframes damage { from { opacity: 1; } to { opacity: 0; } }
|
||||
|
||||
.flux-game {
|
||||
--team: #66e6ff;
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 320px;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
overflow: hidden;
|
||||
color: #e8faff;
|
||||
background:
|
||||
radial-gradient(circle at 50% 45%, rgb(40 137 161 / 16%), transparent 26%),
|
||||
radial-gradient(circle at 8% 50%, rgb(35 198 234 / 10%), transparent 25%),
|
||||
radial-gradient(circle at 92% 50%, rgb(255 128 48 / 11%), transparent 25%),
|
||||
#04080c;
|
||||
user-select: none;
|
||||
}
|
||||
.flux-game--orange { --team: #ff9a4e; }
|
||||
.flux-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: .18;
|
||||
background-image:
|
||||
linear-gradient(rgb(102 226 255 / 16%) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(102 226 255 / 16%) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
mask-image: radial-gradient(circle at center, black, transparent 75%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.flux-header {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: start;
|
||||
padding: 26px 34px;
|
||||
}
|
||||
.flux-header > div:first-child small,
|
||||
.flux-identity small,
|
||||
.flux-energy small {
|
||||
display: block;
|
||||
color: #66818a;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: .48rem;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.flux-header > div:first-child strong { display: block; color: #dffbff; font-size: 1.32rem; letter-spacing: .2em; }
|
||||
.flux-score { display: grid; grid-template-columns: 54px auto 54px; align-items: center; gap: 15px; text-align: center; }
|
||||
.flux-score b { color: #72eaff; font-family: "IBM Plex Mono", monospace; font-size: 1.6rem; font-weight: 500; text-shadow: 0 0 18px rgb(90 225 255 / 50%); }
|
||||
.flux-score b:last-child { color: #ffa05d; text-shadow: 0 0 18px rgb(255 126 54 / 50%); }
|
||||
.flux-score span { padding: 6px 18px; border-inline: 1px solid rgb(119 210 229 / 24%); color: #7d99a2; font-family: "IBM Plex Mono", monospace; font-size: .53rem; letter-spacing: .13em; }
|
||||
.flux-connection { justify-self: end; display: grid; grid-template-columns: auto auto; align-items: center; column-gap: 8px; text-align: right; }
|
||||
.flux-connection i { grid-row: 1 / span 2; width: 7px; height: 7px; border-radius: 50%; background: #66e4ff; box-shadow: 0 0 12px #4bdcff; }
|
||||
.flux-connection span { color: #99eefd; font-size: .65rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.flux-connection small { color: #627e87; font-family: "IBM Plex Mono", monospace; font-size: .48rem; }
|
||||
.flux-connection--connecting i, .flux-connection--reconnecting i { background: #ff9c50; box-shadow: 0 0 12px #ff9c50; }
|
||||
|
||||
.flux-arena {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
width: min(1200px, calc(100% - 64px));
|
||||
grid-template-columns: 145px 1fr 145px;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
margin: auto;
|
||||
}
|
||||
.flux-field { display: grid; height: 230px; grid-template-columns: 80px 1fr 80px; align-items: center; }
|
||||
.flux-lane {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
background: linear-gradient(90deg, #4be3ff, rgb(114 184 199 / 28%) 48%, rgb(114 184 199 / 28%) 52%, #ff8c42);
|
||||
box-shadow: 0 0 24px rgb(75 224 255 / 10%);
|
||||
}
|
||||
.flux-lane::before,
|
||||
.flux-lane::after { position: absolute; top: -42px; width: 1px; height: 90px; background: rgb(107 191 210 / 18%); content: ""; }
|
||||
.flux-lane::before { left: 25%; }
|
||||
.flux-lane::after { right: 25%; }
|
||||
.flux-midline { position: absolute; top: -72px; left: 50%; width: 1px; height: 150px; background: linear-gradient(transparent, rgb(183 239 250 / 42%), transparent); }
|
||||
.flux-core {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 74px;
|
||||
height: 74px;
|
||||
border: 1px solid #d6f9ff;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, #e9fdff 0 8%, #6eeaff 10%, rgb(46 206 239 / 22%) 36%, transparent 38%);
|
||||
box-shadow: 0 0 18px #62e6ff, 0 0 65px rgb(60 219 251 / 50%);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: left 60ms linear;
|
||||
}
|
||||
.flux-core i { position: absolute; inset: -10px; border: 1px solid rgb(135 236 255 / 35%); border-radius: 50%; animation: flux-spin 4s linear infinite; }
|
||||
.flux-core i:nth-child(2) { inset: -18px 8px; transform: rotate(64deg); animation-duration: 2.8s; }
|
||||
.flux-core i:nth-child(3) { inset: 8px -18px; transform: rotate(-64deg); animation-duration: 3.4s; }
|
||||
@keyframes flux-spin { to { transform: rotate(360deg); } }
|
||||
.flux-gate { position: relative; height: 170px; border: 1px solid; opacity: .8; }
|
||||
.flux-gate span { position: absolute; top: 50%; width: 110px; font-family: "IBM Plex Mono", monospace; font-size: .46rem; letter-spacing: .12em; text-align: center; transform: translateY(-50%) rotate(-90deg); }
|
||||
.flux-gate--cyan { border-color: #53def9; border-right: 0; box-shadow: inset 18px 0 30px rgb(57 208 239 / 9%); }
|
||||
.flux-gate--cyan span { left: -28px; color: #55dffb; }
|
||||
.flux-gate--orange { border-color: #ff8c47; border-left: 0; box-shadow: inset -18px 0 30px rgb(255 125 48 / 9%); }
|
||||
.flux-gate--orange span { right: -28px; color: #ff9554; }
|
||||
|
||||
.flux-roster { display: flex; flex-direction: column; gap: 7px; }
|
||||
.flux-roster > small { margin-bottom: 5px; color: #526e77; font-family: "IBM Plex Mono", monospace; font-size: .45rem; letter-spacing: .13em; text-transform: uppercase; }
|
||||
.flux-roster > div { display: grid; grid-template-columns: 9px 1fr auto; align-items: center; gap: 7px; padding: 7px 8px; border-left: 1px solid rgb(88 211 238 / 24%); color: #77949d; background: linear-gradient(90deg, rgb(34 135 158 / 9%), transparent); font-family: "IBM Plex Mono", monospace; font-size: .48rem; }
|
||||
.flux-roster--orange { text-align: right; }
|
||||
.flux-roster--orange > div { grid-template-columns: auto 1fr 9px; border-right: 1px solid rgb(255 142 75 / 24%); border-left: 0; background: linear-gradient(90deg, transparent, rgb(159 77 34 / 9%)); }
|
||||
.flux-roster--orange > div i { grid-column: 3; grid-row: 1; background: #70462f; }
|
||||
.flux-roster--orange > div span { grid-column: 2; grid-row: 1; }
|
||||
.flux-roster--orange > div b { grid-column: 1; grid-row: 1; }
|
||||
.flux-roster > div i { width: 6px; height: 6px; border-radius: 50%; background: #376977; }
|
||||
.flux-roster > div b { color: #465f68; font-size: .4rem; font-weight: 500; }
|
||||
.flux-roster > .flux-player--active { color: #baf7ff; }
|
||||
.flux-roster > .flux-player--active i { background: #6ceaff; box-shadow: 0 0 10px #5ee5ff; }
|
||||
.flux-roster--orange > .flux-player--active i { background: #ff9d5d; box-shadow: 0 0 10px #ff8c45; }
|
||||
.flux-roster > .flux-player--local { border-color: var(--team); background: linear-gradient(90deg, rgb(64 208 239 / 18%), transparent); }
|
||||
|
||||
.flux-event { position: absolute; z-index: 4; top: 26%; left: 50%; padding: 8px 24px; border-block: 1px solid; background: linear-gradient(90deg, transparent, rgb(4 17 23 / 88%) 18%, rgb(4 17 23 / 88%) 82%, transparent); font-family: "IBM Plex Mono", monospace; font-size: .58rem; letter-spacing: .13em; transform: translateX(-50%); pointer-events: none; }
|
||||
.flux-event--cyan { border-color: rgb(84 223 250 / 50%); color: #8bedff; }
|
||||
.flux-event--orange { border-color: rgb(255 144 79 / 50%); color: #ffad76; }
|
||||
|
||||
.flux-controls {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 36px;
|
||||
padding: 24px 34px 30px;
|
||||
border-top: 1px solid rgb(109 205 225 / 12%);
|
||||
background: linear-gradient(180deg, transparent, rgb(2 8 12 / 75%));
|
||||
}
|
||||
.flux-identity strong { display: block; margin: 3px 0; color: var(--team); font-size: .88rem; letter-spacing: .13em; }
|
||||
.flux-identity span { color: #536d76; font-family: "IBM Plex Mono", monospace; font-size: .46rem; }
|
||||
.flux-thrust { min-width: 250px; padding: 13px 28px; border: 1px solid color-mix(in srgb, var(--team) 48%, transparent); color: #dffaff; background: rgb(6 23 30 / 84%); cursor: pointer; clip-path: polygon(10px 0, calc(100% - 10px) 0, 100% 10px, 100% calc(100% - 10px), calc(100% - 10px) 100%, 10px 100%, 0 calc(100% - 10px), 0 10px); }
|
||||
.flux-thrust small { display: block; color: #5e7d87; font-family: "IBM Plex Mono", monospace; font-size: .43rem; letter-spacing: .11em; }
|
||||
.flux-thrust strong { display: block; margin-top: 3px; font-size: .92rem; letter-spacing: .16em; }
|
||||
.flux-thrust:hover { border-color: var(--team); }
|
||||
.flux-thrust--active { color: #fff; background: color-mix(in srgb, var(--team) 20%, rgb(5 21 28)); box-shadow: 0 0 28px color-mix(in srgb, var(--team) 25%, transparent), inset 0 0 24px color-mix(in srgb, var(--team) 14%, transparent); }
|
||||
.flux-energy { justify-self: end; width: 245px; }
|
||||
.flux-energy > div { display: flex; align-items: end; justify-content: space-between; }
|
||||
.flux-energy strong { color: var(--team); font-family: "IBM Plex Mono", monospace; font-size: 1.1rem; font-weight: 500; }
|
||||
.flux-energy > span { display: block; overflow: hidden; height: 4px; margin: 7px 0; background: rgb(119 155 165 / 20%); transform: skewX(-20deg); }
|
||||
.flux-energy > span i { display: block; height: 100%; background: var(--team); box-shadow: 0 0 13px var(--team); transition: width 80ms linear; }
|
||||
.flux-energy em { display: block; color: #4c6871; font-family: "IBM Plex Mono", monospace; font-size: .43rem; font-style: normal; letter-spacing: .08em; text-align: right; }
|
||||
|
||||
.royale-game {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
color: #f1f5e9;
|
||||
background: #17291f;
|
||||
user-select: none;
|
||||
}
|
||||
.royale-viewport,
|
||||
.royale-viewport canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.royale-viewport canvas { display: block; }
|
||||
.royale-grade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(7 14 10 / 54%), transparent 24%, transparent 70%, rgb(4 9 7 / 70%)),
|
||||
radial-gradient(circle, transparent 52%, rgb(3 7 5 / 44%));
|
||||
pointer-events: none;
|
||||
}
|
||||
.royale-deploy {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
width: min(430px, calc(100% - 36px));
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
padding: 24px 34px;
|
||||
border: 1px solid rgb(198 231 161 / 55%);
|
||||
color: #efffdc;
|
||||
background: rgb(8 19 12 / 86%);
|
||||
box-shadow: 0 0 80px rgb(99 177 67 / 17%), inset 0 0 34px rgb(124 202 82 / 7%);
|
||||
clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
|
||||
cursor: crosshair;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.royale-deploy small,
|
||||
.royale-deploy span { color: #84977b; font-family: "IBM Plex Mono", monospace; font-size: .5rem; letter-spacing: .1em; }
|
||||
.royale-deploy strong { font-size: 1.55rem; letter-spacing: .24em; }
|
||||
.royale-deploy:hover { border-color: #e4ffc6; background: rgb(12 30 18 / 92%); }
|
||||
|
||||
.royale-header {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: start;
|
||||
padding: 24px 30px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.royale-brand small { display: block; color: #899987; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .13em; }
|
||||
.royale-brand strong { display: block; margin-top: 2px; color: #efffda; font-size: 1.13rem; letter-spacing: .2em; text-shadow: 0 0 20px rgb(151 214 98 / 24%); }
|
||||
.royale-status { display: flex; align-items: center; gap: 1px; background: rgb(8 19 12 / 65%); }
|
||||
.royale-status span { padding: 7px 14px; border-inline: 1px solid rgb(208 232 187 / 11%); color: #9dab96; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .1em; }
|
||||
.royale-status b { color: #efffd8; font-size: .74rem; font-weight: 500; }
|
||||
.royale-status .is-danger { color: #ff8a65; }
|
||||
.royale-live { justify-self: end; display: grid; grid-template-columns: auto auto; align-items: center; column-gap: 8px; text-align: right; }
|
||||
.royale-live i { grid-row: 1 / span 2; width: 7px; height: 7px; border-radius: 50%; background: #b7f27c; box-shadow: 0 0 12px #a7eb67; }
|
||||
.royale-live span { color: #c9f4a4; font-size: .64rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.royale-live small { color: #758172; font-family: "IBM Plex Mono", monospace; font-size: .46rem; }
|
||||
.royale-live--connecting i,
|
||||
.royale-live--reconnecting i { background: #ffad68; box-shadow: 0 0 12px #ff9a4d; }
|
||||
|
||||
.royale-stream {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 108px;
|
||||
left: 30px;
|
||||
width: 174px;
|
||||
padding: 9px 10px;
|
||||
border-left: 1px solid rgb(187 226 153 / 35%);
|
||||
background: linear-gradient(90deg, rgb(8 18 12 / 80%), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.royale-stream > small { display: block; margin-bottom: 6px; color: #81917d; font-family: "IBM Plex Mono", monospace; font-size: .45rem; letter-spacing: .14em; }
|
||||
.royale-stream div { display: grid; grid-template-columns: 58px 1fr; padding: 2px 0; font-family: "IBM Plex Mono", monospace; font-size: .45rem; }
|
||||
.royale-stream span { color: #60705e; }
|
||||
.royale-stream b { color: #b3c8a7; font-weight: 500; }
|
||||
|
||||
.royale-radar {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 106px;
|
||||
right: 30px;
|
||||
overflow: hidden;
|
||||
width: 148px;
|
||||
height: 148px;
|
||||
border: 1px solid rgb(190 226 157 / 35%);
|
||||
border-radius: 50%;
|
||||
background:
|
||||
linear-gradient(rgb(176 215 144 / 8%) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(176 215 144 / 8%) 1px, transparent 1px),
|
||||
rgb(5 15 9 / 74%);
|
||||
background-size: 18px 18px;
|
||||
box-shadow: inset 0 0 30px rgb(83 141 61 / 12%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.royale-radar__ring { position: absolute; inset: 24%; border: 1px solid rgb(194 231 164 / 15%); border-radius: 50%; }
|
||||
.royale-radar i { position: absolute; display: block; transform: translate(-50%, -50%); }
|
||||
.royale-radar__self { top: 50%; left: 50%; width: 7px; height: 7px; border: 1px solid #ecffd6; transform: translate(-50%, -50%) rotate(45deg) !important; box-shadow: 0 0 8px #beef8f; }
|
||||
.royale-radar__enemy { width: 5px; height: 5px; border-radius: 50%; background: #ff7957; box-shadow: 0 0 7px #ff5d3d; }
|
||||
.royale-radar__loot { width: 3px; height: 3px; background: #78dcff; box-shadow: 0 0 5px #68d7ff; }
|
||||
.royale-radar small { position: absolute; right: 0; bottom: 11px; left: 0; color: #6f806c; font-family: "IBM Plex Mono", monospace; font-size: .39rem; letter-spacing: .11em; text-align: center; }
|
||||
|
||||
.royale-feed { position: absolute; z-index: 4; top: 274px; right: 30px; display: flex; align-items: flex-end; flex-direction: column; gap: 4px; pointer-events: none; }
|
||||
.royale-feed div { display: flex; gap: 7px; padding: 4px 8px; border-right: 1px solid rgb(255 137 92 / 35%); color: #b7c1b2; background: linear-gradient(90deg, transparent, rgb(12 17 12 / 68%)); font-family: "IBM Plex Mono", monospace; font-size: .43rem; }
|
||||
.royale-feed b { color: #ff9068; font-size: .38rem; }
|
||||
|
||||
.royale-crosshair { position: absolute; z-index: 3; top: 50%; left: 50%; width: 42px; height: 42px; transform: translate(-50%, -50%); pointer-events: none; }
|
||||
.royale-crosshair::after { position: absolute; top: 19px; left: 19px; width: 4px; height: 4px; border: 1px solid #f4ffe5; border-radius: 50%; content: ""; }
|
||||
.royale-crosshair i { position: absolute; background: rgb(241 255 224 / 88%); box-shadow: 0 0 5px rgb(177 239 113 / 65%); }
|
||||
.royale-crosshair i:nth-child(1), .royale-crosshair i:nth-child(2) { top: 20px; width: 9px; height: 1px; }
|
||||
.royale-crosshair i:nth-child(1) { left: 1px; }
|
||||
.royale-crosshair i:nth-child(2) { right: 1px; }
|
||||
.royale-crosshair i:nth-child(3), .royale-crosshair i:nth-child(4) { left: 20px; width: 1px; height: 9px; }
|
||||
.royale-crosshair i:nth-child(3) { top: 1px; }
|
||||
.royale-crosshair i:nth-child(4) { bottom: 1px; }
|
||||
.royale-crosshair--hit { animation: royale-hit .16s ease-out; }
|
||||
@keyframes royale-hit { 50% { transform: translate(-50%, -50%) scale(1.55); filter: sepia(1) saturate(5); } }
|
||||
.royale-hit,
|
||||
.royale-pickup { position: absolute; z-index: 4; left: 50%; color: #f0ffdf; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .15em; text-shadow: 0 0 9px #a9e877; transform: translateX(-50%); pointer-events: none; }
|
||||
.royale-hit { top: calc(50% + 33px); }
|
||||
.royale-pickup { bottom: 126px; color: #9fe7ff; text-shadow: 0 0 9px #55cfff; }
|
||||
|
||||
.royale-drop { position: absolute; z-index: 4; top: 50%; right: 33px; display: grid; grid-template-columns: auto 5px; gap: 4px 12px; color: #dff8ff; transform: translateY(-50%); pointer-events: none; }
|
||||
.royale-drop small { color: #7d989c; font-family: "IBM Plex Mono", monospace; font-size: .42rem; letter-spacing: .12em; }
|
||||
.royale-drop strong { grid-column: 1; font-family: "IBM Plex Mono", monospace; font-size: 1.4rem; font-weight: 500; text-align: right; }
|
||||
.royale-drop span { grid-column: 2; grid-row: 1 / span 2; position: relative; overflow: hidden; width: 4px; height: 60px; background: rgb(137 183 190 / 22%); }
|
||||
.royale-drop i { position: absolute; right: 0; bottom: 0; left: 0; background: #9cecff; box-shadow: 0 0 9px #6edfff; }
|
||||
|
||||
.royale-eliminated,
|
||||
.royale-winner { position: absolute; z-index: 5; top: 50%; left: 50%; display: flex; flex-direction: column; align-items: center; gap: 5px; width: min(600px, calc(100% - 40px)); padding: 18px; border-block: 1px solid rgb(255 116 76 / 55%); background: linear-gradient(90deg, transparent, rgb(30 11 7 / 83%), transparent); transform: translate(-50%, -50%); pointer-events: none; }
|
||||
.royale-eliminated small,
|
||||
.royale-winner small { color: #bd735e; font-family: "IBM Plex Mono", monospace; font-size: .5rem; letter-spacing: .17em; }
|
||||
.royale-eliminated strong,
|
||||
.royale-winner strong { color: #ffd0bc; font-size: 1.25rem; letter-spacing: .18em; }
|
||||
.royale-winner { border-color: rgb(184 239 129 / 58%); background: linear-gradient(90deg, transparent, rgb(12 29 9 / 86%), transparent); }
|
||||
.royale-winner small { color: #8cad72; }
|
||||
.royale-winner strong { color: #e8ffd0; }
|
||||
.royale-damage { position: absolute; inset: 0; z-index: 2; background: radial-gradient(circle, transparent 22%, rgb(145 22 10 / 32%)); animation: damage .32s ease-out forwards; pointer-events: none; }
|
||||
|
||||
.royale-hud { position: absolute; z-index: 4; right: 30px; bottom: 24px; left: 30px; display: grid; grid-template-columns: 230px 1fr 260px; align-items: end; gap: 28px; pointer-events: none; }
|
||||
.royale-vitals small,
|
||||
.royale-weapon small { color: #83907d; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .13em; }
|
||||
.royale-vitals strong { display: block; color: #efffde; font-family: "IBM Plex Mono", monospace; font-size: 2.15rem; font-weight: 500; line-height: 1; }
|
||||
.royale-vitals > span { display: block; overflow: hidden; width: 180px; height: 4px; margin: 6px 0; background: rgb(155 174 144 / 24%); transform: skewX(-22deg); }
|
||||
.royale-vitals > span i { display: block; height: 100%; background: #a8ed72; box-shadow: 0 0 12px #9ce362; }
|
||||
.royale-vitals em { color: #7fa0ad; font-family: "IBM Plex Mono", monospace; font-size: .45rem; font-style: normal; }
|
||||
.royale-mission { text-align: center; }
|
||||
.royale-mission strong { display: block; color: #d4dfca; font-size: .7rem; letter-spacing: .17em; }
|
||||
.royale-mission small { color: #71806d; font-family: "IBM Plex Mono", monospace; font-size: .43rem; letter-spacing: .08em; }
|
||||
.royale-weapon { justify-self: end; min-width: 240px; padding-right: 14px; border-right: 1px solid rgb(194 225 166 / 30%); text-align: right; }
|
||||
.royale-weapon strong { display: block; color: #f6ffec; font-family: "IBM Plex Mono", monospace; font-size: 2.35rem; font-weight: 500; line-height: .9; }
|
||||
.royale-weapon strong i { color: #81907d; font-size: .72rem; font-style: normal; }
|
||||
.royale-weapon span { color: #6e8069; font-family: "IBM Plex Mono", monospace; font-size: .45rem; letter-spacing: .1em; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.game-switcher { top: 70px; }
|
||||
.topbar { padding: 16px; grid-template-columns: 1fr auto; }
|
||||
.match-clock { display: none; }
|
||||
.brand small, .telemetry, .mission-note { display: none; }
|
||||
.kill-feed { top: 82px; right: 16px; }
|
||||
.leaderboard { display: none; }
|
||||
.hud { right: 16px; bottom: 16px; left: 16px; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
.weapon-block { grid-column: 2; grid-row: 1; }
|
||||
.score-block { display: none; }
|
||||
.killcam-panel { bottom: 104px; }
|
||||
.killcam-panel__title small, .killcam-panel__meta span:last-child { display: none; }
|
||||
.flux-header { grid-template-columns: 1fr auto; padding: 18px; }
|
||||
.flux-score { grid-column: 1 / -1; grid-row: 2; margin-top: 38px; }
|
||||
.flux-arena { width: calc(100% - 24px); grid-template-columns: 1fr; }
|
||||
.flux-roster { display: none; }
|
||||
.flux-field { height: 180px; grid-template-columns: 45px 1fr 45px; }
|
||||
.flux-gate { height: 125px; }
|
||||
.flux-controls { grid-template-columns: 1fr; gap: 13px; padding: 16px 20px 20px; }
|
||||
.flux-identity { display: none; }
|
||||
.flux-energy { justify-self: stretch; width: auto; }
|
||||
.flux-thrust { grid-row: 1; min-width: 0; }
|
||||
.royale-header { padding: 16px; grid-template-columns: 1fr auto; }
|
||||
.royale-status { grid-column: 1 / -1; grid-row: 2; justify-self: center; margin-top: 42px; }
|
||||
.royale-stream { top: 112px; left: 16px; }
|
||||
.royale-radar { top: 112px; right: 16px; width: 112px; height: 112px; }
|
||||
.royale-feed { display: none; }
|
||||
.royale-hud { right: 16px; bottom: 14px; left: 16px; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
.royale-mission { display: none; }
|
||||
.royale-weapon { min-width: 0; }
|
||||
.royale-drop { right: 16px; }
|
||||
}
|
||||
208
apps/web/src/useFluxClient.ts
Normal file
208
apps/web/src/useFluxClient.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FixedStepClock, type NetworkStats } from "@syncer/engine";
|
||||
import {
|
||||
FLUX_SOCKET_PATH,
|
||||
fluxGame,
|
||||
type FluxClientState,
|
||||
type FluxInput,
|
||||
} from "@syncer/shared";
|
||||
import type { ConnectionStatus, ValidationStatus } from "./useGameClient.js";
|
||||
|
||||
export interface FluxClientView {
|
||||
connection: ConnectionStatus;
|
||||
validation: ValidationStatus;
|
||||
playerId: number | null;
|
||||
tick: number;
|
||||
inputLeadTicks: number;
|
||||
world: FluxClientState;
|
||||
network: NetworkStats;
|
||||
setThrust(active: boolean): void;
|
||||
}
|
||||
|
||||
const emptyNetworkStats: NetworkStats = {
|
||||
roundTripTime: 0,
|
||||
jitter: 0,
|
||||
clockOffset: 0,
|
||||
samples: 0,
|
||||
};
|
||||
|
||||
function socketUrl(): string {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
if (window.location.protocol === "http:" && window.location.port === "5173") {
|
||||
return `ws://${window.location.hostname}:3001${FLUX_SOCKET_PATH}`;
|
||||
}
|
||||
return `${protocol}//${window.location.host}${FLUX_SOCKET_PATH}`;
|
||||
}
|
||||
|
||||
export function useFluxClient(): FluxClientView {
|
||||
const engine = useMemo(() => fluxGame.createClient(), []);
|
||||
const protocol = fluxGame.protocol;
|
||||
const clock = useMemo(
|
||||
() => new FixedStepClock({ rateHz: fluxGame.tickRateHz, maxCatchUpSteps: 5 }),
|
||||
[],
|
||||
);
|
||||
const setThrustRef = useRef<(active: boolean) => void>(() => undefined);
|
||||
const setThrust = useCallback((active: boolean) => setThrustRef.current(active), []);
|
||||
const [view, setView] = useState<Omit<FluxClientView, "setThrust">>({
|
||||
connection: "connecting",
|
||||
validation: "waiting",
|
||||
playerId: null,
|
||||
tick: 0,
|
||||
inputLeadTicks: 1,
|
||||
world: fluxGame.client.createInitialState(),
|
||||
network: emptyNetworkStats,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let socket: WebSocket | undefined;
|
||||
let retryTimer: number | undefined;
|
||||
let animationFrame: number | undefined;
|
||||
let connection: ConnectionStatus = "connecting";
|
||||
let validation: ValidationStatus = "waiting";
|
||||
let input: FluxInput = { thrust: false };
|
||||
|
||||
const publish = () => {
|
||||
if (!active) return;
|
||||
setView({
|
||||
connection,
|
||||
validation,
|
||||
playerId: engine.localPlayerId,
|
||||
tick: engine.tick,
|
||||
inputLeadTicks: engine.networkClock.recommendedInputLeadTicks(fluxGame.tickRateHz),
|
||||
world: fluxGame.client.cloneState(engine.currentState as FluxClientState),
|
||||
network: engine.networkClock.stats,
|
||||
});
|
||||
};
|
||||
|
||||
const send = (frame: ArrayBuffer) => {
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send(frame);
|
||||
};
|
||||
|
||||
const sendInput = () => {
|
||||
if (!engine.initialized) return;
|
||||
send(protocol.encodeClient({ kind: "input", packet: engine.createInput(input) }));
|
||||
};
|
||||
|
||||
const updateThrust = (thrust: boolean) => {
|
||||
if (input.thrust === thrust) return;
|
||||
input = { thrust };
|
||||
sendInput();
|
||||
publish();
|
||||
};
|
||||
setThrustRef.current = updateThrust;
|
||||
|
||||
const connect = () => {
|
||||
connection = engine.initialized ? "reconnecting" : "connecting";
|
||||
publish();
|
||||
socket = new WebSocket(socketUrl());
|
||||
socket.binaryType = "arraybuffer";
|
||||
socket.addEventListener("open", () => {
|
||||
if (!active) return;
|
||||
connection = "live";
|
||||
publish();
|
||||
});
|
||||
socket.addEventListener("message", (event: MessageEvent<ArrayBuffer>) => {
|
||||
if (!active || !(event.data instanceof ArrayBuffer)) return;
|
||||
try {
|
||||
const message = protocol.decodeServer(event.data);
|
||||
switch (message.kind) {
|
||||
case "welcome":
|
||||
engine.initialize(message.playerId, message.snapshot);
|
||||
clock.reset(performance.now());
|
||||
input = { thrust: false };
|
||||
validation = "waiting";
|
||||
sendInput();
|
||||
break;
|
||||
case "snapshot":
|
||||
engine.reconcile(message.snapshot);
|
||||
break;
|
||||
case "acknowledge":
|
||||
engine.acknowledge(message.sequence);
|
||||
break;
|
||||
case "pong":
|
||||
engine.networkClock.receivePong(message.pong, performance.now());
|
||||
break;
|
||||
case "validation":
|
||||
validation = message.valid ? "valid" : "invalid";
|
||||
break;
|
||||
case "reject-input":
|
||||
engine.reject(message.sequence);
|
||||
validation = "invalid";
|
||||
break;
|
||||
case "event":
|
||||
engine.receiveEvent(message.event, message.tick);
|
||||
break;
|
||||
case "replay-start":
|
||||
case "replay-frame":
|
||||
case "replay-end":
|
||||
break;
|
||||
}
|
||||
publish();
|
||||
} catch {
|
||||
socket?.close(1003, "Invalid game frame");
|
||||
}
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
if (!active) return;
|
||||
input = { thrust: false };
|
||||
connection = "reconnecting";
|
||||
publish();
|
||||
retryTimer = window.setTimeout(connect, 1_000);
|
||||
});
|
||||
socket.addEventListener("error", () => socket?.close());
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code !== "Space") return;
|
||||
event.preventDefault();
|
||||
updateThrust(true);
|
||||
};
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.code !== "Space") return;
|
||||
event.preventDefault();
|
||||
updateThrust(false);
|
||||
};
|
||||
const release = () => updateThrust(false);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener("blur", release);
|
||||
|
||||
const pingTimer = window.setInterval(() => {
|
||||
send(protocol.encodeClient({
|
||||
kind: "ping",
|
||||
ping: engine.networkClock.createPing(performance.now()),
|
||||
}));
|
||||
}, 1_000);
|
||||
const validationTimer = window.setInterval(() => {
|
||||
if (engine.initialized) {
|
||||
send(protocol.encodeClient({
|
||||
kind: "state-report",
|
||||
report: engine.createStateReport(),
|
||||
}));
|
||||
}
|
||||
}, 2_000);
|
||||
const animate = (now: number) => {
|
||||
clock.advance(now, () => engine.step());
|
||||
publish();
|
||||
animationFrame = window.requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
connect();
|
||||
animationFrame = window.requestAnimationFrame(animate);
|
||||
return () => {
|
||||
active = false;
|
||||
setThrustRef.current = () => undefined;
|
||||
window.clearTimeout(retryTimer);
|
||||
window.clearInterval(pingTimer);
|
||||
window.clearInterval(validationTimer);
|
||||
if (animationFrame !== undefined) window.cancelAnimationFrame(animationFrame);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("blur", release);
|
||||
socket?.close();
|
||||
};
|
||||
}, [clock, engine, protocol]);
|
||||
|
||||
return { ...view, setThrust };
|
||||
}
|
||||
487
apps/web/src/useGameClient.ts
Normal file
487
apps/web/src/useGameClient.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
FixedStepClock,
|
||||
type NetworkStats,
|
||||
type ProjectedReplayFrame,
|
||||
} from "@syncer/engine";
|
||||
import {
|
||||
GAME_SOCKET_PATH,
|
||||
WEAPONS,
|
||||
Weapon,
|
||||
shooterGame,
|
||||
type ShooterInput,
|
||||
type ShooterPerception,
|
||||
type ShooterWorldState,
|
||||
} from "@syncer/shared";
|
||||
|
||||
export type ConnectionStatus = "connecting" | "live" | "reconnecting";
|
||||
export type ValidationStatus = "waiting" | "valid" | "invalid";
|
||||
|
||||
export interface ReplayPlaybackView {
|
||||
ticketId: number;
|
||||
perspectiveId: number;
|
||||
fromTick: number;
|
||||
toTick: number;
|
||||
currentTick: number;
|
||||
progress: number;
|
||||
playbackRate: number;
|
||||
}
|
||||
|
||||
export interface GameClientView {
|
||||
connection: ConnectionStatus;
|
||||
validation: ValidationStatus;
|
||||
playerId: number | null;
|
||||
cameraPlayerId: number | null;
|
||||
presentationKey: string;
|
||||
tick: number;
|
||||
inputLeadTicks: number;
|
||||
world: ShooterWorldState;
|
||||
network: NetworkStats;
|
||||
replay: ReplayPlaybackView | null;
|
||||
}
|
||||
|
||||
interface IncomingReplay {
|
||||
ticketId: number;
|
||||
perspectiveId: number;
|
||||
fromTick: number;
|
||||
toTick: number;
|
||||
frameCount: number;
|
||||
playbackRate: number;
|
||||
frames: Array<ProjectedReplayFrame<ShooterWorldState, ShooterPerception>>;
|
||||
}
|
||||
|
||||
interface ActiveReplay extends IncomingReplay {
|
||||
startedAt: number;
|
||||
durationMilliseconds: number;
|
||||
}
|
||||
|
||||
const emptyNetworkStats: NetworkStats = {
|
||||
roundTripTime: 0,
|
||||
jitter: 0,
|
||||
clockOffset: 0,
|
||||
samples: 0,
|
||||
};
|
||||
|
||||
const neutralInput: ShooterInput = {
|
||||
strafe: 0,
|
||||
forward: 0,
|
||||
yaw: 0,
|
||||
pitch: 0,
|
||||
fire: false,
|
||||
sprint: false,
|
||||
reload: false,
|
||||
weapon: Weapon.PulseRifle,
|
||||
};
|
||||
|
||||
function getSocketUrl(): string {
|
||||
const socketProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
if (window.location.protocol === "http:" && window.location.port === "5173") {
|
||||
return `ws://${window.location.hostname}:3001${GAME_SOCKET_PATH}`;
|
||||
}
|
||||
return `${socketProtocol}//${window.location.host}${GAME_SOCKET_PATH}`;
|
||||
}
|
||||
|
||||
export function useGameClient(): GameClientView {
|
||||
const engine = useMemo(() => shooterGame.createClient(), []);
|
||||
const protocol = shooterGame.protocol;
|
||||
const simulationClock = useMemo(
|
||||
() => new FixedStepClock({ rateHz: shooterGame.tickRateHz, maxCatchUpSteps: 5 }),
|
||||
[],
|
||||
);
|
||||
const [view, setView] = useState<GameClientView>({
|
||||
connection: "connecting",
|
||||
validation: "waiting",
|
||||
playerId: null,
|
||||
cameraPlayerId: null,
|
||||
presentationKey: "live",
|
||||
tick: 0,
|
||||
inputLeadTicks: 1,
|
||||
world: shooterGame.client.createInitialState(),
|
||||
network: emptyNetworkStats,
|
||||
replay: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let socket: WebSocket | undefined;
|
||||
let retryTimer: number | undefined;
|
||||
let animationFrame: number | undefined;
|
||||
let connection: ConnectionStatus = "connecting";
|
||||
let validation: ValidationStatus = "waiting";
|
||||
const pressedKeys = new Set<string>();
|
||||
let input: ShooterInput = { ...neutralInput };
|
||||
let lastSentInput: ShooterInput | null = null;
|
||||
let inputDirty = true;
|
||||
const incomingReplays = new Map<number, IncomingReplay>();
|
||||
let activeReplay: ActiveReplay | null = null;
|
||||
|
||||
const publishView = () => {
|
||||
if (!active) return;
|
||||
const now = performance.now();
|
||||
let world = shooterGame.client.cloneState(
|
||||
engine.currentState as ShooterWorldState,
|
||||
);
|
||||
let tick = engine.tick;
|
||||
let cameraPlayerId = engine.localPlayerId;
|
||||
let replayView: ReplayPlaybackView | null = null;
|
||||
|
||||
if (activeReplay) {
|
||||
const progress = clamp(
|
||||
(now - activeReplay.startedAt) / activeReplay.durationMilliseconds,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
if (progress >= 1) {
|
||||
activeReplay = null;
|
||||
} else {
|
||||
const targetTick =
|
||||
activeReplay.fromTick +
|
||||
(activeReplay.toTick - activeReplay.fromTick) * progress;
|
||||
const frame = frameAtOrBefore(activeReplay.frames, targetTick);
|
||||
if (frame) {
|
||||
world = shooterGame.client.cloneState(frame.state);
|
||||
world.events = frame.events.map((event) => ({
|
||||
receivedTick: frame.tick,
|
||||
event: { ...event },
|
||||
}));
|
||||
tick = frame.tick;
|
||||
cameraPlayerId = activeReplay.perspectiveId;
|
||||
replayView = {
|
||||
ticketId: activeReplay.ticketId,
|
||||
perspectiveId: activeReplay.perspectiveId,
|
||||
fromTick: activeReplay.fromTick,
|
||||
toTick: activeReplay.toTick,
|
||||
currentTick: frame.tick,
|
||||
progress,
|
||||
playbackRate: activeReplay.playbackRate,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setView({
|
||||
connection,
|
||||
validation,
|
||||
playerId: engine.localPlayerId,
|
||||
cameraPlayerId,
|
||||
presentationKey: replayView ? `replay-${replayView.ticketId}` : "live",
|
||||
tick,
|
||||
inputLeadTicks: engine.networkClock.recommendedInputLeadTicks(shooterGame.tickRateHz),
|
||||
world,
|
||||
network: engine.networkClock.stats,
|
||||
replay: replayView,
|
||||
});
|
||||
};
|
||||
|
||||
const send = (message: ArrayBuffer) => {
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send(message);
|
||||
};
|
||||
|
||||
const sendInput = (force = false) => {
|
||||
if (!engine.initialized || (!force && !inputDirty)) return;
|
||||
if (!force && lastSentInput && inputsEqual(input, lastSentInput)) {
|
||||
inputDirty = false;
|
||||
return;
|
||||
}
|
||||
const packet = engine.createInput(input);
|
||||
send(protocol.encodeClient({ kind: "input", packet }));
|
||||
lastSentInput = { ...input };
|
||||
inputDirty = false;
|
||||
};
|
||||
|
||||
const updateMovement = () => {
|
||||
input = {
|
||||
...input,
|
||||
strafe: Number(pressedKeys.has("d")) - Number(pressedKeys.has("a")),
|
||||
forward: Number(pressedKeys.has("w")) - Number(pressedKeys.has("s")),
|
||||
sprint: pressedKeys.has("shift"),
|
||||
};
|
||||
inputDirty = true;
|
||||
sendInput();
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
connection = engine.initialized ? "reconnecting" : "connecting";
|
||||
publishView();
|
||||
socket = new WebSocket(getSocketUrl());
|
||||
socket.binaryType = "arraybuffer";
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
if (!active) return;
|
||||
connection = "live";
|
||||
publishView();
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (event: MessageEvent<ArrayBuffer>) => {
|
||||
if (!active || !(event.data instanceof ArrayBuffer)) return;
|
||||
try {
|
||||
const message = protocol.decodeServer(event.data);
|
||||
switch (message.kind) {
|
||||
case "welcome": {
|
||||
activeReplay = null;
|
||||
incomingReplays.clear();
|
||||
engine.initialize(message.playerId, message.snapshot);
|
||||
simulationClock.reset(performance.now());
|
||||
const local = message.snapshot.state.players.get(message.playerId);
|
||||
input = {
|
||||
...neutralInput,
|
||||
yaw: local?.yaw ?? 0,
|
||||
pitch: local?.pitch ?? 0,
|
||||
weapon: local?.weapon ?? Weapon.PulseRifle,
|
||||
};
|
||||
lastSentInput = null;
|
||||
inputDirty = true;
|
||||
validation = "waiting";
|
||||
sendInput(true);
|
||||
break;
|
||||
}
|
||||
case "snapshot":
|
||||
engine.reconcile(message.snapshot);
|
||||
break;
|
||||
case "acknowledge":
|
||||
engine.acknowledge(message.sequence);
|
||||
break;
|
||||
case "pong":
|
||||
engine.networkClock.receivePong(message.pong, performance.now());
|
||||
break;
|
||||
case "validation":
|
||||
validation = message.valid ? "valid" : "invalid";
|
||||
break;
|
||||
case "reject-input":
|
||||
engine.reject(message.sequence);
|
||||
validation = "invalid";
|
||||
break;
|
||||
case "event":
|
||||
engine.receiveEvent(message.event, message.tick);
|
||||
break;
|
||||
case "replay-start": {
|
||||
if (message.frameCount === 0 || message.frameCount > 4_096) break;
|
||||
incomingReplays.set(message.ticketId, {
|
||||
ticketId: message.ticketId,
|
||||
perspectiveId: message.perspectiveId,
|
||||
fromTick: message.fromTick,
|
||||
toTick: message.toTick,
|
||||
frameCount: message.frameCount,
|
||||
playbackRate: message.playbackRate,
|
||||
frames: [],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "replay-frame": {
|
||||
const replay = incomingReplays.get(message.ticketId);
|
||||
if (
|
||||
!replay ||
|
||||
replay.frames.length >= replay.frameCount ||
|
||||
message.tick < replay.fromTick ||
|
||||
message.tick > replay.toTick
|
||||
) {
|
||||
break;
|
||||
}
|
||||
replay.frames.push({
|
||||
tick: message.tick,
|
||||
state: message.state,
|
||||
events: message.events,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "replay-end": {
|
||||
const replay = incomingReplays.get(message.ticketId);
|
||||
incomingReplays.delete(message.ticketId);
|
||||
if (!replay || replay.frames.length !== replay.frameCount) break;
|
||||
replay.frames.sort((left, right) => left.tick - right.tick);
|
||||
const replayTicks = Math.max(1, replay.toTick - replay.fromTick);
|
||||
activeReplay = {
|
||||
...replay,
|
||||
startedAt: performance.now(),
|
||||
durationMilliseconds: Math.max(
|
||||
1_200,
|
||||
(replayTicks / shooterGame.tickRateHz / replay.playbackRate) * 1_000,
|
||||
),
|
||||
};
|
||||
releaseControls();
|
||||
break;
|
||||
}
|
||||
}
|
||||
publishView();
|
||||
} catch {
|
||||
socket?.close(1003, "Invalid game frame");
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => {
|
||||
if (!active) return;
|
||||
activeReplay = null;
|
||||
incomingReplays.clear();
|
||||
connection = "reconnecting";
|
||||
publishView();
|
||||
retryTimer = window.setTimeout(connect, 1_000);
|
||||
});
|
||||
socket.addEventListener("error", () => socket?.close());
|
||||
};
|
||||
|
||||
const animate = (now: number) => {
|
||||
simulationClock.advance(now, () => engine.step());
|
||||
publishView();
|
||||
animationFrame = window.requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
if (["w", "a", "s", "d", "shift", "r", "1", "2", "3"].includes(key)) event.preventDefault();
|
||||
if (activeReplay) return;
|
||||
if (["w", "a", "s", "d", "shift"].includes(key)) {
|
||||
pressedKeys.add(key);
|
||||
updateMovement();
|
||||
} else if (key === "r") {
|
||||
input = { ...input, reload: true };
|
||||
inputDirty = true;
|
||||
sendInput();
|
||||
} else if (key === "1" || key === "2" || key === "3") {
|
||||
input = {
|
||||
...input,
|
||||
weapon:
|
||||
key === "1"
|
||||
? Weapon.PulseRifle
|
||||
: key === "2"
|
||||
? Weapon.Scattergun
|
||||
: Weapon.RailRifle,
|
||||
};
|
||||
inputDirty = true;
|
||||
sendInput();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
if (activeReplay) return;
|
||||
if (["w", "a", "s", "d", "shift"].includes(key)) {
|
||||
pressedKeys.delete(key);
|
||||
updateMovement();
|
||||
} else if (key === "r") {
|
||||
input = { ...input, reload: false };
|
||||
inputDirty = true;
|
||||
sendInput();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (activeReplay || !document.pointerLockElement) return;
|
||||
input = {
|
||||
...input,
|
||||
yaw: normalizeAngle(input.yaw + event.movementX * 0.00225),
|
||||
pitch: clamp(input.pitch - event.movementY * 0.0019, -1.25, 1.25),
|
||||
};
|
||||
inputDirty = true;
|
||||
};
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
if (activeReplay || event.button !== 0 || !document.pointerLockElement) return;
|
||||
input = { ...input, fire: true };
|
||||
inputDirty = true;
|
||||
sendInput();
|
||||
};
|
||||
|
||||
const handleMouseUp = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
if (activeReplay) return;
|
||||
input = { ...input, fire: false };
|
||||
inputDirty = true;
|
||||
sendInput();
|
||||
};
|
||||
|
||||
const releaseControls = () => {
|
||||
pressedKeys.clear();
|
||||
input = { ...input, strafe: 0, forward: 0, fire: false, sprint: false, reload: false };
|
||||
inputDirty = true;
|
||||
sendInput(true);
|
||||
};
|
||||
|
||||
const handlePointerLockChange = () => {
|
||||
if (!document.pointerLockElement) releaseControls();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mousedown", handleMouseDown);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
window.addEventListener("blur", releaseControls);
|
||||
document.addEventListener("pointerlockchange", handlePointerLockChange);
|
||||
|
||||
const inputTimer = window.setInterval(
|
||||
() => sendInput(input.fire && WEAPONS[input.weapon].automatic),
|
||||
1_000 / 30,
|
||||
);
|
||||
const pingTimer = window.setInterval(() => {
|
||||
send(protocol.encodeClient({ kind: "ping", ping: engine.networkClock.createPing(performance.now()) }));
|
||||
}, 1_000);
|
||||
const validationTimer = window.setInterval(() => {
|
||||
if (engine.initialized) {
|
||||
send(protocol.encodeClient({ kind: "state-report", report: engine.createStateReport() }));
|
||||
}
|
||||
}, 2_000);
|
||||
|
||||
connect();
|
||||
animationFrame = window.requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearTimeout(retryTimer);
|
||||
window.clearInterval(inputTimer);
|
||||
window.clearInterval(pingTimer);
|
||||
window.clearInterval(validationTimer);
|
||||
if (animationFrame !== undefined) window.cancelAnimationFrame(animationFrame);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mousedown", handleMouseDown);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
window.removeEventListener("blur", releaseControls);
|
||||
document.removeEventListener("pointerlockchange", handlePointerLockChange);
|
||||
socket?.close();
|
||||
};
|
||||
}, [engine, protocol, simulationClock]);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function inputsEqual(left: ShooterInput, right: ShooterInput): boolean {
|
||||
return (
|
||||
left.strafe === right.strafe &&
|
||||
left.forward === right.forward &&
|
||||
left.yaw === right.yaw &&
|
||||
left.pitch === right.pitch &&
|
||||
left.fire === right.fire &&
|
||||
left.sprint === right.sprint &&
|
||||
left.reload === right.reload &&
|
||||
left.weapon === right.weapon
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAngle(value: number): number {
|
||||
return Math.atan2(Math.sin(value), Math.cos(value));
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
|
||||
function frameAtOrBefore<State, Event>(
|
||||
frames: ReadonlyArray<ProjectedReplayFrame<State, Event>>,
|
||||
targetTick: number,
|
||||
): ProjectedReplayFrame<State, Event> | undefined {
|
||||
let low = 0;
|
||||
let high = frames.length - 1;
|
||||
let selected: ProjectedReplayFrame<State, Event> | undefined;
|
||||
while (low <= high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
const frame = frames[middle]!;
|
||||
if (frame.tick <= targetTick) {
|
||||
selected = frame;
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
return selected ?? frames[0];
|
||||
}
|
||||
323
apps/web/src/useRoyaleClient.ts
Normal file
323
apps/web/src/useRoyaleClient.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FixedStepClock, type NetworkStats } from "@syncer/engine";
|
||||
import {
|
||||
ROYALE_SOCKET_PATH,
|
||||
royaleGame,
|
||||
type RoyaleClientState,
|
||||
type RoyaleInput,
|
||||
} from "@syncer/shared";
|
||||
import type { ConnectionStatus, ValidationStatus } from "./useGameClient.js";
|
||||
|
||||
export interface RoyaleClientView {
|
||||
connection: ConnectionStatus;
|
||||
validation: ValidationStatus;
|
||||
playerId: number | null;
|
||||
tick: number;
|
||||
inputLeadTicks: number;
|
||||
world: RoyaleClientState;
|
||||
network: NetworkStats;
|
||||
}
|
||||
|
||||
const emptyNetworkStats: NetworkStats = {
|
||||
roundTripTime: 0,
|
||||
jitter: 0,
|
||||
clockOffset: 0,
|
||||
samples: 0,
|
||||
};
|
||||
|
||||
const neutralInput: RoyaleInput = {
|
||||
forward: 0,
|
||||
strafe: 0,
|
||||
yaw: 0,
|
||||
pitch: 0,
|
||||
fire: false,
|
||||
sprint: false,
|
||||
reload: false,
|
||||
};
|
||||
|
||||
function socketUrls(): string[] {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const proxied = `${protocol}//${window.location.host}${ROYALE_SOCKET_PATH}`;
|
||||
if (window.location.protocol !== "http:" || window.location.port !== "5173") {
|
||||
return [proxied];
|
||||
}
|
||||
return [
|
||||
`ws://${window.location.hostname}:3001${ROYALE_SOCKET_PATH}`,
|
||||
proxied,
|
||||
];
|
||||
}
|
||||
|
||||
export function useRoyaleClient(): RoyaleClientView {
|
||||
const engine = useMemo(() => royaleGame.createClient(), []);
|
||||
const protocol = royaleGame.protocol;
|
||||
const clock = useMemo(
|
||||
() => new FixedStepClock({ rateHz: royaleGame.tickRateHz, maxCatchUpSteps: 5 }),
|
||||
[],
|
||||
);
|
||||
const [view, setView] = useState<RoyaleClientView>({
|
||||
connection: "connecting",
|
||||
validation: "waiting",
|
||||
playerId: null,
|
||||
tick: 0,
|
||||
inputLeadTicks: 1,
|
||||
world: royaleGame.client.createInitialState(),
|
||||
network: emptyNetworkStats,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let socket: WebSocket | undefined;
|
||||
let retryTimer: number | undefined;
|
||||
let animationFrame: number | undefined;
|
||||
let connection: ConnectionStatus = "connecting";
|
||||
let validation: ValidationStatus = "waiting";
|
||||
let input: RoyaleInput = { ...neutralInput };
|
||||
let lastSent: RoyaleInput | null = null;
|
||||
let inputDirty = false;
|
||||
let socketUrlIndex = 0;
|
||||
const connectionUrls = socketUrls();
|
||||
const pressed = new Set<string>();
|
||||
|
||||
const publish = () => {
|
||||
if (!active) return;
|
||||
setView({
|
||||
connection,
|
||||
validation,
|
||||
playerId: engine.localPlayerId,
|
||||
tick: engine.tick,
|
||||
inputLeadTicks: engine.networkClock.recommendedInputLeadTicks(royaleGame.tickRateHz),
|
||||
world: royaleGame.client.cloneState(engine.currentState as RoyaleClientState),
|
||||
network: engine.networkClock.stats,
|
||||
});
|
||||
};
|
||||
|
||||
const send = (frame: ArrayBuffer) => {
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send(frame);
|
||||
};
|
||||
const sendInput = (force = false) => {
|
||||
if (!engine.initialized) return;
|
||||
if (!force && !inputDirty) return;
|
||||
if (!force && lastSent && inputsEqual(lastSent, input)) {
|
||||
inputDirty = false;
|
||||
return;
|
||||
}
|
||||
const packet = engine.createInput(input);
|
||||
send(protocol.encodeClient({ kind: "input", packet }));
|
||||
lastSent = { ...input };
|
||||
inputDirty = false;
|
||||
};
|
||||
const updateMovement = () => {
|
||||
input = {
|
||||
...input,
|
||||
forward: Number(pressed.has("w")) - Number(pressed.has("s")),
|
||||
strafe: Number(pressed.has("d")) - Number(pressed.has("a")),
|
||||
sprint: pressed.has("shift"),
|
||||
};
|
||||
inputDirty = true;
|
||||
sendInput();
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
let opened = false;
|
||||
connection = engine.initialized ? "reconnecting" : "connecting";
|
||||
publish();
|
||||
socket = new WebSocket(connectionUrls[socketUrlIndex]!);
|
||||
socket.binaryType = "arraybuffer";
|
||||
socket.addEventListener("open", () => {
|
||||
if (!active) return;
|
||||
opened = true;
|
||||
connection = "live";
|
||||
publish();
|
||||
});
|
||||
socket.addEventListener("message", (event: MessageEvent<ArrayBuffer>) => {
|
||||
if (!active || !(event.data instanceof ArrayBuffer)) return;
|
||||
try {
|
||||
const message = protocol.decodeServer(event.data);
|
||||
switch (message.kind) {
|
||||
case "welcome": {
|
||||
engine.initialize(message.playerId, message.snapshot);
|
||||
clock.reset(performance.now());
|
||||
const local = message.snapshot.state.players.find(
|
||||
(player) => player.id === message.playerId,
|
||||
);
|
||||
input = {
|
||||
...neutralInput,
|
||||
yaw: local?.yaw ?? 0,
|
||||
pitch: local?.pitch ?? 0,
|
||||
};
|
||||
lastSent = null;
|
||||
inputDirty = true;
|
||||
validation = "waiting";
|
||||
sendInput(true);
|
||||
break;
|
||||
}
|
||||
case "snapshot":
|
||||
engine.reconcile(message.snapshot);
|
||||
break;
|
||||
case "acknowledge":
|
||||
engine.acknowledge(message.sequence);
|
||||
break;
|
||||
case "pong":
|
||||
engine.networkClock.receivePong(message.pong, performance.now());
|
||||
break;
|
||||
case "validation":
|
||||
validation = message.valid ? "valid" : "invalid";
|
||||
break;
|
||||
case "reject-input":
|
||||
engine.reject(message.sequence);
|
||||
validation = "invalid";
|
||||
break;
|
||||
case "event":
|
||||
engine.receiveEvent(message.event, message.tick);
|
||||
break;
|
||||
case "replay-start":
|
||||
case "replay-frame":
|
||||
case "replay-end":
|
||||
break;
|
||||
}
|
||||
publish();
|
||||
} catch {
|
||||
socket?.close(1003, "Invalid game frame");
|
||||
}
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
if (!active) return;
|
||||
if (!opened && connectionUrls.length > 1) {
|
||||
socketUrlIndex = (socketUrlIndex + 1) % connectionUrls.length;
|
||||
} else if (opened) {
|
||||
socketUrlIndex = 0;
|
||||
}
|
||||
connection = "reconnecting";
|
||||
publish();
|
||||
retryTimer = window.setTimeout(connect, 1_000);
|
||||
});
|
||||
socket.addEventListener("error", () => socket?.close());
|
||||
};
|
||||
|
||||
const keyDown = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
if (["w", "a", "s", "d", "shift", "r"].includes(key)) event.preventDefault();
|
||||
if (["w", "a", "s", "d", "shift"].includes(key)) {
|
||||
pressed.add(key);
|
||||
updateMovement();
|
||||
} else if (key === "r") {
|
||||
input = { ...input, reload: true };
|
||||
inputDirty = true;
|
||||
sendInput(true);
|
||||
}
|
||||
};
|
||||
const keyUp = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
if (["w", "a", "s", "d", "shift"].includes(key)) {
|
||||
pressed.delete(key);
|
||||
updateMovement();
|
||||
} else if (key === "r") {
|
||||
input = { ...input, reload: false };
|
||||
inputDirty = true;
|
||||
sendInput(true);
|
||||
}
|
||||
};
|
||||
const mouseMove = (event: MouseEvent) => {
|
||||
if (!document.pointerLockElement) return;
|
||||
input = {
|
||||
...input,
|
||||
yaw: normalizeAngle(input.yaw - event.movementX * 0.0027),
|
||||
pitch: clamp(input.pitch - event.movementY * 0.0022, -1.25, 1.25),
|
||||
};
|
||||
inputDirty = true;
|
||||
};
|
||||
const mouseDown = (event: MouseEvent) => {
|
||||
if (event.button !== 0 || !document.pointerLockElement) return;
|
||||
input = { ...input, fire: true };
|
||||
inputDirty = true;
|
||||
sendInput(true);
|
||||
};
|
||||
const mouseUp = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
input = { ...input, fire: false };
|
||||
inputDirty = true;
|
||||
sendInput(true);
|
||||
};
|
||||
const release = () => {
|
||||
pressed.clear();
|
||||
input = { ...input, forward: 0, strafe: 0, sprint: false, fire: false, reload: false };
|
||||
inputDirty = true;
|
||||
sendInput(true);
|
||||
};
|
||||
const pointerLockChange = () => {
|
||||
if (!document.pointerLockElement) release();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", keyDown);
|
||||
window.addEventListener("keyup", keyUp);
|
||||
window.addEventListener("mousemove", mouseMove);
|
||||
window.addEventListener("mousedown", mouseDown);
|
||||
window.addEventListener("mouseup", mouseUp);
|
||||
window.addEventListener("blur", release);
|
||||
document.addEventListener("pointerlockchange", pointerLockChange);
|
||||
|
||||
const inputTimer = window.setInterval(() => {
|
||||
sendInput(input.fire);
|
||||
}, 1_000 / royaleGame.tickRateHz);
|
||||
const pingTimer = window.setInterval(() => {
|
||||
send(protocol.encodeClient({
|
||||
kind: "ping",
|
||||
ping: engine.networkClock.createPing(performance.now()),
|
||||
}));
|
||||
}, 1_000);
|
||||
const validationTimer = window.setInterval(() => {
|
||||
if (engine.initialized) {
|
||||
send(protocol.encodeClient({
|
||||
kind: "state-report",
|
||||
report: engine.createStateReport(),
|
||||
}));
|
||||
}
|
||||
}, 2_000);
|
||||
const animate = (now: number) => {
|
||||
clock.advance(now, () => engine.step());
|
||||
publish();
|
||||
animationFrame = window.requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
connect();
|
||||
animationFrame = window.requestAnimationFrame(animate);
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearTimeout(retryTimer);
|
||||
window.clearInterval(inputTimer);
|
||||
window.clearInterval(pingTimer);
|
||||
window.clearInterval(validationTimer);
|
||||
if (animationFrame !== undefined) window.cancelAnimationFrame(animationFrame);
|
||||
window.removeEventListener("keydown", keyDown);
|
||||
window.removeEventListener("keyup", keyUp);
|
||||
window.removeEventListener("mousemove", mouseMove);
|
||||
window.removeEventListener("mousedown", mouseDown);
|
||||
window.removeEventListener("mouseup", mouseUp);
|
||||
window.removeEventListener("blur", release);
|
||||
document.removeEventListener("pointerlockchange", pointerLockChange);
|
||||
socket?.close();
|
||||
};
|
||||
}, [clock, engine, protocol]);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function inputsEqual(left: RoyaleInput, right: RoyaleInput): boolean {
|
||||
return (
|
||||
left.forward === right.forward &&
|
||||
left.strafe === right.strafe &&
|
||||
left.yaw === right.yaw &&
|
||||
left.pitch === right.pitch &&
|
||||
left.fire === right.fire &&
|
||||
left.sprint === right.sprint &&
|
||||
left.reload === right.reload
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAngle(value: number): number {
|
||||
return Math.atan2(Math.sin(value), Math.cos(value));
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
13
apps/web/tsconfig.json
Normal file
13
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
|
||||
15
apps/web/vite.config.ts
Normal file
15
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:3001",
|
||||
"/ws": {
|
||||
target: "ws://localhost:3001",
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user