Merge branch 'develop' into feat-12909
This commit is contained in:
commit
919f49946b
|
@ -1,6 +1,12 @@
|
||||||
name: API report (misskey.js)
|
name: API report (misskey.js)
|
||||||
|
|
||||||
on: [push, pull_request]
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- packages/misskey-js/**
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- packages/misskey-js/**
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
report:
|
report:
|
||||||
|
|
|
@ -5,7 +5,19 @@ on:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
- develop
|
- develop
|
||||||
|
paths:
|
||||||
|
- packages/backend/**
|
||||||
|
- packages/frontend/**
|
||||||
|
- packages/sw/**
|
||||||
|
- packages/misskey-js/**
|
||||||
|
- packages/shared/.eslintrc.js
|
||||||
pull_request:
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- packages/backend/**
|
||||||
|
- packages/frontend/**
|
||||||
|
- packages/sw/**
|
||||||
|
- packages/misskey-js/**
|
||||||
|
- packages/shared/.eslintrc.js
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pnpm_install:
|
pnpm_install:
|
||||||
|
|
|
@ -5,7 +5,15 @@ on:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
- develop
|
- develop
|
||||||
|
paths:
|
||||||
|
- packages/backend/**
|
||||||
|
# for permissions
|
||||||
|
- packages/misskey-js/**
|
||||||
pull_request:
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- packages/backend/**
|
||||||
|
# for permissions
|
||||||
|
- packages/misskey-js/**
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
unit:
|
unit:
|
||||||
|
|
|
@ -5,7 +5,20 @@ on:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
- develop
|
- develop
|
||||||
|
paths:
|
||||||
|
- packages/frontend/**
|
||||||
|
# for permissions
|
||||||
|
- packages/misskey-js/**
|
||||||
|
# for e2e
|
||||||
|
- packages/backend/**
|
||||||
|
|
||||||
pull_request:
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- packages/frontend/**
|
||||||
|
# for permissions
|
||||||
|
- packages/misskey-js/**
|
||||||
|
# for e2e
|
||||||
|
- packages/backend/**
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
vitest:
|
vitest:
|
||||||
|
|
|
@ -6,8 +6,12 @@ name: Test (misskey.js)
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ develop ]
|
branches: [ develop ]
|
||||||
|
paths:
|
||||||
|
- packages/misskey-js/**
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ develop ]
|
branches: [ develop ]
|
||||||
|
paths:
|
||||||
|
- packages/misskey-js/**
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|
|
@ -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
|
|
@ -23,9 +23,14 @@
|
||||||
- Feat: 音声・映像プレイヤーを追加
|
- Feat: 音声・映像プレイヤーを追加
|
||||||
- Enhance: ハッシュタグ入力時に、本文の末尾の行に何も書かれていない場合は新たにスペースを追加しないように
|
- Enhance: ハッシュタグ入力時に、本文の末尾の行に何も書かれていない場合は新たにスペースを追加しないように
|
||||||
- Enhance: チャンネルノートのピン留めをノートのメニューからできるように
|
- Enhance: チャンネルノートのピン留めをノートのメニューからできるように
|
||||||
|
- Enhance: 管理者の場合はAPI tokenの発行画面で管理機能に関する権限を付与できるように
|
||||||
|
- Enhance: AiScriptを0.17.0に更新 [CHANGELOG](https://github.com/aiscript-dev/aiscript/blob/bb89d132b633a622d3cb0eff0d0cc7e476c0cfdd/CHANGELOG.md)
|
||||||
|
- 配列の範囲外・非整数のインデックスへの代入が完全禁止になるので注意
|
||||||
|
- Enhance: 絵文字ピッカー・オートコンプリートで、完全一致した絵文字を優先的に表示するように
|
||||||
- Fix: ネイティブモードの絵文字がモノクロにならないように
|
- Fix: ネイティブモードの絵文字がモノクロにならないように
|
||||||
- Fix: v2023.12.0で追加された「モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能」が管理画面上で正しく表示されていない問題を修正
|
- Fix: v2023.12.0で追加された「モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能」が管理画面上で正しく表示されていない問題を修正
|
||||||
- Fix: AiScriptの`readline`関数が不正な値を返すことがある問題のv2023.12.0時点での修正がPlay以外に適用されていないのを修正
|
- Fix: AiScriptの`readline`関数が不正な値を返すことがある問題のv2023.12.0時点での修正がPlay以外に適用されていないのを修正
|
||||||
|
- Fix: v2023.12.1で追加された`$[clickable ...]`および`onClickEv`が正しく機能していないのを修正
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
- Enhance: 連合先のレートリミットに引っかかった際にリトライするようになりました
|
- Enhance: 連合先のレートリミットに引っかかった際にリトライするようになりました
|
||||||
|
|
|
@ -634,6 +634,7 @@ export interface Locale {
|
||||||
"small": string;
|
"small": string;
|
||||||
"generateAccessToken": string;
|
"generateAccessToken": string;
|
||||||
"permission": string;
|
"permission": string;
|
||||||
|
"adminPermission": string;
|
||||||
"enableAll": string;
|
"enableAll": string;
|
||||||
"disableAll": string;
|
"disableAll": string;
|
||||||
"tokenRequested": string;
|
"tokenRequested": string;
|
||||||
|
@ -1195,6 +1196,19 @@ export interface Locale {
|
||||||
"addMfmFunction": string;
|
"addMfmFunction": string;
|
||||||
"enableQuickAddMfmFunction": string;
|
"enableQuickAddMfmFunction": string;
|
||||||
"bubbleGame": string;
|
"bubbleGame": string;
|
||||||
|
"sfx": string;
|
||||||
|
"soundWillBePlayed": string;
|
||||||
|
"showReplay": string;
|
||||||
|
"replay": string;
|
||||||
|
"replaying": string;
|
||||||
|
"_bubbleGame": {
|
||||||
|
"howToPlay": string;
|
||||||
|
"_howToPlay": {
|
||||||
|
"section1": string;
|
||||||
|
"section2": string;
|
||||||
|
"section3": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
"_announcement": {
|
"_announcement": {
|
||||||
"forExistingUsers": string;
|
"forExistingUsers": string;
|
||||||
"forExistingUsersDescription": string;
|
"forExistingUsersDescription": string;
|
||||||
|
|
|
@ -631,6 +631,7 @@ medium: "中"
|
||||||
small: "小"
|
small: "小"
|
||||||
generateAccessToken: "アクセストークンの発行"
|
generateAccessToken: "アクセストークンの発行"
|
||||||
permission: "権限"
|
permission: "権限"
|
||||||
|
adminPermission: "管理者権限"
|
||||||
enableAll: "全て有効にする"
|
enableAll: "全て有効にする"
|
||||||
disableAll: "全て無効にする"
|
disableAll: "全て無効にする"
|
||||||
tokenRequested: "アカウントへのアクセス許可"
|
tokenRequested: "アカウントへのアクセス許可"
|
||||||
|
@ -1192,6 +1193,18 @@ decorate: "デコる"
|
||||||
addMfmFunction: "装飾を追加"
|
addMfmFunction: "装飾を追加"
|
||||||
enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する"
|
enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する"
|
||||||
bubbleGame: "バブルゲーム"
|
bubbleGame: "バブルゲーム"
|
||||||
|
sfx: "効果音"
|
||||||
|
soundWillBePlayed: "サウンドが再生されます"
|
||||||
|
showReplay: "リプレイを見る"
|
||||||
|
replay: "リプレイ"
|
||||||
|
replaying: "リプレイ中"
|
||||||
|
|
||||||
|
_bubbleGame:
|
||||||
|
howToPlay: "遊び方"
|
||||||
|
_howToPlay:
|
||||||
|
section1: "位置を調整してハコにモノを落とします。"
|
||||||
|
section2: "同じ種類のモノがくっつくと別のモノに変化して、スコアが得られます。"
|
||||||
|
section3: "モノがハコからあふれるとゲームオーバーです。ハコからあふれないようにしつつモノを融合させてハイスコアを目指そう!"
|
||||||
|
|
||||||
_announcement:
|
_announcement:
|
||||||
forExistingUsers: "既存ユーザーのみ"
|
forExistingUsers: "既存ユーザーのみ"
|
||||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -24,7 +24,7 @@
|
||||||
"@rollup/plugin-json": "6.1.0",
|
"@rollup/plugin-json": "6.1.0",
|
||||||
"@rollup/plugin-replace": "5.0.5",
|
"@rollup/plugin-replace": "5.0.5",
|
||||||
"@rollup/pluginutils": "5.1.0",
|
"@rollup/pluginutils": "5.1.0",
|
||||||
"@syuilo/aiscript": "0.16.0",
|
"@syuilo/aiscript": "0.17.0",
|
||||||
"@tabler/icons-webfont": "2.44.0",
|
"@tabler/icons-webfont": "2.44.0",
|
||||||
"@twemoji/parser": "15.0.0",
|
"@twemoji/parser": "15.0.0",
|
||||||
"@vitejs/plugin-vue": "5.0.2",
|
"@vitejs/plugin-vue": "5.0.2",
|
||||||
|
@ -58,6 +58,7 @@
|
||||||
"rollup": "4.9.1",
|
"rollup": "4.9.1",
|
||||||
"sanitize-html": "2.11.0",
|
"sanitize-html": "2.11.0",
|
||||||
"sass": "1.69.5",
|
"sass": "1.69.5",
|
||||||
|
"seedrandom": "^3.0.5",
|
||||||
"shiki": "0.14.7",
|
"shiki": "0.14.7",
|
||||||
"strict-event-emitter-types": "2.0.0",
|
"strict-event-emitter-types": "2.0.0",
|
||||||
"textarea-caret": "3.1.0",
|
"textarea-caret": "3.1.0",
|
||||||
|
|
|
@ -271,7 +271,7 @@ export async function mainBoot() {
|
||||||
|
|
||||||
main.on('unreadAntenna', () => {
|
main.on('unreadAntenna', () => {
|
||||||
updateAccount({ hasUnreadAntenna: true });
|
updateAccount({ hasUnreadAntenna: true });
|
||||||
sound.play('antenna');
|
sound.playMisskeySfx('antenna');
|
||||||
});
|
});
|
||||||
|
|
||||||
main.on('readAllAnnouncements', () => {
|
main.on('readAllAnnouncements', () => {
|
||||||
|
|
|
@ -262,14 +262,23 @@ function emojiAutoComplete(query: string | null, emojiDb: EmojiDef[], max = 30):
|
||||||
}
|
}
|
||||||
|
|
||||||
const matched = new Map<string, EmojiScore>();
|
const matched = new Map<string, EmojiScore>();
|
||||||
|
// 完全一致(エイリアス込み)
|
||||||
|
emojiDb.some(x => {
|
||||||
|
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 => {
|
emojiDb.some(x => {
|
||||||
if (x.name.startsWith(query) && !x.aliasOf) {
|
if (x.name.startsWith(query) && !x.aliasOf) {
|
||||||
matched.set(x.name, { emoji: x, score: query.length + 1 });
|
matched.set(x.name, { emoji: x, score: query.length + 1 });
|
||||||
}
|
}
|
||||||
return matched.size === max;
|
return matched.size === max;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 前方一致(エイリアス込み)
|
// 前方一致(エイリアス込み)
|
||||||
if (matched.size < max) {
|
if (matched.size < max) {
|
||||||
|
|
|
@ -221,6 +221,19 @@ watch(q, () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
for (const emoji of emojis) {
|
||||||
if (emoji.name.startsWith(newQ)) {
|
if (emoji.name.startsWith(newQ)) {
|
||||||
matches.add(emoji);
|
matches.add(emoji);
|
||||||
|
|
|
@ -345,7 +345,7 @@ function react(viaKeyboard = false): void {
|
||||||
pleaseLogin();
|
pleaseLogin();
|
||||||
showMovedDialog();
|
showMovedDialog();
|
||||||
if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
||||||
sound.play('reaction');
|
sound.playMisskeySfx('reaction');
|
||||||
|
|
||||||
if (props.mock) {
|
if (props.mock) {
|
||||||
return;
|
return;
|
||||||
|
@ -365,7 +365,7 @@ function react(viaKeyboard = false): void {
|
||||||
} else {
|
} else {
|
||||||
blur();
|
blur();
|
||||||
reactionPicker.show(reactButton.value, reaction => {
|
reactionPicker.show(reactButton.value, reaction => {
|
||||||
sound.play('reaction');
|
sound.playMisskeySfx('reaction');
|
||||||
|
|
||||||
if (props.mock) {
|
if (props.mock) {
|
||||||
emit('reaction', reaction);
|
emit('reaction', reaction);
|
||||||
|
|
|
@ -370,7 +370,7 @@ function react(viaKeyboard = false): void {
|
||||||
pleaseLogin();
|
pleaseLogin();
|
||||||
showMovedDialog();
|
showMovedDialog();
|
||||||
if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
||||||
sound.play('reaction');
|
sound.playMisskeySfx('reaction');
|
||||||
|
|
||||||
misskeyApi('notes/reactions/create', {
|
misskeyApi('notes/reactions/create', {
|
||||||
noteId: appearNote.value.id,
|
noteId: appearNote.value.id,
|
||||||
|
@ -386,7 +386,7 @@ function react(viaKeyboard = false): void {
|
||||||
} else {
|
} else {
|
||||||
blur();
|
blur();
|
||||||
reactionPicker.show(reactButton.value, reaction => {
|
reactionPicker.show(reactButton.value, reaction => {
|
||||||
sound.play('reaction');
|
sound.playMisskeySfx('reaction');
|
||||||
|
|
||||||
misskeyApi('notes/reactions/create', {
|
misskeyApi('notes/reactions/create', {
|
||||||
noteId: appearNote.value.id,
|
noteId: appearNote.value.id,
|
||||||
|
|
|
@ -43,6 +43,7 @@ const props = withDefaults(defineProps<{
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(ev: 'update:modelValue', value: number): void;
|
(ev: 'update:modelValue', value: number): void;
|
||||||
|
(ev: 'dragEnded', value: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const containerEl = shallowRef<HTMLElement>();
|
const containerEl = shallowRef<HTMLElement>();
|
||||||
|
@ -143,6 +144,7 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => {
|
||||||
// 値が変わってたら通知
|
// 値が変わってたら通知
|
||||||
if (beforeValue !== finalValue.value) {
|
if (beforeValue !== finalValue.value) {
|
||||||
emit('update:modelValue', finalValue.value);
|
emit('update:modelValue', finalValue.value);
|
||||||
|
emit('dragEnded', finalValue.value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -62,7 +62,7 @@ async function toggleReaction() {
|
||||||
if (confirm.canceled) return;
|
if (confirm.canceled) return;
|
||||||
|
|
||||||
if (oldReaction !== props.reaction) {
|
if (oldReaction !== props.reaction) {
|
||||||
sound.play('reaction');
|
sound.playMisskeySfx('reaction');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mock) {
|
if (mock) {
|
||||||
|
@ -81,7 +81,7 @@ async function toggleReaction() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
sound.play('reaction');
|
sound.playMisskeySfx('reaction');
|
||||||
|
|
||||||
if (mock) {
|
if (mock) {
|
||||||
emit('reactionToggled', props.reaction, (props.count + 1));
|
emit('reactionToggled', props.reaction, (props.count + 1));
|
||||||
|
|
|
@ -81,7 +81,7 @@ function prepend(note) {
|
||||||
emit('note');
|
emit('note');
|
||||||
|
|
||||||
if (props.sound) {
|
if (props.sound) {
|
||||||
sound.play($i && (note.userId === $i.id) ? 'noteMy' : 'note');
|
sound.playMisskeySfx($i && (note.userId === $i.id) ? 'noteMy' : 'note');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -33,7 +33,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton>
|
<MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton>
|
||||||
</div>
|
</div>
|
||||||
<div class="_gaps_s">
|
<div class="_gaps_s">
|
||||||
<MkSwitch v-for="kind in Object.keys(permissions)" :key="kind" v-model="permissions[kind]">{{ i18n.t(`_permissions.${kind}`) }}</MkSwitch>
|
<MkSwitch v-for="kind in Object.keys(permissionSwitches)" :key="kind" v-model="permissionSwitches[kind]">{{ i18n.t(`_permissions.${kind}`) }}</MkSwitch>
|
||||||
|
</div>
|
||||||
|
<div v-if="iAmAdmin" :class="$style.adminPermissions">
|
||||||
|
<div :class="$style.adminPermissionsHeader"><b>{{ i18n.ts.adminPermission }}</b></div>
|
||||||
|
<div class="_gaps_s">
|
||||||
|
<MkSwitch v-for="kind in Object.keys(permissionSwitchesForAdmin)" :key="kind" v-model="permissionSwitchesForAdmin[kind]">{{ i18n.t(`_permissions.${kind}`) }}</MkSwitch>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MkSpacer>
|
</MkSpacer>
|
||||||
|
@ -49,6 +55,7 @@ import MkButton from './MkButton.vue';
|
||||||
import MkInfo from './MkInfo.vue';
|
import MkInfo from './MkInfo.vue';
|
||||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
|
import { iAmAdmin } from '@/account.js';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
title?: string | null;
|
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 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<InstanceType<typeof MkModalWindow>>();
|
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||||
const name = ref(props.initialName);
|
const name = ref(props.initialName);
|
||||||
const permissions = ref(<Record<(typeof Misskey.permissions)[number], boolean>>{});
|
const permissionSwitches = ref(<Record<(typeof Misskey.permissions)[number], boolean>>{});
|
||||||
|
const permissionSwitchesForAdmin = ref(<Record<(typeof Misskey.permissions)[number], boolean>>{});
|
||||||
|
|
||||||
if (props.initialPermissions) {
|
if (props.initialPermissions) {
|
||||||
for (const kind of props.initialPermissions) {
|
for (const kind of props.initialPermissions) {
|
||||||
permissions.value[kind] = true;
|
permissionSwitches.value[kind] = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const kind of defaultPermissions) {
|
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 {
|
function ok(): void {
|
||||||
emit('done', {
|
emit('done', {
|
||||||
name: name.value,
|
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();
|
dialog.value?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function disableAll(): void {
|
function disableAll(): void {
|
||||||
for (const p in permissions.value) {
|
for (const p in permissionSwitches.value) {
|
||||||
permissions.value[p] = false;
|
permissionSwitches.value[p] = false;
|
||||||
|
}
|
||||||
|
if (iAmAdmin) {
|
||||||
|
for (const p in permissionSwitchesForAdmin.value) {
|
||||||
|
permissionSwitchesForAdmin.value[p] = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function enableAll(): void {
|
function enableAll(): void {
|
||||||
for (const p in permissions.value) {
|
for (const p in permissionSwitches.value) {
|
||||||
permissions.value[p] = true;
|
permissionSwitches.value[p] = true;
|
||||||
|
}
|
||||||
|
if (iAmAdmin) {
|
||||||
|
for (const p in permissionSwitchesForAdmin.value) {
|
||||||
|
permissionSwitchesForAdmin.value[p] = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.adminPermissions {
|
||||||
|
margin: 8px -6px 0;
|
||||||
|
padding: 24px 6px 6px;
|
||||||
|
border: 2px solid var(--error);
|
||||||
|
border-radius: calc(var(--radius) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adminPermissionsHeader {
|
||||||
|
margin: -34px 0 6px 12px;
|
||||||
|
padding: 0 4px;
|
||||||
|
width: fit-content;
|
||||||
|
color: var(--error);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -91,7 +91,7 @@ function onClick(ev: MouseEvent) {
|
||||||
icon: 'ti ti-plus',
|
icon: 'ti ti-plus',
|
||||||
action: () => {
|
action: () => {
|
||||||
react(`:${props.name}:`);
|
react(`:${props.name}:`);
|
||||||
sound.play('reaction');
|
sound.playMisskeySfx('reaction');
|
||||||
},
|
},
|
||||||
}] : [])], ev.currentTarget ?? ev.target);
|
}] : [])], ev.currentTarget ?? ev.target);
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,7 +55,7 @@ function onClick(ev: MouseEvent) {
|
||||||
icon: 'ti ti-plus',
|
icon: 'ti ti-plus',
|
||||||
action: () => {
|
action: () => {
|
||||||
react(props.emoji);
|
react(props.emoji);
|
||||||
sound.play('reaction');
|
sound.playMisskeySfx('reaction');
|
||||||
},
|
},
|
||||||
}] : [])], ev.currentTarget ?? ev.target);
|
}] : [])], ev.currentTarget ?? ev.target);
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -4,17 +4,23 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<MkStickyContainer>
|
<MkSpacer :contentMax="800">
|
||||||
<template #header><MkPageHeader/></template>
|
<Transition
|
||||||
<MkSpacer :contentMax="800">
|
:enterActiveClass="$style.transition_zoom_enterActive"
|
||||||
<div v-show="!gameStarted" :class="$style.root">
|
:leaveActiveClass="$style.transition_zoom_leaveActive"
|
||||||
<div style="text-align: center;" class="_gaps">
|
:enterFromClass="$style.transition_zoom_enterFrom"
|
||||||
<div :class="$style.frame">
|
:leaveToClass="$style.transition_zoom_leaveTo"
|
||||||
|
:moveClass="$style.transition_zoom_move"
|
||||||
|
mode="out-in"
|
||||||
|
>
|
||||||
|
<div v-if="!gameStarted" :class="$style.root">
|
||||||
|
<div class="_gaps">
|
||||||
|
<div :class="$style.frame" style="text-align: center;">
|
||||||
<div :class="$style.frameInner">
|
<div :class="$style.frameInner">
|
||||||
<img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
|
<img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.frame">
|
<div :class="$style.frame" style="text-align: center;">
|
||||||
<div :class="$style.frameInner">
|
<div :class="$style.frameInner">
|
||||||
<div class="_gaps" style="padding: 16px;">
|
<div class="_gaps" style="padding: 16px;">
|
||||||
<MkSelect v-model="gameMode">
|
<MkSelect v-model="gameMode">
|
||||||
|
@ -24,610 +30,66 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkButton primary gradate large rounded inline @click="start">{{ i18n.ts.start }}</MkButton>
|
<MkButton primary gradate large rounded inline @click="start">{{ i18n.ts.start }}</MkButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</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 :class="$style.frameInner">
|
||||||
<b>BUBBLE GAME</b>
|
<div class="_gaps" style="padding: 16px;">
|
||||||
<div>- {{ gameMode }} -</div>
|
<div style="font-size: 90%;"><i class="ti ti-music"></i> {{ i18n.ts.soundWillBePlayed }}</div>
|
||||||
</div>
|
<MkSwitch v-model="mute">
|
||||||
</div>
|
<template #label>{{ i18n.ts.mute }}</template>
|
||||||
<div :class="[$style.frame, $style.stock]" style="margin-left: auto;">
|
</MkSwitch>
|
||||||
<div :class="$style.frameInner" style="text-align: center;">
|
|
||||||
NEXT >>>
|
|
||||||
<TransitionGroup
|
|
||||||
:enterActiveClass="$style.transition_stock_enterActive"
|
|
||||||
:leaveActiveClass="$style.transition_stock_leaveActive"
|
|
||||||
:enterFromClass="$style.transition_stock_enterFrom"
|
|
||||||
:leaveToClass="$style.transition_stock_leaveTo"
|
|
||||||
:moveClass="$style.transition_stock_move"
|
|
||||||
>
|
|
||||||
<div v-for="x in stock" :key="x.id" style="display: inline-block;">
|
|
||||||
<img :src="game.getTextureImageUrl(x.mono)" style="width: 32px;"/>
|
|
||||||
</div>
|
|
||||||
</TransitionGroup>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.main" @contextmenu.stop.prevent>
|
<div :class="$style.frame">
|
||||||
<div ref="containerEl" :class="[$style.container, { [$style.gameOver]: gameOver }]" @click.stop.prevent="onClick" @touchmove.stop.prevent="onTouchmove" @touchend="onTouchend" @mousemove="onMousemove">
|
<div :class="$style.frameInner" style="padding: 16px;">
|
||||||
<img v-if="defaultStore.state.darkMode" src="/client-assets/drop-and-fusion/frame-dark.svg" :class="$style.mainFrameImg"/>
|
<div style="font-weight: bold;">{{ i18n.ts._bubbleGame.howToPlay }}</div>
|
||||||
<img v-else src="/client-assets/drop-and-fusion/frame-light.svg" :class="$style.mainFrameImg"/>
|
<ol>
|
||||||
<canvas ref="canvasEl" :class="$style.canvas"/>
|
<li>{{ i18n.ts._bubbleGame._howToPlay.section1 }}</li>
|
||||||
<Transition
|
<li>{{ i18n.ts._bubbleGame._howToPlay.section2 }}</li>
|
||||||
:enterActiveClass="$style.transition_combo_enterActive"
|
<li>{{ i18n.ts._bubbleGame._howToPlay.section3 }}</li>
|
||||||
:leaveActiveClass="$style.transition_combo_leaveActive"
|
</ol>
|
||||||
:enterFromClass="$style.transition_combo_enterFrom"
|
|
||||||
:leaveToClass="$style.transition_combo_leaveTo"
|
|
||||||
:moveClass="$style.transition_combo_move"
|
|
||||||
>
|
|
||||||
<div v-show="combo > 1" :class="$style.combo" :style="{ fontSize: `${100 + ((comboPrev - 2) * 15)}%` }">{{ comboPrev }} Chain!</div>
|
|
||||||
</Transition>
|
|
||||||
<img v-if="currentPick" src="/client-assets/drop-and-fusion/dropper.png" :class="$style.dropper" :style="{ left: dropperX + 'px' }"/>
|
|
||||||
<Transition
|
|
||||||
:enterActiveClass="$style.transition_picked_enterActive"
|
|
||||||
:leaveActiveClass="$style.transition_picked_leaveActive"
|
|
||||||
:enterFromClass="$style.transition_picked_enterFrom"
|
|
||||||
:leaveToClass="$style.transition_picked_leaveTo"
|
|
||||||
:moveClass="$style.transition_picked_move"
|
|
||||||
mode="out-in"
|
|
||||||
>
|
|
||||||
<img v-if="currentPick" :key="currentPick.id" :src="game.getTextureImageUrl(currentPick.mono)" :class="$style.currentMono" :style="{ top: -(currentPick?.mono.size / 2) + 'px', left: (dropperX - (currentPick?.mono.size / 2)) + 'px', width: `${currentPick?.mono.size}px` }"/>
|
|
||||||
</Transition>
|
|
||||||
<template v-if="dropReady && currentPick">
|
|
||||||
<img src="/client-assets/drop-and-fusion/drop-arrow.svg" :class="$style.currentMonoArrow" :style="{ top: (currentPick.mono.size / 2) + 10 + 'px', left: (dropperX - 10) + 'px', width: `20px` }"/>
|
|
||||||
<div :class="$style.dropGuide" :style="{ left: (dropperX - 2) + 'px' }"/>
|
|
||||||
</template>
|
|
||||||
<div v-if="gameOver" :class="$style.gameOverLabel">
|
|
||||||
<div class="_gaps_s">
|
|
||||||
<img src="/client-assets/drop-and-fusion/gameover.png" style="width: 200px; max-width: 100%; display: block; margin: auto; margin-bottom: -5px;"/>
|
|
||||||
<div>SCORE: <MkNumber :value="score"/></div>
|
|
||||||
<div>MAX CHAIN: <MkNumber :value="maxCombo"/></div>
|
|
||||||
<div class="_buttonsCenter">
|
|
||||||
<MkButton primary rounded @click="restart">Restart</MkButton>
|
|
||||||
<MkButton primary rounded @click="share">Share</MkButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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> (MAX CHAIN: <b><MkNumber :value="maxCombo"/></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 @click="showConfig = !showConfig"><i class="ti ti-settings"></i></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="showConfig" :class="$style.frame">
|
|
||||||
<div :class="$style.frameInner">
|
|
||||||
<MkRange v-model="bgmVolume" :min="0" :max="1" :step="0.0025" :textConverter="(v) => `${Math.floor(v * 100)}%`" :continuousUpdate="true">
|
|
||||||
<template #label>BGM {{ i18n.ts.volume }}</template>
|
|
||||||
</MkRange>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="showConfig" :class="$style.frame">
|
|
||||||
<div :class="$style.frameInner">
|
|
||||||
<div>Credit</div>
|
|
||||||
<div>BGM: @ys@misskey.design</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.frame">
|
<div :class="$style.frame">
|
||||||
<div :class="$style.frameInner">
|
<div :class="$style.frameInner">
|
||||||
<MkButton @click="restart">Restart</MkButton>
|
<div class="_gaps_s" style="padding: 16px;">
|
||||||
|
<div><b>Credit</b></div>
|
||||||
|
<div>
|
||||||
|
<div>Ai-chan illustration: @poteriri@misskey.io</div>
|
||||||
|
<div>BGM: @ys@misskey.design</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MkSpacer>
|
</div>
|
||||||
</MkStickyContainer>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<XGame :gameMode="gameMode" :mute="mute" @end="onGameEnd"/>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</MkSpacer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onDeactivated, ref, shallowRef, watch } from 'vue';
|
import { ref } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import XGame from './drop-and-fusion.game.vue';
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
|
||||||
import * as os from '@/os.js';
|
|
||||||
import MkNumber from '@/components/MkNumber.vue';
|
|
||||||
import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue';
|
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import { claimAchievement } from '@/scripts/achievements.js';
|
|
||||||
import { defaultStore } from '@/store.js';
|
|
||||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { useInterval } from '@/scripts/use-interval.js';
|
|
||||||
import MkSelect from '@/components/MkSelect.vue';
|
import MkSelect from '@/components/MkSelect.vue';
|
||||||
import { apiUrl } from '@/config.js';
|
import MkSwitch from '@/components/MkSwitch.vue';
|
||||||
import { $i } from '@/account.js';
|
|
||||||
import { DropAndFusionGame, Mono } from '@/scripts/drop-and-fusion-engine.js';
|
|
||||||
import * as sound from '@/scripts/sound.js';
|
|
||||||
import MkRange from '@/components/MkRange.vue';
|
|
||||||
|
|
||||||
const containerEl = shallowRef<HTMLElement>();
|
|
||||||
const canvasEl = shallowRef<HTMLCanvasElement>();
|
|
||||||
const dropperX = ref(0);
|
|
||||||
|
|
||||||
const NORMAL_BASE_SIZE = 30;
|
|
||||||
const NORAML_MONOS: Mono[] = [{
|
|
||||||
id: '9377076d-c980-4d83-bdaf-175bc58275b7',
|
|
||||||
level: 10,
|
|
||||||
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,
|
|
||||||
dropCandidate: false,
|
|
||||||
sfxPitch: 0.25,
|
|
||||||
img: '/client-assets/drop-and-fusion/exploding_head.png',
|
|
||||||
imgSize: 256,
|
|
||||||
spriteScale: 1.12,
|
|
||||||
}, {
|
|
||||||
id: 'be9f38d2-b267-4b1a-b420-904e22e80568',
|
|
||||||
level: 9,
|
|
||||||
size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
|
|
||||||
shape: 'circle',
|
|
||||||
score: 256,
|
|
||||||
dropCandidate: false,
|
|
||||||
sfxPitch: 0.5,
|
|
||||||
img: '/client-assets/drop-and-fusion/face_with_symbols_on_mouth.png',
|
|
||||||
imgSize: 256,
|
|
||||||
spriteScale: 1.12,
|
|
||||||
}, {
|
|
||||||
id: 'beb30459-b064-4888-926b-f572e4e72e0c',
|
|
||||||
level: 8,
|
|
||||||
size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
|
|
||||||
shape: 'circle',
|
|
||||||
score: 128,
|
|
||||||
dropCandidate: false,
|
|
||||||
sfxPitch: 0.75,
|
|
||||||
img: '/client-assets/drop-and-fusion/cold_face.png',
|
|
||||||
imgSize: 256,
|
|
||||||
spriteScale: 1.12,
|
|
||||||
}, {
|
|
||||||
id: 'feab6426-d9d8-49ae-849c-048cdbb6cdf0',
|
|
||||||
level: 7,
|
|
||||||
size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
|
|
||||||
shape: 'circle',
|
|
||||||
score: 64,
|
|
||||||
dropCandidate: false,
|
|
||||||
sfxPitch: 1,
|
|
||||||
img: '/client-assets/drop-and-fusion/zany_face.png',
|
|
||||||
imgSize: 256,
|
|
||||||
spriteScale: 1.12,
|
|
||||||
}, {
|
|
||||||
id: 'd6d8fed6-6d18-4726-81a1-6cf2c974df8a',
|
|
||||||
level: 6,
|
|
||||||
size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25,
|
|
||||||
shape: 'circle',
|
|
||||||
score: 32,
|
|
||||||
dropCandidate: false,
|
|
||||||
sfxPitch: 1.5,
|
|
||||||
img: '/client-assets/drop-and-fusion/pleading_face.png',
|
|
||||||
imgSize: 256,
|
|
||||||
spriteScale: 1.12,
|
|
||||||
}, {
|
|
||||||
id: '249c728e-230f-4332-bbbf-281c271c75b2',
|
|
||||||
level: 5,
|
|
||||||
size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25,
|
|
||||||
shape: 'circle',
|
|
||||||
score: 16,
|
|
||||||
dropCandidate: true,
|
|
||||||
sfxPitch: 2,
|
|
||||||
img: '/client-assets/drop-and-fusion/face_with_open_mouth.png',
|
|
||||||
imgSize: 256,
|
|
||||||
spriteScale: 1.12,
|
|
||||||
}, {
|
|
||||||
id: '23d67613-d484-4a93-b71e-3e81b19d6186',
|
|
||||||
level: 4,
|
|
||||||
size: NORMAL_BASE_SIZE * 1.25 * 1.25 * 1.25,
|
|
||||||
shape: 'circle',
|
|
||||||
score: 8,
|
|
||||||
dropCandidate: true,
|
|
||||||
sfxPitch: 2.5,
|
|
||||||
img: '/client-assets/drop-and-fusion/smiling_face_with_sunglasses.png',
|
|
||||||
imgSize: 256,
|
|
||||||
spriteScale: 1.12,
|
|
||||||
}, {
|
|
||||||
id: '3cbd0add-ad7d-4685-bad0-29f6dddc0b99',
|
|
||||||
level: 3,
|
|
||||||
size: NORMAL_BASE_SIZE * 1.25 * 1.25,
|
|
||||||
shape: 'circle',
|
|
||||||
score: 4,
|
|
||||||
dropCandidate: true,
|
|
||||||
sfxPitch: 3,
|
|
||||||
img: '/client-assets/drop-and-fusion/grinning_squinting_face.png',
|
|
||||||
imgSize: 256,
|
|
||||||
spriteScale: 1.12,
|
|
||||||
}, {
|
|
||||||
id: '8f86d4f4-ee02-41bf-ad38-1ce0ae457fb5',
|
|
||||||
level: 2,
|
|
||||||
size: NORMAL_BASE_SIZE * 1.25,
|
|
||||||
shape: 'circle',
|
|
||||||
score: 2,
|
|
||||||
dropCandidate: true,
|
|
||||||
sfxPitch: 3.5,
|
|
||||||
img: '/client-assets/drop-and-fusion/smiling_face_with_hearts.png',
|
|
||||||
imgSize: 256,
|
|
||||||
spriteScale: 1.12,
|
|
||||||
}, {
|
|
||||||
id: '64ec4add-ce39-42b4-96cb-33908f3f118d',
|
|
||||||
level: 1,
|
|
||||||
size: NORMAL_BASE_SIZE,
|
|
||||||
shape: 'circle',
|
|
||||||
score: 1,
|
|
||||||
dropCandidate: true,
|
|
||||||
sfxPitch: 4,
|
|
||||||
img: '/client-assets/drop-and-fusion/heart_suit.png',
|
|
||||||
imgSize: 256,
|
|
||||||
spriteScale: 1.12,
|
|
||||||
}];
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
let viewScaleX = 1;
|
|
||||||
let viewScaleY = 1;
|
|
||||||
const currentPick = shallowRef<{ id: string; mono: Mono } | null>(null);
|
|
||||||
const stock = shallowRef<{ id: string; mono: Mono }[]>([]);
|
|
||||||
const score = ref(0);
|
|
||||||
const combo = ref(0);
|
|
||||||
const comboPrev = ref(0);
|
|
||||||
const maxCombo = ref(0);
|
|
||||||
const dropReady = ref(true);
|
|
||||||
const gameMode = ref<'normal' | 'square'>('normal');
|
const gameMode = ref<'normal' | 'square'>('normal');
|
||||||
const gameOver = ref(false);
|
|
||||||
const gameStarted = ref(false);
|
const gameStarted = ref(false);
|
||||||
const highScore = ref<number | null>(null);
|
const mute = ref(false);
|
||||||
const showConfig = ref(false);
|
|
||||||
const bgmVolume = ref(0.1);
|
|
||||||
|
|
||||||
let game: DropAndFusionGame;
|
|
||||||
let containerElRect: DOMRect | null = null;
|
|
||||||
|
|
||||||
function onClick(ev: MouseEvent) {
|
|
||||||
if (!containerElRect) return;
|
|
||||||
const x = (ev.clientX - containerElRect.left) / viewScaleX;
|
|
||||||
game.drop(x);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTouchend(ev: TouchEvent) {
|
|
||||||
if (!containerElRect) return;
|
|
||||||
const x = (ev.changedTouches[0].clientX - containerElRect.left) / viewScaleX;
|
|
||||||
game.drop(x);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMousemove(ev: MouseEvent) {
|
|
||||||
if (!containerElRect) return;
|
|
||||||
const x = (ev.clientX - containerElRect.left);
|
|
||||||
moveDropper(containerElRect, x);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTouchmove(ev: TouchEvent) {
|
|
||||||
if (!containerElRect) return;
|
|
||||||
const x = (ev.touches[0].clientX - containerElRect.left);
|
|
||||||
moveDropper(containerElRect, x);
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveDropper(rect: DOMRect, x: number) {
|
|
||||||
dropperX.value = Math.min(rect.width * ((GAME_WIDTH - game.PLAYAREA_MARGIN) / GAME_WIDTH), Math.max(rect.width * (game.PLAYAREA_MARGIN / GAME_WIDTH), x));
|
|
||||||
}
|
|
||||||
|
|
||||||
function restart() {
|
|
||||||
game.dispose();
|
|
||||||
gameOver.value = false;
|
|
||||||
currentPick.value = null;
|
|
||||||
dropReady.value = true;
|
|
||||||
stock.value = [];
|
|
||||||
score.value = 0;
|
|
||||||
combo.value = 0;
|
|
||||||
comboPrev.value = 0;
|
|
||||||
gameStarted.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachGameEvents() {
|
|
||||||
game.addListener('changeScore', value => {
|
|
||||||
score.value = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
game.addListener('changeCombo', value => {
|
|
||||||
if (value === 0) {
|
|
||||||
comboPrev.value = combo.value;
|
|
||||||
} else {
|
|
||||||
comboPrev.value = value;
|
|
||||||
}
|
|
||||||
maxCombo.value = Math.max(maxCombo.value, value);
|
|
||||||
combo.value = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
game.addListener('changeStock', value => {
|
|
||||||
currentPick.value = JSON.parse(JSON.stringify(value[0]));
|
|
||||||
stock.value = JSON.parse(JSON.stringify(value.slice(1)));
|
|
||||||
});
|
|
||||||
|
|
||||||
game.addListener('dropped', () => {
|
|
||||||
dropReady.value = false;
|
|
||||||
window.setTimeout(() => {
|
|
||||||
if (!gameOver.value) {
|
|
||||||
dropReady.value = true;
|
|
||||||
}
|
|
||||||
}, game.DROP_INTERVAL);
|
|
||||||
});
|
|
||||||
|
|
||||||
game.addListener('fusioned', (x, y, scoreDelta) => {
|
|
||||||
if (!canvasEl.value) return;
|
|
||||||
|
|
||||||
const rect = canvasEl.value.getBoundingClientRect();
|
|
||||||
const domX = rect.left + (x * viewScaleX);
|
|
||||||
const domY = rect.top + (y * viewScaleY);
|
|
||||||
os.popup(MkRippleEffect, { x: domX, y: domY }, {}, 'end');
|
|
||||||
os.popup(MkPlusOneEffect, { x: domX, y: domY, value: scoreDelta }, {}, 'end');
|
|
||||||
});
|
|
||||||
|
|
||||||
game.addListener('monoAdded', (mono) => {
|
|
||||||
// 実績関連
|
|
||||||
if (mono.level === 10) {
|
|
||||||
claimAchievement('bubbleGameExplodingHead');
|
|
||||||
|
|
||||||
const monos = game.getActiveMonos();
|
|
||||||
if (monos.filter(x => x.level === 10).length >= 2) {
|
|
||||||
claimAchievement('bubbleGameDoubleExplodingHead');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
game.addListener('gameOver', () => {
|
|
||||||
currentPick.value = null;
|
|
||||||
dropReady.value = false;
|
|
||||||
gameOver.value = true;
|
|
||||||
|
|
||||||
if (score.value > (highScore.value ?? 0)) {
|
|
||||||
highScore.value = score.value;
|
|
||||||
|
|
||||||
misskeyApi('i/registry/set', {
|
|
||||||
scope: ['dropAndFusionGame'],
|
|
||||||
key: 'highScore:' + gameMode.value,
|
|
||||||
value: highScore.value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let bgmNodes: ReturnType<typeof sound.createSourceNode> = null;
|
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
try {
|
|
||||||
highScore.value = await misskeyApi('i/registry/get', {
|
|
||||||
scope: ['dropAndFusionGame'],
|
|
||||||
key: 'highScore:' + gameMode.value,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
highScore.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
game = new DropAndFusionGame({
|
|
||||||
width: GAME_WIDTH,
|
|
||||||
height: GAME_HEIGHT,
|
|
||||||
canvas: canvasEl.value!,
|
|
||||||
...(
|
|
||||||
gameMode.value === 'normal' ? {
|
|
||||||
monoDefinitions: NORAML_MONOS,
|
|
||||||
} : {
|
|
||||||
monoDefinitions: SQUARE_MONOS,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
});
|
|
||||||
attachGameEvents();
|
|
||||||
os.promiseDialog(game.load(), async () => {
|
|
||||||
game.start();
|
|
||||||
gameStarted.value = true;
|
gameStarted.value = true;
|
||||||
|
|
||||||
if (bgmNodes) {
|
|
||||||
bgmNodes.soundSource.stop();
|
|
||||||
bgmNodes = null;
|
|
||||||
}
|
|
||||||
const bgmBuffer = await sound.loadAudio('/client-assets/drop-and-fusion/bgm_1.mp3');
|
|
||||||
if (!bgmBuffer) return;
|
|
||||||
bgmNodes = sound.createSourceNode(bgmBuffer, bgmVolume.value);
|
|
||||||
if (!bgmNodes) return;
|
|
||||||
bgmNodes.soundSource.loop = true;
|
|
||||||
bgmNodes.soundSource.start();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(bgmVolume, (value) => {
|
function onGameEnd() {
|
||||||
if (bgmNodes) {
|
gameStarted.value = false;
|
||||||
bgmNodes.gainNode.gain.value = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function getGameImageDriveFile() {
|
|
||||||
return new Promise<Misskey.entities.DriveFile | null>(res => {
|
|
||||||
const dcanvas = document.createElement('canvas');
|
|
||||||
dcanvas.width = GAME_WIDTH;
|
|
||||||
dcanvas.height = GAME_HEIGHT;
|
|
||||||
const ctx = dcanvas.getContext('2d');
|
|
||||||
if (!ctx || !canvasEl.value) return res(null);
|
|
||||||
const dimage = new Image();
|
|
||||||
dimage.src = '/client-assets/drop-and-fusion/frame-light.svg';
|
|
||||||
dimage.addEventListener('load', () => {
|
|
||||||
ctx.fillStyle = '#fff';
|
|
||||||
ctx.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
|
||||||
ctx.drawImage(dimage, 0, 0, GAME_WIDTH, GAME_HEIGHT);
|
|
||||||
ctx.drawImage(canvasEl.value!, 0, 0, GAME_WIDTH, GAME_HEIGHT);
|
|
||||||
|
|
||||||
dcanvas.toBlob(blob => {
|
|
||||||
if (!blob) return res(null);
|
|
||||||
if ($i == null) return res(null);
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', blob);
|
|
||||||
formData.append('name', `bubble-game-${Date.now()}.png`);
|
|
||||||
formData.append('isSensitive', 'false');
|
|
||||||
formData.append('comment', 'null');
|
|
||||||
formData.append('i', $i.token);
|
|
||||||
if (defaultStore.state.uploadFolder) {
|
|
||||||
formData.append('folderId', defaultStore.state.uploadFolder);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.fetch(apiUrl + '/drive/files/create', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(f => {
|
|
||||||
res(f);
|
|
||||||
});
|
|
||||||
}, 'image/png');
|
|
||||||
|
|
||||||
dcanvas.remove();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function share() {
|
|
||||||
const uploading = getGameImageDriveFile();
|
|
||||||
os.promiseDialog(uploading);
|
|
||||||
const file = await uploading;
|
|
||||||
if (!file) return;
|
|
||||||
os.post({
|
|
||||||
initialText: `#BubbleGame
|
|
||||||
MODE: ${gameMode.value}
|
|
||||||
SCORE: ${score.value} (MAX CHAIN: ${maxCombo.value})})`,
|
|
||||||
initialFiles: [file],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
containerElRect = containerEl.value?.getBoundingClientRect() ?? null;
|
|
||||||
}, 1000, { immediate: false, afterMounted: true });
|
|
||||||
|
|
||||||
onDeactivated(() => {
|
|
||||||
game.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
definePageMetadata({
|
definePageMetadata({
|
||||||
title: i18n.ts.bubbleGame,
|
title: i18n.ts.bubbleGame,
|
||||||
icon: 'ti ti-apple',
|
icon: 'ti ti-apple',
|
||||||
|
@ -635,50 +97,15 @@ definePageMetadata({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.transition_stock_move,
|
.transition_zoom_move,
|
||||||
.transition_stock_enterActive,
|
.transition_zoom_enterActive,
|
||||||
.transition_stock_leaveActive {
|
.transition_zoom_leaveActive {
|
||||||
transition: opacity 0.4s cubic-bezier(0,.5,.5,1), transform 0.4s cubic-bezier(0,.5,.5,1) !important;
|
|
||||||
}
|
|
||||||
.transition_stock_enterFrom,
|
|
||||||
.transition_stock_leaveTo {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.7);
|
|
||||||
}
|
|
||||||
.transition_stock_leaveActive {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transition_picked_move,
|
|
||||||
.transition_picked_enterActive {
|
|
||||||
transition: opacity 0.5s cubic-bezier(0,.5,.5,1), transform 0.5s cubic-bezier(0,.5,.5,1) !important;
|
transition: opacity 0.5s cubic-bezier(0,.5,.5,1), transform 0.5s cubic-bezier(0,.5,.5,1) !important;
|
||||||
}
|
}
|
||||||
.transition_picked_leaveActive {
|
.transition_zoom_enterFrom,
|
||||||
transition: all 0s !important;
|
.transition_zoom_leaveTo {
|
||||||
}
|
|
||||||
.transition_picked_enterFrom,
|
|
||||||
.transition_picked_leaveTo {
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-50px);
|
transform: scale(0.8);
|
||||||
}
|
|
||||||
.transition_picked_leaveActive {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transition_combo_move,
|
|
||||||
.transition_combo_enterActive {
|
|
||||||
transition: all 0s !important;
|
|
||||||
}
|
|
||||||
.transition_combo_leaveActive {
|
|
||||||
transition: opacity 0.4s cubic-bezier(0,.5,.5,1), transform 0.4s cubic-bezier(0,.5,.5,1) !important;
|
|
||||||
}
|
|
||||||
.transition_combo_enterFrom,
|
|
||||||
.transition_combo_leaveTo {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.7);
|
|
||||||
}
|
|
||||||
.transition_combo_leaveActive {
|
|
||||||
position: absolute;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
|
@ -697,129 +124,29 @@ definePageMetadata({
|
||||||
box-shadow: 0 6px 16px #0007, 0 0 1px 1px #693410, inset 0 0 2px 1px #ce8a5c;
|
box-shadow: 0 6px 16px #0007, 0 0 1px 1px #693410, inset 0 0 2px 1px #ce8a5c;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.frameH {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.frameInner {
|
.frameInner {
|
||||||
padding: 4px 8px;
|
padding: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
background: #F1E8DC;
|
background: #F1E8DC;
|
||||||
box-shadow: 0 0 2px 1px #ce8a5c, inset 0 0 1px 1px #693410;
|
box-shadow: 0 0 2px 1px #ce8a5c, inset 0 0 1px 1px #693410;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #693410;
|
color: #693410;
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
&:first-child {
|
||||||
position: relative;
|
margin-top: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.mainFrameImg {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
// なんかiOSでちらつく
|
|
||||||
//filter: drop-shadow(0 6px 16px #0007);
|
|
||||||
pointer-events: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas {
|
|
||||||
position: relative;
|
|
||||||
display: block;
|
|
||||||
z-index: 1;
|
|
||||||
margin-top: -50px;
|
|
||||||
width: 100% !important;
|
|
||||||
height: auto !important;
|
|
||||||
pointer-events: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock {
|
|
||||||
pointer-events: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.combo {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 3;
|
|
||||||
top: 50%;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: bold;
|
|
||||||
font-style: oblique;
|
|
||||||
color: #fff;
|
|
||||||
-webkit-text-stroke: 1px rgb(255, 145, 0);
|
|
||||||
text-shadow: 0 0 6px #0005;
|
|
||||||
pointer-events: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.currentMono {
|
|
||||||
position: absolute;
|
|
||||||
margin-top: 80px;
|
|
||||||
z-index: 2;
|
|
||||||
filter: drop-shadow(0 6px 16px #0007);
|
|
||||||
pointer-events: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropper {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
width: 70px;
|
|
||||||
margin-top: -10px;
|
|
||||||
margin-left: -30px;
|
|
||||||
z-index: 2;
|
|
||||||
filter: drop-shadow(0 6px 16px #0007);
|
|
||||||
pointer-events: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.currentMonoArrow {
|
|
||||||
position: absolute;
|
|
||||||
margin-top: 100px;
|
|
||||||
z-index: 3;
|
|
||||||
animation: currentMonoArrow 2s ease infinite;
|
|
||||||
pointer-events: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropGuide {
|
|
||||||
position: absolute;
|
|
||||||
top: 120px;
|
|
||||||
z-index: 3;
|
|
||||||
width: 3px;
|
|
||||||
height: calc(100% - 120px);
|
|
||||||
background: #f002;
|
|
||||||
pointer-events: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gameOverLabel {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 10;
|
|
||||||
top: 50%;
|
|
||||||
width: 100%;
|
|
||||||
padding: 16px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: #0007;
|
|
||||||
color: #fff;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gameOver {
|
|
||||||
.canvas {
|
|
||||||
filter: grayscale(1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes currentMonoArrow {
|
.frameDivider {
|
||||||
0% { transform: translateY(0); }
|
height: 0;
|
||||||
25% { transform: translateY(-8px); }
|
border: none;
|
||||||
50% { transform: translateY(0); }
|
border-top: 1px solid #693410;
|
||||||
75% { transform: translateY(-8px); }
|
border-bottom: 1px solid #ce8a5c;
|
||||||
100% { transform: translateY(0); }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -33,7 +33,7 @@ import MkRange from '@/components/MkRange.vue';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { misskeyApi } from '@/scripts/misskey-api.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';
|
import { selectFile } from '@/scripts/select-file.js';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
@ -119,7 +119,7 @@ function listen() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
playFile(type.value === '_driveFile_' ? {
|
playMisskeySfxFile(type.value === '_driveFile_' ? {
|
||||||
type: '_driveFile_',
|
type: '_driveFile_',
|
||||||
fileId: fileId.value as string,
|
fileId: fileId.value as string,
|
||||||
fileUrl: fileUrl.value as string,
|
fileUrl: fileUrl.value as string,
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { EventEmitter } from 'eventemitter3';
|
import { EventEmitter } from 'eventemitter3';
|
||||||
import * as Matter from 'matter-js';
|
import * as Matter from 'matter-js';
|
||||||
|
import seedrandom from 'seedrandom';
|
||||||
import * as sound from '@/scripts/sound.js';
|
import * as sound from '@/scripts/sound.js';
|
||||||
|
|
||||||
export type Mono = {
|
export type Mono = {
|
||||||
|
@ -20,33 +21,53 @@ export type Mono = {
|
||||||
spriteScale: number;
|
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<{
|
export class DropAndFusionGame extends EventEmitter<{
|
||||||
changeScore: (newScore: number) => void;
|
changeScore: (newScore: number) => void;
|
||||||
changeCombo: (newCombo: number) => void;
|
changeCombo: (newCombo: number) => void;
|
||||||
changeStock: (newStock: { id: string; mono: Mono }[]) => void;
|
changeStock: (newStock: { id: string; mono: Mono }[]) => void;
|
||||||
|
changeHolding: (newHolding: { id: string; mono: Mono } | null) => void;
|
||||||
dropped: () => void;
|
dropped: () => void;
|
||||||
fusioned: (x: number, y: number, scoreDelta: number) => void;
|
fusioned: (x: number, y: number, scoreDelta: number) => void;
|
||||||
monoAdded: (mono: Mono) => void;
|
monoAdded: (mono: Mono) => void;
|
||||||
gameOver: () => void;
|
gameOver: () => void;
|
||||||
}> {
|
}> {
|
||||||
private COMBO_INTERVAL = 1000;
|
private PHYSICS_QUALITY_FACTOR = 16; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる、逆に高すぎても何故か不安定になる
|
||||||
|
private COMBO_INTERVAL = 60; // frame
|
||||||
public readonly DROP_INTERVAL = 500;
|
public readonly DROP_INTERVAL = 500;
|
||||||
public readonly PLAYAREA_MARGIN = 25;
|
public readonly PLAYAREA_MARGIN = 25;
|
||||||
private STOCK_MAX = 4;
|
private STOCK_MAX = 4;
|
||||||
|
private TICK_DELTA = 1000 / 60; // 60fps
|
||||||
private loaded = false;
|
private loaded = false;
|
||||||
|
private frame = 0;
|
||||||
private engine: Matter.Engine;
|
private engine: Matter.Engine;
|
||||||
private render: Matter.Render;
|
private render: Matter.Render;
|
||||||
private runner: Matter.Runner;
|
private tickRaf: ReturnType<typeof requestAnimationFrame> | null = null;
|
||||||
|
private tickCallbackQueue: { frame: number; callback: () => void; }[] = [];
|
||||||
private overflowCollider: Matter.Body;
|
private overflowCollider: Matter.Body;
|
||||||
private isGameOver = false;
|
private isGameOver = false;
|
||||||
|
|
||||||
private gameWidth: number;
|
private gameWidth: number;
|
||||||
private gameHeight: number;
|
private gameHeight: number;
|
||||||
private monoDefinitions: Mono[] = [];
|
private monoDefinitions: Mono[] = [];
|
||||||
private monoTextures: Record<string, Blob> = {};
|
private monoTextures: Record<string, Blob> = {};
|
||||||
private monoTextureUrls: Record<string, string> = {};
|
private monoTextureUrls: Record<string, string> = {};
|
||||||
|
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 latestDroppedBodyId: Matter.Body['id'] | null = null;
|
||||||
|
|
||||||
private latestDroppedAt = 0;
|
private latestDroppedAt = 0;
|
||||||
private latestFusionedAt = 0;
|
private latestFusionedAt = 0; // frame
|
||||||
private stock: { id: string; mono: Mono }[] = [];
|
private stock: { id: string; mono: Mono }[] = [];
|
||||||
|
private holding: { id: string; mono: Mono } | null = null;
|
||||||
|
|
||||||
private _combo = 0;
|
private _combo = 0;
|
||||||
private get combo() {
|
private get combo() {
|
||||||
|
@ -79,22 +101,33 @@ export class DropAndFusionGame extends EventEmitter<{
|
||||||
|
|
||||||
private comboIntervalId: number | null = null;
|
private comboIntervalId: number | null = null;
|
||||||
|
|
||||||
|
public replayPlaybackRate = 1;
|
||||||
|
|
||||||
constructor(opts: {
|
constructor(opts: {
|
||||||
canvas: HTMLCanvasElement;
|
canvas: HTMLCanvasElement;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
monoDefinitions: Mono[];
|
monoDefinitions: Mono[];
|
||||||
|
seed: string;
|
||||||
|
sfxVolume?: number;
|
||||||
}) {
|
}) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
this.tick = this.tick.bind(this);
|
||||||
|
|
||||||
this.gameWidth = opts.width;
|
this.gameWidth = opts.width;
|
||||||
this.gameHeight = opts.height;
|
this.gameHeight = opts.height;
|
||||||
this.monoDefinitions = opts.monoDefinitions;
|
this.monoDefinitions = opts.monoDefinitions;
|
||||||
|
this.rng = seedrandom(opts.seed);
|
||||||
|
|
||||||
|
if (opts.sfxVolume) {
|
||||||
|
this.sfxVolume = opts.sfxVolume;
|
||||||
|
}
|
||||||
|
|
||||||
this.engine = Matter.Engine.create({
|
this.engine = Matter.Engine.create({
|
||||||
constraintIterations: 2 * PHYSICS_QUALITY_FACTOR,
|
constraintIterations: 2 * this.PHYSICS_QUALITY_FACTOR,
|
||||||
positionIterations: 6 * PHYSICS_QUALITY_FACTOR,
|
positionIterations: 6 * this.PHYSICS_QUALITY_FACTOR,
|
||||||
velocityIterations: 4 * PHYSICS_QUALITY_FACTOR,
|
velocityIterations: 4 * this.PHYSICS_QUALITY_FACTOR,
|
||||||
gravity: {
|
gravity: {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 1,
|
y: 1,
|
||||||
|
@ -121,13 +154,11 @@ export class DropAndFusionGame extends EventEmitter<{
|
||||||
|
|
||||||
Matter.Render.run(this.render);
|
Matter.Render.run(this.render);
|
||||||
|
|
||||||
this.runner = Matter.Runner.create();
|
|
||||||
Matter.Runner.run(this.runner, this.engine);
|
|
||||||
|
|
||||||
this.engine.world.bodies = [];
|
this.engine.world.bodies = [];
|
||||||
|
|
||||||
//#region walls
|
//#region walls
|
||||||
const WALL_OPTIONS: Matter.IChamferableBodyDefinition = {
|
const WALL_OPTIONS: Matter.IChamferableBodyDefinition = {
|
||||||
|
label: '_wall_',
|
||||||
isStatic: true,
|
isStatic: true,
|
||||||
friction: 0.7,
|
friction: 0.7,
|
||||||
slop: 1.0,
|
slop: 1.0,
|
||||||
|
@ -183,6 +214,7 @@ export class DropAndFusionGame extends EventEmitter<{
|
||||||
};
|
};
|
||||||
if (mono.shape === 'circle') {
|
if (mono.shape === 'circle') {
|
||||||
return Matter.Bodies.circle(x, y, mono.size / 2, options);
|
return Matter.Bodies.circle(x, y, mono.size / 2, options);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
} else if (mono.shape === 'rectangle') {
|
} else if (mono.shape === 'rectangle') {
|
||||||
return Matter.Bodies.rectangle(x, y, mono.size, mono.size, options);
|
return Matter.Bodies.rectangle(x, y, mono.size, mono.size, options);
|
||||||
} else {
|
} else {
|
||||||
|
@ -191,13 +223,12 @@ export class DropAndFusionGame extends EventEmitter<{
|
||||||
}
|
}
|
||||||
|
|
||||||
private fusion(bodyA: Matter.Body, bodyB: Matter.Body) {
|
private fusion(bodyA: Matter.Body, bodyB: Matter.Body) {
|
||||||
const now = Date.now();
|
if (this.latestFusionedAt > this.frame - this.COMBO_INTERVAL) {
|
||||||
if (this.latestFusionedAt > now - this.COMBO_INTERVAL) {
|
|
||||||
this.combo++;
|
this.combo++;
|
||||||
} else {
|
} else {
|
||||||
this.combo = 1;
|
this.combo = 1;
|
||||||
}
|
}
|
||||||
this.latestFusionedAt = now;
|
this.latestFusionedAt = this.frame;
|
||||||
|
|
||||||
// TODO: 単に位置だけでなくそれぞれの動きベクトルも融合する?
|
// TODO: 単に位置だけでなくそれぞれの動きベクトルも融合する?
|
||||||
const newX = (bodyA.position.x + bodyB.position.x) / 2;
|
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);
|
Matter.Composite.add(this.engine.world, body);
|
||||||
|
|
||||||
// 連鎖してfusionした場合の分かりやすさのため少し間を置いてからfusion対象になるようにする
|
// 連鎖してfusionした場合の分かりやすさのため少し間を置いてからfusion対象になるようにする
|
||||||
window.setTimeout(() => {
|
this.tickCallbackQueue.push({
|
||||||
|
frame: this.frame + 6,
|
||||||
|
callback: () => {
|
||||||
this.activeBodyIds.push(body.id);
|
this.activeBodyIds.push(body.id);
|
||||||
}, 100);
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const comboBonus = 1 + ((this.combo - 1) / 5);
|
const comboBonus = 1 + ((this.combo - 1) / 5);
|
||||||
const additionalScore = Math.round(currentMono.score * comboBonus);
|
const additionalScore = Math.round(currentMono.score * comboBonus);
|
||||||
this.score += additionalScore;
|
this.score += additionalScore;
|
||||||
|
|
||||||
// TODO: 効果音再生はコンポーネント側の責務なので移動する
|
// TODO: 効果音再生はコンポーネント側の責務なので移動するべき?
|
||||||
const pan = ((newX / this.gameWidth) - 0.5) * 2;
|
const panV = newX - this.PLAYAREA_MARGIN;
|
||||||
sound.playUrl('/client-assets/drop-and-fusion/bubble2.mp3', 1, pan, nextMono.sfxPitch);
|
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('monoAdded', nextMono);
|
||||||
this.emit('fusioned', newX, newY, additionalScore);
|
this.emit('fusioned', newX, newY, additionalScore);
|
||||||
} else {
|
} else {
|
||||||
//const VELOCITY = 30;
|
//const VELOCITY = 30;
|
||||||
//for (let i = 0; i < 10; i++) {
|
//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);
|
// Matter.Composite.add(world, body);
|
||||||
// bodies.push(body);
|
// bodies.push(body);
|
||||||
//}
|
//}
|
||||||
//sound.playUrl({
|
//sound.playUrl({
|
||||||
// type: 'syuilo/bubble2',
|
// type: 'syuilo/bubble2',
|
||||||
// volume: 1,
|
// volume: this.sfxVolume,
|
||||||
//});
|
//});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public surrender() {
|
||||||
|
this.logs.push({
|
||||||
|
frame: this.frame,
|
||||||
|
operation: 'surrender',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.gameOver();
|
||||||
|
}
|
||||||
|
|
||||||
private gameOver() {
|
private gameOver() {
|
||||||
this.isGameOver = true;
|
this.isGameOver = true;
|
||||||
Matter.Runner.stop(this.runner);
|
if (this.tickRaf) window.cancelAnimationFrame(this.tickRaf);
|
||||||
|
this.tickRaf = null;
|
||||||
this.emit('gameOver');
|
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) {
|
async function loadSingleMonoTexture(mono: Mono, game: DropAndFusionGame) {
|
||||||
// Matter-js内にキャッシュがある場合はスキップ
|
// Matter-js内にキャッシュがある場合はスキップ
|
||||||
if (game.render.textures[mono.img]) return;
|
if (game.render.textures[mono.img]) return;
|
||||||
console.log('loading', mono.img);
|
|
||||||
|
|
||||||
let src = mono.img;
|
let src = mono.img;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// 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)));
|
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 (!this.loaded) throw new Error('game is not loaded yet');
|
||||||
|
if (logs) this.replaying = true;
|
||||||
|
|
||||||
for (let i = 0; i < this.STOCK_MAX; i++) {
|
for (let i = 0; i < this.STOCK_MAX; i++) {
|
||||||
this.stock.push({
|
this.stock.push({
|
||||||
id: Math.random().toString(),
|
id: this.rng().toString(),
|
||||||
mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
|
mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(this.rng() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.emit('changeStock', this.stock);
|
this.emit('changeStock', this.stock);
|
||||||
|
@ -314,29 +369,106 @@ export class DropAndFusionGame extends EventEmitter<{
|
||||||
this.fusion(bodyA, bodyB);
|
this.fusion(bodyA, bodyB);
|
||||||
} else {
|
} else {
|
||||||
fusionReservedPairs.push({ bodyA, bodyB });
|
fusionReservedPairs.push({ bodyA, bodyB });
|
||||||
window.setTimeout(() => {
|
this.tickCallbackQueue.push({
|
||||||
|
frame: this.frame + 6,
|
||||||
|
callback: () => {
|
||||||
fusionReservedPairs = fusionReservedPairs.filter(x => x.bodyA.id !== bodyA.id && x.bodyB.id !== bodyB.id);
|
fusionReservedPairs = fusionReservedPairs.filter(x => x.bodyA.id !== bodyA.id && x.bodyB.id !== bodyB.id);
|
||||||
this.fusion(bodyA, bodyB);
|
this.fusion(bodyA, bodyB);
|
||||||
}, 100);
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const energy = pairs.collision.depth;
|
const energy = pairs.collision.depth;
|
||||||
if (energy > minCollisionEnergyForSound) {
|
if (energy > minCollisionEnergyForSound) {
|
||||||
// TODO: 効果音再生はコンポーネント側の責務なので移動する
|
// TODO: 効果音再生はコンポーネント側の責務なので移動するべき?
|
||||||
const vol = (Math.min(maxCollisionEnergyForSound, energy - minCollisionEnergyForSound) / maxCollisionEnergyForSound) / 4;
|
const vol = ((Math.min(maxCollisionEnergyForSound, energy - minCollisionEnergyForSound) / maxCollisionEnergyForSound) / 4) * this.sfxVolume;
|
||||||
const pan = ((((bodyA.position.x + bodyB.position.x) / 2) / this.gameWidth) - 0.5) * 2;
|
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)));
|
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 (logs) {
|
||||||
if (this.latestFusionedAt < Date.now() - this.COMBO_INTERVAL) {
|
const playTick = () => {
|
||||||
|
for (let i = 0; i < this.replayPlaybackRate; i++) {
|
||||||
|
this.frame++;
|
||||||
|
if (this.latestFusionedAt < this.frame - this.COMBO_INTERVAL) {
|
||||||
this.combo = 0;
|
this.combo = 0;
|
||||||
}
|
}
|
||||||
}, 500);
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Matter.Engine.update(this.engine, this.TICK_DELTA);
|
||||||
|
if (!this.isGameOver) {
|
||||||
|
this.tickRaf = window.requestAnimationFrame(this.tick);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async load() {
|
public async load() {
|
||||||
|
@ -344,6 +476,10 @@ export class DropAndFusionGame extends EventEmitter<{
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setSfxVolume(volume: number) {
|
||||||
|
this.sfxVolume = volume;
|
||||||
|
}
|
||||||
|
|
||||||
public getTextureImageUrl(mono: Mono) {
|
public getTextureImageUrl(mono: Mono) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
if (this.monoTextureUrls[mono.img]) {
|
if (this.monoTextureUrls[mono.img]) {
|
||||||
|
@ -366,34 +502,76 @@ export class DropAndFusionGame extends EventEmitter<{
|
||||||
|
|
||||||
public drop(_x: number) {
|
public drop(_x: number) {
|
||||||
if (this.isGameOver) return;
|
if (this.isGameOver) return;
|
||||||
if (Date.now() - this.latestDroppedAt < this.DROP_INTERVAL) {
|
if (!this.replaying && (Date.now() - this.latestDroppedAt < this.DROP_INTERVAL)) return;
|
||||||
return;
|
|
||||||
}
|
const head = this.stock.shift()!;
|
||||||
const st = this.stock.shift()!;
|
|
||||||
this.stock.push({
|
this.stock.push({
|
||||||
id: Math.random().toString(),
|
id: this.rng().toString(),
|
||||||
mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
|
mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(this.rng() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
|
||||||
});
|
});
|
||||||
this.emit('changeStock', this.stock);
|
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 inputX = Math.round(_x);
|
||||||
const body = this.createBody(st.mono, x, 50 + st.mono.size / 2);
|
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);
|
Matter.Composite.add(this.engine.world, body);
|
||||||
this.activeBodyIds.push(body.id);
|
this.activeBodyIds.push(body.id);
|
||||||
this.latestDroppedBodyId = body.id;
|
this.latestDroppedBodyId = body.id;
|
||||||
this.latestDroppedAt = Date.now();
|
this.latestDroppedAt = Date.now();
|
||||||
this.emit('dropped');
|
this.emit('dropped');
|
||||||
this.emit('monoAdded', st.mono);
|
this.emit('monoAdded', head.mono);
|
||||||
|
|
||||||
// TODO: 効果音再生はコンポーネント側の責務なので移動する
|
// TODO: 効果音再生はコンポーネント側の責務なので移動するべき?
|
||||||
const pan = ((x / this.gameWidth) - 0.5) * 2;
|
const panV = x - this.PLAYAREA_MARGIN;
|
||||||
sound.playUrl('/client-assets/drop-and-fusion/poi2.mp3', 1, pan);
|
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() {
|
public dispose() {
|
||||||
if (this.comboIntervalId) window.clearInterval(this.comboIntervalId);
|
if (this.comboIntervalId) window.clearInterval(this.comboIntervalId);
|
||||||
|
if (this.tickRaf) window.cancelAnimationFrame(this.tickRaf);
|
||||||
|
this.tickRaf = null;
|
||||||
Matter.Render.stop(this.render);
|
Matter.Render.stop(this.render);
|
||||||
Matter.Runner.stop(this.runner);
|
|
||||||
Matter.World.clear(this.engine.world, false);
|
Matter.World.clear(this.engine.world, false);
|
||||||
Matter.Engine.clear(this.engine);
|
Matter.Engine.clear(this.engine);
|
||||||
}
|
}
|
||||||
|
|
|
@ -99,7 +99,6 @@ export async function loadAudio(url: string, options?: { useCache?: boolean; })
|
||||||
}
|
}
|
||||||
if (options?.useCache ?? true) {
|
if (options?.useCache ?? true) {
|
||||||
if (cache.has(url)) {
|
if (cache.has(url)) {
|
||||||
if (_DEV_) console.log('use cache');
|
|
||||||
return cache.get(url) as AudioBuffer;
|
return cache.get(url) as AudioBuffer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -126,13 +125,12 @@ export async function loadAudio(url: string, options?: { useCache?: boolean; })
|
||||||
* 既定のスプライトを再生する
|
* 既定のスプライトを再生する
|
||||||
* @param type スプライトの種類を指定
|
* @param type スプライトの種類を指定
|
||||||
*/
|
*/
|
||||||
export function play(operationType: OperationType) {
|
export function playMisskeySfx(operationType: OperationType) {
|
||||||
const sound = defaultStore.state[`sound_${operationType}`];
|
const sound = defaultStore.state[`sound_${operationType}`];
|
||||||
if (_DEV_) console.log('play', operationType, sound);
|
|
||||||
if (sound.type == null || !canPlay) return;
|
if (sound.type == null || !canPlay) return;
|
||||||
|
|
||||||
canPlay = false;
|
canPlay = false;
|
||||||
playFile(sound).finally(() => {
|
playMisskeySfxFile(sound).finally(() => {
|
||||||
// ごく短時間に音が重複しないように
|
// ごく短時間に音が重複しないように
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
canPlay = true;
|
canPlay = true;
|
||||||
|
@ -144,41 +142,53 @@ export function play(operationType: OperationType) {
|
||||||
* サウンド設定形式で指定された音声を再生する
|
* サウンド設定形式で指定された音声を再生する
|
||||||
* @param soundStore サウンド設定
|
* @param soundStore サウンド設定
|
||||||
*/
|
*/
|
||||||
export async function playFile(soundStore: SoundStore) {
|
export async function playMisskeySfxFile(soundStore: SoundStore) {
|
||||||
if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) {
|
if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) {
|
||||||
return;
|
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 url = soundStore.type === '_driveFile_' ? soundStore.fileUrl : `/client-assets/sounds/${soundStore.type}.mp3`;
|
||||||
const buffer = await loadAudio(url);
|
const buffer = await loadAudio(url);
|
||||||
if (!buffer) return;
|
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);
|
const buffer = await loadAudio(url);
|
||||||
if (!buffer) return;
|
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;
|
soundSource: AudioBufferSourceNode;
|
||||||
panNode: StereoPannerNode;
|
panNode: StereoPannerNode;
|
||||||
gainNode: GainNode;
|
gainNode: GainNode;
|
||||||
} | null {
|
} {
|
||||||
const masterVolume = defaultStore.state.sound_masterVolume;
|
|
||||||
if (isMute() || masterVolume === 0 || volume === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const panNode = ctx.createStereoPanner();
|
const panNode = ctx.createStereoPanner();
|
||||||
panNode.pan.value = pan;
|
panNode.pan.value = opts.pan ?? 0;
|
||||||
|
|
||||||
const gainNode = ctx.createGain();
|
const gainNode = ctx.createGain();
|
||||||
gainNode.gain.value = masterVolume * volume;
|
|
||||||
|
gainNode.gain.value = opts.volume ?? 1;
|
||||||
|
|
||||||
const soundSource = ctx.createBufferSource();
|
const soundSource = ctx.createBufferSource();
|
||||||
soundSource.buffer = buffer;
|
soundSource.buffer = buffer;
|
||||||
soundSource.playbackRate.value = playbackRate;
|
soundSource.playbackRate.value = opts.playbackRate ?? 1;
|
||||||
soundSource
|
soundSource
|
||||||
.connect(panNode)
|
.connect(panNode)
|
||||||
.connect(gainNode)
|
.connect(gainNode)
|
||||||
|
|
|
@ -420,6 +420,13 @@ export const defaultStore = markRaw(new Storage('base', {
|
||||||
where: 'device',
|
where: 'device',
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
dropAndFusion: {
|
||||||
|
where: 'device',
|
||||||
|
default: {
|
||||||
|
bgmVolume: 0.25,
|
||||||
|
sfxVolume: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
sound_masterVolume: {
|
sound_masterVolume: {
|
||||||
where: 'device',
|
where: 'device',
|
||||||
|
|
|
@ -83,7 +83,7 @@ function onNotification(notification: Misskey.entities.Notification, isClient =
|
||||||
}, 6000);
|
}, 6000);
|
||||||
}
|
}
|
||||||
|
|
||||||
sound.play('notification');
|
sound.playMisskeySfx('notification');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($i) {
|
if ($i) {
|
||||||
|
|
|
@ -123,7 +123,7 @@ const onStats = (stats) => {
|
||||||
current[domain].delayed = stats[domain].delayed;
|
current[domain].delayed = stats[domain].delayed;
|
||||||
|
|
||||||
if (current[domain].waiting > 0 && widgetProps.sound && jammedAudioBuffer.value && !jammedSoundNodePlaying.value) {
|
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) {
|
if (soundNode) {
|
||||||
jammedSoundNodePlaying.value = true;
|
jammedSoundNodePlaying.value = true;
|
||||||
soundNode.onended = () => jammedSoundNodePlaying.value = false;
|
soundNode.onended = () => jammedSoundNodePlaying.value = false;
|
||||||
|
|
|
@ -686,8 +686,8 @@ importers:
|
||||||
specifier: 5.1.0
|
specifier: 5.1.0
|
||||||
version: 5.1.0(rollup@4.9.1)
|
version: 5.1.0(rollup@4.9.1)
|
||||||
'@syuilo/aiscript':
|
'@syuilo/aiscript':
|
||||||
specifier: 0.16.0
|
specifier: 0.17.0
|
||||||
version: 0.16.0
|
version: 0.17.0
|
||||||
'@tabler/icons-webfont':
|
'@tabler/icons-webfont':
|
||||||
specifier: 2.44.0
|
specifier: 2.44.0
|
||||||
version: 2.44.0
|
version: 2.44.0
|
||||||
|
@ -787,6 +787,9 @@ importers:
|
||||||
sass:
|
sass:
|
||||||
specifier: 1.69.5
|
specifier: 1.69.5
|
||||||
version: 1.69.5
|
version: 1.69.5
|
||||||
|
seedrandom:
|
||||||
|
specifier: ^3.0.5
|
||||||
|
version: 3.0.5
|
||||||
shiki:
|
shiki:
|
||||||
specifier: 0.14.7
|
specifier: 0.14.7
|
||||||
version: 0.14.7
|
version: 0.14.7
|
||||||
|
@ -7401,7 +7404,7 @@ packages:
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@swc/core': ^1.2.66
|
'@swc/core': ^1.2.66
|
||||||
chokidar: ^3.5.1
|
chokidar: 3.5.3
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
chokidar:
|
chokidar:
|
||||||
optional: true
|
optional: true
|
||||||
|
@ -7646,8 +7649,8 @@ packages:
|
||||||
dev: false
|
dev: false
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/@syuilo/aiscript@0.16.0:
|
/@syuilo/aiscript@0.17.0:
|
||||||
resolution: {integrity: sha512-CXvoWOq6kmOSUQtKv0IEf7Ebfkk5PO1LxAgLqgRRPgssPvDvINCXu/gFNXKdapkFMkmX+Gj8qjemKR1vnUS4ZA==}
|
resolution: {integrity: sha512-3JtQ1rWJHMxQ3153zLCXMUOwrOgjPPYGBl0dPHhR0ohm4tn7okMQRugxMCT0t3YxByemb9FfiM6TUjd0tEGxdA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
seedrandom: 3.0.5
|
seedrandom: 3.0.5
|
||||||
stringz: 2.1.0
|
stringz: 2.1.0
|
||||||
|
|
Loading…
Reference in New Issue