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
3 changed files with 543 additions and 113 deletions

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 { 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<string, string>();
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();

View file

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

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