Compare commits
2 commits
d0265ad90e
...
d9b80b92ca
| Author | SHA1 | Date | |
|---|---|---|---|
| d9b80b92ca | |||
| b6db7e8dc7 |
34 changed files with 1054 additions and 168 deletions
|
|
@ -32,6 +32,12 @@ jobs:
|
|||
- name: Build (shared -> frontend -> backend)
|
||||
run: npm run build
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint:check
|
||||
|
||||
- name: Deploy to host pages mount
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -99,7 +99,8 @@ export class GameServer extends CommandReceiver {
|
|||
this.players.deletePlayer(player);
|
||||
this.sendServerStateUpdate();
|
||||
});
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error('Failed to register joining player; disconnecting socket', e);
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
|
|
@ -168,6 +169,9 @@ export class GameServer extends CommandReceiver {
|
|||
|
||||
private timeSinceLastPointUpdate = 0;
|
||||
private physicsAccumulator = 0;
|
||||
// Frames since the last stats report where physics ran over budget (more
|
||||
// substeps than the cap). Surfaced by handleStats as a saturation signal.
|
||||
private saturatedFrames = 0;
|
||||
|
||||
private handlePhysics() {
|
||||
const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true });
|
||||
|
|
@ -189,12 +193,24 @@ export class GameServer extends CommandReceiver {
|
|||
|
||||
const fixedDelta = settings.targetPhysicsDeltaTimeInSeconds;
|
||||
const maxSubstepsPerFrame = 5;
|
||||
// Cap on retained physics backlog when saturated, so a long stall can't
|
||||
// accumulate an unrecoverable catch-up.
|
||||
const maxBacklogSeconds = 0.25;
|
||||
|
||||
this.physicsAccumulator += delta;
|
||||
let substeps = Math.floor(this.physicsAccumulator / fixedDelta);
|
||||
if (substeps > maxSubstepsPerFrame) {
|
||||
// Saturated: run the cap's worth of substeps but KEEP the remaining
|
||||
// backlog (clamped) instead of zeroing it. Dropping it silently slowed
|
||||
// simulated time for everyone — and diverged client prediction, whose
|
||||
// wall-clock keeps running. Clamping bounds the catch-up so a transient
|
||||
// spike recovers without a death spiral.
|
||||
this.saturatedFrames++;
|
||||
this.physicsAccumulator = Math.min(
|
||||
this.physicsAccumulator - maxSubstepsPerFrame * fixedDelta,
|
||||
maxBacklogSeconds,
|
||||
);
|
||||
substeps = maxSubstepsPerFrame;
|
||||
this.physicsAccumulator = 0;
|
||||
} else {
|
||||
this.physicsAccumulator -= substeps * fixedDelta;
|
||||
}
|
||||
|
|
@ -237,6 +253,23 @@ export class GameServer extends CommandReceiver {
|
|||
console.info(
|
||||
`Memory used: ${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`,
|
||||
);
|
||||
|
||||
const rtts = this.players.connectedPlayerRttsMs.filter((r) => r > 0);
|
||||
if (rtts.length > 0) {
|
||||
rtts.sort((a, b) => a - b);
|
||||
console.info(
|
||||
`Player RTT median ${rtts[Math.floor(rtts.length / 2)].toFixed(0)} ms ` +
|
||||
`(min ${rtts[0].toFixed(0)}, max ${rtts[rtts.length - 1].toFixed(0)}, n=${rtts.length})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.saturatedFrames > 0) {
|
||||
console.warn(
|
||||
`Physics saturated on ${this.saturatedFrames} frame(s) since last report — shedding backlog`,
|
||||
);
|
||||
this.saturatedFrames = 0;
|
||||
}
|
||||
|
||||
this.deltaTimes = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@ import {
|
|||
CharacterMovementState,
|
||||
CharacterWorld,
|
||||
GroundSurface,
|
||||
headRadius,
|
||||
feetRadius,
|
||||
headOffset,
|
||||
leftFootOffset,
|
||||
rightFootOffset,
|
||||
boundRadius,
|
||||
} from 'shared';
|
||||
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
||||
import { CirclePhysical } from './circle-physical';
|
||||
|
|
@ -38,48 +44,14 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
public readonly canCollide = true;
|
||||
public readonly canMove = true;
|
||||
|
||||
private static readonly headRadius = 50;
|
||||
private static readonly feetRadius = 20;
|
||||
private projectileStrength = settings.playerMaxStrength;
|
||||
|
||||
// offsets are measured from (0, 0). The head sits this far above the feet,
|
||||
// which sets the leg length: kept short so the body reads as a compact head on
|
||||
// stubby legs rather than a long-legged strider.
|
||||
private static readonly desiredHeadOffset = vec2.fromValues(0, 55);
|
||||
private static readonly desiredLeftFootOffset = vec2.fromValues(-20, 0);
|
||||
private static readonly desiredRightFootOffset = vec2.fromValues(20, 0);
|
||||
private static readonly centerOfMass = vec2.scale(
|
||||
vec2.create(),
|
||||
vec2.add(
|
||||
vec2.create(),
|
||||
vec2.add(
|
||||
vec2.create(),
|
||||
CharacterPhysical.desiredHeadOffset,
|
||||
CharacterPhysical.desiredLeftFootOffset,
|
||||
),
|
||||
CharacterPhysical.desiredRightFootOffset,
|
||||
),
|
||||
1 / 3,
|
||||
);
|
||||
|
||||
private static readonly headOffset = vec2.subtract(
|
||||
vec2.create(),
|
||||
CharacterPhysical.desiredHeadOffset,
|
||||
CharacterPhysical.centerOfMass,
|
||||
);
|
||||
private static readonly leftFootOffset = vec2.subtract(
|
||||
vec2.create(),
|
||||
CharacterPhysical.desiredLeftFootOffset,
|
||||
CharacterPhysical.centerOfMass,
|
||||
);
|
||||
private static readonly rightFootOffset = vec2.subtract(
|
||||
vec2.create(),
|
||||
CharacterPhysical.desiredRightFootOffset,
|
||||
CharacterPhysical.centerOfMass,
|
||||
);
|
||||
|
||||
public static readonly boundRadius =
|
||||
(CharacterPhysical.headRadius + CharacterPhysical.feetRadius * 2) * 2;
|
||||
// Body geometry (head/foot radii, posture offsets, bound radius) is defined
|
||||
// once in the shared movement module and imported here, so the authoritative
|
||||
// body and the client's predicted body are bit-identical by construction
|
||||
// instead of by a hand-synced "copied verbatim" duplicate. Re-exposed as a
|
||||
// static only because external callers reference CharacterPhysical.boundRadius.
|
||||
public static readonly boundRadius = boundRadius;
|
||||
|
||||
private timeSinceDying = 0;
|
||||
private isDestroyed = false;
|
||||
|
|
@ -129,20 +101,20 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
) {
|
||||
super(id(), name, killCount, deathCount, team, settings.playerMaxHealth);
|
||||
this.head = new CirclePhysical(
|
||||
vec2.add(vec2.create(), startPosition, CharacterPhysical.headOffset),
|
||||
CharacterPhysical.headRadius,
|
||||
vec2.add(vec2.create(), startPosition, headOffset),
|
||||
headRadius,
|
||||
this,
|
||||
container,
|
||||
);
|
||||
this.leftFoot = new CirclePhysical(
|
||||
vec2.add(vec2.create(), startPosition, CharacterPhysical.leftFootOffset),
|
||||
CharacterPhysical.feetRadius,
|
||||
vec2.add(vec2.create(), startPosition, leftFootOffset),
|
||||
feetRadius,
|
||||
this,
|
||||
container,
|
||||
);
|
||||
this.rightFoot = new CirclePhysical(
|
||||
vec2.add(vec2.create(), startPosition, CharacterPhysical.rightFootOffset),
|
||||
CharacterPhysical.feetRadius,
|
||||
vec2.add(vec2.create(), startPosition, rightFootOffset),
|
||||
feetRadius,
|
||||
this,
|
||||
container,
|
||||
);
|
||||
|
|
@ -154,6 +126,9 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
}
|
||||
|
||||
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;
|
||||
this.movementState = {
|
||||
head: this.head,
|
||||
|
|
@ -411,8 +386,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
}
|
||||
|
||||
private animateScaling(q: number) {
|
||||
this.head.radius = CharacterPhysical.headRadius * q;
|
||||
this.leftFoot.radius = this.rightFoot.radius = CharacterPhysical.feetRadius * q;
|
||||
this.head.radius = headRadius * q;
|
||||
this.leftFoot.radius = this.rightFoot.radius = feetRadius * q;
|
||||
}
|
||||
|
||||
public getPropertyUpdates(): PropertyUpdatesForObject {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,12 @@ export class PlayerContainer {
|
|||
return this._players.length;
|
||||
}
|
||||
|
||||
// Measured round-trip times (ms) of the real connected players, for
|
||||
// server-side latency stats. NPCs have no socket and are excluded.
|
||||
public get connectedPlayerRttsMs(): Array<number> {
|
||||
return this._players.map((p) => p.rttMs);
|
||||
}
|
||||
|
||||
public step(deltaTimeInSeconds: number) {
|
||||
this.players.forEach((p) => p.step(deltaTimeInSeconds));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ import { PhysicalContainer } from '../physics/containers/physical-container';
|
|||
import { PlayerContainer } from './player-container';
|
||||
import { PlayerBase } from './player-base';
|
||||
|
||||
// How often the server pings each client to measure round-trip time.
|
||||
const pingIntervalSeconds = 1;
|
||||
|
||||
export class Player extends PlayerBase {
|
||||
// default, until the clients sends its real value
|
||||
private aspectRatio: number = 16 / 9;
|
||||
|
|
@ -41,6 +44,12 @@ export class Player extends PlayerBase {
|
|||
private lastInputClientTimeMs = 0;
|
||||
private lastLeapClientTimeMs = 0;
|
||||
|
||||
// Measured round-trip time to this client (ms) — the latency primitive that
|
||||
// lag compensation, a latency HUD, and adaptive interpolation build on.
|
||||
public rttMs = 0;
|
||||
private timeSinceLastPing = 0;
|
||||
private lastPingSentMs = 0;
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) =>
|
||||
(this.aspectRatio = v.aspectRatio),
|
||||
|
|
@ -71,6 +80,15 @@ export class Player extends PlayerBase {
|
|||
super(playerInfo, playerContainer, objectContainer, team);
|
||||
this.createCharacter();
|
||||
this.step(0);
|
||||
|
||||
// The client already echoes a Pong for every Ping (see game.ts). Only one
|
||||
// ping is ever in flight, so RTT is simply now − send-time; no payload
|
||||
// needed and no client change required.
|
||||
this.socket.on(TransportEvents.Pong, () => {
|
||||
if (this.lastPingSentMs > 0) {
|
||||
this.rttMs = performance.now() - this.lastPingSentMs;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected createCharacter() {
|
||||
|
|
@ -191,6 +209,12 @@ export class Player extends PlayerBase {
|
|||
this.queueCommandSend(new RemoteCallsForObjects(remoteCalls));
|
||||
}
|
||||
|
||||
if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) {
|
||||
this.timeSinceLastPing = 0;
|
||||
this.lastPingSentMs = performance.now();
|
||||
this.socket.emit(TransportEvents.Ping);
|
||||
}
|
||||
|
||||
if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) {
|
||||
this.handleAnnouncements();
|
||||
this.handleViewAreaUpdate();
|
||||
|
|
|
|||
|
|
@ -332,6 +332,26 @@ body {
|
|||
&::after {
|
||||
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 {
|
||||
|
|
@ -594,8 +614,8 @@ body {
|
|||
.hitmarker {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
@include square(26px);
|
||||
opacity: 0.9;
|
||||
@include square(34px);
|
||||
opacity: 0.95;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
|
|
@ -603,10 +623,13 @@ body {
|
|||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 12px;
|
||||
height: 2px;
|
||||
width: 16px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
|
||||
box-shadow:
|
||||
0 0 6px rgba(255, 255, 255, 0.9),
|
||||
0 0 3px rgba(0, 0, 0, 0.9);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
|
|
@ -618,31 +641,58 @@ body {
|
|||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
}
|
||||
|
||||
animation: hitmarker-pop 250ms ease-out forwards;
|
||||
animation: hitmarker-pop 220ms cubic-bezier(0.2, 0.9, 0.3, 1) forwards;
|
||||
|
||||
&.charged {
|
||||
@include square(44px);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
width: 18px;
|
||||
height: 3px;
|
||||
width: 24px;
|
||||
height: 4px;
|
||||
background-color: $accent;
|
||||
box-shadow:
|
||||
0 0 10px rgba($accent, 0.9),
|
||||
0 0 4px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.kill-flash {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(ellipse at center,
|
||||
transparent 42%,
|
||||
rgba($accent, 0) 56%,
|
||||
rgba($accent, 0.55) 100%);
|
||||
animation: kill-flash 420ms ease-out forwards;
|
||||
|
||||
&.charged {
|
||||
background: radial-gradient(ellipse at center,
|
||||
transparent 36%,
|
||||
rgba($accent, 0) 50%,
|
||||
rgba($accent, 0.8) 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.kill-popup {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
font-weight: 700;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
font-size: 1.7rem;
|
||||
white-space: nowrap;
|
||||
color: $bright-neutral;
|
||||
text-shadow: 0 0 6px rgba(0, 0, 0, 0.9);
|
||||
animation: kill-popup-rise 1200ms ease-out forwards;
|
||||
text-shadow:
|
||||
0 0 10px rgba(0, 0, 0, 0.9),
|
||||
0 0 18px rgba(255, 255, 255, 0.35);
|
||||
animation: kill-popup-rise 1200ms cubic-bezier(0.18, 0.9, 0.3, 1) forwards;
|
||||
|
||||
&.charged {
|
||||
color: $accent;
|
||||
text-shadow:
|
||||
0 0 10px rgba(0, 0, 0, 0.9),
|
||||
0 0 18px rgba($accent, 0.6);
|
||||
}
|
||||
|
||||
.heal {
|
||||
|
|
@ -655,12 +705,14 @@ body {
|
|||
top: 33%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-weight: 700;
|
||||
font-size: 2.6rem;
|
||||
font-weight: 800;
|
||||
font-size: 3rem;
|
||||
color: $accent;
|
||||
text-shadow: 0 0 12px rgba(0, 0, 0, 0.9);
|
||||
text-shadow:
|
||||
0 0 12px rgba(0, 0, 0, 0.9),
|
||||
0 0 24px rgba($accent, 0.7);
|
||||
white-space: nowrap;
|
||||
animation: streak-pop 1400ms ease-out forwards;
|
||||
animation: streak-pop 1400ms cubic-bezier(0.18, 0.9, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.killfeed {
|
||||
|
|
@ -685,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 {
|
||||
|
|
@ -748,46 +828,72 @@ body {
|
|||
@keyframes hitmarker-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(1.6);
|
||||
transform: translate(-50%, -50%) scale(2);
|
||||
}
|
||||
|
||||
25% {
|
||||
18% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(0.82);
|
||||
}
|
||||
|
||||
36% {
|
||||
transform: translate(-50%, -50%) scale(1.06);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes kill-flash {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
12% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes kill-popup-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -30%) scale(0.8);
|
||||
transform: translate(-50%, -20%) scale(0.6);
|
||||
}
|
||||
|
||||
20% {
|
||||
12% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -60%) scale(1);
|
||||
transform: translate(-50%, -56%) scale(1.25);
|
||||
}
|
||||
|
||||
28% {
|
||||
transform: translate(-50%, -62%) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -160%) scale(1);
|
||||
transform: translate(-50%, -170%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes streak-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.6);
|
||||
transform: translateX(-50%) scale(0.5);
|
||||
}
|
||||
|
||||
20% {
|
||||
16% {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) scale(1.1);
|
||||
transform: translateX(-50%) scale(1.25);
|
||||
}
|
||||
|
||||
32% {
|
||||
transform: translateX(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
70% {
|
||||
|
|
@ -820,3 +926,15 @@ body {
|
|||
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 { Pointer } from './helper/pointer';
|
||||
|
||||
|
||||
export abstract class ChargeIndicator {
|
||||
private static element?: HTMLElement;
|
||||
private static heldSince = 0;
|
||||
|
|
@ -41,7 +40,8 @@ export abstract class ChargeIndicator {
|
|||
(performance.now() - ChargeIndicator.heldSince) / 1000,
|
||||
);
|
||||
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) ${
|
||||
charge * 360
|
||||
}deg, rgba(255, 255, 255, 0.15) 0deg)`;
|
||||
element.classList.toggle('full', charge >= 1);
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ import { localCharacterPredictor } from '../helper/prediction/local-character-pr
|
|||
export class TouchListener extends CommandGenerator {
|
||||
private static readonly deadZone = 8;
|
||||
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 joystickButton: HTMLElement;
|
||||
|
|
@ -24,9 +27,12 @@ export class TouchListener extends CommandGenerator {
|
|||
|
||||
private fireButton: HTMLElement;
|
||||
private fireStrengthRing: HTMLElement;
|
||||
private fireAimLine!: HTMLElement;
|
||||
private leapButton: HTMLElement;
|
||||
|
||||
private fireDownAt: number | null = null;
|
||||
private fireButtonCenter: vec2 | null = null;
|
||||
private fireAimScreen: vec2 | null = null;
|
||||
|
||||
constructor(
|
||||
private target: HTMLElement,
|
||||
|
|
@ -45,8 +51,12 @@ export class TouchListener extends CommandGenerator {
|
|||
this.fireStrengthRing = document.createElement('div');
|
||||
this.fireStrengthRing.className = 'strength-ring';
|
||||
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('touchmove', this.fireButtonMoveListener);
|
||||
this.fireButton.addEventListener('touchend', this.fireButtonUpListener);
|
||||
|
||||
this.leapButton = document.createElement('div');
|
||||
|
|
@ -159,12 +169,41 @@ export class TouchListener extends CommandGenerator {
|
|||
this.swallowTouch(event);
|
||||
this.fireDownAt = performance.now();
|
||||
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) => {
|
||||
this.swallowTouch(event);
|
||||
ChargeIndicator.end();
|
||||
this.fireAimLine.style.opacity = '0';
|
||||
if (this.fireDownAt === null) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -174,12 +213,28 @@ export class TouchListener extends CommandGenerator {
|
|||
|
||||
const character = this.game.gameObjects.player;
|
||||
if (!character) {
|
||||
this.fireButtonCenter = null;
|
||||
this.fireAimScreen = null;
|
||||
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(
|
||||
vec2.create(),
|
||||
character.bodyCenter,
|
||||
character.facingDirection,
|
||||
direction,
|
||||
settings.touchAimRange,
|
||||
);
|
||||
this.sendCommandToSubscribers(new PrimaryActionCommand(aim, charge));
|
||||
|
|
@ -195,7 +250,8 @@ export class TouchListener extends CommandGenerator {
|
|||
|
||||
const character = this.game.gameObjects.player;
|
||||
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) ${
|
||||
character.strengthFraction * 360
|
||||
}deg, transparent 0deg)`;
|
||||
}
|
||||
}
|
||||
|
|
@ -207,6 +263,7 @@ export class TouchListener extends CommandGenerator {
|
|||
this.target.removeEventListener('touchend', this.touchEndListener);
|
||||
|
||||
this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener);
|
||||
this.fireButton.removeEventListener('touchmove', this.fireButtonMoveListener);
|
||||
this.fireButton.removeEventListener('touchend', this.fireButtonUpListener);
|
||||
this.leapButton.removeEventListener('touchstart', this.leapButtonListener);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Pointer } from './helper/pointer';
|
|||
export abstract class FeedbackHud {
|
||||
private static root?: HTMLElement;
|
||||
private static killfeed?: HTMLElement;
|
||||
private static elimination?: HTMLElement;
|
||||
|
||||
private static ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } {
|
||||
if (!this.root || !this.killfeed) {
|
||||
|
|
@ -47,6 +48,11 @@ export abstract class FeedbackHud {
|
|||
const { killfeed } = this.ensureRoot();
|
||||
const charged = charge >= settings.chargedHitThreshold;
|
||||
|
||||
// A quick crimson vignette pulse around the whole frame to punctuate the kill.
|
||||
const flash = document.createElement('div');
|
||||
flash.className = 'kill-flash' + (charged ? ' charged' : '');
|
||||
this.addTransient(flash, 420);
|
||||
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'kill-entry';
|
||||
entry.innerHTML = `Eliminated <b>${this.escape(victimName ?? 'enemy')}</b>`;
|
||||
|
|
@ -70,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 {
|
||||
switch (streak) {
|
||||
case 2:
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { localCharacterPredictor } from './helper/prediction/local-character-pre
|
|||
import { Tutorial } from './tutorial';
|
||||
import { Scoreboard } from './scoreboard';
|
||||
import { Minimap } from './minimap';
|
||||
import { ScreenShake } from './screen-shake';
|
||||
|
||||
export class Game extends CommandReceiver {
|
||||
public gameObjects = new GameObjectContainer(this);
|
||||
|
|
@ -80,6 +81,9 @@ export class Game extends CommandReceiver {
|
|||
this.socket?.close();
|
||||
serverTimeline.reset();
|
||||
localCharacterPredictor.reset();
|
||||
// Clear any leftover shake/zoom so a kill at the end of one match can't bleed
|
||||
// its camera impact into the next.
|
||||
ScreenShake.reset();
|
||||
this.gameObjects = new GameObjectContainer(this);
|
||||
this.overlay.innerHTML = '';
|
||||
this.keystoneArrow = undefined;
|
||||
|
|
@ -232,6 +236,11 @@ export class Game extends CommandReceiver {
|
|||
this.resolveStarted();
|
||||
deltaTime /= 1000;
|
||||
|
||||
// Decay the camera impact effects on raw wall-clock time, before any of the
|
||||
// end-game slow-motion scaling below. These only adjust the rendered view,
|
||||
// never the simulation, so they stay decoupled from prediction and netcode.
|
||||
ScreenShake.step(deltaTime);
|
||||
|
||||
// Stepped before the end-game time scaling on purpose: the slow motion is
|
||||
// already baked into the snapshots the server sends, so the playback
|
||||
// cursor itself must keep running on wall-clock time.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,14 @@ import { InputHistory } from './input-history';
|
|||
const stepMs = 1000 / 200; // match the server's 200 Hz fixed tick
|
||||
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
|
||||
// (a stall, or a backgrounded tab catching up) snap to the authoritative pose
|
||||
// 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.
|
||||
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).
|
||||
// The facing direction is NOT carried — it is re-derived from the pose each
|
||||
// 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
|
||||
// time the command should carry so the server can echo it back.
|
||||
public recordInput(direction: vec2): number {
|
||||
const timeMs = Math.round(performance.now());
|
||||
const timeMs = Math.round(nowMs());
|
||||
this.inputHistory.record(direction, timeMs);
|
||||
return timeMs;
|
||||
}
|
||||
|
|
@ -112,7 +126,10 @@ export class LocalCharacterPredictor {
|
|||
): void {
|
||||
// Inputs only advance the acknowledgement forward; the launch momentum and
|
||||
// leap boundary always adopt the latest authoritative values.
|
||||
if (this.lastAckClientTimeMs === undefined || clientTimeMs > this.lastAckClientTimeMs) {
|
||||
if (
|
||||
this.lastAckClientTimeMs === undefined ||
|
||||
clientTimeMs > this.lastAckClientTimeMs
|
||||
) {
|
||||
this.lastAckClientTimeMs = clientTimeMs;
|
||||
}
|
||||
vec2.set(this.authoritativeBodyVelocity, bodyVelocity[0], bodyVelocity[1]);
|
||||
|
|
@ -121,7 +138,7 @@ export class LocalCharacterPredictor {
|
|||
|
||||
public setAuthoritative(head: Circle, leftFoot: Circle, rightFoot: Circle): void {
|
||||
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
|
||||
|
|
@ -129,7 +146,7 @@ export class LocalCharacterPredictor {
|
|||
// it — a rejected leap (no strength/cooldown) self-corrects via the streamed
|
||||
// authoritative momentum.
|
||||
public recordLeap(): number {
|
||||
const timeMs = Math.round(performance.now());
|
||||
const timeMs = Math.round(nowMs());
|
||||
this.leapHistory.push(timeMs);
|
||||
const cutoff = timeMs - 1500;
|
||||
while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) {
|
||||
|
|
@ -142,6 +159,10 @@ export class LocalCharacterPredictor {
|
|||
this.currentStrength = strength;
|
||||
}
|
||||
|
||||
public setAlive(alive: boolean): void {
|
||||
this.alive = alive;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.inputHistory.reset();
|
||||
this.leapHistory = [];
|
||||
|
|
@ -154,6 +175,7 @@ export class LocalCharacterPredictor {
|
|||
this.carriedPlanetId = undefined;
|
||||
this.carriedSecondsSinceSurface = 1;
|
||||
this.hasRender = false;
|
||||
this.alive = true;
|
||||
}
|
||||
|
||||
public get canPredict(): boolean {
|
||||
|
|
@ -175,7 +197,7 @@ export class LocalCharacterPredictor {
|
|||
// caller should use (otherwise fall back to interpolation). `planets` is the
|
||||
// current collision world; `frameSeconds` is the render delta.
|
||||
public update(planets: Array<PredictablePlanet>, frameSeconds: number): boolean {
|
||||
if (!this.canPredict || this.isAnimatingInOrOut) {
|
||||
if (!this.alive || !this.canPredict || this.isAnimatingInOrOut) {
|
||||
// Resume from the authoritative pose with a snap rather than gliding from
|
||||
// a stale rendered one.
|
||||
this.hasRender = false;
|
||||
|
|
@ -209,7 +231,7 @@ export class LocalCharacterPredictor {
|
|||
|
||||
private simulate(): CharacterMovementState {
|
||||
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
|
||||
// (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
|
||||
|
|
|
|||
|
|
@ -16,8 +16,12 @@ export class JoinFormHandler {
|
|||
private resolvePlayerDecision!: (d: PlayerDecision) => void;
|
||||
private pollServersTimer: any;
|
||||
private keyUpListener = (e: KeyboardEvent) => {
|
||||
if (e.key === 'enter') {
|
||||
this.form.submit();
|
||||
// KeyboardEvent.key for Return is 'Enter' (capital E); the old lowercase
|
||||
// comparison never matched, so pressing Enter silently did nothing.
|
||||
// requestSubmit() (unlike submit()) fires the form's onsubmit handler and
|
||||
// runs HTML5 validation, so Enter behaves exactly like clicking Join.
|
||||
if (e.key === 'Enter' && !this.joinButton.disabled) {
|
||||
this.form.requestSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,19 +3,19 @@ import {
|
|||
CircleLight,
|
||||
FilteringOptions,
|
||||
hsl,
|
||||
NoisyPolygonFactory,
|
||||
Renderer,
|
||||
renderNoise,
|
||||
runAnimation,
|
||||
WrapOptions,
|
||||
} from 'sdf-2d';
|
||||
import { settings, rgb, PlanetBase, Random } from 'shared';
|
||||
import { PlanetShape } from './shapes/planet-shape';
|
||||
|
||||
const landingPageVertexCount = 9;
|
||||
const LandingPagePolygon = NoisyPolygonFactory(
|
||||
landingPageVertexCount,
|
||||
rgb(0.5, 0.4, 0.7),
|
||||
);
|
||||
// PlanetShape colours by mixing blue (0) -> red (1). The two backdrop planets
|
||||
// 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 redPlanet = 0.85;
|
||||
|
||||
export class LandingPageBackground {
|
||||
private isActive = true;
|
||||
|
|
@ -34,7 +34,7 @@ export class LandingPageBackground {
|
|||
canvas,
|
||||
[
|
||||
{
|
||||
...LandingPagePolygon.descriptor,
|
||||
...PlanetShape.descriptor,
|
||||
shaderCombinationSteps: [0, 1, 2],
|
||||
},
|
||||
{
|
||||
|
|
@ -74,34 +74,41 @@ export class LandingPageBackground {
|
|||
0.7 * renderer.canvasSize.y,
|
||||
);
|
||||
|
||||
const topPlanet = new LandingPagePolygon(
|
||||
const topPlanet = new PlanetShape(
|
||||
PlanetBase.createPlanetVertices(
|
||||
topPlanetPosition,
|
||||
Random.getRandomInRange(150, 400),
|
||||
Random.getRandomInRange(150, 400),
|
||||
Random.getRandomInRange(10, 20),
|
||||
landingPageVertexCount,
|
||||
),
|
||||
redPlanet,
|
||||
);
|
||||
|
||||
(topPlanet as any).randomOffset = 0.5 + time / 3500;
|
||||
// Fixed terrain phase (no longer animated -> no pulsing); the planet spins
|
||||
// instead, the same way in-game planets do: PlanetShape's rotation uniform
|
||||
// turns the whole body, terrain and outline together, in its own frame.
|
||||
// Speeds sit in the game's per-planet range (~0.05-0.12 rad/s) and
|
||||
// counter-rotate so the two planets don't drift in lockstep.
|
||||
topPlanet.randomOffset = Random.getRandom();
|
||||
topPlanet.rotation = (time / 1000) * 0.09;
|
||||
|
||||
const bottomPlanetPosition = vec2.fromValues(
|
||||
0.3 * renderer.canvasSize.x,
|
||||
0.3 * renderer.canvasSize.y,
|
||||
);
|
||||
|
||||
const bottomPlanet = new LandingPagePolygon(
|
||||
const bottomPlanet = new PlanetShape(
|
||||
PlanetBase.createPlanetVertices(
|
||||
bottomPlanetPosition,
|
||||
Random.getRandomInRange(150, 800),
|
||||
Random.getRandomInRange(150, 400),
|
||||
Random.getRandomInRange(10, 40),
|
||||
landingPageVertexCount,
|
||||
),
|
||||
bluePlanet,
|
||||
);
|
||||
|
||||
(bottomPlanet as any).randomOffset = time / 2500;
|
||||
bottomPlanet.randomOffset = Random.getRandom();
|
||||
bottomPlanet.rotation = (time / 1000) * -0.06;
|
||||
|
||||
const planetDistance = vec2.subtract(
|
||||
vec2.create(),
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from 'shared';
|
||||
import { BeforeDestroyCommand } from '../commands/types/before-destroy';
|
||||
import { StepCommand } from '../commands/types/step';
|
||||
import { FeedbackHud } from '../feedback-hud';
|
||||
import { Game } from '../game';
|
||||
import { serverTimeline } from '../helper/server-timeline';
|
||||
import { PredictablePlanet } from '../helper/prediction/client-character-world';
|
||||
|
|
@ -28,6 +29,7 @@ export class GameObjectContainer extends CommandReceiver {
|
|||
protected objects: Map<Id, GameObject> = new Map();
|
||||
public player!: CharacterView;
|
||||
public camera: Camera = new Camera(this.game);
|
||||
private wasLocalPlayerAlive = false;
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[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
|
||||
// prediction state so it snaps to the new body instead of gliding across.
|
||||
localCharacterPredictor.reset();
|
||||
// Respawned — clear the elimination overlay.
|
||||
FeedbackHud.hideElimination();
|
||||
this.wasLocalPlayerAlive = true;
|
||||
},
|
||||
|
||||
[CreateObjectsCommand.type]: (c: CreateObjectsCommand) =>
|
||||
|
|
@ -45,15 +50,32 @@ export class GameObjectContainer extends CommandReceiver {
|
|||
[StepCommand.type]: (c: StepCommand) => {
|
||||
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
|
||||
// one so it responds to input immediately. Remote objects keep
|
||||
// interpolating. A large correction (respawn / death) snaps inside the
|
||||
// predictor rather than rubber-banding.
|
||||
// one so it responds to input immediately. Suppressed while dead so the
|
||||
// corpse can't be walked around (the server ignores a dead player's
|
||||
// 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(
|
||||
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.leftFoot = localCharacterPredictor.leftFoot;
|
||||
this.player.rightFoot = localCharacterPredictor.rightFoot;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from 'shared';
|
||||
import { RenderCommand } from '../../commands/types/render';
|
||||
import { Game } from '../../game';
|
||||
import { ScreenShake } from '../../screen-shake';
|
||||
|
||||
export class Camera extends CommandReceiver {
|
||||
public center: vec2 = vec2.create();
|
||||
|
|
@ -46,7 +47,16 @@ export class Camera extends CommandReceiver {
|
|||
this.game.aspectRatioChanged(canvasAspectRatio);
|
||||
}
|
||||
|
||||
const viewArea = calculateViewArea(this.center, canvasAspectRatio);
|
||||
// Shake displaces only the rendered view centre and the zoom-punch shrinks
|
||||
// only the rendered view area — neither touches the followed position, so
|
||||
// impacts jolt the frame without nudging the camera off the player. Passing
|
||||
// the zoom as oversizeRatio scales the area about the (shaken) centre.
|
||||
const shakenCenter = vec2.fromValues(
|
||||
this.center[0] + ScreenShake.offsetX,
|
||||
this.center[1] + ScreenShake.offsetY,
|
||||
);
|
||||
const scale = ScreenShake.viewScale;
|
||||
const viewArea = calculateViewArea(shakenCenter, canvasAspectRatio, scale * scale);
|
||||
renderer.setViewArea(viewArea.topLeft, viewArea.size);
|
||||
|
||||
renderer.setRuntimeSettings({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { vec2, vec3 } from 'gl-matrix';
|
||||
import { CircleLight, Renderer } from 'sdf-2d';
|
||||
|
||||
import {
|
||||
|
|
@ -23,10 +23,18 @@ import { CharacterShape } from '../../shapes/character-shape';
|
|||
import { SoundHandler, Sounds } from '../../sound-handler';
|
||||
import { VibrationHandler } from '../../vibration-handler';
|
||||
import { FeedbackHud } from '../../feedback-hud';
|
||||
import { ScreenShake } from '../../screen-shake';
|
||||
|
||||
const muzzleFlashDecaySeconds = 0.12;
|
||||
const hitFlashDecaySeconds = 0.15;
|
||||
|
||||
// A white-hot pop of light thrown at the spot a character dies, seen by everyone
|
||||
// who can see the body. No radius knob on a CircleLight, so the burst is sold by
|
||||
// a bright (HDR) colour with a fast-decaying intensity envelope.
|
||||
const deathBurstDecaySeconds = 0.42;
|
||||
const deathBurstMaxIntensity = 1.7;
|
||||
const deathBurstColor = vec3.fromValues(2.5, 2.3, 2.1);
|
||||
|
||||
const killIcon =
|
||||
'<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
|
||||
'<path d="M6.2,2.44L18.1,14.34L20.22,12.22L21.63,13.63L19.16,16.1L22.34,19.28C22.73,19.67 22.73,20.3 22.34,20.69L21.63,21.4C21.24,21.79 20.61,21.79 20.22,21.4L17,18.23L14.56,20.7L13.15,19.29L15.27,17.17L3.37,5.27V2.44H6.2M15.89,10L20.63,5.26V2.44H17.8L13.06,7.18L15.89,10M10.94,15L8.11,12.13L5.9,14.34L3.78,12.22L2.37,13.63L4.84,16.1L1.66,19.28C1.27,19.67 1.27,20.3 1.66,20.69L2.37,21.4C2.76,21.79 3.39,21.79 3.78,21.4L7,18.23L9.42,20.7L10.83,19.29L8.71,17.17L10.94,15Z"/></svg>';
|
||||
|
|
@ -39,6 +47,8 @@ export class CharacterView extends CharacterBase {
|
|||
private muzzleFlash: CircleLight;
|
||||
private muzzleFlashIntensity = 0;
|
||||
private hitFlashIntensity = 0;
|
||||
private deathBurst: CircleLight;
|
||||
private deathBurstIntensity = 0;
|
||||
private strength = settings.playerMaxStrength;
|
||||
private strengthInterpolator = new LinearInterpolator(settings.playerMaxStrength);
|
||||
private nameElement: HTMLElement = document.createElement('div');
|
||||
|
|
@ -79,6 +89,7 @@ export class CharacterView extends CharacterBase {
|
|||
settings.paletteDim[settings.colorIndices[team]],
|
||||
0,
|
||||
);
|
||||
this.deathBurst = new CircleLight(vec2.clone(this.head!.center), deathBurstColor, 0);
|
||||
|
||||
this.leftFootInterpolator = new CircleInterpolator(this.leftFoot!);
|
||||
this.rightFootInterpolator = new CircleInterpolator(this.rightFoot!);
|
||||
|
|
@ -166,6 +177,9 @@ export class CharacterView extends CharacterBase {
|
|||
|
||||
if (this.isMainCharacter) {
|
||||
VibrationHandler.vibrate(Math.min(200, damage * 4));
|
||||
// Getting hit jolts the frame too, so taking fire has weight, not just
|
||||
// dealing it.
|
||||
ScreenShake.add(clamp01(0.12 + (0.5 * damage) / settings.playerMaxStrength));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -174,17 +188,23 @@ export class CharacterView extends CharacterBase {
|
|||
if (this.isMainCharacter) {
|
||||
VibrationHandler.vibrate(150);
|
||||
}
|
||||
// Visible to everyone who can see the body: a white-hot flash plus a
|
||||
// full-body whiteout, so a kill reads as a violent burst rather than the
|
||||
// character quietly blinking out.
|
||||
this.deathBurstIntensity = 1;
|
||||
this.hitFlashIntensity = 1;
|
||||
}
|
||||
|
||||
public onHitConfirmed(charge = 0) {
|
||||
if (!this.isMainCharacter) {
|
||||
return;
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
// Layer a meaty thud under the crisp confirmation tick; a charged hit lands
|
||||
// lower and harder than a panic tap.
|
||||
SoundHandler.play(Sounds.hit, mix(0.35, 0.7, charge), mix(1.3, 0.95, charge));
|
||||
SoundHandler.play(Sounds.click, mix(0.4, 0.75, charge), mix(1.7, 1.1, charge));
|
||||
ScreenShake.add(mix(0.22, 0.5, charge));
|
||||
VibrationHandler.vibrate(Math.round(mix(12, 35, charge)));
|
||||
FeedbackHud.hitMarker(charge);
|
||||
}
|
||||
|
||||
|
|
@ -192,8 +212,14 @@ export class CharacterView extends CharacterBase {
|
|||
if (!this.isMainCharacter) {
|
||||
return;
|
||||
}
|
||||
SoundHandler.play(Sounds.click, 1, mix(0.7, 0.5, charge));
|
||||
VibrationHandler.vibrate(mix(60, 110, charge));
|
||||
// A heavy low thud for the kill with a brighter confirmation over the top,
|
||||
// a hard frame jolt, a zoom-punch toward the action for weight, and a
|
||||
// double-thump rumble.
|
||||
SoundHandler.play(Sounds.hit, 1, mix(0.62, 0.5, charge));
|
||||
SoundHandler.play(Sounds.click, 0.9, mix(0.6, 0.45, charge));
|
||||
ScreenShake.add(mix(0.75, 1, charge));
|
||||
ScreenShake.addPunch(mix(0.7, 1, charge));
|
||||
VibrationHandler.vibrate([45, 35, Math.round(mix(80, 130, charge))]);
|
||||
FeedbackHud.killConfirmed(victimName, streak, charge);
|
||||
}
|
||||
|
||||
|
|
@ -230,6 +256,18 @@ export class CharacterView extends CharacterBase {
|
|||
this.hitFlashIntensity - deltaTimeInSeconds / hitFlashDecaySeconds,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.deathBurstIntensity > 0) {
|
||||
this.deathBurstIntensity = Math.max(
|
||||
0,
|
||||
this.deathBurstIntensity - deltaTimeInSeconds / deathBurstDecaySeconds,
|
||||
);
|
||||
this.deathBurst.center = this.bodyCenter;
|
||||
// Square the envelope so the flash blooms then drops off sharply rather
|
||||
// than fading out in a flat ramp.
|
||||
this.deathBurst.intensity =
|
||||
deathBurstMaxIntensity * this.deathBurstIntensity * this.deathBurstIntensity;
|
||||
}
|
||||
}
|
||||
|
||||
public onShoot(strength: number) {
|
||||
|
|
@ -273,6 +311,10 @@ export class CharacterView extends CharacterBase {
|
|||
if (this.muzzleFlashIntensity > 0) {
|
||||
renderer.addDrawable(this.muzzleFlash);
|
||||
}
|
||||
|
||||
if (this.deathBurstIntensity > 0) {
|
||||
renderer.addDrawable(this.deathBurst);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateGazeTarget(renderer: Renderer): vec2 {
|
||||
|
|
|
|||
84
frontend/src/scripts/screen-shake.ts
Normal file
84
frontend/src/scripts/screen-shake.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// Camera impact effects fed by combat hits, sampled by the camera at render
|
||||
// time. Kept as a process-wide singleton so any view can feed it without
|
||||
// threading a camera reference through the deserialized object graph. Both
|
||||
// effects touch only the *rendered* view (centre offset + view-area zoom), never
|
||||
// the followed position or the simulation clock — so they can't drift the camera
|
||||
// off the player, and they're completely decoupled from prediction/netcode.
|
||||
//
|
||||
// Two effects:
|
||||
// - shake: a trauma model (Eiserloh) — the felt shake is trauma SQUARED so
|
||||
// small taps stay subtle while a kill punches hard; it bleeds off linearly.
|
||||
// - punch: a brief zoom-in on a kill that snaps back fast, for "weight".
|
||||
//
|
||||
// Honours prefers-reduced-motion: callers can fire freely and it simply no-ops
|
||||
// for players who have asked the OS to minimise motion.
|
||||
export abstract class ScreenShake {
|
||||
private static trauma = 0;
|
||||
private static punch = 0;
|
||||
|
||||
// Peak translation, in world units, at full trauma. The visible world is
|
||||
// ~3800 units wide, so this tops out at a few percent of the screen.
|
||||
private static readonly maxTranslation = 150;
|
||||
// Fraction the view zooms in at full punch (smaller view area = zoomed in).
|
||||
private static readonly maxZoom = 0.07;
|
||||
private static readonly traumaDecayPerSecond = 1.7;
|
||||
// Snaps back quickly so the punch reads as a sharp bite, not a slow drift.
|
||||
private static readonly punchDecayPerSecond = 6;
|
||||
|
||||
private static offsetXValue = 0;
|
||||
private static offsetYValue = 0;
|
||||
|
||||
private static get reducedMotion(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
}
|
||||
|
||||
public static add(trauma: number): void {
|
||||
if (this.reducedMotion) {
|
||||
return;
|
||||
}
|
||||
this.trauma = Math.min(1, this.trauma + trauma);
|
||||
}
|
||||
|
||||
public static addPunch(amount: number): void {
|
||||
if (this.reducedMotion) {
|
||||
return;
|
||||
}
|
||||
this.punch = Math.min(1, this.punch + amount);
|
||||
}
|
||||
|
||||
public static step(deltaTimeInSeconds: number): void {
|
||||
this.trauma = Math.max(
|
||||
0,
|
||||
this.trauma - this.traumaDecayPerSecond * deltaTimeInSeconds,
|
||||
);
|
||||
this.punch = Math.max(0, this.punch - this.punchDecayPerSecond * deltaTimeInSeconds);
|
||||
const shake = this.trauma * this.trauma;
|
||||
this.offsetXValue = this.maxTranslation * shake * (Math.random() * 2 - 1);
|
||||
this.offsetYValue = this.maxTranslation * shake * (Math.random() * 2 - 1);
|
||||
}
|
||||
|
||||
public static get offsetX(): number {
|
||||
return this.offsetXValue;
|
||||
}
|
||||
|
||||
public static get offsetY(): number {
|
||||
return this.offsetYValue;
|
||||
}
|
||||
|
||||
// Multiplier for the view-area size: <1 zooms in. Squared so the punch bites
|
||||
// sharply near its peak rather than ramping linearly.
|
||||
public static get viewScale(): number {
|
||||
return 1 - this.maxZoom * this.punch * this.punch;
|
||||
}
|
||||
|
||||
public static reset(): void {
|
||||
this.trauma = 0;
|
||||
this.punch = 0;
|
||||
this.offsetXValue = 0;
|
||||
this.offsetYValue = 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -141,10 +141,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
|
|||
) {
|
||||
super(vertices);
|
||||
|
||||
this.cullCenter = vertices.reduce(
|
||||
(sum, v) => vec2.add(sum, sum, v),
|
||||
vec2.create(),
|
||||
);
|
||||
this.cullCenter = vertices.reduce((sum, v) => vec2.add(sum, sum, v), vec2.create());
|
||||
vec2.scale(this.cullCenter, this.cullCenter, 1 / vertices.length);
|
||||
|
||||
this.cullRadius = vertices.reduce(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { OptionsHandler } from './options-handler';
|
||||
|
||||
export abstract class VibrationHandler {
|
||||
public static vibrate(time: number): void {
|
||||
// Accepts either a single duration or an on/off pattern (e.g. a double-thump
|
||||
// [40, 30, 90] for a kill).
|
||||
public static vibrate(pattern: number | number[]): void {
|
||||
if (OptionsHandler.options.vibrationEnabled && this.isVibrationEnabled) {
|
||||
navigator.vibrate(time);
|
||||
navigator.vibrate(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,11 +12,15 @@
|
|||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.60.1"
|
||||
"typescript-eslint": "^8.60.1",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "cd shared && npm run build && cd ../frontend && npm run build && cd ../backend && npm run build",
|
||||
"lint": "eslint ./**/src/**/*.{js,ts} --fix && prettier --write ./**/src/**/*.{js,ts,json}",
|
||||
"lint:check": "eslint ./**/src/**/*.{js,ts} && prettier --check ./**/src/**/*.{js,ts,json}",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"init": "cd shared && npm install && cd ../frontend && npm install && cd ../backend && npm install",
|
||||
"dev": "concurrently --kill-others-on-fail \"cd shared && npm dev\" \"cd backend && npm dev\" \"cd frontend && npm dev\""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@
|
|||
"files": [
|
||||
"lib"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npx webpack --mode production",
|
||||
"dev": "npx webpack --mode development --watch",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
export class UpdatePropertyCommand extends Command {
|
||||
constructor(
|
||||
public readonly propertyKey: string,
|
||||
public readonly propertyKey: SyncPropertyKey,
|
||||
public readonly propertyValue: any,
|
||||
public readonly rateOfChange: any,
|
||||
) {
|
||||
|
|
@ -45,16 +59,40 @@ export class PropertyUpdatesForObject {
|
|||
export abstract class GameObject extends CommandReceiver {
|
||||
private remoteCalls: Array<RemoteCall> = [];
|
||||
|
||||
// The only methods a peer may invoke over the wire. processRemoteCalls
|
||||
// dispatches by a raw string taken straight off the network, so without this
|
||||
// gate a malformed or hostile packet could call ANY method on the object
|
||||
// (toArray, resetRemoteCalls, even prototype methods). Keep in sync with the
|
||||
// remoteCall() emitters on the *-physical classes.
|
||||
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();
|
||||
}
|
||||
|
||||
public processRemoteCalls(remoteCalls: Array<RemoteCall>) {
|
||||
remoteCalls.forEach((r) =>
|
||||
(this[r.functionName as keyof this] as unknown as (...args: Array<any>) => unknown)(
|
||||
...r.args,
|
||||
),
|
||||
);
|
||||
remoteCalls.forEach((r) => {
|
||||
if (!GameObject.allowedRemoteCalls.has(r.functionName)) {
|
||||
console.warn(`Dropped disallowed remote call: ${r.functionName}`);
|
||||
return;
|
||||
}
|
||||
const fn = this[r.functionName as keyof this];
|
||||
if (typeof fn === 'function') {
|
||||
(fn as (...args: Array<any>) => unknown).apply(this, r.args);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public getPropertyUpdates(): PropertyUpdatesForObject | void {}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Id } from '../../communication/id';
|
||||
import { Circle } from '../../helper/circle';
|
||||
import { serializable } from '../../serialization/serializable';
|
||||
import { toArrayFromFields } from '../../serialization/serialized-fields';
|
||||
import { GameObject } from '../game-object';
|
||||
|
||||
export enum CharacterTeam {
|
||||
|
|
@ -11,6 +12,18 @@ 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,
|
||||
|
|
@ -47,16 +60,6 @@ export class CharacterBase extends GameObject {
|
|||
}
|
||||
|
||||
public toArray(): Array<any> {
|
||||
return [
|
||||
this.id,
|
||||
this.name,
|
||||
this.killCount,
|
||||
this.deathCount,
|
||||
this.team,
|
||||
this.health,
|
||||
this.head,
|
||||
this.leftFoot,
|
||||
this.rightFoot,
|
||||
];
|
||||
return toArrayFromFields(this, CharacterBase.serializedFields);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
import { vec2, vec3 } from 'gl-matrix';
|
||||
import { Id, serializable } from '../../main';
|
||||
import { toArrayFromFields } from '../../serialization/serialized-fields';
|
||||
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,
|
||||
|
|
@ -19,7 +27,6 @@ export class LampBase extends GameObject {
|
|||
public setLight(color: vec3, lightness: number) {}
|
||||
|
||||
public toArray(): Array<any> {
|
||||
const { id, center, color, lightness } = this;
|
||||
return [id, center, color, lightness];
|
||||
return toArrayFromFields(this, LampBase.serializedFields);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,22 @@ import { vec2 } from 'gl-matrix';
|
|||
import { Random } from '../../helper/random';
|
||||
import { settings } from '../../settings';
|
||||
import { serializable } from '../../serialization/serializable';
|
||||
import { toArrayFromFields } from '../../serialization/serialized-fields';
|
||||
import { GameObject } from '../game-object';
|
||||
import { Id } from '../../communication/id';
|
||||
import { CharacterTeam } from './character-base';
|
||||
|
||||
@serializable
|
||||
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 radius: number;
|
||||
|
||||
|
|
@ -57,6 +67,6 @@ export class PlanetBase extends GameObject {
|
|||
}
|
||||
|
||||
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 { settings } from '../../settings';
|
||||
import { serializable } from '../../serialization/serializable';
|
||||
import { toArrayFromFields } from '../../serialization/serialized-fields';
|
||||
import { GameObject } from '../game-object';
|
||||
import { Id } from '../../communication/id';
|
||||
import { CharacterTeam } from './character-base';
|
||||
|
||||
@serializable
|
||||
export class ProjectileBase extends GameObject {
|
||||
private static readonly serializedFields = [
|
||||
'id',
|
||||
'center',
|
||||
'radius',
|
||||
'team',
|
||||
'strength',
|
||||
] as const;
|
||||
|
||||
constructor(
|
||||
id: Id,
|
||||
public center: vec2,
|
||||
|
|
@ -23,6 +32,6 @@ export class ProjectileBase extends GameObject {
|
|||
}
|
||||
|
||||
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 center = characterCenter(state);
|
||||
springMove(state, state.leftFoot, center, leftFootOffset, settings.postureFeetStiffness);
|
||||
springMove(
|
||||
state,
|
||||
state.leftFoot,
|
||||
center,
|
||||
leftFootOffset,
|
||||
settings.postureFeetStiffness,
|
||||
);
|
||||
springMove(
|
||||
state,
|
||||
state.rightFoot,
|
||||
|
|
@ -133,7 +139,12 @@ const carryWithRotatingPlanet = (
|
|||
const angle = -planet.angularVelocity * deltaTimeInSeconds;
|
||||
const center = planet.center;
|
||||
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(
|
||||
vec2.create(),
|
||||
state.rightFoot.center,
|
||||
|
|
@ -148,10 +159,7 @@ const carryWithRotatingPlanet = (
|
|||
// 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
|
||||
// not on a surface.
|
||||
export const applyLeapImpulse = (
|
||||
state: CharacterMovementState,
|
||||
moveDirection: vec2,
|
||||
) => {
|
||||
export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: vec2) => {
|
||||
const planet = state.currentPlanet;
|
||||
if (!planet) {
|
||||
return;
|
||||
|
|
@ -206,7 +214,9 @@ export const tickPlanetDetachment = (
|
|||
state: CharacterMovementState,
|
||||
deltaTimeInSeconds: number,
|
||||
) => {
|
||||
if ((state.secondsSinceOnSurface += deltaTimeInSeconds) > settings.planetDetachmentSeconds) {
|
||||
if (
|
||||
(state.secondsSinceOnSurface += deltaTimeInSeconds) > settings.planetDetachmentSeconds
|
||||
) {
|
||||
state.currentPlanet = undefined;
|
||||
}
|
||||
};
|
||||
|
|
@ -255,10 +265,7 @@ export const decayMomentum = (
|
|||
}
|
||||
};
|
||||
|
||||
const decayBodyMomentum = (
|
||||
state: CharacterMovementState,
|
||||
deltaTimeInSeconds: number,
|
||||
) => {
|
||||
const decayBodyMomentum = (state: CharacterMovementState, deltaTimeInSeconds: number) => {
|
||||
decayMomentum(state.bodyVelocity, !!state.currentPlanet, deltaTimeInSeconds);
|
||||
};
|
||||
|
||||
|
|
@ -293,7 +300,11 @@ export const stepCharacterMovement = (
|
|||
const center = characterCenter(state);
|
||||
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.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);
|
||||
});
|
||||
});
|
||||
159
test/serialization.test.mjs
Normal file
159
test/serialization.test.mjs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
// Round-trip tests for the custom wire serializer — the single most fragile,
|
||||
// highest-blast-radius mechanism in the codebase (every networked message goes
|
||||
// through it, and dispatch is keyed on class name). We exercise the BUILT shared
|
||||
// bundle (shared/lib/main.js) rather than the TS source, so the decorators are
|
||||
// already applied exactly as they ship and there is no transform/ESM ambiguity.
|
||||
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 {
|
||||
serialize,
|
||||
deserialize,
|
||||
Circle,
|
||||
ServerAnnouncement,
|
||||
MoveActionCommand,
|
||||
UpdatePropertyCommand,
|
||||
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
|
||||
headRadius,
|
||||
feetRadius,
|
||||
boundRadius,
|
||||
headOffset,
|
||||
leftFootOffset,
|
||||
rightFootOffset,
|
||||
} = shared;
|
||||
|
||||
describe('serialization round-trip (built shared lib)', () => {
|
||||
it('reconstructs a ServerAnnouncement as the right class with its payload', () => {
|
||||
const [out] = deserialize(serialize([new ServerAnnouncement('Hello <b>world</b>')]));
|
||||
expect(out).toBeInstanceOf(ServerAnnouncement);
|
||||
expect(out.text).toBe('Hello <b>world</b>');
|
||||
});
|
||||
|
||||
it('round-trips a Circle and rounds floats to 3 decimals', () => {
|
||||
const out = deserialize(serialize(new Circle([12.34567, -7.1], 5.43219)));
|
||||
expect(out).toBeInstanceOf(Circle);
|
||||
// serialize.ts rounds every float via toFixed(3)
|
||||
expect(out.center[0]).toBeCloseTo(12.346, 6);
|
||||
expect(out.center[1]).toBeCloseTo(-7.1, 6);
|
||||
expect(out.radius).toBeCloseTo(5.432, 6);
|
||||
});
|
||||
|
||||
it('keeps integer clientTimeMs exact (the predictor matches on it)', () => {
|
||||
const t = 1718000000123;
|
||||
const out = deserialize(serialize(new MoveActionCommand([1, 0], t)));
|
||||
expect(out).toBeInstanceOf(MoveActionCommand);
|
||||
expect(out.clientTimeMs).toBe(t);
|
||||
});
|
||||
|
||||
it('round-trips nested property-update commands', () => {
|
||||
const out = deserialize(
|
||||
serialize(
|
||||
new PropertyUpdatesForObject(42, [new UpdatePropertyCommand('strength', 80, 8)]),
|
||||
),
|
||||
);
|
||||
expect(out).toBeInstanceOf(PropertyUpdatesForObject);
|
||||
expect(out.id).toBe(42);
|
||||
expect(out.updates[0]).toBeInstanceOf(UpdatePropertyCommand);
|
||||
expect(out.updates[0].propertyKey).toBe('strength');
|
||||
expect(out.updates[0].propertyValue).toBe(80);
|
||||
});
|
||||
|
||||
it('preserves per-item class identity across a mixed batch', () => {
|
||||
// A clobbered name→constructor mapping (two classes sharing a name) would
|
||||
// surface here as an item deserializing to the wrong class.
|
||||
const batch = [
|
||||
new ServerAnnouncement('a'),
|
||||
new MoveActionCommand([0, 1], 5),
|
||||
new Circle([1, 2], 3),
|
||||
];
|
||||
const out = deserialize(serialize(batch));
|
||||
expect(out[0]).toBeInstanceOf(ServerAnnouncement);
|
||||
expect(out[1]).toBeInstanceOf(MoveActionCommand);
|
||||
expect(out[2]).toBeInstanceOf(Circle);
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('matches the known body layout', () => {
|
||||
expect(headRadius).toBe(50);
|
||||
expect(feetRadius).toBe(20);
|
||||
expect(boundRadius).toBe((headRadius + feetRadius * 2) * 2); // 180
|
||||
});
|
||||
|
||||
it('posture offsets are measured from the centre of mass (they sum to ~0)', () => {
|
||||
const sx = headOffset[0] + leftFootOffset[0] + rightFootOffset[0];
|
||||
const sy = headOffset[1] + leftFootOffset[1] + rightFootOffset[1];
|
||||
expect(sx).toBeCloseTo(0, 4);
|
||||
expect(sy).toBeCloseTo(0, 4);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue