This commit is contained in:
Andras Schmelczer 2026-08-26 07:47:30 +01:00
commit 86e97df0dc
6 changed files with 414 additions and 35 deletions

View file

@ -127,7 +127,6 @@ body {
.player-tag {
border-radius: 1000px;
transition: transform 200ms;
&.blue {
color: $bright-blue;
@ -255,7 +254,6 @@ 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);

View file

@ -347,6 +347,9 @@ export class Game extends CommandReceiver {
}
private draw() {
// Before any style writes this frame, so the read cannot force a synchronous layout.
this.minimap.measure();
if (this.lastGameState) {
this.scoreboard.update(this.lastGameState, this.gameObjects.localPlayer?.team);
}

View file

@ -5,8 +5,7 @@ import {
CharacterMovementState,
PhysicsBody,
settings,
clamp,
easeVec2,
followVec2,
stepCharacterMovement,
applyLeapImpulse,
tickPlanetDetachment,
@ -24,10 +23,12 @@ let frameTimeMs = 0;
export const setFrameTimeMs = (timeMs: number): void => {
frameTimeMs = timeMs;
};
export const clientTimeMs = (): number => Math.round(frameTimeMs);
export const clientTimeMs = (): number => frameTimeMs;
const maxReplayMs = 400;
// Only a correction has to fade; the character's own motion is carried straight through, so this
// buys smoothing without costing responsiveness.
const smoothSeconds = 0.06;
const snapDistance = 250;
@ -58,6 +59,7 @@ export class LocalCharacterPredictor {
private renderHead = new Circle(vec2.create(), headRadius);
private renderLeftFoot = new Circle(vec2.create(), feetRadius);
private renderRightFoot = new Circle(vec2.create(), feetRadius);
private previousTargets = [vec2.create(), vec2.create(), vec2.create()];
private hasRender = false;
public get head(): Circle {
@ -152,7 +154,7 @@ export class LocalCharacterPredictor {
this.snapRenderTo(predicted);
this.hasRender = true;
} else {
this.easeRenderTo(predicted, frameSeconds);
this.followRenderTo(predicted, frameSeconds);
}
return true;
}
@ -161,10 +163,10 @@ export class LocalCharacterPredictor {
const auth = this.authoritative!;
const movement = this.movement!;
const now = clientTimeMs();
const startMs = clamp(this.replayAnchorMs!, now - maxReplayMs, now);
const startMs = 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;
const partialStep = (windowMs - steps * stepMs) / stepMs;
const state: CharacterMovementState = {
head: makeBody(auth.head.center, auth.head.radius, upwards),
@ -191,9 +193,10 @@ export class LocalCharacterPredictor {
let t = startMs;
for (let i = 0; i < steps; i++) {
const input = this.inputHistory.directionAt(t);
// Planets first: the server's container holds them ahead of every character.
this.world.advance(stepSeconds);
tickPlanetDetachment(state, stepSeconds);
stepCharacterMovement(state, this.world, input, stepSeconds);
this.world.advance(stepSeconds);
for (const leapMs of this.leapHistory) {
if (
@ -213,21 +216,32 @@ export class LocalCharacterPredictor {
t += stepMs;
}
if (remainderSeconds > 0) {
tickPlanetDetachment(state, remainderSeconds);
// A shorter final step would not integrate like the server's fixed one, so take a whole
// step and read the pose part-way along it instead.
if (partialStep > 0) {
const from = [state.head, state.leftFoot, state.rightFoot].map((b) =>
vec2.clone(b.center),
);
this.world.advance(stepSeconds);
tickPlanetDetachment(state, stepSeconds);
stepCharacterMovement(
state,
this.world,
this.inputHistory.directionAt(now),
remainderSeconds,
this.inputHistory.directionAt(t),
stepSeconds,
);
[state.head, state.leftFoot, state.rightFoot].forEach((body, i) =>
vec2.lerp(body.center, from[i], body.center, partialStep),
);
this.world.advance(remainderSeconds);
}
return state;
}
private snapRenderTo(state: CharacterMovementState): void {
this.previousTargets = [state.head, state.leftFoot, state.rightFoot].map((b) =>
vec2.clone(b.center),
);
this.renderHead = new Circle(vec2.clone(state.head.center), state.head.radius);
this.renderLeftFoot = new Circle(
vec2.clone(state.leftFoot.center),
@ -239,14 +253,26 @@ export class LocalCharacterPredictor {
);
}
private easeRenderTo(state: CharacterMovementState, frameSeconds: number): void {
this.easePart(this.renderHead, state.head, frameSeconds);
this.easePart(this.renderLeftFoot, state.leftFoot, frameSeconds);
this.easePart(this.renderRightFoot, state.rightFoot, frameSeconds);
private followRenderTo(state: CharacterMovementState, frameSeconds: number): void {
this.followPart(this.renderHead, state.head, 0, frameSeconds);
this.followPart(this.renderLeftFoot, state.leftFoot, 1, frameSeconds);
this.followPart(this.renderRightFoot, state.rightFoot, 2, frameSeconds);
}
private easePart(render: Circle, target: PhysicsBody, frameSeconds: number): void {
easeVec2(render.center, target.center, frameSeconds, smoothSeconds, snapDistance);
private followPart(
render: Circle,
target: PhysicsBody,
index: number,
frameSeconds: number,
): void {
followVec2(
render.center,
target.center,
this.previousTargets[index],
frameSeconds,
smoothSeconds,
snapDistance,
);
render.radius = target.radius;
}
}

View file

@ -8,6 +8,7 @@ export class Minimap {
private readonly colors: Record<CharacterTeam, string>;
private readonly smoothed = new Map<Id, vec2>();
private bufferSize = 0;
private size = 0;
constructor() {
this.element.className = 'minimap';
@ -23,8 +24,14 @@ export class Minimap {
};
}
// Reading clientWidth after this frame's style writes would force a synchronous layout of
// the whole overlay inside the animation frame; the size only changes when the window does.
public measure() {
this.size = this.element.clientWidth;
}
public update(localPosition: vec2 | undefined, players: Array<MinimapPlayer>) {
const size = this.element.clientWidth;
const size = this.size;
if (size === 0) {
return;
}

View file

@ -119,12 +119,13 @@ export class GameObjectContainer extends CommandReceiver {
}
public render(renderer: Renderer, overlay: HTMLElement, shouldChangeLayout: boolean) {
// First: everything below projects world coordinates through this frame's view area.
this.camera.draw(renderer);
this.objects.forEach((o) => {
if (this.isVisible(o.id)) {
o.render(renderer, overlay, shouldChangeLayout);
}
});
this.camera.draw(renderer);
}
private isVisible(id: Id): boolean {

View file

@ -1,6 +1,6 @@
import { vec2 } from 'gl-matrix';
import { Renderer } from 'sdf-2d';
import { calculateViewArea, easeVec2, mixRgb, settings } from 'shared';
import { calculateViewArea, followVec2, mixRgb, settings } from 'shared';
import { Game } from '../../game';
import { ScreenShake } from '../../screen-shake';
@ -9,12 +9,20 @@ const snapDistance = 1500;
export class Camera {
public readonly center: vec2 = vec2.create();
private readonly previousTarget = vec2.create();
private aspectRatio?: number;
constructor(private readonly game: Game) {}
public follow(target: vec2, deltaTimeInSeconds: number) {
easeVec2(this.center, target, deltaTimeInSeconds, followSeconds, snapDistance);
followVec2(
this.center,
target,
this.previousTarget,
deltaTimeInSeconds,
followSeconds,
snapDistance,
);
}
public draw(renderer: Renderer) {