From 0d7f9308cc233cf0688364cb947a376afc656871 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?=
 <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Tue, 9 Jan 2024 13:25:33 +0900
Subject: [PATCH 1/2] =?UTF-8?q?enhance(frontend):=20=E3=83=90=E3=83=96?=
 =?UTF-8?q?=E3=83=AB=E3=82=B2=E3=83=BC=E3=83=A0=E3=81=AE=E8=AB=B8=E3=80=85?=
 =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3=E3=83=BB=E6=94=B9=E8=89=AF2=20(#129?=
 =?UTF-8?q?48)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* (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>
---
 locales/index.d.ts                            |   2 +
 locales/ja-JP.yml                             |   2 +
 .../frontend/assets/drop-and-fusion/hold.mp3  | Bin 0 -> 26496 bytes
 packages/frontend/src/boot/main-boot.ts       |   2 +-
 packages/frontend/src/components/MkNote.vue   |   4 +-
 .../src/components/MkNoteDetailed.vue         |   4 +-
 packages/frontend/src/components/MkRange.vue  |   2 +
 .../components/MkReactionsViewer.reaction.vue |   4 +-
 .../frontend/src/components/MkTimeline.vue    |   2 +-
 .../src/components/global/MkCustomEmoji.vue   |   2 +-
 .../src/components/global/MkEmoji.vue         |   2 +-
 .../frontend/src/pages/drop-and-fusion.vue    | 279 ++++++++++++------
 .../src/pages/settings/sounds.sound.vue       |   4 +-
 .../src/scripts/drop-and-fusion-engine.ts     |  77 ++++-
 packages/frontend/src/scripts/sound.ts        |  44 ++-
 packages/frontend/src/store.ts                |   7 +
 packages/frontend/src/ui/_common_/common.vue  |   2 +-
 .../frontend/src/widgets/WidgetJobQueue.vue   |   2 +-
 18 files changed, 311 insertions(+), 130 deletions(-)
 create mode 100644 packages/frontend/assets/drop-and-fusion/hold.mp3

diff --git a/locales/index.d.ts b/locales/index.d.ts
index 7c73caaac9..96bc9099dd 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -1193,6 +1193,8 @@ export interface Locale {
     "addMfmFunction": string;
     "enableQuickAddMfmFunction": string;
     "bubbleGame": string;
+    "sfx": string;
+    "soundWillBePlayed": string;
     "_announcement": {
         "forExistingUsers": string;
         "forExistingUsersDescription": string;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 55ff3201f0..c28fde56cb 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1190,6 +1190,8 @@ decorate: "デコる"
 addMfmFunction: "装飾を追加"
 enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する"
 bubbleGame: "バブルゲーム"
+sfx: "効果音"
+soundWillBePlayed: "サウンドが再生されます"
 
 _announcement:
   forExistingUsers: "既存ユーザーのみ"
diff --git a/packages/frontend/assets/drop-and-fusion/hold.mp3 b/packages/frontend/assets/drop-and-fusion/hold.mp3
new file mode 100644
index 0000000000000000000000000000000000000000..ef03e60f61f68af7b8f182db7e3148ea8ee8861f
GIT binary patch
literal 26496
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

literal 0
HcmV?d00001

diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts
index 5011ce9e74..bdb145b39a 100644
--- a/packages/frontend/src/boot/main-boot.ts
+++ b/packages/frontend/src/boot/main-boot.ts
@@ -271,7 +271,7 @@ export async function mainBoot() {
 
 		main.on('unreadAntenna', () => {
 			updateAccount({ hasUnreadAntenna: true });
-			sound.play('antenna');
+			sound.playMisskeySfx('antenna');
 		});
 
 		main.on('readAllAnnouncements', () => {
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 3ec9c3c46a..9c4354ef5f 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -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);
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 6f0c0323cc..e941827d74 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -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,
diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue
index 04390c6f0c..1aee1aaac3 100644
--- a/packages/frontend/src/components/MkRange.vue
+++ b/packages/frontend/src/components/MkRange.vue
@@ -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);
 		}
 	};
 
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index 2e75f444da..5ca09fa822 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -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));
diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue
index 63f779dbde..8a5076ea1d 100644
--- a/packages/frontend/src/components/MkTimeline.vue
+++ b/packages/frontend/src/components/MkTimeline.vue
@@ -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');
 	}
 }
 
diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue
index a9643d68ca..dd3fe77251 100644
--- a/packages/frontend/src/components/global/MkCustomEmoji.vue
+++ b/packages/frontend/src/components/global/MkCustomEmoji.vue
@@ -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);
 	}
diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue
index f6b21343b6..cbdb3881c6 100644
--- a/packages/frontend/src/components/global/MkEmoji.vue
+++ b/packages/frontend/src/components/global/MkEmoji.vue
@@ -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);
 	}
diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue
index 0ddee55f5f..b8d3d8bf04 100644
--- a/packages/frontend/src/pages/drop-and-fusion.vue
+++ b/packages/frontend/src/pages/drop-and-fusion.vue
@@ -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 {
diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue
index 57bafce0ac..798980b3d1 100644
--- a/packages/frontend/src/pages/settings/sounds.sound.vue
+++ b/packages/frontend/src/pages/settings/sounds.sound.vue
@@ -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,
diff --git a/packages/frontend/src/scripts/drop-and-fusion-engine.ts b/packages/frontend/src/scripts/drop-and-fusion-engine.ts
index b6e735ddf2..03c52e00fe 100644
--- a/packages/frontend/src/scripts/drop-and-fusion-engine.ts
+++ b/packages/frontend/src/scripts/drop-and-fusion-engine.ts
@@ -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() {
diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts
index 690c342c85..142ddf87c9 100644
--- a/packages/frontend/src/scripts/sound.ts
+++ b/packages/frontend/src/scripts/sound.ts
@@ -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)
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 46634af96b..e3a85377d8 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -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',
diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue
index 78af49cdc2..0ec036c5cb 100644
--- a/packages/frontend/src/ui/_common_/common.vue
+++ b/packages/frontend/src/ui/_common_/common.vue
@@ -83,7 +83,7 @@ function onNotification(notification: Misskey.entities.Notification, isClient =
 		}, 6000);
 	}
 
-	sound.play('notification');
+	sound.playMisskeySfx('notification');
 }
 
 if ($i) {
diff --git a/packages/frontend/src/widgets/WidgetJobQueue.vue b/packages/frontend/src/widgets/WidgetJobQueue.vue
index 89ad3bf323..877406fe95 100644
--- a/packages/frontend/src/widgets/WidgetJobQueue.vue
+++ b/packages/frontend/src/widgets/WidgetJobQueue.vue
@@ -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;

From 14aedc17ae4e3ca3db9e523f2663824e874e0569 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Tue, 9 Jan 2024 16:06:22 +0900
Subject: [PATCH 2/2] update sound

---
 .../frontend/assets/drop-and-fusion/click.mp3 | Bin 0 -> 26496 bytes
 .../frontend/assets/drop-and-fusion/hold.mp3  | Bin 26496 -> 21941 bytes
 .../src/scripts/drop-and-fusion-engine.ts     |   7 +++----
 3 files changed, 3 insertions(+), 4 deletions(-)
 create mode 100644 packages/frontend/assets/drop-and-fusion/click.mp3

diff --git a/packages/frontend/assets/drop-and-fusion/click.mp3 b/packages/frontend/assets/drop-and-fusion/click.mp3
new file mode 100644
index 0000000000000000000000000000000000000000..ef03e60f61f68af7b8f182db7e3148ea8ee8861f
GIT binary patch
literal 26496
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

literal 0
HcmV?d00001

diff --git a/packages/frontend/assets/drop-and-fusion/hold.mp3 b/packages/frontend/assets/drop-and-fusion/hold.mp3
index ef03e60f61f68af7b8f182db7e3148ea8ee8861f..f064c976d3b91b18fafe5efc68ca633a9cb334f1 100644
GIT binary patch
literal 21941
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
II4}Kw0HEOeF8}}l

literal 26496
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

diff --git a/packages/frontend/src/scripts/drop-and-fusion-engine.ts b/packages/frontend/src/scripts/drop-and-fusion-engine.ts
index 03c52e00fe..f71f3a668e 100644
--- a/packages/frontend/src/scripts/drop-and-fusion-engine.ts
+++ b/packages/frontend/src/scripts/drop-and-fusion-engine.ts
@@ -387,9 +387,8 @@ export class DropAndFusionGame extends EventEmitter<{
 
 	public drop(_x: number) {
 		if (this.isGameOver) return;
-		if (Date.now() - this.latestDroppedAt < this.DROP_INTERVAL) {
-			return;
-		}
+		if (Date.now() - this.latestDroppedAt < this.DROP_INTERVAL) return;
+
 		const head = this.stock.shift()!;
 		this.stock.push({
 			id: Math.random().toString(),
@@ -435,7 +434,7 @@ export class DropAndFusionGame extends EventEmitter<{
 		}
 
 		sound.playUrl('/client-assets/drop-and-fusion/hold.mp3', {
-			volume: this.sfxVolume,
+			volume: 0.5 * this.sfxVolume,
 		});
 	}