Compare commits
	
		
			13 Commits
		
	
	
		
			chat
			...
			bubble-gam
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 7627da62ee | ||
|   | 31a25f9332 | ||
|   | f85b9107e2 | ||
|   | 9181e3db7d | ||
|   | 028800c0bb | ||
|   | e33c8bf43a | ||
|   | 4918923635 | ||
|   | eda727c487 | ||
|   | 333fac00c8 | ||
|   | aa7dd98119 | ||
|   | c0d28358d6 | ||
|   | 17f368e228 | ||
|   | 14d4ffaa36 | 
							
								
								
									
										3
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -1195,6 +1195,9 @@ export interface Locale { | |||||||
|     "bubbleGame": string; |     "bubbleGame": string; | ||||||
|     "sfx": string; |     "sfx": string; | ||||||
|     "soundWillBePlayed": string; |     "soundWillBePlayed": string; | ||||||
|  |     "showReplay": string; | ||||||
|  |     "replay": string; | ||||||
|  |     "replaying": string; | ||||||
|     "_announcement": { |     "_announcement": { | ||||||
|         "forExistingUsers": string; |         "forExistingUsers": string; | ||||||
|         "forExistingUsersDescription": string; |         "forExistingUsersDescription": string; | ||||||
|   | |||||||
| @@ -1192,6 +1192,9 @@ enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する" | |||||||
| bubbleGame: "バブルゲーム" | bubbleGame: "バブルゲーム" | ||||||
| sfx: "効果音" | sfx: "効果音" | ||||||
| soundWillBePlayed: "サウンドが再生されます" | soundWillBePlayed: "サウンドが再生されます" | ||||||
|  | showReplay: "リプレイを見る" | ||||||
|  | replay: "リプレイ" | ||||||
|  | replaying: "リプレイ中" | ||||||
|  |  | ||||||
| _announcement: | _announcement: | ||||||
|   forExistingUsers: "既存ユーザーのみ" |   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", | 		"rollup": "4.9.1", | ||||||
| 		"sanitize-html": "2.11.0", | 		"sanitize-html": "2.11.0", | ||||||
| 		"sass": "1.69.5", | 		"sass": "1.69.5", | ||||||
|  | 		"seedrandom": "^3.0.5", | ||||||
| 		"shiki": "0.14.7", | 		"shiki": "0.14.7", | ||||||
| 		"strict-event-emitter-types": "2.0.0", | 		"strict-event-emitter-types": "2.0.0", | ||||||
| 		"textarea-caret": "3.1.0", | 		"textarea-caret": "3.1.0", | ||||||
|   | |||||||
| @@ -61,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| 					</div> | 					</div> | ||||||
| 				</div> | 				</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-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"/> | 				<img v-else src="/client-assets/drop-and-fusion/frame-light.svg" :class="$style.mainFrameImg"/> | ||||||
| 				<canvas ref="canvasEl" :class="$style.canvas"/> | 				<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> | 					<div v-show="combo > 1" :class="$style.combo" :style="{ fontSize: `${100 + ((comboPrev - 2) * 15)}%` }">{{ comboPrev }} Chain!</div> | ||||||
| 				</Transition> | 				</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' }"/>--> | 					<!--<img v-if="currentPick" src="/client-assets/drop-and-fusion/dropper.png" :class="$style.dropper" :style="{ left: dropperX + 'px' }"/>--> | ||||||
| 					<Transition | 					<Transition | ||||||
| 						:enterActiveClass="$style.transition_picked_enterActive" | 						:enterActiveClass="$style.transition_picked_enterActive" | ||||||
| @@ -91,15 +91,29 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| 						<div :class="$style.dropGuide"/> | 						<div :class="$style.dropGuide"/> | ||||||
| 					</template> | 					</template> | ||||||
| 				</div> | 				</div> | ||||||
| 				<div v-if="gameOver" :class="$style.gameOverLabel"> | 				<div v-if="isGameOver && !replaying" :class="$style.gameOverLabel"> | ||||||
| 					<div class="_gaps_s"> | 					<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;"/> | 						<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>SCORE: <MkNumber :value="score"/></div> | ||||||
| 						<div>MAX CHAIN: <MkNumber :value="maxCombo"/></div> | 						<div>MAX CHAIN: <MkNumber :value="maxCombo"/></div> | ||||||
| 						<div class="_buttonsCenter"> | 					</div> | ||||||
| 							<MkButton primary rounded @click="restart">Restart</MkButton> | 				</div> | ||||||
| 							<MkButton primary rounded @click="share">Share</MkButton> | 				<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> | ||||||
|  | 			<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> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| @@ -139,7 +153,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| 			</div> | 			</div> | ||||||
| 			<div :class="$style.frame"> | 			<div :class="$style.frame"> | ||||||
| 				<div :class="$style.frameInner"> | 				<div :class="$style.frameInner"> | ||||||
| 					<MkButton @click="restart">Restart</MkButton> | 					<MkButton danger @click="surrender">Retry</MkButton> | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</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 * as sound from '@/scripts/sound.js'; | ||||||
| import MkRange from '@/components/MkRange.vue'; | import MkRange from '@/components/MkRange.vue'; | ||||||
| import MkSwitch from '@/components/MkSwitch.vue'; | import MkSwitch from '@/components/MkSwitch.vue'; | ||||||
|  | import copyToClipboard from '@/scripts/copy-to-clipboard.js'; | ||||||
|  |  | ||||||
| const NORMAL_BASE_SIZE = 30; | const NORMAL_BASE_SIZE = 30; | ||||||
| const NORAML_MONOS: Mono[] = [{ | const NORAML_MONOS: Mono[] = [{ | ||||||
| @@ -401,6 +416,8 @@ const GAME_HEIGHT = 600; | |||||||
| let viewScale = 1; | let viewScale = 1; | ||||||
| let game: DropAndFusionGame; | let game: DropAndFusionGame; | ||||||
| let containerElRect: DOMRect | null = null; | let containerElRect: DOMRect | null = null; | ||||||
|  | let seed: string; | ||||||
|  | let logs: ReturnType<DropAndFusionGame['getLogs']> | null = null; | ||||||
|  |  | ||||||
| const containerEl = shallowRef<HTMLElement>(); | const containerEl = shallowRef<HTMLElement>(); | ||||||
| const canvasEl = shallowRef<HTMLCanvasElement>(); | const canvasEl = shallowRef<HTMLCanvasElement>(); | ||||||
| @@ -414,22 +431,25 @@ const comboPrev = ref(0); | |||||||
| const maxCombo = ref(0); | const maxCombo = ref(0); | ||||||
| const dropReady = ref(true); | const dropReady = ref(true); | ||||||
| const gameMode = ref<'normal' | 'square'>('normal'); | const gameMode = ref<'normal' | 'square'>('normal'); | ||||||
| const gameOver = ref(false); | const isGameOver = ref(false); | ||||||
| const gameStarted = ref(false); | const gameStarted = ref(false); | ||||||
| const highScore = ref<number | null>(null); | const highScore = ref<number | null>(null); | ||||||
| const showConfig = ref(false); | const showConfig = ref(false); | ||||||
|  | const replaying = ref(false); | ||||||
| const mute = ref(false); | const mute = ref(false); | ||||||
| const bgmVolume = ref(defaultStore.state.dropAndFusion.bgmVolume); | const bgmVolume = ref(defaultStore.state.dropAndFusion.bgmVolume); | ||||||
| const sfxVolume = ref(defaultStore.state.dropAndFusion.sfxVolume); | const sfxVolume = ref(defaultStore.state.dropAndFusion.sfxVolume); | ||||||
|  |  | ||||||
| function onClick(ev: MouseEvent) { | function onClick(ev: MouseEvent) { | ||||||
| 	if (!containerElRect) return; | 	if (!containerElRect) return; | ||||||
|  | 	if (replaying.value) return; | ||||||
| 	const x = (ev.clientX - containerElRect.left) / viewScale; | 	const x = (ev.clientX - containerElRect.left) / viewScale; | ||||||
| 	game.drop(x); | 	game.drop(x); | ||||||
| } | } | ||||||
|  |  | ||||||
| function onTouchend(ev: TouchEvent) { | function onTouchend(ev: TouchEvent) { | ||||||
| 	if (!containerElRect) return; | 	if (!containerElRect) return; | ||||||
|  | 	if (replaying.value) return; | ||||||
| 	const x = (ev.changedTouches[0].clientX - containerElRect.left) / viewScale; | 	const x = (ev.changedTouches[0].clientX - containerElRect.left) / viewScale; | ||||||
| 	game.drop(x); | 	game.drop(x); | ||||||
| } | } | ||||||
| @@ -454,9 +474,18 @@ function hold() { | |||||||
| 	game.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(); | 	game.dispose(); | ||||||
| 	gameOver.value = false; | 	isGameOver.value = false; | ||||||
| 	currentPick.value = null; | 	currentPick.value = null; | ||||||
| 	dropReady.value = true; | 	dropReady.value = true; | ||||||
| 	stock.value = []; | 	stock.value = []; | ||||||
| @@ -467,6 +496,45 @@ function restart() { | |||||||
| 	gameStarted.value = false; | 	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() { | function attachGameEvents() { | ||||||
| 	game.addListener('changeScore', value => { | 	game.addListener('changeScore', value => { | ||||||
| 		score.value = value; | 		score.value = value; | ||||||
| @@ -492,9 +560,11 @@ function attachGameEvents() { | |||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
| 	game.addListener('dropped', () => { | 	game.addListener('dropped', () => { | ||||||
|  | 		if (replaying.value) return; | ||||||
|  |  | ||||||
| 		dropReady.value = false; | 		dropReady.value = false; | ||||||
| 		window.setTimeout(() => { | 		window.setTimeout(() => { | ||||||
| 			if (!gameOver.value) { | 			if (!isGameOver.value) { | ||||||
| 				dropReady.value = true; | 				dropReady.value = true; | ||||||
| 			} | 			} | ||||||
| 		}, game.DROP_INTERVAL); | 		}, game.DROP_INTERVAL); | ||||||
| @@ -511,6 +581,8 @@ function attachGameEvents() { | |||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
| 	game.addListener('monoAdded', (mono) => { | 	game.addListener('monoAdded', (mono) => { | ||||||
|  | 		if (replaying.value) return; | ||||||
|  |  | ||||||
| 		// 実績関連 | 		// 実績関連 | ||||||
| 		if (mono.level === 10) { | 		if (mono.level === 10) { | ||||||
| 			claimAchievement('bubbleGameExplodingHead'); | 			claimAchievement('bubbleGameExplodingHead'); | ||||||
| @@ -523,9 +595,15 @@ function attachGameEvents() { | |||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
| 	game.addListener('gameOver', () => { | 	game.addListener('gameOver', () => { | ||||||
|  | 		if (replaying.value) { | ||||||
|  | 			endReplay(); | ||||||
|  | 			return; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		logs = game.getLogs(); | ||||||
| 		currentPick.value = null; | 		currentPick.value = null; | ||||||
| 		dropReady.value = false; | 		dropReady.value = false; | ||||||
| 		gameOver.value = true; | 		isGameOver.value = true; | ||||||
|  |  | ||||||
| 		if (score.value > (highScore.value ?? 0)) { | 		if (score.value > (highScore.value ?? 0)) { | ||||||
| 			highScore.value = score.value; | 			highScore.value = score.value; | ||||||
| @@ -551,10 +629,13 @@ async function start() { | |||||||
| 		highScore.value = null; | 		highScore.value = null; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	seed = Date.now().toString(); | ||||||
|  |  | ||||||
| 	game = new DropAndFusionGame({ | 	game = new DropAndFusionGame({ | ||||||
| 		width: GAME_WIDTH, | 		width: GAME_WIDTH, | ||||||
| 		height: GAME_HEIGHT, | 		height: GAME_HEIGHT, | ||||||
| 		canvas: canvasEl.value!, | 		canvas: canvasEl.value!, | ||||||
|  | 		seed: seed, | ||||||
| 		sfxVolume: mute.value ? 0 : sfxVolume.value, | 		sfxVolume: mute.value ? 0 : sfxVolume.value, | ||||||
| 		...( | 		...( | ||||||
| 			gameMode.value === 'normal' ? { | 			gameMode.value === 'normal' ? { | ||||||
| @@ -690,7 +771,7 @@ useInterval(() => { | |||||||
| }, 1000, { immediate: false, afterMounted: true }); | }, 1000, { immediate: false, afterMounted: true }); | ||||||
|  |  | ||||||
| onDeactivated(() => { | onDeactivated(() => { | ||||||
| 	restart(); | 	end(); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| definePageMetadata({ | 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 { | @keyframes currentMonoArrow { | ||||||
| 	0% { transform: translateY(0); } | 	0% { transform: translateY(0); } | ||||||
| 	25% { transform: translateY(-8px); } | 	25% { transform: translateY(-8px); } | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ | |||||||
|  |  | ||||||
| import { EventEmitter } from 'eventemitter3'; | import { EventEmitter } from 'eventemitter3'; | ||||||
| import * as Matter from 'matter-js'; | import * as Matter from 'matter-js'; | ||||||
|  | import seedrandom from 'seedrandom'; | ||||||
| import * as sound from '@/scripts/sound.js'; | import * as sound from '@/scripts/sound.js'; | ||||||
|  |  | ||||||
| export type Mono = { | export type Mono = { | ||||||
| @@ -20,6 +21,18 @@ export type Mono = { | |||||||
| 	spriteScale: number; | 	spriteScale: number; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | type Log = { | ||||||
|  | 	frame: number; | ||||||
|  | 	operation: 'drop'; | ||||||
|  | 	x: number; | ||||||
|  | } | { | ||||||
|  | 	frame: number; | ||||||
|  | 	operation: 'hold'; | ||||||
|  | } | { | ||||||
|  | 	frame: number; | ||||||
|  | 	operation: 'surrender'; | ||||||
|  | }; | ||||||
|  |  | ||||||
| export class DropAndFusionGame extends EventEmitter<{ | export class DropAndFusionGame extends EventEmitter<{ | ||||||
| 	changeScore: (newScore: number) => void; | 	changeScore: (newScore: number) => void; | ||||||
| 	changeCombo: (newCombo: number) => void; | 	changeCombo: (newCombo: number) => void; | ||||||
| @@ -35,18 +48,23 @@ export class DropAndFusionGame extends EventEmitter<{ | |||||||
| 	public readonly DROP_INTERVAL = 500; | 	public readonly DROP_INTERVAL = 500; | ||||||
| 	public readonly PLAYAREA_MARGIN = 25; | 	public readonly PLAYAREA_MARGIN = 25; | ||||||
| 	private STOCK_MAX = 4; | 	private STOCK_MAX = 4; | ||||||
|  | 	private TICK_DELTA = 1000 / 60; // 60fps | ||||||
| 	private loaded = false; | 	private loaded = false; | ||||||
|  | 	private frame = 0; | ||||||
| 	private engine: Matter.Engine; | 	private engine: Matter.Engine; | ||||||
| 	private render: Matter.Render; | 	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 overflowCollider: Matter.Body; | ||||||
| 	private isGameOver = false; | 	private isGameOver = false; | ||||||
|  |  | ||||||
| 	private gameWidth: number; | 	private gameWidth: number; | ||||||
| 	private gameHeight: number; | 	private gameHeight: number; | ||||||
| 	private monoDefinitions: Mono[] = []; | 	private monoDefinitions: Mono[] = []; | ||||||
| 	private monoTextures: Record<string, Blob> = {}; | 	private monoTextures: Record<string, Blob> = {}; | ||||||
| 	private monoTextureUrls: Record<string, string> = {}; | 	private monoTextureUrls: Record<string, string> = {}; | ||||||
|  | 	private rng: () => number; | ||||||
|  | 	private logs: Log[] = []; | ||||||
|  | 	private replaying = false; | ||||||
|  |  | ||||||
| 	private sfxVolume = 1; | 	private sfxVolume = 1; | ||||||
|  |  | ||||||
| @@ -87,13 +105,17 @@ export class DropAndFusionGame extends EventEmitter<{ | |||||||
| 		width: number; | 		width: number; | ||||||
| 		height: number; | 		height: number; | ||||||
| 		monoDefinitions: Mono[]; | 		monoDefinitions: Mono[]; | ||||||
|  | 		seed: string; | ||||||
| 		sfxVolume?: number; | 		sfxVolume?: number; | ||||||
| 	}) { | 	}) { | ||||||
| 		super(); | 		super(); | ||||||
|  |  | ||||||
|  | 		this.tick = this.tick.bind(this); | ||||||
|  |  | ||||||
| 		this.gameWidth = opts.width; | 		this.gameWidth = opts.width; | ||||||
| 		this.gameHeight = opts.height; | 		this.gameHeight = opts.height; | ||||||
| 		this.monoDefinitions = opts.monoDefinitions; | 		this.monoDefinitions = opts.monoDefinitions; | ||||||
|  | 		this.rng = seedrandom(opts.seed); | ||||||
|  |  | ||||||
| 		if (opts.sfxVolume) { | 		if (opts.sfxVolume) { | ||||||
| 			this.sfxVolume = opts.sfxVolume; | 			this.sfxVolume = opts.sfxVolume; | ||||||
| @@ -129,9 +151,6 @@ export class DropAndFusionGame extends EventEmitter<{ | |||||||
|  |  | ||||||
| 		Matter.Render.run(this.render); | 		Matter.Render.run(this.render); | ||||||
|  |  | ||||||
| 		this.runner = Matter.Runner.create(); |  | ||||||
| 		Matter.Runner.run(this.runner, this.engine); |  | ||||||
|  |  | ||||||
| 		this.engine.world.bodies = []; | 		this.engine.world.bodies = []; | ||||||
|  |  | ||||||
| 		//#region walls | 		//#region walls | ||||||
| @@ -223,9 +242,12 @@ export class DropAndFusionGame extends EventEmitter<{ | |||||||
| 			Matter.Composite.add(this.engine.world, body); | 			Matter.Composite.add(this.engine.world, body); | ||||||
|  |  | ||||||
| 			// 連鎖してfusionした場合の分かりやすさのため少し間を置いてからfusion対象になるようにする | 			// 連鎖してfusionした場合の分かりやすさのため少し間を置いてからfusion対象になるようにする | ||||||
| 			window.setTimeout(() => { | 			this.tickCallbackQueue.push({ | ||||||
| 				this.activeBodyIds.push(body.id); | 				frame: this.frame + 6, | ||||||
| 			}, 100); | 				callback: () => { | ||||||
|  | 					this.activeBodyIds.push(body.id); | ||||||
|  | 				}, | ||||||
|  | 			}); | ||||||
|  |  | ||||||
| 			const comboBonus = 1 + ((this.combo - 1) / 5); | 			const comboBonus = 1 + ((this.combo - 1) / 5); | ||||||
| 			const additionalScore = Math.round(currentMono.score * comboBonus); | 			const additionalScore = Math.round(currentMono.score * comboBonus); | ||||||
| @@ -244,7 +266,7 @@ export class DropAndFusionGame extends EventEmitter<{ | |||||||
| 		} else { | 		} else { | ||||||
| 			//const VELOCITY = 30; | 			//const VELOCITY = 30; | ||||||
| 			//for (let i = 0; i < 10; i++) { | 			//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); | 			//	Matter.Composite.add(world, body); | ||||||
| 			//	bodies.push(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() { | 	private gameOver() { | ||||||
| 		this.isGameOver = true; | 		this.isGameOver = true; | ||||||
| 		Matter.Runner.stop(this.runner); | 		if (this.tickRaf) window.cancelAnimationFrame(this.tickRaf); | ||||||
|  | 		this.tickRaf = null; | ||||||
| 		this.emit('gameOver'); | 		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))); | 		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 (!this.loaded) throw new Error('game is not loaded yet'); | ||||||
|  | 		if (logs) this.replaying = true; | ||||||
|  |  | ||||||
| 		for (let i = 0; i < this.STOCK_MAX; i++) { | 		for (let i = 0; i < this.STOCK_MAX; i++) { | ||||||
| 			this.stock.push({ | 			this.stock.push({ | ||||||
| 				id: Math.random().toString(), | 				id: this.rng().toString(), | ||||||
| 				mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)], | 				mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(this.rng() * this.monoDefinitions.filter(x => x.dropCandidate).length)], | ||||||
| 			}); | 			}); | ||||||
| 		} | 		} | ||||||
| 		this.emit('changeStock', this.stock); | 		this.emit('changeStock', this.stock); | ||||||
| @@ -327,10 +365,13 @@ export class DropAndFusionGame extends EventEmitter<{ | |||||||
| 						this.fusion(bodyA, bodyB); | 						this.fusion(bodyA, bodyB); | ||||||
| 					} else { | 					} else { | ||||||
| 						fusionReservedPairs.push({ bodyA, bodyB }); | 						fusionReservedPairs.push({ bodyA, bodyB }); | ||||||
| 						window.setTimeout(() => { | 						this.tickCallbackQueue.push({ | ||||||
| 							fusionReservedPairs = fusionReservedPairs.filter(x => x.bodyA.id !== bodyA.id && x.bodyB.id !== bodyB.id); | 							frame: this.frame + 6, | ||||||
| 							this.fusion(bodyA, bodyB); | 							callback: () => { | ||||||
| 						}, 100); | 								fusionReservedPairs = fusionReservedPairs.filter(x => x.bodyA.id !== bodyA.id && x.bodyB.id !== bodyB.id); | ||||||
|  | 								this.fusion(bodyA, bodyB); | ||||||
|  | 							}, | ||||||
|  | 						}); | ||||||
| 					} | 					} | ||||||
| 				} else { | 				} else { | ||||||
| 					const energy = pairs.collision.depth; | 					const energy = pairs.collision.depth; | ||||||
| @@ -354,6 +395,69 @@ export class DropAndFusionGame extends EventEmitter<{ | |||||||
| 				this.combo = 0; | 				this.combo = 0; | ||||||
| 			} | 			} | ||||||
| 		}, 500); | 		}, 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() { | 	public async load() { | ||||||
| @@ -387,17 +491,22 @@ export class DropAndFusionGame extends EventEmitter<{ | |||||||
|  |  | ||||||
| 	public drop(_x: number) { | 	public drop(_x: number) { | ||||||
| 		if (this.isGameOver) return; | 		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()!; | 		const head = this.stock.shift()!; | ||||||
| 		this.stock.push({ | 		this.stock.push({ | ||||||
| 			id: Math.random().toString(), | 			id: this.rng().toString(), | ||||||
| 			mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)], | 			mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(this.rng() * this.monoDefinitions.filter(x => x.dropCandidate).length)], | ||||||
| 		}); | 		}); | ||||||
| 		this.emit('changeStock', this.stock); | 		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); | 		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); | 		Matter.Composite.add(this.engine.world, body); | ||||||
| 		this.activeBodyIds.push(body.id); | 		this.activeBodyIds.push(body.id); | ||||||
| 		this.latestDroppedBodyId = body.id; | 		this.latestDroppedBodyId = body.id; | ||||||
| @@ -416,6 +525,11 @@ export class DropAndFusionGame extends EventEmitter<{ | |||||||
| 	public hold() { | 	public hold() { | ||||||
| 		if (this.isGameOver) return; | 		if (this.isGameOver) return; | ||||||
|  |  | ||||||
|  | 		this.logs.push({ | ||||||
|  | 			frame: this.frame, | ||||||
|  | 			operation: 'hold', | ||||||
|  | 		}); | ||||||
|  |  | ||||||
| 		if (this.holding) { | 		if (this.holding) { | ||||||
| 			const head = this.stock.shift()!; | 			const head = this.stock.shift()!; | ||||||
| 			this.stock.unshift(this.holding); | 			this.stock.unshift(this.holding); | ||||||
| @@ -426,8 +540,8 @@ export class DropAndFusionGame extends EventEmitter<{ | |||||||
| 			const head = this.stock.shift()!; | 			const head = this.stock.shift()!; | ||||||
| 			this.holding = head; | 			this.holding = head; | ||||||
| 			this.stock.push({ | 			this.stock.push({ | ||||||
| 				id: Math.random().toString(), | 				id: this.rng().toString(), | ||||||
| 				mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)], | 				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('changeHolding', this.holding); | ||||||
| 			this.emit('changeStock', this.stock); | 			this.emit('changeStock', this.stock); | ||||||
| @@ -440,8 +554,9 @@ export class DropAndFusionGame extends EventEmitter<{ | |||||||
|  |  | ||||||
| 	public dispose() { | 	public dispose() { | ||||||
| 		if (this.comboIntervalId) window.clearInterval(this.comboIntervalId); | 		if (this.comboIntervalId) window.clearInterval(this.comboIntervalId); | ||||||
|  | 		if (this.tickRaf) window.cancelAnimationFrame(this.tickRaf); | ||||||
|  | 		this.tickRaf = null; | ||||||
| 		Matter.Render.stop(this.render); | 		Matter.Render.stop(this.render); | ||||||
| 		Matter.Runner.stop(this.runner); |  | ||||||
| 		Matter.World.clear(this.engine.world, false); | 		Matter.World.clear(this.engine.world, false); | ||||||
| 		Matter.Engine.clear(this.engine); | 		Matter.Engine.clear(this.engine); | ||||||
| 	} | 	} | ||||||
|   | |||||||
							
								
								
									
										5
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							| @@ -787,6 +787,9 @@ importers: | |||||||
|       sass: |       sass: | ||||||
|         specifier: 1.69.5 |         specifier: 1.69.5 | ||||||
|         version: 1.69.5 |         version: 1.69.5 | ||||||
|  |       seedrandom: | ||||||
|  |         specifier: ^3.0.5 | ||||||
|  |         version: 3.0.5 | ||||||
|       shiki: |       shiki: | ||||||
|         specifier: 0.14.7 |         specifier: 0.14.7 | ||||||
|         version: 0.14.7 |         version: 0.14.7 | ||||||
| @@ -7401,7 +7404,7 @@ packages: | |||||||
|     hasBin: true |     hasBin: true | ||||||
|     peerDependencies: |     peerDependencies: | ||||||
|       '@swc/core': ^1.2.66 |       '@swc/core': ^1.2.66 | ||||||
|       chokidar: ^3.5.1 |       chokidar: 3.5.3 | ||||||
|     peerDependenciesMeta: |     peerDependenciesMeta: | ||||||
|       chokidar: |       chokidar: | ||||||
|         optional: true |         optional: true | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user