diff --git a/locales/index.d.ts b/locales/index.d.ts index 96bc9099d..df8441247 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1195,6 +1195,9 @@ export interface Locale { "bubbleGame": string; "sfx": string; "soundWillBePlayed": string; + "showReplay": string; + "replay": string; + "replaying": string; "_announcement": { "forExistingUsers": string; "forExistingUsersDescription": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index c28fde56c..997ddf9c6 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1192,6 +1192,9 @@ enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する" bubbleGame: "バブルゲーム" sfx: "効果音" soundWillBePlayed: "サウンドが再生されます" +showReplay: "リプレイを見る" +replay: "リプレイ" +replaying: "リプレイ中" _announcement: forExistingUsers: "既存ユーザーのみ" diff --git a/packages/frontend/assets/drop-and-fusion/gameover.mp3 b/packages/frontend/assets/drop-and-fusion/gameover.mp3 new file mode 100644 index 000000000..23b41c569 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/gameover.mp3 differ diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 9ef18a56a..895aa4741 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -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", diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue index b8d3d8bf0..974daf35e 100644 --- a/packages/frontend/src/pages/drop-and-fusion.vue +++ b/packages/frontend/src/pages/drop-and-fusion.vue @@ -61,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only -
+
@@ -74,7 +74,7 @@ SPDX-License-Identifier: AGPL-3.0-only >
{{ comboPrev }} Chain!
-
+
-
+
SCORE:
MAX CHAIN:
-
- Restart - Share -
+
+
+
{{ i18n.ts.replaying }}
+
+
+
+
+ END REPLAY +
+
+
+
+
+
+ {{ i18n.ts.done }} + {{ i18n.ts.showReplay }} + {{ i18n.ts.share }} + Copy replay data
@@ -139,7 +153,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- Restart + Retry
@@ -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 | null = null; const containerEl = shallowRef(); const canvasEl = shallowRef(); @@ -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(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); } diff --git a/packages/frontend/src/scripts/drop-and-fusion-engine.ts b/packages/frontend/src/scripts/drop-and-fusion-engine.ts index f71f3a668..9db93d153 100644 --- a/packages/frontend/src/scripts/drop-and-fusion-engine.ts +++ b/packages/frontend/src/scripts/drop-and-fusion-engine.ts @@ -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 | 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 = {}; private monoTextureUrls: Record = {}; + 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); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0f74de84..9d9822482 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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