enhnace(frontend): tweak game
This commit is contained in:
		
							parent
							
								
									14aedc17ae
								
							
						
					
					
						commit
						1063d39de8
					
				
					 7 changed files with 267 additions and 39 deletions
				
			
		
							
								
								
									
										3
									
								
								locales/index.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								locales/index.d.ts
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -1195,6 +1195,9 @@ export interface Locale {
 | 
			
		|||
    "bubbleGame": string;
 | 
			
		||||
    "sfx": string;
 | 
			
		||||
    "soundWillBePlayed": string;
 | 
			
		||||
    "showReplay": string;
 | 
			
		||||
    "replay": string;
 | 
			
		||||
    "replaying": string;
 | 
			
		||||
    "_announcement": {
 | 
			
		||||
        "forExistingUsers": string;
 | 
			
		||||
        "forExistingUsersDescription": string;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1192,6 +1192,9 @@ enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する"
 | 
			
		|||
bubbleGame: "バブルゲーム"
 | 
			
		||||
sfx: "効果音"
 | 
			
		||||
soundWillBePlayed: "サウンドが再生されます"
 | 
			
		||||
showReplay: "リプレイを見る"
 | 
			
		||||
replay: "リプレイ"
 | 
			
		||||
replaying: "リプレイ中"
 | 
			
		||||
 | 
			
		||||
_announcement:
 | 
			
		||||
  forExistingUsers: "既存ユーザーのみ"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										
											BIN
										
									
								
								packages/frontend/assets/drop-and-fusion/gameover.mp3
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								packages/frontend/assets/drop-and-fusion/gameover.mp3
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
						 | 
				
			
			@ -58,6 +58,7 @@
 | 
			
		|||
		"rollup": "4.9.1",
 | 
			
		||||
		"sanitize-html": "2.11.0",
 | 
			
		||||
		"sass": "1.69.5",
 | 
			
		||||
		"seedrandom": "^3.0.5",
 | 
			
		||||
		"shiki": "0.14.7",
 | 
			
		||||
		"strict-event-emitter-types": "2.0.0",
 | 
			
		||||
		"textarea-caret": "3.1.0",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -61,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div ref="containerEl" :class="[$style.gameContainer, { [$style.gameOver]: gameOver }]" @contextmenu.stop.prevent @click.stop.prevent="onClick" @touchmove.stop.prevent="onTouchmove" @touchend="onTouchend" @mousemove="onMousemove">
 | 
			
		||||
			<div ref="containerEl" :class="[$style.gameContainer, { [$style.gameOver]: isGameOver && !replaying }]" @contextmenu.stop.prevent @click.stop.prevent="onClick" @touchmove.stop.prevent="onTouchmove" @touchend="onTouchend" @mousemove="onMousemove">
 | 
			
		||||
				<img v-if="defaultStore.state.darkMode" src="/client-assets/drop-and-fusion/frame-dark.svg" :class="$style.mainFrameImg"/>
 | 
			
		||||
				<img v-else src="/client-assets/drop-and-fusion/frame-light.svg" :class="$style.mainFrameImg"/>
 | 
			
		||||
				<canvas ref="canvasEl" :class="$style.canvas"/>
 | 
			
		||||
| 
						 | 
				
			
			@ -74,7 +74,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
				>
 | 
			
		||||
					<div v-show="combo > 1" :class="$style.combo" :style="{ fontSize: `${100 + ((comboPrev - 2) * 15)}%` }">{{ comboPrev }} Chain!</div>
 | 
			
		||||
				</Transition>
 | 
			
		||||
				<div :class="$style.dropperContainer" :style="{ left: dropperX + 'px' }">
 | 
			
		||||
				<div v-if="!isGameOver && !replaying" :class="$style.dropperContainer" :style="{ left: dropperX + 'px' }">
 | 
			
		||||
					<!--<img v-if="currentPick" src="/client-assets/drop-and-fusion/dropper.png" :class="$style.dropper" :style="{ left: dropperX + 'px' }"/>-->
 | 
			
		||||
					<Transition
 | 
			
		||||
						:enterActiveClass="$style.transition_picked_enterActive"
 | 
			
		||||
| 
						 | 
				
			
			@ -91,15 +91,29 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
						<div :class="$style.dropGuide"/>
 | 
			
		||||
					</template>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div v-if="gameOver" :class="$style.gameOverLabel">
 | 
			
		||||
				<div v-if="isGameOver && !replaying" :class="$style.gameOverLabel">
 | 
			
		||||
					<div class="_gaps_s">
 | 
			
		||||
						<img src="/client-assets/drop-and-fusion/gameover.png" style="width: 200px; max-width: 100%; display: block; margin: auto; margin-bottom: -5px;"/>
 | 
			
		||||
						<div>SCORE: <MkNumber :value="score"/></div>
 | 
			
		||||
						<div>MAX CHAIN: <MkNumber :value="maxCombo"/></div>
 | 
			
		||||
						<div class="_buttonsCenter">
 | 
			
		||||
							<MkButton primary rounded @click="restart">Restart</MkButton>
 | 
			
		||||
							<MkButton primary rounded @click="share">Share</MkButton>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</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">
 | 
			
		||||
						<MkButton @click="endReplay"><i class="ti ti-player-stop"></i> END REPLAY</MkButton>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div v-if="isGameOver" :class="$style.frame">
 | 
			
		||||
				<div :class="$style.frameInner">
 | 
			
		||||
					<div class="_buttonsCenter">
 | 
			
		||||
						<MkButton primary rounded @click="end">{{ i18n.ts.done }}</MkButton>
 | 
			
		||||
						<MkButton primary rounded @click="replay">{{ i18n.ts.showReplay }}</MkButton>
 | 
			
		||||
						<MkButton primary rounded @click="share">{{ i18n.ts.share }}</MkButton>
 | 
			
		||||
						<MkButton rounded @click="exportLog">Copy replay data</MkButton>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -139,7 +153,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
			</div>
 | 
			
		||||
			<div :class="$style.frame">
 | 
			
		||||
				<div :class="$style.frameInner">
 | 
			
		||||
					<MkButton @click="restart">Restart</MkButton>
 | 
			
		||||
					<MkButton danger @click="surrender">Retry</MkButton>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -168,6 +182,7 @@ import { DropAndFusionGame, Mono } from '@/scripts/drop-and-fusion-engine.js';
 | 
			
		|||
import * as sound from '@/scripts/sound.js';
 | 
			
		||||
import MkRange from '@/components/MkRange.vue';
 | 
			
		||||
import MkSwitch from '@/components/MkSwitch.vue';
 | 
			
		||||
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
 | 
			
		||||
 | 
			
		||||
const NORMAL_BASE_SIZE = 30;
 | 
			
		||||
const NORAML_MONOS: Mono[] = [{
 | 
			
		||||
| 
						 | 
				
			
			@ -401,6 +416,8 @@ const GAME_HEIGHT = 600;
 | 
			
		|||
let viewScale = 1;
 | 
			
		||||
let game: DropAndFusionGame;
 | 
			
		||||
let containerElRect: DOMRect | null = null;
 | 
			
		||||
let seed: string;
 | 
			
		||||
let logs: ReturnType<DropAndFusionGame['getLogs']> | null = null;
 | 
			
		||||
 | 
			
		||||
const containerEl = shallowRef<HTMLElement>();
 | 
			
		||||
const canvasEl = shallowRef<HTMLCanvasElement>();
 | 
			
		||||
| 
						 | 
				
			
			@ -414,22 +431,25 @@ const comboPrev = ref(0);
 | 
			
		|||
const maxCombo = ref(0);
 | 
			
		||||
const dropReady = ref(true);
 | 
			
		||||
const gameMode = ref<'normal' | 'square'>('normal');
 | 
			
		||||
const gameOver = ref(false);
 | 
			
		||||
const isGameOver = ref(false);
 | 
			
		||||
const gameStarted = ref(false);
 | 
			
		||||
const highScore = ref<number | null>(null);
 | 
			
		||||
const showConfig = ref(false);
 | 
			
		||||
const replaying = ref(false);
 | 
			
		||||
const mute = ref(false);
 | 
			
		||||
const bgmVolume = ref(defaultStore.state.dropAndFusion.bgmVolume);
 | 
			
		||||
const sfxVolume = ref(defaultStore.state.dropAndFusion.sfxVolume);
 | 
			
		||||
 | 
			
		||||
function onClick(ev: MouseEvent) {
 | 
			
		||||
	if (!containerElRect) return;
 | 
			
		||||
	if (replaying.value) return;
 | 
			
		||||
	const x = (ev.clientX - containerElRect.left) / viewScale;
 | 
			
		||||
	game.drop(x);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function onTouchend(ev: TouchEvent) {
 | 
			
		||||
	if (!containerElRect) return;
 | 
			
		||||
	if (replaying.value) return;
 | 
			
		||||
	const x = (ev.changedTouches[0].clientX - containerElRect.left) / viewScale;
 | 
			
		||||
	game.drop(x);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -454,9 +474,18 @@ function hold() {
 | 
			
		|||
	game.hold();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function restart() {
 | 
			
		||||
async function surrender() {
 | 
			
		||||
	const { canceled } = await os.confirm({
 | 
			
		||||
		type: 'warning',
 | 
			
		||||
		text: i18n.ts.areYouSure,
 | 
			
		||||
	});
 | 
			
		||||
	if (canceled) return;
 | 
			
		||||
	game.surrender();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function end() {
 | 
			
		||||
	game.dispose();
 | 
			
		||||
	gameOver.value = false;
 | 
			
		||||
	isGameOver.value = false;
 | 
			
		||||
	currentPick.value = null;
 | 
			
		||||
	dropReady.value = true;
 | 
			
		||||
	stock.value = [];
 | 
			
		||||
| 
						 | 
				
			
			@ -467,6 +496,45 @@ function restart() {
 | 
			
		|||
	gameStarted.value = false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function replay() {
 | 
			
		||||
	replaying.value = true;
 | 
			
		||||
	game.dispose();
 | 
			
		||||
	game = new DropAndFusionGame({
 | 
			
		||||
		width: GAME_WIDTH,
 | 
			
		||||
		height: GAME_HEIGHT,
 | 
			
		||||
		canvas: canvasEl.value!,
 | 
			
		||||
		seed: seed,
 | 
			
		||||
		sfxVolume: mute.value ? 0 : sfxVolume.value,
 | 
			
		||||
		...(
 | 
			
		||||
			gameMode.value === 'normal' ? {
 | 
			
		||||
				monoDefinitions: NORAML_MONOS,
 | 
			
		||||
			} : {
 | 
			
		||||
				monoDefinitions: SQUARE_MONOS,
 | 
			
		||||
			}
 | 
			
		||||
		),
 | 
			
		||||
	});
 | 
			
		||||
	attachGameEvents();
 | 
			
		||||
	os.promiseDialog(game.load(), async () => {
 | 
			
		||||
		game.start(logs!);
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function endReplay() {
 | 
			
		||||
	replaying.value = false;
 | 
			
		||||
	game.dispose();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function exportLog() {
 | 
			
		||||
	if (!logs) return;
 | 
			
		||||
	const data = JSON.stringify({
 | 
			
		||||
		seed: seed,
 | 
			
		||||
		date: new Date().toISOString(),
 | 
			
		||||
		logs: logs,
 | 
			
		||||
	});
 | 
			
		||||
	copyToClipboard(data);
 | 
			
		||||
	os.success();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function attachGameEvents() {
 | 
			
		||||
	game.addListener('changeScore', value => {
 | 
			
		||||
		score.value = value;
 | 
			
		||||
| 
						 | 
				
			
			@ -492,9 +560,11 @@ function attachGameEvents() {
 | 
			
		|||
	});
 | 
			
		||||
 | 
			
		||||
	game.addListener('dropped', () => {
 | 
			
		||||
		if (replaying.value) return;
 | 
			
		||||
 | 
			
		||||
		dropReady.value = false;
 | 
			
		||||
		window.setTimeout(() => {
 | 
			
		||||
			if (!gameOver.value) {
 | 
			
		||||
			if (!isGameOver.value) {
 | 
			
		||||
				dropReady.value = true;
 | 
			
		||||
			}
 | 
			
		||||
		}, game.DROP_INTERVAL);
 | 
			
		||||
| 
						 | 
				
			
			@ -511,6 +581,8 @@ function attachGameEvents() {
 | 
			
		|||
	});
 | 
			
		||||
 | 
			
		||||
	game.addListener('monoAdded', (mono) => {
 | 
			
		||||
		if (replaying.value) return;
 | 
			
		||||
 | 
			
		||||
		// 実績関連
 | 
			
		||||
		if (mono.level === 10) {
 | 
			
		||||
			claimAchievement('bubbleGameExplodingHead');
 | 
			
		||||
| 
						 | 
				
			
			@ -523,9 +595,15 @@ function attachGameEvents() {
 | 
			
		|||
	});
 | 
			
		||||
 | 
			
		||||
	game.addListener('gameOver', () => {
 | 
			
		||||
		if (replaying.value) {
 | 
			
		||||
			endReplay();
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		logs = game.getLogs();
 | 
			
		||||
		currentPick.value = null;
 | 
			
		||||
		dropReady.value = false;
 | 
			
		||||
		gameOver.value = true;
 | 
			
		||||
		isGameOver.value = true;
 | 
			
		||||
 | 
			
		||||
		if (score.value > (highScore.value ?? 0)) {
 | 
			
		||||
			highScore.value = score.value;
 | 
			
		||||
| 
						 | 
				
			
			@ -551,10 +629,13 @@ async function start() {
 | 
			
		|||
		highScore.value = null;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	seed = Date.now().toString();
 | 
			
		||||
 | 
			
		||||
	game = new DropAndFusionGame({
 | 
			
		||||
		width: GAME_WIDTH,
 | 
			
		||||
		height: GAME_HEIGHT,
 | 
			
		||||
		canvas: canvasEl.value!,
 | 
			
		||||
		seed: seed,
 | 
			
		||||
		sfxVolume: mute.value ? 0 : sfxVolume.value,
 | 
			
		||||
		...(
 | 
			
		||||
			gameMode.value === 'normal' ? {
 | 
			
		||||
| 
						 | 
				
			
			@ -690,7 +771,7 @@ useInterval(() => {
 | 
			
		|||
}, 1000, { immediate: false, afterMounted: true });
 | 
			
		||||
 | 
			
		||||
onDeactivated(() => {
 | 
			
		||||
	restart();
 | 
			
		||||
	end();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
definePageMetadata({
 | 
			
		||||
| 
						 | 
				
			
			@ -922,6 +1003,28 @@ definePageMetadata({
 | 
			
		|||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.replayIndicator {
 | 
			
		||||
	position: absolute;
 | 
			
		||||
	z-index: 10;
 | 
			
		||||
	left: 10px;
 | 
			
		||||
	bottom: 10px;
 | 
			
		||||
	padding: 6px 8px;
 | 
			
		||||
	color: #f00;
 | 
			
		||||
	background: #0008;
 | 
			
		||||
	border-radius: 6px;
 | 
			
		||||
	pointer-events: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.replayIndicatorText {
 | 
			
		||||
	animation: replayIndicator-blink 2s infinite;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes replayIndicator-blink {
 | 
			
		||||
	0% { opacity: 1; }
 | 
			
		||||
	50% { opacity: 0; }
 | 
			
		||||
	100% { opacity: 1; }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes currentMonoArrow {
 | 
			
		||||
	0% { transform: translateY(0); }
 | 
			
		||||
	25% { transform: translateY(-8px); }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,6 +5,7 @@
 | 
			
		|||
 | 
			
		||||
import { EventEmitter } from 'eventemitter3';
 | 
			
		||||
import * as Matter from 'matter-js';
 | 
			
		||||
import seedrandom from 'seedrandom';
 | 
			
		||||
import * as sound from '@/scripts/sound.js';
 | 
			
		||||
 | 
			
		||||
export type Mono = {
 | 
			
		||||
| 
						 | 
				
			
			@ -20,6 +21,18 @@ export type Mono = {
 | 
			
		|||
	spriteScale: number;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type Log = {
 | 
			
		||||
	frame: number;
 | 
			
		||||
	operation: 'drop';
 | 
			
		||||
	x: number;
 | 
			
		||||
} | {
 | 
			
		||||
	frame: number;
 | 
			
		||||
	operation: 'hold';
 | 
			
		||||
} | {
 | 
			
		||||
	frame: number;
 | 
			
		||||
	operation: 'surrender';
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export class DropAndFusionGame extends EventEmitter<{
 | 
			
		||||
	changeScore: (newScore: number) => void;
 | 
			
		||||
	changeCombo: (newCombo: number) => void;
 | 
			
		||||
| 
						 | 
				
			
			@ -35,18 +48,23 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
	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 runner: Matter.Runner;
 | 
			
		||||
	private tickRaf: ReturnType<typeof requestAnimationFrame> | null = null;
 | 
			
		||||
	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;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -87,13 +105,17 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
		width: number;
 | 
			
		||||
		height: number;
 | 
			
		||||
		monoDefinitions: Mono[];
 | 
			
		||||
		seed: string;
 | 
			
		||||
		sfxVolume?: number;
 | 
			
		||||
	}) {
 | 
			
		||||
		super();
 | 
			
		||||
 | 
			
		||||
		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;
 | 
			
		||||
| 
						 | 
				
			
			@ -129,9 +151,6 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
 | 
			
		||||
		Matter.Render.run(this.render);
 | 
			
		||||
 | 
			
		||||
		this.runner = Matter.Runner.create();
 | 
			
		||||
		Matter.Runner.run(this.runner, this.engine);
 | 
			
		||||
 | 
			
		||||
		this.engine.world.bodies = [];
 | 
			
		||||
 | 
			
		||||
		//#region walls
 | 
			
		||||
| 
						 | 
				
			
			@ -223,9 +242,12 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
			Matter.Composite.add(this.engine.world, body);
 | 
			
		||||
 | 
			
		||||
			// 連鎖してfusionした場合の分かりやすさのため少し間を置いてからfusion対象になるようにする
 | 
			
		||||
			window.setTimeout(() => {
 | 
			
		||||
				this.activeBodyIds.push(body.id);
 | 
			
		||||
			}, 100);
 | 
			
		||||
			this.tickCallbackQueue.push({
 | 
			
		||||
				frame: this.frame + 6,
 | 
			
		||||
				callback: () => {
 | 
			
		||||
					this.activeBodyIds.push(body.id);
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			const comboBonus = 1 + ((this.combo - 1) / 5);
 | 
			
		||||
			const additionalScore = Math.round(currentMono.score * comboBonus);
 | 
			
		||||
| 
						 | 
				
			
			@ -244,7 +266,7 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
		} 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)));
 | 
			
		||||
			//	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);
 | 
			
		||||
			//}
 | 
			
		||||
| 
						 | 
				
			
			@ -255,10 +277,25 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public surrender() {
 | 
			
		||||
		this.logs.push({
 | 
			
		||||
			frame: this.frame,
 | 
			
		||||
			operation: 'surrender',
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		this.gameOver();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private gameOver() {
 | 
			
		||||
		this.isGameOver = true;
 | 
			
		||||
		Matter.Runner.stop(this.runner);
 | 
			
		||||
		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,
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/** テクスチャをすべてキャッシュする */
 | 
			
		||||
| 
						 | 
				
			
			@ -292,13 +329,14 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
		return Promise.all(this.monoDefinitions.map(x => loadSingleMonoTexture(x, this)));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public start() {
 | 
			
		||||
	public start(logs?: Log[]) {
 | 
			
		||||
		if (!this.loaded) throw new Error('game is not loaded yet');
 | 
			
		||||
		if (logs) this.replaying = true;
 | 
			
		||||
 | 
			
		||||
		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)],
 | 
			
		||||
				id: this.rng().toString(),
 | 
			
		||||
				mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(this.rng() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
		this.emit('changeStock', this.stock);
 | 
			
		||||
| 
						 | 
				
			
			@ -327,10 +365,13 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
						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);
 | 
			
		||||
						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;
 | 
			
		||||
| 
						 | 
				
			
			@ -354,6 +395,69 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
				this.combo = 0;
 | 
			
		||||
			}
 | 
			
		||||
		}, 500);
 | 
			
		||||
 | 
			
		||||
		if (logs) {
 | 
			
		||||
			const playTick = () => {
 | 
			
		||||
				this.frame++;
 | 
			
		||||
				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();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public getLogs() {
 | 
			
		||||
		return this.logs;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private tick() {
 | 
			
		||||
		this.frame++;
 | 
			
		||||
		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(this.tick);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public async load() {
 | 
			
		||||
| 
						 | 
				
			
			@ -387,17 +491,22 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
 | 
			
		||||
	public drop(_x: number) {
 | 
			
		||||
		if (this.isGameOver) return;
 | 
			
		||||
		if (Date.now() - this.latestDroppedAt < this.DROP_INTERVAL) return;
 | 
			
		||||
		if (!this.replaying && (Date.now() - this.latestDroppedAt < this.DROP_INTERVAL)) return;
 | 
			
		||||
 | 
			
		||||
		const head = 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)],
 | 
			
		||||
			id: this.rng().toString(),
 | 
			
		||||
			mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(this.rng() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
 | 
			
		||||
		});
 | 
			
		||||
		this.emit('changeStock', this.stock);
 | 
			
		||||
 | 
			
		||||
		const x = Math.min(this.gameWidth - this.PLAYAREA_MARGIN - (head.mono.size / 2), Math.max(this.PLAYAREA_MARGIN + (head.mono.size / 2), _x));
 | 
			
		||||
		const x = Math.min(this.gameWidth - this.PLAYAREA_MARGIN - (head.mono.size / 2), Math.max(this.PLAYAREA_MARGIN + (head.mono.size / 2), Math.round(_x)));
 | 
			
		||||
		const body = this.createBody(head.mono, x, 50 + head.mono.size / 2);
 | 
			
		||||
		this.logs.push({
 | 
			
		||||
			frame: this.frame,
 | 
			
		||||
			operation: 'drop',
 | 
			
		||||
			x,
 | 
			
		||||
		});
 | 
			
		||||
		Matter.Composite.add(this.engine.world, body);
 | 
			
		||||
		this.activeBodyIds.push(body.id);
 | 
			
		||||
		this.latestDroppedBodyId = body.id;
 | 
			
		||||
| 
						 | 
				
			
			@ -416,6 +525,11 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
	public hold() {
 | 
			
		||||
		if (this.isGameOver) return;
 | 
			
		||||
 | 
			
		||||
		this.logs.push({
 | 
			
		||||
			frame: this.frame,
 | 
			
		||||
			operation: 'hold',
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		if (this.holding) {
 | 
			
		||||
			const head = this.stock.shift()!;
 | 
			
		||||
			this.stock.unshift(this.holding);
 | 
			
		||||
| 
						 | 
				
			
			@ -426,8 +540,8 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
			const head = this.stock.shift()!;
 | 
			
		||||
			this.holding = head;
 | 
			
		||||
			this.stock.push({
 | 
			
		||||
				id: Math.random().toString(),
 | 
			
		||||
				mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
 | 
			
		||||
				id: this.rng().toString(),
 | 
			
		||||
				mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(this.rng() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
 | 
			
		||||
			});
 | 
			
		||||
			this.emit('changeHolding', this.holding);
 | 
			
		||||
			this.emit('changeStock', this.stock);
 | 
			
		||||
| 
						 | 
				
			
			@ -440,8 +554,9 @@ export class DropAndFusionGame extends EventEmitter<{
 | 
			
		|||
 | 
			
		||||
	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.Runner.stop(this.runner);
 | 
			
		||||
		Matter.World.clear(this.engine.world, false);
 | 
			
		||||
		Matter.Engine.clear(this.engine);
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										5
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -787,6 +787,9 @@ importers:
 | 
			
		|||
      sass:
 | 
			
		||||
        specifier: 1.69.5
 | 
			
		||||
        version: 1.69.5
 | 
			
		||||
      seedrandom:
 | 
			
		||||
        specifier: ^3.0.5
 | 
			
		||||
        version: 3.0.5
 | 
			
		||||
      shiki:
 | 
			
		||||
        specifier: 0.14.7
 | 
			
		||||
        version: 0.14.7
 | 
			
		||||
| 
						 | 
				
			
			@ -7401,7 +7404,7 @@ packages:
 | 
			
		|||
    hasBin: true
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      '@swc/core': ^1.2.66
 | 
			
		||||
      chokidar: ^3.5.1
 | 
			
		||||
      chokidar: 3.5.3
 | 
			
		||||
    peerDependenciesMeta:
 | 
			
		||||
      chokidar:
 | 
			
		||||
        optional: true
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue