tweak game

This commit is contained in:
syuilo 2024-01-07 13:19:10 +09:00
parent 8bf6d31334
commit 00e195f50b
15 changed files with 245 additions and 54 deletions

1
locales/index.d.ts vendored
View file

@ -1192,6 +1192,7 @@ export interface Locale {
"decorate": string;
"addMfmFunction": string;
"enableQuickAddMfmFunction": string;
"bubbleGame": string;
"_announcement": {
"forExistingUsers": string;
"forExistingUsersDescription": string;

View file

@ -1189,6 +1189,7 @@ seasonalScreenEffect: "季節に応じた画面の演出"
decorate: "デコる"
addMfmFunction: "装飾を追加"
enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する"
bubbleGame: "バブルゲーム"
_announcement:
forExistingUsers: "既存ユーザーのみ"

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View file

@ -7,12 +7,22 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<MkSpacer :contentMax="800">
<div class="_gaps_s" :class="$style.root" style="margin: 0 auto; max-width: 600px;">
<div v-show="!gameStarted" class="_gaps_s" :class="$style.root">
<div style="text-align: center;">
<div>{{ i18n.ts.bubbleGame }}</div>
<MkSelect v-model="gameMode">
<option value="normal">NORMAL</option>
<option value="square">SQUARE</option>
</MkSelect>
<MkButton primary gradate large rounded inline @click="start">{{ i18n.ts.start }}</MkButton>
</div>
</div>
<div v-show="gameStarted" class="_gaps_s" :class="$style.root">
<div style="display: flex;">
<div :class="$style.frame" style="flex: 1; margin-right: 10px;">
<div :class="$style.frameInner">
<div>SCORE: <b><MkNumber :value="score"/></b></div>
<div>HIGH SCORE: <b v-if="highScore"><MkNumber :value="highScore"/></b><b v-else>-</b></div>
<b>BUBBLE GAME</b>
<div>- {{ gameMode }} -</div>
</div>
</div>
<div :class="[$style.frame, $style.stock]" style="margin-left: auto;">
@ -33,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div :class="$style.main">
<div ref="containerEl" :class="[$style.container, { [$style.gameOver]: gameOver }]" @click.stop.prevent="onClick" @touchmove="onTouchmove" @touchend="onTouchend" @mousemove="onMousemove">
<div ref="containerEl" :class="[$style.container, { [$style.gameOver]: gameOver }]" @click.stop.prevent="onClick" @touchmove.stop.prevent="onTouchmove" @touchend="onTouchend" @mousemove="onMousemove">
<img v-if="defaultStore.state.darkMode" src="/client-assets/drop-and-fusion/frame-dark.svg" :class="$style.mainFrameImg"/>
<img v-else src="/client-assets/drop-and-fusion/frame-light.svg" :class="$style.mainFrameImg"/>
<canvas ref="canvasEl" :class="$style.canvas"/>
@ -67,8 +77,24 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
<div style="display: flex;">
<div :class="$style.frame" style="flex: 1; margin-right: 10px;">
<div :class="$style.frameInner">
<div>SCORE: <b><MkNumber :value="score"/></b></div>
<div>HIGH SCORE: <b v-if="highScore"><MkNumber :value="highScore"/></b><b v-else>-</b></div>
</div>
</div>
<div :class="[$style.frame]" style="margin-left: auto;">
<div :class="$style.frameInner" style="text-align: center;">
</div>
</div>
</div>
<div :class="$style.frame">
<div :class="$style.frameInner">
<MkButton @click="restart">Restart</MkButton>
</div>
</div>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
@ -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<HTMLElement>();
const canvasEl = shallowRef<HTMLCanvasElement>();
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<number | null>(null);
@ -213,7 +379,7 @@ const highScore = ref<number | null>(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',
});
</script>
@ -666,6 +853,8 @@ definePageMetadata({
}
.root {
margin: 0 auto;
max-width: 600px;
user-select: none;
* {

View file

@ -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,
}, {

View file

@ -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',