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

@ -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}%,