deploy Syncer to a2acloud Kubernetes
Some checks failed
build / image (push) Failing after 28s

This commit is contained in:
Syncer Deploy
2026-08-28 10:46:42 -03:00
commit e93c1b026f
72 changed files with 17680 additions and 0 deletions

17
apps/web/index.html Normal file
View 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
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

241
apps/web/src/App.tsx Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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; }
}

View 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 };
}

View 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];
}

View 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
View 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
View 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,
},
},
},
});