From 762fa6a8d85691e2d5d94a46b23d7641feefd402 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Thu, 11 Jan 2024 12:34:03 +0900
Subject: [PATCH] enhance(drop-and-fusion): make game engine headless for
 server-side running

---
 .../src/pages/drop-and-fusion.game.vue        | 458 ++++++++++++------
 .../src/scripts/drop-and-fusion-engine.ts     | 359 ++++----------
 2 files changed, 397 insertions(+), 420 deletions(-)

diff --git a/packages/frontend/src/pages/drop-and-fusion.game.vue b/packages/frontend/src/pages/drop-and-fusion.game.vue
index acaebbadf7..3fefb49fae 100644
--- a/packages/frontend/src/pages/drop-and-fusion.game.vue
+++ b/packages/frontend/src/pages/drop-and-fusion.game.vue
@@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<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;"/>
+						<img v-if="holdingStock" :src="getTextureImageUrl(holdingStock.mono)" style="width: 32px; margin-left: 8px; vertical-align: bottom;"/>
 					</div>
 					<div :class="[$style.frameInner, $style.stock]" style="text-align: center;">
 						<TransitionGroup
@@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 							:leaveToClass="$style.transition_stock_leaveTo"
 							:moveClass="$style.transition_stock_move"
 						>
-							<img v-for="x in stock" :key="x.id" :src="game.getTextureImageUrl(x.mono)" style="width: 32px; vertical-align: bottom;"/>
+							<img v-for="x in stock" :key="x.id" :src="getTextureImageUrl(x.mono)" style="width: 32px; vertical-align: bottom;"/>
 						</TransitionGroup>
 					</div>
 				</div>
@@ -65,7 +65,7 @@ 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="{ marginBottom: -((currentPick?.mono.size * viewScale) / 2) + 'px', left: -((currentPick?.mono.size * viewScale) / 2) + 'px', width: `${currentPick?.mono.size * viewScale}px` }"/>
+						<img v-if="currentPick" :key="currentPick.id" :src="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"/>
@@ -81,14 +81,17 @@ SPDX-License-Identifier: AGPL-3.0-only
 				</div>
 				<div v-if="replaying" :class="$style.replayIndicator"><span :class="$style.replayIndicatorText"><i class="ti ti-player-play"></i> {{ i18n.ts.replaying }}</span></div>
 			</div>
-			<div v-if="replaying" style="display: flex;">
-				<div :class="$style.frame" style="flex: 1; margin-right: 10px;">
-					<div :class="$style.frameInner">
-						<div class="_buttonsCenter">
-							<MkButton @click="endReplay"><i class="ti ti-player-stop"></i> END REPLAY</MkButton>
-							<MkButton :primary="replayPlaybackRate === 2" @click="replayPlaybackRate = replayPlaybackRate === 2 ? 1 : 2"><i class="ti ti-player-track-next"></i> x2</MkButton>
-							<MkButton :primary="replayPlaybackRate === 4" @click="replayPlaybackRate = replayPlaybackRate === 4 ? 1 : 4"><i class="ti ti-player-track-next"></i> x4</MkButton>
-						</div>
+			<div v-if="replaying" :class="$style.frame">
+				<div :class="$style.frameInner">
+					<div style="background: #0004;">
+						<div style="height: 10px; background: var(--accent); will-change: width;" :style="{ width: `${(currentFrame / endedAtFrame) * 100}%` }"></div>
+					</div>
+				</div>
+				<div :class="$style.frameInner">
+					<div class="_buttonsCenter">
+						<MkButton @click="endReplay"><i class="ti ti-player-stop"></i> END</MkButton>
+						<MkButton :primary="replayPlaybackRate === 2" @click="replayPlaybackRate = replayPlaybackRate === 2 ? 1 : 2"><i class="ti ti-player-track-next"></i> x2</MkButton>
+						<MkButton :primary="replayPlaybackRate === 4" @click="replayPlaybackRate = replayPlaybackRate === 4 ? 1 : 4"><i class="ti ti-player-track-next"></i> x4</MkButton>
 					</div>
 				</div>
 			</div>
@@ -140,6 +143,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { onDeactivated, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
+import * as Matter from 'matter-js';
 import * as Misskey from 'misskey-js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
@@ -385,9 +389,6 @@ const SQUARE_MONOS: Mono[] = [{
 	spriteScale: 1.12,
 }];
 
-const GAME_WIDTH = 450;
-const GAME_HEIGHT = 600;
-
 const props = defineProps<{
 	gameMode: 'normal' | 'square';
 	mute: boolean;
@@ -397,12 +398,23 @@ const emit = defineEmits<{
 	(ev: 'end'): void;
 }>();
 
+const monoDefinitions = props.gameMode === 'normal' ? NORAML_MONOS : SQUARE_MONOS;
+
 let viewScale = 1;
-let game: DropAndFusionGame;
+let seed: string = Date.now().toString();
 let containerElRect: DOMRect | null = null;
-let seed: string;
 let logs: ReturnType<DropAndFusionGame['getLogs']> | null = null;
+let endedAtFrame = 0;
 let bgmNodes: ReturnType<typeof sound.createSourceNode> | null = null;
+let renderer: Matter.Render | null = null;
+let monoTextures: Record<string, Blob> = {};
+let monoTextureUrls: Record<string, string> = {};
+let tickRaf: number | null = null;
+let game = new DropAndFusionGame({
+	seed: seed,
+	monoDefinitions,
+});
+attachGameEvents();
 
 const containerEl = shallowRef<HTMLElement>();
 const canvasEl = shallowRef<HTMLCanvasElement>();
@@ -421,6 +433,7 @@ const highScore = ref<number | null>(null);
 const showConfig = ref(false);
 const replaying = ref(false);
 const replayPlaybackRate = ref(1);
+const currentFrame = ref(0);
 const bgmVolume = ref(defaultStore.state.dropAndFusion.bgmVolume);
 const sfxVolume = ref(defaultStore.state.dropAndFusion.sfxVolume);
 
@@ -434,50 +447,125 @@ watch(bgmVolume, (newValue) => {
 	}
 });
 
-watch(sfxVolume, (newValue) => {
-	// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
-	if (game) {
-		game.setSfxVolume(props.mute ? 0 : newValue);
-	}
-});
-
-function createGameInstance() {
-	return new DropAndFusionGame({
-		width: GAME_WIDTH,
-		height: GAME_HEIGHT,
+function createRendererInstance(game: DropAndFusionGame) {
+	return Matter.Render.create({
+		engine: game.engine,
 		canvas: canvasEl.value!,
-		seed: seed,
-		sfxVolume: props.mute ? 0 : sfxVolume.value,
-		...(
-			props.gameMode === 'normal' ? {
-				monoDefinitions: NORAML_MONOS,
-			} : {
-				monoDefinitions: SQUARE_MONOS,
-			}
-		),
+		options: {
+			width: game.GAME_WIDTH,
+			height: game.GAME_HEIGHT,
+			background: 'transparent', // transparent to hide
+			wireframeBackground: 'transparent', // transparent to hide
+			wireframes: false,
+			showSleeping: false,
+			pixelRatio: Math.max(2, window.devicePixelRatio),
+		},
 	});
 }
 
-async function start() {
-	seed = Date.now().toString();
+function loadMonoTextures() {
+	async function loadSingleMonoTexture(mono: Mono) {
+		if (renderer == null) return;
 
-	game = createGameInstance();
-	attachGameEvents();
-	await game.load();
+		// Matter-js内にキャッシュがある場合はスキップ
+		if (renderer.textures[mono.img]) return;
+
+		let src = mono.img;
+		// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+		if (monoTextureUrls[mono.img]) {
+			src = monoTextureUrls[mono.img];
+			// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+		} else if (monoTextures[mono.img]) {
+			src = URL.createObjectURL(monoTextures[mono.img]);
+			monoTextureUrls[mono.img] = src;
+		} else {
+			const res = await fetch(mono.img);
+			const blob = await res.blob();
+			monoTextures[mono.img] = blob;
+			src = URL.createObjectURL(blob);
+			monoTextureUrls[mono.img] = src;
+		}
+
+		const image = new Image();
+		image.src = src;
+		renderer.textures[mono.img] = image;
+	}
+
+	return Promise.all(monoDefinitions.map(x => loadSingleMonoTexture(x)));
+}
+
+function getTextureImageUrl(mono: Mono) {
+	// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+	if (monoTextureUrls[mono.img]) {
+		return monoTextureUrls[mono.img];
+
+		// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+	} else if (monoTextures[mono.img]) {
+		// Gameクラス内にキャッシュがある場合はそれを使う
+		const out = URL.createObjectURL(monoTextures[mono.img]);
+		monoTextureUrls[mono.img] = out;
+		return out;
+	} else {
+		return mono.img;
+	}
+}
+
+function tick() {
+	const hasNextTick = game.tick();
+	if (hasNextTick) {
+		tickRaf = window.requestAnimationFrame(tick);
+	} else {
+		tickRaf = null;
+	}
+}
+
+function tickReplay() {
+	let hasNextTick;
+	for (let i = 0; i < replayPlaybackRate.value; i++) {
+		const log = logs!.find(x => x.frame === game.frame);
+		if (log) {
+			switch (log.operation) {
+				case 'drop': {
+					game.drop(log.x);
+					break;
+				}
+				case 'hold': {
+					game.hold();
+					break;
+				}
+				case 'surrender': {
+					game.surrender();
+					break;
+				}
+				default:
+					break;
+			}
+		}
+
+		hasNextTick = game.tick();
+		currentFrame.value = game.frame;
+		if (!hasNextTick) break;
+	}
+
+	if (hasNextTick) {
+		tickRaf = window.requestAnimationFrame(tickReplay);
+	} else {
+		tickRaf = null;
+	}
+}
+
+async function start() {
+	await loadMonoTextures();
+	renderer = createRendererInstance(game);
+	Matter.Render.lookAt(renderer, {
+		min: { x: 0, y: 0 },
+		max: { x: game.GAME_WIDTH, y: game.GAME_HEIGHT },
+	});
+	Matter.Render.run(renderer);
 	game.start();
+	window.requestAnimationFrame(tick);
 
 	gameLoaded.value = true;
-
-	if (bgmNodes == null) {
-		const bgmBuffer = await sound.loadAudio('/client-assets/drop-and-fusion/bgm_1.mp3');
-		if (!bgmBuffer) return;
-		bgmNodes = sound.createSourceNode(bgmBuffer, {
-			volume: props.mute ? 0 : bgmVolume.value,
-		});
-		if (!bgmNodes) return;
-		bgmNodes.soundSource.loop = true;
-		bgmNodes.soundSource.start();
-	}
 }
 
 function onClick(ev: MouseEvent) {
@@ -507,7 +595,7 @@ function onTouchmove(ev: TouchEvent) {
 }
 
 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.GAME_WIDTH - game.PLAYAREA_MARGIN) / game.GAME_WIDTH), Math.max(rect.width * (game.PLAYAREA_MARGIN / game.GAME_WIDTH), x));
 }
 
 function hold() {
@@ -525,11 +613,17 @@ async function surrender() {
 
 async function restart() {
 	reset();
+	game = new DropAndFusionGame({
+		seed: seed,
+		monoDefinitions,
+	});
+	attachGameEvents();
 	await start();
 }
 
 function reset() {
-	game.dispose();
+	dispose();
+	seed = Date.now().toString();
 	isGameOver.value = false;
 	replaying.value = false;
 	replayPlaybackRate.value = 1;
@@ -544,9 +638,12 @@ function reset() {
 	gameLoaded.value = false;
 }
 
-function end() {
+function dispose() {
 	game.dispose();
-	bgmNodes?.soundSource.stop();
+	Matter.Render.stop(renderer);
+	if (tickRaf) {
+		window.cancelAnimationFrame(tickRaf);
+	}
 }
 
 function backToTitle() {
@@ -555,17 +652,28 @@ function backToTitle() {
 
 function replay() {
 	replaying.value = true;
-	game.dispose();
-	game = createGameInstance();
+	dispose();
+	game = new DropAndFusionGame({
+		seed: seed,
+		monoDefinitions,
+		replaying: true,
+	});
 	attachGameEvents();
-	os.promiseDialog(game.load(), async () => {
-		game.start(logs!);
+	os.promiseDialog(loadMonoTextures(), async () => {
+		renderer = createRendererInstance(game);
+		Matter.Render.lookAt(renderer, {
+			min: { x: 0, y: 0 },
+			max: { x: game.GAME_WIDTH, y: game.GAME_HEIGHT },
+		});
+		Matter.Render.run(renderer);
+		game.start();
+		window.requestAnimationFrame(tickReplay);
 	});
 }
 
 function endReplay() {
 	replaying.value = false;
-	game.dispose();
+	dispose();
 }
 
 function exportLog() {
@@ -579,88 +687,6 @@ function exportLog() {
 	os.success();
 }
 
-function attachGameEvents() {
-	game.addListener('changeScore', value => {
-		score.value = value;
-	});
-
-	game.addListener('changeCombo', value => {
-		if (value === 0) {
-			comboPrev.value = combo.value;
-		} else {
-			comboPrev.value = value;
-		}
-		maxCombo.value = Math.max(maxCombo.value, value);
-		combo.value = value;
-	});
-
-	game.addListener('changeStock', value => {
-		currentPick.value = JSON.parse(JSON.stringify(value[0]));
-		stock.value = JSON.parse(JSON.stringify(value.slice(1)));
-	});
-
-	game.addListener('changeHolding', value => {
-		holdingStock.value = value;
-	});
-
-	game.addListener('dropped', () => {
-		if (replaying.value) return;
-
-		dropReady.value = false;
-		window.setTimeout(() => {
-			if (!isGameOver.value) {
-				dropReady.value = true;
-			}
-		}, game.DROP_INTERVAL);
-	});
-
-	game.addListener('fusioned', (x, y, scoreDelta) => {
-		if (!canvasEl.value) return;
-
-		const rect = canvasEl.value.getBoundingClientRect();
-		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');
-	});
-
-	game.addListener('monoAdded', (mono) => {
-		if (replaying.value) return;
-
-		// 実績関連
-		if (mono.level === 10) {
-			claimAchievement('bubbleGameExplodingHead');
-
-			const monos = game.getActiveMonos();
-			if (monos.filter(x => x.level === 10).length >= 2) {
-				claimAchievement('bubbleGameDoubleExplodingHead');
-			}
-		}
-	});
-
-	game.addListener('gameOver', () => {
-		if (replaying.value) {
-			endReplay();
-			return;
-		}
-
-		logs = game.getLogs();
-		currentPick.value = null;
-		dropReady.value = false;
-		isGameOver.value = true;
-
-		if (score.value > (highScore.value ?? 0)) {
-			highScore.value = score.value;
-
-			misskeyApi('i/registry/set', {
-				scope: ['dropAndFusionGame'],
-				key: 'highScore:' + props.gameMode,
-				value: highScore.value,
-			});
-		}
-	});
-}
-
 function updateSettings<
 	K extends keyof typeof defaultStore.state.dropAndFusion,
 	V extends typeof defaultStore.state.dropAndFusion[K],
@@ -686,8 +712,8 @@ function loadImage(url: string) {
 function getGameImageDriveFile() {
 	return new Promise<Misskey.entities.DriveFile | null>(res => {
 		const dcanvas = document.createElement('canvas');
-		dcanvas.width = GAME_WIDTH;
-		dcanvas.height = GAME_HEIGHT;
+		dcanvas.width = game.GAME_WIDTH;
+		dcanvas.height = game.GAME_HEIGHT;
 		const ctx = dcanvas.getContext('2d');
 		if (!ctx || !canvasEl.value) return res(null);
 		Promise.all([
@@ -696,11 +722,11 @@ function getGameImageDriveFile() {
 		]).then((images) => {
 			const [frame, logo] = images;
 			ctx.fillStyle = '#fff';
-			ctx.fillRect(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.fillRect(0, 0, game.GAME_WIDTH, game.GAME_HEIGHT);
+			ctx.drawImage(frame, 0, 0, game.GAME_WIDTH, game.GAME_HEIGHT);
+			ctx.drawImage(canvasEl.value!, 0, 0, game.GAME_WIDTH, game.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.drawImage(logo, game.GAME_WIDTH * 0.55, 6, game.GAME_WIDTH * 0.45, game.GAME_WIDTH * 0.45 * (logo.height / logo.width));
 			ctx.globalAlpha = 1;
 
 			dcanvas.toBlob(blob => {
@@ -745,11 +771,126 @@ SCORE: ${score.value} (MAX CHAIN: ${maxCombo.value})`,
 	});
 }
 
+function attachGameEvents() {
+	game.addListener('changeScore', value => {
+		score.value = value;
+	});
+
+	game.addListener('changeCombo', value => {
+		if (value === 0) {
+			comboPrev.value = combo.value;
+		} else {
+			comboPrev.value = value;
+		}
+		maxCombo.value = Math.max(maxCombo.value, value);
+		combo.value = value;
+	});
+
+	game.addListener('changeStock', value => {
+		currentPick.value = JSON.parse(JSON.stringify(value[0]));
+		stock.value = JSON.parse(JSON.stringify(value.slice(1)));
+	});
+
+	game.addListener('changeHolding', value => {
+		holdingStock.value = value;
+
+		sound.playUrl('/client-assets/drop-and-fusion/hold.mp3', {
+			volume: 0.5 * sfxVolume.value,
+		});
+	});
+
+	game.addListener('dropped', (x) => {
+		const panV = x - game.PLAYAREA_MARGIN;
+		const panW = game.GAME_WIDTH - game.PLAYAREA_MARGIN - game.PLAYAREA_MARGIN;
+		const pan = ((panV / panW) - 0.5) * 2;
+		sound.playUrl('/client-assets/drop-and-fusion/poi2.mp3', {
+			volume: sfxVolume.value,
+			pan,
+			playbackRate: replayPlaybackRate.value,
+		});
+
+		if (replaying.value) return;
+
+		dropReady.value = false;
+		window.setTimeout(() => {
+			if (!isGameOver.value) {
+				dropReady.value = true;
+			}
+		}, game.DROP_INTERVAL);
+	});
+
+	game.addListener('fusioned', (x, y, scoreDelta) => {
+		if (!canvasEl.value) return;
+
+		const rect = canvasEl.value.getBoundingClientRect();
+		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');
+	});
+
+	game.addListener('monoAdded', (mono) => {
+		if (replaying.value) return;
+
+		// 実績関連
+		if (mono.level === 10) {
+			claimAchievement('bubbleGameExplodingHead');
+
+			const monos = game.getActiveMonos();
+			if (monos.filter(x => x.level === 10).length >= 2) {
+				claimAchievement('bubbleGameDoubleExplodingHead');
+			}
+		}
+	});
+
+	game.addListener('gameOver', () => {
+		sound.playUrl('/client-assets/drop-and-fusion/gameover.mp3', {
+			volume: sfxVolume.value,
+		});
+
+		if (replaying.value) {
+			endReplay();
+			return;
+		}
+
+		logs = game.getLogs();
+		endedAtFrame = game.frame;
+		currentPick.value = null;
+		dropReady.value = false;
+		isGameOver.value = true;
+
+		if (score.value > (highScore.value ?? 0)) {
+			highScore.value = score.value;
+
+			misskeyApi('i/registry/set', {
+				scope: ['dropAndFusionGame'],
+				key: 'highScore:' + props.gameMode,
+				value: highScore.value,
+			});
+		}
+	});
+
+	game.addListener('sfx', (type, params) => {
+		if (props.mute) return;
+
+		const soundUrl =
+			type === 'fusion' ? '/client-assets/drop-and-fusion/bubble2.mp3' :
+			type === 'collision' ? '/client-assets/drop-and-fusion/poi1.mp3' :
+			null as never;
+
+		sound.playUrl(soundUrl, {
+			volume: params.volume * sfxVolume.value,
+			pan: params.pan,
+			playbackRate: params.pitch * replayPlaybackRate.value,
+		});
+	});
+}
+
 useInterval(() => {
 	if (!canvasEl.value) return;
 	const actualCanvasWidth = canvasEl.value.getBoundingClientRect().width;
 	if (actualCanvasWidth === 0) return;
-	viewScale = actualCanvasWidth / GAME_WIDTH;
+	viewScale = actualCanvasWidth / game.GAME_WIDTH;
 	containerElRect = containerEl.value?.getBoundingClientRect() ?? null;
 }, 1000, { immediate: false, afterMounted: true });
 
@@ -763,15 +904,26 @@ onMounted(async () => {
 		highScore.value = null;
 	}
 
-	start();
+	await start();
+
+	const bgmBuffer = await sound.loadAudio('/client-assets/drop-and-fusion/bgm_1.mp3');
+	if (!bgmBuffer) return;
+	bgmNodes = sound.createSourceNode(bgmBuffer, {
+		volume: props.mute ? 0 : bgmVolume.value,
+	});
+	if (!bgmNodes) return;
+	bgmNodes.soundSource.loop = true;
+	bgmNodes.soundSource.start();
 });
 
 onUnmounted(() => {
-	end();
+	dispose();
+	bgmNodes?.soundSource.stop();
 });
 
 onDeactivated(() => {
-	end();
+	dispose();
+	bgmNodes?.soundSource.stop();
 });
 
 definePageMetadata({
diff --git a/packages/frontend/src/scripts/drop-and-fusion-engine.ts b/packages/frontend/src/scripts/drop-and-fusion-engine.ts
index d64c6015a5..c6eabc8af3 100644
--- a/packages/frontend/src/scripts/drop-and-fusion-engine.ts
+++ b/packages/frontend/src/scripts/drop-and-fusion-engine.ts
@@ -6,7 +6,6 @@
 import { EventEmitter } from 'eventemitter3';
 import * as Matter from 'matter-js';
 import seedrandom from 'seedrandom';
-import * as sound from '@/scripts/sound.js';
 
 export type Mono = {
 	id: string;
@@ -39,41 +38,41 @@ export class DropAndFusionGame extends EventEmitter<{
 	changeCombo: (newCombo: number) => void;
 	changeStock: (newStock: { id: string; mono: Mono }[]) => void;
 	changeHolding: (newHolding: { id: string; mono: Mono } | null) => void;
-	dropped: () => void;
+	dropped: (x: number) => void;
 	fusioned: (x: number, y: number, scoreDelta: number) => void;
 	monoAdded: (mono: Mono) => void;
 	gameOver: () => void;
+	sfx(type: string, params: { volume: number; pan: number; pitch: number; }): void;
 }> {
 	private PHYSICS_QUALITY_FACTOR = 16; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる、逆に高すぎても何故か不安定になる
 	private COMBO_INTERVAL = 60; // frame
+	public readonly GAME_WIDTH = 450;
+	public readonly GAME_HEIGHT = 600;
 	public readonly DROP_INTERVAL = 500;
 	public readonly PLAYAREA_MARGIN = 25;
 	private STOCK_MAX = 4;
 	private TICK_DELTA = 1000 / 60; // 60fps
-	private loaded = false;
-	private frame = 0;
-	private engine: Matter.Engine;
-	private render: Matter.Render;
-	private tickRaf: ReturnType<typeof requestAnimationFrame> | null = null;
+	public frame = 0;
+	public engine: Matter.Engine;
 	private tickCallbackQueue: { frame: number; callback: () => void; }[] = [];
 	private overflowCollider: Matter.Body;
 	private isGameOver = false;
-	private gameWidth: number;
-	private gameHeight: number;
 	private monoDefinitions: Mono[] = [];
-	private monoTextures: Record<string, Blob> = {};
-	private monoTextureUrls: Record<string, string> = {};
 	private rng: () => number;
 	private logs: Log[] = [];
 	private replaying = false;
 
-	private sfxVolume = 1;
-
 	/**
 	 * フィールドに出ていて、かつ合体の対象となるアイテム
 	 */
 	private activeBodyIds: Matter.Body['id'][] = [];
 
+	/**
+	 * fusion予約アイテムのペア
+	 * TODO: これらのモノは光らせるなどの演出をすると視覚的に楽しそう
+	 */
+	private fusionReservedPairs: { bodyA: Matter.Body; bodyB: Matter.Body }[] = [];
+
 	private latestDroppedBodyId: Matter.Body['id'] | null = null;
 
 	private latestDroppedAt = 0;
@@ -99,31 +98,17 @@ export class DropAndFusionGame extends EventEmitter<{
 		this.emit('changeScore', value);
 	}
 
-	private comboIntervalId: number | null = null;
-
 	public replayPlaybackRate = 1;
 
-	constructor(opts: {
-		canvas: HTMLCanvasElement;
-		width: number;
-		height: number;
-		monoDefinitions: Mono[];
-		seed: string;
-		sfxVolume?: number;
-	}) {
+	constructor(env: { monoDefinitions: Mono[]; seed: string; replaying?: boolean }) {
 		super();
 
+		this.replaying = !!env.replaying;
+		this.monoDefinitions = env.monoDefinitions;
+		this.rng = seedrandom(env.seed);
+
 		this.tick = this.tick.bind(this);
 
-		this.gameWidth = opts.width;
-		this.gameHeight = opts.height;
-		this.monoDefinitions = opts.monoDefinitions;
-		this.rng = seedrandom(opts.seed);
-
-		if (opts.sfxVolume) {
-			this.sfxVolume = opts.sfxVolume;
-		}
-
 		this.engine = Matter.Engine.create({
 			constraintIterations: 2 * this.PHYSICS_QUALITY_FACTOR,
 			positionIterations: 6 * this.PHYSICS_QUALITY_FACTOR,
@@ -138,22 +123,6 @@ export class DropAndFusionGame extends EventEmitter<{
 			enableSleeping: false,
 		});
 
-		this.render = Matter.Render.create({
-			engine: this.engine,
-			canvas: opts.canvas,
-			options: {
-				width: this.gameWidth,
-				height: this.gameHeight,
-				background: 'transparent', // transparent to hide
-				wireframeBackground: 'transparent', // transparent to hide
-				wireframes: false,
-				showSleeping: false,
-				pixelRatio: Math.max(2, window.devicePixelRatio),
-			},
-		});
-
-		Matter.Render.run(this.render);
-
 		this.engine.world.bodies = [];
 
 		//#region walls
@@ -170,13 +139,13 @@ export class DropAndFusionGame extends EventEmitter<{
 
 		const thickness = 100;
 		Matter.Composite.add(this.engine.world, [
-			Matter.Bodies.rectangle(this.gameWidth / 2, this.gameHeight + (thickness / 2) - this.PLAYAREA_MARGIN, this.gameWidth, thickness, WALL_OPTIONS),
-			Matter.Bodies.rectangle(this.gameWidth + (thickness / 2) - this.PLAYAREA_MARGIN, this.gameHeight / 2, thickness, this.gameHeight, WALL_OPTIONS),
-			Matter.Bodies.rectangle(-((thickness / 2) - this.PLAYAREA_MARGIN), this.gameHeight / 2, thickness, this.gameHeight, WALL_OPTIONS),
+			Matter.Bodies.rectangle(this.GAME_WIDTH / 2, this.GAME_HEIGHT + (thickness / 2) - this.PLAYAREA_MARGIN, this.GAME_WIDTH, thickness, WALL_OPTIONS),
+			Matter.Bodies.rectangle(this.GAME_WIDTH + (thickness / 2) - this.PLAYAREA_MARGIN, this.GAME_HEIGHT / 2, thickness, this.GAME_HEIGHT, WALL_OPTIONS),
+			Matter.Bodies.rectangle(-((thickness / 2) - this.PLAYAREA_MARGIN), this.GAME_HEIGHT / 2, thickness, this.GAME_HEIGHT, WALL_OPTIONS),
 		]);
 		//#endregion
 
-		this.overflowCollider = Matter.Bodies.rectangle(this.gameWidth / 2, 0, this.gameWidth, 200, {
+		this.overflowCollider = Matter.Bodies.rectangle(this.GAME_WIDTH / 2, 0, this.GAME_WIDTH, 200, {
 			isStatic: true,
 			isSensor: true,
 			render: {
@@ -185,12 +154,6 @@ export class DropAndFusionGame extends EventEmitter<{
 			},
 		});
 		Matter.Composite.add(this.engine.world, this.overflowCollider);
-
-		// fit the render viewport to the scene
-		Matter.Render.lookAt(this.render, {
-			min: { x: 0, y: 0 },
-			max: { x: this.gameWidth, y: this.gameHeight },
-		});
 	}
 
 	private createBody(mono: Mono, x: number, y: number) {
@@ -256,29 +219,69 @@ export class DropAndFusionGame extends EventEmitter<{
 			const additionalScore = Math.round(currentMono.score * comboBonus);
 			this.score += additionalScore;
 
-			// TODO: 効果音再生はコンポーネント側の責務なので移動するべき?
-			const panV = newX - this.PLAYAREA_MARGIN;
-			const panW = this.gameWidth - this.PLAYAREA_MARGIN - this.PLAYAREA_MARGIN;
-			const pan = ((panV / panW) - 0.5) * 2;
-			sound.playUrl('/client-assets/drop-and-fusion/bubble2.mp3', {
-				volume: this.sfxVolume,
-				pan,
-				playbackRate: nextMono.sfxPitch * this.replayPlaybackRate,
-			});
-
 			this.emit('monoAdded', nextMono);
 			this.emit('fusioned', newX, newY, additionalScore);
+
+			const panV = newX - this.PLAYAREA_MARGIN;
+			const panW = this.GAME_WIDTH - this.PLAYAREA_MARGIN - this.PLAYAREA_MARGIN;
+			const pan = ((panV / panW) - 0.5) * 2;
+			this.emit('sfx', 'fusion', { volume: 1, pan, pitch: nextMono.sfxPitch });
 		} else {
-			//const VELOCITY = 30;
-			//for (let i = 0; i < 10; i++) {
-			//	const body = createBody(FRUITS.find(x => x.level === (1 + Math.floor(this.rng() * 3)))!, x + ((this.rng() * VELOCITY) - (VELOCITY / 2)), y + ((this.rng() * VELOCITY) - (VELOCITY / 2)));
-			//	Matter.Composite.add(world, body);
-			//	bodies.push(body);
-			//}
-			//sound.playUrl({
-			//	type: 'syuilo/bubble2',
-			//	volume: this.sfxVolume,
-			//});
+			// nop
+		}
+	}
+
+	private onCollision(event: Matter.IEventCollision<Matter.Engine>) {
+		const minCollisionEnergyForSound = 2.5;
+		const maxCollisionEnergyForSound = 9;
+		const soundPitchMax = 4;
+		const soundPitchMin = 0.5;
+
+		for (const pairs of event.pairs) {
+			const { bodyA, bodyB } = pairs;
+
+			if (bodyA.id === this.overflowCollider.id || bodyB.id === this.overflowCollider.id) {
+				if (bodyA.id === this.latestDroppedBodyId || bodyB.id === this.latestDroppedBodyId) {
+					continue;
+				}
+				this.gameOver();
+				break;
+			}
+
+			const shouldFusion = (bodyA.label === bodyB.label) &&
+				!this.fusionReservedPairs.some(x =>
+					x.bodyA.id === bodyA.id ||
+					x.bodyA.id === bodyB.id ||
+					x.bodyB.id === bodyA.id ||
+					x.bodyB.id === bodyB.id);
+
+			if (shouldFusion) {
+				if (this.activeBodyIds.includes(bodyA.id) && this.activeBodyIds.includes(bodyB.id)) {
+					this.fusion(bodyA, bodyB);
+				} else {
+					this.fusionReservedPairs.push({ bodyA, bodyB });
+					this.tickCallbackQueue.push({
+						frame: this.frame + 6,
+						callback: () => {
+							this.fusionReservedPairs = this.fusionReservedPairs.filter(x => x.bodyA.id !== bodyA.id && x.bodyB.id !== bodyB.id);
+							this.fusion(bodyA, bodyB);
+						},
+					});
+				}
+			} else {
+				const energy = pairs.collision.depth;
+				if (energy > minCollisionEnergyForSound) {
+					const volume = (Math.min(maxCollisionEnergyForSound, energy - minCollisionEnergyForSound) / maxCollisionEnergyForSound) / 4;
+					const panV =
+						pairs.bodyA.label === '_wall_' ? bodyB.position.x - this.PLAYAREA_MARGIN :
+						pairs.bodyB.label === '_wall_' ? bodyA.position.x - this.PLAYAREA_MARGIN :
+						((bodyA.position.x + bodyB.position.x) / 2) - this.PLAYAREA_MARGIN;
+					const panW = this.GAME_WIDTH - this.PLAYAREA_MARGIN - this.PLAYAREA_MARGIN;
+					const pan = ((panV / panW) - 0.5) * 2;
+					const pitch = soundPitchMin + ((soundPitchMax - soundPitchMin) * (1 - (Math.min(10, energy) / 10)));
+					this.emit('sfx', 'collision', { volume, pan, pitch });
+				}
+			}
 		}
 	}
 
@@ -293,50 +296,10 @@ export class DropAndFusionGame extends EventEmitter<{
 
 	private gameOver() {
 		this.isGameOver = true;
-		if (this.tickRaf) window.cancelAnimationFrame(this.tickRaf);
-		this.tickRaf = null;
 		this.emit('gameOver');
-
-		// TODO: 効果音再生はコンポーネント側の責務なので移動するべき?
-		sound.playUrl('/client-assets/drop-and-fusion/gameover.mp3', {
-			volume: this.sfxVolume,
-		});
 	}
 
-	/** テクスチャをすべてキャッシュする */
-	private async loadMonoTextures() {
-		async function loadSingleMonoTexture(mono: Mono, game: DropAndFusionGame) {
-			// Matter-js内にキャッシュがある場合はスキップ
-			if (game.render.textures[mono.img]) return;
-
-			let src = mono.img;
-			// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
-			if (game.monoTextureUrls[mono.img]) {
-				src = game.monoTextureUrls[mono.img];
-			// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
-			} else if (game.monoTextures[mono.img]) {
-				src = URL.createObjectURL(game.monoTextures[mono.img]);
-				game.monoTextureUrls[mono.img] = src;
-			} else {
-				const res = await fetch(mono.img);
-				const blob = await res.blob();
-				game.monoTextures[mono.img] = blob;
-				src = URL.createObjectURL(blob);
-				game.monoTextureUrls[mono.img] = src;
-			}
-
-			const image = new Image();
-			image.src = src;
-			game.render.textures[mono.img] = image;
-		}
-
-		return Promise.all(this.monoDefinitions.map(x => loadSingleMonoTexture(x, this)));
-	}
-
-	public start(logs?: Log[]) {
-		if (!this.loaded) throw new Error('game is not loaded yet');
-		if (logs) this.replaying = true;
-
+	public start() {
 		for (let i = 0; i < this.STOCK_MAX; i++) {
 			this.stock.push({
 				id: this.rng().toString(),
@@ -345,118 +308,20 @@ export class DropAndFusionGame extends EventEmitter<{
 		}
 		this.emit('changeStock', this.stock);
 
-		// TODO: fusion予約状態のアイテムは光らせるなどの演出をすると楽しそう
-		let fusionReservedPairs: { bodyA: Matter.Body; bodyB: Matter.Body }[] = [];
-
-		const minCollisionEnergyForSound = 2.5;
-		const maxCollisionEnergyForSound = 9;
-		const soundPitchMax = 4;
-		const soundPitchMin = 0.5;
-
-		Matter.Events.on(this.engine, 'collisionStart', (event) => {
-			for (const pairs of event.pairs) {
-				const { bodyA, bodyB } = pairs;
-				if (bodyA.id === this.overflowCollider.id || bodyB.id === this.overflowCollider.id) {
-					if (bodyA.id === this.latestDroppedBodyId || bodyB.id === this.latestDroppedBodyId) {
-						continue;
-					}
-					this.gameOver();
-					break;
-				}
-				const shouldFusion = (bodyA.label === bodyB.label) && !fusionReservedPairs.some(x => x.bodyA.id === bodyA.id || x.bodyA.id === bodyB.id || x.bodyB.id === bodyA.id || x.bodyB.id === bodyB.id);
-				if (shouldFusion) {
-					if (this.activeBodyIds.includes(bodyA.id) && this.activeBodyIds.includes(bodyB.id)) {
-						this.fusion(bodyA, bodyB);
-					} else {
-						fusionReservedPairs.push({ bodyA, bodyB });
-						this.tickCallbackQueue.push({
-							frame: this.frame + 6,
-							callback: () => {
-								fusionReservedPairs = fusionReservedPairs.filter(x => x.bodyA.id !== bodyA.id && x.bodyB.id !== bodyB.id);
-								this.fusion(bodyA, bodyB);
-							},
-						});
-					}
-				} else {
-					const energy = pairs.collision.depth;
-					if (energy > minCollisionEnergyForSound) {
-						// TODO: 効果音再生はコンポーネント側の責務なので移動するべき?
-						const vol = ((Math.min(maxCollisionEnergyForSound, energy - minCollisionEnergyForSound) / maxCollisionEnergyForSound) / 4) * this.sfxVolume;
-						const panV =
-							pairs.bodyA.label === '_wall_' ? bodyB.position.x - this.PLAYAREA_MARGIN :
-							pairs.bodyB.label === '_wall_' ? bodyA.position.x - this.PLAYAREA_MARGIN :
-							((bodyA.position.x + bodyB.position.x) / 2) - this.PLAYAREA_MARGIN;
-						const panW = this.gameWidth - this.PLAYAREA_MARGIN - this.PLAYAREA_MARGIN;
-						const pan = ((panV / panW) - 0.5) * 2;
-						const pitch = soundPitchMin + ((soundPitchMax - soundPitchMin) * (1 - (Math.min(10, energy) / 10)));
-						sound.playUrl('/client-assets/drop-and-fusion/poi1.mp3', {
-							volume: vol,
-							pan,
-							playbackRate: pitch * this.replayPlaybackRate,
-						});
-					}
-				}
-			}
-		});
-
-		if (logs) {
-			const playTick = () => {
-				for (let i = 0; i < this.replayPlaybackRate; i++) {
-					this.frame++;
-					if (this.latestFusionedAt < this.frame - this.COMBO_INTERVAL) {
-						this.combo = 0;
-					}
-					const log = logs.find(x => x.frame === this.frame - 1);
-					if (log) {
-						switch (log.operation) {
-							case 'drop': {
-								this.drop(log.x);
-								break;
-							}
-							case 'hold': {
-								this.hold();
-								break;
-							}
-							case 'surrender': {
-								this.surrender();
-								break;
-							}
-							default:
-								break;
-						}
-					}
-					this.tickCallbackQueue = this.tickCallbackQueue.filter(x => {
-						if (x.frame === this.frame) {
-							x.callback();
-							return false;
-						} else {
-							return true;
-						}
-					});
-
-					Matter.Engine.update(this.engine, this.TICK_DELTA);
-				}
-
-				if (!this.isGameOver) {
-					this.tickRaf = window.requestAnimationFrame(playTick);
-				}
-			};
-
-			playTick();
-		} else {
-			this.tick();
-		}
+		Matter.Events.on(this.engine, 'collisionStart', this.onCollision.bind(this));
 	}
 
 	public getLogs() {
 		return this.logs;
 	}
 
-	private tick() {
+	public tick() {
 		this.frame++;
+
 		if (this.latestFusionedAt < this.frame - this.COMBO_INTERVAL) {
 			this.combo = 0;
 		}
+
 		this.tickCallbackQueue = this.tickCallbackQueue.filter(x => {
 			if (x.frame === this.frame) {
 				x.callback();
@@ -465,35 +330,12 @@ export class DropAndFusionGame extends EventEmitter<{
 				return true;
 			}
 		});
+
 		Matter.Engine.update(this.engine, this.TICK_DELTA);
-		if (!this.isGameOver) {
-			this.tickRaf = window.requestAnimationFrame(this.tick);
-		}
-	}
 
-	public async load() {
-		await this.loadMonoTextures();
-		this.loaded = true;
-	}
+		const hasNextTick = !this.isGameOver;
 
-	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]) {
-			return this.monoTextureUrls[mono.img];
-
-		// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
-		} else if (this.monoTextures[mono.img]) {
-			// Gameクラス内にキャッシュがある場合はそれを使う
-			const out = URL.createObjectURL(this.monoTextures[mono.img]);
-			this.monoTextureUrls[mono.img] = out;
-			return out;
-		} else {
-			return mono.img;
-		}
+		return hasNextTick;
 	}
 
 	public getActiveMonos() {
@@ -502,6 +344,7 @@ export class DropAndFusionGame extends EventEmitter<{
 
 	public drop(_x: number) {
 		if (this.isGameOver) return;
+		// TODO: フレームで計算するようにすればリプレイかどうかのチェックは不要になる
 		if (!this.replaying && (Date.now() - this.latestDroppedAt < this.DROP_INTERVAL)) return;
 
 		const head = this.stock.shift()!;
@@ -512,7 +355,7 @@ export class DropAndFusionGame extends EventEmitter<{
 		this.emit('changeStock', this.stock);
 
 		const inputX = Math.round(_x);
-		const x = Math.min(this.gameWidth - this.PLAYAREA_MARGIN - (head.mono.size / 2), Math.max(this.PLAYAREA_MARGIN + (head.mono.size / 2), inputX));
+		const x = Math.min(this.GAME_WIDTH - this.PLAYAREA_MARGIN - (head.mono.size / 2), Math.max(this.PLAYAREA_MARGIN + (head.mono.size / 2), inputX));
 		const body = this.createBody(head.mono, x, 50 + head.mono.size / 2);
 		this.logs.push({
 			frame: this.frame,
@@ -523,18 +366,8 @@ export class DropAndFusionGame extends EventEmitter<{
 		this.activeBodyIds.push(body.id);
 		this.latestDroppedBodyId = body.id;
 		this.latestDroppedAt = Date.now();
-		this.emit('dropped');
+		this.emit('dropped', x);
 		this.emit('monoAdded', head.mono);
-
-		// TODO: 効果音再生はコンポーネント側の責務なので移動するべき?
-		const panV = x - this.PLAYAREA_MARGIN;
-		const panW = this.gameWidth - this.PLAYAREA_MARGIN - this.PLAYAREA_MARGIN;
-		const pan = ((panV / panW) - 0.5) * 2;
-		sound.playUrl('/client-assets/drop-and-fusion/poi2.mp3', {
-			volume: this.sfxVolume,
-			pan,
-			playbackRate: this.replayPlaybackRate,
-		});
 	}
 
 	public hold() {
@@ -561,17 +394,9 @@ export class DropAndFusionGame extends EventEmitter<{
 			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() {
-		if (this.comboIntervalId) window.clearInterval(this.comboIntervalId);
-		if (this.tickRaf) window.cancelAnimationFrame(this.tickRaf);
-		this.tickRaf = null;
-		Matter.Render.stop(this.render);
 		Matter.World.clear(this.engine.world, false);
 		Matter.Engine.clear(this.engine);
 	}