diff --git a/locales/index.d.ts b/locales/index.d.ts index 75517fa2a..8dfb81790 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1192,6 +1192,7 @@ export interface Locale { "decorate": string; "addMfmFunction": string; "enableQuickAddMfmFunction": string; + "bubbleGame": string; "_announcement": { "forExistingUsers": string; "forExistingUsersDescription": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 8b6b119d7..d92c5f9a1 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1189,6 +1189,7 @@ seasonalScreenEffect: "季節に応じた画面の演出" decorate: "デコる" addMfmFunction: "装飾を追加" enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する" +bubbleGame: "バブルゲーム" _announcement: forExistingUsers: "既存ユーザーのみ" diff --git a/packages/frontend/assets/drop-and-fusion/keycap_1.png b/packages/frontend/assets/drop-and-fusion/keycap_1.png new file mode 100644 index 000000000..d672f2854 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/keycap_1.png differ diff --git a/packages/frontend/assets/drop-and-fusion/keycap_10.png b/packages/frontend/assets/drop-and-fusion/keycap_10.png new file mode 100644 index 000000000..32cf19354 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/keycap_10.png differ diff --git a/packages/frontend/assets/drop-and-fusion/keycap_2.png b/packages/frontend/assets/drop-and-fusion/keycap_2.png new file mode 100644 index 000000000..81c3f58e6 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/keycap_2.png differ diff --git a/packages/frontend/assets/drop-and-fusion/keycap_3.png b/packages/frontend/assets/drop-and-fusion/keycap_3.png new file mode 100644 index 000000000..424d8c123 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/keycap_3.png differ diff --git a/packages/frontend/assets/drop-and-fusion/keycap_4.png b/packages/frontend/assets/drop-and-fusion/keycap_4.png new file mode 100644 index 000000000..ea6ae5053 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/keycap_4.png differ diff --git a/packages/frontend/assets/drop-and-fusion/keycap_5.png b/packages/frontend/assets/drop-and-fusion/keycap_5.png new file mode 100644 index 000000000..ad435da69 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/keycap_5.png differ diff --git a/packages/frontend/assets/drop-and-fusion/keycap_6.png b/packages/frontend/assets/drop-and-fusion/keycap_6.png new file mode 100644 index 000000000..70c9522b4 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/keycap_6.png differ diff --git a/packages/frontend/assets/drop-and-fusion/keycap_7.png b/packages/frontend/assets/drop-and-fusion/keycap_7.png new file mode 100644 index 000000000..5a2430748 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/keycap_7.png differ diff --git a/packages/frontend/assets/drop-and-fusion/keycap_8.png b/packages/frontend/assets/drop-and-fusion/keycap_8.png new file mode 100644 index 000000000..9689d8ecf Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/keycap_8.png differ diff --git a/packages/frontend/assets/drop-and-fusion/keycap_9.png b/packages/frontend/assets/drop-and-fusion/keycap_9.png new file mode 100644 index 000000000..ac3f63884 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/keycap_9.png differ diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue index 601493156..7f41be4c5 100644 --- a/packages/frontend/src/pages/drop-and-fusion.vue +++ b/packages/frontend/src/pages/drop-and-fusion.vue @@ -7,12 +7,22 @@ SPDX-License-Identifier: AGPL-3.0-only -
+
+
+
{{ i18n.ts.bubbleGame }}
+ + + + + {{ i18n.ts.start }} +
+
+
-
SCORE:
-
HIGH SCORE: -
+ BUBBLE GAME +
- {{ gameMode }} -
@@ -33,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
@@ -67,7 +77,23 @@ SPDX-License-Identifier: AGPL-3.0-only
- Restart +
+
+
+
SCORE:
+
HIGH SCORE: -
+
+
+
+
+
+
+
+
+
+ Restart +
+
@@ -86,18 +112,35 @@ import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue'; import MkButton from '@/components/MkButton.vue'; import { defaultStore } from '@/store.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import { useInterval } from '@/scripts/use-interval.js'; +import MkSelect from '@/components/MkSelect.vue'; + +type Mono = { + id: string; + level: number; + size: number; + shape: 'circle' | 'rectangle'; + score: number; + dropCandidate: boolean; + sfxPitch: number; + img: string; + imgSize: number; + spriteScale: number; +}; const containerEl = shallowRef(); const canvasEl = shallowRef(); const mouseX = ref(0); -const BASE_SIZE = 30; -const FRUITS = [{ +const NORMAL_BASE_SIZE = 30; +const NORAML_MONOS: Mono[] = [{ id: '9377076d-c980-4d83-bdaf-175bc58275b7', level: 10, - size: BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25, + size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25, + shape: 'circle', score: 512, - available: false, + dropCandidate: false, sfxPitch: 0.25, img: '/client-assets/drop-and-fusion/exploding_head.png', imgSize: 256, @@ -105,9 +148,10 @@ const FRUITS = [{ }, { id: 'be9f38d2-b267-4b1a-b420-904e22e80568', level: 9, - size: BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25, + size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25, + shape: 'circle', score: 256, - available: false, + dropCandidate: false, sfxPitch: 0.5, img: '/client-assets/drop-and-fusion/face_with_symbols_on_mouth.png', imgSize: 256, @@ -115,9 +159,10 @@ const FRUITS = [{ }, { id: 'beb30459-b064-4888-926b-f572e4e72e0c', level: 8, - size: BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25, + size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25, + shape: 'circle', score: 128, - available: false, + dropCandidate: false, sfxPitch: 0.75, img: '/client-assets/drop-and-fusion/cold_face.png', imgSize: 256, @@ -125,9 +170,10 @@ const FRUITS = [{ }, { id: 'feab6426-d9d8-49ae-849c-048cdbb6cdf0', level: 7, - size: BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25, + size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25, + shape: 'circle', score: 64, - available: false, + dropCandidate: false, sfxPitch: 1, img: '/client-assets/drop-and-fusion/zany_face.png', imgSize: 256, @@ -135,9 +181,10 @@ const FRUITS = [{ }, { id: 'd6d8fed6-6d18-4726-81a1-6cf2c974df8a', level: 6, - size: BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25, + size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25, + shape: 'circle', score: 32, - available: false, + dropCandidate: false, sfxPitch: 1.5, img: '/client-assets/drop-and-fusion/pleading_face.png', imgSize: 256, @@ -145,9 +192,10 @@ const FRUITS = [{ }, { id: '249c728e-230f-4332-bbbf-281c271c75b2', level: 5, - size: BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25, + size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25, + shape: 'circle', score: 16, - available: true, + dropCandidate: true, sfxPitch: 2, img: '/client-assets/drop-and-fusion/face_with_open_mouth.png', imgSize: 256, @@ -155,9 +203,10 @@ const FRUITS = [{ }, { id: '23d67613-d484-4a93-b71e-3e81b19d6186', level: 4, - size: BASE_SIZE * 1.25 * 1.25 * 1.25, + size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25, + shape: 'circle', score: 8, - available: true, + dropCandidate: true, sfxPitch: 2.5, img: '/client-assets/drop-and-fusion/smiling_face_with_sunglasses.png', imgSize: 256, @@ -165,9 +214,10 @@ const FRUITS = [{ }, { id: '3cbd0add-ad7d-4685-bad0-29f6dddc0b99', level: 3, - size: BASE_SIZE * 1.25 * 1.25, + size: NORMAL_BASE_SIZE * 1.25 * 1.25, + shape: 'circle', score: 4, - available: true, + dropCandidate: true, sfxPitch: 3, img: '/client-assets/drop-and-fusion/grinning_squinting_face.png', imgSize: 256, @@ -175,9 +225,10 @@ const FRUITS = [{ }, { id: '8f86d4f4-ee02-41bf-ad38-1ce0ae457fb5', level: 2, - size: BASE_SIZE * 1.25, + size: NORMAL_BASE_SIZE * 1.25, + shape: 'circle', score: 2, - available: true, + dropCandidate: true, sfxPitch: 3.5, img: '/client-assets/drop-and-fusion/smiling_face_with_hearts.png', imgSize: 256, @@ -185,14 +236,128 @@ const FRUITS = [{ }, { id: '64ec4add-ce39-42b4-96cb-33908f3f118d', level: 1, - size: BASE_SIZE, + size: NORMAL_BASE_SIZE, + shape: 'circle', score: 1, - available: true, + dropCandidate: true, sfxPitch: 4, img: '/client-assets/drop-and-fusion/heart_suit.png', imgSize: 256, spriteScale: 1.12, -}] as const; +}]; + +const SQUARE_BASE_SIZE = 28; +const SQUARE_MONOS: Mono[] = [{ + id: 'f75fd0ba-d3d4-40a4-9712-b470e45b0525', + level: 10, + size: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25, + shape: 'rectangle', + score: 512, + dropCandidate: false, + sfxPitch: 0.25, + img: '/client-assets/drop-and-fusion/keycap_10.png', + imgSize: 256, + spriteScale: 1.12, +}, { + id: '7b70f4af-1c01-45fd-af72-61b1f01e03d1', + level: 9, + size: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25, + shape: 'rectangle', + score: 256, + dropCandidate: false, + sfxPitch: 0.5, + img: '/client-assets/drop-and-fusion/keycap_9.png', + imgSize: 256, + spriteScale: 1.12, +}, { + id: '41607ef3-b6d6-4829-95b6-3737bf8bb956', + level: 8, + size: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25, + shape: 'rectangle', + score: 128, + dropCandidate: false, + sfxPitch: 0.75, + img: '/client-assets/drop-and-fusion/keycap_8.png', + imgSize: 256, + spriteScale: 1.12, +}, { + id: '8a8310d2-0374-460f-bb50-ca9cd3ee3416', + level: 7, + size: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25, + shape: 'rectangle', + score: 64, + dropCandidate: false, + sfxPitch: 1, + img: '/client-assets/drop-and-fusion/keycap_7.png', + imgSize: 256, + spriteScale: 1.12, +}, { + id: '1092e069-fe1a-450b-be97-b5d477ec398c', + level: 6, + size: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25, + shape: 'rectangle', + score: 32, + dropCandidate: false, + sfxPitch: 1.5, + img: '/client-assets/drop-and-fusion/keycap_6.png', + imgSize: 256, + spriteScale: 1.12, +}, { + id: '2294734d-7bb8-4781-bb7b-ef3820abf3d0', + level: 5, + size: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25, + shape: 'rectangle', + score: 16, + dropCandidate: true, + sfxPitch: 2, + img: '/client-assets/drop-and-fusion/keycap_5.png', + imgSize: 256, + spriteScale: 1.12, +}, { + id: 'ea8a61af-e350-45f7-ba6a-366fcd65692a', + level: 4, + size: SQUARE_BASE_SIZE * 1.25 * 1.25 * 1.25, + shape: 'rectangle', + score: 8, + dropCandidate: true, + sfxPitch: 2.5, + img: '/client-assets/drop-and-fusion/keycap_4.png', + imgSize: 256, + spriteScale: 1.12, +}, { + id: 'd0c74815-fc1c-4fbe-9953-c92e4b20f919', + level: 3, + size: SQUARE_BASE_SIZE * 1.25 * 1.25, + shape: 'rectangle', + score: 4, + dropCandidate: true, + sfxPitch: 3, + img: '/client-assets/drop-and-fusion/keycap_3.png', + imgSize: 256, + spriteScale: 1.12, +}, { + id: 'd8fbd70e-611d-402d-87da-1a7fd8cd2c8d', + level: 2, + size: SQUARE_BASE_SIZE * 1.25, + shape: 'rectangle', + score: 2, + dropCandidate: true, + sfxPitch: 3.5, + img: '/client-assets/drop-and-fusion/keycap_2.png', + imgSize: 256, + spriteScale: 1.12, +}, { + id: '35e476ee-44bd-4711-ad42-87be245d3efd', + level: 1, + size: SQUARE_BASE_SIZE, + shape: 'rectangle', + score: 1, + dropCandidate: true, + sfxPitch: 4, + img: '/client-assets/drop-and-fusion/keycap_1.png', + imgSize: 256, + spriteScale: 1.12, +}]; const GAME_WIDTH = 450; const GAME_HEIGHT = 600; @@ -200,12 +365,13 @@ const PHYSICS_QUALITY_FACTOR = 16; // 低いほどパフォーマンスが高い let viewScaleX = 1; let viewScaleY = 1; -const currentPick = shallowRef<{ id: string; fruit: typeof FRUITS[number] } | null>(null); -const stock = shallowRef<{ id: string; fruit: typeof FRUITS[number] }[]>([]); +const currentPick = shallowRef<{ id: string; fruit: Mono } | null>(null); +const stock = shallowRef<{ id: string; fruit: Mono }[]>([]); const score = ref(0); const combo = ref(0); const comboPrev = ref(0); const dropReady = ref(true); +const gameMode = ref<'normal' | 'square'>('normal'); const gameOver = ref(false); const gameStarted = ref(false); const highScore = ref(null); @@ -213,7 +379,7 @@ const highScore = ref(null); class Game extends EventEmitter<{ changeScore: (score: number) => void; changeCombo: (combo: number) => void; - changeStock: (stock: { id: string; fruit: typeof FRUITS[number] }[]) => void; + changeStock: (stock: { id: string; fruit: Mono }[]) => void; dropped: () => void; fusioned: (x: number, y: number, score: number) => void; gameOver: () => void; @@ -228,6 +394,8 @@ class Game extends EventEmitter<{ private overflowCollider: Matter.Body; private isGameOver = false; + private monoDefinitions: Mono[] = []; + /** * フィールドに出ていて、かつ合体の対象となるアイテム */ @@ -237,7 +405,7 @@ class Game extends EventEmitter<{ private latestDroppedAt = 0; private latestFusionedAt = 0; - private stock: { id: string; fruit: typeof FRUITS[number] }[] = []; + private stock: { id: string; fruit: Mono }[] = []; private _combo = 0; private get combo() { @@ -259,9 +427,13 @@ class Game extends EventEmitter<{ private comboIntervalId: number | null = null; - constructor() { + constructor(opts: { + monoDefinitions: Mono[]; + }) { super(); + this.monoDefinitions = opts.monoDefinitions; + this.engine = Matter.Engine.create({ constraintIterations: 2 * PHYSICS_QUALITY_FACTOR, positionIterations: 6 * PHYSICS_QUALITY_FACTOR, @@ -333,8 +505,8 @@ class Game extends EventEmitter<{ }); } - private createBody(fruit: typeof FRUITS[number], x: number, y: number) { - return Matter.Bodies.circle(x, y, fruit.size / 2, { + private createBody(fruit: Mono, x: number, y: number) { + const options: Matter.IBodyDefinition = { label: fruit.id, //density: 0.0005, density: fruit.size / 1000, @@ -351,7 +523,14 @@ class Game extends EventEmitter<{ yScale: (fruit.size / fruit.imgSize) * fruit.spriteScale, }, }, - }); + }; + if (fruit.shape === 'circle') { + return Matter.Bodies.circle(x, y, fruit.size / 2, options); + } else if (fruit.shape === 'rectangle') { + return Matter.Bodies.rectangle(x, y, fruit.size, fruit.size, options); + } else { + throw new Error('unrecognized shape'); + } } private fusion(bodyA: Matter.Body, bodyB: Matter.Body) { @@ -370,8 +549,8 @@ class Game extends EventEmitter<{ Matter.Composite.remove(this.engine.world, [bodyA, bodyB]); this.activeBodyIds = this.activeBodyIds.filter(x => x !== bodyA.id && x !== bodyB.id); - const currentFruit = FRUITS.find(y => y.id === bodyA.label)!; - const nextFruit = FRUITS.find(x => x.level === currentFruit.level + 1); + const currentFruit = this.monoDefinitions.find(y => y.id === bodyA.label)!; + const nextFruit = this.monoDefinitions.find(x => x.level === currentFruit.level + 1); if (nextFruit) { const body = this.createBody(nextFruit, newX, newY); @@ -382,7 +561,8 @@ class Game extends EventEmitter<{ this.activeBodyIds.push(body.id); }, 100); - const additionalScore = Math.round(currentFruit.score * (1 + ((this.combo - 1) / 3))); + const comboBonus = 1 + ((this.combo - 1) / 5); + const additionalScore = Math.round(currentFruit.score * comboBonus); this.score += additionalScore; const pan = ((newX / GAME_WIDTH) - 0.5) * 2; @@ -413,7 +593,7 @@ class Game extends EventEmitter<{ for (let i = 0; i < this.STOCK_MAX; i++) { this.stock.push({ id: Math.random().toString(), - fruit: FRUITS.filter(x => x.available)[Math.floor(Math.random() * FRUITS.filter(x => x.available).length)], + fruit: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)], }); } this.emit('changeStock', this.stock); @@ -474,7 +654,7 @@ class Game extends EventEmitter<{ const st = this.stock.shift()!; this.stock.push({ id: Math.random().toString(), - fruit: FRUITS.filter(x => x.available)[Math.floor(Math.random() * FRUITS.filter(x => x.available).length)], + fruit: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)], }); this.emit('changeStock', this.stock); @@ -533,9 +713,7 @@ function restart() { score.value = 0; combo.value = 0; comboPrev.value = 0; - game = new Game(); - attachGame(); - game.start(); + gameStarted.value = false; } function attachGame() { @@ -584,36 +762,45 @@ function attachGame() { misskeyApi('i/registry/set', { scope: ['dropAndFusionGame'], - key: 'highScore', + key: 'highScore:' + gameMode.value, value: highScore.value, }); } }); } -onMounted(async () => { +async function start() { try { highScore.value = await misskeyApi('i/registry/get', { scope: ['dropAndFusionGame'], - key: 'highScore', + key: 'highScore:' + gameMode.value, }); } catch (err) { } - game = new Game(); - + gameStarted.value = true; + game = new Game(gameMode.value === 'normal' ? { + monoDefinitions: NORAML_MONOS, + } : { + monoDefinitions: SQUARE_MONOS, + }); attachGame(); - game.start(); +} +useInterval(() => { + if (!canvasEl.value) return; const actualCanvasWidth = canvasEl.value.getBoundingClientRect().width; const actualCanvasHeight = canvasEl.value.getBoundingClientRect().height; viewScaleX = actualCanvasWidth / GAME_WIDTH; viewScaleY = actualCanvasHeight / GAME_HEIGHT; +}, 1000, { immediate: false, afterMounted: true }); + +onMounted(async () => { }); definePageMetadata({ - title: 'Drop & Fusion', + title: i18n.ts.bubbleGame, icon: 'ti ti-apple', }); @@ -666,6 +853,8 @@ definePageMetadata({ } .root { + margin: 0 auto; + max-width: 600px; user-select: none; * { diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index 9cf4be778..35478a35a 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -528,7 +528,7 @@ export const routes = [{ component: page(() => import('./pages/clicker.vue')), loginRequired: true, }, { - path: '/drop-and-fusion', + path: '/bubble-game', component: page(() => import('./pages/drop-and-fusion.vue')), loginRequired: true, }, { diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts index e50002dc2..9930b321f 100644 --- a/packages/frontend/src/ui/_common_/common.ts +++ b/packages/frontend/src/ui/_common_/common.ts @@ -29,8 +29,8 @@ function toolsMenuItems(): MenuItem[] { icon: 'ti ti-cookie', }, { type: 'link', - to: '/drop-and-fusion', - text: 'Drop & Fusion', + to: '/bubble-game', + text: i18n.ts.bubbleGame, icon: 'ti ti-apple', }, ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) ? { type: 'link',