enhance(frontend): バブルゲームの諸々を修正・改良2 (#12948)
* (fix) ゲームが正常に終了するように * (enhance) 効果音の音量を設定可能に * (add) store * (add) スクショにロゴの透かしを入れる * Update packages/frontend/src/pages/drop-and-fusion.vue Co-authored-by: まっちゃとーにゅ <17376330+u1-liquid@users.noreply.github.com> * tweak * tweak * tweak * tweak * Update drop-and-fusion.vue * tweak * tweak --------- Co-authored-by: syuilo <Syuilotan@yahoo.co.jp> Co-authored-by: まっちゃとーにゅ <17376330+u1-liquid@users.noreply.github.com>
This commit is contained in:
		
							parent
							
								
									34088ecd27
								
							
						
					
					
						commit
						0d7f9308cc
					
				
					 18 changed files with 311 additions and 130 deletions
				
			
		
							
								
								
									
										2
									
								
								locales/index.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								locales/index.d.ts
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -1193,6 +1193,8 @@ export interface Locale {
 | 
			
		|||
    "addMfmFunction": string;
 | 
			
		||||
    "enableQuickAddMfmFunction": string;
 | 
			
		||||
    "bubbleGame": string;
 | 
			
		||||
    "sfx": string;
 | 
			
		||||
    "soundWillBePlayed": string;
 | 
			
		||||
    "_announcement": {
 | 
			
		||||
        "forExistingUsers": string;
 | 
			
		||||
        "forExistingUsersDescription": string;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1190,6 +1190,8 @@ decorate: "デコる"
 | 
			
		|||
addMfmFunction: "装飾を追加"
 | 
			
		||||
enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する"
 | 
			
		||||
bubbleGame: "バブルゲーム"
 | 
			
		||||
sfx: "効果音"
 | 
			
		||||
soundWillBePlayed: "サウンドが再生されます"
 | 
			
		||||
 | 
			
		||||
_announcement:
 | 
			
		||||
  forExistingUsers: "既存ユーザーのみ"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										
											BIN
										
									
								
								packages/frontend/assets/drop-and-fusion/hold.mp3
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								packages/frontend/assets/drop-and-fusion/hold.mp3
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
						 | 
				
			
			@ -271,7 +271,7 @@ export async function mainBoot() {
 | 
			
		|||
 | 
			
		||||
		main.on('unreadAntenna', () => {
 | 
			
		||||
			updateAccount({ hasUnreadAntenna: true });
 | 
			
		||||
			sound.play('antenna');
 | 
			
		||||
			sound.playMisskeySfx('antenna');
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		main.on('readAllAnnouncements', () => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -345,7 +345,7 @@ function react(viaKeyboard = false): void {
 | 
			
		|||
	pleaseLogin();
 | 
			
		||||
	showMovedDialog();
 | 
			
		||||
	if (appearNote.value.reactionAcceptance === 'likeOnly') {
 | 
			
		||||
		sound.play('reaction');
 | 
			
		||||
		sound.playMisskeySfx('reaction');
 | 
			
		||||
 | 
			
		||||
		if (props.mock) {
 | 
			
		||||
			return;
 | 
			
		||||
| 
						 | 
				
			
			@ -365,7 +365,7 @@ function react(viaKeyboard = false): void {
 | 
			
		|||
	} else {
 | 
			
		||||
		blur();
 | 
			
		||||
		reactionPicker.show(reactButton.value, reaction => {
 | 
			
		||||
			sound.play('reaction');
 | 
			
		||||
			sound.playMisskeySfx('reaction');
 | 
			
		||||
 | 
			
		||||
			if (props.mock) {
 | 
			
		||||
				emit('reaction', reaction);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -370,7 +370,7 @@ function react(viaKeyboard = false): void {
 | 
			
		|||
	pleaseLogin();
 | 
			
		||||
	showMovedDialog();
 | 
			
		||||
	if (appearNote.value.reactionAcceptance === 'likeOnly') {
 | 
			
		||||
		sound.play('reaction');
 | 
			
		||||
		sound.playMisskeySfx('reaction');
 | 
			
		||||
 | 
			
		||||
		misskeyApi('notes/reactions/create', {
 | 
			
		||||
			noteId: appearNote.value.id,
 | 
			
		||||
| 
						 | 
				
			
			@ -386,7 +386,7 @@ function react(viaKeyboard = false): void {
 | 
			
		|||
	} else {
 | 
			
		||||
		blur();
 | 
			
		||||
		reactionPicker.show(reactButton.value, reaction => {
 | 
			
		||||
			sound.play('reaction');
 | 
			
		||||
			sound.playMisskeySfx('reaction');
 | 
			
		||||
 | 
			
		||||
			misskeyApi('notes/reactions/create', {
 | 
			
		||||
				noteId: appearNote.value.id,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -43,6 +43,7 @@ const props = withDefaults(defineProps<{
 | 
			
		|||
 | 
			
		||||
const emit = defineEmits<{
 | 
			
		||||
	(ev: 'update:modelValue', value: number): void;
 | 
			
		||||
	(ev: 'dragEnded', value: number): void;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
const containerEl = shallowRef<HTMLElement>();
 | 
			
		||||
| 
						 | 
				
			
			@ -143,6 +144,7 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => {
 | 
			
		|||
		// 値が変わってたら通知
 | 
			
		||||
		if (beforeValue !== finalValue.value) {
 | 
			
		||||
			emit('update:modelValue', finalValue.value);
 | 
			
		||||
			emit('dragEnded', finalValue.value);
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -62,7 +62,7 @@ async function toggleReaction() {
 | 
			
		|||
		if (confirm.canceled) return;
 | 
			
		||||
 | 
			
		||||
		if (oldReaction !== props.reaction) {
 | 
			
		||||
			sound.play('reaction');
 | 
			
		||||
			sound.playMisskeySfx('reaction');
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (mock) {
 | 
			
		||||
| 
						 | 
				
			
			@ -81,7 +81,7 @@ async function toggleReaction() {
 | 
			
		|||
			}
 | 
			
		||||
		});
 | 
			
		||||
	} else {
 | 
			
		||||
		sound.play('reaction');
 | 
			
		||||
		sound.playMisskeySfx('reaction');
 | 
			
		||||
 | 
			
		||||
		if (mock) {
 | 
			
		||||
			emit('reactionToggled', props.reaction, (props.count + 1));
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -81,7 +81,7 @@ function prepend(note) {
 | 
			
		|||
	emit('note');
 | 
			
		||||
 | 
			
		||||
	if (props.sound) {
 | 
			
		||||
		sound.play($i && (note.userId === $i.id) ? 'noteMy' : 'note');
 | 
			
		||||
		sound.playMisskeySfx($i && (note.userId === $i.id) ? 'noteMy' : 'note');
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -91,7 +91,7 @@ function onClick(ev: MouseEvent) {
 | 
			
		|||
			icon: 'ti ti-plus',
 | 
			
		||||
			action: () => {
 | 
			
		||||
				react(`:${props.name}:`);
 | 
			
		||||
				sound.play('reaction');
 | 
			
		||||
				sound.playMisskeySfx('reaction');
 | 
			
		||||
			},
 | 
			
		||||
		}] : [])], ev.currentTarget ?? ev.target);
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -55,7 +55,7 @@ function onClick(ev: MouseEvent) {
 | 
			
		|||
			icon: 'ti ti-plus',
 | 
			
		||||
			action: () => {
 | 
			
		||||
				react(props.emoji);
 | 
			
		||||
				sound.play('reaction');
 | 
			
		||||
				sound.playMisskeySfx('reaction');
 | 
			
		||||
			},
 | 
			
		||||
		}] : [])], ev.currentTarget ?? ev.target);
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -24,20 +24,31 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
							<MkButton primary gradate large rounded inline @click="start">{{ i18n.ts.start }}</MkButton>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div :class="$style.frameInner">
 | 
			
		||||
						<div class="_gaps" style="padding: 16px;">
 | 
			
		||||
							<div style="font-size: 90%;"><i class="ti ti-music"></i> {{ i18n.ts.soundWillBePlayed }}</div>
 | 
			
		||||
							<MkSwitch v-model="mute">
 | 
			
		||||
								<template #label>{{ i18n.ts.mute }}</template>
 | 
			
		||||
							</MkSwitch>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
		<div v-show="gameStarted" class="_gaps_s" :class="$style.root">
 | 
			
		||||
			<div style="display: flex;">
 | 
			
		||||
				<div :class="$style.frame" style="flex: 1; margin-right: 10px;">
 | 
			
		||||
			<div :class="$style.header">
 | 
			
		||||
				<div :class="[$style.frame, $style.headerTitle]">
 | 
			
		||||
					<div :class="$style.frameInner">
 | 
			
		||||
						<b>BUBBLE GAME</b>
 | 
			
		||||
						<div>- {{ gameMode }} -</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div :class="[$style.frame, $style.stock]" style="margin-left: auto;">
 | 
			
		||||
					<div :class="$style.frameInner" style="text-align: center;">
 | 
			
		||||
						NEXT >>>
 | 
			
		||||
				<div :class="[$style.frame, $style.frameH]">
 | 
			
		||||
					<div :class="$style.frameInner">
 | 
			
		||||
						<MkButton inline small @click="hold">HOLD</MkButton>
 | 
			
		||||
						<img v-if="holdingStock" :src="game.getTextureImageUrl(holdingStock.mono)" style="width: 32px; margin-left: 8px; vertical-align: bottom;"/>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div :class="[$style.frameInner, $style.stock]" style="text-align: center;">
 | 
			
		||||
						<TransitionGroup
 | 
			
		||||
							:enterActiveClass="$style.transition_stock_enterActive"
 | 
			
		||||
							:leaveActiveClass="$style.transition_stock_leaveActive"
 | 
			
		||||
| 
						 | 
				
			
			@ -45,28 +56,26 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
							:leaveToClass="$style.transition_stock_leaveTo"
 | 
			
		||||
							:moveClass="$style.transition_stock_move"
 | 
			
		||||
						>
 | 
			
		||||
							<div v-for="x in stock" :key="x.id" style="display: inline-block;">
 | 
			
		||||
								<img :src="game.getTextureImageUrl(x.mono)" style="width: 32px;"/>
 | 
			
		||||
							</div>
 | 
			
		||||
							<img v-for="x in stock" :key="x.id" :src="game.getTextureImageUrl(x.mono)" style="width: 32px; vertical-align: bottom;"/>
 | 
			
		||||
						</TransitionGroup>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div :class="$style.main" @contextmenu.stop.prevent>
 | 
			
		||||
				<div ref="containerEl" :class="[$style.container, { [$style.gameOver]: gameOver }]" @click.stop.prevent="onClick" @touchmove.stop.prevent="onTouchmove" @touchend="onTouchend" @mousemove="onMousemove">
 | 
			
		||||
					<img v-if="defaultStore.state.darkMode" src="/client-assets/drop-and-fusion/frame-dark.svg" :class="$style.mainFrameImg"/>
 | 
			
		||||
					<img v-else src="/client-assets/drop-and-fusion/frame-light.svg" :class="$style.mainFrameImg"/>
 | 
			
		||||
					<canvas ref="canvasEl" :class="$style.canvas"/>
 | 
			
		||||
					<Transition
 | 
			
		||||
						:enterActiveClass="$style.transition_combo_enterActive"
 | 
			
		||||
						:leaveActiveClass="$style.transition_combo_leaveActive"
 | 
			
		||||
						:enterFromClass="$style.transition_combo_enterFrom"
 | 
			
		||||
						:leaveToClass="$style.transition_combo_leaveTo"
 | 
			
		||||
						:moveClass="$style.transition_combo_move"
 | 
			
		||||
					>
 | 
			
		||||
						<div v-show="combo > 1" :class="$style.combo" :style="{ fontSize: `${100 + ((comboPrev - 2) * 15)}%` }">{{ comboPrev }} Chain!</div>
 | 
			
		||||
					</Transition>
 | 
			
		||||
					<img v-if="currentPick" src="/client-assets/drop-and-fusion/dropper.png" :class="$style.dropper" :style="{ left: dropperX + 'px' }"/>
 | 
			
		||||
			<div ref="containerEl" :class="[$style.gameContainer, { [$style.gameOver]: gameOver }]" @contextmenu.stop.prevent @click.stop.prevent="onClick" @touchmove.stop.prevent="onTouchmove" @touchend="onTouchend" @mousemove="onMousemove">
 | 
			
		||||
				<img v-if="defaultStore.state.darkMode" src="/client-assets/drop-and-fusion/frame-dark.svg" :class="$style.mainFrameImg"/>
 | 
			
		||||
				<img v-else src="/client-assets/drop-and-fusion/frame-light.svg" :class="$style.mainFrameImg"/>
 | 
			
		||||
				<canvas ref="canvasEl" :class="$style.canvas"/>
 | 
			
		||||
				<Transition
 | 
			
		||||
					:enterActiveClass="$style.transition_combo_enterActive"
 | 
			
		||||
					:leaveActiveClass="$style.transition_combo_leaveActive"
 | 
			
		||||
					:enterFromClass="$style.transition_combo_enterFrom"
 | 
			
		||||
					:leaveToClass="$style.transition_combo_leaveTo"
 | 
			
		||||
					:moveClass="$style.transition_combo_move"
 | 
			
		||||
				>
 | 
			
		||||
					<div v-show="combo > 1" :class="$style.combo" :style="{ fontSize: `${100 + ((comboPrev - 2) * 15)}%` }">{{ comboPrev }} Chain!</div>
 | 
			
		||||
				</Transition>
 | 
			
		||||
				<div :class="$style.dropperContainer" :style="{ left: dropperX + 'px' }">
 | 
			
		||||
					<!--<img v-if="currentPick" src="/client-assets/drop-and-fusion/dropper.png" :class="$style.dropper" :style="{ left: dropperX + 'px' }"/>-->
 | 
			
		||||
					<Transition
 | 
			
		||||
						:enterActiveClass="$style.transition_picked_enterActive"
 | 
			
		||||
						:leaveActiveClass="$style.transition_picked_leaveActive"
 | 
			
		||||
| 
						 | 
				
			
			@ -75,21 +84,21 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
						:moveClass="$style.transition_picked_move"
 | 
			
		||||
						mode="out-in"
 | 
			
		||||
					>
 | 
			
		||||
						<img v-if="currentPick" :key="currentPick.id" :src="game.getTextureImageUrl(currentPick.mono)" :class="$style.currentMono" :style="{ top: -(currentPick?.mono.size / 2) + 'px', left: (dropperX - (currentPick?.mono.size / 2)) + 'px', width: `${currentPick?.mono.size}px` }"/>
 | 
			
		||||
						<img v-if="currentPick" :key="currentPick.id" :src="game.getTextureImageUrl(currentPick.mono)" :class="$style.currentMono" :style="{ marginBottom: -((currentPick?.mono.size * viewScale) / 2) + 'px', left: -((currentPick?.mono.size * viewScale) / 2) + 'px', width: `${currentPick?.mono.size * viewScale}px` }"/>
 | 
			
		||||
					</Transition>
 | 
			
		||||
					<template v-if="dropReady && currentPick">
 | 
			
		||||
						<img src="/client-assets/drop-and-fusion/drop-arrow.svg" :class="$style.currentMonoArrow" :style="{ top: (currentPick.mono.size / 2) + 10 + 'px', left: (dropperX - 10) + 'px', width: `20px` }"/>
 | 
			
		||||
						<div :class="$style.dropGuide" :style="{ left: (dropperX - 2) + 'px' }"/>
 | 
			
		||||
						<img src="/client-assets/drop-and-fusion/drop-arrow.svg" :class="$style.currentMonoArrow"/>
 | 
			
		||||
						<div :class="$style.dropGuide"/>
 | 
			
		||||
					</template>
 | 
			
		||||
					<div v-if="gameOver" :class="$style.gameOverLabel">
 | 
			
		||||
						<div class="_gaps_s">
 | 
			
		||||
							<img src="/client-assets/drop-and-fusion/gameover.png" style="width: 200px; max-width: 100%; display: block; margin: auto; margin-bottom: -5px;"/>
 | 
			
		||||
							<div>SCORE: <MkNumber :value="score"/></div>
 | 
			
		||||
							<div>MAX CHAIN: <MkNumber :value="maxCombo"/></div>
 | 
			
		||||
							<div class="_buttonsCenter">
 | 
			
		||||
								<MkButton primary rounded @click="restart">Restart</MkButton>
 | 
			
		||||
								<MkButton primary rounded @click="share">Share</MkButton>
 | 
			
		||||
							</div>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div v-if="gameOver" :class="$style.gameOverLabel">
 | 
			
		||||
					<div class="_gaps_s">
 | 
			
		||||
						<img src="/client-assets/drop-and-fusion/gameover.png" style="width: 200px; max-width: 100%; display: block; margin: auto; margin-bottom: -5px;"/>
 | 
			
		||||
						<div>SCORE: <MkNumber :value="score"/></div>
 | 
			
		||||
						<div>MAX CHAIN: <MkNumber :value="maxCombo"/></div>
 | 
			
		||||
						<div class="_buttonsCenter">
 | 
			
		||||
							<MkButton primary rounded @click="restart">Restart</MkButton>
 | 
			
		||||
							<MkButton primary rounded @click="share">Share</MkButton>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -109,15 +118,23 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
			</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 class="_gaps">
 | 
			
		||||
						<MkRange v-model="bgmVolume" :min="0" :max="1" :step="0.01" :textConverter="(v) => `${Math.floor(v * 100)}%`" :continuousUpdate="true" @dragEnded="(v) => updateSettings('bgmVolume', v)">
 | 
			
		||||
							<template #label>BGM {{ i18n.ts.volume }}</template>
 | 
			
		||||
						</MkRange>
 | 
			
		||||
						<MkRange v-model="sfxVolume" :min="0" :max="1" :step="0.01" :textConverter="(v) => `${Math.floor(v * 100)}%`" :continuousUpdate="true" @dragEnded="(v) => updateSettings('sfxVolume', v)">
 | 
			
		||||
							<template #label>{{ i18n.ts.sfx }} {{ i18n.ts.volume }}</template>
 | 
			
		||||
						</MkRange>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div v-if="showConfig" :class="$style.frame">
 | 
			
		||||
				<div :class="$style.frameInner">
 | 
			
		||||
					<div>Credit</div>
 | 
			
		||||
					<div>BGM: @ys@misskey.design</div>
 | 
			
		||||
					<div class="_gaps_s">
 | 
			
		||||
						<div><b>Credit</b></div>
 | 
			
		||||
						<div>
 | 
			
		||||
							<div>Ai-chan illustration: @poteriri@misskey.io</div>
 | 
			
		||||
							<div>BGM: @ys@misskey.design</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div :class="$style.frame">
 | 
			
		||||
| 
						 | 
				
			
			@ -150,10 +167,7 @@ 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>();
 | 
			
		||||
const dropperX = ref(0);
 | 
			
		||||
import MkSwitch from '@/components/MkSwitch.vue';
 | 
			
		||||
 | 
			
		||||
const NORMAL_BASE_SIZE = 30;
 | 
			
		||||
const NORAML_MONOS: Mono[] = [{
 | 
			
		||||
| 
						 | 
				
			
			@ -384,10 +398,16 @@ const SQUARE_MONOS: Mono[] = [{
 | 
			
		|||
const GAME_WIDTH = 450;
 | 
			
		||||
const GAME_HEIGHT = 600;
 | 
			
		||||
 | 
			
		||||
let viewScaleX = 1;
 | 
			
		||||
let viewScaleY = 1;
 | 
			
		||||
let viewScale = 1;
 | 
			
		||||
let game: DropAndFusionGame;
 | 
			
		||||
let containerElRect: DOMRect | null = null;
 | 
			
		||||
 | 
			
		||||
const containerEl = shallowRef<HTMLElement>();
 | 
			
		||||
const canvasEl = shallowRef<HTMLCanvasElement>();
 | 
			
		||||
const dropperX = ref(0);
 | 
			
		||||
const currentPick = shallowRef<{ id: string; mono: Mono } | null>(null);
 | 
			
		||||
const stock = shallowRef<{ id: string; mono: Mono }[]>([]);
 | 
			
		||||
const holdingStock = shallowRef<{ id: string; mono: Mono } | null>(null);
 | 
			
		||||
const score = ref(0);
 | 
			
		||||
const combo = ref(0);
 | 
			
		||||
const comboPrev = ref(0);
 | 
			
		||||
| 
						 | 
				
			
			@ -398,20 +418,19 @@ 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;
 | 
			
		||||
const mute = ref(false);
 | 
			
		||||
const bgmVolume = ref(defaultStore.state.dropAndFusion.bgmVolume);
 | 
			
		||||
const sfxVolume = ref(defaultStore.state.dropAndFusion.sfxVolume);
 | 
			
		||||
 | 
			
		||||
function onClick(ev: MouseEvent) {
 | 
			
		||||
	if (!containerElRect) return;
 | 
			
		||||
	const x = (ev.clientX - containerElRect.left) / viewScaleX;
 | 
			
		||||
	const x = (ev.clientX - containerElRect.left) / viewScale;
 | 
			
		||||
	game.drop(x);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function onTouchend(ev: TouchEvent) {
 | 
			
		||||
	if (!containerElRect) return;
 | 
			
		||||
	const x = (ev.changedTouches[0].clientX - containerElRect.left) / viewScaleX;
 | 
			
		||||
	const x = (ev.changedTouches[0].clientX - containerElRect.left) / viewScale;
 | 
			
		||||
	game.drop(x);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -431,6 +450,10 @@ function moveDropper(rect: DOMRect, x: number) {
 | 
			
		|||
	dropperX.value = Math.min(rect.width * ((GAME_WIDTH - game.PLAYAREA_MARGIN) / GAME_WIDTH), Math.max(rect.width * (game.PLAYAREA_MARGIN / GAME_WIDTH), x));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function hold() {
 | 
			
		||||
	game.hold();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function restart() {
 | 
			
		||||
	game.dispose();
 | 
			
		||||
	gameOver.value = false;
 | 
			
		||||
| 
						 | 
				
			
			@ -440,6 +463,7 @@ function restart() {
 | 
			
		|||
	score.value = 0;
 | 
			
		||||
	combo.value = 0;
 | 
			
		||||
	comboPrev.value = 0;
 | 
			
		||||
	bgmNodes?.soundSource.stop();
 | 
			
		||||
	gameStarted.value = false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -463,6 +487,10 @@ function attachGameEvents() {
 | 
			
		|||
		stock.value = JSON.parse(JSON.stringify(value.slice(1)));
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	game.addListener('changeHolding', value => {
 | 
			
		||||
		holdingStock.value = value;
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	game.addListener('dropped', () => {
 | 
			
		||||
		dropReady.value = false;
 | 
			
		||||
		window.setTimeout(() => {
 | 
			
		||||
| 
						 | 
				
			
			@ -476,8 +504,8 @@ function attachGameEvents() {
 | 
			
		|||
		if (!canvasEl.value) return;
 | 
			
		||||
 | 
			
		||||
		const rect = canvasEl.value.getBoundingClientRect();
 | 
			
		||||
		const domX = rect.left + (x * viewScaleX);
 | 
			
		||||
		const domY = rect.top + (y * viewScaleY);
 | 
			
		||||
		const domX = rect.left + (x * viewScale);
 | 
			
		||||
		const domY = rect.top + (y * viewScale);
 | 
			
		||||
		os.popup(MkRippleEffect, { x: domX, y: domY }, {}, 'end');
 | 
			
		||||
		os.popup(MkPlusOneEffect, { x: domX, y: domY, value: scoreDelta }, {}, 'end');
 | 
			
		||||
	});
 | 
			
		||||
| 
						 | 
				
			
			@ -511,7 +539,7 @@ function attachGameEvents() {
 | 
			
		|||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
let bgmNodes: ReturnType<typeof sound.createSourceNode> = null;
 | 
			
		||||
let bgmNodes: ReturnType<typeof sound.createSourceNode> | null = null;
 | 
			
		||||
 | 
			
		||||
async function start() {
 | 
			
		||||
	try {
 | 
			
		||||
| 
						 | 
				
			
			@ -527,6 +555,7 @@ async function start() {
 | 
			
		|||
		width: GAME_WIDTH,
 | 
			
		||||
		height: GAME_HEIGHT,
 | 
			
		||||
		canvas: canvasEl.value!,
 | 
			
		||||
		sfxVolume: mute.value ? 0 : sfxVolume.value,
 | 
			
		||||
		...(
 | 
			
		||||
			gameMode.value === 'normal' ? {
 | 
			
		||||
				monoDefinitions: NORAML_MONOS,
 | 
			
		||||
| 
						 | 
				
			
			@ -546,19 +575,50 @@ async function start() {
 | 
			
		|||
		}
 | 
			
		||||
		const bgmBuffer = await sound.loadAudio('/client-assets/drop-and-fusion/bgm_1.mp3');
 | 
			
		||||
		if (!bgmBuffer) return;
 | 
			
		||||
		bgmNodes = sound.createSourceNode(bgmBuffer, bgmVolume.value);
 | 
			
		||||
		bgmNodes = sound.createSourceNode(bgmBuffer, {
 | 
			
		||||
			volume: mute.value ? 0 : bgmVolume.value,
 | 
			
		||||
		});
 | 
			
		||||
		if (!bgmNodes) return;
 | 
			
		||||
		bgmNodes.soundSource.loop = true;
 | 
			
		||||
		bgmNodes.soundSource.start();
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
watch(bgmVolume, (value) => {
 | 
			
		||||
watch(bgmVolume, (newValue, oldValue) => {
 | 
			
		||||
	if (bgmNodes) {
 | 
			
		||||
		bgmNodes.gainNode.gain.value = value;
 | 
			
		||||
		bgmNodes.gainNode.gain.value = mute.value ? 0 : newValue;
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
watch(sfxVolume, (newValue, oldValue) => {
 | 
			
		||||
	// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
 | 
			
		||||
	if (game) {
 | 
			
		||||
		game.setSfxVolume(mute.value ? 0 : newValue);
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function updateSettings<
 | 
			
		||||
	K extends keyof typeof defaultStore.state.dropAndFusion,
 | 
			
		||||
	V extends typeof defaultStore.state.dropAndFusion[K],
 | 
			
		||||
>(key: K, value: V) {
 | 
			
		||||
	const changes: { [P in K]?: V } = {};
 | 
			
		||||
	changes[key] = value;
 | 
			
		||||
	defaultStore.set('dropAndFusion', {
 | 
			
		||||
		...defaultStore.state.dropAndFusion,
 | 
			
		||||
		...changes,
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function loadImage(url: string) {
 | 
			
		||||
	return new Promise<HTMLImageElement>(res => {
 | 
			
		||||
		const img = new Image();
 | 
			
		||||
		img.src = url;
 | 
			
		||||
		img.addEventListener('load', () => {
 | 
			
		||||
			res(img);
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getGameImageDriveFile() {
 | 
			
		||||
	return new Promise<Misskey.entities.DriveFile | null>(res => {
 | 
			
		||||
		const dcanvas = document.createElement('canvas');
 | 
			
		||||
| 
						 | 
				
			
			@ -566,13 +626,18 @@ function getGameImageDriveFile() {
 | 
			
		|||
		dcanvas.height = GAME_HEIGHT;
 | 
			
		||||
		const ctx = dcanvas.getContext('2d');
 | 
			
		||||
		if (!ctx || !canvasEl.value) return res(null);
 | 
			
		||||
		const dimage = new Image();
 | 
			
		||||
		dimage.src = '/client-assets/drop-and-fusion/frame-light.svg';
 | 
			
		||||
		dimage.addEventListener('load', () => {
 | 
			
		||||
		Promise.all([
 | 
			
		||||
			loadImage('/client-assets/drop-and-fusion/frame-light.svg'),
 | 
			
		||||
			loadImage('/client-assets/drop-and-fusion/logo.png'),
 | 
			
		||||
		]).then((images) => {
 | 
			
		||||
			const [frame, logo] = images;
 | 
			
		||||
			ctx.fillStyle = '#fff';
 | 
			
		||||
			ctx.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
 | 
			
		||||
			ctx.drawImage(dimage, 0, 0, GAME_WIDTH, GAME_HEIGHT);
 | 
			
		||||
			ctx.drawImage(frame, 0, 0, GAME_WIDTH, GAME_HEIGHT);
 | 
			
		||||
			ctx.drawImage(canvasEl.value!, 0, 0, GAME_WIDTH, GAME_HEIGHT);
 | 
			
		||||
			ctx.globalAlpha = 0.7;
 | 
			
		||||
			ctx.drawImage(logo, GAME_WIDTH * 0.55, 6, GAME_WIDTH * 0.45, GAME_WIDTH * 0.45 * (logo.height / logo.width));
 | 
			
		||||
			ctx.globalAlpha = 1;
 | 
			
		||||
 | 
			
		||||
			dcanvas.toBlob(blob => {
 | 
			
		||||
				if (!blob) return res(null);
 | 
			
		||||
| 
						 | 
				
			
			@ -610,22 +675,22 @@ async function share() {
 | 
			
		|||
	os.post({
 | 
			
		||||
		initialText: `#BubbleGame
 | 
			
		||||
MODE: ${gameMode.value}
 | 
			
		||||
SCORE: ${score.value} (MAX CHAIN: ${maxCombo.value})})`,
 | 
			
		||||
SCORE: ${score.value} (MAX CHAIN: ${maxCombo.value})`,
 | 
			
		||||
		initialFiles: [file],
 | 
			
		||||
		instant: true,
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
useInterval(() => {
 | 
			
		||||
	if (!canvasEl.value) return;
 | 
			
		||||
	const actualCanvasWidth = canvasEl.value.getBoundingClientRect().width;
 | 
			
		||||
	const actualCanvasHeight = canvasEl.value.getBoundingClientRect().height;
 | 
			
		||||
	viewScaleX = actualCanvasWidth / GAME_WIDTH;
 | 
			
		||||
	viewScaleY = actualCanvasHeight / GAME_HEIGHT;
 | 
			
		||||
	if (actualCanvasWidth === 0) return;
 | 
			
		||||
	viewScale = actualCanvasWidth / GAME_WIDTH;
 | 
			
		||||
	containerElRect = containerEl.value?.getBoundingClientRect() ?? null;
 | 
			
		||||
}, 1000, { immediate: false, afterMounted: true });
 | 
			
		||||
 | 
			
		||||
onDeactivated(() => {
 | 
			
		||||
	game.dispose();
 | 
			
		||||
	restart();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
definePageMetadata({
 | 
			
		||||
| 
						 | 
				
			
			@ -697,16 +762,52 @@ definePageMetadata({
 | 
			
		|||
	box-shadow: 0 6px 16px #0007, 0 0 1px 1px #693410, inset 0 0 2px 1px #ce8a5c;
 | 
			
		||||
	border-radius: 10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.frameH {
 | 
			
		||||
	display: flex;
 | 
			
		||||
	gap: 6px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.frameInner {
 | 
			
		||||
	padding: 4px 8px;
 | 
			
		||||
	padding: 8px;
 | 
			
		||||
	margin-top: 8px;
 | 
			
		||||
	background: #F1E8DC;
 | 
			
		||||
	box-shadow: 0 0 2px 1px #ce8a5c, inset 0 0 1px 1px #693410;
 | 
			
		||||
	border-radius: 6px;
 | 
			
		||||
	color: #693410;
 | 
			
		||||
 | 
			
		||||
	&:first-child {
 | 
			
		||||
		margin-top: 0;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.main {
 | 
			
		||||
.frameDivider {
 | 
			
		||||
	height: 0;
 | 
			
		||||
	border: none;
 | 
			
		||||
	border-top: 1px solid #693410;
 | 
			
		||||
	border-bottom: 1px solid #ce8a5c;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.header {
 | 
			
		||||
	position: relative;
 | 
			
		||||
	z-index: 10;
 | 
			
		||||
	display: grid;
 | 
			
		||||
	grid-template-columns: 1fr;
 | 
			
		||||
	grid-template-rows: auto auto;
 | 
			
		||||
	gap: 8px;
 | 
			
		||||
 | 
			
		||||
	> .headerTitle {
 | 
			
		||||
		text-align: center;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	@media (min-width: 500px) {
 | 
			
		||||
		grid-template-columns: 1fr auto;
 | 
			
		||||
		grid-template-rows: auto;
 | 
			
		||||
 | 
			
		||||
		> .headerTitle {
 | 
			
		||||
			text-align: start;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mainFrameImg {
 | 
			
		||||
| 
						 | 
				
			
			@ -724,15 +825,15 @@ definePageMetadata({
 | 
			
		|||
	position: relative;
 | 
			
		||||
	display: block;
 | 
			
		||||
	z-index: 1;
 | 
			
		||||
	margin-top: -50px;
 | 
			
		||||
	width: 100% !important;
 | 
			
		||||
	height: auto !important;
 | 
			
		||||
	pointer-events: none;
 | 
			
		||||
	user-select: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.container {
 | 
			
		||||
.gameContainer {
 | 
			
		||||
	position: relative;
 | 
			
		||||
	margin-top: -20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.stock {
 | 
			
		||||
| 
						 | 
				
			
			@ -755,45 +856,51 @@ definePageMetadata({
 | 
			
		|||
	user-select: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.currentMono {
 | 
			
		||||
.dropperContainer {
 | 
			
		||||
	position: absolute;
 | 
			
		||||
	margin-top: 80px;
 | 
			
		||||
	top: 0;
 | 
			
		||||
	height: 100%;
 | 
			
		||||
	z-index: 2;
 | 
			
		||||
	filter: drop-shadow(0 6px 16px #0007);
 | 
			
		||||
	pointer-events: none;
 | 
			
		||||
	user-select: none;
 | 
			
		||||
	will-change: left;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.currentMono {
 | 
			
		||||
	position: absolute;
 | 
			
		||||
	display: block;
 | 
			
		||||
	bottom: 88%;
 | 
			
		||||
	z-index: 2;
 | 
			
		||||
	filter: drop-shadow(0 6px 16px #0007);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dropper {
 | 
			
		||||
	position: absolute;
 | 
			
		||||
	position: relative;
 | 
			
		||||
	top: 0;
 | 
			
		||||
	width: 70px;
 | 
			
		||||
	margin-top: -10px;
 | 
			
		||||
	margin-left: -30px;
 | 
			
		||||
	z-index: 2;
 | 
			
		||||
	filter: drop-shadow(0 6px 16px #0007);
 | 
			
		||||
	pointer-events: none;
 | 
			
		||||
	user-select: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.currentMonoArrow {
 | 
			
		||||
	position: absolute;
 | 
			
		||||
	margin-top: 100px;
 | 
			
		||||
	width: 20px;
 | 
			
		||||
	bottom: 80%;
 | 
			
		||||
	left: -10px;
 | 
			
		||||
	z-index: 3;
 | 
			
		||||
	animation: currentMonoArrow 2s ease infinite;
 | 
			
		||||
	pointer-events: none;
 | 
			
		||||
	user-select: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dropGuide {
 | 
			
		||||
	position: absolute;
 | 
			
		||||
	top: 120px;
 | 
			
		||||
	z-index: 3;
 | 
			
		||||
	bottom: 0;
 | 
			
		||||
	width: 3px;
 | 
			
		||||
	height: calc(100% - 120px);
 | 
			
		||||
	margin-left: -2px;
 | 
			
		||||
	height: 85%;
 | 
			
		||||
	background: #f002;
 | 
			
		||||
	pointer-events: none;
 | 
			
		||||
	user-select: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.gameOverLabel {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -33,7 +33,7 @@ import MkRange from '@/components/MkRange.vue';
 | 
			
		|||
import { i18n } from '@/i18n.js';
 | 
			
		||||
import * as os from '@/os.js';
 | 
			
		||||
import { misskeyApi } from '@/scripts/misskey-api.js';
 | 
			
		||||
import { playFile, soundsTypes, getSoundDuration } from '@/scripts/sound.js';
 | 
			
		||||
import { playMisskeySfxFile, soundsTypes, getSoundDuration } from '@/scripts/sound.js';
 | 
			
		||||
import { selectFile } from '@/scripts/select-file.js';
 | 
			
		||||
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
| 
						 | 
				
			
			@ -119,7 +119,7 @@ function listen() {
 | 
			
		|||
		return;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	playFile(type.value === '_driveFile_' ? {
 | 
			
		||||
	playMisskeySfxFile(type.value === '_driveFile_' ? {
 | 
			
		||||
		type: '_driveFile_',
 | 
			
		||||
		fileId: fileId.value as string,
 | 
			
		||||
		fileUrl: fileUrl.value as string,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -20,17 +20,17 @@ export type Mono = {
 | 
			
		|||
	spriteScale: number;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const PHYSICS_QUALITY_FACTOR = 16; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる、逆に高すぎても何故か不安定になる
 | 
			
		||||
 | 
			
		||||
export class DropAndFusionGame extends EventEmitter<{
 | 
			
		||||
	changeScore: (newScore: number) => void;
 | 
			
		||||
	changeCombo: (newCombo: number) => void;
 | 
			
		||||
	changeStock: (newStock: { id: string; mono: Mono }[]) => void;
 | 
			
		||||
	changeHolding: (newHolding: { id: string; mono: Mono } | null) => void;
 | 
			
		||||
	dropped: () => void;
 | 
			
		||||
	fusioned: (x: number, y: number, scoreDelta: number) => void;
 | 
			
		||||
	monoAdded: (mono: Mono) => void;
 | 
			
		||||
	gameOver: () => void;
 | 
			
		||||
}> {
 | 
			
		||||
	private PHYSICS_QUALITY_FACTOR = 16; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる、逆に高すぎても何故か不安定になる
 | 
			
		||||
	private COMBO_INTERVAL = 1000;
 | 
			
		||||
	public readonly DROP_INTERVAL = 500;
 | 
			
		||||
	public readonly PLAYAREA_MARGIN = 25;
 | 
			
		||||
| 
						 | 
				
			
			@ -48,6 +48,8 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
	private monoTextures: Record<string, Blob> = {};
 | 
			
		||||
	private monoTextureUrls: Record<string, string> = {};
 | 
			
		||||
 | 
			
		||||
	private sfxVolume = 1;
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * フィールドに出ていて、かつ合体の対象となるアイテム
 | 
			
		||||
	 */
 | 
			
		||||
| 
						 | 
				
			
			@ -58,6 +60,7 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
	private latestDroppedAt = 0;
 | 
			
		||||
	private latestFusionedAt = 0;
 | 
			
		||||
	private stock: { id: string; mono: Mono }[] = [];
 | 
			
		||||
	private holding: { id: string; mono: Mono } | null = null;
 | 
			
		||||
 | 
			
		||||
	private _combo = 0;
 | 
			
		||||
	private get combo() {
 | 
			
		||||
| 
						 | 
				
			
			@ -84,6 +87,7 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
		width: number;
 | 
			
		||||
		height: number;
 | 
			
		||||
		monoDefinitions: Mono[];
 | 
			
		||||
		sfxVolume?: number;
 | 
			
		||||
	}) {
 | 
			
		||||
		super();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -91,10 +95,14 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
		this.gameHeight = opts.height;
 | 
			
		||||
		this.monoDefinitions = opts.monoDefinitions;
 | 
			
		||||
 | 
			
		||||
		if (opts.sfxVolume) {
 | 
			
		||||
			this.sfxVolume = opts.sfxVolume;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		this.engine = Matter.Engine.create({
 | 
			
		||||
			constraintIterations: 2 * PHYSICS_QUALITY_FACTOR,
 | 
			
		||||
			positionIterations: 6 * PHYSICS_QUALITY_FACTOR,
 | 
			
		||||
			velocityIterations: 4 * PHYSICS_QUALITY_FACTOR,
 | 
			
		||||
			constraintIterations: 2 * this.PHYSICS_QUALITY_FACTOR,
 | 
			
		||||
			positionIterations: 6 * this.PHYSICS_QUALITY_FACTOR,
 | 
			
		||||
			velocityIterations: 4 * this.PHYSICS_QUALITY_FACTOR,
 | 
			
		||||
			gravity: {
 | 
			
		||||
				x: 0,
 | 
			
		||||
				y: 1,
 | 
			
		||||
| 
						 | 
				
			
			@ -183,6 +191,7 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
		};
 | 
			
		||||
		if (mono.shape === 'circle') {
 | 
			
		||||
			return Matter.Bodies.circle(x, y, mono.size / 2, options);
 | 
			
		||||
		// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
 | 
			
		||||
		} else if (mono.shape === 'rectangle') {
 | 
			
		||||
			return Matter.Bodies.rectangle(x, y, mono.size, mono.size, options);
 | 
			
		||||
		} else {
 | 
			
		||||
| 
						 | 
				
			
			@ -224,7 +233,11 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
 | 
			
		||||
			// TODO: 効果音再生はコンポーネント側の責務なので移動する
 | 
			
		||||
			const pan = ((newX / this.gameWidth) - 0.5) * 2;
 | 
			
		||||
			sound.playUrl('/client-assets/drop-and-fusion/bubble2.mp3', 1, pan, nextMono.sfxPitch);
 | 
			
		||||
			sound.playUrl('/client-assets/drop-and-fusion/bubble2.mp3', {
 | 
			
		||||
				volume: this.sfxVolume,
 | 
			
		||||
				pan,
 | 
			
		||||
				playbackRate: nextMono.sfxPitch,
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			this.emit('monoAdded', nextMono);
 | 
			
		||||
			this.emit('fusioned', newX, newY, additionalScore);
 | 
			
		||||
| 
						 | 
				
			
			@ -237,7 +250,7 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
			//}
 | 
			
		||||
			//sound.playUrl({
 | 
			
		||||
			//	type: 'syuilo/bubble2',
 | 
			
		||||
			//	volume: 1,
 | 
			
		||||
			//	volume: this.sfxVolume,
 | 
			
		||||
			//});
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			@ -323,10 +336,14 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
					const energy = pairs.collision.depth;
 | 
			
		||||
					if (energy > minCollisionEnergyForSound) {
 | 
			
		||||
						// TODO: 効果音再生はコンポーネント側の責務なので移動する
 | 
			
		||||
						const vol = (Math.min(maxCollisionEnergyForSound, energy - minCollisionEnergyForSound) / maxCollisionEnergyForSound) / 4;
 | 
			
		||||
						const vol = ((Math.min(maxCollisionEnergyForSound, energy - minCollisionEnergyForSound) / maxCollisionEnergyForSound) / 4) * this.sfxVolume;
 | 
			
		||||
						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.playUrl('/client-assets/drop-and-fusion/poi1.mp3', vol, pan, pitch);
 | 
			
		||||
						sound.playUrl('/client-assets/drop-and-fusion/poi1.mp3', {
 | 
			
		||||
							volume: vol,
 | 
			
		||||
							pan,
 | 
			
		||||
							playbackRate: pitch,
 | 
			
		||||
						});
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
| 
						 | 
				
			
			@ -344,6 +361,10 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
		this.loaded = true;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public setSfxVolume(volume: number) {
 | 
			
		||||
		this.sfxVolume = volume;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public getTextureImageUrl(mono: Mono) {
 | 
			
		||||
		// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
 | 
			
		||||
		if (this.monoTextureUrls[mono.img]) {
 | 
			
		||||
| 
						 | 
				
			
			@ -369,25 +390,53 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
		if (Date.now() - this.latestDroppedAt < this.DROP_INTERVAL) {
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
		const st = this.stock.shift()!;
 | 
			
		||||
		const head = this.stock.shift()!;
 | 
			
		||||
		this.stock.push({
 | 
			
		||||
			id: Math.random().toString(),
 | 
			
		||||
			mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
 | 
			
		||||
		});
 | 
			
		||||
		this.emit('changeStock', this.stock);
 | 
			
		||||
 | 
			
		||||
		const x = Math.min(this.gameWidth - this.PLAYAREA_MARGIN - (st.mono.size / 2), Math.max(this.PLAYAREA_MARGIN + (st.mono.size / 2), _x));
 | 
			
		||||
		const body = this.createBody(st.mono, x, 50 + st.mono.size / 2);
 | 
			
		||||
		const x = Math.min(this.gameWidth - this.PLAYAREA_MARGIN - (head.mono.size / 2), Math.max(this.PLAYAREA_MARGIN + (head.mono.size / 2), _x));
 | 
			
		||||
		const body = this.createBody(head.mono, x, 50 + head.mono.size / 2);
 | 
			
		||||
		Matter.Composite.add(this.engine.world, body);
 | 
			
		||||
		this.activeBodyIds.push(body.id);
 | 
			
		||||
		this.latestDroppedBodyId = body.id;
 | 
			
		||||
		this.latestDroppedAt = Date.now();
 | 
			
		||||
		this.emit('dropped');
 | 
			
		||||
		this.emit('monoAdded', st.mono);
 | 
			
		||||
		this.emit('monoAdded', head.mono);
 | 
			
		||||
 | 
			
		||||
		// TODO: 効果音再生はコンポーネント側の責務なので移動する
 | 
			
		||||
		const pan = ((x / this.gameWidth) - 0.5) * 2;
 | 
			
		||||
		sound.playUrl('/client-assets/drop-and-fusion/poi2.mp3', 1, pan);
 | 
			
		||||
		sound.playUrl('/client-assets/drop-and-fusion/poi2.mp3', {
 | 
			
		||||
			volume: this.sfxVolume,
 | 
			
		||||
			pan,
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public hold() {
 | 
			
		||||
		if (this.isGameOver) return;
 | 
			
		||||
 | 
			
		||||
		if (this.holding) {
 | 
			
		||||
			const head = this.stock.shift()!;
 | 
			
		||||
			this.stock.unshift(this.holding);
 | 
			
		||||
			this.holding = head;
 | 
			
		||||
			this.emit('changeHolding', this.holding);
 | 
			
		||||
			this.emit('changeStock', this.stock);
 | 
			
		||||
		} else {
 | 
			
		||||
			const head = this.stock.shift()!;
 | 
			
		||||
			this.holding = head;
 | 
			
		||||
			this.stock.push({
 | 
			
		||||
				id: Math.random().toString(),
 | 
			
		||||
				mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
 | 
			
		||||
			});
 | 
			
		||||
			this.emit('changeHolding', this.holding);
 | 
			
		||||
			this.emit('changeStock', this.stock);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		sound.playUrl('/client-assets/drop-and-fusion/hold.mp3', {
 | 
			
		||||
			volume: this.sfxVolume,
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public dispose() {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -126,13 +126,13 @@ export async function loadAudio(url: string, options?: { useCache?: boolean; })
 | 
			
		|||
 * 既定のスプライトを再生する
 | 
			
		||||
 * @param type スプライトの種類を指定
 | 
			
		||||
 */
 | 
			
		||||
export function play(operationType: OperationType) {
 | 
			
		||||
export function playMisskeySfx(operationType: OperationType) {
 | 
			
		||||
	const sound = defaultStore.state[`sound_${operationType}`];
 | 
			
		||||
	if (_DEV_) console.log('play', operationType, sound);
 | 
			
		||||
	if (sound.type == null || !canPlay) return;
 | 
			
		||||
 | 
			
		||||
	canPlay = false;
 | 
			
		||||
	playFile(sound).finally(() => {
 | 
			
		||||
	playMisskeySfxFile(sound).finally(() => {
 | 
			
		||||
		// ごく短時間に音が重複しないように
 | 
			
		||||
		setTimeout(() => {
 | 
			
		||||
			canPlay = true;
 | 
			
		||||
| 
						 | 
				
			
			@ -144,41 +144,53 @@ export function play(operationType: OperationType) {
 | 
			
		|||
 * サウンド設定形式で指定された音声を再生する
 | 
			
		||||
 * @param soundStore サウンド設定
 | 
			
		||||
 */
 | 
			
		||||
export async function playFile(soundStore: SoundStore) {
 | 
			
		||||
export async function playMisskeySfxFile(soundStore: SoundStore) {
 | 
			
		||||
	if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) {
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
	const masterVolume = defaultStore.state.sound_masterVolume;
 | 
			
		||||
	if (isMute() || masterVolume === 0 || soundStore.volume === 0) {
 | 
			
		||||
		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)?.soundSource.start();
 | 
			
		||||
	const volume = soundStore.volume * masterVolume;
 | 
			
		||||
	createSourceNode(buffer, { volume }).soundSource.start();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function playUrl(url: string, volume = 1, pan = 0, playbackRate = 1) {
 | 
			
		||||
export async function playUrl(url: string, opts: {
 | 
			
		||||
	volume?: number;
 | 
			
		||||
	pan?: number;
 | 
			
		||||
	playbackRate?: number;
 | 
			
		||||
}) {
 | 
			
		||||
	if (opts.volume === 0) {
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
	const buffer = await loadAudio(url);
 | 
			
		||||
	if (!buffer) return;
 | 
			
		||||
	createSourceNode(buffer, volume, pan, playbackRate)?.soundSource.start();
 | 
			
		||||
	createSourceNode(buffer, opts).soundSource.start();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function createSourceNode(buffer: AudioBuffer, volume: number, pan = 0, playbackRate = 1): {
 | 
			
		||||
export function createSourceNode(buffer: AudioBuffer, opts: {
 | 
			
		||||
	volume?: number;
 | 
			
		||||
	pan?: number;
 | 
			
		||||
	playbackRate?: number;
 | 
			
		||||
}): {
 | 
			
		||||
	soundSource: AudioBufferSourceNode;
 | 
			
		||||
	panNode: StereoPannerNode;
 | 
			
		||||
	gainNode: GainNode;
 | 
			
		||||
} | null {
 | 
			
		||||
	const masterVolume = defaultStore.state.sound_masterVolume;
 | 
			
		||||
	if (isMute() || masterVolume === 0 || volume === 0) {
 | 
			
		||||
		return null;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
} {
 | 
			
		||||
	const panNode = ctx.createStereoPanner();
 | 
			
		||||
	panNode.pan.value = pan;
 | 
			
		||||
	panNode.pan.value = opts.pan ?? 0;
 | 
			
		||||
 | 
			
		||||
	const gainNode = ctx.createGain();
 | 
			
		||||
	gainNode.gain.value = masterVolume * volume;
 | 
			
		||||
 | 
			
		||||
	gainNode.gain.value = opts.volume ?? 1;
 | 
			
		||||
 | 
			
		||||
	const soundSource = ctx.createBufferSource();
 | 
			
		||||
	soundSource.buffer = buffer;
 | 
			
		||||
	soundSource.playbackRate.value = playbackRate;
 | 
			
		||||
	soundSource.playbackRate.value = opts.playbackRate ?? 1;
 | 
			
		||||
	soundSource
 | 
			
		||||
		.connect(panNode)
 | 
			
		||||
		.connect(gainNode)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -420,6 +420,13 @@ export const defaultStore = markRaw(new Storage('base', {
 | 
			
		|||
		where: 'device',
 | 
			
		||||
		default: false,
 | 
			
		||||
	},
 | 
			
		||||
	dropAndFusion: {
 | 
			
		||||
		where: 'device',
 | 
			
		||||
		default: {
 | 
			
		||||
			bgmVolume: 0.25,
 | 
			
		||||
			sfxVolume: 1,
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	sound_masterVolume: {
 | 
			
		||||
		where: 'device',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -83,7 +83,7 @@ function onNotification(notification: Misskey.entities.Notification, isClient =
 | 
			
		|||
		}, 6000);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	sound.play('notification');
 | 
			
		||||
	sound.playMisskeySfx('notification');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
if ($i) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -123,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)?.soundSource;
 | 
			
		||||
			const soundNode = sound.createSourceNode(jammedAudioBuffer.value, {}).soundSource;
 | 
			
		||||
			if (soundNode) {
 | 
			
		||||
				jammedSoundNodePlaying.value = true;
 | 
			
		||||
				soundNode.onended = () => jammedSoundNodePlaying.value = false;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue