diff --git a/locales/index.d.ts b/locales/index.d.ts index 941566100..1ec9ed64e 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1233,6 +1233,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 44430bc0a..4a4feed06 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1230,6 +1230,8 @@ decorate: "デコる" addMfmFunction: "装飾を追加" enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する" bubbleGame: "バブルゲーム" +sfx: "効果音" +soundWillBePlayed: "サウンドが再生されます" _announcement: forExistingUsers: "既存ユーザーのみ" 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 000000000..ef03e60f6 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/click.mp3 differ 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 000000000..f064c976d Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/hold.mp3 differ diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 81e0f3ae9..f248bc10e 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -269,7 +269,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 7870e1e4b..182ef7661 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -563,7 +563,7 @@ function reply(viaKeyboard = false): void { function like(): void { pleaseLogin(); showMovedDialog(); - sound.play('reaction'); + sound.playMisskeySfx('reaction'); if (props.mock) { return; } @@ -584,7 +584,7 @@ function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); if (appearNote.value.reactionAcceptance === 'likeOnly') { - sound.play('reaction'); + sound.playMisskeySfx('reaction'); if (props.mock) { return; @@ -604,7 +604,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 317666a5e..74fb476f1 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -597,6 +597,8 @@ function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); if (appearNote.value.reactionAcceptance === 'likeOnly') { + sound.playMisskeySfx('reaction'); + misskeyApi('notes/like', { noteId: appearNote.value.id, override: defaultLike.value, @@ -611,7 +613,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, @@ -629,7 +631,7 @@ function react(viaKeyboard = false): void { function like(): void { pleaseLogin(); showMovedDialog(); - sound.play('reaction'); + sound.playMisskeySfx('reaction'); misskeyApi('notes/like', { noteId: appearNote.value.id, override: defaultLike.value, diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index 9d403bf09..fbc7a7f9e 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -195,7 +195,7 @@ function reply(viaKeyboard = false): void { function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); - sound.play('reaction'); + sound.playMisskeySfx('reaction'); if (props.note.reactionAcceptance === 'likeOnly') { misskeyApi('notes/like', { noteId: props.note.id, @@ -227,7 +227,7 @@ function react(viaKeyboard = false): void { function like(): void { pleaseLogin(); showMovedDialog(); - sound.play('reaction'); + sound.playMisskeySfx('reaction'); misskeyApi('notes/like', { noteId: props.note.id, override: defaultLike.value, diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue index c1f5b6a79..e8760e194 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(); @@ -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 a3791aee0..c8c8d0f91 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 00be5d204..572d6edcd 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -84,7 +84,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/SkNote.vue b/packages/frontend/src/components/SkNote.vue index 66ef22633..15192405f 100644 --- a/packages/frontend/src/components/SkNote.vue +++ b/packages/frontend/src/components/SkNote.vue @@ -564,7 +564,7 @@ function reply(viaKeyboard = false): void { function like(): void { pleaseLogin(); showMovedDialog(); - sound.play('reaction'); + sound.playMisskeySfx('reaction'); if (props.mock) { return; } @@ -585,7 +585,7 @@ function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); if (appearNote.value.reactionAcceptance === 'likeOnly') { - sound.play('reaction'); + sound.playMisskeySfx('reaction'); if (props.mock) { return; @@ -605,7 +605,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/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue index 212fa99be..014c655bb 100644 --- a/packages/frontend/src/components/SkNoteDetailed.vue +++ b/packages/frontend/src/components/SkNoteDetailed.vue @@ -620,7 +620,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, @@ -638,7 +638,7 @@ function react(viaKeyboard = false): void { function like(): void { pleaseLogin(); showMovedDialog(); - sound.play('reaction'); + sound.playMisskeySfx('reaction'); misskeyApi('notes/like', { noteId: appearNote.value.id, override: defaultLike.value, diff --git a/packages/frontend/src/components/SkNoteSub.vue b/packages/frontend/src/components/SkNoteSub.vue index 60a574731..363dcef34 100644 --- a/packages/frontend/src/components/SkNoteSub.vue +++ b/packages/frontend/src/components/SkNoteSub.vue @@ -204,7 +204,7 @@ function reply(viaKeyboard = false): void { function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); - sound.play('reaction'); + sound.playMisskeySfx('reaction'); if (props.note.reactionAcceptance === 'likeOnly') { misskeyApi('notes/like', { noteId: props.note.id, @@ -236,7 +236,7 @@ function react(viaKeyboard = false): void { function like(): void { pleaseLogin(); showMovedDialog(); - sound.play('reaction'); + sound.playMisskeySfx('reaction'); misskeyApi('notes/like', { noteId: props.note.id, override: defaultLike.value, diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue index e8732d1b1..18fdcd4ff 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: 'ph-smiley ph-bold ph-lg', 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 8945f2b64..ba4a40ce0 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: 'ph-smiley ph-bold ph-lg', 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 0ddee55f5..b8d3d8bf0 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 {{ i18n.ts.start }} +
+
+
{{ i18n.ts.soundWillBePlayed }}
+ + + +
+
-
-
+
+
BUBBLE GAME
- {{ gameMode }} -
-
-
- NEXT >>> +
+
+ HOLD + +
+
-
- -
+
-
-
- - - - -
{{ comboPrev }} Chain!
-
- +
+ + + + +
{{ comboPrev }} Chain!
+
+
+ - + -
-
- -
SCORE:
-
MAX CHAIN:
-
- Restart - Share -
+
+
+
+ +
SCORE:
+
MAX CHAIN:
+
+ Restart + Share
@@ -109,15 +118,23 @@ SPDX-License-Identifier: AGPL-3.0-only
- - - +
+ + + + + + +
-
-
-
Credit
-
BGM: @ys@misskey.design
+
+
Credit
+
+
Ai-chan illustration: @poteriri@misskey.io
+
BGM: @ys@misskey.design
+
+
@@ -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(); -const canvasEl = shallowRef(); -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(); +const canvasEl = shallowRef(); +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(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 = null; +let bgmNodes: ReturnType | 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(res => { + const img = new Image(); + img.src = url; + img.addEventListener('load', () => { + res(img); + }); + }); +} + function getGameImageDriveFile() { return new Promise(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 813a971d3..73812d592 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 b6e735ddf..f71f3a668 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 = {}; private monoTextureUrls: Record = {}; + 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]) { @@ -366,28 +387,55 @@ export class DropAndFusionGame extends EventEmitter<{ public drop(_x: number) { if (this.isGameOver) return; - if (Date.now() - this.latestDroppedAt < this.DROP_INTERVAL) { - return; - } - const st = this.stock.shift()!; + if (Date.now() - this.latestDroppedAt < this.DROP_INTERVAL) return; + + 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: 0.5 * this.sfxVolume, + }); } public dispose() { diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts index 690c342c8..142ddf87c 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 c60dfb938..ea7c24800 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -468,6 +468,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 78af49cdc..0ec036c5c 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 e5d8a3e5e..01a79e7d7 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;