diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts
index 5e8d20c..69d0191 100644
--- a/backend/src/game-server.ts
+++ b/backend/src/game-server.ts
@@ -271,7 +271,7 @@ export class GameServer implements GameEvents {
points >= this.options.scoreLimit * settings.matchPointScoreRatio
) {
this.matchPointAnnounced[team] = true;
- this.announce(`Match point — team ${team}!`);
+ this.announce(`Match point: team ${team}!`);
}
}
diff --git a/backend/src/objects/planet-physical.ts b/backend/src/objects/planet-physical.ts
index 4c77ba4..7579dcf 100644
--- a/backend/src/objects/planet-physical.ts
+++ b/backend/src/objects/planet-physical.ts
@@ -140,12 +140,6 @@ export class PlanetPhysical extends PlanetBase implements Physical {
Math.round(settings.captureFlipPointReward * this.sizePointMultiplier),
);
this.awardToOwner(settings.captureFlipPointReward);
-
- if (this.isKeystone) {
- this.game.announce(
- `Team ${currentTeam} captured the Heart`,
- );
- }
}
const control = Math.abs(this.ownership - 0.5) / 0.5;
diff --git a/frontend/src/main.scss b/frontend/src/main.scss
index b9c6a92..3e65033 100644
--- a/frontend/src/main.scss
+++ b/frontend/src/main.scss
@@ -208,7 +208,9 @@ body {
mask-image: url('../static/mask.svg');
&.contested {
- animation: contested-pulse 0.7s ease-in-out infinite;
+ box-shadow:
+ inset 0 0 0 2px rgba(255, 255, 255, 0.7),
+ inset 0 0 3px 0px rgba(0, 0, 0, 0.2);
}
&.keystone {
@@ -320,6 +322,15 @@ body {
display: inline-block;
}
+ &[hidden] {
+ display: none;
+ }
+
+ &[aria-disabled='true'] {
+ opacity: 0.3;
+ cursor: default;
+ }
+
&.fire {
right: $medium-padding;
@@ -757,18 +768,6 @@ body {
}
}
-@keyframes contested-pulse {
-
- 0%,
- 100% {
- opacity: 1;
- }
-
- 50% {
- opacity: 0.35;
- }
-}
-
.charge-ring {
position: fixed;
@include square(56px);
diff --git a/frontend/src/scripts/commands/touch-listener.ts b/frontend/src/scripts/commands/touch-listener.ts
index 3af1895..e92dd9e 100644
--- a/frontend/src/scripts/commands/touch-listener.ts
+++ b/frontend/src/scripts/commands/touch-listener.ts
@@ -4,6 +4,7 @@ import type { Command } from 'shared';
import type { Game } from '../game';
import { ChargeIndicator } from '../charge-indicator';
import { centeredTransform } from '../helper/centered-transform';
+import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
import { InputGenerator } from './input-generator';
const deadZone = 8;
@@ -196,15 +197,23 @@ export class TouchListener extends InputGenerator {
event.stopPropagation();
}
+ private get isAlive(): boolean {
+ return (this.game.gameObjects.localPlayer?.health ?? 0) > 0;
+ }
+
private leapButtonListener = (event: TouchEvent) => {
this.swallowTouch(event);
+ if (!this.isAlive || !localCharacterPredictor.canLeap) {
+ return;
+ }
this.sendLeap();
+ this.leapButton.hidden = true;
};
private fireButtonDownListener = (event: TouchEvent) => {
this.swallowTouch(event);
const touch = event.changedTouches[0];
- if (this.fireTouchId !== null || !touch) {
+ if (!this.isAlive || this.fireTouchId !== null || !touch) {
return;
}
this.fireTouchId = touch.identifier;
@@ -255,7 +264,7 @@ export class TouchListener extends InputGenerator {
this.cancelFire();
const character = this.game.gameObjects.localPlayer;
- if (!character) {
+ if (!character || character.health <= 0) {
return;
}
@@ -287,6 +296,11 @@ export class TouchListener extends InputGenerator {
}
const character = this.game.gameObjects.localPlayer;
+ this.fireButton.setAttribute('aria-disabled', String(!this.isAlive));
+ this.leapButton.hidden = !this.isAlive || !localCharacterPredictor.canLeap;
+ if (!this.isAlive && this.fireTouchId !== null) {
+ this.cancelFire();
+ }
if (character) {
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${
character.strengthFraction * 360
diff --git a/frontend/src/scripts/feedback-hud.ts b/frontend/src/scripts/feedback-hud.ts
index 4d59359..63d1058 100644
--- a/frontend/src/scripts/feedback-hud.ts
+++ b/frontend/src/scripts/feedback-hud.ts
@@ -3,7 +3,6 @@ import { getDisplayPosition } from './helper/pointer';
let root: HTMLElement | undefined;
let killfeed: HTMLElement | undefined;
-let elimination: HTMLElement | undefined;
function ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } {
if (!root || !killfeed) {
@@ -74,27 +73,7 @@ function killConfirmed(victimName?: string, streak = 1, charge = 0) {
}
}
-function showElimination(): void {
- if (elimination) {
- return;
- }
- const { root: r } = ensureRoot();
- const el = document.createElement('div');
- el.className = 'elimination';
- el.innerHTML =
- '
Eliminated
' +
- 'Respawning…
';
- r.appendChild(el);
- elimination = el;
-}
-
-function hideElimination(): void {
- elimination?.remove();
- elimination = undefined;
-}
-
function resetFeedbackHud(): void {
- hideElimination();
root?.remove();
root = undefined;
killfeed = undefined;
@@ -122,7 +101,5 @@ function escapeHtml(text: string): string {
export const FeedbackHud = {
hitMarker,
killConfirmed,
- showElimination,
- hideElimination,
reset: resetFeedbackHud,
};
diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts
index e76fd80..9c0d5aa 100644
--- a/frontend/src/scripts/game.ts
+++ b/frontend/src/scripts/game.ts
@@ -55,7 +55,7 @@ export class Game extends CommandReceiver {
private socket!: Socket;
private socketReceiver!: CommandSocket;
- private tutorial!: Tutorial;
+ private tutorial?: Tutorial;
private resolveStarted!: () => void;
private isBetweenGames = false;
private isActive = true;
@@ -92,7 +92,7 @@ export class Game extends CommandReceiver {
),
[GameEndCommand.name]: () => (localCharacterPredictor.enabled = false),
[UpdateMinimap.name]: (c: UpdateMinimap) => (this.lastMinimap = c),
- [GameStartCommand.name]: () => this.initialize(),
+ [GameStartCommand.name]: () => this.initialize(true),
};
constructor(
@@ -106,7 +106,7 @@ export class Game extends CommandReceiver {
const onInput = (c: Command) => {
this.socketReceiver.queue(c);
- this.tutorial.handleCommand(c);
+ this.tutorial?.handleCommand(c);
};
this.keyboardListener = new KeyboardListener(onInput);
this.mouseListener = new MouseListener(this.canvas, this, onInput);
@@ -117,7 +117,7 @@ export class Game extends CommandReceiver {
this.gameObjects.handleCommand(c);
}
- private initialize() {
+ private initialize(isNewGame = false) {
this.isBetweenGames = true;
this.socket?.close();
@@ -131,14 +131,15 @@ export class Game extends CommandReceiver {
this.keystoneArrow = undefined;
this.connectionBanner = undefined;
this.lastMinimap = undefined;
- this.lastAnnouncementText = '';
+ this.lastAnnouncementText = isNewGame ? 'New game!' : '';
+ this.timeSinceLastAnnouncement = 0;
this.announcementText.innerText = '';
this.overlay.append(
this.scoreboard.element,
this.minimap.element,
this.announcementText,
);
- this.tutorial = new Tutorial(this.overlay);
+ this.tutorial = isNewGame ? undefined : new Tutorial(this.overlay);
this.socket = io(this.playerDecision.server, {
reconnectionDelayMax: 10000,
@@ -281,9 +282,9 @@ export class Game extends CommandReceiver {
public static rejectionText(reason: JoinRejectionReason): string {
switch (reason) {
case JoinRejectionReason.ServerFull:
- return 'That server is full — pick another';
+ return 'That server is full. Pick another';
case JoinRejectionReason.RoundEnding:
- return 'That round is just finishing — try again in a moment';
+ return 'That round is just finishing. Try again in a moment';
case JoinRejectionReason.AlreadyJoined:
return 'Already joined on this connection';
default:
@@ -354,7 +355,7 @@ export class Game extends CommandReceiver {
this.gameObjects.step(deltaTime);
this.gameObjects.render(renderer, this.overlay, shouldChangeLayout);
this.touchListener.update();
- this.tutorial.step(this.gameObjects);
+ this.tutorial?.step(this.gameObjects);
this.socketReceiver.sendQueuedCommands();
return this.isActive;
diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts
index ccf248b..1e4acd6 100644
--- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts
+++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts
@@ -28,6 +28,11 @@ const maxReplayMs = 400;
const correctionSeconds = 0.08;
const snapDistance = 1500;
+interface PredictedMovementState extends CharacterMovementState {
+ availableStrength: number;
+ leapCooldownRemaining: number;
+}
+
interface ReplaySnapshot {
pose: { head: Circle; leftFoot: Circle; rightFoot: Circle };
movement: CharacterMovementSnapshot;
@@ -35,7 +40,7 @@ interface ReplaySnapshot {
leapAckMs: number;
strength: number;
planets: Array;
- horizonState?: CharacterMovementState;
+ horizonState?: PredictedMovementState;
}
const upwards = vec2.fromValues(0, 1);
@@ -59,6 +64,7 @@ export class LocalCharacterPredictor {
private leapHistory: Array = [];
private lastLeapAckMs = -Infinity;
private currentStrength = settings.playerMaxStrength;
+ private predicted?: PredictedMovementState;
private alive = true;
private previousSnapshot?: ReplaySnapshot;
@@ -79,6 +85,17 @@ export class LocalCharacterPredictor {
return this.renderRightFoot;
}
+ public get canLeap(): boolean {
+ return (
+ this.enabled &&
+ this.alive &&
+ !this.isAnimatingInOrOut &&
+ !!this.predicted?.currentPlanet &&
+ this.predicted.leapCooldownRemaining <= 0 &&
+ this.predicted.availableStrength >= settings.leapStrengthCost
+ );
+ }
+
public recordInput(direction: vec2): number {
const timeMs = clientTimeMs();
this.inputHistory.record(direction, timeMs);
@@ -103,6 +120,7 @@ export class LocalCharacterPredictor {
public recordLeap(): number {
const timeMs = clientTimeMs();
this.leapHistory.push(timeMs);
+ this.predicted = undefined;
const cutoff = timeMs - 1500;
while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) {
this.leapHistory.shift();
@@ -126,6 +144,7 @@ export class LocalCharacterPredictor {
this.replayAnchorMs = undefined;
this.movement = undefined;
this.currentStrength = settings.playerMaxStrength;
+ this.predicted = undefined;
this.alive = true;
this.previousSnapshot = undefined;
this.lastUpdateMs = undefined;
@@ -149,6 +168,7 @@ export class LocalCharacterPredictor {
public update(planets: Array): boolean {
if (!this.enabled || !this.alive || !this.canPredict || this.isAnimatingInOrOut) {
+ this.predicted = undefined;
this.previousSnapshot = undefined;
this.lastUpdateMs = undefined;
vec2.zero(this.correction);
@@ -182,6 +202,7 @@ export class LocalCharacterPredictor {
),
};
const predicted = this.simulate(snapshot);
+ this.predicted = predicted;
if (this.previousSnapshot && this.previousSnapshot.pose !== snapshot.pose) {
// Compare both snapshots at the same instant: smooth only the correction,
@@ -210,7 +231,7 @@ export class LocalCharacterPredictor {
return true;
}
- private simulate(snapshot: ReplaySnapshot): CharacterMovementState {
+ private simulate(snapshot: ReplaySnapshot): PredictedMovementState {
if (snapshot.horizonState) {
return snapshot.horizonState;
}
@@ -222,7 +243,7 @@ export class LocalCharacterPredictor {
const steps = Math.floor(windowMs / stepMs);
const partialStep = (windowMs - steps * stepMs) / stepMs;
- const state: CharacterMovementState = {
+ const state: PredictedMovementState = {
head: makeBody(auth.head.center, auth.head.radius, upwards),
leftFoot: makeBody(
auth.leftFoot.center,
@@ -238,11 +259,10 @@ export class LocalCharacterPredictor {
currentPlanet: this.world.surfaceById(movement.groundPlanetId),
secondsSinceOnSurface: movement.secondsSinceOnSurface,
bodyVelocity: vec2.fromValues(movement.bodyVelocity[0], movement.bodyVelocity[1]),
+ availableStrength: snapshot.strength,
+ leapCooldownRemaining: movement.leapCooldownRemaining ?? 0,
};
- let availableStrength = snapshot.strength;
- let cooldown = movement.leapCooldownRemaining ?? 0;
-
let t = startMs;
for (let i = 0; i < steps + (partialStep > 0 ? 1 : 0); i++) {
const fraction = i < steps ? 1 : partialStep;
@@ -258,12 +278,12 @@ export class LocalCharacterPredictor {
leapMs < t + stepMs * fraction &&
leapMs > snapshot.leapAckMs &&
state.currentPlanet &&
- cooldown <= 0 &&
- availableStrength >= settings.leapStrengthCost
+ state.leapCooldownRemaining <= 0 &&
+ state.availableStrength >= settings.leapStrengthCost
) {
applyLeapImpulse(state, this.inputHistory.directionAt(leapMs));
- availableStrength -= settings.leapStrengthCost;
- cooldown = settings.leapCooldownSeconds;
+ state.availableStrength -= settings.leapStrengthCost;
+ state.leapCooldownRemaining = settings.leapCooldownSeconds;
}
}
this.world.advance(stepSeconds);
@@ -274,10 +294,14 @@ export class LocalCharacterPredictor {
this.inputHistory.directionAt(t),
stepSeconds,
);
- cooldown = Math.max(0, cooldown - stepSeconds);
- availableStrength = Math.min(
+ state.leapCooldownRemaining = Math.max(
+ 0,
+ state.leapCooldownRemaining - stepSeconds,
+ );
+ state.availableStrength = Math.min(
settings.playerMaxStrength,
- availableStrength + settings.playerStrengthRegenerationPerSeconds * stepSeconds,
+ state.availableStrength +
+ settings.playerStrengthRegenerationPerSeconds * stepSeconds,
);
if (from) {
[state.head, state.leftFoot, state.rightFoot].forEach((body, index) =>
diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts
index a469ff1..c6d1c17 100644
--- a/frontend/src/scripts/objects/game-object-container.ts
+++ b/frontend/src/scripts/objects/game-object-container.ts
@@ -13,7 +13,6 @@ import {
RemoteCallsForObjects,
UpdatePropertyCommand,
} from 'shared';
-import { FeedbackHud } from '../feedback-hud';
import type { Game } from '../game';
import { serverTimeline } from '../helper/server-timeline';
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
@@ -50,7 +49,6 @@ export class GameObjectContainer extends CommandReceiver {
this.addObject(this.player);
localCharacterPredictor.reset();
this.game.resendMovement();
- FeedbackHud.hideElimination();
this.wasLocalPlayerAlive = true;
},
@@ -133,9 +131,6 @@ export class GameObjectContainer extends CommandReceiver {
const player = this.localPlayer;
const alive = !!player && player.health > 0;
- if (this.wasLocalPlayerAlive && !alive) {
- FeedbackHud.showElimination();
- }
this.wasLocalPlayerAlive = alive;
if (player) {
diff --git a/frontend/src/scripts/sound-handler.ts b/frontend/src/scripts/sound-handler.ts
index cdab0d6..fb47b49 100644
--- a/frontend/src/scripts/sound-handler.ts
+++ b/frontend/src/scripts/sound-handler.ts
@@ -8,37 +8,51 @@ import { options } from './options-handler';
export const Sounds = { hit: 'hit', shoot: 'shoot', click: 'click' } as const;
export type Sound = (typeof Sounds)[keyof typeof Sounds];
-let sounds: Record;
+const sounds: Partial> = {};
+const maxVoices = 16;
+const voices = new Set<{ source: AudioBufferSourceNode; gain: GainNode }>();
+let context: AudioContext | undefined;
+let initialization: Promise | undefined;
let isAmbientPlaying = false;
const ambient = new Audio(ambientAudio);
-let initialized = false;
+let ambientInitialized = false;
-async function initializeSound(src: string): Promise {
- const snd = new Audio(src);
- snd.muted = true;
- await snd.play().catch(() => undefined);
- snd.pause();
- snd.muted = false;
- snd.currentTime = 0;
- return snd;
+async function initializeEffects() {
+ try {
+ context = new AudioContext({ latencyHint: 'interactive' });
+ } catch {
+ return;
+ }
+ const audioContext = context;
+ // Unlock during the initiating gesture, before any network or decode work.
+ void audioContext.resume().catch(() => undefined);
+ await Promise.all(
+ Object.entries({ hit: hitSound, shoot: shootSound, click: clickSound }).map(
+ async ([name, src]) => {
+ try {
+ const response = await fetch(src);
+ if (response.ok) {
+ sounds[name as Sound] = await audioContext.decodeAudioData(
+ await response.arrayBuffer(),
+ );
+ }
+ } catch {
+ // Missing audio must not interrupt gameplay or prevent other effects loading.
+ }
+ },
+ ),
+ );
}
-async function initialize(
- onPlayKeypress: () => unknown = () => null,
- onPauseKeypress: () => unknown = () => null,
+async function initializeAmbient(
+ onPlayKeypress: () => unknown,
+ onPauseKeypress: () => unknown,
) {
ambient.muted = true;
ambient.volume = 0.5;
ambient.loop = true;
- // Unlock every audio element during the initiating click, before awaiting any one.
- const ambientReady = ambient.play().catch(() => undefined);
- const [hit, shoot, click] = await Promise.all(
- [hitSound, shootSound, clickSound].map(initializeSound),
- );
- sounds = { hit, shoot, click };
-
- await ambientReady;
- initialized = true;
+ await ambient.play().catch(() => undefined);
+ ambientInitialized = true;
ambient.onpause = onPauseKeypress;
ambient.onplay = onPlayKeypress;
@@ -49,32 +63,65 @@ async function initialize(
}
}
+function initialize(
+ onPlayKeypress: () => unknown = () => null,
+ onPauseKeypress: () => unknown = () => null,
+): Promise {
+ if (!initialization) {
+ initialization = initializeEffects();
+ // Streaming music can take much longer to load than the short effects.
+ void initializeAmbient(onPlayKeypress, onPauseKeypress);
+ }
+ return initialization;
+}
+
function play(snd: Sound, volume = 1, playbackRate = 1) {
- if (!initialized || !options.soundsEnabled) {
+ const buffer = sounds[snd];
+ if (!context || !buffer || !options.soundsEnabled) {
+ return;
+ }
+ if (context.state !== 'running') {
+ void context.resume().catch(() => undefined);
return;
}
- const pooled = sounds[snd];
- const isBusy = !pooled.paused && !pooled.ended;
- const audio = isBusy ? (pooled.cloneNode(true) as HTMLAudioElement) : pooled;
- if (!isBusy) {
- audio.currentTime = 0;
+ // Delayed network packets can deliver many effects together. Keep their audio work bounded.
+ if (voices.size >= maxVoices) {
+ const oldest = voices.values().next().value!;
+ oldest.source.stop();
+ oldest.source.disconnect();
+ oldest.gain.disconnect();
+ voices.delete(oldest);
}
- audio.volume = clamp01(volume);
- audio.playbackRate = playbackRate;
- void audio.play().catch(() => undefined);
+
+ const source = context.createBufferSource();
+ const gain = context.createGain();
+ source.buffer = buffer;
+ source.playbackRate.value = playbackRate;
+ gain.gain.value = clamp01(volume);
+ source.connect(gain);
+ gain.connect(context.destination);
+ const voice = { source, gain };
+ voices.add(voice);
+ source.onended = () => {
+ if (voices.delete(voice)) {
+ source.disconnect();
+ gain.disconnect();
+ }
+ };
+ source.start();
}
function playAmbient() {
isAmbientPlaying = true;
- if (initialized) {
+ if (ambientInitialized) {
void ambient.play().catch(() => undefined);
}
}
function stopAmbient() {
isAmbientPlaying = false;
- if (initialized) {
+ if (ambientInitialized) {
ambient.pause();
}
}
diff --git a/frontend/src/scripts/tutorial.ts b/frontend/src/scripts/tutorial.ts
index e227c71..e495800 100644
--- a/frontend/src/scripts/tutorial.ts
+++ b/frontend/src/scripts/tutorial.ts
@@ -13,10 +13,18 @@ import { PlanetView } from './objects/types/planet-view';
type StageTrigger = 'move' | 'shoot' | 'leap' | 'capture';
-const stages: ReadonlyArray<{ hint: string; clearsOn: StageTrigger }> = [
- { hint: 'WASD / drag to walk', clearsOn: 'move' },
- { hint: 'Click / tap to shoot', clearsOn: 'shoot' },
- { hint: 'Space / leap button to launch off a planet', clearsOn: 'leap' },
+const stages: ReadonlyArray<{
+ hint: string;
+ touchHint?: string;
+ clearsOn: StageTrigger;
+}> = [
+ { hint: 'WASD to walk', touchHint: 'Drag to walk', clearsOn: 'move' },
+ { hint: 'Click to shoot', touchHint: 'Tap to shoot', clearsOn: 'shoot' },
+ {
+ hint: 'Space to launch off a planet',
+ touchHint: 'Tap the leap button to launch off a planet',
+ clearsOn: 'leap',
+ },
{ hint: 'Stand on a planet to capture it', clearsOn: 'capture' },
];
@@ -54,7 +62,7 @@ export class Tutorial extends CommandReceiver {
constructor(overlay: HTMLElement) {
super();
this.element.className = 'tutorial-hint';
- this.element.innerText = stages[0].hint;
+ this.setStage(0);
overlay.appendChild(this.element);
}
@@ -107,6 +115,14 @@ export class Tutorial extends CommandReceiver {
private setStage(stage: number) {
this.stage = stage;
- this.element.innerText = stages[stage]?.hint ?? '';
+ // Keep the hints consistent with the touch buttons' visibility in main.scss.
+ const useTouchControls = window.matchMedia(
+ '(hover: none) and (pointer: coarse)',
+ ).matches;
+ const currentStage = stages[stage];
+ this.element.innerText =
+ (useTouchControls ? currentStage?.touchHint : undefined) ??
+ currentStage?.hint ??
+ '';
}
}
diff --git a/test/frontend-regressions.test.ts b/test/frontend-regressions.test.ts
index 5379f5b..aaf1bce 100644
--- a/test/frontend-regressions.test.ts
+++ b/test/frontend-regressions.test.ts
@@ -6,6 +6,8 @@ import { TouchListener } from '../frontend/src/scripts/commands/touch-listener';
import { JoinFormHandler } from '../frontend/src/scripts/join-form-handler';
import { initializeOptions, options } from '../frontend/src/scripts/options-handler';
import { loadStoredValue, saveStoredValue } from '../frontend/src/scripts/helper/storage';
+import { ChargeIndicator } from '../frontend/src/scripts/charge-indicator';
+import { localCharacterPredictor } from '../frontend/src/scripts/helper/prediction/local-character-predictor';
const { connect } = vi.hoisted(() => ({ connect: vi.fn() }));
vi.mock('../frontend/node_modules/socket.io-client/build/esm-debug/index.js', () => ({
@@ -30,8 +32,16 @@ class Element extends EventTarget {
public className = '';
public checked = false;
public disabled = false;
+ public hidden = false;
+ private attributes = new Map();
public onsubmit: ((event: Event) => void) | null = null;
+ public setAttribute(name: string, value: string) {
+ this.attributes.set(name, value);
+ }
+ public getAttribute(name: string) {
+ return this.attributes.get(name) ?? null;
+ }
public appendChild(child: Element) {
child.remove();
this.children.push(child);
@@ -81,6 +91,7 @@ let sockets: Socket[];
beforeEach(() => {
vi.useFakeTimers();
+ localCharacterPredictor.reset();
windowTarget = new EventTarget();
sockets = [];
vi.stubGlobal('window', windowTarget);
@@ -103,6 +114,7 @@ afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks();
+ vi.restoreAllMocks();
});
const chooser = () => {
@@ -194,10 +206,16 @@ const touchControls = () => {
const canvas = new Element();
const overlay = new Element();
const send = vi.fn();
+ const character = {
+ health: 100,
+ bodyCenter: [0, 0],
+ facingDirection: [1, 0],
+ strengthFraction: 1,
+ };
const game = {
displayToWorldCoordinates: (position: number[]) => position,
gameObjects: {
- localPlayer: { bodyCenter: [0, 0], facingDirection: [1, 0], strengthFraction: 1 },
+ localPlayer: character as typeof character | undefined,
},
};
const listener = new TouchListener(
@@ -207,9 +225,86 @@ const touchControls = () => {
send,
);
listener.update();
- return { canvas, overlay, send, listener, fire: overlay.children[0] };
+ return {
+ canvas,
+ overlay,
+ send,
+ listener,
+ game,
+ character,
+ fire: overlay.children[0],
+ leap: overlay.children[1],
+ };
};
+describe('touch action availability', () => {
+ it('cancels a held shot on death and requires a fresh press after respawn', () => {
+ const { fire, send, listener, game, character } = touchControls();
+ touchEvent(fire, 'touchstart', [touch(1)]);
+ touchEvent(fire, 'touchmove', [touch(1, 100, 50)]);
+ expect(fire.children[1].style.opacity).toBe('1');
+
+ character.health = 0;
+ listener.update();
+ expect(fire.getAttribute('aria-disabled')).toBe('true');
+ expect(fire.children[1].style.opacity).toBe('0');
+ expect(ChargeIndicator.end).toHaveBeenCalled();
+ vi.mocked(ChargeIndicator.begin).mockClear();
+ touchEvent(fire, 'touchstart', [touch(2)]);
+ touchEvent(fire, 'touchend', [touch(2)], []);
+ expect(ChargeIndicator.begin).not.toHaveBeenCalled();
+ expect(send).not.toHaveBeenCalled();
+
+ game.gameObjects.localPlayer = undefined;
+ listener.update();
+ touchEvent(fire, 'touchstart', [touch(3)]);
+ touchEvent(fire, 'touchend', [touch(3)], []);
+ expect(send).not.toHaveBeenCalled();
+
+ game.gameObjects.localPlayer = { ...character, health: 100 };
+ listener.update();
+ expect(fire.getAttribute('aria-disabled')).toBe('false');
+ touchEvent(fire, 'touchend', [touch(1)], []);
+ expect(send).not.toHaveBeenCalled();
+ touchEvent(fire, 'touchstart', [touch(4)]);
+ touchEvent(fire, 'touchend', [touch(4)], []);
+ expect(send).toHaveBeenCalledTimes(1);
+ listener.destroy();
+ });
+
+ it('rejects a shot released after death before the controls update', () => {
+ const { fire, send, listener, character } = touchControls();
+ touchEvent(fire, 'touchstart', [touch(1)]);
+ character.health = 0;
+ touchEvent(fire, 'touchend', [touch(1)], []);
+ expect(send).not.toHaveBeenCalled();
+ listener.destroy();
+ });
+
+ it('only shows and accepts jumps while the living player can leap', () => {
+ const canLeap = vi.spyOn(localCharacterPredictor, 'canLeap', 'get');
+ canLeap.mockReturnValue(false);
+ const { leap, send, listener, character } = touchControls();
+ expect(leap.hidden).toBe(true);
+ touchEvent(leap, 'touchstart', [touch(1)]);
+ expect(send).not.toHaveBeenCalled();
+
+ canLeap.mockReturnValue(true);
+ listener.update();
+ expect(leap.hidden).toBe(false);
+ touchEvent(leap, 'touchstart', [touch(2)]);
+ expect(send.mock.lastCall?.[0].constructor.name).toBe('LeapActionCommand');
+ expect(leap.hidden).toBe(true);
+
+ character.health = 0;
+ listener.update();
+ expect(leap.hidden).toBe(true);
+ touchEvent(leap, 'touchstart', [touch(3)]);
+ expect(send).toHaveBeenCalledTimes(1);
+ listener.destroy();
+ });
+});
+
describe('touch ownership', () => {
it('keeps the first canvas gesture when a second finger lands before dragging', () => {
const { canvas, send, listener } = touchControls();
diff --git a/test/prediction-reconciliation.test.ts b/test/prediction-reconciliation.test.ts
index eed53f0..a5c5488 100644
--- a/test/prediction-reconciliation.test.ts
+++ b/test/prediction-reconciliation.test.ts
@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest';
+import { settings } from '../shared/src/settings';
import {
LocalCharacterPredictor,
setFrameTimeMs,
@@ -313,3 +314,84 @@ describe('leap prediction', () => {
expect(leapHeight(0.2, true)).toEqual(leapHeight(0.2, false));
});
});
+
+describe('leap availability', () => {
+ const prepare = (
+ cooldown = 0,
+ strength = settings.leapStrengthCost,
+ groundPlanetId: number | null = ground.id,
+ ) => {
+ const predictor = new LocalCharacterPredictor();
+ setFrameTimeMs(1000);
+ const pose = poseAt(0, 900);
+ predictor.acknowledge(
+ 1000,
+ {
+ ...movementState(),
+ groundPlanetId,
+ secondsSinceOnSurface: 0,
+ leapCooldownRemaining: cooldown,
+ } as never,
+ -Infinity,
+ );
+ predictor.setStrength(strength);
+ predictor.setAuthoritative(
+ pose.head as never,
+ pose.leftFoot as never,
+ pose.rightFoot as never,
+ );
+ predictor.update([ground] as never);
+ return { predictor, pose };
+ };
+
+ it('requires a planet, sufficient strength, and an expired cooldown', () => {
+ expect(new LocalCharacterPredictor().canLeap).toBe(false);
+ expect(prepare().predictor.canLeap).toBe(true);
+ expect(prepare(0.2).predictor.canLeap).toBe(false);
+ expect(prepare(0, settings.leapStrengthCost - 1).predictor.canLeap).toBe(false);
+ expect(prepare(0, settings.playerMaxStrength, null).predictor.canLeap).toBe(false);
+ });
+
+ it('becomes available as the predicted cooldown and strength recover', () => {
+ const { predictor } = prepare(0.02, settings.leapStrengthCost - 1);
+ expect(predictor.canLeap).toBe(false);
+ setFrameTimeMs(1030);
+ predictor.update([ground] as never);
+ expect(predictor.canLeap).toBe(true);
+ });
+
+ it('disables immediately after a leap and stays unavailable in flight', () => {
+ const { predictor } = prepare();
+ predictor.recordLeap();
+ expect(predictor.canLeap).toBe(false);
+ setFrameTimeMs(1002);
+ predictor.update([ground] as never);
+ expect(predictor.canLeap).toBe(false);
+ setFrameTimeMs(1380);
+ predictor.update([ground] as never);
+ expect(predictor.canLeap).toBe(false);
+ });
+
+ it('clears availability on death, reset, spawn animation, and game end', () => {
+ const { predictor, pose } = prepare();
+ predictor.setAlive(false);
+ expect(predictor.canLeap).toBe(false);
+ predictor.update([ground] as never);
+ predictor.setAlive(true);
+ expect(predictor.canLeap).toBe(false);
+ predictor.update([ground] as never);
+ expect(predictor.canLeap).toBe(true);
+
+ predictor.enabled = false;
+ expect(predictor.canLeap).toBe(false);
+ predictor.enabled = true;
+ predictor.setAuthoritative(
+ { ...pose.head, radius: 10 } as never,
+ pose.leftFoot as never,
+ pose.rightFoot as never,
+ );
+ expect(predictor.canLeap).toBe(false);
+ predictor.reset();
+ expect(predictor.canLeap).toBe(false);
+ });
+});
diff --git a/test/sound-handler.test.ts b/test/sound-handler.test.ts
new file mode 100644
index 0000000..074dbde
--- /dev/null
+++ b/test/sound-handler.test.ts
@@ -0,0 +1,186 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const { options } = vi.hoisted(() => ({ options: { soundsEnabled: true } }));
+vi.mock('../frontend/src/scripts/options-handler', () => ({ options }));
+
+class FakeAudio {
+ static instances: FakeAudio[] = [];
+ static ready: Promise;
+ muted = false;
+ paused = true;
+ ended = false;
+ currentTime = 0;
+ volume = 1;
+ playbackRate = 1;
+ loop = false;
+ onpause?: () => unknown;
+ onplay?: () => unknown;
+ constructor(public src: string) {
+ FakeAudio.instances.push(this);
+ }
+ play = vi.fn(() => {
+ this.paused = false;
+ return FakeAudio.ready;
+ });
+ pause = vi.fn(() => {
+ this.paused = true;
+ });
+ cloneNode() {
+ return new FakeAudio(this.src);
+ }
+}
+
+class FakeSource {
+ buffer: unknown;
+ playbackRate = { value: 1 };
+ onended?: () => void;
+ connect = vi.fn();
+ disconnect = vi.fn();
+ start = vi.fn();
+ stop = vi.fn();
+}
+
+class FakeAudioContext {
+ static instances: FakeAudioContext[] = [];
+ state = 'running';
+ destination = {};
+ sources: FakeSource[] = [];
+ gains: ReturnType[] = [];
+ resume = vi.fn(async () => undefined);
+ decodeAudioData = vi.fn(async (data: ArrayBuffer) => ({ data }));
+ constructor() {
+ FakeAudioContext.instances.push(this);
+ }
+ createBufferSource() {
+ const source = new FakeSource();
+ this.sources.push(source);
+ return source;
+ }
+ createGain(): {
+ gain: { value: number };
+ connect: ReturnType;
+ disconnect: ReturnType;
+ } {
+ const gain = { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
+ this.gains.push(gain);
+ return gain;
+ }
+}
+
+const load = () => import('../frontend/src/scripts/sound-handler');
+
+beforeEach(() => {
+ vi.resetModules();
+ options.soundsEnabled = true;
+ FakeAudio.instances = [];
+ FakeAudio.ready = Promise.resolve();
+ FakeAudioContext.instances = [];
+ vi.stubGlobal('Audio', FakeAudio);
+ vi.stubGlobal('AudioContext', FakeAudioContext);
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) })),
+ );
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('sound playback under a slow connection', () => {
+ it('reuses decoded audio without making media players or requests for overlapping shots', async () => {
+ const { SoundHandler, Sounds } = await load();
+ await SoundHandler.initialize();
+ const players = FakeAudio.instances.length;
+ const requests = vi.mocked(fetch).mock.calls.length;
+
+ for (let i = 0; i < 80; i++) SoundHandler.play(Sounds.shoot, 0.7, 0.8);
+
+ expect(FakeAudio.instances).toHaveLength(players);
+ expect(fetch).toHaveBeenCalledTimes(requests);
+ const context = FakeAudioContext.instances[0];
+ expect(context.decodeAudioData).toHaveBeenCalledTimes(3);
+ expect(context.sources).toHaveLength(80);
+ expect(new Set(context.sources.map((s) => s.buffer)).size).toBe(1);
+ expect(context.sources.every((s) => s.start.mock.calls.length === 1)).toBe(true);
+ expect(context.sources.every((s) => s.playbackRate.value === 0.8)).toBe(true);
+ expect(context.gains.every((g) => g.gain.value === 0.7)).toBe(true);
+ });
+
+ it('allows loaded effects to play while music or another effect is still loading', async () => {
+ FakeAudio.ready = new Promise(() => {});
+ vi.mocked(fetch).mockImplementation(async (src) =>
+ String(src).includes('hit.mp3')
+ ? new Promise(() => {})
+ : ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) } as Response),
+ );
+ const { SoundHandler, Sounds } = await load();
+ void SoundHandler.initialize();
+ await vi.waitFor(() =>
+ expect(FakeAudioContext.instances[0]?.decodeAudioData).toHaveBeenCalledTimes(2),
+ );
+ SoundHandler.play(Sounds.shoot);
+ expect(FakeAudioContext.instances[0].sources[0].start).toHaveBeenCalledOnce();
+ });
+
+ it('drops effects that are not ready instead of replaying a backlog after loading', async () => {
+ let resolve!: (response: Response) => void;
+ vi.mocked(fetch).mockReturnValue(new Promise((r) => (resolve = r)));
+ const { SoundHandler, Sounds } = await load();
+ const ready = SoundHandler.initialize();
+ for (let i = 0; i < 80; i++) SoundHandler.play(Sounds.shoot);
+ expect(FakeAudioContext.instances[0].sources).toHaveLength(0);
+
+ resolve({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) } as Response);
+ await ready;
+ expect(FakeAudioContext.instances[0].sources).toHaveLength(0);
+ SoundHandler.play(Sounds.shoot);
+ expect(FakeAudioContext.instances[0].sources).toHaveLength(1);
+ });
+
+ it('bounds overlapping voices and disconnects both stolen and finished sounds', async () => {
+ const { SoundHandler, Sounds } = await load();
+ await SoundHandler.initialize();
+ for (let i = 0; i < 80; i++) SoundHandler.play(Sounds.shoot);
+ const context = FakeAudioContext.instances[0];
+ const stopped = context.sources.filter((s) => s.stop.mock.calls.length > 0);
+ const active = context.sources.filter((s) => s.stop.mock.calls.length === 0);
+ expect(active.length).toBeGreaterThan(1);
+ expect(active.length).toBeLessThanOrEqual(16);
+ expect(stopped.every((s) => s.disconnect.mock.calls.length === 1)).toBe(true);
+
+ const last = context.sources[context.sources.length - 1];
+ last.onended?.();
+ expect(last.disconnect).toHaveBeenCalledOnce();
+ expect(context.gains[context.gains.length - 1].disconnect).toHaveBeenCalledOnce();
+ });
+
+ it('initializes only once and respects the sound toggle and volume limits', async () => {
+ const { SoundHandler, Sounds } = await load();
+ await Promise.all([SoundHandler.initialize(), SoundHandler.initialize()]);
+ expect(FakeAudioContext.instances).toHaveLength(1);
+ expect(fetch).toHaveBeenCalledTimes(3);
+ const context = FakeAudioContext.instances[0];
+ expect(context.resume).toHaveBeenCalledOnce();
+ options.soundsEnabled = false;
+ SoundHandler.play(Sounds.shoot);
+ expect(context.sources).toHaveLength(0);
+ options.soundsEnabled = true;
+ SoundHandler.play(Sounds.hit, 2);
+ expect(context.gains[0].gain.value).toBe(1);
+ });
+
+ it('keeps playback harmless when audio is unavailable or an asset fails', async () => {
+ vi.mocked(fetch).mockRejectedValue(new Error('offline'));
+ const { SoundHandler, Sounds } = await load();
+ await expect(SoundHandler.initialize()).resolves.toBeUndefined();
+ expect(() => SoundHandler.play(Sounds.shoot)).not.toThrow();
+ expect(FakeAudioContext.instances[0].sources).toHaveLength(0);
+
+ vi.resetModules();
+ vi.stubGlobal('AudioContext', undefined);
+ const unavailable = await load();
+ await expect(unavailable.SoundHandler.initialize()).resolves.toBeUndefined();
+ expect(() => unavailable.SoundHandler.play(Sounds.shoot)).not.toThrow();
+ });
+});