enhance(frontend): add game bgm and refactor sound system
This commit is contained in:
parent
145d28a8e4
commit
e9c3fe1228
8 changed files with 74 additions and 63 deletions
BIN
packages/frontend/assets/drop-and-fusion/bgm_1.mp3
Normal file
BIN
packages/frontend/assets/drop-and-fusion/bgm_1.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/drop-and-fusion/bubble2.mp3
Normal file
BIN
packages/frontend/assets/drop-and-fusion/bubble2.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/drop-and-fusion/poi1.mp3
Normal file
BIN
packages/frontend/assets/drop-and-fusion/poi1.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/drop-and-fusion/poi2.mp3
Normal file
BIN
packages/frontend/assets/drop-and-fusion/poi2.mp3
Normal file
Binary file not shown.
|
@ -103,9 +103,23 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
<div :class="[$style.frame]" style="margin-left: auto;">
|
<div :class="[$style.frame]" style="margin-left: auto;">
|
||||||
<div :class="$style.frameInner" style="text-align: center;">
|
<div :class="$style.frameInner" style="text-align: center;">
|
||||||
|
<div @click="showConfig = !showConfig"><i class="ti ti-settings"></i></div>
|
||||||
</div>
|
</div>
|
||||||
</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.frame">
|
||||||
<div :class="$style.frameInner">
|
<div :class="$style.frameInner">
|
||||||
<MkButton @click="restart">Restart</MkButton>
|
<MkButton @click="restart">Restart</MkButton>
|
||||||
|
@ -117,7 +131,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onDeactivated, ref, shallowRef } from 'vue';
|
import { onDeactivated, ref, shallowRef, watch } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||||
|
@ -134,6 +148,8 @@ import MkSelect from '@/components/MkSelect.vue';
|
||||||
import { apiUrl } from '@/config.js';
|
import { apiUrl } from '@/config.js';
|
||||||
import { $i } from '@/account.js';
|
import { $i } from '@/account.js';
|
||||||
import { DropAndFusionGame, Mono } from '@/scripts/drop-and-fusion-engine.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 containerEl = shallowRef<HTMLElement>();
|
||||||
const canvasEl = shallowRef<HTMLCanvasElement>();
|
const canvasEl = shallowRef<HTMLCanvasElement>();
|
||||||
|
@ -381,6 +397,8 @@ const gameMode = ref<'normal' | 'square'>('normal');
|
||||||
const gameOver = ref(false);
|
const gameOver = ref(false);
|
||||||
const gameStarted = ref(false);
|
const gameStarted = ref(false);
|
||||||
const highScore = ref<number | null>(null);
|
const highScore = ref<number | null>(null);
|
||||||
|
const showConfig = ref(false);
|
||||||
|
const bgmVolume = ref(0.1);
|
||||||
|
|
||||||
let game: DropAndFusionGame;
|
let game: DropAndFusionGame;
|
||||||
let containerElRect: DOMRect | null = null;
|
let containerElRect: DOMRect | null = null;
|
||||||
|
@ -493,6 +511,8 @@ function attachGameEvents() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let bgmNodes: ReturnType<typeof sound.createSourceNode> = null;
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
try {
|
try {
|
||||||
highScore.value = await misskeyApi('i/registry/get', {
|
highScore.value = await misskeyApi('i/registry/get', {
|
||||||
|
@ -516,12 +536,29 @@ async function start() {
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
attachGameEvents();
|
attachGameEvents();
|
||||||
os.promiseDialog(game.load(), () => {
|
os.promiseDialog(game.load(), async () => {
|
||||||
game.start();
|
game.start();
|
||||||
gameStarted.value = true;
|
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() {
|
function getGameImageDriveFile() {
|
||||||
return new Promise<Misskey.entities.DriveFile | null>(res => {
|
return new Promise<Misskey.entities.DriveFile | null>(res => {
|
||||||
const dcanvas = document.createElement('canvas');
|
const dcanvas = document.createElement('canvas');
|
||||||
|
|
|
@ -199,7 +199,7 @@ export class DropAndFusionGame extends EventEmitter<{
|
||||||
}
|
}
|
||||||
this.latestFusionedAt = now;
|
this.latestFusionedAt = now;
|
||||||
|
|
||||||
// TODO: 単に位置だけでなくそれぞれの動きベクトルも融合する
|
// TODO: 単に位置だけでなくそれぞれの動きベクトルも融合する?
|
||||||
const newX = (bodyA.position.x + bodyB.position.x) / 2;
|
const newX = (bodyA.position.x + bodyB.position.x) / 2;
|
||||||
const newY = (bodyA.position.y + bodyB.position.y) / 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);
|
const additionalScore = Math.round(currentMono.score * comboBonus);
|
||||||
this.score += additionalScore;
|
this.score += additionalScore;
|
||||||
|
|
||||||
|
// TODO: 効果音再生はコンポーネント側の責務なので移動する
|
||||||
const pan = ((newX / this.gameWidth) - 0.5) * 2;
|
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('monoAdded', nextMono);
|
||||||
this.emit('fusioned', newX, newY, additionalScore);
|
this.emit('fusioned', newX, newY, additionalScore);
|
||||||
|
@ -234,7 +235,7 @@ export class DropAndFusionGame extends EventEmitter<{
|
||||||
// Matter.Composite.add(world, body);
|
// Matter.Composite.add(world, body);
|
||||||
// bodies.push(body);
|
// bodies.push(body);
|
||||||
//}
|
//}
|
||||||
//sound.playRaw({
|
//sound.playUrl({
|
||||||
// type: 'syuilo/bubble2',
|
// type: 'syuilo/bubble2',
|
||||||
// volume: 1,
|
// volume: 1,
|
||||||
//});
|
//});
|
||||||
|
@ -321,10 +322,11 @@ export class DropAndFusionGame extends EventEmitter<{
|
||||||
} else {
|
} else {
|
||||||
const energy = pairs.collision.depth;
|
const energy = pairs.collision.depth;
|
||||||
if (energy > minCollisionEnergyForSound) {
|
if (energy > minCollisionEnergyForSound) {
|
||||||
|
// TODO: 効果音再生はコンポーネント側の責務なので移動する
|
||||||
const vol = (Math.min(maxCollisionEnergyForSound, energy - minCollisionEnergyForSound) / maxCollisionEnergyForSound) / 4;
|
const vol = (Math.min(maxCollisionEnergyForSound, energy - minCollisionEnergyForSound) / maxCollisionEnergyForSound) / 4;
|
||||||
const pan = ((((bodyA.position.x + bodyB.position.x) / 2) / this.gameWidth) - 0.5) * 2;
|
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)));
|
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.latestDroppedAt = Date.now();
|
||||||
this.emit('dropped');
|
this.emit('dropped');
|
||||||
this.emit('monoAdded', st.mono);
|
this.emit('monoAdded', st.mono);
|
||||||
|
|
||||||
|
// TODO: 効果音再生はコンポーネント側の責務なので移動する
|
||||||
const pan = ((x / this.gameWidth) - 0.5) * 2;
|
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() {
|
public dispose() {
|
||||||
|
|
|
@ -5,7 +5,6 @@
|
||||||
|
|
||||||
import type { SoundStore } from '@/store.js';
|
import type { SoundStore } from '@/store.js';
|
||||||
import { defaultStore } from '@/store.js';
|
import { defaultStore } from '@/store.js';
|
||||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
|
||||||
|
|
||||||
let ctx: AudioContext;
|
let ctx: AudioContext;
|
||||||
const cache = new Map<string, AudioBuffer>();
|
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` 一度再生した音声はキャッシュする
|
* @param options `useCache`: デフォルトは`true` 一度再生した音声はキャッシュする
|
||||||
*/
|
*/
|
||||||
export async function loadAudio(soundStore: {
|
export async function loadAudio(url: string, options?: { useCache?: boolean; }) {
|
||||||
type: Exclude<SoundType, '_driveFile_'>;
|
|
||||||
} | {
|
|
||||||
type: '_driveFile_';
|
|
||||||
fileId: string;
|
|
||||||
fileUrl: string;
|
|
||||||
}, options?: { useCache?: boolean; }) {
|
|
||||||
if (_DEV_) console.log('loading audio. opts:', options);
|
if (_DEV_) console.log('loading audio. opts:', options);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// 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) {
|
if (ctx == null) {
|
||||||
ctx = new AudioContext();
|
ctx = new AudioContext();
|
||||||
}
|
}
|
||||||
if (options?.useCache ?? true) {
|
if (options?.useCache ?? true) {
|
||||||
if (soundStore.type === '_driveFile_' && cache.has(soundStore.fileId)) {
|
if (cache.has(url)) {
|
||||||
if (_DEV_) console.log('use cache');
|
if (_DEV_) console.log('use cache');
|
||||||
return cache.get(soundStore.fileId) as AudioBuffer;
|
return cache.get(url) as AudioBuffer;
|
||||||
} else if (cache.has(soundStore.type)) {
|
|
||||||
if (_DEV_) console.log('use cache');
|
|
||||||
return cache.get(soundStore.type) as AudioBuffer;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
|
|
||||||
if (soundStore.type === '_driveFile_') {
|
|
||||||
try {
|
try {
|
||||||
response = await fetch(soundStore.fileUrl);
|
response = await fetch(url);
|
||||||
} 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) {
|
} catch (err) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
|
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
|
||||||
|
|
||||||
if (options?.useCache ?? true) {
|
if (options?.useCache ?? true) {
|
||||||
if (soundStore.type === '_driveFile_') {
|
cache.set(url, audioBuffer);
|
||||||
cache.set(soundStore.fileId, audioBuffer);
|
|
||||||
} else {
|
|
||||||
cache.set(soundStore.type, audioBuffer);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return audioBuffer;
|
return audioBuffer;
|
||||||
|
@ -180,18 +145,26 @@ export function play(operationType: OperationType) {
|
||||||
* @param soundStore サウンド設定
|
* @param soundStore サウンド設定
|
||||||
*/
|
*/
|
||||||
export async function playFile(soundStore: 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;
|
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) {
|
export async function playUrl(url: string, volume = 1, pan = 0, playbackRate = 1) {
|
||||||
const buffer = await loadAudio({ type });
|
const buffer = await loadAudio(url);
|
||||||
if (!buffer) return;
|
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;
|
const masterVolume = defaultStore.state.sound_masterVolume;
|
||||||
if (isMute() || masterVolume === 0 || volume === 0) {
|
if (isMute() || masterVolume === 0 || volume === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -211,7 +184,7 @@ export function createSourceNode(buffer: AudioBuffer, volume: number, pan = 0, p
|
||||||
.connect(gainNode)
|
.connect(gainNode)
|
||||||
.connect(ctx.destination);
|
.connect(ctx.destination);
|
||||||
|
|
||||||
return soundSource;
|
return { soundSource, panNode, gainNode };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -104,10 +104,7 @@ const jammedAudioBuffer = ref<AudioBuffer | null>(null);
|
||||||
const jammedSoundNodePlaying = ref<boolean>(false);
|
const jammedSoundNodePlaying = ref<boolean>(false);
|
||||||
|
|
||||||
if (defaultStore.state.sound_masterVolume) {
|
if (defaultStore.state.sound_masterVolume) {
|
||||||
sound.loadAudio({
|
sound.loadAudio('/client-assets/sounds/syuilo/queue-jammed.mp3').then(buf => {
|
||||||
type: 'syuilo/queue-jammed',
|
|
||||||
volume: 1,
|
|
||||||
}).then(buf => {
|
|
||||||
if (!buf) throw new Error('[WidgetJobQueue] Failed to initialize AudioBuffer');
|
if (!buf) throw new Error('[WidgetJobQueue] Failed to initialize AudioBuffer');
|
||||||
jammedAudioBuffer.value = buf;
|
jammedAudioBuffer.value = buf;
|
||||||
});
|
});
|
||||||
|
@ -126,7 +123,7 @@ const onStats = (stats) => {
|
||||||
current[domain].delayed = stats[domain].delayed;
|
current[domain].delayed = stats[domain].delayed;
|
||||||
|
|
||||||
if (current[domain].waiting > 0 && widgetProps.sound && jammedAudioBuffer.value && !jammedSoundNodePlaying.value) {
|
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) {
|
if (soundNode) {
|
||||||
jammedSoundNodePlaying.value = true;
|
jammedSoundNodePlaying.value = true;
|
||||||
soundNode.onended = () => jammedSoundNodePlaying.value = false;
|
soundNode.onended = () => jammedSoundNodePlaying.value = false;
|
||||||
|
|
Loading…
Reference in a new issue