diff --git a/packages/frontend/assets/drop-and-fusion/bgm_1.mp3 b/packages/frontend/assets/drop-and-fusion/bgm_1.mp3 new file mode 100644 index 0000000000..cafc34ad9c Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/bgm_1.mp3 differ diff --git a/packages/frontend/assets/drop-and-fusion/bubble2.mp3 b/packages/frontend/assets/drop-and-fusion/bubble2.mp3 new file mode 100644 index 0000000000..8b4f8df6e9 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/bubble2.mp3 differ diff --git a/packages/frontend/assets/drop-and-fusion/poi1.mp3 b/packages/frontend/assets/drop-and-fusion/poi1.mp3 new file mode 100644 index 0000000000..59dae90965 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/poi1.mp3 differ diff --git a/packages/frontend/assets/drop-and-fusion/poi2.mp3 b/packages/frontend/assets/drop-and-fusion/poi2.mp3 new file mode 100644 index 0000000000..a65c653891 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/poi2.mp3 differ diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue index 1daf9ddc62..0ddee55f5f 100644 --- a/packages/frontend/src/pages/drop-and-fusion.vue +++ b/packages/frontend/src/pages/drop-and-fusion.vue @@ -103,9 +103,23 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div :class="[$style.frame]" style="margin-left: auto;"> <div :class="$style.frameInner" style="text-align: center;"> + <div @click="showConfig = !showConfig"><i class="ti ti-settings"></i></div> </div> </div> </div> + <div v-if="showConfig" :class="$style.frame"> + <div :class="$style.frameInner"> + <MkRange v-model="bgmVolume" :min="0" :max="1" :step="0.0025" :textConverter="(v) => `${Math.floor(v * 100)}%`" :continuousUpdate="true"> + <template #label>BGM {{ i18n.ts.volume }}</template> + </MkRange> + </div> + </div> + <div v-if="showConfig" :class="$style.frame"> + <div :class="$style.frameInner"> + <div>Credit</div> + <div>BGM: @ys@misskey.design</div> + </div> + </div> <div :class="$style.frame"> <div :class="$style.frameInner"> <MkButton @click="restart">Restart</MkButton> @@ -117,7 +131,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onDeactivated, ref, shallowRef } from 'vue'; +import { onDeactivated, ref, shallowRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; @@ -134,6 +148,8 @@ import MkSelect from '@/components/MkSelect.vue'; import { apiUrl } from '@/config.js'; import { $i } from '@/account.js'; import { DropAndFusionGame, Mono } from '@/scripts/drop-and-fusion-engine.js'; +import * as sound from '@/scripts/sound.js'; +import MkRange from '@/components/MkRange.vue'; const containerEl = shallowRef<HTMLElement>(); const canvasEl = shallowRef<HTMLCanvasElement>(); @@ -381,6 +397,8 @@ const gameMode = ref<'normal' | 'square'>('normal'); const gameOver = ref(false); const gameStarted = ref(false); const highScore = ref<number | null>(null); +const showConfig = ref(false); +const bgmVolume = ref(0.1); let game: DropAndFusionGame; let containerElRect: DOMRect | null = null; @@ -493,6 +511,8 @@ function attachGameEvents() { }); } +let bgmNodes: ReturnType<typeof sound.createSourceNode> = null; + async function start() { try { highScore.value = await misskeyApi('i/registry/get', { @@ -516,12 +536,29 @@ async function start() { ), }); attachGameEvents(); - os.promiseDialog(game.load(), () => { + os.promiseDialog(game.load(), async () => { game.start(); gameStarted.value = true; + + if (bgmNodes) { + bgmNodes.soundSource.stop(); + bgmNodes = null; + } + const bgmBuffer = await sound.loadAudio('/client-assets/drop-and-fusion/bgm_1.mp3'); + if (!bgmBuffer) return; + bgmNodes = sound.createSourceNode(bgmBuffer, bgmVolume.value); + if (!bgmNodes) return; + bgmNodes.soundSource.loop = true; + bgmNodes.soundSource.start(); }); } +watch(bgmVolume, (value) => { + if (bgmNodes) { + bgmNodes.gainNode.gain.value = value; + } +}); + function getGameImageDriveFile() { return new Promise<Misskey.entities.DriveFile | null>(res => { const dcanvas = document.createElement('canvas'); diff --git a/packages/frontend/src/scripts/drop-and-fusion-engine.ts b/packages/frontend/src/scripts/drop-and-fusion-engine.ts index 7241525a38..b6e735ddf2 100644 --- a/packages/frontend/src/scripts/drop-and-fusion-engine.ts +++ b/packages/frontend/src/scripts/drop-and-fusion-engine.ts @@ -199,7 +199,7 @@ export class DropAndFusionGame extends EventEmitter<{ } this.latestFusionedAt = now; - // TODO: 単に位置だけでなくそれぞれの動きベクトルも融合する + // TODO: 単に位置だけでなくそれぞれの動きベクトルも融合する? const newX = (bodyA.position.x + bodyB.position.x) / 2; const newY = (bodyA.position.y + bodyB.position.y) / 2; @@ -222,8 +222,9 @@ export class DropAndFusionGame extends EventEmitter<{ const additionalScore = Math.round(currentMono.score * comboBonus); this.score += additionalScore; + // TODO: 効果音再生はコンポーネント側の責務なので移動する const pan = ((newX / this.gameWidth) - 0.5) * 2; - sound.playRaw('syuilo/bubble2', 1, pan, nextMono.sfxPitch); + sound.playUrl('/client-assets/drop-and-fusion/bubble2.mp3', 1, pan, nextMono.sfxPitch); this.emit('monoAdded', nextMono); this.emit('fusioned', newX, newY, additionalScore); @@ -234,7 +235,7 @@ export class DropAndFusionGame extends EventEmitter<{ // Matter.Composite.add(world, body); // bodies.push(body); //} - //sound.playRaw({ + //sound.playUrl({ // type: 'syuilo/bubble2', // volume: 1, //}); @@ -321,10 +322,11 @@ export class DropAndFusionGame extends EventEmitter<{ } else { const energy = pairs.collision.depth; if (energy > minCollisionEnergyForSound) { + // TODO: 効果音再生はコンポーネント側の責務なので移動する const vol = (Math.min(maxCollisionEnergyForSound, energy - minCollisionEnergyForSound) / maxCollisionEnergyForSound) / 4; const pan = ((((bodyA.position.x + bodyB.position.x) / 2) / this.gameWidth) - 0.5) * 2; const pitch = soundPitchMin + ((soundPitchMax - soundPitchMin) * (1 - (Math.min(10, energy) / 10))); - sound.playRaw('syuilo/poi1', vol, pan, pitch); + sound.playUrl('/client-assets/drop-and-fusion/poi1.mp3', vol, pan, pitch); } } } @@ -382,8 +384,10 @@ export class DropAndFusionGame extends EventEmitter<{ this.latestDroppedAt = Date.now(); this.emit('dropped'); this.emit('monoAdded', st.mono); + + // TODO: 効果音再生はコンポーネント側の責務なので移動する const pan = ((x / this.gameWidth) - 0.5) * 2; - sound.playRaw('syuilo/poi2', 1, pan); + sound.playUrl('/client-assets/drop-and-fusion/poi2.mp3', 1, pan); } public dispose() { diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts index acde78f5fd..690c342c85 100644 --- a/packages/frontend/src/scripts/sound.ts +++ b/packages/frontend/src/scripts/sound.ts @@ -5,7 +5,6 @@ import type { SoundStore } from '@/store.js'; import { defaultStore } from '@/store.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; let ctx: AudioContext; const cache = new Map<string, AudioBuffer>(); @@ -89,69 +88,35 @@ export type OperationType = typeof operationTypes[number]; /** * 音声を読み込む - * @param soundStore サウンド設定 + * @param url url * @param options `useCache`: デフォルトは`true` 一度再生した音声はキャッシュする */ -export async function loadAudio(soundStore: { - type: Exclude<SoundType, '_driveFile_'>; -} | { - type: '_driveFile_'; - fileId: string; - fileUrl: string; -}, options?: { useCache?: boolean; }) { +export async function loadAudio(url: string, options?: { useCache?: boolean; }) { if (_DEV_) console.log('loading audio. opts:', options); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) { - return; - } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (ctx == null) { ctx = new AudioContext(); } if (options?.useCache ?? true) { - if (soundStore.type === '_driveFile_' && cache.has(soundStore.fileId)) { + if (cache.has(url)) { if (_DEV_) console.log('use cache'); - return cache.get(soundStore.fileId) as AudioBuffer; - } else if (cache.has(soundStore.type)) { - if (_DEV_) console.log('use cache'); - return cache.get(soundStore.type) as AudioBuffer; + return cache.get(url) as AudioBuffer; } } let response: Response; - if (soundStore.type === '_driveFile_') { - try { - response = await fetch(soundStore.fileUrl); - } catch (err) { - try { - // URLが変わっている可能性があるのでドライブ側からURLを取得するフォールバック - const apiRes = await misskeyApi('drive/files/show', { - fileId: soundStore.fileId, - }); - response = await fetch(apiRes.url); - } catch (fbErr) { - // それでも無理なら諦める - return; - } - } - } else { - try { - response = await fetch(`/client-assets/sounds/${soundStore.type}.mp3`); - } catch (err) { - return; - } + try { + response = await fetch(url); + } catch (err) { + return; } const arrayBuffer = await response.arrayBuffer(); const audioBuffer = await ctx.decodeAudioData(arrayBuffer); if (options?.useCache ?? true) { - if (soundStore.type === '_driveFile_') { - cache.set(soundStore.fileId, audioBuffer); - } else { - cache.set(soundStore.type, audioBuffer); - } + cache.set(url, audioBuffer); } return audioBuffer; @@ -180,18 +145,26 @@ export function play(operationType: OperationType) { * @param soundStore サウンド設定 */ export async function playFile(soundStore: SoundStore) { - const buffer = await loadAudio(soundStore); + if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) { + return; + } + const url = soundStore.type === '_driveFile_' ? soundStore.fileUrl : `/client-assets/sounds/${soundStore.type}.mp3`; + const buffer = await loadAudio(url); if (!buffer) return; - createSourceNode(buffer, soundStore.volume)?.start(); + createSourceNode(buffer, soundStore.volume)?.soundSource.start(); } -export async function playRaw(type: Exclude<SoundType, '_driveFile_'>, volume = 1, pan = 0, playbackRate = 1) { - const buffer = await loadAudio({ type }); +export async function playUrl(url: string, volume = 1, pan = 0, playbackRate = 1) { + const buffer = await loadAudio(url); if (!buffer) return; - createSourceNode(buffer, volume, pan, playbackRate)?.start(); + createSourceNode(buffer, volume, pan, playbackRate)?.soundSource.start(); } -export function createSourceNode(buffer: AudioBuffer, volume: number, pan = 0, playbackRate = 1) : AudioBufferSourceNode | null { +export function createSourceNode(buffer: AudioBuffer, volume: number, pan = 0, playbackRate = 1): { + soundSource: AudioBufferSourceNode; + panNode: StereoPannerNode; + gainNode: GainNode; +} | null { const masterVolume = defaultStore.state.sound_masterVolume; if (isMute() || masterVolume === 0 || volume === 0) { return null; @@ -211,7 +184,7 @@ export function createSourceNode(buffer: AudioBuffer, volume: number, pan = 0, p .connect(gainNode) .connect(ctx.destination); - return soundSource; + return { soundSource, panNode, gainNode }; } /** diff --git a/packages/frontend/src/widgets/WidgetJobQueue.vue b/packages/frontend/src/widgets/WidgetJobQueue.vue index 91983d8474..89ad3bf323 100644 --- a/packages/frontend/src/widgets/WidgetJobQueue.vue +++ b/packages/frontend/src/widgets/WidgetJobQueue.vue @@ -104,10 +104,7 @@ const jammedAudioBuffer = ref<AudioBuffer | null>(null); const jammedSoundNodePlaying = ref<boolean>(false); if (defaultStore.state.sound_masterVolume) { - sound.loadAudio({ - type: 'syuilo/queue-jammed', - volume: 1, - }).then(buf => { + sound.loadAudio('/client-assets/sounds/syuilo/queue-jammed.mp3').then(buf => { if (!buf) throw new Error('[WidgetJobQueue] Failed to initialize AudioBuffer'); jammedAudioBuffer.value = buf; }); @@ -126,7 +123,7 @@ const onStats = (stats) => { current[domain].delayed = stats[domain].delayed; if (current[domain].waiting > 0 && widgetProps.sound && jammedAudioBuffer.value && !jammedSoundNodePlaying.value) { - const soundNode = sound.createSourceNode(jammedAudioBuffer.value, 1); + const soundNode = sound.createSourceNode(jammedAudioBuffer.value, 1)?.soundSource; if (soundNode) { jammedSoundNodePlaying.value = true; soundNode.onended = () => jammedSoundNodePlaying.value = false;