summaryrefslogtreecommitdiff
path: root/packages/frontend/src/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/frontend/src/scripts')
-rw-r--r--packages/frontend/src/scripts/drop-and-fusion-engine.ts359
1 files changed, 92 insertions, 267 deletions
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,30 +98,16 @@ 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.tick = this.tick.bind(this);
-
- this.gameWidth = opts.width;
- this.gameHeight = opts.height;
- this.monoDefinitions = opts.monoDefinitions;
- this.rng = seedrandom(opts.seed);
+ this.replaying = !!env.replaying;
+ this.monoDefinitions = env.monoDefinitions;
+ this.rng = seedrandom(env.seed);
- if (opts.sfxVolume) {
- this.sfxVolume = opts.sfxVolume;
- }
+ this.tick = this.tick.bind(this);
this.engine = Matter.Engine.create({
constraintIterations: 2 * 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;
- }
- public setSfxVolume(volume: number) {
- this.sfxVolume = volume;
- }
+ Matter.Engine.update(this.engine, this.TICK_DELTA);
- public getTextureImageUrl(mono: Mono) {
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
- if (this.monoTextureUrls[mono.img]) {
- return this.monoTextureUrls[mono.img];
+ const hasNextTick = !this.isGameOver;
- // 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);
}