summaryrefslogtreecommitdiff
path: root/packages/frontend/src
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2024-01-08 11:13:20 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2024-01-08 11:13:20 +0900
commit145d28a8e4923f1c33adf0aebd931f473cbf54fd (patch)
tree85004b1bbf8859929a62be05dfed642e59d7c361 /packages/frontend/src
parentenhance(frontend): バブルゲームの諸々を修正・改良 (#12938) (diff)
downloadmisskey-145d28a8e4923f1c33adf0aebd931f473cbf54fd.tar.gz
misskey-145d28a8e4923f1c33adf0aebd931f473cbf54fd.tar.bz2
misskey-145d28a8e4923f1c33adf0aebd931f473cbf54fd.zip
refactor(frontend): extract game engine from vue component
Diffstat (limited to 'packages/frontend/src')
-rw-r--r--packages/frontend/src/pages/drop-and-fusion.vue401
-rw-r--r--packages/frontend/src/scripts/drop-and-fusion-engine.ts396
2 files changed, 409 insertions, 388 deletions
diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue
index 482ee7e004..1daf9ddc62 100644
--- a/packages/frontend/src/pages/drop-and-fusion.vue
+++ b/packages/frontend/src/pages/drop-and-fusion.vue
@@ -117,12 +117,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import * as Matter from 'matter-js';
import { onDeactivated, ref, shallowRef } from 'vue';
-import { EventEmitter } from 'eventemitter3';
import * as Misskey from 'misskey-js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
-import * as sound from '@/scripts/sound.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import * as os from '@/os.js';
import MkNumber from '@/components/MkNumber.vue';
@@ -136,19 +133,7 @@ import { useInterval } from '@/scripts/use-interval.js';
import MkSelect from '@/components/MkSelect.vue';
import { apiUrl } from '@/config.js';
import { $i } from '@/account.js';
-
-type Mono = {
- id: string;
- level: number;
- size: number;
- shape: 'circle' | 'rectangle';
- score: number;
- dropCandidate: boolean;
- sfxPitch: number;
- img: string;
- imgSize: number;
- spriteScale: number;
-};
+import { DropAndFusionGame, Mono } from '@/scripts/drop-and-fusion-engine.js';
const containerEl = shallowRef<HTMLElement>();
const canvasEl = shallowRef<HTMLCanvasElement>();
@@ -382,7 +367,6 @@ const SQUARE_MONOS: Mono[] = [{
const GAME_WIDTH = 450;
const GAME_HEIGHT = 600;
-const PHYSICS_QUALITY_FACTOR = 16; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる、逆に高すぎても何故か不安定になる
let viewScaleX = 1;
let viewScaleY = 1;
@@ -398,373 +382,7 @@ const gameOver = ref(false);
const gameStarted = ref(false);
const highScore = ref<number | null>(null);
-class Game extends EventEmitter<{
- changeScore: (newScore: number) => void;
- changeCombo: (newCombo: number) => void;
- changeStock: (newStock: { id: string; mono: Mono }[]) => void;
- dropped: () => void;
- fusioned: (x: number, y: number, scoreDelta: number) => void;
- monoAdded: (mono: Mono) => void;
- gameOver: () => void;
-}> {
- private COMBO_INTERVAL = 1000;
- public readonly DROP_INTERVAL = 500;
- public readonly PLAYAREA_MARGIN = 25;
- private STOCK_MAX = 4;
- private loaded = false;
- private engine: Matter.Engine;
- private render: Matter.Render;
- private runner: Matter.Runner;
- private overflowCollider: Matter.Body;
- private isGameOver = false;
-
- private monoDefinitions: Mono[] = [];
- private monoTextures: Record<string, Blob> = {};
- private monoTextureUrls: Record<string, string> = {};
-
- /**
- * フィールドに出ていて、かつ合体の対象となるアイテム
- */
- private activeBodyIds: Matter.Body['id'][] = [];
-
- private latestDroppedBodyId: Matter.Body['id'] | null = null;
-
- private latestDroppedAt = 0;
- private latestFusionedAt = 0;
- private stock: { id: string; mono: Mono }[] = [];
-
- private _combo = 0;
- private get combo() {
- return this._combo;
- }
- private set combo(value: number) {
- this._combo = value;
- this.emit('changeCombo', value);
- }
-
- private _score = 0;
- private get score() {
- return this._score;
- }
- private set score(value: number) {
- this._score = value;
- this.emit('changeScore', value);
- }
-
- private comboIntervalId: number | null = null;
-
- constructor(opts: {
- monoDefinitions: Mono[];
- }) {
- super();
-
- this.monoDefinitions = opts.monoDefinitions;
-
- this.engine = Matter.Engine.create({
- constraintIterations: 2 * PHYSICS_QUALITY_FACTOR,
- positionIterations: 6 * PHYSICS_QUALITY_FACTOR,
- velocityIterations: 4 * PHYSICS_QUALITY_FACTOR,
- gravity: {
- x: 0,
- y: 1,
- },
- timing: {
- timeScale: 2,
- },
- enableSleeping: false,
- });
-
- this.render = Matter.Render.create({
- engine: this.engine,
- canvas: canvasEl.value,
- options: {
- width: GAME_WIDTH,
- height: GAME_HEIGHT,
- 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.runner = Matter.Runner.create();
- Matter.Runner.run(this.runner, this.engine);
-
- this.engine.world.bodies = [];
-
- //#region walls
- const WALL_OPTIONS: Matter.IChamferableBodyDefinition = {
- isStatic: true,
- friction: 0.7,
- slop: 1.0,
- render: {
- strokeStyle: 'transparent',
- fillStyle: 'transparent',
- },
- };
-
- const thickness = 100;
- Matter.Composite.add(this.engine.world, [
- Matter.Bodies.rectangle(GAME_WIDTH / 2, GAME_HEIGHT + (thickness / 2) - this.PLAYAREA_MARGIN, GAME_WIDTH, thickness, WALL_OPTIONS),
- Matter.Bodies.rectangle(GAME_WIDTH + (thickness / 2) - this.PLAYAREA_MARGIN, GAME_HEIGHT / 2, thickness, GAME_HEIGHT, WALL_OPTIONS),
- Matter.Bodies.rectangle(-((thickness / 2) - this.PLAYAREA_MARGIN), GAME_HEIGHT / 2, thickness, GAME_HEIGHT, WALL_OPTIONS),
- ]);
- //#endregion
-
- this.overflowCollider = Matter.Bodies.rectangle(GAME_WIDTH / 2, 0, GAME_WIDTH, 200, {
- isStatic: true,
- isSensor: true,
- render: {
- strokeStyle: 'transparent',
- fillStyle: 'transparent',
- },
- });
- 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: GAME_WIDTH, y: GAME_HEIGHT },
- });
- }
-
- private createBody(mono: Mono, x: number, y: number) {
- const options: Matter.IBodyDefinition = {
- label: mono.id,
- //density: 0.0005,
- density: mono.size / 1000,
- restitution: 0.2,
- frictionAir: 0.01,
- friction: 0.7,
- frictionStatic: 5,
- slop: 1.0,
- //mass: 0,
- render: {
- sprite: {
- texture: mono.img,
- xScale: (mono.size / mono.imgSize) * mono.spriteScale,
- yScale: (mono.size / mono.imgSize) * mono.spriteScale,
- },
- },
- };
- if (mono.shape === 'circle') {
- return Matter.Bodies.circle(x, y, mono.size / 2, options);
- } else if (mono.shape === 'rectangle') {
- return Matter.Bodies.rectangle(x, y, mono.size, mono.size, options);
- } else {
- throw new Error('unrecognized shape');
- }
- }
-
- private fusion(bodyA: Matter.Body, bodyB: Matter.Body) {
- const now = Date.now();
- if (this.latestFusionedAt > now - this.COMBO_INTERVAL) {
- this.combo++;
- } else {
- this.combo = 1;
- }
- this.latestFusionedAt = now;
-
- // TODO: 単に位置だけでなくそれぞれの動きベクトルも融合する
- const newX = (bodyA.position.x + bodyB.position.x) / 2;
- const newY = (bodyA.position.y + bodyB.position.y) / 2;
-
- Matter.Composite.remove(this.engine.world, [bodyA, bodyB]);
- this.activeBodyIds = this.activeBodyIds.filter(x => x !== bodyA.id && x !== bodyB.id);
-
- const currentMono = this.monoDefinitions.find(y => y.id === bodyA.label)!;
- const nextMono = this.monoDefinitions.find(x => x.level === currentMono.level + 1);
-
- if (nextMono) {
- const body = this.createBody(nextMono, newX, newY);
- Matter.Composite.add(this.engine.world, body);
-
- // 連鎖してfusionした場合の分かりやすさのため少し間を置いてからfusion対象になるようにする
- window.setTimeout(() => {
- this.activeBodyIds.push(body.id);
- }, 100);
-
- const comboBonus = 1 + ((this.combo - 1) / 5);
- const additionalScore = Math.round(currentMono.score * comboBonus);
- this.score += additionalScore;
-
- const pan = ((newX / GAME_WIDTH) - 0.5) * 2;
- sound.playRaw('syuilo/bubble2', 1, pan, nextMono.sfxPitch);
-
- this.emit('monoAdded', nextMono);
- this.emit('fusioned', newX, newY, additionalScore);
- } else {
- //const VELOCITY = 30;
- //for (let i = 0; i < 10; i++) {
- // const body = createBody(FRUITS.find(x => x.level === (1 + Math.floor(Math.random() * 3)))!, x + ((Math.random() * VELOCITY) - (VELOCITY / 2)), y + ((Math.random() * VELOCITY) - (VELOCITY / 2)));
- // Matter.Composite.add(world, body);
- // bodies.push(body);
- //}
- //sound.playRaw({
- // type: 'syuilo/bubble2',
- // volume: 1,
- //});
- }
- }
-
- private gameOver() {
- this.isGameOver = true;
- Matter.Runner.stop(this.runner);
- this.emit('gameOver');
- }
-
- /** テクスチャをすべてキャッシュする */
- private async loadMonoTextures() {
- async function loadSingleMonoTexture(mono: Mono, game: Game) {
- // Matter-js内にキャッシュがある場合はスキップ
- if (game.render.textures[mono.img]) return;
- console.log('loading', mono.img);
-
- 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() {
- if (!this.loaded) throw new Error('game is not loaded yet');
-
- for (let i = 0; i < this.STOCK_MAX; i++) {
- 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);
-
- // 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 });
- window.setTimeout(() => {
- fusionReservedPairs = fusionReservedPairs.filter(x => x.bodyA.id !== bodyA.id && x.bodyB.id !== bodyB.id);
- this.fusion(bodyA, bodyB);
- }, 100);
- }
- } else {
- const energy = pairs.collision.depth;
- if (energy > minCollisionEnergyForSound) {
- const vol = (Math.min(maxCollisionEnergyForSound, energy - minCollisionEnergyForSound) / maxCollisionEnergyForSound) / 4;
- const pan = ((((bodyA.position.x + bodyB.position.x) / 2) / GAME_WIDTH) - 0.5) * 2;
- const pitch = soundPitchMin + ((soundPitchMax - soundPitchMin) * (1 - (Math.min(10, energy) / 10)));
- sound.playRaw('syuilo/poi1', vol, pan, pitch);
- }
- }
- }
- });
-
- this.comboIntervalId = window.setInterval(() => {
- if (this.latestFusionedAt < Date.now() - this.COMBO_INTERVAL) {
- this.combo = 0;
- }
- }, 500);
- }
-
- public async load() {
- await this.loadMonoTextures();
- this.loaded = true;
- }
-
- 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;
- }
- }
-
- public getActiveMonos() {
- return this.engine.world.bodies.map(x => this.monoDefinitions.find((mono) => mono.id === x.label)!).filter(x => x !== undefined);
- }
-
- public drop(_x: number) {
- if (this.isGameOver) return;
- if (Date.now() - this.latestDroppedAt < this.DROP_INTERVAL) {
- return;
- }
- const st = 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(GAME_WIDTH - 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);
- 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);
- const pan = ((x / GAME_WIDTH) - 0.5) * 2;
- sound.playRaw('syuilo/poi2', 1, pan);
- }
-
- public dispose() {
- if (this.comboIntervalId) window.clearInterval(this.comboIntervalId);
- Matter.Render.stop(this.render);
- Matter.Runner.stop(this.runner);
- Matter.World.clear(this.engine.world, false);
- Matter.Engine.clear(this.engine);
- }
-}
-
-let game: Game;
+let game: DropAndFusionGame;
let containerElRect: DOMRect | null = null;
function onClick(ev: MouseEvent) {
@@ -885,10 +503,17 @@ async function start() {
highScore.value = null;
}
- game = new Game(gameMode.value === 'normal' ? {
- monoDefinitions: NORAML_MONOS,
- } : {
- monoDefinitions: SQUARE_MONOS,
+ game = new DropAndFusionGame({
+ width: GAME_WIDTH,
+ height: GAME_HEIGHT,
+ canvas: canvasEl.value!,
+ ...(
+ gameMode.value === 'normal' ? {
+ monoDefinitions: NORAML_MONOS,
+ } : {
+ monoDefinitions: SQUARE_MONOS,
+ }
+ ),
});
attachGameEvents();
os.promiseDialog(game.load(), () => {
diff --git a/packages/frontend/src/scripts/drop-and-fusion-engine.ts b/packages/frontend/src/scripts/drop-and-fusion-engine.ts
new file mode 100644
index 0000000000..7241525a38
--- /dev/null
+++ b/packages/frontend/src/scripts/drop-and-fusion-engine.ts
@@ -0,0 +1,396 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { EventEmitter } from 'eventemitter3';
+import * as Matter from 'matter-js';
+import * as sound from '@/scripts/sound.js';
+
+export type Mono = {
+ id: string;
+ level: number;
+ size: number;
+ shape: 'circle' | 'rectangle';
+ score: number;
+ dropCandidate: boolean;
+ sfxPitch: number;
+ img: string;
+ imgSize: number;
+ 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;
+ dropped: () => void;
+ fusioned: (x: number, y: number, scoreDelta: number) => void;
+ monoAdded: (mono: Mono) => void;
+ gameOver: () => void;
+}> {
+ private COMBO_INTERVAL = 1000;
+ public readonly DROP_INTERVAL = 500;
+ public readonly PLAYAREA_MARGIN = 25;
+ private STOCK_MAX = 4;
+ private loaded = false;
+ private engine: Matter.Engine;
+ private render: Matter.Render;
+ private runner: Matter.Runner;
+ 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 activeBodyIds: Matter.Body['id'][] = [];
+
+ private latestDroppedBodyId: Matter.Body['id'] | null = null;
+
+ private latestDroppedAt = 0;
+ private latestFusionedAt = 0;
+ private stock: { id: string; mono: Mono }[] = [];
+
+ private _combo = 0;
+ private get combo() {
+ return this._combo;
+ }
+ private set combo(value: number) {
+ this._combo = value;
+ this.emit('changeCombo', value);
+ }
+
+ private _score = 0;
+ private get score() {
+ return this._score;
+ }
+ private set score(value: number) {
+ this._score = value;
+ this.emit('changeScore', value);
+ }
+
+ private comboIntervalId: number | null = null;
+
+ constructor(opts: {
+ canvas: HTMLCanvasElement;
+ width: number;
+ height: number;
+ monoDefinitions: Mono[];
+ }) {
+ super();
+
+ this.gameWidth = opts.width;
+ this.gameHeight = opts.height;
+ this.monoDefinitions = opts.monoDefinitions;
+
+ this.engine = Matter.Engine.create({
+ constraintIterations: 2 * PHYSICS_QUALITY_FACTOR,
+ positionIterations: 6 * PHYSICS_QUALITY_FACTOR,
+ velocityIterations: 4 * PHYSICS_QUALITY_FACTOR,
+ gravity: {
+ x: 0,
+ y: 1,
+ },
+ timing: {
+ timeScale: 2,
+ },
+ 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.runner = Matter.Runner.create();
+ Matter.Runner.run(this.runner, this.engine);
+
+ this.engine.world.bodies = [];
+
+ //#region walls
+ const WALL_OPTIONS: Matter.IChamferableBodyDefinition = {
+ isStatic: true,
+ friction: 0.7,
+ slop: 1.0,
+ render: {
+ strokeStyle: 'transparent',
+ fillStyle: 'transparent',
+ },
+ };
+
+ 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),
+ ]);
+ //#endregion
+
+ this.overflowCollider = Matter.Bodies.rectangle(this.gameWidth / 2, 0, this.gameWidth, 200, {
+ isStatic: true,
+ isSensor: true,
+ render: {
+ strokeStyle: 'transparent',
+ fillStyle: 'transparent',
+ },
+ });
+ 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) {
+ const options: Matter.IBodyDefinition = {
+ label: mono.id,
+ //density: 0.0005,
+ density: mono.size / 1000,
+ restitution: 0.2,
+ frictionAir: 0.01,
+ friction: 0.7,
+ frictionStatic: 5,
+ slop: 1.0,
+ //mass: 0,
+ render: {
+ sprite: {
+ texture: mono.img,
+ xScale: (mono.size / mono.imgSize) * mono.spriteScale,
+ yScale: (mono.size / mono.imgSize) * mono.spriteScale,
+ },
+ },
+ };
+ if (mono.shape === 'circle') {
+ return Matter.Bodies.circle(x, y, mono.size / 2, options);
+ } else if (mono.shape === 'rectangle') {
+ return Matter.Bodies.rectangle(x, y, mono.size, mono.size, options);
+ } else {
+ throw new Error('unrecognized shape');
+ }
+ }
+
+ private fusion(bodyA: Matter.Body, bodyB: Matter.Body) {
+ const now = Date.now();
+ if (this.latestFusionedAt > now - this.COMBO_INTERVAL) {
+ this.combo++;
+ } else {
+ this.combo = 1;
+ }
+ this.latestFusionedAt = now;
+
+ // TODO: 単に位置だけでなくそれぞれの動きベクトルも融合する
+ const newX = (bodyA.position.x + bodyB.position.x) / 2;
+ const newY = (bodyA.position.y + bodyB.position.y) / 2;
+
+ Matter.Composite.remove(this.engine.world, [bodyA, bodyB]);
+ this.activeBodyIds = this.activeBodyIds.filter(x => x !== bodyA.id && x !== bodyB.id);
+
+ const currentMono = this.monoDefinitions.find(y => y.id === bodyA.label)!;
+ const nextMono = this.monoDefinitions.find(x => x.level === currentMono.level + 1);
+
+ if (nextMono) {
+ const body = this.createBody(nextMono, newX, newY);
+ Matter.Composite.add(this.engine.world, body);
+
+ // 連鎖してfusionした場合の分かりやすさのため少し間を置いてからfusion対象になるようにする
+ window.setTimeout(() => {
+ this.activeBodyIds.push(body.id);
+ }, 100);
+
+ const comboBonus = 1 + ((this.combo - 1) / 5);
+ const additionalScore = Math.round(currentMono.score * comboBonus);
+ this.score += additionalScore;
+
+ const pan = ((newX / this.gameWidth) - 0.5) * 2;
+ sound.playRaw('syuilo/bubble2', 1, pan, nextMono.sfxPitch);
+
+ this.emit('monoAdded', nextMono);
+ this.emit('fusioned', newX, newY, additionalScore);
+ } else {
+ //const VELOCITY = 30;
+ //for (let i = 0; i < 10; i++) {
+ // const body = createBody(FRUITS.find(x => x.level === (1 + Math.floor(Math.random() * 3)))!, x + ((Math.random() * VELOCITY) - (VELOCITY / 2)), y + ((Math.random() * VELOCITY) - (VELOCITY / 2)));
+ // Matter.Composite.add(world, body);
+ // bodies.push(body);
+ //}
+ //sound.playRaw({
+ // type: 'syuilo/bubble2',
+ // volume: 1,
+ //});
+ }
+ }
+
+ private gameOver() {
+ this.isGameOver = true;
+ Matter.Runner.stop(this.runner);
+ this.emit('gameOver');
+ }
+
+ /** テクスチャをすべてキャッシュする */
+ private async loadMonoTextures() {
+ async function loadSingleMonoTexture(mono: Mono, game: DropAndFusionGame) {
+ // Matter-js内にキャッシュがある場合はスキップ
+ if (game.render.textures[mono.img]) return;
+ console.log('loading', mono.img);
+
+ 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() {
+ if (!this.loaded) throw new Error('game is not loaded yet');
+
+ for (let i = 0; i < this.STOCK_MAX; i++) {
+ 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);
+
+ // 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 });
+ window.setTimeout(() => {
+ fusionReservedPairs = fusionReservedPairs.filter(x => x.bodyA.id !== bodyA.id && x.bodyB.id !== bodyB.id);
+ this.fusion(bodyA, bodyB);
+ }, 100);
+ }
+ } else {
+ const energy = pairs.collision.depth;
+ if (energy > minCollisionEnergyForSound) {
+ const vol = (Math.min(maxCollisionEnergyForSound, energy - minCollisionEnergyForSound) / maxCollisionEnergyForSound) / 4;
+ 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.playRaw('syuilo/poi1', vol, pan, pitch);
+ }
+ }
+ }
+ });
+
+ this.comboIntervalId = window.setInterval(() => {
+ if (this.latestFusionedAt < Date.now() - this.COMBO_INTERVAL) {
+ this.combo = 0;
+ }
+ }, 500);
+ }
+
+ public async load() {
+ await this.loadMonoTextures();
+ this.loaded = true;
+ }
+
+ 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;
+ }
+ }
+
+ public getActiveMonos() {
+ return this.engine.world.bodies.map(x => this.monoDefinitions.find((mono) => mono.id === x.label)!).filter(x => x !== undefined);
+ }
+
+ public drop(_x: number) {
+ if (this.isGameOver) return;
+ if (Date.now() - this.latestDroppedAt < this.DROP_INTERVAL) {
+ return;
+ }
+ const st = 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);
+ 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);
+ const pan = ((x / this.gameWidth) - 0.5) * 2;
+ sound.playRaw('syuilo/poi2', 1, pan);
+ }
+
+ public dispose() {
+ if (this.comboIntervalId) window.clearInterval(this.comboIntervalId);
+ Matter.Render.stop(this.render);
+ Matter.Runner.stop(this.runner);
+ Matter.World.clear(this.engine.world, false);
+ Matter.Engine.clear(this.engine);
+ }
+}