diff --git a/CHANGELOG.md b/CHANGELOG.md index 2270bb1c13..ad7d06b977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,11 @@ - Feat: 新しいゲームを追加 - Enhance: ハッシュタグ入力時に、本文の末尾の行に何も書かれていない場合は新たにスペースを追加しないように - Enhance: チャンネルノートのピン留めをノートのメニューからできるように +- Enhance: 管理者の場合はAPI tokenの発行画面で管理機能に関する権限を付与できるように - Fix: ネイティブモードの絵文字がモノクロにならないように - Fix: v2023.12.0で追加された「モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能」が管理画面上で正しく表示されていない問題を修正 - Fix: AiScriptの`readline`関数が不正な値を返すことがある問題のv2023.12.0時点での修正がPlay以外に適用されていないのを修正 +- Fix: v2023.12.1で追加された`$[clickable ...]`および`onClickEv`が正しく機能していないのを修正 ### Server - Enhance: 連合先のレートリミットに引っかかった際にリトライするようになりました diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 83b254b2d5..fd25ba0289 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -973,6 +973,7 @@ neverShow: "Nicht wieder anzeigen" remindMeLater: "Vielleicht später" didYouLikeMisskey: "Gefällt dir Sharkey?" pleaseDonate: "Sharkey ist die kostenlose Software, die von {host} verwendet wird. Wir würden uns über Spenden freuen, damit dessen Entwicklung weitergeführt werden kann!" +pleaseDonateInstance: "Du kannst {host} auch direkt unterstützen, indem du an deine Instanz Administration spendest." roles: "Rollen" role: "Rolle" noRole: "Rolle nicht gefunden" @@ -1150,6 +1151,8 @@ impressumDescription: "In manchen Ländern, wie Deutschland und dessen Umgebung, privacyPolicy: "Datenschutzerklärung" privacyPolicyUrl: "Datenschutzerklärungs-URL" tosAndPrivacyPolicy: "Nutzungsbedingungen und Datenschutzerklärung" +donation: "Spenden" +donationUrl: "Spenden-URL" avatarDecorations: "Profilbilddekoration" attach: "Anbringen" detach: "Entfernen" diff --git a/locales/en-US.yml b/locales/en-US.yml index 0affe133ed..7a28ce1095 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1014,6 +1014,7 @@ neverShow: "Don't show again" remindMeLater: "Maybe later" didYouLikeMisskey: "Have you taken a liking to Sharkey?" pleaseDonate: "{host} uses the free software, Sharkey. We would highly appreciate your donations so development of Sharkey can continue!" +pleaseDonateInstance: "You can also support {host} directly by donating to your instance administration." roles: "Roles" role: "Role" noRole: "Role not found" @@ -1200,6 +1201,8 @@ impressumDescription: "In some countries, like germany, the inclusion of operato privacyPolicy: "Privacy Policy" privacyPolicyUrl: "Privacy Policy URL" tosAndPrivacyPolicy: "Terms of Service and Privacy Policy" +donation: "Donate" +donationUrl: "Donation URL" avatarDecorations: "Avatar decorations" attach: "Attach" detach: "Remove" diff --git a/locales/index.d.ts b/locales/index.d.ts index 1ec9ed64e0..fd7876b947 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -648,6 +648,7 @@ export interface Locale { "small": string; "generateAccessToken": string; "permission": string; + "adminPermission": string; "enableAll": string; "disableAll": string; "tokenRequested": string; @@ -1023,6 +1024,7 @@ export interface Locale { "remindMeLater": string; "didYouLikeMisskey": string; "pleaseDonate": string; + "pleaseDonateInstance": string; "roles": string; "role": string; "noRole": string; @@ -1209,6 +1211,8 @@ export interface Locale { "privacyPolicy": string; "privacyPolicyUrl": string; "tosAndPrivacyPolicy": string; + "donation": string; + "donationUrl": string; "avatarDecorations": string; "attach": string; "detach": string; @@ -1235,6 +1239,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 4a4feed061..d337adb0ca 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -645,6 +645,7 @@ medium: "中" small: "小" generateAccessToken: "アクセストークンの発行" permission: "権限" +adminPermission: "管理者権限" enableAll: "全て有効にする" disableAll: "全て無効にする" tokenRequested: "アカウントへのアクセス許可" @@ -1020,6 +1021,7 @@ neverShow: "今後表示しない" remindMeLater: "また後で" didYouLikeMisskey: "Sharkeyを気に入っていただけましたか?" pleaseDonate: "Sharkeyは{host}が使用している無料のソフトウェアです。これからも開発を続けられるように、ぜひ寄付をお願いします!" +pleaseDonateInstance: "インスタンス管理への寄付によって{host}を直接サポートすることもできます。" roles: "ロール" role: "ロール" noRole: "ロールはありません" @@ -1206,6 +1208,8 @@ impressumDescription: "ドイツなどの一部の国と地域では表示が義 privacyPolicy: "プライバシーポリシー" privacyPolicyUrl: "プライバシーポリシーURL" tosAndPrivacyPolicy: "利用規約・プライバシーポリシー" +donation: "寄付する" +donationUrl: "寄付URL" avatarDecorations: "アイコンデコレーション" attach: "付ける" detach: "外す" @@ -1232,6 +1236,9 @@ enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する" bubbleGame: "バブルゲーム" sfx: "効果音" soundWillBePlayed: "サウンドが再生されます" +showReplay: "リプレイを見る" +replay: "リプレイ" +replaying: "リプレイ中" _announcement: forExistingUsers: "既存ユーザーのみ" @@ -2541,7 +2548,7 @@ _dataRequest: warn: "データのリクエストは3日ごとにしかできない。" text: "データのダウンロードが完了すると、このアカウントに登録されているEメールアドレスにEメールが送信されます。" button: "リクエスト" - + _dataSaver: _media: title: "メディアの読み込み" diff --git a/packages/backend/migration/1704744370000-add-donation-url.js b/packages/backend/migration/1704744370000-add-donation-url.js new file mode 100644 index 0000000000..c953b13cc9 --- /dev/null +++ b/packages/backend/migration/1704744370000-add-donation-url.js @@ -0,0 +1,10 @@ +export class AddDonationUrl1704744370000 { + name = 'AddDonationUrl1704744370000' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "donationUrl" character varying(1024)`); + } + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "donationUrl"`); + } +} diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index e7f7458c19..6d5c4b3746 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -385,6 +385,12 @@ export class MiMeta { }) public privacyPolicyUrl: string | null; + @Column('varchar', { + length: 1024, + nullable: true, + }) + public donationUrl: string | null; + @Column('varchar', { length: 8192, nullable: true, diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index 31479269b9..a3e3c39ecc 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -108,6 +108,7 @@ export class NodeinfoServerService { tosUrl: meta.termsOfServiceUrl, privacyPolicyUrl: meta.privacyPolicyUrl, impressumUrl: meta.impressumUrl, + donationUrl: meta.donationUrl, repositoryUrl: meta.repositoryUrl, feedbackUrl: meta.feedbackUrl, disableRegistration: meta.disableRegistration, diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 66b6799ed1..9fe997f889 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -415,6 +415,10 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + donationUrl: { + type: 'string', + optional: false, nullable: true, + }, maintainerEmail: { type: 'string', optional: false, nullable: true, @@ -498,6 +502,7 @@ export default class extends Endpoint { // eslint- repositoryUrl: instance.repositoryUrl, feedbackUrl: instance.feedbackUrl, impressumUrl: instance.impressumUrl, + donationUrl: instance.donationUrl, privacyPolicyUrl: instance.privacyPolicyUrl, disableRegistration: instance.disableRegistration, emailRequiredForSignup: instance.emailRequiredForSignup, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 05d2cd61ca..786a628d60 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -105,6 +105,7 @@ export const paramDef = { repositoryUrl: { type: 'string' }, feedbackUrl: { type: 'string' }, impressumUrl: { type: 'string', nullable: true }, + donationUrl: { type: 'string', nullable: true }, privacyPolicyUrl: { type: 'string', nullable: true }, useObjectStorage: { type: 'boolean' }, objectStorageBaseUrl: { type: 'string', nullable: true }, @@ -406,6 +407,10 @@ export default class extends Endpoint { // eslint- set.impressumUrl = ps.impressumUrl; } + if (ps.donationUrl !== undefined) { + set.donationUrl = ps.donationUrl; + } + if (ps.privacyPolicyUrl !== undefined) { set.privacyPolicyUrl = ps.privacyPolicyUrl; } diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index 9d2ae8369c..af779aa850 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -291,6 +291,10 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + donationUrl: { + type: 'string', + optional: false, nullable: true, + }, logoImageUrl: { type: 'string', optional: false, nullable: true, @@ -365,6 +369,7 @@ export default class extends Endpoint { // eslint- repositoryUrl: instance.repositoryUrl, feedbackUrl: instance.feedbackUrl, impressumUrl: instance.impressumUrl, + donationUrl: instance.donationUrl, privacyPolicyUrl: instance.privacyPolicyUrl, disableRegistration: instance.disableRegistration, emailRequiredForSignup: instance.emailRequiredForSignup, 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 86add62fca..4b66703554 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -59,6 +59,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/components/MkDonation.vue b/packages/frontend/src/components/MkDonation.vue index a77ff42f94..92e4f14bbe 100644 --- a/packages/frontend/src/components/MkDonation.vue +++ b/packages/frontend/src/components/MkDonation.vue @@ -26,6 +26,16 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.learnMore }} +
+ + + +
+ {{ i18n.ts.learnMore }} +
+
{{ i18n.ts.remindMeLater }} {{ i18n.ts.neverShow }} 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/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index 9f4fc00938..8f15fa93a6 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -123,7 +123,13 @@ function showMenu(ev) { action: () => { window.open(instance.privacyPolicyUrl, '_blank', 'noopener'); }, - } : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : { type: 'divider' }, { + } : undefined, (instance.donationUrl) ? { + text: i18n.ts.donation, + icon: 'ph-hand-coins ph-bold ph-lg', + action: () => { + window.open(instance.donationUrl, '_blank', 'noopener'); + }, + } : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl && !instance.donationUrl) ? undefined : { type: 'divider' }, { text: i18n.ts.help, icon: 'ph-question ph-bold ph-lg', action: () => { diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue index 8209515065..024f2dc746 100644 --- a/packages/frontend/src/pages/about.vue +++ b/packages/frontend/src/pages/about.vue @@ -57,6 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.termsOfService }} {{ i18n.ts.privacyPolicy }} + {{ i18n.ts.donation }} diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index d08bfac74a..f11d37ab23 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -40,6 +40,11 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + @@ -170,6 +175,7 @@ const description = ref(null); const maintainerName = ref(null); const maintainerEmail = ref(null); const impressumUrl = ref(null); +const donationUrl = ref(null); const pinnedUsers = ref(''); const cacheRemoteFiles = ref(false); const cacheRemoteSensitiveFiles = ref(false); @@ -192,6 +198,7 @@ async function init(): Promise { maintainerName.value = meta.maintainerName; maintainerEmail.value = meta.maintainerEmail; impressumUrl.value = meta.impressumUrl; + donationUrl.value = meta.donationUrl; pinnedUsers.value = meta.pinnedUsers.join('\n'); cacheRemoteFiles.value = meta.cacheRemoteFiles; cacheRemoteSensitiveFiles.value = meta.cacheRemoteSensitiveFiles; @@ -215,6 +222,7 @@ async function save(): void { maintainerName: maintainerName.value, maintainerEmail: maintainerEmail.value, impressumUrl: impressumUrl.value, + donationUrl: donationUrl.value, pinnedUsers: pinnedUsers.value.split('\n'), cacheRemoteFiles: cacheRemoteFiles.value, cacheRemoteSensitiveFiles: cacheRemoteSensitiveFiles.value, diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue index b8d3d8bf04..974daf35e4 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 f71f3a668e..16fe87d97a 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,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; @@ -416,6 +526,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 +541,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 +555,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/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts index e7f24228b6..6e2f8b7e46 100644 --- a/packages/frontend/src/ui/_common_/common.ts +++ b/packages/frontend/src/ui/_common_/common.ts @@ -102,7 +102,13 @@ export function openInstanceMenu(ev: MouseEvent) { action: () => { window.open(instance.privacyPolicyUrl, '_blank', 'noopener'); }, - } : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : { type: 'divider' }, { + } : undefined, (instance.donationUrl) ? { + text: i18n.ts.donation, + icon: 'ph-hand-coins ph-bold ph-lg', + action: () => { + window.open(instance.donationUrl, '_blank', 'noopener'); + }, + } : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl && !instance.donationUrl) ? undefined : { type: 'divider' }, { text: i18n.ts.help, icon: 'ph-question ph-bold ph-lg', action: () => { diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 0e068e5267..f881c5db62 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4631,6 +4631,7 @@ export type operations = { description: string | null; disableRegistration: boolean; impressumUrl: string | null; + donationUrl: string | null; maintainerEmail: string | null; maintainerName: string | null; name: string | null; @@ -8650,6 +8651,7 @@ export type operations = { repositoryUrl?: string; feedbackUrl?: string; impressumUrl?: string | null; + donationUrl?: string | null; privacyPolicyUrl?: string | null; useObjectStorage?: boolean; objectStorageBaseUrl?: string | null; @@ -19400,6 +19402,7 @@ export type operations = { }; backgroundImageUrl: string | null; impressumUrl: string | null; + donationUrl: string | null; logoImageUrl: string | null; privacyPolicyUrl: string | null; serverRules: string[]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6445d3818..513322de67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -795,6 +795,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