This commit is contained in:
Andras Schmelczer 2026-06-14 20:58:42 +01:00
parent a1fb6755c7
commit fc9df09ee1
36 changed files with 2011 additions and 251 deletions

View file

@ -207,6 +207,14 @@ body {
@include square(50px);
border-radius: 1000px;
mask-image: url('../static/mask.svg');
&.contested {
animation: contested-pulse 0.7s ease-in-out infinite;
}
&.keystone {
@include square(72px);
}
}
.falling-point {
@ -244,6 +252,34 @@ body {
}
}
// Off-screen pointer to the keystone "Heart", tinted by its current owner.
.keystone-arrow {
transition: transform 150ms;
opacity: 0.9;
@include square($large-icon);
@media (max-width: $breakpoint) {
@include square($small-icon);
}
mask-image: url('../static/chevron.svg');
mask-size: contain;
background-color: $bright-neutral;
&.blue {
background-color: $bright-blue;
}
&.red {
background-color: $bright-red;
}
&.neutral {
background-color: $bright-neutral;
}
}
.joystick {
@include square($large-icon * 1.3);
background-color: white;
@ -296,6 +332,15 @@ body {
}
}
&.leap {
right: $medium-padding;
bottom: $medium-padding + $large-icon * 1.6 + $small-padding;
&::after {
content: '\25B2';
}
}
.strength-ring {
position: absolute;
top: -3px;
@ -572,6 +617,16 @@ body {
}
animation: hitmarker-pop 250ms ease-out forwards;
&.charged {
&::before,
&::after {
width: 18px;
height: 3px;
background-color: $accent;
}
}
}
.kill-popup {
@ -584,6 +639,10 @@ body {
text-shadow: 0 0 6px rgba(0, 0, 0, 0.9);
animation: kill-popup-rise 1200ms ease-out forwards;
&.charged {
color: $accent;
}
.heal {
color: #6fcf6f;
}
@ -626,6 +685,18 @@ body {
}
}
@keyframes contested-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.charge-ring {
position: fixed;
@include square(56px);
@ -649,6 +720,7 @@ body {
}
@keyframes match-point-pulse {
0%,
100% {
box-shadow: 0 0 4px 0 currentColor;

View file

@ -1,5 +1,6 @@
import { vec2 } from 'gl-matrix';
import { CommandGenerator, MoveActionCommand } from 'shared';
import { CommandGenerator, LeapActionCommand, MoveActionCommand } from 'shared';
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
export class KeyboardListener extends CommandGenerator {
private keysDown: Set<string> = new Set();
@ -13,7 +14,14 @@ export class KeyboardListener extends CommandGenerator {
}
private keyDownListener = (event: KeyboardEvent) => {
this.keysDown.add(event.key.toLowerCase());
const key = event.key.toLowerCase();
// Space leaps (W / ArrowUp already cover walking up). Edge-triggered so a
// held key's auto-repeat doesn't spam leaps.
if ((key === ' ' || key === 'shift') && !this.keysDown.has(key)) {
const clientTimeMs = localCharacterPredictor.recordLeap();
this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs));
}
this.keysDown.add(key);
this.generateCommands();
};
@ -28,11 +36,7 @@ export class KeyboardListener extends CommandGenerator {
};
private generateCommands() {
const up = ~~(
this.keysDown.has('w') ||
this.keysDown.has('arrowup') ||
this.keysDown.has(' ')
);
const up = ~~(this.keysDown.has('w') || this.keysDown.has('arrowup'));
const down = ~~(this.keysDown.has('s') || this.keysDown.has('arrowdown'));
const left = ~~(this.keysDown.has('a') || this.keysDown.has('arrowleft'));
const right = ~~(this.keysDown.has('d') || this.keysDown.has('arrowright'));
@ -42,7 +46,8 @@ export class KeyboardListener extends CommandGenerator {
vec2.normalize(movement, movement);
}
this.sendCommandToSubscribers(new MoveActionCommand(movement));
const clientTimeMs = localCharacterPredictor.recordInput(movement);
this.sendCommandToSubscribers(new MoveActionCommand(movement, clientTimeMs));
}
public destroy() {

View file

@ -4,11 +4,13 @@ import {
MoveActionCommand,
last,
PrimaryActionCommand,
LeapActionCommand,
holdDurationToCharge,
settings,
} from 'shared';
import { Game } from '../game';
import { ChargeIndicator } from '../charge-indicator';
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
export class TouchListener extends CommandGenerator {
private static readonly deadZone = 8;
@ -22,6 +24,7 @@ export class TouchListener extends CommandGenerator {
private fireButton: HTMLElement;
private fireStrengthRing: HTMLElement;
private leapButton: HTMLElement;
private fireDownAt: number | null = null;
@ -46,7 +49,12 @@ export class TouchListener extends CommandGenerator {
this.fireButton.addEventListener('touchstart', this.fireButtonDownListener);
this.fireButton.addEventListener('touchend', this.fireButtonUpListener);
this.leapButton = document.createElement('div');
this.leapButton.className = 'touch-button leap';
this.leapButton.addEventListener('touchstart', this.leapButtonListener);
this.overlay.appendChild(this.fireButton);
this.overlay.appendChild(this.leapButton);
target.addEventListener('touchstart', this.touchStartListener);
target.addEventListener('touchmove', this.touchMoveListener);
@ -101,12 +109,17 @@ export class TouchListener extends CommandGenerator {
vec2.set(delta, delta.x, -delta.y);
if (deltaLength > TouchListener.deadZone) {
const direction = vec2.normalize(delta, delta);
this.sendCommandToSubscribers(new MoveActionCommand(direction));
this.sendMove(direction);
} else {
this.sendCommandToSubscribers(new MoveActionCommand(vec2.create()));
this.sendMove(vec2.create());
}
};
private sendMove(direction: vec2) {
const clientTimeMs = localCharacterPredictor.recordInput(direction);
this.sendCommandToSubscribers(new MoveActionCommand(direction, clientTimeMs));
}
private touchEndListener = (event: TouchEvent) => {
event.preventDefault();
@ -127,7 +140,7 @@ export class TouchListener extends CommandGenerator {
} else if (event.touches.length === 0) {
this.isJoystickActive = false;
this.joystick.parentElement?.removeChild(this.joystick);
this.sendCommandToSubscribers(new MoveActionCommand(vec2.create()));
this.sendMove(vec2.create());
}
};
@ -136,6 +149,12 @@ export class TouchListener extends CommandGenerator {
event.stopPropagation();
};
private leapButtonListener = (event: TouchEvent) => {
this.swallowTouch(event);
const clientTimeMs = localCharacterPredictor.recordLeap();
this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs));
};
private fireButtonDownListener = (event: TouchEvent) => {
this.swallowTouch(event);
this.fireDownAt = performance.now();
@ -170,6 +189,9 @@ export class TouchListener extends CommandGenerator {
if (!this.fireButton.parentElement) {
this.overlay.appendChild(this.fireButton);
}
if (!this.leapButton.parentElement) {
this.overlay.appendChild(this.leapButton);
}
const character = this.game.gameObjects.player;
if (character) {
@ -186,7 +208,9 @@ export class TouchListener extends CommandGenerator {
this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener);
this.fireButton.removeEventListener('touchend', this.fireButtonUpListener);
this.leapButton.removeEventListener('touchstart', this.leapButtonListener);
this.fireButton.parentElement?.removeChild(this.fireButton);
this.leapButton.parentElement?.removeChild(this.leapButton);
}
}

View file

@ -33,17 +33,19 @@ export abstract class FeedbackHud {
setTimeout(() => element.parentElement?.removeChild(element), lifetimeMs);
}
public static hitMarker() {
public static hitMarker(charge = 0) {
const { x, y } = this.focusPoint();
const marker = document.createElement('div');
marker.className = 'hitmarker';
marker.className =
'hitmarker' + (charge >= settings.chargedHitThreshold ? ' charged' : '');
marker.style.left = `${x}px`;
marker.style.top = `${y}px`;
this.addTransient(marker, 250);
}
public static killConfirmed(victimName?: string, streak = 1) {
public static killConfirmed(victimName?: string, streak = 1, charge = 0) {
const { killfeed } = this.ensureRoot();
const charged = charge >= settings.chargedHitThreshold;
const entry = document.createElement('div');
entry.className = 'kill-entry';
@ -53,13 +55,13 @@ export abstract class FeedbackHud {
const { x, y } = this.focusPoint();
const popup = document.createElement('div');
popup.className = 'kill-popup';
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 callout = this.streakName(streak);
const callout = this.streakName(streak) ?? (charged ? 'Charged Kill!' : undefined);
if (callout) {
const el = document.createElement('div');
el.className = 'streak-callout';

View file

@ -21,6 +21,7 @@ import {
CommandExecutors,
Command,
settings,
InputAcknowledgement,
} from 'shared';
import { io, Socket } from 'socket.io-client';
import { KeyboardListener } from './commands/keyboard-listener';
@ -35,6 +36,7 @@ import { PlanetShape } from './shapes/planet-shape';
import { RenderCommand } from './commands/types/render';
import { StepCommand } from './commands/types/step';
import { serverTimeline } from './helper/server-timeline';
import { localCharacterPredictor } from './helper/prediction/local-character-predictor';
import { Tutorial } from './tutorial';
import { Scoreboard } from './scoreboard';
@ -54,6 +56,7 @@ export class Game extends CommandReceiver {
private scoreboard = new Scoreboard();
private announcementText = document.createElement('h2');
private arrows: { [id: number]: HTMLElement } = {};
private keystoneArrow?: HTMLElement;
private socketReceiver!: CommandSocket;
private tutorial!: Tutorial;
@ -76,9 +79,11 @@ export class Game extends CommandReceiver {
this.socket?.close();
serverTimeline.reset();
localCharacterPredictor.reset();
this.gameObjects = new GameObjectContainer(this);
this.overlay.innerHTML = '';
this.arrows = {};
this.keystoneArrow = undefined;
this.isEnding = false;
this.lastAnnouncementText = '';
this.overlay.appendChild(this.scoreboard.element);
@ -148,6 +153,12 @@ export class Game extends CommandReceiver {
this.timeSinceLastAnnouncement = 0;
},
[UpdateGameState.type]: (c: UpdateGameState) => (this.lastGameState = c),
[InputAcknowledgement.type]: (c: InputAcknowledgement) =>
localCharacterPredictor.acknowledge(
c.clientTimeMs,
c.bodyVelocity,
c.lastLeapClientTimeMs,
),
[GameEndCommand.type]: () => (this.isEnding = true),
[UpdateOtherPlayerDirections.type]: (c: UpdateOtherPlayerDirections) =>
(this.lastOtherPlayerDirections = c),
@ -325,6 +336,71 @@ export class Game extends CommandReceiver {
this.handleOtherPlayerDirections(this.lastOtherPlayerDirections);
}
this.handleKeystoneArrow();
this.announcementText.innerHTML = this.lastAnnouncementText;
}
// 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;
}
const keystone = this.gameObjects.planets.find((p) => p.isKeystone);
if (!keystone) {
if (this.keystoneArrow) {
this.keystoneArrow.style.display = 'none';
}
return;
}
if (!this.keystoneArrow) {
this.keystoneArrow = document.createElement('div');
this.overlay.appendChild(this.keystoneArrow);
}
const width = this.renderer.canvasSize.x;
const height = this.renderer.canvasSize.y;
const display = this.renderer.worldToDisplayCoordinates(keystone.center);
const margin = 48;
const onScreen =
display.x >= margin &&
display.x <= width - margin &&
display.y >= margin &&
display.y <= height - margin;
const control = Math.abs(keystone.ownership - 0.5);
const team =
control < settings.planetControlThreshold
? 'neutral'
: keystone.ownership < 0.5
? 'blue'
: 'red';
this.keystoneArrow.className = 'keystone-arrow ' + team;
if (onScreen) {
this.keystoneArrow.style.display = 'none';
return;
}
this.keystoneArrow.style.display = 'block';
const center = vec2.fromValues(width / 2, height / 2);
const dir = vec2.fromValues(display.x - center.x, display.y - center.y);
const angle = Math.atan2(dir.y, dir.x);
const aspectRatio = width / height;
const directionRatio = dir.x / dir.y;
let deltaX: number, deltaY: number;
if (aspectRatio < Math.abs(directionRatio)) {
deltaX = (width / 2 - margin) * Math.sign(dir.x);
deltaY = deltaX / directionRatio;
} else {
deltaY = (height / 2 - margin) * Math.sign(dir.y);
deltaX = deltaY * directionRatio;
}
const p = vec2.add(center, center, vec2.fromValues(deltaX, deltaY));
this.keystoneArrow.style.transform = `translateX(${p.x}px) translateY(${p.y}px) translateX(-50%) translateY(-50%) rotate(${angle + Math.PI / 2}rad)`;
}
}

View file

@ -0,0 +1,138 @@
import { vec2 } from 'gl-matrix';
import {
CharacterWorld,
GroundSurface,
Id,
PhysicsBody,
planetDistance,
planetGravity,
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.
export interface PredictablePlanet {
id: Id;
vertices: Array<vec2>;
center: vec2;
radius: number;
rotation: number;
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.
class PlanetSurface implements GroundSurface {
public readonly canCollide = true;
public readonly isGround = true;
public readonly id: Id;
public center: vec2;
public angularVelocity: number;
private vertices: Array<vec2>;
private radius: number;
private rotation = 0;
private cos = 1;
private sin = 0;
constructor(planet: PredictablePlanet) {
this.id = planet.id;
this.center = planet.center;
this.vertices = planet.vertices;
this.radius = planet.radius;
this.angularVelocity = planet.rotationSpeed;
this.setRotation(planet.rotation);
}
public sync(planet: PredictablePlanet) {
this.center = planet.center;
this.vertices = planet.vertices;
this.radius = planet.radius;
this.angularVelocity = planet.rotationSpeed;
this.setRotation(planet.rotation);
}
private setRotation(rotation: number) {
this.rotation = rotation;
this.cos = Math.cos(rotation);
this.sin = Math.sin(rotation);
}
public advance(deltaTimeInSeconds: number) {
this.setRotation(this.rotation + this.angularVelocity * deltaTimeInSeconds);
}
public distance(target: vec2): number {
return planetDistance(target, this.vertices, this.center, this.cos, this.sin);
}
public gravityAt(target: vec2): vec2 {
return planetGravity(this.center, this.radius, target);
}
}
// 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.
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.
public sync(planets: Array<PredictablePlanet>) {
const seen = new Set<Id>();
for (const planet of planets) {
seen.add(planet.id);
const existing = this.surfaces.get(planet.id);
if (existing) {
existing.sync(planet);
} else {
this.surfaces.set(planet.id, new PlanetSurface(planet));
}
}
for (const id of [...this.surfaces.keys()]) {
if (!seen.has(id)) {
this.surfaces.delete(id);
}
}
this.ordered = [...this.surfaces.entries()]
.sort((a, b) => Number(a[0]) - Number(b[0]))
.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.
public advance(deltaTimeInSeconds: number) {
for (const surface of this.ordered) {
surface.advance(deltaTimeInSeconds);
}
}
public surfaceById(id: Id | undefined): GroundSurface | undefined {
return id == null ? undefined : this.surfaces.get(id);
}
public idOf(surface: GroundSurface | undefined): Id | undefined {
return surface instanceof PlanetSurface ? surface.id : undefined;
}
public groundsNear(): Array<GroundSurface> {
return this.ordered;
}
public stepBody(
body: PhysicsBody,
deltaTimeInSeconds: number,
): GroundSurface | undefined {
const { hitObject } = resolveCircleMovement(body, deltaTimeInSeconds, this.ordered);
return hitObject && (hitObject as GroundSurface).isGround
? (hitObject as GroundSurface)
: undefined;
}
}

View file

@ -0,0 +1,47 @@
import { vec2 } from 'gl-matrix';
interface InputSample {
timeMs: number;
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.
export class InputHistory {
private samples: Array<InputSample> = [];
public record(direction: vec2, timeMs: number): void {
this.samples.push({ timeMs, direction: vec2.clone(direction) });
const cutoff = timeMs - retainMs;
while (this.samples.length > 1 && this.samples[1].timeMs <= cutoff) {
this.samples.shift();
}
}
// 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) {
if (sample.timeMs <= timeMs) {
direction = sample.direction;
} else {
break;
}
}
return vec2.clone(direction);
}
public reset(): void {
this.samples = [];
}
}

View file

@ -0,0 +1,328 @@
import { vec2 } from 'gl-matrix';
import {
Circle,
CharacterMovementState,
Id,
PhysicsBody,
settings,
stepCharacterMovement,
applyLeapImpulse,
tickPlanetDetachment,
feetRadius,
headRadius,
} from 'shared';
import { ClientCharacterWorld, PredictablePlanet } from './client-character-world';
import { InputHistory } from './input-history';
const stepMs = 1000 / 200; // match the server's 200 Hz fixed tick
const stepSeconds = 1 / 200;
// Don't replay more than this far back: if the last acknowledged input is older
// (a stall, or a backgrounded tab catching up) snap to the authoritative pose
// instead of grinding through hundreds of steps.
const maxReplayMs = 300;
// 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 makeBody = (center: vec2, radius: number): PhysicsBody => ({
center: vec2.clone(center),
radius,
velocity: vec2.create(),
lastNormal: vec2.fromValues(0, 1),
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 };
private lastAckClientTimeMs?: number;
// Wall-clock (client) time the latest authoritative snapshot was received. The
// replay predicts forward from HERE by the snapshot's age, so the local pose
// advances smoothly with real time between the 25 Hz snapshots. Anchoring to
// the last *acked input* time instead breaks when input is sent only on change
// (a held key sends nothing): that time freezes, the window pins to the
// maxReplayMs clamp, the replay displacement goes constant, and the pose
// stair-steps at the snapshot rate.
private authReceiptMs = 0;
// Authoritative launch momentum at the last snapshot — seeds each replay so a
// leap/slingshot/recoil flight is reproduced and continuously corrected.
private authoritativeBodyVelocity = vec2.create();
// Wall-clock 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 authoritativeBodyVelocity;
// only newer ones are replayed, 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;
// Continuous state carried between replays (the snapshot carries only poses).
// The facing direction is NOT carried — it is re-derived from the pose each
// frame (see directionFromPose / simulate); only the latched planet and the
// time-since-surface persist.
private carriedPlanetId?: Id;
private carriedSecondsSinceSurface = 1;
// 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);
private hasRender = false;
public get head(): Circle {
return this.renderHead;
}
public get leftFoot(): Circle {
return this.renderLeftFoot;
}
public get rightFoot(): Circle {
return this.renderRightFoot;
}
// Stamp a movement command and record it for replay. Returns the wall-clock
// time the command should carry so the server can echo it back.
public recordInput(direction: vec2): number {
const timeMs = Math.round(performance.now());
this.inputHistory.record(direction, timeMs);
return timeMs;
}
public acknowledge(
clientTimeMs: number,
bodyVelocity: vec2,
lastLeapClientTimeMs: number,
): void {
// Inputs only advance the acknowledgement forward; the launch momentum and
// leap boundary always adopt the latest authoritative values.
if (this.lastAckClientTimeMs === undefined || clientTimeMs > this.lastAckClientTimeMs) {
this.lastAckClientTimeMs = clientTimeMs;
}
vec2.set(this.authoritativeBodyVelocity, bodyVelocity[0], bodyVelocity[1]);
this.lastLeapAckMs = lastLeapClientTimeMs;
}
public setAuthoritative(head: Circle, leftFoot: Circle, rightFoot: Circle): void {
this.authoritative = { head, leftFoot, rightFoot };
this.authReceiptMs = Math.round(performance.now());
}
// 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.
public recordLeap(): number {
const timeMs = Math.round(performance.now());
this.leapHistory.push(timeMs);
const cutoff = timeMs - 1500;
while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) {
this.leapHistory.shift();
}
return timeMs;
}
public setStrength(strength: number): void {
this.currentStrength = strength;
}
public reset(): void {
this.inputHistory.reset();
this.leapHistory = [];
this.lastLeapAckMs = -Infinity;
this.authoritative = undefined;
this.lastAckClientTimeMs = undefined;
this.authReceiptMs = 0;
vec2.zero(this.authoritativeBodyVelocity);
this.currentStrength = settings.playerMaxStrength;
this.carriedPlanetId = undefined;
this.carriedSecondsSinceSurface = 1;
this.hasRender = false;
}
public get canPredict(): boolean {
return this.authoritative !== undefined && this.lastAckClientTimeMs !== undefined;
}
// 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.
private get isAnimatingInOrOut(): boolean {
return (
this.authoritative !== undefined &&
this.authoritative.head.radius < headRadius - 0.5
);
}
// 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.canPredict || this.isAnimatingInOrOut) {
// Resume from the authoritative pose with a snap rather than gliding from
// a stale rendered one.
this.hasRender = false;
return false;
}
this.world.sync(planets);
const predicted = this.simulate();
if (!this.hasRender) {
this.snapRenderTo(predicted);
this.hasRender = true;
} else {
this.easeRenderTo(predicted, frameSeconds);
}
return true;
}
// The body's facing angle is encoded in the pose: each part is sprung toward
// center + R(direction)*offset and the head's offset points +y, so
// direction = atan2(head - center) - PI/2. Re-deriving it from the snapshot
// each frame (rather than carrying the previous frame's evolved value onto
// this past pose, re-evolved by a variable substep count) keeps the posture
// seed a pure function of the snapshot — carrying it fed a frame-rate-dependent
// loop that wobbled the rendered limbs.
private directionFromPose(head: Circle, leftFoot: Circle, rightFoot: Circle): number {
const cx = (head.center[0] + leftFoot.center[0] + rightFoot.center[0]) / 3;
const cy = (head.center[1] + leftFoot.center[1] + rightFoot.center[1]) / 3;
return Math.atan2(head.center[1] - cy, head.center[0] - cx) - Math.PI / 2;
}
private simulate(): CharacterMovementState {
const auth = this.authoritative!;
const now = Math.round(performance.now());
// Predict forward from the latest snapshot by its age, clamped so a stall
// (or a backgrounded tab catching up) snaps instead of grinding hundreds of
// steps. This advances with wall-clock time even while a held key sends no
// fresh input, so the pose no longer pins to the 25 Hz snapshot cadence.
const startMs = Math.max(this.authReceiptMs, now - maxReplayMs);
const windowMs = Math.max(0, now - startMs);
const steps = Math.floor(windowMs / stepMs);
const remainderSeconds = (windowMs - steps * stepMs) / 1000;
const state: CharacterMovementState = {
head: makeBody(auth.head.center, auth.head.radius),
leftFoot: makeBody(auth.leftFoot.center, auth.leftFoot.radius),
rightFoot: makeBody(auth.rightFoot.center, auth.rightFoot.radius),
direction: this.directionFromPose(auth.head, auth.leftFoot, auth.rightFoot),
currentPlanet: this.world.surfaceById(this.carriedPlanetId),
secondsSinceOnSurface: this.carriedSecondsSinceSurface,
// Leaps before the replay window are already baked into this; leaps inside
// the window are re-applied below, so neither is double-counted.
bodyVelocity: vec2.clone(this.authoritativeBodyVelocity),
};
// 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).
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.
let availableStrength = this.currentStrength;
let lastLeapMs = -Infinity;
let t = startMs;
for (let i = 0; i < steps; i++) {
const input = this.inputHistory.directionAt(t);
tickPlanetDetachment(state, stepSeconds);
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.
leapMs > this.lastLeapAckMs &&
state.currentPlanet &&
leapMs - lastLeapMs >= cooldownMs &&
availableStrength >= settings.leapStrengthCost
) {
applyLeapImpulse(state, this.inputHistory.directionAt(leapMs));
availableStrength -= settings.leapStrengthCost;
lastLeapMs = leapMs;
}
}
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.
if (remainderSeconds > 0) {
tickPlanetDetachment(state, remainderSeconds);
stepCharacterMovement(
state,
this.world,
this.inputHistory.directionAt(now),
remainderSeconds,
);
this.world.advance(remainderSeconds);
}
this.carriedPlanetId = this.world.idOf(state.currentPlanet);
this.carriedSecondsSinceSurface = state.secondsSinceOnSurface;
return state;
}
private snapRenderTo(state: CharacterMovementState): void {
this.renderHead = new Circle(vec2.clone(state.head.center), state.head.radius);
this.renderLeftFoot = new Circle(
vec2.clone(state.leftFoot.center),
state.leftFoot.radius,
);
this.renderRightFoot = new Circle(
vec2.clone(state.rightFoot.center),
state.rightFoot.radius,
);
}
private easeRenderTo(state: CharacterMovementState, frameSeconds: number): void {
const q = 1 - Math.exp(-frameSeconds / smoothSeconds);
this.easePart(this.renderHead, state.head, q);
this.easePart(this.renderLeftFoot, state.leftFoot, q);
this.easePart(this.renderRightFoot, state.rightFoot, q);
}
private easePart(render: Circle, target: PhysicsBody, q: number): void {
if (vec2.distance(render.center, target.center) > snapDistance) {
vec2.copy(render.center, target.center);
} else {
vec2.lerp(render.center, render.center, target.center, q);
}
render.radius = target.radius;
}
}
export const localCharacterPredictor = new LocalCharacterPredictor();

View file

@ -1,4 +1,5 @@
import {
Circle,
Command,
CommandExecutors,
CommandReceiver,
@ -9,11 +10,15 @@ import {
Id,
PropertyUpdatesForObjects,
RemoteCallsForObjects,
settings,
UpdatePropertyCommand,
} from 'shared';
import { BeforeDestroyCommand } from '../commands/types/before-destroy';
import { StepCommand } from '../commands/types/step';
import { Game } from '../game';
import { serverTimeline } from '../helper/server-timeline';
import { PredictablePlanet } from '../helper/prediction/client-character-world';
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
import { Camera } from './types/camera';
import { CharacterView } from './types/character-view';
import { PlanetView } from './types/planet-view';
@ -28,6 +33,9 @@ export class GameObjectContainer extends CommandReceiver {
this.player = c.character as CharacterView;
this.player.isMainCharacter = true;
this.addObject(this.player);
// Fresh character (first spawn or respawn at a far planet): drop any
// prediction state so it snaps to the new body instead of gliding across.
localCharacterPredictor.reset();
},
[CreateObjectsCommand.type]: (c: CreateObjectsCommand) =>
@ -37,6 +45,18 @@ export class GameObjectContainer extends CommandReceiver {
this.defaultCommandExecutor(c);
if (this.player) {
// Override the interpolated pose of the local player with the predicted
// one so it responds to input immediately. Remote objects keep
// interpolating. A large correction (respawn / death) snaps inside the
// predictor rather than rubber-banding.
localCharacterPredictor.setStrength(
this.player.strengthFraction * settings.playerMaxStrength,
);
if (localCharacterPredictor.update(this.predictablePlanets(), c.deltaTimeInSeconds)) {
this.player.head = localCharacterPredictor.head;
this.player.leftFoot = localCharacterPredictor.leftFoot;
this.player.rightFoot = localCharacterPredictor.rightFoot;
}
this.camera.follow(this.player.position, c.deltaTimeInSeconds);
}
},
@ -48,9 +68,12 @@ export class GameObjectContainer extends CommandReceiver {
[PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => {
serverTimeline.onSnapshot(c.timestamp);
c.updates.forEach((u) =>
u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au)),
);
c.updates.forEach((u) => {
u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au));
if (this.player && u.id === this.player.id) {
this.feedPredictor(u.updates);
}
});
},
[DeleteObjectsCommand.type]: (c: DeleteObjectsCommand) =>
@ -76,6 +99,34 @@ export class GameObjectContainer extends CommandReceiver {
this.camera.handleCommand(c);
}
// Hand the local player's raw authoritative pose to the predictor (the
// interpolated pose would already be ~100 ms stale). The three body parts
// arrive together in one snapshot.
private feedPredictor(updates: Array<UpdatePropertyCommand>) {
let head: Circle | undefined;
let leftFoot: Circle | undefined;
let rightFoot: Circle | undefined;
for (const u of updates) {
if (u.propertyKey === 'head') head = u.propertyValue as Circle;
else if (u.propertyKey === 'leftFoot') leftFoot = u.propertyValue as Circle;
else if (u.propertyKey === 'rightFoot') rightFoot = u.propertyValue as Circle;
}
if (head && leftFoot && rightFoot) {
localCharacterPredictor.setAuthoritative(head, leftFoot, rightFoot);
}
}
private predictablePlanets(): Array<PredictablePlanet> {
return this.planets.map((p) => ({
id: p.id,
vertices: p.vertices,
center: p.center,
radius: p.radius,
rotation: p.predictionRotation,
rotationSpeed: p.predictionRotationSpeed,
}));
}
private addObject(object: GameObject) {
this.objects.set(object.id, object);
}

View file

@ -176,21 +176,32 @@ export class CharacterView extends CharacterBase {
}
}
public onHitConfirmed() {
public onHitConfirmed(charge = 0) {
if (!this.isMainCharacter) {
return;
}
SoundHandler.play(Sounds.click, 0.4, 1.7);
FeedbackHud.hitMarker();
// A charged hit lands lower and harder than a panic tap.
SoundHandler.play(Sounds.click, mix(0.4, 0.75, charge), mix(1.7, 1.05, charge));
if (charge >= settings.chargedHitThreshold) {
VibrationHandler.vibrate(25);
}
FeedbackHud.hitMarker(charge);
}
public onKillConfirmed(victimName?: string, streak = 1) {
public onKillConfirmed(victimName?: string, streak = 1, charge = 0) {
if (!this.isMainCharacter) {
return;
}
SoundHandler.play(Sounds.click, 1, 0.7);
VibrationHandler.vibrate(60);
FeedbackHud.killConfirmed(victimName, streak);
SoundHandler.play(Sounds.click, 1, mix(0.7, 0.5, charge));
VibrationHandler.vibrate(mix(60, 110, charge));
FeedbackHud.killConfirmed(victimName, streak, charge);
}
public onLeap() {
if (!this.isMainCharacter) {
return;
}
SoundHandler.play(Sounds.shoot, 0.3, 1.5);
}
private step({ deltaTimeInSeconds }: StepCommand): void {

View file

@ -56,17 +56,39 @@ export class PlanetView extends PlanetBase {
[UpdatePropertyCommand.type]: this.updateProperty.bind(this),
};
constructor(id: Id, vertices: Array<vec2>, ownership: number) {
super(id, vertices);
constructor(id: Id, vertices: Array<vec2>, ownership = 0.5, isKeystone = false) {
super(id, vertices, ownership, isKeystone);
this.shape = new PlanetShape(vertices, ownership);
this.shape.randomOffset = Random.getRandom();
this.ownershipProgress = document.createElement('div');
this.ownershipProgress.className = 'ownership';
this.ownershipProgress.className = 'ownership' + (isKeystone ? ' keystone' : '');
}
public setContested(contested: boolean) {
this.ownershipProgress.classList.toggle('contested', contested);
}
private renderedRotation = 0;
private rotationSpeed = 0;
// Newest streamed rotation VALUE — the server-current angle at the latest
// snapshot — as opposed to renderedRotation, which the interpolator holds
// ~interpolationDelaySeconds in the PAST for drawing. The predictor seeds the
// local body from the same snapshot's pose, so it must collide against the
// planet at THIS (newest) phase and advance forward from it; using the drawn
// lagged angle biases the body off the surface by omega*delay*radius and
// wobbles it whenever the spin or the interpolator's rate cursor varies.
private latestRotation = 0;
public get predictionRotation(): number {
return this.latestRotation;
}
public get predictionRotationSpeed(): number {
return this.rotationSpeed;
}
private step({ deltaTimeInSeconds }: StepCommand): void {
this.shape.rotation = this.rotationInterpolator.getValue(deltaTimeInSeconds);
this.renderedRotation = this.rotationInterpolator.getValue(deltaTimeInSeconds);
this.shape.rotation = this.renderedRotation;
this.shape.colorMixQ = this.ownership;
if (this.flareIntensity > 0) {
@ -129,6 +151,8 @@ export class PlanetView extends PlanetBase {
}: UpdatePropertyCommand): void {
if (propertyKey === 'rotation') {
this.rotationInterpolator.addFrame(propertyValue, rateOfChange);
this.latestRotation = propertyValue;
this.rotationSpeed = rateOfChange;
} else {
this.ownership = propertyValue;
}
@ -170,7 +194,12 @@ export class PlanetView extends PlanetBase {
private getGradient(): string {
const sideBlue = this.ownership < 0.5;
const sidePercent = (Math.abs(this.ownership - 0.5) / 0.5) * 100;
// Keep the ring neutral through the same dead-band that gates scoring
// (settings.planetControlThreshold), so "the ring fills" and "this planet
// pays my team" happen together rather than disagreeing.
const control = Math.abs(this.ownership - 0.5);
const t = settings.planetControlThreshold;
const sidePercent = control <= t ? 0 : ((control - t) / (0.5 - t)) * 100;
return sideBlue
? `conic-gradient(
var(--bright-blue) ${sidePercent}%,

View file

@ -103,8 +103,8 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
if (dist < minDistance) {
minDistance = dist;
color = mix(${colorToString(settings.bluePlanetColor)}, ${colorToString(
settings.redPlanetColor,
)}, planetColorMixQ[j]);
settings.redPlanetColor,
)}, planetColorMixQ[j]);
}
}
@ -129,11 +129,32 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
public randomOffset = 0;
public rotation = 0;
// Circle about the rotation centre (the vertex centroid, which is what the
// shader spins around — see planetMinDistance above). The vertices never
// change after construction, so cache it once.
private readonly cullCenter: vec2;
private readonly cullRadius: number;
constructor(
public vertices: Array<vec2>,
public colorMixQ: number,
) {
super(vertices);
this.cullCenter = vertices.reduce(
(sum, v) => vec2.add(sum, sum, v),
vec2.create(),
);
vec2.scale(this.cullCenter, this.cullCenter, 1 / vertices.length);
this.cullRadius = vertices.reduce(
(max, v) => Math.max(max, vec2.distance(this.cullCenter, v)),
0,
);
}
public minDistance(target: vec2): number {
return vec2.distance(target, this.cullCenter) - this.cullRadius;
}
protected getObjectToSerialize(transform2d: mat2d, _: number): any {

View file

@ -4,17 +4,19 @@ import {
CommandExecutors,
CommandReceiver,
Id,
LeapActionCommand,
MoveActionCommand,
PrimaryActionCommand,
} from 'shared';
import { GameObjectContainer } from './objects/game-object-container';
import { PlanetView } from './objects/types/planet-view';
type StageTrigger = 'move' | 'shoot' | 'capture';
type StageTrigger = 'move' | 'shoot' | 'leap' | 'capture';
const stages: ReadonlyArray<{ hint: string; clearsOn: StageTrigger }> = [
{ hint: 'WASD / drag to walk', clearsOn: 'move' },
{ hint: 'Click / tap to shoot', clearsOn: 'shoot' },
{ hint: 'Space / leap button to launch off a planet', clearsOn: 'leap' },
{ hint: 'Stand on a planet to capture it', clearsOn: 'capture' },
];
@ -42,6 +44,11 @@ export class Tutorial extends CommandReceiver {
this.advance();
}
},
[LeapActionCommand.type]: () => {
if (this.clearsOn() === 'leap') {
this.advance();
}
},
};
constructor(overlay: HTMLElement) {