Add tests

This commit is contained in:
Andras Schmelczer 2026-06-16 07:54:06 +01:00
parent d0265ad90e
commit b6db7e8dc7
18 changed files with 494 additions and 109 deletions

View file

@ -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: |

View file

@ -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 = [];
}
}

View file

@ -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,
);
@ -411,8 +383,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 {

View file

@ -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));
}

View file

@ -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();

View file

@ -594,8 +594,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 +603,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 +621,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 +685,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 {
@ -748,46 +780,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% {

View file

@ -47,6 +47,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>`;

View file

@ -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.

View file

@ -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();
}
};

View file

@ -3,19 +3,18 @@ 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),
);
// colorMixQ ends of PlanetShape's blue<->red gradient, so the two backdrop
// planets read as the two in-game teams.
const bluePlanet = 0;
const redPlanet = 1;
export class LandingPageBackground {
private isActive = true;
@ -34,7 +33,7 @@ export class LandingPageBackground {
canvas,
[
{
...LandingPagePolygon.descriptor,
...PlanetShape.descriptor,
shaderCombinationSteps: [0, 1, 2],
},
{
@ -74,34 +73,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(),

View file

@ -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({

View file

@ -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 {

View 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;
}
}

View file

@ -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);
}
}

View file

@ -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\""
}

View file

@ -7,6 +7,9 @@
"files": [
"lib"
],
"engines": {
"node": ">=20"
},
"scripts": {
"build": "npx webpack --mode production",
"dev": "npx webpack --mode development --watch",

View file

@ -45,16 +45,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 {}

View file

@ -0,0 +1,93 @@
// 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,
// 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('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);
});
});