feat(frontend): バブルゲームを追加 (MisskeyIO#336)
|  | @ -1190,6 +1190,7 @@ export interface Locale { | |||
|     "decorate": string; | ||||
|     "addMfmFunction": string; | ||||
|     "enableQuickAddMfmFunction": string; | ||||
|     "bubbleGame": string; | ||||
|     "abuseReportCategory": string; | ||||
|     "selectCategory": string; | ||||
|     "reportComplete": string; | ||||
|  |  | |||
|  | @ -1187,6 +1187,7 @@ seasonalScreenEffect: "季節に応じた画面の演出" | |||
| decorate: "デコる" | ||||
| addMfmFunction: "装飾を追加" | ||||
| enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する" | ||||
| bubbleGame: "バブルゲーム" | ||||
| abuseReportCategory: "通報の種類" | ||||
| selectCategory: "カテゴリを選択" | ||||
| reportComplete: "通報完了" | ||||
|  |  | |||
| After Width: | Height: | Size: 40 KiB | 
| After Width: | Height: | Size: 646 B | 
| After Width: | Height: | Size: 32 KiB | 
| After Width: | Height: | Size: 46 KiB | 
| After Width: | Height: | Size: 36 KiB | 
| After Width: | Height: | Size: 39 KiB | 
| After Width: | Height: | Size: 67 KiB | 
| After Width: | Height: | Size: 66 KiB | 
| After Width: | Height: | Size: 40 KiB | 
| After Width: | Height: | Size: 22 KiB | 
| After Width: | Height: | Size: 28 KiB | 
| After Width: | Height: | Size: 33 KiB | 
| After Width: | Height: | Size: 32 KiB | 
| After Width: | Height: | Size: 32 KiB | 
| After Width: | Height: | Size: 30 KiB | 
| After Width: | Height: | Size: 32 KiB | 
| After Width: | Height: | Size: 31 KiB | 
| After Width: | Height: | Size: 31 KiB | 
| After Width: | Height: | Size: 32 KiB | 
| After Width: | Height: | Size: 32 KiB | 
| After Width: | Height: | Size: 43 KiB | 
| After Width: | Height: | Size: 51 KiB | 
| After Width: | Height: | Size: 47 KiB | 
| After Width: | Height: | Size: 44 KiB | 
|  | @ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 
 | ||||
| <template> | ||||
| <div :class="$style.root" :style="{ zIndex, top: `${y - 64}px`, left: `${x - 64}px` }"> | ||||
| 	<span class="text" :class="{ up }">+1</span> | ||||
| 	<span class="text" :class="{ up }">+{{ value }}</span> | ||||
| </div> | ||||
| </template> | ||||
| 
 | ||||
|  | @ -16,7 +16,9 @@ import * as os from '@/os.js'; | |||
| const props = withDefaults(defineProps<{ | ||||
| 	x: number; | ||||
| 	y: number; | ||||
| 	value?: number; | ||||
| }>(), { | ||||
| 	value: 1, | ||||
| }); | ||||
| 
 | ||||
| const emit = defineEmits<{ | ||||
|  |  | |||
|  | @ -531,6 +531,10 @@ export const routes = [{ | |||
| 	path: '/clicker', | ||||
| 	component: page(() => import('./pages/clicker.vue')), | ||||
| 	loginRequired: true, | ||||
| }, { | ||||
| 	path: '/bubble-game', | ||||
| 	component: page(() => import('./pages/drop-and-fusion.vue')), | ||||
| 	loginRequired: true, | ||||
| }, { | ||||
| 	path: '/timeline', | ||||
| 	component: page(() => import('./pages/timeline.vue')), | ||||
|  |  | |||
|  | @ -154,7 +154,13 @@ export type OperationType = typeof operationTypes[number]; | |||
|  * @param soundStore サウンド設定 | ||||
|  * @param options `useCache`: デフォルトは`true` 一度再生した音声はキャッシュする | ||||
|  */ | ||||
| export async function loadAudio(soundStore: SoundStore, options?: { useCache?: boolean; }) { | ||||
| export async function loadAudio(soundStore: { | ||||
| 	type: Exclude<SoundType, '_driveFile_'>; | ||||
| } | { | ||||
| 	type: '_driveFile_'; | ||||
| 	fileId: string; | ||||
| 	fileUrl: 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_' && (!$i?.policies.canUseDriveFileInSoundSettings || !soundStore.fileUrl))) { | ||||
|  | @ -241,18 +247,31 @@ export async function playFile(soundStore: SoundStore) { | |||
| 	createSourceNode(buffer, soundStore.volume)?.start(); | ||||
| } | ||||
| 
 | ||||
| export function createSourceNode(buffer: AudioBuffer, volume: number) : AudioBufferSourceNode | null { | ||||
| export async function playRaw(type: Exclude<SoundType, '_driveFile_'>, volume = 1, pan = 0, playbackRate = 1) { | ||||
| 	const buffer = await loadAudio({ type }); | ||||
| 	if (!buffer) return; | ||||
| 	createSourceNode(buffer, volume, pan, playbackRate)?.start(); | ||||
| } | ||||
| 
 | ||||
| export function createSourceNode(buffer: AudioBuffer, volume: number, pan = 0, playbackRate = 1) : AudioBufferSourceNode | null { | ||||
| 	const masterVolume = defaultStore.state.sound_masterVolume; | ||||
| 	if (isMute() || masterVolume === 0 || volume === 0) { | ||||
| 		return null; | ||||
| 	} | ||||
| 
 | ||||
| 	const panNode = ctx.createStereoPanner(); | ||||
| 	panNode.pan.value = pan; | ||||
| 
 | ||||
| 	const gainNode = ctx.createGain(); | ||||
| 	gainNode.gain.value = masterVolume * volume; | ||||
| 
 | ||||
| 	const soundSource = ctx.createBufferSource(); | ||||
| 	soundSource.buffer = buffer; | ||||
| 	soundSource.connect(gainNode).connect(ctx.destination); | ||||
| 	soundSource.playbackRate.value = playbackRate; | ||||
| 	soundSource | ||||
| 		.connect(panNode) | ||||
| 		.connect(gainNode) | ||||
| 		.connect(ctx.destination); | ||||
| 
 | ||||
| 	return soundSource; | ||||
| } | ||||
|  |  | |||
|  | @ -27,6 +27,11 @@ function toolsMenuItems(): MenuItem[] { | |||
| 		to: '/clicker', | ||||
| 		text: '🍪👈', | ||||
| 		icon: 'ti ti-cookie', | ||||
| 	}, { | ||||
| 		type: 'link', | ||||
| 		to: '/bubble-game', | ||||
| 		text: i18n.ts.bubbleGame, | ||||
| 		icon: 'ti ti-apple', | ||||
| 	}, ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) ? { | ||||
| 		type: 'link', | ||||
| 		to: '/custom-emojis-manager', | ||||
|  |  | |||