diff --git a/CHANGELOG.md b/CHANGELOG.md index 2270bb1c1..ad7d06b97 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/index.d.ts b/locales/index.d.ts index 28b3a9444..fd7876b94 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; @@ -1238,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 e6f9dc6b9..d337adb0c 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -645,6 +645,7 @@ medium: "中" small: "小" generateAccessToken: "アクセストークンの発行" permission: "権限" +adminPermission: "管理者権限" enableAll: "全て有効にする" disableAll: "全て無効にする" tokenRequested: "アカウントへのアクセス許可" @@ -1235,6 +1236,9 @@ enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する" bubbleGame: "バブルゲーム" sfx: "効果音" soundWillBePlayed: "サウンドが再生されます" +showReplay: "リプレイを見る" +replay: "リプレイ" +replaying: "リプレイ中" _announcement: forExistingUsers: "既存ユーザーのみ" diff --git a/packages/frontend/assets/drop-and-fusion/gameover.mp3 b/packages/frontend/assets/drop-and-fusion/gameover.mp3 new file mode 100644 index 000000000..23b41c569 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/gameover.mp3 differ diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 86add62fc..4b6670355 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/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue index d024e1e59..a42767e1b 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 b8d3d8bf0..974daf35e 100644 --- a/packages/frontend/src/pages/drop-and-fusion.vue +++ b/packages/frontend/src/pages/drop-and-fusion.vue @@ -61,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only -
+
@@ -74,7 +74,7 @@ SPDX-License-Identifier: AGPL-3.0-only >
{{ comboPrev }} Chain!
-
+
-
+
SCORE:
MAX CHAIN:
-
- Restart - Share -
+
+
+
{{ i18n.ts.replaying }}
+
+
+
+
+ END REPLAY +
+
+
+
+
+
+ {{ i18n.ts.done }} + {{ i18n.ts.showReplay }} + {{ i18n.ts.share }} + Copy replay data
@@ -139,7 +153,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- Restart + Retry
@@ -168,6 +182,7 @@ import { DropAndFusionGame, Mono } from '@/scripts/drop-and-fusion-engine.js'; import * as sound from '@/scripts/sound.js'; import MkRange from '@/components/MkRange.vue'; import MkSwitch from '@/components/MkSwitch.vue'; +import copyToClipboard from '@/scripts/copy-to-clipboard.js'; const NORMAL_BASE_SIZE = 30; const NORAML_MONOS: Mono[] = [{ @@ -401,6 +416,8 @@ const GAME_HEIGHT = 600; let viewScale = 1; let game: DropAndFusionGame; let containerElRect: DOMRect | null = null; +let seed: string; +let logs: ReturnType | null = null; const containerEl = shallowRef(); const canvasEl = shallowRef(); @@ -414,22 +431,25 @@ const comboPrev = ref(0); const maxCombo = ref(0); const dropReady = ref(true); const gameMode = ref<'normal' | 'square'>('normal'); -const gameOver = ref(false); +const isGameOver = ref(false); const gameStarted = ref(false); const highScore = ref(null); const showConfig = ref(false); +const replaying = ref(false); const mute = ref(false); const bgmVolume = ref(defaultStore.state.dropAndFusion.bgmVolume); const sfxVolume = ref(defaultStore.state.dropAndFusion.sfxVolume); function onClick(ev: MouseEvent) { if (!containerElRect) return; + if (replaying.value) return; const x = (ev.clientX - containerElRect.left) / viewScale; game.drop(x); } function onTouchend(ev: TouchEvent) { if (!containerElRect) return; + if (replaying.value) return; const x = (ev.changedTouches[0].clientX - containerElRect.left) / viewScale; game.drop(x); } @@ -454,9 +474,18 @@ function hold() { game.hold(); } -function restart() { +async function surrender() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.areYouSure, + }); + if (canceled) return; + game.surrender(); +} + +function end() { game.dispose(); - gameOver.value = false; + isGameOver.value = false; currentPick.value = null; dropReady.value = true; stock.value = []; @@ -467,6 +496,45 @@ function restart() { gameStarted.value = false; } +function replay() { + replaying.value = true; + game.dispose(); + game = new DropAndFusionGame({ + width: GAME_WIDTH, + height: GAME_HEIGHT, + canvas: canvasEl.value!, + seed: seed, + sfxVolume: mute.value ? 0 : sfxVolume.value, + ...( + gameMode.value === 'normal' ? { + monoDefinitions: NORAML_MONOS, + } : { + monoDefinitions: SQUARE_MONOS, + } + ), + }); + attachGameEvents(); + os.promiseDialog(game.load(), async () => { + game.start(logs!); + }); +} + +function endReplay() { + replaying.value = false; + game.dispose(); +} + +function exportLog() { + if (!logs) return; + const data = JSON.stringify({ + seed: seed, + date: new Date().toISOString(), + logs: logs, + }); + copyToClipboard(data); + os.success(); +} + function attachGameEvents() { game.addListener('changeScore', value => { score.value = value; @@ -492,9 +560,11 @@ function attachGameEvents() { }); game.addListener('dropped', () => { + if (replaying.value) return; + dropReady.value = false; window.setTimeout(() => { - if (!gameOver.value) { + if (!isGameOver.value) { dropReady.value = true; } }, game.DROP_INTERVAL); @@ -511,6 +581,8 @@ function attachGameEvents() { }); game.addListener('monoAdded', (mono) => { + if (replaying.value) return; + // 実績関連 if (mono.level === 10) { claimAchievement('bubbleGameExplodingHead'); @@ -523,9 +595,15 @@ function attachGameEvents() { }); game.addListener('gameOver', () => { + if (replaying.value) { + endReplay(); + return; + } + + logs = game.getLogs(); currentPick.value = null; dropReady.value = false; - gameOver.value = true; + isGameOver.value = true; if (score.value > (highScore.value ?? 0)) { highScore.value = score.value; @@ -551,10 +629,13 @@ async function start() { highScore.value = null; } + seed = Date.now().toString(); + game = new DropAndFusionGame({ width: GAME_WIDTH, height: GAME_HEIGHT, canvas: canvasEl.value!, + seed: seed, sfxVolume: mute.value ? 0 : sfxVolume.value, ...( gameMode.value === 'normal' ? { @@ -690,7 +771,7 @@ useInterval(() => { }, 1000, { immediate: false, afterMounted: true }); onDeactivated(() => { - restart(); + end(); }); definePageMetadata({ @@ -922,6 +1003,28 @@ definePageMetadata({ } } +.replayIndicator { + position: absolute; + z-index: 10; + left: 10px; + bottom: 10px; + padding: 6px 8px; + color: #f00; + background: #0008; + border-radius: 6px; + pointer-events: none; +} + +.replayIndicatorText { + animation: replayIndicator-blink 2s infinite; +} + +@keyframes replayIndicator-blink { + 0% { opacity: 1; } + 50% { opacity: 0; } + 100% { opacity: 1; } +} + @keyframes currentMonoArrow { 0% { transform: translateY(0); } 25% { transform: translateY(-8px); } diff --git a/packages/frontend/src/scripts/drop-and-fusion-engine.ts b/packages/frontend/src/scripts/drop-and-fusion-engine.ts index f71f3a668..16fe87d97 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/pnpm-lock.yaml b/pnpm-lock.yaml index f6445d381..513322de6 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