diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f3f3bec43..510732cfb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,9 +22,14 @@ - Feat: 新しいゲームを追加 - Enhance: ハッシュタグ入力時に、本文の末尾の行に何も書かれていない場合は新たにスペースを追加しないように - Enhance: チャンネルノートのピン留めをノートのメニューからできるように +- Enhance: 管理者の場合はAPI tokenの発行画面で管理機能に関する権限を付与できるように +- Enhance: AiScriptを0.17.0に更新 [CHANGELOG](https://github.com/aiscript-dev/aiscript/blob/bb89d132b633a622d3cb0eff0d0cc7e476c0cfdd/CHANGELOG.md) + - 配列の範囲外・非整数のインデックスへの代入が完全禁止になるので注意 +- Enhance: 絵文字ピッカー・オートコンプリートで、完全一致した絵文字を優先的に表示するように - Fix: ネイティブモードの絵文字がモノクロにならないように - Fix: v2023.12.0で追加された「モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能」が管理画面上で正しく表示されていない問題を修正 - Fix: AiScriptの`readline`関数が不正な値を返すことがある問題のv2023.12.0時点での修正がPlay以外に適用されていないのを修正 +- Fix: v2023.12.1で追加された`$[clickable ...]`および`onClickEv`が正しく機能していないのを修正 ### Server - Enhance: 連合先のレートリミットに引っかかった際にリトライするようになりました diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index f85eeaf8e2..2feb52c5c6 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -730,6 +730,7 @@ medium: "中" small: "小" generateAccessToken: "アクセストークンの発行" permission: "権限" +adminPermission: "管理者権限" enableAll: "全て有効にする" disableAll: "全て無効にする" tokenRequested: "アカウントへのアクセス許可" @@ -1303,6 +1304,9 @@ enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する" bubbleGame: "バブルゲーム" sfx: "効果音" soundWillBePlayed: "サウンドが再生されます" +showReplay: "リプレイを見る" +replay: "リプレイ" +replaying: "リプレイ中" showUnreadNotificationsCount: "未読の通知の数を表示する" showCatOnly: "キャット付きのみ" additionalPermissionsForFlash: "Playへの追加許可" @@ -1330,6 +1334,13 @@ _showingAnimatedImages: _messaging: direct: "ダイレクトメッセージ" +_bubbleGame: + howToPlay: "遊び方" + _howToPlay: + section1: "位置を調整してハコにモノを落とします。" + section2: "同じ種類のモノがくっつくと別のモノに変化して、スコアが得られます。" + section3: "モノがハコからあふれるとゲームオーバーです。ハコからあふれないようにしつつモノを融合させてハイスコアを目指そう!" + _announcement: forExistingUsers: "既存ユーザーのみ" forExistingUsersDescription: "有効にすると、このお知らせ作成時点で存在するユーザーにのみお知らせが表示されます。無効にすると、このお知らせ作成後にアカウントを作成したユーザーにもお知らせが表示されます。" 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 0000000000..23b41c5699 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 90fa8e2444..f608d90755 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -25,7 +25,7 @@ "@rollup/plugin-json": "6.1.0", "@rollup/plugin-replace": "5.0.5", "@rollup/pluginutils": "5.1.0", - "@syuilo/aiscript": "0.16.0", + "@syuilo/aiscript": "0.17.0", "@tabler/icons-webfont": "2.44.0", "@twemoji/parser": "15.0.0", "@vitejs/plugin-vue": "5.0.2", @@ -63,6 +63,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", "temml": "0.10.20", diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index 7c3cc4e02b..772d7e3353 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -276,15 +276,24 @@ function emojiAutoComplete(query: string | null, emojiDb: EmojiDef[], max = 30): } const matched = new Map(); - - // 前方一致(エイリアスなし) + // 完全一致(エイリアス込み) emojiDb.some(x => { - if (x.name.startsWith(query) && !x.aliasOf) { - matched.set(x.name, { emoji: x, score: query.length + 1 }); + if (x.name === query && !matched.has(x.aliasOf ?? x.name)) { + matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length + 2 }); } return matched.size === max; }); + // 前方一致(エイリアスなし) + if (matched.size < max) { + emojiDb.some(x => { + if (x.name.startsWith(query) && !x.aliasOf) { + matched.set(x.name, { emoji: x, score: query.length + 1 }); + } + return matched.size === max; + }); + } + // 前方一致(エイリアス込み) if (matched.size < max) { emojiDb.some(x => { diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index a1b56e7f56..5772cb8b9f 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -222,6 +222,19 @@ watch(q, () => { } } } else { + if (customEmojisMap.has(newQ)) { + matches.add(customEmojisMap.get(newQ)!); + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.aliases.some(alias => alias === newQ)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + for (const emoji of emojis) { if (emoji.name.startsWith(newQ)) { matches.add(emoji); diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue index 2eed9c0e83..13fc1f7a7f 100644 --- a/packages/frontend/src/components/MkTokenGenerateWindow.vue +++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue @@ -33,7 +33,13 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.enableAll }}
- {{ i18n.t(`_permissions.${kind}`) }} + {{ i18n.t(`_permissions.${kind}`) }} +
+
+
{{ i18n.ts.adminPermission }}
+
+ {{ i18n.t(`_permissions.${kind}`) }} +
@@ -49,6 +55,7 @@ import MkButton from './MkButton.vue'; import MkInfo from './MkInfo.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; +import { iAmAdmin } from '@/account.js'; const props = withDefaults(defineProps<{ title?: string | null; @@ -68,37 +75,76 @@ const emit = defineEmits<{ }>(); const defaultPermissions = Misskey.permissions.filter(p => !p.startsWith('read:admin') && !p.startsWith('write:admin')); +const adminPermissions = Misskey.permissions.filter(p => p.startsWith('read:admin') || p.startsWith('write:admin')); + const dialog = shallowRef>(); const name = ref(props.initialName); -const permissions = ref(>{}); +const permissionSwitches = ref(>{}); +const permissionSwitchesForAdmin = ref(>{}); if (props.initialPermissions) { for (const kind of props.initialPermissions) { - permissions.value[kind] = true; + permissionSwitches.value[kind] = true; } } else { for (const kind of defaultPermissions) { - permissions.value[kind] = false; + permissionSwitches.value[kind] = false; + } + + if (iAmAdmin) { + for (const kind of adminPermissions) { + permissionSwitchesForAdmin.value[kind] = false; + } } } function ok(): void { emit('done', { name: name.value, - permissions: Object.keys(permissions.value).filter(p => permissions.value[p]), + permissions: [ + ...Object.keys(permissionSwitches.value).filter(p => permissionSwitches.value[p]), + ...(iAmAdmin ? Object.keys(permissionSwitchesForAdmin.value).filter(p => permissionSwitchesForAdmin.value[p]) : []), + ], }); dialog.value?.close(); } function disableAll(): void { - for (const p in permissions.value) { - permissions.value[p] = false; + for (const p in permissionSwitches.value) { + permissionSwitches.value[p] = false; + } + if (iAmAdmin) { + for (const p in permissionSwitchesForAdmin.value) { + permissionSwitchesForAdmin.value[p] = false; + } } } function enableAll(): void { - for (const p in permissions.value) { - permissions.value[p] = true; + for (const p in permissionSwitches.value) { + permissionSwitches.value[p] = true; + } + if (iAmAdmin) { + for (const p in permissionSwitchesForAdmin.value) { + permissionSwitchesForAdmin.value[p] = true; + } } } + + diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue index a9f4da598c..3eb3ce4694 100644 --- a/packages/frontend/src/pages/drop-and-fusion.vue +++ b/packages/frontend/src/pages/drop-and-fusion.vue @@ -8,13 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only
-
-
+
+
-
+
@@ -33,6 +33,16 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+
+
{{ i18n.ts._bubbleGame.howToPlay }}
+
    +
  1. {{ i18n.ts._bubbleGame._howToPlay.section1 }}
  2. +
  3. {{ i18n.ts._bubbleGame._howToPlay.section2 }}
  4. +
  5. {{ i18n.ts._bubbleGame._howToPlay.section3 }}
  6. +
+
+
@@ -61,7 +71,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
@@ -74,7 +84,7 @@ SPDX-License-Identifier: AGPL-3.0-only >
{{ comboPrev }} Chain!
-
+
-
+
SCORE:
MAX CHAIN:
+
+
+
{{ i18n.ts.replaying }}
+
+
+
+
- Restart - Share + END REPLAY + x2 + x4
+
+
+
+ {{ i18n.ts.done }} + {{ i18n.ts.showReplay }} + {{ i18n.ts.share }} + Copy replay data +
+
+
@@ -139,7 +167,8 @@ SPDX-License-Identifier: AGPL-3.0-only
- Restart + Surrender + Retry
@@ -168,6 +197,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 +431,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 +446,30 @@ 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 replayPlaybackRate = ref(1); const mute = ref(false); const bgmVolume = ref(defaultStore.state.dropAndFusion.bgmVolume); const sfxVolume = ref(defaultStore.state.dropAndFusion.sfxVolume); +watch(replayPlaybackRate, (newValue) => { + game.replayPlaybackRate = newValue; +}); + 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,19 +494,75 @@ 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(); +} + +async function retry() { + end(); + await start(); +} + +function end() { game.dispose(); - gameOver.value = false; + isGameOver.value = false; + replaying.value = false; + replayPlaybackRate.value = 1; currentPick.value = null; dropReady.value = true; stock.value = []; score.value = 0; combo.value = 0; comboPrev.value = 0; + maxCombo.value = 0; bgmNodes?.soundSource.stop(); 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 +588,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 +609,8 @@ function attachGameEvents() { }); game.addListener('monoAdded', (mono) => { + if (replaying.value) return; + // 実績関連 if (mono.level === 10) { claimAchievement('bubbleGameExplodingHead'); @@ -523,9 +623,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 +657,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 +799,7 @@ useInterval(() => { }, 1000, { immediate: false, afterMounted: true }); onDeactivated(() => { - restart(); + end(); }); definePageMetadata({ @@ -922,6 +1031,29 @@ definePageMetadata({ } } +.replayIndicator { + position: absolute; + z-index: 10; + left: 10px; + bottom: 10px; + padding: 6px 8px; + color: #f00; + font-weight: bold; + 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 a3eb4a9378..e70f6a09b5 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; @@ -31,22 +44,27 @@ export class DropAndFusionGame extends EventEmitter<{ gameOver: () => void; }> { private PHYSICS_QUALITY_FACTOR = 16; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる、逆に高すぎても何故か不安定になる - private COMBO_INTERVAL = 1000; + private COMBO_INTERVAL = 60; // frame 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; @@ -58,7 +76,7 @@ export class DropAndFusionGame extends EventEmitter<{ private latestDroppedBodyId: Matter.Body['id'] | null = null; private latestDroppedAt = 0; - private latestFusionedAt = 0; + private latestFusionedAt = 0; // frame private stock: { id: string; mono: Mono }[] = []; private holding: { id: string; mono: Mono } | null = null; @@ -82,18 +100,24 @@ export class DropAndFusionGame extends EventEmitter<{ private comboIntervalId: number | null = null; + public replayPlaybackRate = 1; + constructor(opts: { canvas: HTMLCanvasElement; 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,13 +153,11 @@ 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 const WALL_OPTIONS: Matter.IChamferableBodyDefinition = { + label: '_wall_', isStatic: true, friction: 0.7, slop: 1.0, @@ -200,13 +222,12 @@ export class DropAndFusionGame extends EventEmitter<{ } private fusion(bodyA: Matter.Body, bodyB: Matter.Body) { - const now = Date.now(); - if (this.latestFusionedAt > now - this.COMBO_INTERVAL) { + if (this.latestFusionedAt > this.frame - this.COMBO_INTERVAL) { this.combo++; } else { this.combo = 1; } - this.latestFusionedAt = now; + this.latestFusionedAt = this.frame; // TODO: 単に位置だけでなくそれぞれの動きベクトルも融合する? const newX = (bodyA.position.x + bodyB.position.x) / 2; @@ -223,20 +244,25 @@ 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); this.score += additionalScore; - // TODO: 効果音再生はコンポーネント側の責務なので移動する - const pan = ((newX / this.gameWidth) - 0.5) * 2; + // 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, + playbackRate: nextMono.sfxPitch * this.replayPlaybackRate, }); this.emit('monoAdded', nextMono); @@ -244,7 +270,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 +281,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 +333,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,33 +369,106 @@ 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; if (energy > minCollisionEnergyForSound) { - // TODO: 効果音再生はコンポーネント側の責務なので移動する + // TODO: 効果音再生はコンポーネント側の責務なので移動するべき? const vol = ((Math.min(maxCollisionEnergyForSound, energy - minCollisionEnergyForSound) / maxCollisionEnergyForSound) / 4) * this.sfxVolume; - const pan = ((((bodyA.position.x + bodyB.position.x) / 2) / this.gameWidth) - 0.5) * 2; + 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, + playbackRate: pitch * this.replayPlaybackRate, }); } } } }); - this.comboIntervalId = window.setInterval(() => { - if (this.latestFusionedAt < Date.now() - this.COMBO_INTERVAL) { - this.combo = 0; + 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(); + } + } + + public getLogs() { + return this.logs; + } + + private 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(); + return false; + } else { + return true; } - }, 500); + }); + Matter.Engine.update(this.engine, this.TICK_DELTA); + if (!this.isGameOver) { + this.tickRaf = window.requestAnimationFrame(this.tick); + } } public async load() { @@ -387,17 +502,23 @@ 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 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 body = this.createBody(head.mono, x, 50 + head.mono.size / 2); + this.logs.push({ + frame: this.frame, + operation: 'drop', + x: inputX, + }); Matter.Composite.add(this.engine.world, body); this.activeBodyIds.push(body.id); this.latestDroppedBodyId = body.id; @@ -405,17 +526,25 @@ export class DropAndFusionGame extends EventEmitter<{ this.emit('dropped'); this.emit('monoAdded', head.mono); - // TODO: 効果音再生はコンポーネント側の責務なので移動する - const pan = ((x / this.gameWidth) - 0.5) * 2; + // 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() { 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 +555,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 +569,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/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts index 79228b4be9..6863ca73c1 100644 --- a/packages/frontend/src/scripts/sound.ts +++ b/packages/frontend/src/scripts/sound.ts @@ -102,7 +102,6 @@ export async function loadAudio(url: string, options?: { useCache?: boolean; }) } if (options?.useCache ?? true) { if (cache.has(url)) { - if (_DEV_) console.log('use cache'); return cache.get(url) as AudioBuffer; } } @@ -131,7 +130,6 @@ export async function loadAudio(url: string, options?: { useCache?: boolean; }) */ export function playMisskeySfx(operationType: OperationType) { const sound = defaultStore.state[`sound_${operationType}`]; - if (_DEV_) console.log('play', operationType, sound); if (sound.type == null || !canPlay) return; canPlay = false;