Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed packages/client/public/audio/cave-melody.ogg
Binary file not shown.
Binary file added packages/client/public/audio/dark-cave-mix.ogg
Binary file not shown.
Binary file added packages/client/public/audio/windy-peaks-mix.ogg
Binary file not shown.
26 changes: 13 additions & 13 deletions packages/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,25 +40,25 @@ import { IS_CHAT_BOX_OPEN_KEY } from './utils/constants';

export const App = (): JSX.Element => {
return (
<SoundProvider>
<Router>
<QueueProvider>
<MapProvider>
<BattleProvider>
<ChatProvider>
<MovementProvider>
<FragmentProvider>
<GoldMerchantProvider>
<AppInner />
</GoldMerchantProvider>
</FragmentProvider>
</MovementProvider>
</ChatProvider>
</BattleProvider>
<SoundProvider>
<BattleProvider>
<ChatProvider>
<MovementProvider>
<FragmentProvider>
<GoldMerchantProvider>
<AppInner />
</GoldMerchantProvider>
</FragmentProvider>
</MovementProvider>
</ChatProvider>
</BattleProvider>
</SoundProvider>
</MapProvider>
</QueueProvider>
</Router>
</SoundProvider>
);
};

Expand Down
83 changes: 76 additions & 7 deletions packages/client/src/contexts/SoundContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,20 @@ import { ChakraProvider } from '@chakra-ui/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SoundProvider, useGameAudio } from './SoundContext';

// Mock howler — we don't want actual audio in tests
// Track mock Howl instances for assertions
const mockPlay = vi.fn();
const mockStop = vi.fn();
const mockFade = vi.fn();
const mockUnload = vi.fn();
const mockVolume = vi.fn();

vi.mock('howler', () => {
const mockHowl = vi.fn().mockImplementation(() => ({
play: vi.fn(),
stop: vi.fn(),
unload: vi.fn(),
play: mockPlay,
stop: mockStop,
fade: mockFade,
unload: mockUnload,
volume: mockVolume,
}));
return { Howl: mockHowl };
});
Expand All @@ -19,6 +27,12 @@ vi.mock('./AuthContext', () => ({
useAuth: () => mockUseAuth(),
}));

// Mock useMap — default to zone 1
const mockUseMap = vi.fn().mockReturnValue({ currentZone: 1 });
vi.mock('./MapContext', () => ({
useMap: () => mockUseMap(),
}));

const TestConsumer = () => {
const { soundEnabled, toggleSound } = useGameAudio();
return (
Expand All @@ -44,6 +58,8 @@ describe('SoundContext', () => {
localStorage.clear();
sessionStorage.clear();
mockUseAuth.mockReturnValue({ isAuthenticated: false });
mockUseMap.mockReturnValue({ currentZone: 1 });
vi.clearAllMocks();
});

afterEach(() => {
Expand Down Expand Up @@ -77,7 +93,7 @@ describe('SoundContext', () => {
expect(screen.getByTestId('status').textContent).toBe('on');
});

it('creates Howl instance when sound is enabled', async () => {
it('creates Howl for current zone when sound is enabled', async () => {
const { Howl } = await import('howler');
renderWithProvider();

Expand All @@ -86,12 +102,66 @@ describe('SoundContext', () => {

expect(Howl).toHaveBeenCalledWith(
expect.objectContaining({
src: ['/audio/cave-melody.ogg'],
src: ['/audio/dark-cave-mix.ogg'],
loop: true,
}),
);
});

it('creates Howl for zone 2 when in Windy Peaks', async () => {
mockUseMap.mockReturnValue({ currentZone: 2 });
const { Howl } = await import('howler');
renderWithProvider();

fireEvent.click(screen.getByText('toggle'));

expect(Howl).toHaveBeenCalledWith(
expect.objectContaining({
src: ['/audio/windy-peaks-mix.ogg'],
loop: true,
}),
);
});

it('crossfades when zone changes while sound is on', async () => {
const { Howl } = await import('howler');
localStorage.setItem('ud:sound-enabled', 'true');
const { rerender } = renderWithProvider();

// Zone 1 playing — should have called play
expect(mockPlay).toHaveBeenCalled();

// Change to zone 2
mockUseMap.mockReturnValue({ currentZone: 2 });
rerender(
<ChakraProvider>
<SoundProvider>
<TestConsumer />
</SoundProvider>
</ChakraProvider>,
);

// Should fade out old and fade in new
expect(mockFade).toHaveBeenCalled();
// New Howl created for zone 2
expect(Howl).toHaveBeenCalledWith(
expect.objectContaining({
src: ['/audio/windy-peaks-mix.ogg'],
}),
);
});

it('stops all tracks when sound is disabled', () => {
localStorage.setItem('ud:sound-enabled', 'true');
renderWithProvider();

expect(mockPlay).toHaveBeenCalled();

// Disable sound
fireEvent.click(screen.getByText('toggle'));
expect(mockStop).toHaveBeenCalled();
});

it('auto-enables sound on authentication', () => {
mockUseAuth.mockReturnValue({ isAuthenticated: true });
renderWithProvider();
Expand All @@ -118,7 +188,6 @@ describe('SoundContext', () => {
localStorage.setItem('ud:sound-enabled', 'true');
mockUseAuth.mockReturnValue({ isAuthenticated: true });
renderWithProvider();
// Sound already on from localStorage — auto-start shouldn't interfere
expect(screen.getByTestId('status').textContent).toBe('on');
});

Expand Down
86 changes: 70 additions & 16 deletions packages/client/src/contexts/SoundContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,18 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
import { Howl } from 'howler';

import { useAuth } from './AuthContext';
import { useMap } from './MapContext';

const SOUND_ENABLED_KEY = 'ud:sound-enabled';
const SOUND_AUTO_STARTED_KEY = 'ud:sound-auto-started';
const AMBIENT_VOLUME = 0.25;
const CROSSFADE_MS = 2000;

/** Map zone IDs to their background audio files */
const ZONE_AUDIO: Record<number, string> = {
1: '/audio/dark-cave-mix.ogg',
2: '/audio/windy-peaks-mix.ogg',
};

type SoundContextValue = {
soundEnabled: boolean;
Expand All @@ -21,14 +29,34 @@ export const useGameAudio = () => useContext(SoundContext);

export const SoundProvider = ({ children }: { children: React.ReactNode }): JSX.Element => {
const { isAuthenticated } = useAuth();
const { currentZone } = useMap();

const [soundEnabled, setSoundEnabled] = useState(() => {
return localStorage.getItem(SOUND_ENABLED_KEY) === 'true';
});

const ambientRef = useRef<Howl | null>(null);
/** Cache of Howl instances by zone ID — created lazily, reused across transitions */
const howlsRef = useRef<Record<number, Howl>>({});
/** The zone ID that is currently playing */
const activeZoneRef = useRef<number | null>(null);
const autoStartedRef = useRef(false);

/** Get or create a Howl for a zone */
const getHowl = useCallback((zoneId: number): Howl | null => {
const src = ZONE_AUDIO[zoneId];
if (!src) return null;

if (!howlsRef.current[zoneId]) {
howlsRef.current[zoneId] = new Howl({
src: [src],
loop: true,
volume: 0,
preload: true,
});
}
return howlsRef.current[zoneId];
}, []);

// Auto-enable sound when user authenticates (once per session)
useEffect(() => {
if (
Expand All @@ -44,28 +72,54 @@ export const SoundProvider = ({ children }: { children: React.ReactNode }): JSX.
}
}, [isAuthenticated, soundEnabled]);

// Lazy-init and play/stop ambient based on soundEnabled
// Handle zone changes and sound toggle — crossfade between zone tracks
useEffect(() => {
if (soundEnabled) {
if (!ambientRef.current) {
ambientRef.current = new Howl({
src: ['/audio/cave-melody.ogg'],
loop: true,
volume: AMBIENT_VOLUME,
preload: true,
});
if (!soundEnabled) {
// Stop all playing tracks
for (const howl of Object.values(howlsRef.current)) {
howl.stop();
}
ambientRef.current.play();
} else {
ambientRef.current?.stop();
activeZoneRef.current = null;
return;
}
}, [soundEnabled]);

// Sound is enabled — play the correct zone track
if (activeZoneRef.current === currentZone) return;

// Fade out the old zone track
const oldZone = activeZoneRef.current;
if (oldZone !== null) {
const oldHowl = howlsRef.current[oldZone];
if (oldHowl) {
oldHowl.fade(AMBIENT_VOLUME, 0, CROSSFADE_MS);
// Stop after fade completes to free resources
setTimeout(() => {
// Only stop if this zone is still not the active one
if (activeZoneRef.current !== oldZone) {
oldHowl.stop();
}
}, CROSSFADE_MS + 50);
}
}

// Fade in the new zone track
const newHowl = getHowl(currentZone);
if (newHowl) {
newHowl.volume(0);
newHowl.play();
newHowl.fade(0, AMBIENT_VOLUME, CROSSFADE_MS);
activeZoneRef.current = currentZone;
}
}, [soundEnabled, currentZone, getHowl]);

// Cleanup on unmount
useEffect(() => {
return () => {
ambientRef.current?.unload();
ambientRef.current = null;
for (const howl of Object.values(howlsRef.current)) {
howl.unload();
}
howlsRef.current = {};
activeZoneRef.current = null;
};
}, []);

Expand Down
Loading