Fix issues from mobile playtesting (#5)
All checks were successful
Build & deploy / Build & publish server image (push) Successful in 29s
Build & deploy / Build & deploy website (push) Successful in 34s

Reviewed-on: https://home.schmelczer.dev/git/andras/decla-red/pulls/5
This commit is contained in:
Andras Schmelczer 2026-09-15 21:03:47 +01:00
commit 4d1761df16
13 changed files with 543 additions and 113 deletions

View file

@ -271,7 +271,7 @@ export class GameServer implements GameEvents {
points >= this.options.scoreLimit * settings.matchPointScoreRatio points >= this.options.scoreLimit * settings.matchPointScoreRatio
) { ) {
this.matchPointAnnounced[team] = true; this.matchPointAnnounced[team] = true;
this.announce(`Match point — team <span class="${team}">${team}</span>!`); this.announce(`Match point: team <span class="${team}">${team}</span>!`);
} }
} }

View file

@ -140,12 +140,6 @@ export class PlanetPhysical extends PlanetBase implements Physical {
Math.round(settings.captureFlipPointReward * this.sizePointMultiplier), Math.round(settings.captureFlipPointReward * this.sizePointMultiplier),
); );
this.awardToOwner(settings.captureFlipPointReward); this.awardToOwner(settings.captureFlipPointReward);
if (this.isKeystone) {
this.game.announce(
`Team <span class="${currentTeam}">${currentTeam}</span> captured the Heart`,
);
}
} }
const control = Math.abs(this.ownership - 0.5) / 0.5; const control = Math.abs(this.ownership - 0.5) / 0.5;

View file

@ -208,7 +208,9 @@ body {
mask-image: url('../static/mask.svg'); mask-image: url('../static/mask.svg');
&.contested { &.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 { &.keystone {
@ -320,6 +322,15 @@ body {
display: inline-block; display: inline-block;
} }
&[hidden] {
display: none;
}
&[aria-disabled='true'] {
opacity: 0.3;
cursor: default;
}
&.fire { &.fire {
right: $medium-padding; right: $medium-padding;
@ -757,18 +768,6 @@ body {
} }
} }
@keyframes contested-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.charge-ring { .charge-ring {
position: fixed; position: fixed;
@include square(56px); @include square(56px);

View file

@ -4,6 +4,7 @@ import type { Command } from 'shared';
import type { Game } from '../game'; import type { Game } from '../game';
import { ChargeIndicator } from '../charge-indicator'; import { ChargeIndicator } from '../charge-indicator';
import { centeredTransform } from '../helper/centered-transform'; import { centeredTransform } from '../helper/centered-transform';
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
import { InputGenerator } from './input-generator'; import { InputGenerator } from './input-generator';
const deadZone = 8; const deadZone = 8;
@ -196,15 +197,23 @@ export class TouchListener extends InputGenerator {
event.stopPropagation(); event.stopPropagation();
} }
private get isAlive(): boolean {
return (this.game.gameObjects.localPlayer?.health ?? 0) > 0;
}
private leapButtonListener = (event: TouchEvent) => { private leapButtonListener = (event: TouchEvent) => {
this.swallowTouch(event); this.swallowTouch(event);
if (!this.isAlive || !localCharacterPredictor.canLeap) {
return;
}
this.sendLeap(); this.sendLeap();
this.leapButton.hidden = true;
}; };
private fireButtonDownListener = (event: TouchEvent) => { private fireButtonDownListener = (event: TouchEvent) => {
this.swallowTouch(event); this.swallowTouch(event);
const touch = event.changedTouches[0]; const touch = event.changedTouches[0];
if (this.fireTouchId !== null || !touch) { if (!this.isAlive || this.fireTouchId !== null || !touch) {
return; return;
} }
this.fireTouchId = touch.identifier; this.fireTouchId = touch.identifier;
@ -255,7 +264,7 @@ export class TouchListener extends InputGenerator {
this.cancelFire(); this.cancelFire();
const character = this.game.gameObjects.localPlayer; const character = this.game.gameObjects.localPlayer;
if (!character) { if (!character || character.health <= 0) {
return; return;
} }
@ -287,6 +296,11 @@ export class TouchListener extends InputGenerator {
} }
const character = this.game.gameObjects.localPlayer; 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) { if (character) {
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${ this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${
character.strengthFraction * 360 character.strengthFraction * 360

View file

@ -3,7 +3,6 @@ import { getDisplayPosition } from './helper/pointer';
let root: HTMLElement | undefined; let root: HTMLElement | undefined;
let killfeed: HTMLElement | undefined; let killfeed: HTMLElement | undefined;
let elimination: HTMLElement | undefined;
function ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } { function ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } {
if (!root || !killfeed) { 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 =
'<div class="elimination-title">Eliminated</div>' +
'<div class="elimination-sub">Respawning…</div>';
r.appendChild(el);
elimination = el;
}
function hideElimination(): void {
elimination?.remove();
elimination = undefined;
}
function resetFeedbackHud(): void { function resetFeedbackHud(): void {
hideElimination();
root?.remove(); root?.remove();
root = undefined; root = undefined;
killfeed = undefined; killfeed = undefined;
@ -122,7 +101,5 @@ function escapeHtml(text: string): string {
export const FeedbackHud = { export const FeedbackHud = {
hitMarker, hitMarker,
killConfirmed, killConfirmed,
showElimination,
hideElimination,
reset: resetFeedbackHud, reset: resetFeedbackHud,
}; };

View file

@ -55,7 +55,7 @@ export class Game extends CommandReceiver {
private socket!: Socket; private socket!: Socket;
private socketReceiver!: CommandSocket; private socketReceiver!: CommandSocket;
private tutorial!: Tutorial; private tutorial?: Tutorial;
private resolveStarted!: () => void; private resolveStarted!: () => void;
private isBetweenGames = false; private isBetweenGames = false;
private isActive = true; private isActive = true;
@ -92,7 +92,7 @@ export class Game extends CommandReceiver {
), ),
[GameEndCommand.name]: () => (localCharacterPredictor.enabled = false), [GameEndCommand.name]: () => (localCharacterPredictor.enabled = false),
[UpdateMinimap.name]: (c: UpdateMinimap) => (this.lastMinimap = c), [UpdateMinimap.name]: (c: UpdateMinimap) => (this.lastMinimap = c),
[GameStartCommand.name]: () => this.initialize(), [GameStartCommand.name]: () => this.initialize(true),
}; };
constructor( constructor(
@ -106,7 +106,7 @@ export class Game extends CommandReceiver {
const onInput = (c: Command) => { const onInput = (c: Command) => {
this.socketReceiver.queue(c); this.socketReceiver.queue(c);
this.tutorial.handleCommand(c); this.tutorial?.handleCommand(c);
}; };
this.keyboardListener = new KeyboardListener(onInput); this.keyboardListener = new KeyboardListener(onInput);
this.mouseListener = new MouseListener(this.canvas, this, onInput); this.mouseListener = new MouseListener(this.canvas, this, onInput);
@ -117,7 +117,7 @@ export class Game extends CommandReceiver {
this.gameObjects.handleCommand(c); this.gameObjects.handleCommand(c);
} }
private initialize() { private initialize(isNewGame = false) {
this.isBetweenGames = true; this.isBetweenGames = true;
this.socket?.close(); this.socket?.close();
@ -131,14 +131,15 @@ export class Game extends CommandReceiver {
this.keystoneArrow = undefined; this.keystoneArrow = undefined;
this.connectionBanner = undefined; this.connectionBanner = undefined;
this.lastMinimap = undefined; this.lastMinimap = undefined;
this.lastAnnouncementText = ''; this.lastAnnouncementText = isNewGame ? 'New game!' : '';
this.timeSinceLastAnnouncement = 0;
this.announcementText.innerText = ''; this.announcementText.innerText = '';
this.overlay.append( this.overlay.append(
this.scoreboard.element, this.scoreboard.element,
this.minimap.element, this.minimap.element,
this.announcementText, this.announcementText,
); );
this.tutorial = new Tutorial(this.overlay); this.tutorial = isNewGame ? undefined : new Tutorial(this.overlay);
this.socket = io(this.playerDecision.server, { this.socket = io(this.playerDecision.server, {
reconnectionDelayMax: 10000, reconnectionDelayMax: 10000,
@ -281,9 +282,9 @@ export class Game extends CommandReceiver {
public static rejectionText(reason: JoinRejectionReason): string { public static rejectionText(reason: JoinRejectionReason): string {
switch (reason) { switch (reason) {
case JoinRejectionReason.ServerFull: case JoinRejectionReason.ServerFull:
return 'That server is full — pick another'; return 'That server is full. Pick another';
case JoinRejectionReason.RoundEnding: 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: case JoinRejectionReason.AlreadyJoined:
return 'Already joined on this connection'; return 'Already joined on this connection';
default: default:
@ -354,7 +355,7 @@ export class Game extends CommandReceiver {
this.gameObjects.step(deltaTime); this.gameObjects.step(deltaTime);
this.gameObjects.render(renderer, this.overlay, shouldChangeLayout); this.gameObjects.render(renderer, this.overlay, shouldChangeLayout);
this.touchListener.update(); this.touchListener.update();
this.tutorial.step(this.gameObjects); this.tutorial?.step(this.gameObjects);
this.socketReceiver.sendQueuedCommands(); this.socketReceiver.sendQueuedCommands();
return this.isActive; return this.isActive;

View file

@ -28,6 +28,11 @@ const maxReplayMs = 400;
const correctionSeconds = 0.08; const correctionSeconds = 0.08;
const snapDistance = 1500; const snapDistance = 1500;
interface PredictedMovementState extends CharacterMovementState {
availableStrength: number;
leapCooldownRemaining: number;
}
interface ReplaySnapshot { interface ReplaySnapshot {
pose: { head: Circle; leftFoot: Circle; rightFoot: Circle }; pose: { head: Circle; leftFoot: Circle; rightFoot: Circle };
movement: CharacterMovementSnapshot; movement: CharacterMovementSnapshot;
@ -35,7 +40,7 @@ interface ReplaySnapshot {
leapAckMs: number; leapAckMs: number;
strength: number; strength: number;
planets: Array<PlanetSnapshot>; planets: Array<PlanetSnapshot>;
horizonState?: CharacterMovementState; horizonState?: PredictedMovementState;
} }
const upwards = vec2.fromValues(0, 1); const upwards = vec2.fromValues(0, 1);
@ -59,6 +64,7 @@ export class LocalCharacterPredictor {
private leapHistory: Array<number> = []; private leapHistory: Array<number> = [];
private lastLeapAckMs = -Infinity; private lastLeapAckMs = -Infinity;
private currentStrength = settings.playerMaxStrength; private currentStrength = settings.playerMaxStrength;
private predicted?: PredictedMovementState;
private alive = true; private alive = true;
private previousSnapshot?: ReplaySnapshot; private previousSnapshot?: ReplaySnapshot;
@ -79,6 +85,17 @@ export class LocalCharacterPredictor {
return this.renderRightFoot; 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 { public recordInput(direction: vec2): number {
const timeMs = clientTimeMs(); const timeMs = clientTimeMs();
this.inputHistory.record(direction, timeMs); this.inputHistory.record(direction, timeMs);
@ -103,6 +120,7 @@ export class LocalCharacterPredictor {
public recordLeap(): number { public recordLeap(): number {
const timeMs = clientTimeMs(); const timeMs = clientTimeMs();
this.leapHistory.push(timeMs); this.leapHistory.push(timeMs);
this.predicted = undefined;
const cutoff = timeMs - 1500; const cutoff = timeMs - 1500;
while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) { while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) {
this.leapHistory.shift(); this.leapHistory.shift();
@ -126,6 +144,7 @@ export class LocalCharacterPredictor {
this.replayAnchorMs = undefined; this.replayAnchorMs = undefined;
this.movement = undefined; this.movement = undefined;
this.currentStrength = settings.playerMaxStrength; this.currentStrength = settings.playerMaxStrength;
this.predicted = undefined;
this.alive = true; this.alive = true;
this.previousSnapshot = undefined; this.previousSnapshot = undefined;
this.lastUpdateMs = undefined; this.lastUpdateMs = undefined;
@ -149,6 +168,7 @@ export class LocalCharacterPredictor {
public update(planets: Array<PlanetView>): boolean { public update(planets: Array<PlanetView>): boolean {
if (!this.enabled || !this.alive || !this.canPredict || this.isAnimatingInOrOut) { if (!this.enabled || !this.alive || !this.canPredict || this.isAnimatingInOrOut) {
this.predicted = undefined;
this.previousSnapshot = undefined; this.previousSnapshot = undefined;
this.lastUpdateMs = undefined; this.lastUpdateMs = undefined;
vec2.zero(this.correction); vec2.zero(this.correction);
@ -182,6 +202,7 @@ export class LocalCharacterPredictor {
), ),
}; };
const predicted = this.simulate(snapshot); const predicted = this.simulate(snapshot);
this.predicted = predicted;
if (this.previousSnapshot && this.previousSnapshot.pose !== snapshot.pose) { if (this.previousSnapshot && this.previousSnapshot.pose !== snapshot.pose) {
// Compare both snapshots at the same instant: smooth only the correction, // Compare both snapshots at the same instant: smooth only the correction,
@ -210,7 +231,7 @@ export class LocalCharacterPredictor {
return true; return true;
} }
private simulate(snapshot: ReplaySnapshot): CharacterMovementState { private simulate(snapshot: ReplaySnapshot): PredictedMovementState {
if (snapshot.horizonState) { if (snapshot.horizonState) {
return snapshot.horizonState; return snapshot.horizonState;
} }
@ -222,7 +243,7 @@ export class LocalCharacterPredictor {
const steps = Math.floor(windowMs / stepMs); const steps = Math.floor(windowMs / stepMs);
const partialStep = (windowMs - steps * stepMs) / stepMs; const partialStep = (windowMs - steps * stepMs) / stepMs;
const state: CharacterMovementState = { const state: PredictedMovementState = {
head: makeBody(auth.head.center, auth.head.radius, upwards), head: makeBody(auth.head.center, auth.head.radius, upwards),
leftFoot: makeBody( leftFoot: makeBody(
auth.leftFoot.center, auth.leftFoot.center,
@ -238,11 +259,10 @@ export class LocalCharacterPredictor {
currentPlanet: this.world.surfaceById(movement.groundPlanetId), currentPlanet: this.world.surfaceById(movement.groundPlanetId),
secondsSinceOnSurface: movement.secondsSinceOnSurface, secondsSinceOnSurface: movement.secondsSinceOnSurface,
bodyVelocity: vec2.fromValues(movement.bodyVelocity[0], movement.bodyVelocity[1]), 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; let t = startMs;
for (let i = 0; i < steps + (partialStep > 0 ? 1 : 0); i++) { for (let i = 0; i < steps + (partialStep > 0 ? 1 : 0); i++) {
const fraction = i < steps ? 1 : partialStep; const fraction = i < steps ? 1 : partialStep;
@ -258,12 +278,12 @@ export class LocalCharacterPredictor {
leapMs < t + stepMs * fraction && leapMs < t + stepMs * fraction &&
leapMs > snapshot.leapAckMs && leapMs > snapshot.leapAckMs &&
state.currentPlanet && state.currentPlanet &&
cooldown <= 0 && state.leapCooldownRemaining <= 0 &&
availableStrength >= settings.leapStrengthCost state.availableStrength >= settings.leapStrengthCost
) { ) {
applyLeapImpulse(state, this.inputHistory.directionAt(leapMs)); applyLeapImpulse(state, this.inputHistory.directionAt(leapMs));
availableStrength -= settings.leapStrengthCost; state.availableStrength -= settings.leapStrengthCost;
cooldown = settings.leapCooldownSeconds; state.leapCooldownRemaining = settings.leapCooldownSeconds;
} }
} }
this.world.advance(stepSeconds); this.world.advance(stepSeconds);
@ -274,10 +294,14 @@ export class LocalCharacterPredictor {
this.inputHistory.directionAt(t), this.inputHistory.directionAt(t),
stepSeconds, stepSeconds,
); );
cooldown = Math.max(0, cooldown - stepSeconds); state.leapCooldownRemaining = Math.max(
availableStrength = Math.min( 0,
state.leapCooldownRemaining - stepSeconds,
);
state.availableStrength = Math.min(
settings.playerMaxStrength, settings.playerMaxStrength,
availableStrength + settings.playerStrengthRegenerationPerSeconds * stepSeconds, state.availableStrength +
settings.playerStrengthRegenerationPerSeconds * stepSeconds,
); );
if (from) { if (from) {
[state.head, state.leftFoot, state.rightFoot].forEach((body, index) => [state.head, state.leftFoot, state.rightFoot].forEach((body, index) =>

View file

@ -13,7 +13,6 @@ import {
RemoteCallsForObjects, RemoteCallsForObjects,
UpdatePropertyCommand, UpdatePropertyCommand,
} from 'shared'; } from 'shared';
import { FeedbackHud } from '../feedback-hud';
import type { Game } from '../game'; import type { Game } from '../game';
import { serverTimeline } from '../helper/server-timeline'; import { serverTimeline } from '../helper/server-timeline';
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor'; import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
@ -50,7 +49,6 @@ export class GameObjectContainer extends CommandReceiver {
this.addObject(this.player); this.addObject(this.player);
localCharacterPredictor.reset(); localCharacterPredictor.reset();
this.game.resendMovement(); this.game.resendMovement();
FeedbackHud.hideElimination();
this.wasLocalPlayerAlive = true; this.wasLocalPlayerAlive = true;
}, },
@ -133,9 +131,6 @@ export class GameObjectContainer extends CommandReceiver {
const player = this.localPlayer; const player = this.localPlayer;
const alive = !!player && player.health > 0; const alive = !!player && player.health > 0;
if (this.wasLocalPlayerAlive && !alive) {
FeedbackHud.showElimination();
}
this.wasLocalPlayerAlive = alive; this.wasLocalPlayerAlive = alive;
if (player) { if (player) {

View file

@ -8,37 +8,51 @@ import { options } from './options-handler';
export const Sounds = { hit: 'hit', shoot: 'shoot', click: 'click' } as const; export const Sounds = { hit: 'hit', shoot: 'shoot', click: 'click' } as const;
export type Sound = (typeof Sounds)[keyof typeof Sounds]; export type Sound = (typeof Sounds)[keyof typeof Sounds];
let sounds: Record<Sound, HTMLAudioElement>; const sounds: Partial<Record<Sound, AudioBuffer>> = {};
const maxVoices = 16;
const voices = new Set<{ source: AudioBufferSourceNode; gain: GainNode }>();
let context: AudioContext | undefined;
let initialization: Promise<void> | undefined;
let isAmbientPlaying = false; let isAmbientPlaying = false;
const ambient = new Audio(ambientAudio); const ambient = new Audio(ambientAudio);
let initialized = false; let ambientInitialized = false;
async function initializeSound(src: string): Promise<HTMLAudioElement> { async function initializeEffects() {
const snd = new Audio(src); try {
snd.muted = true; context = new AudioContext({ latencyHint: 'interactive' });
await snd.play().catch(() => undefined); } catch {
snd.pause(); return;
snd.muted = false; }
snd.currentTime = 0; const audioContext = context;
return snd; // 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( async function initializeAmbient(
onPlayKeypress: () => unknown = () => null, onPlayKeypress: () => unknown,
onPauseKeypress: () => unknown = () => null, onPauseKeypress: () => unknown,
) { ) {
ambient.muted = true; ambient.muted = true;
ambient.volume = 0.5; ambient.volume = 0.5;
ambient.loop = true; ambient.loop = true;
// Unlock every audio element during the initiating click, before awaiting any one. await ambient.play().catch(() => undefined);
const ambientReady = ambient.play().catch(() => undefined); ambientInitialized = true;
const [hit, shoot, click] = await Promise.all(
[hitSound, shootSound, clickSound].map(initializeSound),
);
sounds = { hit, shoot, click };
await ambientReady;
initialized = true;
ambient.onpause = onPauseKeypress; ambient.onpause = onPauseKeypress;
ambient.onplay = onPlayKeypress; ambient.onplay = onPlayKeypress;
@ -49,32 +63,65 @@ async function initialize(
} }
} }
function initialize(
onPlayKeypress: () => unknown = () => null,
onPauseKeypress: () => unknown = () => null,
): Promise<void> {
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) { 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; return;
} }
const pooled = sounds[snd]; // Delayed network packets can deliver many effects together. Keep their audio work bounded.
const isBusy = !pooled.paused && !pooled.ended; if (voices.size >= maxVoices) {
const audio = isBusy ? (pooled.cloneNode(true) as HTMLAudioElement) : pooled; const oldest = voices.values().next().value!;
if (!isBusy) { oldest.source.stop();
audio.currentTime = 0; oldest.source.disconnect();
oldest.gain.disconnect();
voices.delete(oldest);
} }
audio.volume = clamp01(volume);
audio.playbackRate = playbackRate; const source = context.createBufferSource();
void audio.play().catch(() => undefined); 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() { function playAmbient() {
isAmbientPlaying = true; isAmbientPlaying = true;
if (initialized) { if (ambientInitialized) {
void ambient.play().catch(() => undefined); void ambient.play().catch(() => undefined);
} }
} }
function stopAmbient() { function stopAmbient() {
isAmbientPlaying = false; isAmbientPlaying = false;
if (initialized) { if (ambientInitialized) {
ambient.pause(); ambient.pause();
} }
} }

View file

@ -13,10 +13,18 @@ import { PlanetView } from './objects/types/planet-view';
type StageTrigger = 'move' | 'shoot' | 'leap' | 'capture'; type StageTrigger = 'move' | 'shoot' | 'leap' | 'capture';
const stages: ReadonlyArray<{ hint: string; clearsOn: StageTrigger }> = [ const stages: ReadonlyArray<{
{ hint: 'WASD / drag to walk', clearsOn: 'move' }, hint: string;
{ hint: 'Click / tap to shoot', clearsOn: 'shoot' }, touchHint?: string;
{ hint: 'Space / leap button to launch off a planet', clearsOn: 'leap' }, 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' }, { hint: 'Stand on a planet to capture it', clearsOn: 'capture' },
]; ];
@ -54,7 +62,7 @@ export class Tutorial extends CommandReceiver {
constructor(overlay: HTMLElement) { constructor(overlay: HTMLElement) {
super(); super();
this.element.className = 'tutorial-hint'; this.element.className = 'tutorial-hint';
this.element.innerText = stages[0].hint; this.setStage(0);
overlay.appendChild(this.element); overlay.appendChild(this.element);
} }
@ -107,6 +115,14 @@ export class Tutorial extends CommandReceiver {
private setStage(stage: number) { private setStage(stage: number) {
this.stage = stage; 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 ??
'';
} }
} }

View file

@ -6,6 +6,8 @@ import { TouchListener } from '../frontend/src/scripts/commands/touch-listener';
import { JoinFormHandler } from '../frontend/src/scripts/join-form-handler'; import { JoinFormHandler } from '../frontend/src/scripts/join-form-handler';
import { initializeOptions, options } from '../frontend/src/scripts/options-handler'; import { initializeOptions, options } from '../frontend/src/scripts/options-handler';
import { loadStoredValue, saveStoredValue } from '../frontend/src/scripts/helper/storage'; 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() })); const { connect } = vi.hoisted(() => ({ connect: vi.fn() }));
vi.mock('../frontend/node_modules/socket.io-client/build/esm-debug/index.js', () => ({ vi.mock('../frontend/node_modules/socket.io-client/build/esm-debug/index.js', () => ({
@ -30,8 +32,16 @@ class Element extends EventTarget {
public className = ''; public className = '';
public checked = false; public checked = false;
public disabled = false; public disabled = false;
public hidden = false;
private attributes = new Map<string, string>();
public onsubmit: ((event: Event) => void) | null = null; 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) { public appendChild(child: Element) {
child.remove(); child.remove();
this.children.push(child); this.children.push(child);
@ -81,6 +91,7 @@ let sockets: Socket[];
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
localCharacterPredictor.reset();
windowTarget = new EventTarget(); windowTarget = new EventTarget();
sockets = []; sockets = [];
vi.stubGlobal('window', windowTarget); vi.stubGlobal('window', windowTarget);
@ -103,6 +114,7 @@ afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
vi.clearAllMocks(); vi.clearAllMocks();
vi.restoreAllMocks();
}); });
const chooser = () => { const chooser = () => {
@ -194,10 +206,16 @@ const touchControls = () => {
const canvas = new Element(); const canvas = new Element();
const overlay = new Element(); const overlay = new Element();
const send = vi.fn(); const send = vi.fn();
const character = {
health: 100,
bodyCenter: [0, 0],
facingDirection: [1, 0],
strengthFraction: 1,
};
const game = { const game = {
displayToWorldCoordinates: (position: number[]) => position, displayToWorldCoordinates: (position: number[]) => position,
gameObjects: { gameObjects: {
localPlayer: { bodyCenter: [0, 0], facingDirection: [1, 0], strengthFraction: 1 }, localPlayer: character as typeof character | undefined,
}, },
}; };
const listener = new TouchListener( const listener = new TouchListener(
@ -207,8 +225,85 @@ const touchControls = () => {
send, send,
); );
listener.update(); 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', () => { describe('touch ownership', () => {
it('keeps the first canvas gesture when a second finger lands before dragging', () => { it('keeps the first canvas gesture when a second finger lands before dragging', () => {

View file

@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { settings } from '../shared/src/settings';
import { import {
LocalCharacterPredictor, LocalCharacterPredictor,
setFrameTimeMs, setFrameTimeMs,
@ -313,3 +314,84 @@ describe('leap prediction', () => {
expect(leapHeight(0.2, true)).toEqual(leapHeight(0.2, false)); 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);
});
});

186
test/sound-handler.test.ts Normal file
View file

@ -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<void>;
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<FakeAudioContext['createGain']>[] = [];
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<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
} {
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();
});
});