Skip to content
Snippets Groups Projects
Commit e9c3fe12 authored by syuilo's avatar syuilo
Browse files

enhance(frontend): add game bgm and refactor sound system

parent 145d28a8
No related branches found
No related tags found
No related merge requests found
File added
File added
File added
File added
......@@ -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');
......
......@@ -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() {
......
......@@ -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 (_DEV_) console.log('use cache');
return cache.get(soundStore.fileId) as AudioBuffer;
} else if (cache.has(soundStore.type)) {
if (cache.has(url)) {
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 };
}
/**
......
......@@ -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;
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment