diff --git a/.github/workflows/api-misskey-js.yml b/.github/workflows/api-misskey-js.yml index 5cffbd81bc..e52cbc33e4 100644 --- a/.github/workflows/api-misskey-js.yml +++ b/.github/workflows/api-misskey-js.yml @@ -1,6 +1,12 @@ name: API report (misskey.js) -on: [push, pull_request] +on: + push: + paths: + - packages/misskey-js/** + pull_request: + paths: + - packages/misskey-js/** jobs: report: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f3074ab0a4..23cea7d565 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,7 +5,19 @@ on: branches: - master - develop + paths: + - packages/backend/** + - packages/frontend/** + - packages/sw/** + - packages/misskey-js/** + - packages/shared/.eslintrc.js pull_request: + paths: + - packages/backend/** + - packages/frontend/** + - packages/sw/** + - packages/misskey-js/** + - packages/shared/.eslintrc.js jobs: pnpm_install: diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index 3b49173f45..a6c12e2824 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -5,7 +5,15 @@ on: branches: - master - develop + paths: + - packages/backend/** + # for permissions + - packages/misskey-js/** pull_request: + paths: + - packages/backend/** + # for permissions + - packages/misskey-js/** jobs: unit: diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index 83740bf156..3fb880fac2 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -5,7 +5,20 @@ on: branches: - master - develop + paths: + - packages/frontend/** + # for permissions + - packages/misskey-js/** + # for e2e + - packages/backend/** + pull_request: + paths: + - packages/frontend/** + # for permissions + - packages/misskey-js/** + # for e2e + - packages/backend/** jobs: vitest: diff --git a/.github/workflows/test-misskey-js.yml b/.github/workflows/test-misskey-js.yml index 055152f321..10c7ccf4d3 100644 --- a/.github/workflows/test-misskey-js.yml +++ b/.github/workflows/test-misskey-js.yml @@ -6,8 +6,12 @@ name: Test (misskey.js) on: push: branches: [ develop ] + paths: + - packages/misskey-js/** pull_request: branches: [ develop ] + paths: + - packages/misskey-js/** jobs: test: diff --git a/.github/workflows/validate-api-json.yml b/.github/workflows/validate-api-json.yml new file mode 100644 index 0000000000..bc5ba20cb9 --- /dev/null +++ b/.github/workflows/validate-api-json.yml @@ -0,0 +1,47 @@ +name: Test (backend) + +on: + push: + branches: + - master + - develop + paths: + - packages/backend/** + pull_request: + paths: + - packages/backend/** + +jobs: + validate-api-json: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.10.0] + + steps: + - uses: actions/checkout@v4.1.1 + with: + submodules: true + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + run_install: false + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4.0.1 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - name: Install swagger-cli + run: npm i -g swagger-cli + - run: corepack enable + - run: pnpm i --frozen-lockfile + - name: Check pnpm-lock.yaml + run: git diff --exit-code pnpm-lock.yaml + - name: Copy Configure + run: cp .config/example.yml .config/default.yml + - name: Build and generate + run: pnpm build && pnpm --filter backend generate-api-json + - name: Validation + run: swagger-cli validate ./packages/backend/built/api.json diff --git a/CHANGELOG.md b/CHANGELOG.md index d8a26a923e..c957a44035 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,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/index.d.ts b/locales/index.d.ts index b3da898648..a1ad3ba0a3 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -634,6 +634,7 @@ export interface Locale { "small": string; "generateAccessToken": string; "permission": string; + "adminPermission": string; "enableAll": string; "disableAll": string; "tokenRequested": string; @@ -1195,6 +1196,19 @@ export interface Locale { "addMfmFunction": string; "enableQuickAddMfmFunction": string; "bubbleGame": string; + "sfx": string; + "soundWillBePlayed": string; + "showReplay": string; + "replay": string; + "replaying": string; + "_bubbleGame": { + "howToPlay": string; + "_howToPlay": { + "section1": string; + "section2": string; + "section3": string; + }; + }; "_announcement": { "forExistingUsers": string; "forExistingUsersDescription": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index ed7af7f2cf..da39028bf5 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -631,6 +631,7 @@ medium: "中" small: "小" generateAccessToken: "アクセストークンの発行" permission: "権限" +adminPermission: "管理者権限" enableAll: "全て有効にする" disableAll: "全て無効にする" tokenRequested: "アカウントへのアクセス許可" @@ -1192,6 +1193,18 @@ decorate: "デコる" addMfmFunction: "装飾を追加" enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する" bubbleGame: "バブルゲーム" +sfx: "効果音" +soundWillBePlayed: "サウンドが再生されます" +showReplay: "リプレイを見る" +replay: "リプレイ" +replaying: "リプレイ中" + +_bubbleGame: + howToPlay: "遊び方" + _howToPlay: + section1: "位置を調整してハコにモノを落とします。" + section2: "同じ種類のモノがくっつくと別のモノに変化して、スコアが得られます。" + section3: "モノがハコからあふれるとゲームオーバーです。ハコからあふれないようにしつつモノを融合させてハイスコアを目指そう!" _announcement: forExistingUsers: "既存ユーザーのみ" diff --git a/packages/frontend/assets/drop-and-fusion/click.mp3 b/packages/frontend/assets/drop-and-fusion/click.mp3 new file mode 100644 index 0000000000..ef03e60f61 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/click.mp3 differ 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/assets/drop-and-fusion/hold.mp3 b/packages/frontend/assets/drop-and-fusion/hold.mp3 new file mode 100644 index 0000000000..f064c976d3 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/hold.mp3 differ diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 9ef18a56a7..8c3ce30668 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -24,7 +24,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", @@ -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/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 5011ce9e74..bdb145b39a 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -271,7 +271,7 @@ export async function mainBoot() { main.on('unreadAntenna', () => { updateAccount({ hasUnreadAntenna: true }); - sound.play('antenna'); + sound.playMisskeySfx('antenna'); }); main.on('readAllAnnouncements', () => { diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index 49884c705f..15eda4499f 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -262,15 +262,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 f36d46506f..84424c58ed 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -221,6 +221,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/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 3ec9c3c46a..9c4354ef5f 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -345,7 +345,7 @@ function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); if (appearNote.value.reactionAcceptance === 'likeOnly') { - sound.play('reaction'); + sound.playMisskeySfx('reaction'); if (props.mock) { return; @@ -365,7 +365,7 @@ function react(viaKeyboard = false): void { } else { blur(); reactionPicker.show(reactButton.value, reaction => { - sound.play('reaction'); + sound.playMisskeySfx('reaction'); if (props.mock) { emit('reaction', reaction); diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 6f0c0323cc..e941827d74 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -370,7 +370,7 @@ function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); if (appearNote.value.reactionAcceptance === 'likeOnly') { - sound.play('reaction'); + sound.playMisskeySfx('reaction'); misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, @@ -386,7 +386,7 @@ function react(viaKeyboard = false): void { } else { blur(); reactionPicker.show(reactButton.value, reaction => { - sound.play('reaction'); + sound.playMisskeySfx('reaction'); misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue index 04390c6f0c..1aee1aaac3 100644 --- a/packages/frontend/src/components/MkRange.vue +++ b/packages/frontend/src/components/MkRange.vue @@ -43,6 +43,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'update:modelValue', value: number): void; + (ev: 'dragEnded', value: number): void; }>(); const containerEl = shallowRef(); @@ -143,6 +144,7 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => { // 値が変わってたら通知 if (beforeValue !== finalValue.value) { emit('update:modelValue', finalValue.value); + emit('dragEnded', finalValue.value); } }; diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index 2e75f444da..5ca09fa822 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -62,7 +62,7 @@ async function toggleReaction() { if (confirm.canceled) return; if (oldReaction !== props.reaction) { - sound.play('reaction'); + sound.playMisskeySfx('reaction'); } if (mock) { @@ -81,7 +81,7 @@ async function toggleReaction() { } }); } else { - sound.play('reaction'); + sound.playMisskeySfx('reaction'); if (mock) { emit('reactionToggled', props.reaction, (props.count + 1)); diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 63f779dbde..8a5076ea1d 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -81,7 +81,7 @@ function prepend(note) { emit('note'); if (props.sound) { - sound.play($i && (note.userId === $i.id) ? 'noteMy' : 'note'); + sound.playMisskeySfx($i && (note.userId === $i.id) ? 'noteMy' : 'note'); } } diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue index d024e1e593..a42767e1b6 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/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue index a9643d68ca..dd3fe77251 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.vue +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -91,7 +91,7 @@ function onClick(ev: MouseEvent) { icon: 'ti ti-plus', action: () => { react(`:${props.name}:`); - sound.play('reaction'); + sound.playMisskeySfx('reaction'); }, }] : [])], ev.currentTarget ?? ev.target); } diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue index f6b21343b6..cbdb3881c6 100644 --- a/packages/frontend/src/components/global/MkEmoji.vue +++ b/packages/frontend/src/components/global/MkEmoji.vue @@ -55,7 +55,7 @@ function onClick(ev: MouseEvent) { icon: 'ti ti-plus', action: () => { react(props.emoji); - sound.play('reaction'); + sound.playMisskeySfx('reaction'); }, }] : [])], ev.currentTarget ?? ev.target); } diff --git a/packages/frontend/src/pages/drop-and-fusion.game.vue b/packages/frontend/src/pages/drop-and-fusion.game.vue new file mode 100644 index 0000000000..acaebbadf7 --- /dev/null +++ b/packages/frontend/src/pages/drop-and-fusion.game.vue @@ -0,0 +1,1052 @@ + + + + + + + diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue index 0ddee55f5f..7bd0eef000 100644 --- a/packages/frontend/src/pages/drop-and-fusion.vue +++ b/packages/frontend/src/pages/drop-and-fusion.vue @@ -4,17 +4,23 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue index 57bafce0ac..798980b3d1 100644 --- a/packages/frontend/src/pages/settings/sounds.sound.vue +++ b/packages/frontend/src/pages/settings/sounds.sound.vue @@ -33,7 +33,7 @@ import MkRange from '@/components/MkRange.vue'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { playFile, soundsTypes, getSoundDuration } from '@/scripts/sound.js'; +import { playMisskeySfxFile, soundsTypes, getSoundDuration } from '@/scripts/sound.js'; import { selectFile } from '@/scripts/select-file.js'; const props = defineProps<{ @@ -119,7 +119,7 @@ function listen() { return; } - playFile(type.value === '_driveFile_' ? { + playMisskeySfxFile(type.value === '_driveFile_' ? { type: '_driveFile_', fileId: fileId.value as string, fileUrl: fileUrl.value as string, diff --git a/packages/frontend/src/scripts/drop-and-fusion-engine.ts b/packages/frontend/src/scripts/drop-and-fusion-engine.ts index b6e735ddf2..d64c6015a5 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,33 +21,53 @@ export type Mono = { spriteScale: number; }; -const PHYSICS_QUALITY_FACTOR = 16; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる、逆に高すぎても何故か不安定になる +type Log = { + frame: number; + operation: 'drop'; + x: number; +} | { + frame: number; + operation: 'hold'; +} | { + frame: number; + operation: 'surrender'; +}; +// TODO: インスタンスを作り直さなくてもゲームをリスタートできるようにする export class DropAndFusionGame extends EventEmitter<{ changeScore: (newScore: number) => void; changeCombo: (newCombo: number) => void; changeStock: (newStock: { id: string; mono: Mono }[]) => void; + changeHolding: (newHolding: { id: string; mono: Mono } | null) => void; dropped: () => void; fusioned: (x: number, y: number, scoreDelta: number) => void; monoAdded: (mono: Mono) => void; gameOver: () => void; }> { - private COMBO_INTERVAL = 1000; + private PHYSICS_QUALITY_FACTOR = 16; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる、逆に高すぎても何故か不安定になる + 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; /** * フィールドに出ていて、かつ合体の対象となるアイテム @@ -56,8 +77,9 @@ 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; private _combo = 0; private get combo() { @@ -79,22 +101,33 @@ 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; + } this.engine = Matter.Engine.create({ - constraintIterations: 2 * PHYSICS_QUALITY_FACTOR, - positionIterations: 6 * PHYSICS_QUALITY_FACTOR, - velocityIterations: 4 * PHYSICS_QUALITY_FACTOR, + constraintIterations: 2 * this.PHYSICS_QUALITY_FACTOR, + positionIterations: 6 * this.PHYSICS_QUALITY_FACTOR, + velocityIterations: 4 * this.PHYSICS_QUALITY_FACTOR, gravity: { x: 0, y: 1, @@ -121,13 +154,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, @@ -183,6 +214,7 @@ export class DropAndFusionGame extends EventEmitter<{ }; if (mono.shape === 'circle') { return Matter.Bodies.circle(x, y, mono.size / 2, options); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition } else if (mono.shape === 'rectangle') { return Matter.Bodies.rectangle(x, y, mono.size, mono.size, options); } else { @@ -191,13 +223,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; @@ -214,38 +245,62 @@ 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; - sound.playUrl('/client-assets/drop-and-fusion/bubble2.mp3', 1, pan, nextMono.sfxPitch); + // TODO: 効果音再生はコンポーネント側の責務なので移動するべき? + const panV = newX - this.PLAYAREA_MARGIN; + const panW = this.gameWidth - this.PLAYAREA_MARGIN - this.PLAYAREA_MARGIN; + const pan = ((panV / panW) - 0.5) * 2; + sound.playUrl('/client-assets/drop-and-fusion/bubble2.mp3', { + volume: this.sfxVolume, + pan, + playbackRate: nextMono.sfxPitch * this.replayPlaybackRate, + }); this.emit('monoAdded', nextMono); this.emit('fusioned', newX, newY, additionalScore); } 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); //} //sound.playUrl({ // type: 'syuilo/bubble2', - // volume: 1, + // volume: this.sfxVolume, //}); } } + 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, + }); } /** テクスチャをすべてキャッシュする */ @@ -253,7 +308,6 @@ export class DropAndFusionGame extends EventEmitter<{ async function loadSingleMonoTexture(mono: Mono, game: DropAndFusionGame) { // Matter-js内にキャッシュがある場合はスキップ if (game.render.textures[mono.img]) return; - console.log('loading', mono.img); let src = mono.img; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -279,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); @@ -314,29 +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: 効果音再生はコンポーネント側の責務なので移動する - const vol = (Math.min(maxCollisionEnergyForSound, energy - minCollisionEnergyForSound) / maxCollisionEnergyForSound) / 4; - const pan = ((((bodyA.position.x + bodyB.position.x) / 2) / this.gameWidth) - 0.5) * 2; + // TODO: 効果音再生はコンポーネント側の責務なので移動するべき? + const vol = ((Math.min(maxCollisionEnergyForSound, energy - minCollisionEnergyForSound) / maxCollisionEnergyForSound) / 4) * this.sfxVolume; + const panV = + pairs.bodyA.label === '_wall_' ? bodyB.position.x - this.PLAYAREA_MARGIN : + pairs.bodyB.label === '_wall_' ? bodyA.position.x - this.PLAYAREA_MARGIN : + ((bodyA.position.x + bodyB.position.x) / 2) - this.PLAYAREA_MARGIN; + const panW = this.gameWidth - this.PLAYAREA_MARGIN - this.PLAYAREA_MARGIN; + const pan = ((panV / panW) - 0.5) * 2; const pitch = soundPitchMin + ((soundPitchMax - soundPitchMin) * (1 - (Math.min(10, energy) / 10))); - sound.playUrl('/client-assets/drop-and-fusion/poi1.mp3', vol, pan, pitch); + sound.playUrl('/client-assets/drop-and-fusion/poi1.mp3', { + volume: vol, + pan, + 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() { @@ -344,6 +476,10 @@ export class DropAndFusionGame extends EventEmitter<{ this.loaded = true; } + public setSfxVolume(volume: number) { + this.sfxVolume = volume; + } + public getTextureImageUrl(mono: Mono) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (this.monoTextureUrls[mono.img]) { @@ -366,34 +502,76 @@ export class DropAndFusionGame extends EventEmitter<{ public drop(_x: number) { if (this.isGameOver) return; - if (Date.now() - this.latestDroppedAt < this.DROP_INTERVAL) { - return; - } - const st = this.stock.shift()!; + 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 - (st.mono.size / 2), Math.max(this.PLAYAREA_MARGIN + (st.mono.size / 2), _x)); - const body = this.createBody(st.mono, x, 50 + st.mono.size / 2); + 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; this.latestDroppedAt = Date.now(); this.emit('dropped'); - this.emit('monoAdded', st.mono); + this.emit('monoAdded', head.mono); - // TODO: 効果音再生はコンポーネント側の責務なので移動する - const pan = ((x / this.gameWidth) - 0.5) * 2; - sound.playUrl('/client-assets/drop-and-fusion/poi2.mp3', 1, pan); + // 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); + this.holding = head; + this.emit('changeHolding', this.holding); + this.emit('changeStock', this.stock); + } else { + const head = this.stock.shift()!; + this.holding = head; + this.stock.push({ + 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); + } + + sound.playUrl('/client-assets/drop-and-fusion/hold.mp3', { + volume: 0.5 * this.sfxVolume, + }); } public dispose() { if (this.comboIntervalId) window.clearInterval(this.comboIntervalId); + if (this.tickRaf) window.cancelAnimationFrame(this.tickRaf); + this.tickRaf = null; Matter.Render.stop(this.render); - Matter.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 690c342c85..05c8977ecf 100644 --- a/packages/frontend/src/scripts/sound.ts +++ b/packages/frontend/src/scripts/sound.ts @@ -99,7 +99,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; } } @@ -126,13 +125,12 @@ export async function loadAudio(url: string, options?: { useCache?: boolean; }) * 既定のスプライトを再生する * @param type スプライトの種類を指定 */ -export function play(operationType: OperationType) { +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; - playFile(sound).finally(() => { + playMisskeySfxFile(sound).finally(() => { // ごく短時間に音が重複しないように setTimeout(() => { canPlay = true; @@ -144,41 +142,53 @@ export function play(operationType: OperationType) { * サウンド設定形式で指定された音声を再生する * @param soundStore サウンド設定 */ -export async function playFile(soundStore: SoundStore) { +export async function playMisskeySfxFile(soundStore: SoundStore) { if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) { return; } + const masterVolume = defaultStore.state.sound_masterVolume; + if (isMute() || masterVolume === 0 || soundStore.volume === 0) { + return; + } const url = soundStore.type === '_driveFile_' ? soundStore.fileUrl : `/client-assets/sounds/${soundStore.type}.mp3`; const buffer = await loadAudio(url); if (!buffer) return; - createSourceNode(buffer, soundStore.volume)?.soundSource.start(); + const volume = soundStore.volume * masterVolume; + createSourceNode(buffer, { volume }).soundSource.start(); } -export async function playUrl(url: string, volume = 1, pan = 0, playbackRate = 1) { +export async function playUrl(url: string, opts: { + volume?: number; + pan?: number; + playbackRate?: number; +}) { + if (opts.volume === 0) { + return; + } const buffer = await loadAudio(url); if (!buffer) return; - createSourceNode(buffer, volume, pan, playbackRate)?.soundSource.start(); + createSourceNode(buffer, opts).soundSource.start(); } -export function createSourceNode(buffer: AudioBuffer, volume: number, pan = 0, playbackRate = 1): { +export function createSourceNode(buffer: AudioBuffer, opts: { + volume?: number; + pan?: number; + playbackRate?: number; +}): { soundSource: AudioBufferSourceNode; panNode: StereoPannerNode; gainNode: GainNode; -} | null { - const masterVolume = defaultStore.state.sound_masterVolume; - if (isMute() || masterVolume === 0 || volume === 0) { - return null; - } - +} { const panNode = ctx.createStereoPanner(); - panNode.pan.value = pan; + panNode.pan.value = opts.pan ?? 0; const gainNode = ctx.createGain(); - gainNode.gain.value = masterVolume * volume; + + gainNode.gain.value = opts.volume ?? 1; const soundSource = ctx.createBufferSource(); soundSource.buffer = buffer; - soundSource.playbackRate.value = playbackRate; + soundSource.playbackRate.value = opts.playbackRate ?? 1; soundSource .connect(panNode) .connect(gainNode) diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 46634af96b..e3a85377d8 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -420,6 +420,13 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, + dropAndFusion: { + where: 'device', + default: { + bgmVolume: 0.25, + sfxVolume: 1, + }, + }, sound_masterVolume: { where: 'device', diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue index 78af49cdc2..0ec036c5cb 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -83,7 +83,7 @@ function onNotification(notification: Misskey.entities.Notification, isClient = }, 6000); } - sound.play('notification'); + sound.playMisskeySfx('notification'); } if ($i) { diff --git a/packages/frontend/src/widgets/WidgetJobQueue.vue b/packages/frontend/src/widgets/WidgetJobQueue.vue index 89ad3bf323..877406fe95 100644 --- a/packages/frontend/src/widgets/WidgetJobQueue.vue +++ b/packages/frontend/src/widgets/WidgetJobQueue.vue @@ -123,7 +123,7 @@ const onStats = (stats) => { current[domain].delayed = stats[domain].delayed; if (current[domain].waiting > 0 && widgetProps.sound && jammedAudioBuffer.value && !jammedSoundNodePlaying.value) { - const soundNode = sound.createSourceNode(jammedAudioBuffer.value, 1)?.soundSource; + const soundNode = sound.createSourceNode(jammedAudioBuffer.value, {}).soundSource; if (soundNode) { jammedSoundNodePlaying.value = true; soundNode.onended = () => jammedSoundNodePlaying.value = false; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0f74de843..400051bce7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -686,8 +686,8 @@ importers: specifier: 5.1.0 version: 5.1.0(rollup@4.9.1) '@syuilo/aiscript': - specifier: 0.16.0 - version: 0.16.0 + specifier: 0.17.0 + version: 0.17.0 '@tabler/icons-webfont': specifier: 2.44.0 version: 2.44.0 @@ -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 @@ -7646,8 +7649,8 @@ packages: dev: false optional: true - /@syuilo/aiscript@0.16.0: - resolution: {integrity: sha512-CXvoWOq6kmOSUQtKv0IEf7Ebfkk5PO1LxAgLqgRRPgssPvDvINCXu/gFNXKdapkFMkmX+Gj8qjemKR1vnUS4ZA==} + /@syuilo/aiscript@0.17.0: + resolution: {integrity: sha512-3JtQ1rWJHMxQ3153zLCXMUOwrOgjPPYGBl0dPHhR0ohm4tn7okMQRugxMCT0t3YxByemb9FfiM6TUjd0tEGxdA==} dependencies: seedrandom: 3.0.5 stringz: 2.1.0