ai
This commit is contained in:
parent
b6db7e8dc7
commit
d9b80b92ca
22 changed files with 563 additions and 62 deletions
|
|
@ -126,6 +126,9 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
}
|
}
|
||||||
|
|
||||||
private initMovementBridge() {
|
private initMovementBridge() {
|
||||||
|
// The movementState object-literal getters/setters below can't use `this`
|
||||||
|
// (it would bind to the literal), so alias the character instance.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||||
const self = this;
|
const self = this;
|
||||||
this.movementState = {
|
this.movementState = {
|
||||||
head: this.head,
|
head: this.head,
|
||||||
|
|
|
||||||
|
|
@ -315,7 +315,7 @@ export class NPC extends PlayerBase {
|
||||||
|
|
||||||
const charge =
|
const charge =
|
||||||
distance > npcTuning.chargeRangeThreshold &&
|
distance > npcTuning.chargeRangeThreshold &&
|
||||||
Random.getRandom() <
|
Random.getRandom() <
|
||||||
npcTuning.chargeBaseChance + npcTuning.chargeAggressionChance * this.aggression
|
npcTuning.chargeBaseChance + npcTuning.chargeAggressionChance * this.aggression
|
||||||
? Random.getRandomInRange(npcTuning.chargeMin, 1)
|
? Random.getRandomInRange(npcTuning.chargeMin, 1)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ export abstract class PlayerBase extends CommandReceiver {
|
||||||
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
|
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
|
||||||
let rotation = 0;
|
let rotation = 0;
|
||||||
let radius = 0;
|
let radius = 0;
|
||||||
for (; ;) {
|
for (;;) {
|
||||||
const playerPosition = vec2.fromValues(
|
const playerPosition = vec2.fromValues(
|
||||||
radius * Math.cos(rotation) + preferredCenter.x,
|
radius * Math.cos(rotation) + preferredCenter.x,
|
||||||
radius * Math.sin(rotation) + preferredCenter.y,
|
radius * Math.sin(rotation) + preferredCenter.y,
|
||||||
|
|
|
||||||
|
|
@ -332,6 +332,26 @@ body {
|
||||||
&::after {
|
&::after {
|
||||||
content: '\25C9';
|
content: '\25C9';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Twin-stick aim indicator: drawn from the button centre toward the
|
||||||
|
// current drag direction while aiming a shot (see TouchListener).
|
||||||
|
.aim-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 130px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
transform-origin: left center;
|
||||||
|
transform: translateY(-50%) rotate(0rad);
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
rgba(255, 255, 255, 0.85),
|
||||||
|
rgba(255, 255, 255, 0));
|
||||||
|
box-shadow: 0 0 6px rgba(0, 0, 0, 0.6);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 80ms;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.leap {
|
&.leap {
|
||||||
|
|
@ -717,6 +737,34 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Centred "Eliminated" overlay shown while the local player is dead; the
|
||||||
|
// respawn countdown is the server-driven "Reviving in N…" announcement.
|
||||||
|
.elimination {
|
||||||
|
position: absolute;
|
||||||
|
top: 42%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
text-align: center;
|
||||||
|
animation: elimination-in 220ms ease-out;
|
||||||
|
|
||||||
|
.elimination-title {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 3.4rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: $accent;
|
||||||
|
text-shadow:
|
||||||
|
0 0 12px rgba(0, 0, 0, 0.9),
|
||||||
|
0 0 28px rgba($accent, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.elimination-sub {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
text-shadow: 0 0 8px rgba(0, 0, 0, 0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes contested-pulse {
|
@keyframes contested-pulse {
|
||||||
|
|
@ -877,4 +925,16 @@ body {
|
||||||
100% {
|
100% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes elimination-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -42%) scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { holdDurationToCharge } from 'shared';
|
import { holdDurationToCharge } from 'shared';
|
||||||
import { Pointer } from './helper/pointer';
|
import { Pointer } from './helper/pointer';
|
||||||
|
|
||||||
|
|
||||||
export abstract class ChargeIndicator {
|
export abstract class ChargeIndicator {
|
||||||
private static element?: HTMLElement;
|
private static element?: HTMLElement;
|
||||||
private static heldSince = 0;
|
private static heldSince = 0;
|
||||||
|
|
@ -41,8 +40,9 @@ export abstract class ChargeIndicator {
|
||||||
(performance.now() - ChargeIndicator.heldSince) / 1000,
|
(performance.now() - ChargeIndicator.heldSince) / 1000,
|
||||||
);
|
);
|
||||||
element.style.opacity = charge < 0.12 ? '0' : '1';
|
element.style.opacity = charge < 0.12 ? '0' : '1';
|
||||||
element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${charge * 360
|
element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${
|
||||||
}deg, rgba(255, 255, 255, 0.15) 0deg)`;
|
charge * 360
|
||||||
|
}deg, rgba(255, 255, 255, 0.15) 0deg)`;
|
||||||
element.classList.toggle('full', charge >= 1);
|
element.classList.toggle('full', charge >= 1);
|
||||||
|
|
||||||
if (ChargeIndicator.followPointer) {
|
if (ChargeIndicator.followPointer) {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ import { localCharacterPredictor } from '../helper/prediction/local-character-pr
|
||||||
export class TouchListener extends CommandGenerator {
|
export class TouchListener extends CommandGenerator {
|
||||||
private static readonly deadZone = 8;
|
private static readonly deadZone = 8;
|
||||||
private static readonly deltaScaling = 0.4;
|
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;
|
private joystick: HTMLElement;
|
||||||
private joystickButton: HTMLElement;
|
private joystickButton: HTMLElement;
|
||||||
|
|
@ -24,9 +27,12 @@ export class TouchListener extends CommandGenerator {
|
||||||
|
|
||||||
private fireButton: HTMLElement;
|
private fireButton: HTMLElement;
|
||||||
private fireStrengthRing: HTMLElement;
|
private fireStrengthRing: HTMLElement;
|
||||||
|
private fireAimLine!: HTMLElement;
|
||||||
private leapButton: HTMLElement;
|
private leapButton: HTMLElement;
|
||||||
|
|
||||||
private fireDownAt: number | null = null;
|
private fireDownAt: number | null = null;
|
||||||
|
private fireButtonCenter: vec2 | null = null;
|
||||||
|
private fireAimScreen: vec2 | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private target: HTMLElement,
|
private target: HTMLElement,
|
||||||
|
|
@ -45,8 +51,12 @@ export class TouchListener extends CommandGenerator {
|
||||||
this.fireStrengthRing = document.createElement('div');
|
this.fireStrengthRing = document.createElement('div');
|
||||||
this.fireStrengthRing.className = 'strength-ring';
|
this.fireStrengthRing.className = 'strength-ring';
|
||||||
this.fireButton.appendChild(this.fireStrengthRing);
|
this.fireButton.appendChild(this.fireStrengthRing);
|
||||||
|
this.fireAimLine = document.createElement('div');
|
||||||
|
this.fireAimLine.className = 'aim-line';
|
||||||
|
this.fireButton.appendChild(this.fireAimLine);
|
||||||
|
|
||||||
this.fireButton.addEventListener('touchstart', this.fireButtonDownListener);
|
this.fireButton.addEventListener('touchstart', this.fireButtonDownListener);
|
||||||
|
this.fireButton.addEventListener('touchmove', this.fireButtonMoveListener);
|
||||||
this.fireButton.addEventListener('touchend', this.fireButtonUpListener);
|
this.fireButton.addEventListener('touchend', this.fireButtonUpListener);
|
||||||
|
|
||||||
this.leapButton = document.createElement('div');
|
this.leapButton = document.createElement('div');
|
||||||
|
|
@ -159,12 +169,41 @@ export class TouchListener extends CommandGenerator {
|
||||||
this.swallowTouch(event);
|
this.swallowTouch(event);
|
||||||
this.fireDownAt = performance.now();
|
this.fireDownAt = performance.now();
|
||||||
const rect = this.fireButton.getBoundingClientRect();
|
const rect = this.fireButton.getBoundingClientRect();
|
||||||
ChargeIndicator.begin(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
this.fireButtonCenter = vec2.fromValues(
|
||||||
|
rect.left + rect.width / 2,
|
||||||
|
rect.top + rect.height / 2,
|
||||||
|
);
|
||||||
|
this.fireAimScreen = null;
|
||||||
|
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) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const touch = event.targetTouches[0] ?? event.changedTouches[0];
|
||||||
|
if (!touch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.fireAimScreen = vec2.fromValues(touch.clientX, touch.clientY);
|
||||||
|
const dx = this.fireAimScreen[0] - this.fireButtonCenter[0];
|
||||||
|
const dy = this.fireAimScreen[1] - this.fireButtonCenter[1];
|
||||||
|
if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) {
|
||||||
|
this.fireAimLine.style.opacity = '1';
|
||||||
|
this.fireAimLine.style.transform = `translateY(-50%) rotate(${Math.atan2(dy, dx)}rad)`;
|
||||||
|
} else {
|
||||||
|
this.fireAimLine.style.opacity = '0';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private fireButtonUpListener = (event: TouchEvent) => {
|
private fireButtonUpListener = (event: TouchEvent) => {
|
||||||
this.swallowTouch(event);
|
this.swallowTouch(event);
|
||||||
ChargeIndicator.end();
|
ChargeIndicator.end();
|
||||||
|
this.fireAimLine.style.opacity = '0';
|
||||||
if (this.fireDownAt === null) {
|
if (this.fireDownAt === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -174,12 +213,28 @@ export class TouchListener extends CommandGenerator {
|
||||||
|
|
||||||
const character = this.game.gameObjects.player;
|
const character = this.game.gameObjects.player;
|
||||||
if (!character) {
|
if (!character) {
|
||||||
|
this.fireButtonCenter = null;
|
||||||
|
this.fireAimScreen = null;
|
||||||
return;
|
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.
|
||||||
|
let direction = character.facingDirection;
|
||||||
|
if (this.fireButtonCenter && this.fireAimScreen) {
|
||||||
|
const dx = this.fireAimScreen[0] - this.fireButtonCenter[0];
|
||||||
|
const dy = this.fireAimScreen[1] - this.fireButtonCenter[1];
|
||||||
|
if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) {
|
||||||
|
direction = vec2.normalize(vec2.create(), vec2.fromValues(dx, -dy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.fireButtonCenter = null;
|
||||||
|
this.fireAimScreen = null;
|
||||||
|
|
||||||
const aim = vec2.scaleAndAdd(
|
const aim = vec2.scaleAndAdd(
|
||||||
vec2.create(),
|
vec2.create(),
|
||||||
character.bodyCenter,
|
character.bodyCenter,
|
||||||
character.facingDirection,
|
direction,
|
||||||
settings.touchAimRange,
|
settings.touchAimRange,
|
||||||
);
|
);
|
||||||
this.sendCommandToSubscribers(new PrimaryActionCommand(aim, charge));
|
this.sendCommandToSubscribers(new PrimaryActionCommand(aim, charge));
|
||||||
|
|
@ -195,8 +250,9 @@ export class TouchListener extends CommandGenerator {
|
||||||
|
|
||||||
const character = this.game.gameObjects.player;
|
const character = this.game.gameObjects.player;
|
||||||
if (character) {
|
if (character) {
|
||||||
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${character.strengthFraction * 360
|
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${
|
||||||
}deg, transparent 0deg)`;
|
character.strengthFraction * 360
|
||||||
|
}deg, transparent 0deg)`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,6 +263,7 @@ export class TouchListener extends CommandGenerator {
|
||||||
this.target.removeEventListener('touchend', this.touchEndListener);
|
this.target.removeEventListener('touchend', this.touchEndListener);
|
||||||
|
|
||||||
this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener);
|
this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener);
|
||||||
|
this.fireButton.removeEventListener('touchmove', this.fireButtonMoveListener);
|
||||||
this.fireButton.removeEventListener('touchend', this.fireButtonUpListener);
|
this.fireButton.removeEventListener('touchend', this.fireButtonUpListener);
|
||||||
this.leapButton.removeEventListener('touchstart', this.leapButtonListener);
|
this.leapButton.removeEventListener('touchstart', this.leapButtonListener);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Pointer } from './helper/pointer';
|
||||||
export abstract class FeedbackHud {
|
export abstract class FeedbackHud {
|
||||||
private static root?: HTMLElement;
|
private static root?: HTMLElement;
|
||||||
private static killfeed?: HTMLElement;
|
private static killfeed?: HTMLElement;
|
||||||
|
private static elimination?: HTMLElement;
|
||||||
|
|
||||||
private static ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } {
|
private static ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } {
|
||||||
if (!this.root || !this.killfeed) {
|
if (!this.root || !this.killfeed) {
|
||||||
|
|
@ -75,6 +76,29 @@ export abstract class FeedbackHud {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 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;
|
||||||
|
}
|
||||||
|
|
||||||
private static streakName(streak: number): string | undefined {
|
private static streakName(streak: number): string | undefined {
|
||||||
switch (streak) {
|
switch (streak) {
|
||||||
case 2:
|
case 2:
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,14 @@ import { InputHistory } from './input-history';
|
||||||
const stepMs = 1000 / 200; // match the server's 200 Hz fixed tick
|
const stepMs = 1000 / 200; // match the server's 200 Hz fixed tick
|
||||||
const stepSeconds = 1 / 200;
|
const stepSeconds = 1 / 200;
|
||||||
|
|
||||||
|
// Clock source for the predictor. Injectable so deterministic reconciliation
|
||||||
|
// tests can drive prediction without real time passing; defaults to the
|
||||||
|
// browser wall clock in production.
|
||||||
|
let nowMs: () => number = () => performance.now();
|
||||||
|
export const setPredictorClockForTesting = (clock: () => number): void => {
|
||||||
|
nowMs = clock;
|
||||||
|
};
|
||||||
|
|
||||||
// Don't replay more than this far back: if the last acknowledged input is older
|
// 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
|
// (a stall, or a backgrounded tab catching up) snap to the authoritative pose
|
||||||
// instead of grinding through hundreds of steps.
|
// instead of grinding through hundreds of steps.
|
||||||
|
|
@ -74,6 +82,12 @@ export class LocalCharacterPredictor {
|
||||||
// Latest streamed shooting-strength, to gate predicted leaps as the server does.
|
// Latest streamed shooting-strength, to gate predicted leaps as the server does.
|
||||||
private currentStrength = settings.playerMaxStrength;
|
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.
|
||||||
|
private alive = true;
|
||||||
|
|
||||||
// Continuous state carried between replays (the snapshot carries only poses).
|
// Continuous state carried between replays (the snapshot carries only poses).
|
||||||
// The facing direction is NOT carried — it is re-derived from the pose each
|
// The facing direction is NOT carried — it is re-derived from the pose each
|
||||||
// frame (see directionFromPose / simulate); only the latched planet and the
|
// frame (see directionFromPose / simulate); only the latched planet and the
|
||||||
|
|
@ -100,7 +114,7 @@ export class LocalCharacterPredictor {
|
||||||
// Stamp a movement command and record it for replay. Returns the wall-clock
|
// 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.
|
// time the command should carry so the server can echo it back.
|
||||||
public recordInput(direction: vec2): number {
|
public recordInput(direction: vec2): number {
|
||||||
const timeMs = Math.round(performance.now());
|
const timeMs = Math.round(nowMs());
|
||||||
this.inputHistory.record(direction, timeMs);
|
this.inputHistory.record(direction, timeMs);
|
||||||
return timeMs;
|
return timeMs;
|
||||||
}
|
}
|
||||||
|
|
@ -112,7 +126,10 @@ export class LocalCharacterPredictor {
|
||||||
): void {
|
): void {
|
||||||
// Inputs only advance the acknowledgement forward; the launch momentum and
|
// Inputs only advance the acknowledgement forward; the launch momentum and
|
||||||
// leap boundary always adopt the latest authoritative values.
|
// leap boundary always adopt the latest authoritative values.
|
||||||
if (this.lastAckClientTimeMs === undefined || clientTimeMs > this.lastAckClientTimeMs) {
|
if (
|
||||||
|
this.lastAckClientTimeMs === undefined ||
|
||||||
|
clientTimeMs > this.lastAckClientTimeMs
|
||||||
|
) {
|
||||||
this.lastAckClientTimeMs = clientTimeMs;
|
this.lastAckClientTimeMs = clientTimeMs;
|
||||||
}
|
}
|
||||||
vec2.set(this.authoritativeBodyVelocity, bodyVelocity[0], bodyVelocity[1]);
|
vec2.set(this.authoritativeBodyVelocity, bodyVelocity[0], bodyVelocity[1]);
|
||||||
|
|
@ -121,7 +138,7 @@ export class LocalCharacterPredictor {
|
||||||
|
|
||||||
public setAuthoritative(head: Circle, leftFoot: Circle, rightFoot: Circle): void {
|
public setAuthoritative(head: Circle, leftFoot: Circle, rightFoot: Circle): void {
|
||||||
this.authoritative = { head, leftFoot, rightFoot };
|
this.authoritative = { head, leftFoot, rightFoot };
|
||||||
this.authReceiptMs = Math.round(performance.now());
|
this.authReceiptMs = Math.round(nowMs());
|
||||||
}
|
}
|
||||||
|
|
||||||
// The player pressed leap; remember when, so the replay applies the same
|
// The player pressed leap; remember when, so the replay applies the same
|
||||||
|
|
@ -129,7 +146,7 @@ export class LocalCharacterPredictor {
|
||||||
// it — a rejected leap (no strength/cooldown) self-corrects via the streamed
|
// it — a rejected leap (no strength/cooldown) self-corrects via the streamed
|
||||||
// authoritative momentum.
|
// authoritative momentum.
|
||||||
public recordLeap(): number {
|
public recordLeap(): number {
|
||||||
const timeMs = Math.round(performance.now());
|
const timeMs = Math.round(nowMs());
|
||||||
this.leapHistory.push(timeMs);
|
this.leapHistory.push(timeMs);
|
||||||
const cutoff = timeMs - 1500;
|
const cutoff = timeMs - 1500;
|
||||||
while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) {
|
while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) {
|
||||||
|
|
@ -142,6 +159,10 @@ export class LocalCharacterPredictor {
|
||||||
this.currentStrength = strength;
|
this.currentStrength = strength;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setAlive(alive: boolean): void {
|
||||||
|
this.alive = alive;
|
||||||
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.inputHistory.reset();
|
this.inputHistory.reset();
|
||||||
this.leapHistory = [];
|
this.leapHistory = [];
|
||||||
|
|
@ -154,6 +175,7 @@ export class LocalCharacterPredictor {
|
||||||
this.carriedPlanetId = undefined;
|
this.carriedPlanetId = undefined;
|
||||||
this.carriedSecondsSinceSurface = 1;
|
this.carriedSecondsSinceSurface = 1;
|
||||||
this.hasRender = false;
|
this.hasRender = false;
|
||||||
|
this.alive = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get canPredict(): boolean {
|
public get canPredict(): boolean {
|
||||||
|
|
@ -175,7 +197,7 @@ export class LocalCharacterPredictor {
|
||||||
// caller should use (otherwise fall back to interpolation). `planets` is the
|
// caller should use (otherwise fall back to interpolation). `planets` is the
|
||||||
// current collision world; `frameSeconds` is the render delta.
|
// current collision world; `frameSeconds` is the render delta.
|
||||||
public update(planets: Array<PredictablePlanet>, frameSeconds: number): boolean {
|
public update(planets: Array<PredictablePlanet>, frameSeconds: number): boolean {
|
||||||
if (!this.canPredict || this.isAnimatingInOrOut) {
|
if (!this.alive || !this.canPredict || this.isAnimatingInOrOut) {
|
||||||
// Resume from the authoritative pose with a snap rather than gliding from
|
// Resume from the authoritative pose with a snap rather than gliding from
|
||||||
// a stale rendered one.
|
// a stale rendered one.
|
||||||
this.hasRender = false;
|
this.hasRender = false;
|
||||||
|
|
@ -209,7 +231,7 @@ export class LocalCharacterPredictor {
|
||||||
|
|
||||||
private simulate(): CharacterMovementState {
|
private simulate(): CharacterMovementState {
|
||||||
const auth = this.authoritative!;
|
const auth = this.authoritative!;
|
||||||
const now = Math.round(performance.now());
|
const now = Math.round(nowMs());
|
||||||
// Predict forward from the latest snapshot by its age, clamped so a stall
|
// 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
|
// (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
|
// steps. This advances with wall-clock time even while a held key sends no
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,11 @@ import {
|
||||||
import { settings, rgb, PlanetBase, Random } from 'shared';
|
import { settings, rgb, PlanetBase, Random } from 'shared';
|
||||||
import { PlanetShape } from './shapes/planet-shape';
|
import { PlanetShape } from './shapes/planet-shape';
|
||||||
|
|
||||||
// colorMixQ ends of PlanetShape's blue<->red gradient, so the two backdrop
|
// PlanetShape colours by mixing blue (0) -> red (1). The two backdrop planets
|
||||||
// planets read as the two in-game teams.
|
// 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 bluePlanet = 0;
|
||||||
const redPlanet = 1;
|
const redPlanet = 0.85;
|
||||||
|
|
||||||
export class LandingPageBackground {
|
export class LandingPageBackground {
|
||||||
private isActive = true;
|
private isActive = true;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { BeforeDestroyCommand } from '../commands/types/before-destroy';
|
import { BeforeDestroyCommand } from '../commands/types/before-destroy';
|
||||||
import { StepCommand } from '../commands/types/step';
|
import { StepCommand } from '../commands/types/step';
|
||||||
|
import { FeedbackHud } from '../feedback-hud';
|
||||||
import { Game } from '../game';
|
import { Game } from '../game';
|
||||||
import { serverTimeline } from '../helper/server-timeline';
|
import { serverTimeline } from '../helper/server-timeline';
|
||||||
import { PredictablePlanet } from '../helper/prediction/client-character-world';
|
import { PredictablePlanet } from '../helper/prediction/client-character-world';
|
||||||
|
|
@ -28,6 +29,7 @@ export class GameObjectContainer extends CommandReceiver {
|
||||||
protected objects: Map<Id, GameObject> = new Map();
|
protected objects: Map<Id, GameObject> = new Map();
|
||||||
public player!: CharacterView;
|
public player!: CharacterView;
|
||||||
public camera: Camera = new Camera(this.game);
|
public camera: Camera = new Camera(this.game);
|
||||||
|
private wasLocalPlayerAlive = false;
|
||||||
|
|
||||||
protected commandExecutors: CommandExecutors = {
|
protected commandExecutors: CommandExecutors = {
|
||||||
[CreatePlayerCommand.type]: (c: CreatePlayerCommand) => {
|
[CreatePlayerCommand.type]: (c: CreatePlayerCommand) => {
|
||||||
|
|
@ -37,6 +39,9 @@ export class GameObjectContainer extends CommandReceiver {
|
||||||
// Fresh character (first spawn or respawn at a far planet): drop any
|
// 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.
|
// prediction state so it snaps to the new body instead of gliding across.
|
||||||
localCharacterPredictor.reset();
|
localCharacterPredictor.reset();
|
||||||
|
// Respawned — clear the elimination overlay.
|
||||||
|
FeedbackHud.hideElimination();
|
||||||
|
this.wasLocalPlayerAlive = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
[CreateObjectsCommand.type]: (c: CreateObjectsCommand) =>
|
[CreateObjectsCommand.type]: (c: CreateObjectsCommand) =>
|
||||||
|
|
@ -45,15 +50,32 @@ export class GameObjectContainer extends CommandReceiver {
|
||||||
[StepCommand.type]: (c: StepCommand) => {
|
[StepCommand.type]: (c: StepCommand) => {
|
||||||
this.defaultCommandExecutor(c);
|
this.defaultCommandExecutor(c);
|
||||||
|
|
||||||
if (this.player) {
|
// The local body is alive only while its object still exists (the server
|
||||||
|
// deletes it on death) and its health is above zero — `player` keeps
|
||||||
|
// pointing at the now-stale view after death, so both checks are needed.
|
||||||
|
const bodyPresent = !!this.player && this.objects.has(this.player.id);
|
||||||
|
const alive = bodyPresent && this.player.health > 0;
|
||||||
|
|
||||||
|
// Show the elimination overlay on the alive→dead edge; CreatePlayerCommand
|
||||||
|
// clears it on respawn.
|
||||||
|
if (this.wasLocalPlayerAlive && !alive) {
|
||||||
|
FeedbackHud.showElimination();
|
||||||
|
}
|
||||||
|
this.wasLocalPlayerAlive = alive;
|
||||||
|
|
||||||
|
if (bodyPresent) {
|
||||||
// Override the interpolated pose of the local player with the predicted
|
// Override the interpolated pose of the local player with the predicted
|
||||||
// one so it responds to input immediately. Remote objects keep
|
// one so it responds to input immediately. Suppressed while dead so the
|
||||||
// interpolating. A large correction (respawn / death) snaps inside the
|
// corpse can't be walked around (the server ignores a dead player's
|
||||||
// predictor rather than rubber-banding.
|
// input — a moving predicted body would be a pure client-side desync).
|
||||||
|
// A large correction (respawn / death) snaps inside the predictor.
|
||||||
|
localCharacterPredictor.setAlive(alive);
|
||||||
localCharacterPredictor.setStrength(
|
localCharacterPredictor.setStrength(
|
||||||
this.player.strengthFraction * settings.playerMaxStrength,
|
this.player.strengthFraction * settings.playerMaxStrength,
|
||||||
);
|
);
|
||||||
if (localCharacterPredictor.update(this.predictablePlanets(), c.deltaTimeInSeconds)) {
|
if (
|
||||||
|
localCharacterPredictor.update(this.predictablePlanets(), c.deltaTimeInSeconds)
|
||||||
|
) {
|
||||||
this.player.head = localCharacterPredictor.head;
|
this.player.head = localCharacterPredictor.head;
|
||||||
this.player.leftFoot = localCharacterPredictor.leftFoot;
|
this.player.leftFoot = localCharacterPredictor.leftFoot;
|
||||||
this.player.rightFoot = localCharacterPredictor.rightFoot;
|
this.player.rightFoot = localCharacterPredictor.rightFoot;
|
||||||
|
|
|
||||||
|
|
@ -103,8 +103,8 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
|
||||||
if (dist < minDistance) {
|
if (dist < minDistance) {
|
||||||
minDistance = dist;
|
minDistance = dist;
|
||||||
color = mix(${colorToString(settings.bluePlanetColor)}, ${colorToString(
|
color = mix(${colorToString(settings.bluePlanetColor)}, ${colorToString(
|
||||||
settings.redPlanetColor,
|
settings.redPlanetColor,
|
||||||
)}, planetColorMixQ[j]);
|
)}, planetColorMixQ[j]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,10 +141,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
|
||||||
) {
|
) {
|
||||||
super(vertices);
|
super(vertices);
|
||||||
|
|
||||||
this.cullCenter = vertices.reduce(
|
this.cullCenter = vertices.reduce((sum, v) => vec2.add(sum, sum, v), vec2.create());
|
||||||
(sum, v) => vec2.add(sum, sum, v),
|
|
||||||
vec2.create(),
|
|
||||||
);
|
|
||||||
vec2.scale(this.cullCenter, this.cullCenter, 1 / vertices.length);
|
vec2.scale(this.cullCenter, this.cullCenter, 1 / vertices.length);
|
||||||
|
|
||||||
this.cullRadius = vertices.reduce(
|
this.cullRadius = vertices.reduce(
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,24 @@ export class RemoteCall {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every object property streamed via UpdatePropertyCommand. A single union means
|
||||||
|
// a typo or rename on the producing (server *-physical) or consuming (client
|
||||||
|
// *-view) side is a compile error instead of a silently dropped update that
|
||||||
|
// just stops a body interpolating. The wire format is unchanged — these remain
|
||||||
|
// the same strings, only now compiler-checked at both ends.
|
||||||
|
export type SyncPropertyKey =
|
||||||
|
| 'head'
|
||||||
|
| 'leftFoot'
|
||||||
|
| 'rightFoot'
|
||||||
|
| 'strength'
|
||||||
|
| 'center'
|
||||||
|
| 'ownership'
|
||||||
|
| 'rotation';
|
||||||
|
|
||||||
@serializable
|
@serializable
|
||||||
export class UpdatePropertyCommand extends Command {
|
export class UpdatePropertyCommand extends Command {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly propertyKey: string,
|
public readonly propertyKey: SyncPropertyKey,
|
||||||
public readonly propertyValue: any,
|
public readonly propertyValue: any,
|
||||||
public readonly rateOfChange: any,
|
public readonly rateOfChange: any,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Id } from '../../communication/id';
|
import { Id } from '../../communication/id';
|
||||||
import { Circle } from '../../helper/circle';
|
import { Circle } from '../../helper/circle';
|
||||||
import { serializable } from '../../serialization/serializable';
|
import { serializable } from '../../serialization/serializable';
|
||||||
|
import { toArrayFromFields } from '../../serialization/serialized-fields';
|
||||||
import { GameObject } from '../game-object';
|
import { GameObject } from '../game-object';
|
||||||
|
|
||||||
export enum CharacterTeam {
|
export enum CharacterTeam {
|
||||||
|
|
@ -11,6 +12,18 @@ export enum CharacterTeam {
|
||||||
|
|
||||||
@serializable
|
@serializable
|
||||||
export class CharacterBase extends GameObject {
|
export class CharacterBase extends GameObject {
|
||||||
|
private static readonly serializedFields = [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'killCount',
|
||||||
|
'deathCount',
|
||||||
|
'team',
|
||||||
|
'health',
|
||||||
|
'head',
|
||||||
|
'leftFoot',
|
||||||
|
'rightFoot',
|
||||||
|
] as const;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
id: Id,
|
id: Id,
|
||||||
public name: string,
|
public name: string,
|
||||||
|
|
@ -26,37 +39,27 @@ export class CharacterBase extends GameObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
public onShoot(strength: number) { }
|
public onShoot(strength: number) {}
|
||||||
|
|
||||||
public onLeap() { }
|
public onLeap() {}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
public onHitConfirmed(charge?: number) { }
|
public onHitConfirmed(charge?: number) {}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
public onKillConfirmed(victimName?: string, streak?: number, charge?: number) { }
|
public onKillConfirmed(victimName?: string, streak?: number, charge?: number) {}
|
||||||
|
|
||||||
public setHealth(health: number) {
|
public setHealth(health: number) {
|
||||||
this.health = health;
|
this.health = health;
|
||||||
}
|
}
|
||||||
|
|
||||||
public onDie() { }
|
public onDie() {}
|
||||||
|
|
||||||
public setKillCount(killCount: number) {
|
public setKillCount(killCount: number) {
|
||||||
this.killCount = killCount;
|
this.killCount = killCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
public toArray(): Array<any> {
|
public toArray(): Array<any> {
|
||||||
return [
|
return toArrayFromFields(this, CharacterBase.serializedFields);
|
||||||
this.id,
|
|
||||||
this.name,
|
|
||||||
this.killCount,
|
|
||||||
this.deathCount,
|
|
||||||
this.team,
|
|
||||||
this.health,
|
|
||||||
this.head,
|
|
||||||
this.leftFoot,
|
|
||||||
this.rightFoot,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
import { vec2, vec3 } from 'gl-matrix';
|
import { vec2, vec3 } from 'gl-matrix';
|
||||||
import { Id, serializable } from '../../main';
|
import { Id, serializable } from '../../main';
|
||||||
|
import { toArrayFromFields } from '../../serialization/serialized-fields';
|
||||||
import { GameObject } from '../game-object';
|
import { GameObject } from '../game-object';
|
||||||
|
|
||||||
@serializable
|
@serializable
|
||||||
export class LampBase extends GameObject {
|
export class LampBase extends GameObject {
|
||||||
|
private static readonly serializedFields = [
|
||||||
|
'id',
|
||||||
|
'center',
|
||||||
|
'color',
|
||||||
|
'lightness',
|
||||||
|
] as const;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
id: Id,
|
id: Id,
|
||||||
public center: vec2,
|
public center: vec2,
|
||||||
|
|
@ -19,7 +27,6 @@ export class LampBase extends GameObject {
|
||||||
public setLight(color: vec3, lightness: number) {}
|
public setLight(color: vec3, lightness: number) {}
|
||||||
|
|
||||||
public toArray(): Array<any> {
|
public toArray(): Array<any> {
|
||||||
const { id, center, color, lightness } = this;
|
return toArrayFromFields(this, LampBase.serializedFields);
|
||||||
return [id, center, color, lightness];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,22 @@ import { vec2 } from 'gl-matrix';
|
||||||
import { Random } from '../../helper/random';
|
import { Random } from '../../helper/random';
|
||||||
import { settings } from '../../settings';
|
import { settings } from '../../settings';
|
||||||
import { serializable } from '../../serialization/serializable';
|
import { serializable } from '../../serialization/serializable';
|
||||||
|
import { toArrayFromFields } from '../../serialization/serialized-fields';
|
||||||
import { GameObject } from '../game-object';
|
import { GameObject } from '../game-object';
|
||||||
import { Id } from '../../communication/id';
|
import { Id } from '../../communication/id';
|
||||||
import { CharacterTeam } from './character-base';
|
import { CharacterTeam } from './character-base';
|
||||||
|
|
||||||
@serializable
|
@serializable
|
||||||
export class PlanetBase extends GameObject {
|
export class PlanetBase extends GameObject {
|
||||||
|
// centre/radius are derived from vertices in the constructor, so they are not
|
||||||
|
// serialized — only the constructor parameters are.
|
||||||
|
private static readonly serializedFields = [
|
||||||
|
'id',
|
||||||
|
'vertices',
|
||||||
|
'ownership',
|
||||||
|
'isKeystone',
|
||||||
|
] as const;
|
||||||
|
|
||||||
public readonly center: vec2;
|
public readonly center: vec2;
|
||||||
public readonly radius: number;
|
public readonly radius: number;
|
||||||
|
|
||||||
|
|
@ -57,6 +67,6 @@ export class PlanetBase extends GameObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
public toArray(): Array<any> {
|
public toArray(): Array<any> {
|
||||||
return [this.id, this.vertices, this.ownership, this.isKeystone];
|
return toArrayFromFields(this, PlanetBase.serializedFields);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
import { settings } from '../../settings';
|
import { settings } from '../../settings';
|
||||||
import { serializable } from '../../serialization/serializable';
|
import { serializable } from '../../serialization/serializable';
|
||||||
|
import { toArrayFromFields } from '../../serialization/serialized-fields';
|
||||||
import { GameObject } from '../game-object';
|
import { GameObject } from '../game-object';
|
||||||
import { Id } from '../../communication/id';
|
import { Id } from '../../communication/id';
|
||||||
import { CharacterTeam } from './character-base';
|
import { CharacterTeam } from './character-base';
|
||||||
|
|
||||||
@serializable
|
@serializable
|
||||||
export class ProjectileBase extends GameObject {
|
export class ProjectileBase extends GameObject {
|
||||||
|
private static readonly serializedFields = [
|
||||||
|
'id',
|
||||||
|
'center',
|
||||||
|
'radius',
|
||||||
|
'team',
|
||||||
|
'strength',
|
||||||
|
] as const;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
id: Id,
|
id: Id,
|
||||||
public center: vec2,
|
public center: vec2,
|
||||||
|
|
@ -23,6 +32,6 @@ export class ProjectileBase extends GameObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
public toArray(): Array<any> {
|
public toArray(): Array<any> {
|
||||||
return [this.id, this.center, this.radius, this.team, this.strength];
|
return toArrayFromFields(this, ProjectileBase.serializedFields);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,13 @@ const springMove = (
|
||||||
|
|
||||||
const keepPosture = (state: CharacterMovementState) => {
|
const keepPosture = (state: CharacterMovementState) => {
|
||||||
const center = characterCenter(state);
|
const center = characterCenter(state);
|
||||||
springMove(state, state.leftFoot, center, leftFootOffset, settings.postureFeetStiffness);
|
springMove(
|
||||||
|
state,
|
||||||
|
state.leftFoot,
|
||||||
|
center,
|
||||||
|
leftFootOffset,
|
||||||
|
settings.postureFeetStiffness,
|
||||||
|
);
|
||||||
springMove(
|
springMove(
|
||||||
state,
|
state,
|
||||||
state.rightFoot,
|
state.rightFoot,
|
||||||
|
|
@ -133,7 +139,12 @@ const carryWithRotatingPlanet = (
|
||||||
const angle = -planet.angularVelocity * deltaTimeInSeconds;
|
const angle = -planet.angularVelocity * deltaTimeInSeconds;
|
||||||
const center = planet.center;
|
const center = planet.center;
|
||||||
state.head.center = vec2.rotate(vec2.create(), state.head.center, center, angle);
|
state.head.center = vec2.rotate(vec2.create(), state.head.center, center, angle);
|
||||||
state.leftFoot.center = vec2.rotate(vec2.create(), state.leftFoot.center, center, angle);
|
state.leftFoot.center = vec2.rotate(
|
||||||
|
vec2.create(),
|
||||||
|
state.leftFoot.center,
|
||||||
|
center,
|
||||||
|
angle,
|
||||||
|
);
|
||||||
state.rightFoot.center = vec2.rotate(
|
state.rightFoot.center = vec2.rotate(
|
||||||
vec2.create(),
|
vec2.create(),
|
||||||
state.rightFoot.center,
|
state.rightFoot.center,
|
||||||
|
|
@ -148,10 +159,7 @@ const carryWithRotatingPlanet = (
|
||||||
// server's leap() and the client's prediction apply the exact same impulse.
|
// server's leap() and the client's prediction apply the exact same impulse.
|
||||||
// The caller does the gating (strength, cooldown, alive); this is a no-op when
|
// The caller does the gating (strength, cooldown, alive); this is a no-op when
|
||||||
// not on a surface.
|
// not on a surface.
|
||||||
export const applyLeapImpulse = (
|
export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: vec2) => {
|
||||||
state: CharacterMovementState,
|
|
||||||
moveDirection: vec2,
|
|
||||||
) => {
|
|
||||||
const planet = state.currentPlanet;
|
const planet = state.currentPlanet;
|
||||||
if (!planet) {
|
if (!planet) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -206,7 +214,9 @@ export const tickPlanetDetachment = (
|
||||||
state: CharacterMovementState,
|
state: CharacterMovementState,
|
||||||
deltaTimeInSeconds: number,
|
deltaTimeInSeconds: number,
|
||||||
) => {
|
) => {
|
||||||
if ((state.secondsSinceOnSurface += deltaTimeInSeconds) > settings.planetDetachmentSeconds) {
|
if (
|
||||||
|
(state.secondsSinceOnSurface += deltaTimeInSeconds) > settings.planetDetachmentSeconds
|
||||||
|
) {
|
||||||
state.currentPlanet = undefined;
|
state.currentPlanet = undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -255,10 +265,7 @@ export const decayMomentum = (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const decayBodyMomentum = (
|
const decayBodyMomentum = (state: CharacterMovementState, deltaTimeInSeconds: number) => {
|
||||||
state: CharacterMovementState,
|
|
||||||
deltaTimeInSeconds: number,
|
|
||||||
) => {
|
|
||||||
decayMomentum(state.bodyVelocity, !!state.currentPlanet, deltaTimeInSeconds);
|
decayMomentum(state.bodyVelocity, !!state.currentPlanet, deltaTimeInSeconds);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -293,7 +300,11 @@ export const stepCharacterMovement = (
|
||||||
const center = characterCenter(state);
|
const center = characterCenter(state);
|
||||||
const grounds = world.groundsNear(center, boundRadius + settings.maxGravityDistance);
|
const grounds = world.groundsNear(center, boundRadius + settings.maxGravityDistance);
|
||||||
|
|
||||||
const movementForce = vec2.scale(inputDirection, inputDirection, settings.maxAcceleration);
|
const movementForce = vec2.scale(
|
||||||
|
inputDirection,
|
||||||
|
inputDirection,
|
||||||
|
settings.maxAcceleration,
|
||||||
|
);
|
||||||
applyForce(state.leftFoot, movementForce, deltaTimeInSeconds);
|
applyForce(state.leftFoot, movementForce, deltaTimeInSeconds);
|
||||||
applyForce(state.rightFoot, movementForce, deltaTimeInSeconds);
|
applyForce(state.rightFoot, movementForce, deltaTimeInSeconds);
|
||||||
|
|
||||||
|
|
|
||||||
13
shared/src/serialization/serialized-fields.ts
Normal file
13
shared/src/serialization/serialized-fields.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
// Derive a serializable object's positional wire array from a single declared
|
||||||
|
// list of field names, so toArray() and the constructor share ONE source of
|
||||||
|
// truth for field order instead of a hand-written array that can silently drift
|
||||||
|
// out of step with the parameters.
|
||||||
|
//
|
||||||
|
// deserialize reconstructs via `new Ctor(...array.slice(1))`, so the field list
|
||||||
|
// MUST name the constructor's parameters in order. Fields a constructor
|
||||||
|
// recomputes from others (e.g. a planet's centre/radius from its vertices) are
|
||||||
|
// derived, not serialized, and so are deliberately absent from the list.
|
||||||
|
export const toArrayFromFields = (
|
||||||
|
object: any,
|
||||||
|
fields: ReadonlyArray<string>,
|
||||||
|
): Array<any> => fields.map((field) => object[field]);
|
||||||
3
test/__snapshots__/physics-determinism.test.mjs.snap
Normal file
3
test/__snapshots__/physics-determinism.test.mjs.snap
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`shared character simulation determinism > matches the pinned reference pose (changes only with intentional physics edits) 1`] = `"{"head":[76.14,123.412],"leftFoot":[83.214,105.036],"rightFoot":[79.974,123.01],"bodyVelocity":[0,0]}"`;
|
||||||
83
test/physics-determinism.test.mjs
Normal file
83
test/physics-determinism.test.mjs
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// Determinism guard for the shared character simulation. The client predictor
|
||||||
|
// and the authoritative server run the EXACT same stepCharacterMovement; if it
|
||||||
|
// were non-deterministic (a stray Math.random / Date, or an order-dependent
|
||||||
|
// reduce) prediction would rubber-band and could never reconcile. This is also
|
||||||
|
// the regression net for the upcoming GC/scratch-pool perf rewrites: the pinned
|
||||||
|
// hash must not change unless physics behaviour is intentionally changed.
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const shared = require('../shared/lib/main.js');
|
||||||
|
const { vec2 } = require('../shared/node_modules/gl-matrix');
|
||||||
|
|
||||||
|
const {
|
||||||
|
stepCharacterMovement,
|
||||||
|
resolveCircleMovement,
|
||||||
|
headRadius,
|
||||||
|
feetRadius,
|
||||||
|
headOffset,
|
||||||
|
leftFootOffset,
|
||||||
|
rightFootOffset,
|
||||||
|
} = shared;
|
||||||
|
|
||||||
|
const makeBody = (center, radius) => ({
|
||||||
|
center: vec2.clone(center),
|
||||||
|
radius,
|
||||||
|
velocity: vec2.create(),
|
||||||
|
lastNormal: vec2.fromValues(0, 1),
|
||||||
|
restitution: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A free-space world (no planets): the body is driven purely by the movement
|
||||||
|
// input force, posture springs and momentum decay — enough to exercise the
|
||||||
|
// deterministic core without coupling the test to planet SDF geometry.
|
||||||
|
const emptyWorld = {
|
||||||
|
groundsNear: () => [],
|
||||||
|
stepBody: (body, dt) => {
|
||||||
|
resolveCircleMovement(body, dt, []);
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepSeconds = 1 / 200;
|
||||||
|
|
||||||
|
// Run a fixed, scripted input sequence through the shared sim and return a
|
||||||
|
// stable string snapshot of the final pose + carried momentum.
|
||||||
|
const runSimulation = () => {
|
||||||
|
const start = vec2.fromValues(100, 100);
|
||||||
|
const state = {
|
||||||
|
head: makeBody(vec2.add(vec2.create(), start, headOffset), headRadius),
|
||||||
|
leftFoot: makeBody(vec2.add(vec2.create(), start, leftFootOffset), feetRadius),
|
||||||
|
rightFoot: makeBody(vec2.add(vec2.create(), start, rightFootOffset), feetRadius),
|
||||||
|
direction: 0,
|
||||||
|
currentPlanet: undefined,
|
||||||
|
secondsSinceOnSurface: 1,
|
||||||
|
bodyVelocity: vec2.create(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
const angle = i * 0.1;
|
||||||
|
const input = vec2.fromValues(Math.cos(angle), Math.sin(angle));
|
||||||
|
stepCharacterMovement(state, emptyWorld, input, stepSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const round = (v) => Math.round(v * 1000) / 1000;
|
||||||
|
return JSON.stringify({
|
||||||
|
head: [round(state.head.center[0]), round(state.head.center[1])],
|
||||||
|
leftFoot: [round(state.leftFoot.center[0]), round(state.leftFoot.center[1])],
|
||||||
|
rightFoot: [round(state.rightFoot.center[0]), round(state.rightFoot.center[1])],
|
||||||
|
bodyVelocity: [round(state.bodyVelocity[0]), round(state.bodyVelocity[1])],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('shared character simulation determinism', () => {
|
||||||
|
it('produces identical output across independent runs', () => {
|
||||||
|
expect(runSimulation()).toBe(runSimulation());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the pinned reference pose (changes only with intentional physics edits)', () => {
|
||||||
|
// Regression pin — update deliberately when physics behaviour changes.
|
||||||
|
expect(runSimulation()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
96
test/prediction-reconciliation.test.ts
Normal file
96
test/prediction-reconciliation.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
// Reconciliation guard for the REAL client predictor. It exercises
|
||||||
|
// LocalCharacterPredictor end-to-end with an injected clock, verifying the two
|
||||||
|
// properties reconciliation depends on:
|
||||||
|
// 1. a fully-acknowledged snapshot reproduces the authoritative pose exactly
|
||||||
|
// (no spurious drift on top of server truth), and
|
||||||
|
// 2. un-acknowledged input is replayed forward deterministically.
|
||||||
|
// This is the net for prediction-touching changes (the death-while-dead fix,
|
||||||
|
// future netcode work) — a regression shows up as drift or non-determinism.
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
LocalCharacterPredictor,
|
||||||
|
setPredictorClockForTesting,
|
||||||
|
} from '../frontend/src/scripts/helper/prediction/local-character-predictor';
|
||||||
|
|
||||||
|
const HEAD_RADIUS = 50;
|
||||||
|
const FEET_RADIUS = 20;
|
||||||
|
|
||||||
|
// A Circle-shaped pose; the predictor only reads .center / .radius, so plain
|
||||||
|
// objects with array centres are sufficient (and avoid importing gl-matrix).
|
||||||
|
const poseAt = (cx: number, cy: number) => ({
|
||||||
|
head: { center: [cx, cy + 37], radius: HEAD_RADIUS },
|
||||||
|
leftFoot: { center: [cx - 33, cy - 18], radius: FEET_RADIUS },
|
||||||
|
rightFoot: { center: [cx + 33, cy - 18], radius: FEET_RADIUS },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('local prediction reconciliation', () => {
|
||||||
|
it('reproduces the authoritative pose exactly when all input is acknowledged', () => {
|
||||||
|
let clock = 1000;
|
||||||
|
setPredictorClockForTesting(() => clock);
|
||||||
|
|
||||||
|
const predictor = new LocalCharacterPredictor();
|
||||||
|
const auth = poseAt(500, 500);
|
||||||
|
|
||||||
|
const t = predictor.recordInput([1, 0]);
|
||||||
|
predictor.acknowledge(t, [0, 0], -Infinity); // server has consumed this input
|
||||||
|
predictor.setStrength(80);
|
||||||
|
predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never);
|
||||||
|
|
||||||
|
// No clock advance → zero replay window → predicted pose == authoritative.
|
||||||
|
const used = predictor.update([], 1 / 60);
|
||||||
|
|
||||||
|
expect(used).toBe(true);
|
||||||
|
expect(predictor.head.center[0]).toBeCloseTo(auth.head.center[0], 5);
|
||||||
|
expect(predictor.head.center[1]).toBeCloseTo(auth.head.center[1], 5);
|
||||||
|
expect(predictor.leftFoot.center[0]).toBeCloseTo(auth.leftFoot.center[0], 5);
|
||||||
|
expect(predictor.rightFoot.center[0]).toBeCloseTo(auth.rightFoot.center[0], 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replays un-acknowledged input deterministically', () => {
|
||||||
|
const drive = () => {
|
||||||
|
let clock = 0;
|
||||||
|
setPredictorClockForTesting(() => clock);
|
||||||
|
|
||||||
|
const predictor = new LocalCharacterPredictor();
|
||||||
|
clock = 1000;
|
||||||
|
predictor.acknowledge(900, [0, 0], -Infinity); // baseline ack (older than the input below)
|
||||||
|
predictor.setStrength(80);
|
||||||
|
const auth = poseAt(0, 0);
|
||||||
|
predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never);
|
||||||
|
predictor.recordInput([1, 0]); // unacked rightward input at t=1000
|
||||||
|
|
||||||
|
clock = 1100; // replay ~100 ms forward
|
||||||
|
predictor.update([], 1 / 60);
|
||||||
|
return [
|
||||||
|
predictor.head.center[0],
|
||||||
|
predictor.head.center[1],
|
||||||
|
predictor.leftFoot.center[0],
|
||||||
|
predictor.rightFoot.center[0],
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const a = drive();
|
||||||
|
const b = drive();
|
||||||
|
expect(a).toEqual(b); // deterministic replay
|
||||||
|
expect(a.every((n) => Number.isFinite(n))).toBe(true);
|
||||||
|
expect(a[0]).toBeGreaterThan(0.5); // rightward input actually moved the body
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses prediction while the local player is dead', () => {
|
||||||
|
let clock = 5000;
|
||||||
|
setPredictorClockForTesting(() => clock);
|
||||||
|
|
||||||
|
const predictor = new LocalCharacterPredictor();
|
||||||
|
const auth = poseAt(200, 200);
|
||||||
|
const t = predictor.recordInput([1, 0]);
|
||||||
|
predictor.acknowledge(t, [0, 0], -Infinity);
|
||||||
|
predictor.setStrength(80);
|
||||||
|
predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never);
|
||||||
|
predictor.setAlive(false); // dead, awaiting respawn
|
||||||
|
|
||||||
|
clock = 5200; // input + elapsed time that would otherwise be replayed forward
|
||||||
|
// Even with a valid authoritative pose and pending input, a dead body must
|
||||||
|
// not be predicted/moved — that was the "move while dead" bug.
|
||||||
|
expect(predictor.update([], 1 / 60)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -17,6 +17,11 @@ const {
|
||||||
MoveActionCommand,
|
MoveActionCommand,
|
||||||
UpdatePropertyCommand,
|
UpdatePropertyCommand,
|
||||||
PropertyUpdatesForObject,
|
PropertyUpdatesForObject,
|
||||||
|
// networked entity bases — their toArray() must mirror their constructor order
|
||||||
|
CharacterBase,
|
||||||
|
PlanetBase,
|
||||||
|
ProjectileBase,
|
||||||
|
LampBase,
|
||||||
// geometry — single source of truth shared by server & client prediction
|
// geometry — single source of truth shared by server & client prediction
|
||||||
headRadius,
|
headRadius,
|
||||||
feetRadius,
|
feetRadius,
|
||||||
|
|
@ -77,6 +82,67 @@ describe('serialization round-trip (built shared lib)', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('networked entity round-trips (toArray ↔ constructor contract)', () => {
|
||||||
|
it('round-trips a ProjectileBase', () => {
|
||||||
|
const out = deserialize(serialize(new ProjectileBase(7, [10, 20], 30, 'blue', 40)));
|
||||||
|
expect(out).toBeInstanceOf(ProjectileBase);
|
||||||
|
expect(out.id).toBe(7);
|
||||||
|
expect(out.center[0]).toBeCloseTo(10, 5);
|
||||||
|
expect(out.center[1]).toBeCloseTo(20, 5);
|
||||||
|
expect(out.radius).toBe(30);
|
||||||
|
expect(out.team).toBe('blue');
|
||||||
|
expect(out.strength).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips a CharacterBase with its nested body Circles', () => {
|
||||||
|
const out = deserialize(
|
||||||
|
serialize(
|
||||||
|
new CharacterBase(
|
||||||
|
8,
|
||||||
|
'Bob',
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
'red',
|
||||||
|
100,
|
||||||
|
new Circle([1, 2], 50),
|
||||||
|
new Circle([3, 4], 20),
|
||||||
|
new Circle([5, 6], 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(out).toBeInstanceOf(CharacterBase);
|
||||||
|
expect(out.id).toBe(8);
|
||||||
|
expect(out.name).toBe('Bob');
|
||||||
|
expect(out.killCount).toBe(2);
|
||||||
|
expect(out.deathCount).toBe(1);
|
||||||
|
expect(out.team).toBe('red');
|
||||||
|
expect(out.health).toBe(100);
|
||||||
|
expect(out.head).toBeInstanceOf(Circle);
|
||||||
|
expect(out.head.center[0]).toBeCloseTo(1, 5);
|
||||||
|
expect(out.head.radius).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips a LampBase', () => {
|
||||||
|
const out = deserialize(serialize(new LampBase(9, [7, 8], [1, 0.5, 0.2], 0.8)));
|
||||||
|
expect(out).toBeInstanceOf(LampBase);
|
||||||
|
expect(out.center[1]).toBeCloseTo(8, 5);
|
||||||
|
expect(out.color[2]).toBeCloseTo(0.2, 5);
|
||||||
|
expect(out.lightness).toBeCloseTo(0.8, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips a PlanetBase (derived centre/radius recomputed from vertices)', () => {
|
||||||
|
const out = deserialize(
|
||||||
|
serialize(new PlanetBase(10, [[0, 0], [100, 0], [50, 100]], 0.7, true)),
|
||||||
|
);
|
||||||
|
expect(out).toBeInstanceOf(PlanetBase);
|
||||||
|
expect(out.id).toBe(10);
|
||||||
|
expect(out.vertices).toHaveLength(3);
|
||||||
|
expect(out.ownership).toBeCloseTo(0.7, 5);
|
||||||
|
expect(out.isKeystone).toBe(true);
|
||||||
|
expect(out.center[0]).toBeCloseTo(50, 5); // recomputed by the constructor
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('shared character geometry — single source of truth', () => {
|
describe('shared character geometry — single source of truth', () => {
|
||||||
it('matches the known body layout', () => {
|
it('matches the known body layout', () => {
|
||||||
expect(headRadius).toBe(50);
|
expect(headRadius).toBe(50);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue