diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx index 67c9264..47e09d3 100644 --- a/app/pixel-office/page.tsx +++ b/app/pixel-office/page.tsx @@ -19,7 +19,14 @@ import { TileType, EditTool } from '@/lib/pixel-office/types' import type { TileType as TileTypeVal, FloorColor, OfficeLayout } from '@/lib/pixel-office/types' import { getCatalogEntry, isRotatable } from '@/lib/pixel-office/layout/furnitureCatalog' import { createDefaultLayout, migrateLayoutColors, serializeLayout } from '@/lib/pixel-office/layout/layoutSerializer' -import { playDoneSound, unlockAudio, setSoundEnabled, isSoundEnabled } from '@/lib/pixel-office/notificationSound' +import { + playDoneSound, + playBackgroundMusic, + stopBackgroundMusic, + unlockAudio, + setSoundEnabled, + isSoundEnabled, +} from '@/lib/pixel-office/notificationSound' import { loadCharacterPNGs, loadWallPNG } from '@/lib/pixel-office/sprites/pngLoader' import { useI18n } from '@/lib/i18n' import { EditorToolbar } from './components/EditorToolbar' @@ -424,6 +431,7 @@ export default function PixelOfficePage() { } return () => { + stopBackgroundMusic() cachedOfficeState = officeRef.current cachedEditorState = editorRef.current cachedSavedLayout = savedLayoutRef.current @@ -435,6 +443,14 @@ export default function PixelOfficePage() { } }, []) + useEffect(() => { + if (soundOn) { + void playBackgroundMusic() + } else { + stopBackgroundMusic() + } + }, [soundOn]) + useEffect(() => { cachedAgents = agents }, [agents]) @@ -1090,6 +1106,7 @@ export default function PixelOfficePage() { const PHOTO_COUNT = 13 const handleMouseDown = (e: React.MouseEvent) => { unlockAudio() + if (soundOn) void playBackgroundMusic() if (!canvasRef.current || !officeRef.current) return const office = officeRef.current const editor = editorRef.current @@ -1531,6 +1548,11 @@ export default function PixelOfficePage() { setSoundEnabled(newVal) setSoundOn(newVal) localStorage.setItem('pixel-office-sound', String(newVal)) + if (newVal) { + void playBackgroundMusic() + } else { + stopBackgroundMusic() + } }, []) const resetView = useCallback(() => { diff --git a/lib/pixel-office/notificationSound.ts b/lib/pixel-office/notificationSound.ts index b75e2ad..34e9604 100644 --- a/lib/pixel-office/notificationSound.ts +++ b/lib/pixel-office/notificationSound.ts @@ -9,9 +9,15 @@ import { let soundEnabled = true let audioCtx: AudioContext | null = null +let bgmAudio: HTMLAudioElement | null = null +let bgmGestureRetryBound = false + +const BGM_SRC = '/assets/pixel-office/pixel-adventure.mp3' +const BGM_VOLUME = 0.28 export function setSoundEnabled(enabled: boolean): void { soundEnabled = enabled + if (!enabled) stopBackgroundMusic() } export function isSoundEnabled(): boolean { @@ -36,6 +42,51 @@ function playNote(ctx: AudioContext, freq: number, startOffset: number): void { osc.stop(t + NOTIFICATION_NOTE_DURATION_SEC) } +function getBgmAudio(): HTMLAudioElement | null { + if (typeof window === 'undefined') return null + if (!bgmAudio) { + bgmAudio = new Audio(BGM_SRC) + bgmAudio.loop = true + bgmAudio.preload = 'auto' + bgmAudio.volume = BGM_VOLUME + } + return bgmAudio +} + +function bindBgmGestureRetry(): void { + if (typeof window === 'undefined' || bgmGestureRetryBound) return + bgmGestureRetryBound = true + + const cleanup = () => { + if (typeof window === 'undefined' || !bgmGestureRetryBound) return + bgmGestureRetryBound = false + window.removeEventListener('pointerdown', resumeOnGesture) + window.removeEventListener('touchstart', resumeOnGesture) + window.removeEventListener('keydown', resumeOnGesture) + } + + const resumeOnGesture = () => { + if (!soundEnabled) { + cleanup() + return + } + const audio = getBgmAudio() + if (!audio) { + cleanup() + return + } + audio.play().then(() => { + cleanup() + }).catch(() => { + // Keep listeners for next user gesture attempt. + }) + } + + window.addEventListener('pointerdown', resumeOnGesture, { passive: true }) + window.addEventListener('touchstart', resumeOnGesture, { passive: true }) + window.addEventListener('keydown', resumeOnGesture) +} + export async function playDoneSound(): Promise { if (!soundEnabled) return try { @@ -64,3 +115,24 @@ export function unlockAudio(): void { // ignore } } + +export async function playBackgroundMusic(): Promise { + if (!soundEnabled) return + try { + const audio = getBgmAudio() + if (!audio) return + audio.muted = false + audio.loop = true + audio.volume = BGM_VOLUME + await audio.play() + } catch { + // Browser autoplay may block playback until a user gesture. + bindBgmGestureRetry() + } +} + +export function stopBackgroundMusic(): void { + if (!bgmAudio) return + bgmAudio.pause() + bgmAudio.currentTime = 0 +} diff --git a/public/assets/pixel-office/pixel-adventure.mp3 b/public/assets/pixel-office/pixel-adventure.mp3 new file mode 100644 index 0000000..e788669 Binary files /dev/null and b/public/assets/pixel-office/pixel-adventure.mp3 differ