even more
This commit is contained in:
parent
82e092ba55
commit
8f50ebdb97
35 changed files with 1842 additions and 3095 deletions
|
|
@ -1,9 +1,9 @@
|
|||
export abstract class Command {
|
||||
public static get type(): string {
|
||||
return (this as any).name;
|
||||
return this.name;
|
||||
}
|
||||
|
||||
public get type(): string {
|
||||
return (this as any).constructor.name;
|
||||
return this.constructor.name;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import { serializable } from '../../../serialization/serializable';
|
||||
import { Command } from '../../command';
|
||||
|
||||
// Appended to every client -> server batch. Movement is only sent on change,
|
||||
// so without this the server's "newest input applied" marker freezes while a
|
||||
// key is held, pinning the predictor's replay window. This carries the client's
|
||||
// wall-clock so the marker advances at the send rate regardless of input change.
|
||||
@serializable
|
||||
export class ClientHeartbeatCommand extends Command {
|
||||
public constructor(public readonly clientTimeMs: number = 0) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import { serializable } from '../../../serialization/serializable';
|
||||
import { Command } from '../../command';
|
||||
|
||||
// clientTimeMs is echoed back via InputAcknowledgement so the predictor knows
|
||||
// which leaps the server has already folded into the streamed launch momentum
|
||||
// and must not replay again.
|
||||
@serializable
|
||||
export class LeapActionCommand extends Command {
|
||||
public constructor(public readonly clientTimeMs: number = 0) {
|
||||
|
|
|
|||
|
|
@ -4,10 +4,6 @@ import { Command } from '../../command';
|
|||
|
||||
@serializable
|
||||
export class MoveActionCommand extends Command {
|
||||
// clientTimeMs (integer ms, survives the serializer's toFixed(3)) is echoed
|
||||
// back via InputAcknowledgement so the client knows how much of its input
|
||||
// timeline is baked into a snapshot and can replay the rest. Defaults to 0
|
||||
// for inputs the server itself synthesises.
|
||||
public constructor(
|
||||
public readonly direction: vec2,
|
||||
public readonly clientTimeMs: number = 0,
|
||||
|
|
|
|||
|
|
@ -7,9 +7,6 @@ export class PrimaryActionCommand extends Command {
|
|||
public constructor(
|
||||
public readonly position: vec2,
|
||||
public readonly charge: number = 0,
|
||||
// Client wall-clock at shot release, echoed back via InputAcknowledgement
|
||||
// like the other actions' — it advances the predictor's replay anchor past
|
||||
// this shot. Appended last so the existing wire order is unchanged.
|
||||
public readonly clientTimeMs: number = 0,
|
||||
) {
|
||||
super();
|
||||
|
|
|
|||
|
|
@ -3,10 +3,6 @@ import { Command } from '../command';
|
|||
|
||||
@serializable
|
||||
export class GameStartCommand extends Command {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public toArray(): Array<any> {
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,16 +3,7 @@ import { Id } from '../../communication/communication';
|
|||
import { serializable } from '../../serialization/serializable';
|
||||
import { Command } from '../command';
|
||||
|
||||
/**
|
||||
* Movement state carried tick-to-tick that is NOT in the pose. Together with
|
||||
* the pose (which travels as ordinary property updates) this is the complete
|
||||
* state `stepCharacterMovement` needs, so the predictor resumes the server's
|
||||
* simulation rather than approximating it.
|
||||
*
|
||||
* `direction` is not recoverable from the pose: the body is sprung *towards*
|
||||
* the posture that direction implies and lags it while walking, so deriving the
|
||||
* angle back out is off by tens of degrees.
|
||||
*/
|
||||
// `direction` is not recoverable from the pose: the body lags the posture it is sprung towards.
|
||||
@serializable
|
||||
export class CharacterMovementSnapshot {
|
||||
public constructor(
|
||||
|
|
@ -20,7 +11,7 @@ export class CharacterMovementSnapshot {
|
|||
public readonly bodyVelocity: vec2,
|
||||
public readonly leftFootNormal: vec2,
|
||||
public readonly rightFootNormal: vec2,
|
||||
public readonly groundPlanetId: Id,
|
||||
public readonly groundPlanetId: Id | null,
|
||||
public readonly secondsSinceOnSurface: number,
|
||||
) {}
|
||||
|
||||
|
|
@ -36,21 +27,12 @@ export class CharacterMovementSnapshot {
|
|||
}
|
||||
}
|
||||
|
||||
// Sent server -> owning client only. Carries the clientTimeMs of the most
|
||||
// recent input the server had received when the snapshot was taken (so the
|
||||
// predictor replays only the inputs the server hasn't seen) and the movement
|
||||
// state the replay resumes from. See LocalCharacterPredictor.
|
||||
@serializable
|
||||
export class InputAcknowledgement extends Command {
|
||||
public constructor(
|
||||
public readonly clientTimeMs: number,
|
||||
public readonly movement: CharacterMovementSnapshot,
|
||||
// clientTimeMs of the most recent leap the server has received: any leap at
|
||||
// or before it is already in movement.bodyVelocity and must not be replayed.
|
||||
public readonly lastLeapClientTimeMs: number,
|
||||
// Age of the acknowledged input at snapshot time. Adding it back places the
|
||||
// replay anchor at the snapshot instant instead of whichever input landed
|
||||
// last, keeping the predicted pose free of the send-cadence beat.
|
||||
public readonly ackAgeMs: number = 0,
|
||||
) {
|
||||
super();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { Command } from '../command';
|
|||
export class PropertyUpdatesForObjects extends Command {
|
||||
constructor(
|
||||
public readonly updates: Array<PropertyUpdatesForObject>,
|
||||
// Only differences between timestamps are meaningful.
|
||||
public readonly timestamp: number,
|
||||
) {
|
||||
super();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Id, RemoteCall } from '../../main';
|
||||
import { Id } from '../../communication/communication';
|
||||
import { RemoteCall } from '../../objects/game-object';
|
||||
import { serializable } from '../../serialization/serializable';
|
||||
import { Command } from '../command';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
export type Id = number | null;
|
||||
export type Id = number;
|
||||
|
||||
let currentId = 0;
|
||||
export const id = (): number => {
|
||||
return currentId++;
|
||||
};
|
||||
export const id = (): Id => currentId++;
|
||||
|
||||
export interface PlayerInformation {
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { settings } from '../main';
|
||||
import { settings } from '../settings';
|
||||
|
||||
export const calculateViewArea = (
|
||||
center: vec2,
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import { settings } from '../settings';
|
|||
export const holdDurationToCharge = (heldSeconds: number): number =>
|
||||
clamp01(heldSeconds / settings.chargeShotFullHoldSeconds);
|
||||
|
||||
// Inverse of the server's projectile mix(); both sides need this number, so it
|
||||
// lives here rather than being derived twice.
|
||||
export const chargeHeldSince = (heldSinceMs: number): number =>
|
||||
holdDurationToCharge((performance.now() - heldSinceMs) / 1000);
|
||||
|
||||
export const strengthToCharge = (strength: number): number =>
|
||||
clamp01(
|
||||
(strength - settings.chargeShotStrengthMin) /
|
||||
|
|
|
|||
15
shared/src/helper/ease.ts
Normal file
15
shared/src/helper/ease.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
export const smoothing = (deltaTimeInSeconds: number, seconds: number): number =>
|
||||
1 - Math.exp(-deltaTimeInSeconds / seconds);
|
||||
|
||||
export const easeVec2 = (
|
||||
out: vec2,
|
||||
target: vec2,
|
||||
deltaTimeInSeconds: number,
|
||||
seconds: number,
|
||||
snapDistance: number,
|
||||
): vec2 =>
|
||||
vec2.distance(out, target) > snapDistance
|
||||
? vec2.copy(out, target)
|
||||
: vec2.lerp(out, out, target, smoothing(deltaTimeInSeconds, seconds));
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { clamp } from './clamp';
|
||||
|
||||
export const isFiniteNumber = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value);
|
||||
|
||||
/** A finite number within [min, max], or `fallback` if the input is unusable. */
|
||||
export const finiteInRange = (
|
||||
value: unknown,
|
||||
min: number,
|
||||
|
|
@ -13,15 +13,9 @@ export const finiteInRange = (
|
|||
if (!isFiniteNumber(value)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.min(max, Math.max(min, value));
|
||||
return clamp(value, min, max);
|
||||
};
|
||||
|
||||
/**
|
||||
* A finite 2-vector whose magnitude is within `maxMagnitude`, or `undefined` if
|
||||
* the input is not a usable pair of finite numbers. Callers should treat
|
||||
* `undefined` as "drop this command" rather than substituting a default, so a
|
||||
* malformed action is ignored instead of being silently reinterpreted.
|
||||
*/
|
||||
export const finiteVec2 = (value: unknown, maxMagnitude: number): vec2 | undefined => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
|
|
@ -47,10 +41,6 @@ export const finiteVec2 = (value: unknown, maxMagnitude: number): vec2 | undefin
|
|||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Coerces non-strings: a non-string survives `.slice()` and round-trips through
|
||||
* the serializer revived as a class on every peer, throwing in the reviver.
|
||||
*/
|
||||
export const sanitizeName = (value: unknown, maxLength: number): string => {
|
||||
const text = typeof value === 'string' ? value : String(value ?? '');
|
||||
return text.slice(0, maxLength);
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ import { vec3 } from 'gl-matrix';
|
|||
import { clamp01 } from './clamp';
|
||||
import { mix } from './mix';
|
||||
|
||||
// Component-wise lerp of two colours. Kept as a helper because the alternative
|
||||
// is the same clamp-plus-three-mixes expansion repeated at every call site.
|
||||
export const mixRgb = (a: vec3, b: vec3, q: number): vec3 => {
|
||||
const clampedQ = clamp01(q);
|
||||
return vec3.fromValues(
|
||||
|
|
|
|||
|
|
@ -8,9 +8,6 @@ function getRandom(): number {
|
|||
}
|
||||
|
||||
export const Random = {
|
||||
get seed() {
|
||||
return seed;
|
||||
},
|
||||
set seed(value: number) {
|
||||
seed = value;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,13 +10,11 @@ export * from './commands/types/game-start';
|
|||
export * from './commands/types/update-minimap';
|
||||
export * from './commands/types/update-game-state';
|
||||
export * from './commands/command-receiver';
|
||||
export * from './commands/command-generator';
|
||||
export * from './commands/types/actions/move-action';
|
||||
export * from './commands/types/actions/primary-action';
|
||||
export * from './commands/types/actions/leap-action';
|
||||
export * from './commands/types/actions/client-heartbeat';
|
||||
export * from './commands/types/actions/set-aspect-ratio-action';
|
||||
export * from './helper/array';
|
||||
export * from './helper/hsl';
|
||||
export * from './helper/rgb255';
|
||||
export * from './helper/clamp';
|
||||
|
|
@ -25,13 +23,13 @@ export * from './helper/charge';
|
|||
export * from './helper/calculate-view-area';
|
||||
export * from './helper/circle';
|
||||
export * from './helper/mix';
|
||||
export * from './helper/ease';
|
||||
export * from './helper/mix-rgb';
|
||||
export * from './helper/random';
|
||||
export * from './communication/communication';
|
||||
export * from './objects/game-object';
|
||||
export * from './serialization/deserialize';
|
||||
export * from './serialization/serialize';
|
||||
export * from './serialization/serializes-to';
|
||||
export * from './serialization/serializable';
|
||||
export * from './serialization/override-deserialization';
|
||||
export * from './objects/types/character-base';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Command } from '../commands/command';
|
||||
import { CommandReceiver } from '../commands/command-receiver';
|
||||
import { Id } from '../communication/communication';
|
||||
import { serializable } from '../serialization/serializable';
|
||||
|
||||
|
|
@ -15,8 +14,6 @@ export class RemoteCall {
|
|||
}
|
||||
}
|
||||
|
||||
// Wire property keys — compiler-checked at both the producing (server) and
|
||||
// consuming (client) ends; wire strings are unchanged.
|
||||
export type SyncPropertyKey =
|
||||
| 'head'
|
||||
| 'leftFoot'
|
||||
|
|
@ -53,67 +50,51 @@ export class PropertyUpdatesForObject {
|
|||
}
|
||||
}
|
||||
|
||||
let currentUpdateGeneration = 0;
|
||||
export const beginPropertyUpdateGeneration = (): void => {
|
||||
currentUpdateGeneration++;
|
||||
};
|
||||
// Security: remote calls are dispatched by a raw string off the network.
|
||||
const allowedRemoteCalls: ReadonlySet<string> = new Set([
|
||||
'onShoot',
|
||||
'onLeap',
|
||||
'onDie',
|
||||
'onHitConfirmed',
|
||||
'onKillConfirmed',
|
||||
'setHealth',
|
||||
'setKillCount',
|
||||
'onFlipped',
|
||||
'setContested',
|
||||
'generatedPoints',
|
||||
'setLight',
|
||||
]);
|
||||
|
||||
export abstract class GameObject extends CommandReceiver {
|
||||
export abstract class GameObject {
|
||||
private remoteCalls: Array<RemoteCall> = [];
|
||||
private updateGeneration = -1;
|
||||
private cachedPropertyUpdates?: PropertyUpdatesForObject;
|
||||
|
||||
// Security: processRemoteCalls dispatches by a raw string off the network.
|
||||
// Keep this allow-list in sync with the remoteCall() emitters on *-physical.
|
||||
private static readonly allowedRemoteCalls: ReadonlySet<string> = new Set([
|
||||
'onShoot',
|
||||
'onLeap',
|
||||
'onDie',
|
||||
'onHitConfirmed',
|
||||
'onKillConfirmed',
|
||||
'setHealth',
|
||||
'setKillCount',
|
||||
'onFlipped',
|
||||
'setContested',
|
||||
'generatedPoints',
|
||||
'setLight',
|
||||
]);
|
||||
|
||||
constructor(public readonly id: Id) {
|
||||
super();
|
||||
}
|
||||
constructor(public readonly id: Id) {}
|
||||
|
||||
public processRemoteCalls(remoteCalls: Array<RemoteCall>) {
|
||||
remoteCalls.forEach((r) => {
|
||||
if (!GameObject.allowedRemoteCalls.has(r.functionName)) {
|
||||
console.warn(`Dropped disallowed remote call: ${r.functionName}`);
|
||||
return;
|
||||
for (const { functionName, args } of remoteCalls) {
|
||||
if (!allowedRemoteCalls.has(functionName)) {
|
||||
console.warn(`Dropped disallowed remote call: ${functionName}`);
|
||||
continue;
|
||||
}
|
||||
const fn = this[r.functionName as keyof this];
|
||||
const fn = this[functionName as keyof this];
|
||||
if (typeof fn === 'function') {
|
||||
(fn as (...args: Array<any>) => unknown).apply(this, r.args);
|
||||
(fn as (...args: Array<any>) => unknown).apply(this, args);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public getPropertyUpdates(): PropertyUpdatesForObject | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public getPropertyUpdatesForFrame(): PropertyUpdatesForObject | undefined {
|
||||
if (this.updateGeneration !== currentUpdateGeneration) {
|
||||
this.updateGeneration = currentUpdateGeneration;
|
||||
this.cachedPropertyUpdates = this.getPropertyUpdates();
|
||||
}
|
||||
return this.cachedPropertyUpdates;
|
||||
}
|
||||
|
||||
public getRemoteCalls(): Array<RemoteCall> {
|
||||
return this.remoteCalls;
|
||||
}
|
||||
|
||||
public resetRemoteCalls() {
|
||||
this.remoteCalls = [];
|
||||
if (this.remoteCalls.length > 0) {
|
||||
this.remoteCalls = [];
|
||||
}
|
||||
}
|
||||
|
||||
protected remoteCall(name: string & keyof this, ...args: Array<any>) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { Id } from '../../communication/communication';
|
||||
import { Circle } from '../../helper/circle';
|
||||
import { serializable } from '../../serialization/serializable';
|
||||
import { toArrayFromFields } from '../../serialization/serialized-fields';
|
||||
import { GameObject } from '../game-object';
|
||||
|
||||
export enum CharacterTeam {
|
||||
|
|
@ -12,18 +11,6 @@ export enum CharacterTeam {
|
|||
|
||||
@serializable
|
||||
export class CharacterBase extends GameObject {
|
||||
private static readonly serializedFields = [
|
||||
'id',
|
||||
'name',
|
||||
'killCount',
|
||||
'deathCount',
|
||||
'team',
|
||||
'health',
|
||||
'head',
|
||||
'leftFoot',
|
||||
'rightFoot',
|
||||
] as const;
|
||||
|
||||
constructor(
|
||||
id: Id,
|
||||
public name: string,
|
||||
|
|
@ -31,23 +18,20 @@ export class CharacterBase extends GameObject {
|
|||
public deathCount: number,
|
||||
public team: CharacterTeam,
|
||||
public health: number,
|
||||
public head?: Circle,
|
||||
public leftFoot?: Circle,
|
||||
public rightFoot?: Circle,
|
||||
public head: Circle,
|
||||
public leftFoot: Circle,
|
||||
public rightFoot: Circle,
|
||||
) {
|
||||
super(id);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public onShoot(strength: number) {}
|
||||
public onShoot(_strength: number) {}
|
||||
|
||||
public onLeap() {}
|
||||
|
||||
// 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
|
||||
public onKillConfirmed(victimName?: string, streak?: number, charge?: number) {}
|
||||
public onKillConfirmed(_victimName?: string, _streak?: number, _charge?: number) {}
|
||||
|
||||
public setHealth(health: number) {
|
||||
this.health = health;
|
||||
|
|
@ -60,6 +44,16 @@ export class CharacterBase extends GameObject {
|
|||
}
|
||||
|
||||
public toArray(): Array<any> {
|
||||
return toArrayFromFields(this, CharacterBase.serializedFields);
|
||||
return [
|
||||
this.id,
|
||||
this.name,
|
||||
this.killCount,
|
||||
this.deathCount,
|
||||
this.team,
|
||||
this.health,
|
||||
this.head,
|
||||
this.leftFoot,
|
||||
this.rightFoot,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,10 @@
|
|||
import { vec2, vec3 } from 'gl-matrix';
|
||||
import { Id, serializable } from '../../main';
|
||||
import { toArrayFromFields } from '../../serialization/serialized-fields';
|
||||
import { Id } from '../../communication/communication';
|
||||
import { serializable } from '../../serialization/serializable';
|
||||
import { GameObject } from '../game-object';
|
||||
|
||||
@serializable
|
||||
export class LampBase extends GameObject {
|
||||
private static readonly serializedFields = [
|
||||
'id',
|
||||
'center',
|
||||
'color',
|
||||
'lightness',
|
||||
] as const;
|
||||
|
||||
constructor(
|
||||
id: Id,
|
||||
public center: vec2,
|
||||
|
|
@ -21,10 +14,9 @@ export class LampBase extends GameObject {
|
|||
super(id);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public setLight(color: vec3, lightness: number) {}
|
||||
public setLight(_color: vec3, _lightness: number) {}
|
||||
|
||||
public toArray(): Array<any> {
|
||||
return toArrayFromFields(this, LampBase.serializedFields);
|
||||
return [this.id, this.center, this.color, this.lightness];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue