This commit is contained in:
Andras Schmelczer 2026-08-22 18:35:53 +01:00
commit 5017ff168a
33 changed files with 1004 additions and 2007 deletions

View file

@ -1,16 +1,9 @@
import { init as plausibleInit } from '@plausible-analytics/tracker';
const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true;
const ANALYTICS_DOMAIN = 'doppler.schmelczer.dev';
const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status';
const ANALYTICS_LOGGING = process.env.NODE_ENV !== 'production';
import { init } from '@plausible-analytics/tracker';
try {
plausibleInit({
domain: ANALYTICS_DOMAIN,
endpoint: ANALYTICS_ENDPOINT,
autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
logging: ANALYTICS_LOGGING,
init({
domain: 'doppler.schmelczer.dev',
endpoint: 'https://stats.schmelczer.dev/status',
});
} catch (error) {
console.warn('Could not initialize analytics.', error);

View file

@ -1,58 +1,58 @@
import { holdDurationToCharge } from 'shared';
import { Pointer } from './helper/pointer';
import { pointer } from './helper/pointer';
export abstract class ChargeIndicator {
private static element?: HTMLElement;
private static heldSince = 0;
private static raf = 0;
private static followPointer = false;
let element: HTMLElement | undefined;
let heldSince = 0;
let raf = 0;
let _followPointer = false;
public static begin(x: number, y: number, followPointer = false) {
this.end();
export function beginChargeIndicator(x: number, y: number, followPointer = false) {
endChargeIndicator();
const element = document.createElement('div');
element.className = 'charge-ring';
element.style.left = `${x}px`;
element.style.top = `${y}px`;
document.body.appendChild(element);
const el = document.createElement('div');
el.className = 'charge-ring';
el.style.left = `${x}px`;
el.style.top = `${y}px`;
document.body.appendChild(el);
this.element = element;
this.heldSince = performance.now();
this.followPointer = followPointer;
this.raf = requestAnimationFrame(this.update);
}
public static end() {
if (this.element) {
cancelAnimationFrame(this.raf);
this.element.parentElement?.removeChild(this.element);
this.element = undefined;
}
}
private static update = () => {
const element = ChargeIndicator.element;
if (!element) {
return;
}
const charge = holdDurationToCharge(
(performance.now() - ChargeIndicator.heldSince) / 1000,
);
element.style.opacity = charge < 0.12 ? '0' : '1';
element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${
charge * 360
}deg, rgba(255, 255, 255, 0.15) 0deg)`;
element.classList.toggle('full', charge >= 1);
if (ChargeIndicator.followPointer) {
const cursor = Pointer.getDisplayPosition();
if (cursor) {
element.style.left = `${cursor.x}px`;
element.style.top = `${cursor.y}px`;
}
}
ChargeIndicator.raf = requestAnimationFrame(ChargeIndicator.update);
};
element = el;
heldSince = performance.now();
_followPointer = followPointer;
raf = requestAnimationFrame(updateChargeIndicator);
}
export function endChargeIndicator() {
if (element) {
cancelAnimationFrame(raf);
element.parentElement?.removeChild(element);
element = undefined;
}
}
function updateChargeIndicator() {
if (!element) {
return;
}
const charge = holdDurationToCharge((performance.now() - heldSince) / 1000);
element.style.opacity = charge < 0.12 ? '0' : '1';
element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${
charge * 360
}deg, rgba(255, 255, 255, 0.15) 0deg)`;
element.classList.toggle('full', charge >= 1);
if (_followPointer) {
const cursor = pointer.displayPosition;
if (cursor) {
element.style.left = `${cursor.x}px`;
element.style.top = `${cursor.y}px`;
}
}
raf = requestAnimationFrame(updateChargeIndicator);
}
export const ChargeIndicator = {
begin: beginChargeIndicator,
end: endChargeIndicator,
};

View file

@ -32,16 +32,7 @@ export class CommandSocket extends CommandReceiver {
public sendQueuedCommands() {
const nowMs = predictorNowMs();
// Real input goes out on the frame it happens — that is the whole latency
// budget the player feels. An idle client still has to keep the server's
// input acknowledgement moving (a held key generates no fresh command), but
// that heartbeat is paced by the clock rather than by the frame rate.
//
// Sending once per rendered frame instead put every display above
// settings.maxInboundMessagesPerSecond permanently over the server's inbound
// allowance; once the burst was spent it silently discarded whole batches,
// and since movement commands are edge-triggered and never re-sent, a lost
// batch meant the direction change simply never happened.
// Real input goes out on the frame it happens — that is the latency budget the player feels. An idle client still keeps the server's input acknowledgement moving (a held key generates no fresh command), paced by the clock not the frame rate; sending every rendered frame would exceed settings.maxInboundMessagesPerSecond, and since movement commands are edge-triggered and never re-sent, a discarded batch would drop the direction change for good.
if (
this.commandQueue.length === 0 &&
nowMs - this.lastSendMs < heartbeatIntervalMs - dueToleranceMs

View file

@ -3,11 +3,9 @@ import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'sh
import { Game } from '../game';
import { ChargeIndicator } from '../charge-indicator';
import { predictorNowMs } from '../helper/prediction/local-character-predictor';
import { Pointer } from '../helper/pointer';
import { pointer } from '../helper/pointer';
export class MouseListener extends CommandGenerator {
// Timestamp (ms) of the primary press, or null when not held. On release the
// held duration is mapped to the charge scalar; a quick tap reads as ~0.
private primaryDownAt: number | null = null;
constructor(
@ -19,25 +17,19 @@ export class MouseListener extends CommandGenerator {
target.addEventListener('mousedown', this.mouseDownListener);
target.addEventListener('mousemove', this.mouseMoveListener);
target.addEventListener('contextmenu', this.contextMenuListener);
// Release is watched on the window, not the canvas: a charge released over
// the settings gear, the fullscreen icon, or outside the window otherwise
// never fired the shot and left the charge ring up until the next press.
// Release watched on the window, not the canvas — a release over UI or outside the window must still fire.
window.addEventListener('mouseup', this.mouseUpListener);
window.addEventListener('blur', this.cancelPrimary);
}
// Only the screen position is stored; it is reprojected to world space each
// frame so the gaze stays correct even while the camera pans under a still
// cursor.
// Store screen position; reproject to world each frame so gaze stays correct while the camera pans.
private mouseMoveListener = (event: MouseEvent) => {
Pointer.setDisplayPosition(event.clientX, event.clientY);
pointer.displayPosition = vec2.fromValues(event.clientX, event.clientY);
};
private mouseDownListener = (event: MouseEvent) => {
if (event.button === 0) {
this.primaryDownAt = performance.now();
// The ring follows the cursor and only fades in once this press has
// clearly become a hold.
ChargeIndicator.begin(event.clientX, event.clientY, true);
}
};
@ -59,8 +51,6 @@ export class MouseListener extends CommandGenerator {
);
};
// Losing the window mid-charge cannot produce a meaningful aim point, so drop
// the charge rather than leaving the indicator running.
private cancelPrimary = () => {
if (this.primaryDownAt !== null) {
this.primaryDownAt = null;
@ -68,7 +58,6 @@ export class MouseListener extends CommandGenerator {
}
};
// Suppress the browser context menu on the canvas; right-click has no action.
private contextMenuListener = (event: MouseEvent) => {
event.preventDefault();
};

View file

@ -2,7 +2,6 @@ import { vec2 } from 'gl-matrix';
import {
CommandGenerator,
MoveActionCommand,
last,
PrimaryActionCommand,
LeapActionCommand,
holdDurationToCharge,
@ -18,8 +17,6 @@ import {
export class TouchListener extends CommandGenerator {
private static readonly deadZone = 8;
private static readonly deltaScaling = 0.4;
// Min screen drag (px) from the fire button before a shot is aimed by the
// drag direction instead of firing straight ahead.
private static readonly aimDeadZone = 18;
private joystick: HTMLElement;
@ -76,7 +73,6 @@ export class TouchListener extends CommandGenerator {
target.addEventListener('touchcancel', this.touchCancelListener);
}
// The contact that started this gesture, or undefined if it is not in `list`.
private findGestureTouch(list: TouchList): Touch | undefined {
if (this.gestureTouchId === null) {
return undefined;
@ -93,8 +89,8 @@ export class TouchListener extends CommandGenerator {
event.preventDefault();
if (this.isJoystickActive) {
const center = vec2.fromValues(
last(event.changedTouches)!.clientX,
last(event.changedTouches)!.clientY,
event.changedTouches[event.changedTouches.length - 1].clientX,
event.changedTouches[event.changedTouches.length - 1].clientY,
);
this.sendCommandToSubscribers(
new PrimaryActionCommand(
@ -159,8 +155,6 @@ export class TouchListener extends CommandGenerator {
private touchEndListener = (event: TouchEvent) => {
event.preventDefault();
// Only the contact that owns the gesture ends it — not whichever finger
// happens to lift, and not "the screen is now empty".
const touch = this.findGestureTouch(event.changedTouches);
if (!touch) {
return;
@ -187,8 +181,7 @@ export class TouchListener extends CommandGenerator {
}
};
// Also reached from touchcancel: a system gesture, an incoming call or a
// palm rejection would otherwise leave the stick latched at full tilt.
// Also reached from touchcancel — otherwise a system gesture leaves the stick latched.
private touchCancelListener = (event: TouchEvent) => {
if (!this.findGestureTouch(event.changedTouches)) {
return;
@ -230,9 +223,6 @@ export class TouchListener extends CommandGenerator {
ChargeIndicator.begin(this.fireButtonCenter[0], this.fireButtonCenter[1]);
};
// Dragging from the fire button aims the shot: the drag vector sets the
// direction, decoupling aim from movement so a touch player can fire one way
// while walking another. A tap with no meaningful drag fires straight ahead.
private fireButtonMoveListener = (event: TouchEvent) => {
this.swallowTouch(event);
if (this.fireDownAt === null || !this.fireButtonCenter) {
@ -271,8 +261,7 @@ export class TouchListener extends CommandGenerator {
return;
}
// Screen drag → world aim direction (flip Y: screen +y is down). Below the
// dead-zone it's a tap, so fall back to firing along the facing direction.
// Screen drag → world aim direction (flip Y: screen +y is down).
let direction = character.facingDirection;
if (this.fireButtonCenter && this.fireAimScreen) {
const dx = this.fireAimScreen[0] - this.fireButtonCenter[0];

View file

@ -1,20 +1,12 @@
/**
* Hardcoded list of game servers the landing page offers to players.
*
* Each entry is the public origin of a dockerized `doppler-server` instance.
* The join screen polls `<origin>/state` (see `serverInformationEndpoint`) and
* only shows a server once it responds, so listing an offline origin here is
* harmless. Add or remove origins as you deploy more server containers.
* Game server origins. The join screen polls each and only shows those that
* respond, so listing an offline origin here is harmless.
*/
// This origin predates the rebrand; update it once the game server is
// redeployed under a doppler subdomain.
const productionServers: Array<string> = ['https://declared.schmelczer.dev'];
/**
* When the page is served from localhost (i.e. the webpack-dev-server), also
* offer the local backend so a `npm start` server can be joined during
* development. The backend's default port is 3000 (see backend/src/options.ts).
* In production the page is served from its own hostname, so this never leaks.
* On localhost (webpack-dev-server) also offer the local backend on its default
* port; in production the page is served from its own hostname, so this never leaks.
*/
const isDevelopment =
typeof location !== 'undefined' &&

View file

@ -1,132 +1,128 @@
import { settings } from 'shared';
import { Pointer } from './helper/pointer';
import { pointer } from './helper/pointer';
export abstract class FeedbackHud {
private static root?: HTMLElement;
private static killfeed?: HTMLElement;
private static elimination?: HTMLElement;
let root: HTMLElement | undefined;
let killfeed: HTMLElement | undefined;
let elimination: HTMLElement | undefined;
private static ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } {
if (!this.root || !this.killfeed) {
this.root = document.createElement('div');
this.root.className = 'feedback-hud';
function ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } {
if (!root || !killfeed) {
root = document.createElement('div');
root.className = 'feedback-hud';
this.killfeed = document.createElement('div');
this.killfeed.className = 'killfeed';
this.root.appendChild(this.killfeed);
killfeed = document.createElement('div');
killfeed.className = 'killfeed';
root.appendChild(killfeed);
document.body.appendChild(this.root);
}
return { root: this.root, killfeed: this.killfeed };
document.body.appendChild(root);
}
return { root, killfeed };
}
private static focusPoint(): { x: number; y: number } {
const cursor = Pointer.getDisplayPosition();
if (cursor) {
return { x: cursor.x, y: cursor.y };
}
return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
function focusPoint(): { x: number; y: number } {
const cursor = pointer.displayPosition;
if (cursor) {
return { x: cursor.x, y: cursor.y };
}
return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
}
private static addTransient(element: HTMLElement, lifetimeMs: number) {
const { root } = this.ensureRoot();
root.appendChild(element);
setTimeout(() => element.parentElement?.removeChild(element), lifetimeMs);
}
function addTransient(el: HTMLElement, lifetimeMs: number) {
const { root: r } = ensureRoot();
r.appendChild(el);
setTimeout(() => el.parentElement?.removeChild(el), lifetimeMs);
}
public static hitMarker(charge = 0) {
const { x, y } = this.focusPoint();
const marker = document.createElement('div');
marker.className =
'hitmarker' + (charge >= settings.chargedHitThreshold ? ' charged' : '');
marker.style.left = `${x}px`;
marker.style.top = `${y}px`;
this.addTransient(marker, 250);
}
export function hitMarker(charge = 0) {
const { x, y } = focusPoint();
const marker = document.createElement('div');
marker.className =
'hitmarker' + (charge >= settings.chargedHitThreshold ? ' charged' : '');
marker.style.left = `${x}px`;
marker.style.top = `${y}px`;
addTransient(marker, 250);
}
public static killConfirmed(victimName?: string, streak = 1, charge = 0) {
const { killfeed } = this.ensureRoot();
const charged = charge >= settings.chargedHitThreshold;
export function killConfirmed(victimName?: string, streak = 1, charge = 0) {
const { killfeed: kf } = ensureRoot();
const charged = charge >= settings.chargedHitThreshold;
// A quick crimson vignette pulse around the whole frame to punctuate the kill.
const flash = document.createElement('div');
flash.className = 'kill-flash' + (charged ? ' charged' : '');
this.addTransient(flash, 420);
const flash = document.createElement('div');
flash.className = 'kill-flash' + (charged ? ' charged' : '');
addTransient(flash, 420);
const entry = document.createElement('div');
entry.className = 'kill-entry';
entry.innerHTML = `Eliminated <b>${this.escape(victimName ?? 'enemy')}</b>`;
killfeed.insertBefore(entry, killfeed.firstChild);
setTimeout(() => entry.parentElement?.removeChild(entry), 4500);
const entry = document.createElement('div');
entry.className = 'kill-entry';
entry.innerHTML = `Eliminated <b>${escapeHtml(victimName ?? 'enemy')}</b>`;
kf.insertBefore(entry, kf.firstChild);
setTimeout(() => entry.parentElement?.removeChild(entry), 4500);
const { x, y } = this.focusPoint();
const popup = document.createElement('div');
popup.className = 'kill-popup' + (charged ? ' charged' : '');
popup.innerHTML = `+${settings.playerKillPoint} <span class="heal">+${settings.playerKillHealthReward}❤</span>`;
popup.style.left = `${x}px`;
popup.style.top = `${y}px`;
this.addTransient(popup, 1200);
const { x, y } = focusPoint();
const popup = document.createElement('div');
popup.className = 'kill-popup' + (charged ? ' charged' : '');
popup.innerHTML = `+${settings.playerKillPoint} <span class="heal">+${settings.playerKillHealthReward}❤</span>`;
popup.style.left = `${x}px`;
popup.style.top = `${y}px`;
addTransient(popup, 1200);
const callout = this.streakName(streak) ?? (charged ? 'Charged Kill!' : undefined);
if (callout) {
const el = document.createElement('div');
el.className = 'streak-callout';
el.innerText = callout;
this.addTransient(el, 1400);
}
}
// Persistent centred overlay shown while the local player is dead and waiting
// to respawn. The countdown itself is the server-driven "Reviving in N…"
// announcement; this makes the death state unmistakable and stays up until
// hideElimination() is called on respawn.
public static showElimination(): void {
if (this.elimination) {
return;
}
const { root } = this.ensureRoot();
const callout = streakName(streak) ?? (charged ? 'Charged Kill!' : undefined);
if (callout) {
const el = document.createElement('div');
el.className = 'elimination';
el.innerHTML =
'<div class="elimination-title">Eliminated</div>' +
'<div class="elimination-sub">Respawning…</div>';
root.appendChild(el);
this.elimination = el;
}
public static hideElimination(): void {
this.elimination?.parentElement?.removeChild(this.elimination);
this.elimination = undefined;
}
/**
* Tear the HUD down entirely. Its root lives on document.body rather than the
* game overlay, so leaving a match while dead used to leave "Eliminated /
* Respawning…" painted over the landing page.
*/
public static reset(): void {
this.hideElimination();
this.root?.parentElement?.removeChild(this.root);
this.root = undefined;
this.killfeed = undefined;
}
private static streakName(streak: number): string | undefined {
switch (streak) {
case 2:
return 'Double Kill!';
case 3:
return 'Triple Kill!';
case 4:
return 'Quad Kill!';
default:
return streak >= 5 ? 'Rampage!' : undefined;
}
}
private static escape(text: string): string {
const div = document.createElement('div');
div.innerText = text;
return div.innerHTML;
el.className = 'streak-callout';
el.innerText = callout;
addTransient(el, 1400);
}
}
export function showElimination(): void {
if (elimination) {
return;
}
const { root: r } = ensureRoot();
const el = document.createElement('div');
el.className = 'elimination';
el.innerHTML =
'<div class="elimination-title">Eliminated</div>' +
'<div class="elimination-sub">Respawning…</div>';
r.appendChild(el);
elimination = el;
}
export function hideElimination(): void {
elimination?.parentElement?.removeChild(elimination);
elimination = undefined;
}
export function resetFeedbackHud(): void {
hideElimination();
root?.parentElement?.removeChild(root);
root = undefined;
killfeed = undefined;
}
function streakName(streak: number): string | undefined {
switch (streak) {
case 2:
return 'Double Kill!';
case 3:
return 'Triple Kill!';
case 4:
return 'Quad Kill!';
default:
return streak >= 5 ? 'Rampage!' : undefined;
}
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.innerText = text;
return div.innerHTML;
}
export const FeedbackHud = {
hitMarker,
killConfirmed,
showElimination,
hideElimination,
reset: resetFeedbackHud,
};

View file

@ -65,8 +65,6 @@ export class Game extends CommandReceiver {
private keystoneArrow?: HTMLElement;
private socketReceiver!: CommandSocket;
private tutorial!: Tutorial;
// Issued by the server on join; presented on reconnect to reclaim this
// player's team and score instead of coming back as a blank slate.
private reconnectToken?: string;
private connectionBanner?: HTMLElement;
private rejectionReason?: JoinRejectionReason;
@ -91,8 +89,6 @@ export class Game extends CommandReceiver {
this.socket?.close();
serverTimeline.reset();
localCharacterPredictor.reset();
// Clear any leftover shake/zoom so a kill at the end of one match can't bleed
// its camera impact into the next.
ScreenShake.reset();
this.gameObjects = new GameObjectContainer(this);
this.overlay.innerHTML = '';
@ -114,16 +110,12 @@ export class Game extends CommandReceiver {
parser,
} as any);
// In socket.io-client v4 reconnection events are emitted by the Manager
// (`socket.io`), not the Socket itself.
// In socket.io-client v4 reconnection events are emitted by the Manager (`socket.io`), not the Socket itself.
this.socket.io.on('reconnect_attempt', () => {
this.socket.io.opts.transports = ['polling', 'websocket'];
});
// A transport drop is not the end of the match. Previously this tore the
// game down on the first `disconnect`, which stopped the render loop, which
// closed the socket — cancelling the reconnection the client is configured
// for before it could ever run, and dumping the player on the server list.
// A transport drop is not the end of the match — tearing the game down would cancel the reconnection the client is configured for.
this.socket.on('disconnect', () => {
if (this.isBetweenGames) {
return;
@ -135,15 +127,11 @@ export class Game extends CommandReceiver {
if (this.isBetweenGames) {
return;
}
// A reconnect is a brand-new server-side connection that has never seen a
// join, so the join has to be re-sent or the client sits connected and
// invisible forever.
// A reconnect is a brand-new server-side connection that has never seen a join, so the join must be re-sent or the client sits connected and invisible forever.
this.hideConnectionBanner();
serverTimeline.reset();
localCharacterPredictor.reset();
// ...and it is a brand-new server-side *player*, whose view-area
// bookkeeping starts empty. Anything the dropped session was told about
// will never be retracted, so the old world has to go here.
// A brand-new server-side player whose view-area bookkeeping starts empty: the dropped session's objects will never be retracted, so reset here.
this.gameObjects.reset();
this.socket.emit(TransportEvents.PlayerJoining, {
...this.playerDecision,
@ -167,17 +155,13 @@ export class Game extends CommandReceiver {
this.destroy();
});
// Echo the nonce: the server only accepts a reply that matches the ping
// still outstanding, so a stale or duplicated Pong cannot move its RTT.
// Echo the nonce: the server only accepts a reply matching the ping still outstanding, so a stale or duplicated Pong cannot move its RTT.
this.socket.on(TransportEvents.Ping, (nonce: unknown) => {
this.socket.emit(TransportEvents.Pong, nonce);
});
this.socket.on(TransportEvents.ServerToPlayer, (serializedCommands: string) => {
// One malformed object must not take down the message pump. deserialize
// revives classes by name from the payload, so a hostile or corrupt field
// can throw inside JSON.parse's reviver — and every later batch would be
// lost with it.
// deserialize revives classes by name from the payload, so a hostile or corrupt field can throw inside JSON.parse's reviver — one malformed object must not take down the message pump or every later batch is lost with it.
try {
const commands: Array<Command> = deserialize(serializedCommands);
commands.forEach((c) => {
@ -193,8 +177,6 @@ export class Game extends CommandReceiver {
});
this.socketReceiver = new CommandSocket(this.socket);
// The tutorial listens to the same input streams as the socket, so its
// stages clear off the player's own commands without any server involvement.
this.keyboardListener.clearSubscribers();
this.keyboardListener.subscribe(this.socketReceiver);
this.keyboardListener.subscribe(this.tutorial);
@ -205,9 +187,7 @@ export class Game extends CommandReceiver {
this.touchListener.subscribe(this.socketReceiver);
this.touchListener.subscribe(this.tutorial);
// The join is emitted from the socket's `connect` handler above, which fires
// for the first connection and for every reconnection alike — so one code
// path covers both, and a reconnect can never be left unjoined.
// Join is emitted from the `connect` handler above, which fires for the first connection and every reconnection alike — so one code path covers both and a reconnect can never be left unjoined.
this.isBetweenGames = false;
}
@ -278,15 +258,13 @@ export class Game extends CommandReceiver {
this.socket.close();
this.overlay.innerHTML = '';
this.hideConnectionBanner();
// The HUD root lives on document.body, not the overlay, so it has to be torn
// down explicitly or it stays painted over the landing page.
// The HUD root lives on document.body, not the overlay, so it must be torn down explicitly or it stays painted over the landing page.
FeedbackHud.reset();
this.keyboardListener.destroy();
this.mouseListener.destroy();
this.touchListener.destroy();
}
/** Why the player was sent back to the server list, if they were. */
public get lastRejectionReason(): JoinRejectionReason | undefined {
return this.rejectionReason;
}
@ -341,22 +319,14 @@ export class Game extends CommandReceiver {
deltaTime: DOMHighResTimeStamp,
): boolean {
this.resolveStarted();
// The client's one clock, for input stamps, the outgoing send cadence and
// the prediction replay window alike. It has to be the frame's timestamp
// rather than performance.now(): the two differ by however long the browser
// took to dispatch this callback, and that difference would land straight
// in the replay window and jitter the predicted body.
// Must use the frame's timestamp, not performance.now(): the dispatch delay would land in the prediction replay window and jitter the predicted body.
setFrameTimeMs(currentTime);
deltaTime /= 1000;
// Decay the camera impact effects on raw wall-clock time, before any of the
// end-game slow-motion scaling below. These only adjust the rendered view,
// never the simulation, so they stay decoupled from prediction and netcode.
// Camera impact decays on raw wall-clock time, before the end-game slow-motion scaling below — view-only, never the simulation, so it stays decoupled from prediction and netcode.
ScreenShake.step(deltaTime);
// Stepped before the end-game time scaling on purpose: the slow motion is
// already baked into the snapshots the server sends, so the playback
// cursor itself must keep running on wall-clock time.
// Stepped before the end-game time scaling on purpose: the slow motion is already baked into the server's snapshots, so the playback cursor must keep running on wall-clock time.
serverTimeline.step(deltaTime);
let shouldChangeLayout = false;
@ -395,7 +365,6 @@ export class Game extends CommandReceiver {
private draw() {
if (this.lastGameState) {
// The local player's team is read off the main character once it exists.
this.scoreboard.update(this.lastGameState, this.gameObjects.player?.team);
}
@ -411,8 +380,6 @@ export class Game extends CommandReceiver {
}
}
// Points an off-screen chevron toward the keystone "Heart" planet, tinted by
// who currently holds it, so the match's focal objective is always findable.
private handleKeystoneArrow() {
if (!this.renderer) {
return;

View file

@ -1,7 +0,0 @@
export const hide = (e: HTMLElement, useDisplay = false) => {
if (useDisplay) {
e.style.display = 'none';
} else {
e.style.visibility = 'hidden';
}
};

View file

@ -1,22 +0,0 @@
import { Circle } from 'shared';
import { LinearInterpolator } from './linear-interpolator';
import { Vec2Interpolator } from './vec2-interpolator';
export class CircleInterpolator {
private center: Vec2Interpolator;
private radius: LinearInterpolator;
constructor(currentValue: Circle) {
this.center = new Vec2Interpolator(currentValue.center);
this.radius = new LinearInterpolator(currentValue.radius);
}
public addFrame(value: Circle, rateOfChange: Circle) {
this.center.addFrame(value.center, rateOfChange.center);
this.radius.addFrame(value.radius, rateOfChange.radius);
}
public getValue(deltaTime: number): Circle {
return new Circle(this.center.getValue(deltaTime), this.radius.getValue(deltaTime));
}
}

View file

@ -1,4 +1,4 @@
import { last, mix } from 'shared';
import { mix } from 'shared';
import { serverTimeline } from '../server-timeline';
interface Frame {
@ -7,25 +7,16 @@ interface Frame {
velocity: number;
}
// How far past the newest snapshot the value may coast on its last known
// velocity (network hiccup) before freezing in place.
const maxCoastSeconds = 0.06;
// When fresh snapshots arrive after a coast, they usually disagree with where
// the coast ended up; the difference decays away with this time constant
// instead of being shown as a jump.
const blendSeconds = 0.12;
// At 25 snapshots per second this spans over a second of state, far more than
// rendering ever needs; it only bounds memory while the tab is hidden and
// snapshots keep arriving without being consumed.
const maxFrames = 32;
/**
* Replays a server-streamed scalar by interpolating between timestamped
* snapshots at the shared timeline's render time, which trails the newest
* snapshot. The streamed rate of change is only used to coast briefly when
* the buffer runs dry.
* snapshots at the render time; coasts briefly on the streamed rate of change
* when the buffer runs dry.
*/
export class LinearInterpolator {
private frames: Array<Frame> = [];
@ -39,7 +30,7 @@ export class LinearInterpolator {
public addFrame(value: number, rateOfChange: number) {
const time = serverTimeline.snapshotTime;
const newest = last(this.frames);
const newest = this.frames.length > 0 ? this.frames[this.frames.length - 1] : null;
const wasCoasting = this.isCoasting;
if (newest && time <= newest.time) {
@ -56,8 +47,6 @@ export class LinearInterpolator {
}
if (wasCoasting) {
// Continue from where the coast left off and let the offset decay,
// instead of jumping onto the freshly revealed trajectory.
this.blendOffset = this.lastValue - this.sample(serverTimeline.renderTime);
}
}

View file

@ -1,21 +0,0 @@
import { vec2 } from 'gl-matrix';
import { LinearInterpolator } from './linear-interpolator';
export class Vec2Interpolator {
private x: LinearInterpolator;
private y: LinearInterpolator;
constructor(currentValue: vec2) {
this.x = new LinearInterpolator(currentValue.x);
this.y = new LinearInterpolator(currentValue.y);
}
public addFrame(value: vec2, rateOfChange: vec2) {
this.x.addFrame(value.x, rateOfChange.x);
this.y.addFrame(value.y, rateOfChange.y);
}
public getValue(deltaTime: number): vec2 {
return vec2.fromValues(this.x.getValue(deltaTime), this.y.getValue(deltaTime));
}
}

View file

@ -1,13 +1,3 @@
import { vec2 } from 'gl-matrix';
export class Pointer {
private static displayPosition: vec2 | null = null;
public static setDisplayPosition(x: number, y: number): void {
Pointer.displayPosition = vec2.fromValues(x, y);
}
public static getDisplayPosition(): vec2 | null {
return Pointer.displayPosition;
}
}
export const pointer = { displayPosition: null as vec2 | null };

View file

@ -9,8 +9,7 @@ import {
resolveCircleMovement,
} from 'shared';
// What the predictor needs to know about a planet to collide and be pulled by
// it. The client reads this off its PlanetViews.
// What the predictor needs to know about a planet to collide and be pulled by it.
export interface PredictablePlanet {
id: Id;
vertices: Array<vec2>;
@ -20,9 +19,7 @@ export interface PredictablePlanet {
rotationSpeed: number;
}
// A planet collision/gravity surface whose rotation can be advanced during
// replay, so its outline turns in lockstep with the body the carry term moves —
// exactly as the server steps the planet before the character each tick.
// A planet surface whose rotation advances during replay, in lockstep with the body — exactly as the server steps the planet before the character each tick.
class PlanetSurface implements GroundSurface {
public readonly canCollide = true;
public readonly isGround = true;
@ -71,18 +68,12 @@ class PlanetSurface implements GroundSurface {
}
}
// The planets-only collision world the local predictor runs against. It holds
// persistent surfaces keyed by planet id (so a `currentPlanet` reference stays
// valid across frames) and never dispatches collision reactions — damage,
// scoring and the like are server-authoritative.
// Planets-only collision world for the predictor. Persistent surfaces keyed by id (currentPlanet stays valid across frames); never dispatches collision reactions — those are server-authoritative.
export class ClientCharacterWorld implements CharacterWorld {
private surfaces = new Map<Id, PlanetSurface>();
private ordered: Array<PlanetSurface> = [];
// Refresh from the current PlanetViews. Far planets contribute zero gravity
// (the falloff clamps to 0 past maxGravityDistance) and never collide, so the
// whole set can be handed to every query without a range filter. Ordered by
// id so the (rare) two-surface contact picks a stable surface.
// Far planets contribute zero gravity (the falloff clamps to 0 past maxGravityDistance) and never collide, so the whole set can be handed to every query without a range filter. Ordered by id so the (rare) two-surface contact picks a stable surface.
public sync(planets: Array<PredictablePlanet>) {
const seen = new Set<Id>();
for (const planet of planets) {
@ -104,10 +95,7 @@ export class ClientCharacterWorld implements CharacterWorld {
.map((e) => e[1]);
}
// Advance every planet's collision frame by one replay substep, so surfaces
// and the carried body rotate together. The surfaces are re-synced to the
// newest snapshot rotation each frame (see sync), so the replay only ever
// steps forward from there.
// Advance every planet's collision frame by one replay substep, so surfaces and the carried body rotate together. Surfaces are re-synced to the newest snapshot rotation each frame (see sync), so the replay only ever steps forward from there.
public advance(deltaTimeInSeconds: number) {
for (const surface of this.ordered) {
surface.advance(deltaTimeInSeconds);

View file

@ -5,16 +5,9 @@ interface InputSample {
direction: vec2;
}
// Keep a little over a second of input — far more than any sane reconciliation
// window — so a brief stall (or a backgrounded tab catching up) can still be
// replayed, while old samples are pruned to bound memory.
const retainMs = 1500;
// A timeline of the local player's movement directions in client-clock time.
// Each generated MoveActionCommand is stamped with the time this hands out, and
// the same sample is recorded here so the predictor replays exactly the input
// the server will eventually receive. Direction is piecewise-constant: the
// active direction at any instant is the most recent sample at or before it.
// Timeline of local movement directions; the predictor replays exactly the input the server receives.
export class InputHistory {
private samples: Array<InputSample> = [];
@ -27,8 +20,6 @@ export class InputHistory {
}
}
// The held direction at `timeMs`: the latest sample at or before it, or zero
// before any input exists.
public directionAt(timeMs: number): vec2 {
let direction = vec2.create();
for (const sample of this.samples) {

View file

@ -14,49 +14,26 @@ import {
import { ClientCharacterWorld, PredictablePlanet } from './client-character-world';
import { InputHistory } from './input-history';
// The server's fixed tick, read from the same place the server reads it — the
// replay integrates over this dt, so a comment promising they match is not
// enough.
// Must match the server's tick source — the replay integrates over this dt.
const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds;
const stepMs = stepSeconds * 1000;
// The client's clock: the timestamp of the frame currently being rendered, set
// once per frame by the game loop (requestAnimationFrame hands it to us
// already). Everything time-related on the client reads it from here, so input
// stamps, the outgoing send cadence and the replay window all sit on one
// timeline.
//
// Sampling performance.now() ad hoc instead — which is what this used to do —
// reads the frame's *dispatch* time, and the few ms of scheduling jitter
// between the frame's timestamp and the callback actually running went straight
// into the replay window. The body then advanced by a slightly different amount
// than the frame it was drawn in.
// Frame timestamp, not performance.now() — dispatch jitter would land in the replay window.
let frameTimeMs = 0;
export const setFrameTimeMs = (timeMs: number): void => {
frameTimeMs = timeMs;
};
export const predictorNowMs = (): number => frameTimeMs;
// Don't replay more than this far back: if the last acknowledged input is older
// (a stall, or a backgrounded tab catching up) fall back to a shorter window
// instead of grinding through hundreds of steps. Comfortably covers the replay
// a 300 ms round trip needs; beyond that the body simply trails a little.
const maxReplayMs = 400;
// Render-side easing of the correction the reconciliation produces each frame,
// so a snapshot that disagrees with the prediction is smoothed out instead of
// popping. Short, so the local player still feels immediate.
const smoothSeconds = 0.06;
// A correction bigger than this isn't prediction error — it's a respawn,
// teleport, or a server-side impulse (leap / slingshot / recoil / death throw)
// the predictor doesn't model. Snap to it rather than gliding across the gap.
const snapDistance = 250;
const upwards = vec2.fromValues(0, 1);
// Velocity is deliberately zero: the movement simulation rebuilds it from
// scratch every tick and zeroes it again at the end, so it is not carried state.
// Velocity starts at zero — the sim rebuilds it each tick, so it is not carried state.
const makeBody = (center: vec2, radius: number, normal: vec2): PhysicsBody => ({
center: vec2.clone(center),
radius,
@ -65,68 +42,22 @@ const makeBody = (center: vec2, radius: number, normal: vec2): PhysicsBody => ({
restitution: 0,
});
// Predicts the local player's character so it responds to input immediately,
// instead of lagging ~100 ms + RTT behind like the interpolated remote objects.
// Each frame it resets to the latest authoritative snapshot and replays the
// player's own un-acknowledged input through the SAME movement simulation the
// server runs (shared/stepCharacterMovement), then eases the rendered pose
// toward the result. Discrete server-side impulses it can't model show up as a
// large correction and snap rather than rubber-band.
export class LocalCharacterPredictor {
private readonly inputHistory = new InputHistory();
private readonly world = new ClientCharacterWorld();
private authoritative?: { head: Circle; leftFoot: Circle; rightFoot: Circle };
// Client-clock instant the authoritative pose below belongs to, less one
// one-way trip — which is exactly the lead the local body should have, since
// the input the player is giving right now will not reach the server for that
// long. Replaying from here reproduces every input the server has not applied
// yet, so the body responds immediately at any latency.
//
// It is built from BOTH halves of the acknowledgement: the client-clock time
// of the newest input the server had applied, plus how long after that the
// snapshot was actually taken. Both halves are needed.
//
// Anchoring on the acknowledged input time ALONE beats against the send
// cadence: at snapshot time the newest input the server holds is 0..1 send
// interval old depending on where the client's sends fell, and that age lands
// directly in the replay window. Measured on localhost it walked a sawtooth —
// 17, 10, 6, 1, 11, 6, 2, 12 ms — so the window jumped by up to a frame of
// travel every snapshot and the body stuttered at 25 Hz. ackAgeMs cancels it
// exactly.
//
// Anchoring on the snapshot's ARRIVAL is free of that beat, but it under-
// advances by a one-way trip, so the local body trails its own input at any
// real latency. This anchor degenerates to arrival on a zero-latency link and
// grows into full compensation as latency rises.
//
// A held key sends no fresh movement, which would freeze the acknowledgement
// altogether; that is fixed at the source, by closing every outgoing batch
// with a ClientHeartbeatCommand.
// Replay anchor: built from both halves of the ack (input time + ackAge), advances forward only.
private replayAnchorMs?: number;
// The rest of the server's movement state at that same instant: facing
// direction, launch momentum, foot contact normals and the latched planet.
// Together with the pose above it is everything stepCharacterMovement carries
// between ticks, so a replay continues the server's simulation instead of
// approximating it.
private movement?: CharacterMovementSnapshot;
// Frame times the player issued a leap, replayed (with the impulse applied
// locally) so the launch is felt immediately, not after a round trip.
private leapHistory: Array<number> = [];
// clientTimeMs of the last leap the server has folded into the streamed
// momentum. Leaps at or before this are already in movement.bodyVelocity;
// only newer ones are replayed, so a leap is never applied twice.
// Last leap the server folded into streamed momentum; newer ones only, so a leap is never applied twice.
private lastLeapAckMs = -Infinity;
// Latest streamed shooting-strength, to gate predicted leaps as the server does.
private currentStrength = settings.playerMaxStrength;
// Whether the local body is alive on the server. While dead (awaiting respawn)
// prediction is suppressed so the corpse/ghost can't keep walking in response
// to input — the server ignores a dead player's movement, so a predicted body
// that still moved would be a pure client-side desync.
// Dead players don't predict — the server ignores their input, so moving would desync.
private alive = true;
// The eased, rendered pose handed to the view.
private renderHead = new Circle(vec2.create(), headRadius);
private renderLeftFoot = new Circle(vec2.create(), feetRadius);
private renderRightFoot = new Circle(vec2.create(), feetRadius);
@ -142,8 +73,6 @@ export class LocalCharacterPredictor {
return this.renderRightFoot;
}
// Stamp a movement command and record it for replay. Returns the client-clock
// time the command should carry so the server can echo it back.
public recordInput(direction: vec2): number {
const timeMs = Math.round(frameTimeMs);
this.inputHistory.record(direction, timeMs);
@ -156,8 +85,7 @@ export class LocalCharacterPredictor {
lastLeapClientTimeMs: number,
ackAgeMs = 0,
): void {
// Inputs only advance the acknowledgement forward; the movement state and
// leap boundary always adopt the latest authoritative values.
// The anchor only advances forward; movement state and leap boundary always adopt the latest authoritative values.
const anchor = clientTimeMs + Math.max(0, ackAgeMs);
if (this.replayAnchorMs === undefined || anchor > this.replayAnchorMs) {
this.replayAnchorMs = anchor;
@ -170,10 +98,7 @@ export class LocalCharacterPredictor {
this.authoritative = { head, leftFoot, rightFoot };
}
// The player pressed leap; remember when, so the replay applies the same
// impulse the server will. Recorded regardless of whether the server accepts
// it — a rejected leap (no strength/cooldown) self-corrects via the streamed
// authoritative momentum.
// Recorded regardless of acceptance — a rejected leap self-corrects via streamed momentum.
public recordLeap(): number {
const timeMs = Math.round(frameTimeMs);
this.leapHistory.push(timeMs);
@ -212,10 +137,7 @@ export class LocalCharacterPredictor {
);
}
// During spawn-in and death the server freezes walking and only scales the
// body (CharacterPhysical.step returns early), so its head radius is below
// nominal. Predicting then would walk the body off a position the server is
// holding still — let interpolation show the animation instead.
// While spawn-in/death the server freezes walking (head radius < nominal) — don't predict, let interpolation show it.
private get isAnimatingInOrOut(): boolean {
return (
this.authoritative !== undefined &&
@ -223,13 +145,8 @@ export class LocalCharacterPredictor {
);
}
// Run one frame of prediction. Returns true if it produced a rendered pose the
// caller should use (otherwise fall back to interpolation). `planets` is the
// current collision world; `frameSeconds` is the render delta.
public update(planets: Array<PredictablePlanet>, frameSeconds: number): boolean {
if (!this.alive || !this.canPredict || this.isAnimatingInOrOut) {
// Resume from the authoritative pose with a snap rather than gliding from
// a stale rendered one.
this.hasRender = false;
return false;
}
@ -250,21 +167,14 @@ export class LocalCharacterPredictor {
const auth = this.authoritative!;
const movement = this.movement!;
const now = Math.round(frameTimeMs);
// Replay every input the server has not confirmed yet: from the anchor up to
// now, clamped so a stall (or a backgrounded tab catching up) cannot grind
// through hundreds of steps.
// Replay unconfirmed input from anchor to now, clamped so a stalled tab can't grind hundreds of steps.
const startMs = Math.min(now, Math.max(this.replayAnchorMs!, now - maxReplayMs));
const windowMs = Math.max(0, now - startMs);
const steps = Math.floor(windowMs / stepMs);
const remainderSeconds = (windowMs - steps * stepMs) / 1000;
// The full authoritative state: the pose from the property updates, the
// rest from the acknowledgement. Nothing here is inferred or carried over
// from the previous frame, so a replay is a pure function of (snapshot,
// input history, window) and cannot drift.
// Full authoritative state — replay is a pure function of (snapshot, input, window) and cannot drift.
const state: CharacterMovementState = {
// The head's contact normal is written by the collision but never read by
// the movement, so it does not need to travel.
head: makeBody(auth.head.center, auth.head.radius, upwards),
leftFoot: makeBody(
auth.leftFoot.center,
@ -279,22 +189,14 @@ export class LocalCharacterPredictor {
direction: movement.direction,
currentPlanet: this.world.surfaceById(movement.groundPlanetId),
secondsSinceOnSurface: movement.secondsSinceOnSurface,
// Leaps before the replay window are already baked into this; leaps inside
// the window are re-applied below, so neither is double-counted.
// Leaps before the window are baked in; inside it they're re-applied — neither double-counted.
bodyVelocity: vec2.fromValues(movement.bodyVelocity[0], movement.bodyVelocity[1]),
};
// The planet collision frames were synced (in update(), just before this)
// to the NEWEST snapshot's rotation — the same instant the authoritative
// body pose is from — so the body and the surface start the replay at the
// same phase. The loop below advances the surfaces FORWARD in lockstep with
// the body's carry from that shared phase, so no rewind is needed (and the
// persistent surfaces are re-synced next frame, so this never accumulates).
// Planet frames are synced to the newest snapshot's rotation, then advanced forward in lockstep with the body — no rewind needed.
const cooldownMs = settings.leapCooldownSeconds * 1000;
// Mirror the server: each accepted leap spends leapStrengthCost, so a burst
// of leaps in one replay window is gated by the running strength rather than
// a single up-front affordability check.
// Mirror the server: each accepted leap spends strength, so a burst is gated by running strength.
let availableStrength = this.currentStrength;
let lastLeapMs = -Infinity;
@ -305,15 +207,11 @@ export class LocalCharacterPredictor {
stepCharacterMovement(state, this.world, input, stepSeconds);
this.world.advance(stepSeconds);
// A leap issued during this step launches now (the next step injects the
// momentum), gated like the server: on a surface, off cooldown, with
// strength. applyLeapImpulse is a no-op off-surface.
for (const leapMs of this.leapHistory) {
if (
leapMs >= t &&
leapMs < t + stepMs &&
// Only leaps the server hasn't yet folded into the seed, so a leap
// is never both seeded and replayed.
// Only leaps the server hasn't seeded — never both seeded and replayed.
leapMs > this.lastLeapAckMs &&
state.currentPlanet &&
leapMs - lastLeapMs >= cooldownMs &&
@ -328,10 +226,7 @@ export class LocalCharacterPredictor {
t += stepMs;
}
// Final sub-tick of the leftover (< stepMs) so the predicted pose is a
// continuous function of the window length rather than advancing in 5 ms
// quanta — the floor() above would otherwise surface that as a per-frame
// wobble on top of the ~16.7 ms render cadence.
// Sub-tick the remainder so the pose is continuous in the window length, not quantized to stepMs.
if (remainderSeconds > 0) {
tickPlanetDetachment(state, remainderSeconds);
stepCharacterMovement(

View file

@ -1,27 +1,15 @@
import { clamp, settings } from 'shared';
// Convergence rate of the playback cursor towards its target; a deviation
// decays with a time constant of 1 / rateGain seconds.
const rateGain = 2;
// Playback speed stays within [0.75, 1.25]× so corrections are invisible.
const maxRateAdjustment = 0.25;
// Beyond this divergence (tab was in the background, server changed) chasing
// the target is pointless: jump straight to it.
const resyncSeconds = 0.3;
/**
* The playback clock for state streamed from the server.
*
* Snapshot timestamps estimate the server's clock: the newest received
* timestamp plus the time elapsed since it arrived. Rendering happens
* settings.interpolationDelaySeconds behind that estimate, so there is
* normally a newer snapshot to interpolate towards and network jitter is
* absorbed by the buffer instead of being shown.
*
* The cursor never jumps under normal operation: it runs at a gently adjusted
* rate to stay on target and only snaps after a long divergence.
* Playback clock for streamed server state — renders interpolationDelaySeconds
* behind the server clock so a newer snapshot is usually available; the cursor
* adjusts its rate gently and only snaps after a long divergence.
*/
class ServerTimeline {
private cursor?: number;
@ -29,12 +17,10 @@ class ServerTimeline {
private sinceNewestSnapshot = 0;
private _snapshotTime = 0;
/** Timestamp of the update batch currently being applied. */
public get snapshotTime(): number {
return this._snapshotTime;
}
/** The point on the server's clock that should be rendered this frame. */
public get renderTime(): number {
return this.cursor ?? 0;
}

View file

@ -1,7 +0,0 @@
export const show = (e: HTMLElement, useDisplay = false, normalDisplayValue?: string) => {
if (useDisplay) {
e.style.display = normalDisplayValue!;
} else {
e.style.visibility = 'inherit';
}
};

View file

@ -16,10 +16,7 @@ export class JoinFormHandler {
private resolvePlayerDecision!: (d: PlayerDecision) => void;
private pollServersTimer: any;
private keyUpListener = (e: KeyboardEvent) => {
// KeyboardEvent.key for Return is 'Enter' (capital E); the old lowercase
// comparison never matched, so pressing Enter silently did nothing.
// requestSubmit() (unlike submit()) fires the form's onsubmit handler and
// runs HTML5 validation, so Enter behaves exactly like clicking Join.
// requestSubmit() (not submit()) fires onsubmit and runs HTML5 validation, so Enter matches clicking Join.
if (e.key === 'Enter' && !this.joinButton.disabled) {
this.form.requestSubmit();
}
@ -79,9 +76,7 @@ export class JoinFormHandler {
let response: Response | undefined;
try {
response = await fetch(url + serverInformationEndpoint, { signal });
} catch {
// it's okay
}
} catch {}
if (response?.ok) {
const content: ServerInformation = await response.json();

View file

@ -1,4 +1,4 @@
import { vec2 } from 'gl-matrix';
import { vec2, vec3 } from 'gl-matrix';
import {
CircleLight,
FilteringOptions,
@ -8,12 +8,9 @@ import {
runAnimation,
WrapOptions,
} from 'sdf-2d';
import { settings, rgb, PlanetBase, Random } from 'shared';
import { settings, PlanetBase, Random } from 'shared';
import { PlanetShape } from './shapes/planet-shape';
// PlanetShape colours by mixing blue (0) -> red (1). The two backdrop planets
// read as the two in-game teams; the red planet is pulled a little off the
// pure-red end so it shows as a more muted, less saturated red.
const bluePlanet = 0;
const redPlanet = 0.85;
@ -46,7 +43,7 @@ export class LandingPageBackground {
{
shadowTraceCount: 16,
paletteSize: 1,
ambientLight: rgb(0, 0, 0),
ambientLight: vec3.fromValues(0, 0, 0),
lightCutoffDistance: settings.lightCutoffDistance,
textures: {
noiseTexture: {
@ -84,11 +81,6 @@ export class LandingPageBackground {
redPlanet,
);
// Fixed terrain phase (no longer animated -> no pulsing); the planet spins
// instead, the same way in-game planets do: PlanetShape's rotation uniform
// turns the whole body, terrain and outline together, in its own frame.
// Speeds sit in the game's per-planet range (~0.05-0.12 rad/s) and
// counter-rotate so the two planets don't drift in lockstep.
topPlanet.randomOffset = Random.getRandom();
topPlanet.rotation = (time / 1000) * 0.09;

Some files were not shown because too many files have changed in this diff Show more