diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..6d71d36 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,3 @@ +{ + "outputStyle": "Concise" +} diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 4d72944..b149a77 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -45,7 +45,7 @@ jobs: uses: http://forgejo:3000/andras/ci-actions/deploy-pages@main with: source: frontend/dist - target: declared + target: doppler server-image: name: Build & publish server image 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/configuration.ts b/frontend/src/scripts/configuration.ts index d8d2dd0..c61edd9 100644 --- a/frontend/src/scripts/configuration.ts +++ b/frontend/src/scripts/configuration.ts @@ -1,4 +1,4 @@ -const productionServers: Array = ['https://declared.schmelczer.dev']; +const productionServers: Array = ['https://doppler.schmelczer.dev']; const isDevelopment = typeof location !== 'undefined' && 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(); + }); +});