summaryrefslogtreecommitdiff
path: root/packages/frontend/src/scripts
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/scripts
parentenhance(frontend): バブルゲームの諸々を修正・改良 (#12938) (diff)
downloadsharkey-145d28a8e4923f1c33adf0aebd931f473cbf54fd.tar.gz
sharkey-145d28a8e4923f1c33adf0aebd931f473cbf54fd.tar.bz2
sharkey-145d28a8e4923f1c33adf0aebd931f473cbf54fd.zip
refactor(frontend): extract game engine from vue component
Diffstat (limited to 'packages/frontend/src/scripts')
-rw-r--r--packages/frontend/src/scripts/drop-and-fusion-engine.ts396
1 files changed, 396 insertions, 0 deletions
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);
+ }
+}