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:
かっこかり 2024-01-09 13:25:33 +09:00 committed by GitHub
parent 34088ecd27
commit 0d7f9308cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 311 additions and 130 deletions

2
locales/index.d.ts vendored
View File

@ -1193,6 +1193,8 @@ export interface Locale {
"addMfmFunction": string; "addMfmFunction": string;
"enableQuickAddMfmFunction": string; "enableQuickAddMfmFunction": string;
"bubbleGame": string; "bubbleGame": string;
"sfx": string;
"soundWillBePlayed": string;
"_announcement": { "_announcement": {
"forExistingUsers": string; "forExistingUsers": string;
"forExistingUsersDescription": string; "forExistingUsersDescription": string;

View File

@ -1190,6 +1190,8 @@ decorate: "デコる"
addMfmFunction: "装飾を追加" addMfmFunction: "装飾を追加"
enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する" enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する"
bubbleGame: "バブルゲーム" bubbleGame: "バブルゲーム"
sfx: "効果音"
soundWillBePlayed: "サウンドが再生されます"
_announcement: _announcement:
forExistingUsers: "既存ユーザーのみ" forExistingUsers: "既存ユーザーのみ"

Binary file not shown.

View File

@ -271,7 +271,7 @@ export async function mainBoot() {
main.on('unreadAntenna', () => { main.on('unreadAntenna', () => {
updateAccount({ hasUnreadAntenna: true }); updateAccount({ hasUnreadAntenna: true });
sound.play('antenna'); sound.playMisskeySfx('antenna');
}); });
main.on('readAllAnnouncements', () => { main.on('readAllAnnouncements', () => {

View File

@ -345,7 +345,7 @@ function react(viaKeyboard = false): void {
pleaseLogin(); pleaseLogin();
showMovedDialog(); showMovedDialog();
if (appearNote.value.reactionAcceptance === 'likeOnly') { if (appearNote.value.reactionAcceptance === 'likeOnly') {
sound.play('reaction'); sound.playMisskeySfx('reaction');
if (props.mock) { if (props.mock) {
return; return;
@ -365,7 +365,7 @@ function react(viaKeyboard = false): void {
} else { } else {
blur(); blur();
reactionPicker.show(reactButton.value, reaction => { reactionPicker.show(reactButton.value, reaction => {
sound.play('reaction'); sound.playMisskeySfx('reaction');
if (props.mock) { if (props.mock) {
emit('reaction', reaction); emit('reaction', reaction);

View File

@ -370,7 +370,7 @@ function react(viaKeyboard = false): void {
pleaseLogin(); pleaseLogin();
showMovedDialog(); showMovedDialog();
if (appearNote.value.reactionAcceptance === 'likeOnly') { if (appearNote.value.reactionAcceptance === 'likeOnly') {
sound.play('reaction'); sound.playMisskeySfx('reaction');
misskeyApi('notes/reactions/create', { misskeyApi('notes/reactions/create', {
noteId: appearNote.value.id, noteId: appearNote.value.id,
@ -386,7 +386,7 @@ function react(viaKeyboard = false): void {
} else { } else {
blur(); blur();
reactionPicker.show(reactButton.value, reaction => { reactionPicker.show(reactButton.value, reaction => {
sound.play('reaction'); sound.playMisskeySfx('reaction');
misskeyApi('notes/reactions/create', { misskeyApi('notes/reactions/create', {
noteId: appearNote.value.id, noteId: appearNote.value.id,

View File

@ -43,6 +43,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'update:modelValue', value: number): void; (ev: 'update:modelValue', value: number): void;
(ev: 'dragEnded', value: number): void;
}>(); }>();
const containerEl = shallowRef<HTMLElement>(); const containerEl = shallowRef<HTMLElement>();
@ -143,6 +144,7 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => {
// //
if (beforeValue !== finalValue.value) { if (beforeValue !== finalValue.value) {
emit('update:modelValue', finalValue.value); emit('update:modelValue', finalValue.value);
emit('dragEnded', finalValue.value);
} }
}; };

View File

@ -62,7 +62,7 @@ async function toggleReaction() {
if (confirm.canceled) return; if (confirm.canceled) return;
if (oldReaction !== props.reaction) { if (oldReaction !== props.reaction) {
sound.play('reaction'); sound.playMisskeySfx('reaction');
} }
if (mock) { if (mock) {
@ -81,7 +81,7 @@ async function toggleReaction() {
} }
}); });
} else { } else {
sound.play('reaction'); sound.playMisskeySfx('reaction');
if (mock) { if (mock) {
emit('reactionToggled', props.reaction, (props.count + 1)); emit('reactionToggled', props.reaction, (props.count + 1));

View File

@ -81,7 +81,7 @@ function prepend(note) {
emit('note'); emit('note');
if (props.sound) { if (props.sound) {
sound.play($i && (note.userId === $i.id) ? 'noteMy' : 'note'); sound.playMisskeySfx($i && (note.userId === $i.id) ? 'noteMy' : 'note');
} }
} }

View File

@ -91,7 +91,7 @@ function onClick(ev: MouseEvent) {
icon: 'ti ti-plus', icon: 'ti ti-plus',
action: () => { action: () => {
react(`:${props.name}:`); react(`:${props.name}:`);
sound.play('reaction'); sound.playMisskeySfx('reaction');
}, },
}] : [])], ev.currentTarget ?? ev.target); }] : [])], ev.currentTarget ?? ev.target);
} }

View File

@ -55,7 +55,7 @@ function onClick(ev: MouseEvent) {
icon: 'ti ti-plus', icon: 'ti ti-plus',
action: () => { action: () => {
react(props.emoji); react(props.emoji);
sound.play('reaction'); sound.playMisskeySfx('reaction');
}, },
}] : [])], ev.currentTarget ?? ev.target); }] : [])], ev.currentTarget ?? ev.target);
} }

View File

@ -24,20 +24,31 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton primary gradate large rounded inline @click="start">{{ i18n.ts.start }}</MkButton> <MkButton primary gradate large rounded inline @click="start">{{ i18n.ts.start }}</MkButton>
</div> </div>
</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>
</div> </div>
<div v-show="gameStarted" class="_gaps_s" :class="$style.root"> <div v-show="gameStarted" class="_gaps_s" :class="$style.root">
<div style="display: flex;"> <div :class="$style.header">
<div :class="$style.frame" style="flex: 1; margin-right: 10px;"> <div :class="[$style.frame, $style.headerTitle]">
<div :class="$style.frameInner"> <div :class="$style.frameInner">
<b>BUBBLE GAME</b> <b>BUBBLE GAME</b>
<div>- {{ gameMode }} -</div> <div>- {{ gameMode }} -</div>
</div> </div>
</div> </div>
<div :class="[$style.frame, $style.stock]" style="margin-left: auto;"> <div :class="[$style.frame, $style.frameH]">
<div :class="$style.frameInner" style="text-align: center;"> <div :class="$style.frameInner">
NEXT >>> <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 <TransitionGroup
:enterActiveClass="$style.transition_stock_enterActive" :enterActiveClass="$style.transition_stock_enterActive"
:leaveActiveClass="$style.transition_stock_leaveActive" :leaveActiveClass="$style.transition_stock_leaveActive"
@ -45,15 +56,12 @@ SPDX-License-Identifier: AGPL-3.0-only
:leaveToClass="$style.transition_stock_leaveTo" :leaveToClass="$style.transition_stock_leaveTo"
:moveClass="$style.transition_stock_move" :moveClass="$style.transition_stock_move"
> >
<div v-for="x in stock" :key="x.id" style="display: inline-block;"> <img v-for="x in stock" :key="x.id" :src="game.getTextureImageUrl(x.mono)" style="width: 32px; vertical-align: bottom;"/>
<img :src="game.getTextureImageUrl(x.mono)" style="width: 32px;"/>
</div>
</TransitionGroup> </TransitionGroup>
</div> </div>
</div> </div>
</div> </div>
<div :class="$style.main" @contextmenu.stop.prevent> <div ref="containerEl" :class="[$style.gameContainer, { [$style.gameOver]: gameOver }]" @contextmenu.stop.prevent @click.stop.prevent="onClick" @touchmove.stop.prevent="onTouchmove" @touchend="onTouchend" @mousemove="onMousemove">
<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-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"/> <img v-else src="/client-assets/drop-and-fusion/frame-light.svg" :class="$style.mainFrameImg"/>
<canvas ref="canvasEl" :class="$style.canvas"/> <canvas ref="canvasEl" :class="$style.canvas"/>
@ -66,7 +74,8 @@ SPDX-License-Identifier: AGPL-3.0-only
> >
<div v-show="combo > 1" :class="$style.combo" :style="{ fontSize: `${100 + ((comboPrev - 2) * 15)}%` }">{{ comboPrev }} Chain!</div> <div v-show="combo > 1" :class="$style.combo" :style="{ fontSize: `${100 + ((comboPrev - 2) * 15)}%` }">{{ comboPrev }} Chain!</div>
</Transition> </Transition>
<img v-if="currentPick" src="/client-assets/drop-and-fusion/dropper.png" :class="$style.dropper" :style="{ left: dropperX + 'px' }"/> <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 <Transition
:enterActiveClass="$style.transition_picked_enterActive" :enterActiveClass="$style.transition_picked_enterActive"
:leaveActiveClass="$style.transition_picked_leaveActive" :leaveActiveClass="$style.transition_picked_leaveActive"
@ -75,12 +84,13 @@ SPDX-License-Identifier: AGPL-3.0-only
:moveClass="$style.transition_picked_move" :moveClass="$style.transition_picked_move"
mode="out-in" 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> </Transition>
<template v-if="dropReady && currentPick"> <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` }"/> <img src="/client-assets/drop-and-fusion/drop-arrow.svg" :class="$style.currentMonoArrow"/>
<div :class="$style.dropGuide" :style="{ left: (dropperX - 2) + 'px' }"/> <div :class="$style.dropGuide"/>
</template> </template>
</div>
<div v-if="gameOver" :class="$style.gameOverLabel"> <div v-if="gameOver" :class="$style.gameOverLabel">
<div class="_gaps_s"> <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;"/> <img src="/client-assets/drop-and-fusion/gameover.png" style="width: 200px; max-width: 100%; display: block; margin: auto; margin-bottom: -5px;"/>
@ -93,7 +103,6 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
</div> </div>
</div>
<div style="display: flex;"> <div style="display: flex;">
<div :class="$style.frame" style="flex: 1; margin-right: 10px;"> <div :class="$style.frame" style="flex: 1; margin-right: 10px;">
<div :class="$style.frameInner"> <div :class="$style.frameInner">
@ -109,17 +118,25 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<div v-if="showConfig" :class="$style.frame"> <div v-if="showConfig" :class="$style.frame">
<div :class="$style.frameInner"> <div :class="$style.frameInner">
<MkRange v-model="bgmVolume" :min="0" :max="1" :step="0.0025" :textConverter="(v) => `${Math.floor(v * 100)}%`" :continuousUpdate="true"> <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> <template #label>BGM {{ i18n.ts.volume }}</template>
</MkRange> </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>
<div v-if="showConfig" :class="$style.frame">
<div :class="$style.frameInner"> <div :class="$style.frameInner">
<div>Credit</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>BGM: @ys@misskey.design</div>
</div> </div>
</div> </div>
</div>
</div>
<div :class="$style.frame"> <div :class="$style.frame">
<div :class="$style.frameInner"> <div :class="$style.frameInner">
<MkButton @click="restart">Restart</MkButton> <MkButton @click="restart">Restart</MkButton>
@ -150,10 +167,7 @@ import { $i } from '@/account.js';
import { DropAndFusionGame, Mono } from '@/scripts/drop-and-fusion-engine.js'; import { DropAndFusionGame, Mono } from '@/scripts/drop-and-fusion-engine.js';
import * as sound from '@/scripts/sound.js'; import * as sound from '@/scripts/sound.js';
import MkRange from '@/components/MkRange.vue'; import MkRange from '@/components/MkRange.vue';
import MkSwitch from '@/components/MkSwitch.vue';
const containerEl = shallowRef<HTMLElement>();
const canvasEl = shallowRef<HTMLCanvasElement>();
const dropperX = ref(0);
const NORMAL_BASE_SIZE = 30; const NORMAL_BASE_SIZE = 30;
const NORAML_MONOS: Mono[] = [{ const NORAML_MONOS: Mono[] = [{
@ -384,10 +398,16 @@ const SQUARE_MONOS: Mono[] = [{
const GAME_WIDTH = 450; const GAME_WIDTH = 450;
const GAME_HEIGHT = 600; const GAME_HEIGHT = 600;
let viewScaleX = 1; let viewScale = 1;
let viewScaleY = 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 currentPick = shallowRef<{ id: string; mono: Mono } | null>(null);
const stock = shallowRef<{ id: string; mono: Mono }[]>([]); const stock = shallowRef<{ id: string; mono: Mono }[]>([]);
const holdingStock = shallowRef<{ id: string; mono: Mono } | null>(null);
const score = ref(0); const score = ref(0);
const combo = ref(0); const combo = ref(0);
const comboPrev = ref(0); const comboPrev = ref(0);
@ -398,20 +418,19 @@ const gameOver = ref(false);
const gameStarted = ref(false); const gameStarted = ref(false);
const highScore = ref<number | null>(null); const highScore = ref<number | null>(null);
const showConfig = ref(false); const showConfig = ref(false);
const bgmVolume = ref(0.1); const mute = ref(false);
const bgmVolume = ref(defaultStore.state.dropAndFusion.bgmVolume);
let game: DropAndFusionGame; const sfxVolume = ref(defaultStore.state.dropAndFusion.sfxVolume);
let containerElRect: DOMRect | null = null;
function onClick(ev: MouseEvent) { function onClick(ev: MouseEvent) {
if (!containerElRect) return; if (!containerElRect) return;
const x = (ev.clientX - containerElRect.left) / viewScaleX; const x = (ev.clientX - containerElRect.left) / viewScale;
game.drop(x); game.drop(x);
} }
function onTouchend(ev: TouchEvent) { function onTouchend(ev: TouchEvent) {
if (!containerElRect) return; if (!containerElRect) return;
const x = (ev.changedTouches[0].clientX - containerElRect.left) / viewScaleX; const x = (ev.changedTouches[0].clientX - containerElRect.left) / viewScale;
game.drop(x); 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)); 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() { function restart() {
game.dispose(); game.dispose();
gameOver.value = false; gameOver.value = false;
@ -440,6 +463,7 @@ function restart() {
score.value = 0; score.value = 0;
combo.value = 0; combo.value = 0;
comboPrev.value = 0; comboPrev.value = 0;
bgmNodes?.soundSource.stop();
gameStarted.value = false; gameStarted.value = false;
} }
@ -463,6 +487,10 @@ function attachGameEvents() {
stock.value = JSON.parse(JSON.stringify(value.slice(1))); stock.value = JSON.parse(JSON.stringify(value.slice(1)));
}); });
game.addListener('changeHolding', value => {
holdingStock.value = value;
});
game.addListener('dropped', () => { game.addListener('dropped', () => {
dropReady.value = false; dropReady.value = false;
window.setTimeout(() => { window.setTimeout(() => {
@ -476,8 +504,8 @@ function attachGameEvents() {
if (!canvasEl.value) return; if (!canvasEl.value) return;
const rect = canvasEl.value.getBoundingClientRect(); const rect = canvasEl.value.getBoundingClientRect();
const domX = rect.left + (x * viewScaleX); const domX = rect.left + (x * viewScale);
const domY = rect.top + (y * viewScaleY); const domY = rect.top + (y * viewScale);
os.popup(MkRippleEffect, { x: domX, y: domY }, {}, 'end'); os.popup(MkRippleEffect, { x: domX, y: domY }, {}, 'end');
os.popup(MkPlusOneEffect, { x: domX, y: domY, value: scoreDelta }, {}, '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() { async function start() {
try { try {
@ -527,6 +555,7 @@ async function start() {
width: GAME_WIDTH, width: GAME_WIDTH,
height: GAME_HEIGHT, height: GAME_HEIGHT,
canvas: canvasEl.value!, canvas: canvasEl.value!,
sfxVolume: mute.value ? 0 : sfxVolume.value,
...( ...(
gameMode.value === 'normal' ? { gameMode.value === 'normal' ? {
monoDefinitions: NORAML_MONOS, monoDefinitions: NORAML_MONOS,
@ -546,19 +575,50 @@ async function start() {
} }
const bgmBuffer = await sound.loadAudio('/client-assets/drop-and-fusion/bgm_1.mp3'); const bgmBuffer = await sound.loadAudio('/client-assets/drop-and-fusion/bgm_1.mp3');
if (!bgmBuffer) return; if (!bgmBuffer) return;
bgmNodes = sound.createSourceNode(bgmBuffer, bgmVolume.value); bgmNodes = sound.createSourceNode(bgmBuffer, {
volume: mute.value ? 0 : bgmVolume.value,
});
if (!bgmNodes) return; if (!bgmNodes) return;
bgmNodes.soundSource.loop = true; bgmNodes.soundSource.loop = true;
bgmNodes.soundSource.start(); bgmNodes.soundSource.start();
}); });
} }
watch(bgmVolume, (value) => { watch(bgmVolume, (newValue, oldValue) => {
if (bgmNodes) { 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() { function getGameImageDriveFile() {
return new Promise<Misskey.entities.DriveFile | null>(res => { return new Promise<Misskey.entities.DriveFile | null>(res => {
const dcanvas = document.createElement('canvas'); const dcanvas = document.createElement('canvas');
@ -566,13 +626,18 @@ function getGameImageDriveFile() {
dcanvas.height = GAME_HEIGHT; dcanvas.height = GAME_HEIGHT;
const ctx = dcanvas.getContext('2d'); const ctx = dcanvas.getContext('2d');
if (!ctx || !canvasEl.value) return res(null); if (!ctx || !canvasEl.value) return res(null);
const dimage = new Image(); Promise.all([
dimage.src = '/client-assets/drop-and-fusion/frame-light.svg'; loadImage('/client-assets/drop-and-fusion/frame-light.svg'),
dimage.addEventListener('load', () => { loadImage('/client-assets/drop-and-fusion/logo.png'),
]).then((images) => {
const [frame, logo] = images;
ctx.fillStyle = '#fff'; ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); 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.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 => { dcanvas.toBlob(blob => {
if (!blob) return res(null); if (!blob) return res(null);
@ -610,22 +675,22 @@ async function share() {
os.post({ os.post({
initialText: `#BubbleGame initialText: `#BubbleGame
MODE: ${gameMode.value} MODE: ${gameMode.value}
SCORE: ${score.value} (MAX CHAIN: ${maxCombo.value})})`, SCORE: ${score.value} (MAX CHAIN: ${maxCombo.value})`,
initialFiles: [file], initialFiles: [file],
instant: true,
}); });
} }
useInterval(() => { useInterval(() => {
if (!canvasEl.value) return; if (!canvasEl.value) return;
const actualCanvasWidth = canvasEl.value.getBoundingClientRect().width; const actualCanvasWidth = canvasEl.value.getBoundingClientRect().width;
const actualCanvasHeight = canvasEl.value.getBoundingClientRect().height; if (actualCanvasWidth === 0) return;
viewScaleX = actualCanvasWidth / GAME_WIDTH; viewScale = actualCanvasWidth / GAME_WIDTH;
viewScaleY = actualCanvasHeight / GAME_HEIGHT;
containerElRect = containerEl.value?.getBoundingClientRect() ?? null; containerElRect = containerEl.value?.getBoundingClientRect() ?? null;
}, 1000, { immediate: false, afterMounted: true }); }, 1000, { immediate: false, afterMounted: true });
onDeactivated(() => { onDeactivated(() => {
game.dispose(); restart();
}); });
definePageMetadata({ definePageMetadata({
@ -697,16 +762,52 @@ definePageMetadata({
box-shadow: 0 6px 16px #0007, 0 0 1px 1px #693410, inset 0 0 2px 1px #ce8a5c; box-shadow: 0 6px 16px #0007, 0 0 1px 1px #693410, inset 0 0 2px 1px #ce8a5c;
border-radius: 10px; border-radius: 10px;
} }
.frameH {
display: flex;
gap: 6px;
}
.frameInner { .frameInner {
padding: 4px 8px; padding: 8px;
margin-top: 8px;
background: #F1E8DC; background: #F1E8DC;
box-shadow: 0 0 2px 1px #ce8a5c, inset 0 0 1px 1px #693410; box-shadow: 0 0 2px 1px #ce8a5c, inset 0 0 1px 1px #693410;
border-radius: 6px; border-radius: 6px;
color: #693410; 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; 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 { .mainFrameImg {
@ -724,15 +825,15 @@ definePageMetadata({
position: relative; position: relative;
display: block; display: block;
z-index: 1; z-index: 1;
margin-top: -50px;
width: 100% !important; width: 100% !important;
height: auto !important; height: auto !important;
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
} }
.container { .gameContainer {
position: relative; position: relative;
margin-top: -20px;
} }
.stock { .stock {
@ -755,45 +856,51 @@ definePageMetadata({
user-select: none; user-select: none;
} }
.currentMono { .dropperContainer {
position: absolute; position: absolute;
margin-top: 80px; top: 0;
height: 100%;
z-index: 2; z-index: 2;
filter: drop-shadow(0 6px 16px #0007);
pointer-events: none; pointer-events: none;
user-select: 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 { .dropper {
position: absolute; position: relative;
top: 0; top: 0;
width: 70px; width: 70px;
margin-top: -10px; margin-top: -10px;
margin-left: -30px; margin-left: -30px;
z-index: 2; z-index: 2;
filter: drop-shadow(0 6px 16px #0007); filter: drop-shadow(0 6px 16px #0007);
pointer-events: none;
user-select: none;
} }
.currentMonoArrow { .currentMonoArrow {
position: absolute; position: absolute;
margin-top: 100px; width: 20px;
bottom: 80%;
left: -10px;
z-index: 3; z-index: 3;
animation: currentMonoArrow 2s ease infinite; animation: currentMonoArrow 2s ease infinite;
pointer-events: none;
user-select: none;
} }
.dropGuide { .dropGuide {
position: absolute; position: absolute;
top: 120px;
z-index: 3; z-index: 3;
bottom: 0;
width: 3px; width: 3px;
height: calc(100% - 120px); margin-left: -2px;
height: 85%;
background: #f002; background: #f002;
pointer-events: none;
user-select: none;
} }
.gameOverLabel { .gameOverLabel {

View File

@ -33,7 +33,7 @@ import MkRange from '@/components/MkRange.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.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'; import { selectFile } from '@/scripts/select-file.js';
const props = defineProps<{ const props = defineProps<{
@ -119,7 +119,7 @@ function listen() {
return; return;
} }
playFile(type.value === '_driveFile_' ? { playMisskeySfxFile(type.value === '_driveFile_' ? {
type: '_driveFile_', type: '_driveFile_',
fileId: fileId.value as string, fileId: fileId.value as string,
fileUrl: fileUrl.value as string, fileUrl: fileUrl.value as string,

View File

@ -20,17 +20,17 @@ export type Mono = {
spriteScale: number; spriteScale: number;
}; };
const PHYSICS_QUALITY_FACTOR = 16; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる、逆に高すぎても何故か不安定になる
export class DropAndFusionGame extends EventEmitter<{ export class DropAndFusionGame extends EventEmitter<{
changeScore: (newScore: number) => void; changeScore: (newScore: number) => void;
changeCombo: (newCombo: number) => void; changeCombo: (newCombo: number) => void;
changeStock: (newStock: { id: string; mono: Mono }[]) => void; changeStock: (newStock: { id: string; mono: Mono }[]) => void;
changeHolding: (newHolding: { id: string; mono: Mono } | null) => void;
dropped: () => void; dropped: () => void;
fusioned: (x: number, y: number, scoreDelta: number) => void; fusioned: (x: number, y: number, scoreDelta: number) => void;
monoAdded: (mono: Mono) => void; monoAdded: (mono: Mono) => void;
gameOver: () => void; gameOver: () => void;
}> { }> {
private PHYSICS_QUALITY_FACTOR = 16; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる、逆に高すぎても何故か不安定になる
private COMBO_INTERVAL = 1000; private COMBO_INTERVAL = 1000;
public readonly DROP_INTERVAL = 500; public readonly DROP_INTERVAL = 500;
public readonly PLAYAREA_MARGIN = 25; public readonly PLAYAREA_MARGIN = 25;
@ -48,6 +48,8 @@ export class DropAndFusionGame extends EventEmitter<{
private monoTextures: Record<string, Blob> = {}; private monoTextures: Record<string, Blob> = {};
private monoTextureUrls: Record<string, string> = {}; private monoTextureUrls: Record<string, string> = {};
private sfxVolume = 1;
/** /**
* *
*/ */
@ -58,6 +60,7 @@ export class DropAndFusionGame extends EventEmitter<{
private latestDroppedAt = 0; private latestDroppedAt = 0;
private latestFusionedAt = 0; private latestFusionedAt = 0;
private stock: { id: string; mono: Mono }[] = []; private stock: { id: string; mono: Mono }[] = [];
private holding: { id: string; mono: Mono } | null = null;
private _combo = 0; private _combo = 0;
private get combo() { private get combo() {
@ -84,6 +87,7 @@ export class DropAndFusionGame extends EventEmitter<{
width: number; width: number;
height: number; height: number;
monoDefinitions: Mono[]; monoDefinitions: Mono[];
sfxVolume?: number;
}) { }) {
super(); super();
@ -91,10 +95,14 @@ export class DropAndFusionGame extends EventEmitter<{
this.gameHeight = opts.height; this.gameHeight = opts.height;
this.monoDefinitions = opts.monoDefinitions; this.monoDefinitions = opts.monoDefinitions;
if (opts.sfxVolume) {
this.sfxVolume = opts.sfxVolume;
}
this.engine = Matter.Engine.create({ this.engine = Matter.Engine.create({
constraintIterations: 2 * PHYSICS_QUALITY_FACTOR, constraintIterations: 2 * this.PHYSICS_QUALITY_FACTOR,
positionIterations: 6 * PHYSICS_QUALITY_FACTOR, positionIterations: 6 * this.PHYSICS_QUALITY_FACTOR,
velocityIterations: 4 * PHYSICS_QUALITY_FACTOR, velocityIterations: 4 * this.PHYSICS_QUALITY_FACTOR,
gravity: { gravity: {
x: 0, x: 0,
y: 1, y: 1,
@ -183,6 +191,7 @@ export class DropAndFusionGame extends EventEmitter<{
}; };
if (mono.shape === 'circle') { if (mono.shape === 'circle') {
return Matter.Bodies.circle(x, y, mono.size / 2, options); return Matter.Bodies.circle(x, y, mono.size / 2, options);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (mono.shape === 'rectangle') { } else if (mono.shape === 'rectangle') {
return Matter.Bodies.rectangle(x, y, mono.size, mono.size, options); return Matter.Bodies.rectangle(x, y, mono.size, mono.size, options);
} else { } else {
@ -224,7 +233,11 @@ export class DropAndFusionGame extends EventEmitter<{
// TODO: 効果音再生はコンポーネント側の責務なので移動する // TODO: 効果音再生はコンポーネント側の責務なので移動する
const pan = ((newX / this.gameWidth) - 0.5) * 2; 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('monoAdded', nextMono);
this.emit('fusioned', newX, newY, additionalScore); this.emit('fusioned', newX, newY, additionalScore);
@ -237,7 +250,7 @@ export class DropAndFusionGame extends EventEmitter<{
//} //}
//sound.playUrl({ //sound.playUrl({
// type: 'syuilo/bubble2', // type: 'syuilo/bubble2',
// volume: 1, // volume: this.sfxVolume,
//}); //});
} }
} }
@ -323,10 +336,14 @@ export class DropAndFusionGame extends EventEmitter<{
const energy = pairs.collision.depth; const energy = pairs.collision.depth;
if (energy > minCollisionEnergyForSound) { if (energy > minCollisionEnergyForSound) {
// TODO: 効果音再生はコンポーネント側の責務なので移動する // 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 pan = ((((bodyA.position.x + bodyB.position.x) / 2) / this.gameWidth) - 0.5) * 2;
const pitch = soundPitchMin + ((soundPitchMax - soundPitchMin) * (1 - (Math.min(10, energy) / 10))); const pitch = soundPitchMin + ((soundPitchMax - soundPitchMin) * (1 - (Math.min(10, energy) / 10)));
sound.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; this.loaded = true;
} }
public setSfxVolume(volume: number) {
this.sfxVolume = volume;
}
public getTextureImageUrl(mono: Mono) { public getTextureImageUrl(mono: Mono) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this.monoTextureUrls[mono.img]) { if (this.monoTextureUrls[mono.img]) {
@ -369,25 +390,53 @@ export class DropAndFusionGame extends EventEmitter<{
if (Date.now() - this.latestDroppedAt < this.DROP_INTERVAL) { if (Date.now() - this.latestDroppedAt < this.DROP_INTERVAL) {
return; return;
} }
const st = this.stock.shift()!; const head = this.stock.shift()!;
this.stock.push({ this.stock.push({
id: Math.random().toString(), id: Math.random().toString(),
mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)], mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
}); });
this.emit('changeStock', this.stock); 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 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(st.mono, x, 50 + st.mono.size / 2); const body = this.createBody(head.mono, x, 50 + head.mono.size / 2);
Matter.Composite.add(this.engine.world, body); Matter.Composite.add(this.engine.world, body);
this.activeBodyIds.push(body.id); this.activeBodyIds.push(body.id);
this.latestDroppedBodyId = body.id; this.latestDroppedBodyId = body.id;
this.latestDroppedAt = Date.now(); this.latestDroppedAt = Date.now();
this.emit('dropped'); this.emit('dropped');
this.emit('monoAdded', st.mono); this.emit('monoAdded', head.mono);
// TODO: 効果音再生はコンポーネント側の責務なので移動する // TODO: 効果音再生はコンポーネント側の責務なので移動する
const pan = ((x / this.gameWidth) - 0.5) * 2; 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() { public dispose() {

View File

@ -126,13 +126,13 @@ export async function loadAudio(url: string, options?: { useCache?: boolean; })
* *
* @param type * @param type
*/ */
export function play(operationType: OperationType) { export function playMisskeySfx(operationType: OperationType) {
const sound = defaultStore.state[`sound_${operationType}`]; const sound = defaultStore.state[`sound_${operationType}`];
if (_DEV_) console.log('play', operationType, sound); if (_DEV_) console.log('play', operationType, sound);
if (sound.type == null || !canPlay) return; if (sound.type == null || !canPlay) return;
canPlay = false; canPlay = false;
playFile(sound).finally(() => { playMisskeySfxFile(sound).finally(() => {
// ごく短時間に音が重複しないように // ごく短時間に音が重複しないように
setTimeout(() => { setTimeout(() => {
canPlay = true; canPlay = true;
@ -144,41 +144,53 @@ export function play(operationType: OperationType) {
* *
* @param soundStore * @param soundStore
*/ */
export async function playFile(soundStore: SoundStore) { export async function playMisskeySfxFile(soundStore: SoundStore) {
if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) { if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) {
return; 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 url = soundStore.type === '_driveFile_' ? soundStore.fileUrl : `/client-assets/sounds/${soundStore.type}.mp3`;
const buffer = await loadAudio(url); const buffer = await loadAudio(url);
if (!buffer) return; 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); const buffer = await loadAudio(url);
if (!buffer) return; 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; soundSource: AudioBufferSourceNode;
panNode: StereoPannerNode; panNode: StereoPannerNode;
gainNode: GainNode; gainNode: GainNode;
} | null { } {
const masterVolume = defaultStore.state.sound_masterVolume;
if (isMute() || masterVolume === 0 || volume === 0) {
return null;
}
const panNode = ctx.createStereoPanner(); const panNode = ctx.createStereoPanner();
panNode.pan.value = pan; panNode.pan.value = opts.pan ?? 0;
const gainNode = ctx.createGain(); const gainNode = ctx.createGain();
gainNode.gain.value = masterVolume * volume;
gainNode.gain.value = opts.volume ?? 1;
const soundSource = ctx.createBufferSource(); const soundSource = ctx.createBufferSource();
soundSource.buffer = buffer; soundSource.buffer = buffer;
soundSource.playbackRate.value = playbackRate; soundSource.playbackRate.value = opts.playbackRate ?? 1;
soundSource soundSource
.connect(panNode) .connect(panNode)
.connect(gainNode) .connect(gainNode)

View File

@ -420,6 +420,13 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device', where: 'device',
default: false, default: false,
}, },
dropAndFusion: {
where: 'device',
default: {
bgmVolume: 0.25,
sfxVolume: 1,
},
},
sound_masterVolume: { sound_masterVolume: {
where: 'device', where: 'device',

View File

@ -83,7 +83,7 @@ function onNotification(notification: Misskey.entities.Notification, isClient =
}, 6000); }, 6000);
} }
sound.play('notification'); sound.playMisskeySfx('notification');
} }
if ($i) { if ($i) {

View File

@ -123,7 +123,7 @@ const onStats = (stats) => {
current[domain].delayed = stats[domain].delayed; current[domain].delayed = stats[domain].delayed;
if (current[domain].waiting > 0 && widgetProps.sound && jammedAudioBuffer.value && !jammedSoundNodePlaying.value) { if (current[domain].waiting > 0 && widgetProps.sound && jammedAudioBuffer.value && !jammedSoundNodePlaying.value) {
const soundNode = sound.createSourceNode(jammedAudioBuffer.value, 1)?.soundSource; const soundNode = sound.createSourceNode(jammedAudioBuffer.value, {}).soundSource;
if (soundNode) { if (soundNode) {
jammedSoundNodePlaying.value = true; jammedSoundNodePlaying.value = true;
soundNode.onended = () => jammedSoundNodePlaying.value = false; soundNode.onended = () => jammedSoundNodePlaying.value = false;