mirror of
https://git.joinsharkey.org/Sharkey/Sharkey.git
synced 2024-11-24 21:33:08 +02:00
Compare commits
10 commits
d974b30e56
...
91969623bd
Author | SHA1 | Date | |
---|---|---|---|
|
91969623bd | ||
|
f5b864df7b | ||
|
3877b74858 | ||
|
f3aeb4c351 | ||
|
e0e5236864 | ||
|
e74cd2f6ee | ||
|
9ceb74b98d | ||
|
7e52ea4818 | ||
|
358dc6289b | ||
|
1063d39de8 |
23 changed files with 407 additions and 50 deletions
|
@ -8,9 +8,11 @@
|
||||||
- Feat: 新しいゲームを追加
|
- Feat: 新しいゲームを追加
|
||||||
- Enhance: ハッシュタグ入力時に、本文の末尾の行に何も書かれていない場合は新たにスペースを追加しないように
|
- Enhance: ハッシュタグ入力時に、本文の末尾の行に何も書かれていない場合は新たにスペースを追加しないように
|
||||||
- Enhance: チャンネルノートのピン留めをノートのメニューからできるように
|
- Enhance: チャンネルノートのピン留めをノートのメニューからできるように
|
||||||
|
- Enhance: 管理者の場合はAPI tokenの発行画面で管理機能に関する権限を付与できるように
|
||||||
- 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: 連合先のレートリミットに引っかかった際にリトライするようになりました
|
||||||
|
|
|
@ -973,6 +973,7 @@ neverShow: "Nicht wieder anzeigen"
|
||||||
remindMeLater: "Vielleicht später"
|
remindMeLater: "Vielleicht später"
|
||||||
didYouLikeMisskey: "Gefällt dir Sharkey?"
|
didYouLikeMisskey: "Gefällt dir Sharkey?"
|
||||||
pleaseDonate: "Sharkey ist die kostenlose Software, die von {host} verwendet wird. Wir würden uns über Spenden freuen, damit dessen Entwicklung weitergeführt werden kann!"
|
pleaseDonate: "Sharkey ist die kostenlose Software, die von {host} verwendet wird. Wir würden uns über Spenden freuen, damit dessen Entwicklung weitergeführt werden kann!"
|
||||||
|
pleaseDonateInstance: "Du kannst {host} auch direkt unterstützen, indem du an deine Instanz Administration spendest."
|
||||||
roles: "Rollen"
|
roles: "Rollen"
|
||||||
role: "Rolle"
|
role: "Rolle"
|
||||||
noRole: "Rolle nicht gefunden"
|
noRole: "Rolle nicht gefunden"
|
||||||
|
@ -1150,6 +1151,8 @@ impressumDescription: "In manchen Ländern, wie Deutschland und dessen Umgebung,
|
||||||
privacyPolicy: "Datenschutzerklärung"
|
privacyPolicy: "Datenschutzerklärung"
|
||||||
privacyPolicyUrl: "Datenschutzerklärungs-URL"
|
privacyPolicyUrl: "Datenschutzerklärungs-URL"
|
||||||
tosAndPrivacyPolicy: "Nutzungsbedingungen und Datenschutzerklärung"
|
tosAndPrivacyPolicy: "Nutzungsbedingungen und Datenschutzerklärung"
|
||||||
|
donation: "Spenden"
|
||||||
|
donationUrl: "Spenden-URL"
|
||||||
avatarDecorations: "Profilbilddekoration"
|
avatarDecorations: "Profilbilddekoration"
|
||||||
attach: "Anbringen"
|
attach: "Anbringen"
|
||||||
detach: "Entfernen"
|
detach: "Entfernen"
|
||||||
|
|
|
@ -1014,6 +1014,7 @@ neverShow: "Don't show again"
|
||||||
remindMeLater: "Maybe later"
|
remindMeLater: "Maybe later"
|
||||||
didYouLikeMisskey: "Have you taken a liking to Sharkey?"
|
didYouLikeMisskey: "Have you taken a liking to Sharkey?"
|
||||||
pleaseDonate: "{host} uses the free software, Sharkey. We would highly appreciate your donations so development of Sharkey can continue!"
|
pleaseDonate: "{host} uses the free software, Sharkey. We would highly appreciate your donations so development of Sharkey can continue!"
|
||||||
|
pleaseDonateInstance: "You can also support {host} directly by donating to your instance administration."
|
||||||
roles: "Roles"
|
roles: "Roles"
|
||||||
role: "Role"
|
role: "Role"
|
||||||
noRole: "Role not found"
|
noRole: "Role not found"
|
||||||
|
@ -1200,6 +1201,8 @@ impressumDescription: "In some countries, like germany, the inclusion of operato
|
||||||
privacyPolicy: "Privacy Policy"
|
privacyPolicy: "Privacy Policy"
|
||||||
privacyPolicyUrl: "Privacy Policy URL"
|
privacyPolicyUrl: "Privacy Policy URL"
|
||||||
tosAndPrivacyPolicy: "Terms of Service and Privacy Policy"
|
tosAndPrivacyPolicy: "Terms of Service and Privacy Policy"
|
||||||
|
donation: "Donate"
|
||||||
|
donationUrl: "Donation URL"
|
||||||
avatarDecorations: "Avatar decorations"
|
avatarDecorations: "Avatar decorations"
|
||||||
attach: "Attach"
|
attach: "Attach"
|
||||||
detach: "Remove"
|
detach: "Remove"
|
||||||
|
|
7
locales/index.d.ts
vendored
7
locales/index.d.ts
vendored
|
@ -648,6 +648,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;
|
||||||
|
@ -1023,6 +1024,7 @@ export interface Locale {
|
||||||
"remindMeLater": string;
|
"remindMeLater": string;
|
||||||
"didYouLikeMisskey": string;
|
"didYouLikeMisskey": string;
|
||||||
"pleaseDonate": string;
|
"pleaseDonate": string;
|
||||||
|
"pleaseDonateInstance": string;
|
||||||
"roles": string;
|
"roles": string;
|
||||||
"role": string;
|
"role": string;
|
||||||
"noRole": string;
|
"noRole": string;
|
||||||
|
@ -1209,6 +1211,8 @@ export interface Locale {
|
||||||
"privacyPolicy": string;
|
"privacyPolicy": string;
|
||||||
"privacyPolicyUrl": string;
|
"privacyPolicyUrl": string;
|
||||||
"tosAndPrivacyPolicy": string;
|
"tosAndPrivacyPolicy": string;
|
||||||
|
"donation": string;
|
||||||
|
"donationUrl": string;
|
||||||
"avatarDecorations": string;
|
"avatarDecorations": string;
|
||||||
"attach": string;
|
"attach": string;
|
||||||
"detach": string;
|
"detach": string;
|
||||||
|
@ -1235,6 +1239,9 @@ export interface Locale {
|
||||||
"bubbleGame": string;
|
"bubbleGame": string;
|
||||||
"sfx": string;
|
"sfx": string;
|
||||||
"soundWillBePlayed": string;
|
"soundWillBePlayed": string;
|
||||||
|
"showReplay": string;
|
||||||
|
"replay": string;
|
||||||
|
"replaying": string;
|
||||||
"_announcement": {
|
"_announcement": {
|
||||||
"forExistingUsers": string;
|
"forExistingUsers": string;
|
||||||
"forExistingUsersDescription": string;
|
"forExistingUsersDescription": string;
|
||||||
|
|
|
@ -645,6 +645,7 @@ medium: "中"
|
||||||
small: "小"
|
small: "小"
|
||||||
generateAccessToken: "アクセストークンの発行"
|
generateAccessToken: "アクセストークンの発行"
|
||||||
permission: "権限"
|
permission: "権限"
|
||||||
|
adminPermission: "管理者権限"
|
||||||
enableAll: "全て有効にする"
|
enableAll: "全て有効にする"
|
||||||
disableAll: "全て無効にする"
|
disableAll: "全て無効にする"
|
||||||
tokenRequested: "アカウントへのアクセス許可"
|
tokenRequested: "アカウントへのアクセス許可"
|
||||||
|
@ -1020,6 +1021,7 @@ neverShow: "今後表示しない"
|
||||||
remindMeLater: "また後で"
|
remindMeLater: "また後で"
|
||||||
didYouLikeMisskey: "Sharkeyを気に入っていただけましたか?"
|
didYouLikeMisskey: "Sharkeyを気に入っていただけましたか?"
|
||||||
pleaseDonate: "Sharkeyは{host}が使用している無料のソフトウェアです。これからも開発を続けられるように、ぜひ寄付をお願いします!"
|
pleaseDonate: "Sharkeyは{host}が使用している無料のソフトウェアです。これからも開発を続けられるように、ぜひ寄付をお願いします!"
|
||||||
|
pleaseDonateInstance: "インスタンス管理への寄付によって{host}を直接サポートすることもできます。"
|
||||||
roles: "ロール"
|
roles: "ロール"
|
||||||
role: "ロール"
|
role: "ロール"
|
||||||
noRole: "ロールはありません"
|
noRole: "ロールはありません"
|
||||||
|
@ -1206,6 +1208,8 @@ impressumDescription: "ドイツなどの一部の国と地域では表示が義
|
||||||
privacyPolicy: "プライバシーポリシー"
|
privacyPolicy: "プライバシーポリシー"
|
||||||
privacyPolicyUrl: "プライバシーポリシーURL"
|
privacyPolicyUrl: "プライバシーポリシーURL"
|
||||||
tosAndPrivacyPolicy: "利用規約・プライバシーポリシー"
|
tosAndPrivacyPolicy: "利用規約・プライバシーポリシー"
|
||||||
|
donation: "寄付する"
|
||||||
|
donationUrl: "寄付URL"
|
||||||
avatarDecorations: "アイコンデコレーション"
|
avatarDecorations: "アイコンデコレーション"
|
||||||
attach: "付ける"
|
attach: "付ける"
|
||||||
detach: "外す"
|
detach: "外す"
|
||||||
|
@ -1232,6 +1236,9 @@ enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する"
|
||||||
bubbleGame: "バブルゲーム"
|
bubbleGame: "バブルゲーム"
|
||||||
sfx: "効果音"
|
sfx: "効果音"
|
||||||
soundWillBePlayed: "サウンドが再生されます"
|
soundWillBePlayed: "サウンドが再生されます"
|
||||||
|
showReplay: "リプレイを見る"
|
||||||
|
replay: "リプレイ"
|
||||||
|
replaying: "リプレイ中"
|
||||||
|
|
||||||
_announcement:
|
_announcement:
|
||||||
forExistingUsers: "既存ユーザーのみ"
|
forExistingUsers: "既存ユーザーのみ"
|
||||||
|
|
10
packages/backend/migration/1704744370000-add-donation-url.js
Normal file
10
packages/backend/migration/1704744370000-add-donation-url.js
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
export class AddDonationUrl1704744370000 {
|
||||||
|
name = 'AddDonationUrl1704744370000'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" ADD "donationUrl" character varying(1024)`);
|
||||||
|
}
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "donationUrl"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -385,6 +385,12 @@ export class MiMeta {
|
||||||
})
|
})
|
||||||
public privacyPolicyUrl: string | null;
|
public privacyPolicyUrl: string | null;
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
length: 1024,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
public donationUrl: string | null;
|
||||||
|
|
||||||
@Column('varchar', {
|
@Column('varchar', {
|
||||||
length: 8192,
|
length: 8192,
|
||||||
nullable: true,
|
nullable: true,
|
||||||
|
|
|
@ -108,6 +108,7 @@ export class NodeinfoServerService {
|
||||||
tosUrl: meta.termsOfServiceUrl,
|
tosUrl: meta.termsOfServiceUrl,
|
||||||
privacyPolicyUrl: meta.privacyPolicyUrl,
|
privacyPolicyUrl: meta.privacyPolicyUrl,
|
||||||
impressumUrl: meta.impressumUrl,
|
impressumUrl: meta.impressumUrl,
|
||||||
|
donationUrl: meta.donationUrl,
|
||||||
repositoryUrl: meta.repositoryUrl,
|
repositoryUrl: meta.repositoryUrl,
|
||||||
feedbackUrl: meta.feedbackUrl,
|
feedbackUrl: meta.feedbackUrl,
|
||||||
disableRegistration: meta.disableRegistration,
|
disableRegistration: meta.disableRegistration,
|
||||||
|
|
|
@ -415,6 +415,10 @@ export const meta = {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
optional: false, nullable: true,
|
optional: false, nullable: true,
|
||||||
},
|
},
|
||||||
|
donationUrl: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
maintainerEmail: {
|
maintainerEmail: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
optional: false, nullable: true,
|
optional: false, nullable: true,
|
||||||
|
@ -498,6 +502,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
repositoryUrl: instance.repositoryUrl,
|
repositoryUrl: instance.repositoryUrl,
|
||||||
feedbackUrl: instance.feedbackUrl,
|
feedbackUrl: instance.feedbackUrl,
|
||||||
impressumUrl: instance.impressumUrl,
|
impressumUrl: instance.impressumUrl,
|
||||||
|
donationUrl: instance.donationUrl,
|
||||||
privacyPolicyUrl: instance.privacyPolicyUrl,
|
privacyPolicyUrl: instance.privacyPolicyUrl,
|
||||||
disableRegistration: instance.disableRegistration,
|
disableRegistration: instance.disableRegistration,
|
||||||
emailRequiredForSignup: instance.emailRequiredForSignup,
|
emailRequiredForSignup: instance.emailRequiredForSignup,
|
||||||
|
|
|
@ -105,6 +105,7 @@ export const paramDef = {
|
||||||
repositoryUrl: { type: 'string' },
|
repositoryUrl: { type: 'string' },
|
||||||
feedbackUrl: { type: 'string' },
|
feedbackUrl: { type: 'string' },
|
||||||
impressumUrl: { type: 'string', nullable: true },
|
impressumUrl: { type: 'string', nullable: true },
|
||||||
|
donationUrl: { type: 'string', nullable: true },
|
||||||
privacyPolicyUrl: { type: 'string', nullable: true },
|
privacyPolicyUrl: { type: 'string', nullable: true },
|
||||||
useObjectStorage: { type: 'boolean' },
|
useObjectStorage: { type: 'boolean' },
|
||||||
objectStorageBaseUrl: { type: 'string', nullable: true },
|
objectStorageBaseUrl: { type: 'string', nullable: true },
|
||||||
|
@ -406,6 +407,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
set.impressumUrl = ps.impressumUrl;
|
set.impressumUrl = ps.impressumUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ps.donationUrl !== undefined) {
|
||||||
|
set.donationUrl = ps.donationUrl;
|
||||||
|
}
|
||||||
|
|
||||||
if (ps.privacyPolicyUrl !== undefined) {
|
if (ps.privacyPolicyUrl !== undefined) {
|
||||||
set.privacyPolicyUrl = ps.privacyPolicyUrl;
|
set.privacyPolicyUrl = ps.privacyPolicyUrl;
|
||||||
}
|
}
|
||||||
|
|
|
@ -291,6 +291,10 @@ export const meta = {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
optional: false, nullable: true,
|
optional: false, nullable: true,
|
||||||
},
|
},
|
||||||
|
donationUrl: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
logoImageUrl: {
|
logoImageUrl: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
optional: false, nullable: true,
|
optional: false, nullable: true,
|
||||||
|
@ -365,6 +369,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
repositoryUrl: instance.repositoryUrl,
|
repositoryUrl: instance.repositoryUrl,
|
||||||
feedbackUrl: instance.feedbackUrl,
|
feedbackUrl: instance.feedbackUrl,
|
||||||
impressumUrl: instance.impressumUrl,
|
impressumUrl: instance.impressumUrl,
|
||||||
|
donationUrl: instance.donationUrl,
|
||||||
privacyPolicyUrl: instance.privacyPolicyUrl,
|
privacyPolicyUrl: instance.privacyPolicyUrl,
|
||||||
disableRegistration: instance.disableRegistration,
|
disableRegistration: instance.disableRegistration,
|
||||||
emailRequiredForSignup: instance.emailRequiredForSignup,
|
emailRequiredForSignup: instance.emailRequiredForSignup,
|
||||||
|
|
BIN
packages/frontend/assets/drop-and-fusion/gameover.mp3
Normal file
BIN
packages/frontend/assets/drop-and-fusion/gameover.mp3
Normal file
Binary file not shown.
|
@ -59,6 +59,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",
|
||||||
|
|
|
@ -26,6 +26,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkLink target="_blank" url="https://ko-fi.com/transfem">{{ i18n.ts.learnMore }}</MkLink>
|
<MkLink target="_blank" url="https://ko-fi.com/transfem">{{ i18n.ts.learnMore }}</MkLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="instance.donationUrl" :class="$style.text">
|
||||||
|
<I18n :src="i18n.ts.pleaseDonateInstance" tag="span">
|
||||||
|
<template #host>
|
||||||
|
{{ instance.name ?? host }}
|
||||||
|
</template>
|
||||||
|
</I18n>
|
||||||
|
<div style="margin-top: 0.2em;">
|
||||||
|
<MkLink target="_blank" :url="instance.donationUrl">{{ i18n.ts.learnMore }}</MkLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="_buttons">
|
<div class="_buttons">
|
||||||
<MkButton @click="close">{{ i18n.ts.remindMeLater }}</MkButton>
|
<MkButton @click="close">{{ i18n.ts.remindMeLater }}</MkButton>
|
||||||
<MkButton @click="neverShow">{{ i18n.ts.neverShow }}</MkButton>
|
<MkButton @click="neverShow">{{ i18n.ts.neverShow }}</MkButton>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -123,7 +123,13 @@ function showMenu(ev) {
|
||||||
action: () => {
|
action: () => {
|
||||||
window.open(instance.privacyPolicyUrl, '_blank', 'noopener');
|
window.open(instance.privacyPolicyUrl, '_blank', 'noopener');
|
||||||
},
|
},
|
||||||
} : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : { type: 'divider' }, {
|
} : undefined, (instance.donationUrl) ? {
|
||||||
|
text: i18n.ts.donation,
|
||||||
|
icon: 'ph-hand-coins ph-bold ph-lg',
|
||||||
|
action: () => {
|
||||||
|
window.open(instance.donationUrl, '_blank', 'noopener');
|
||||||
|
},
|
||||||
|
} : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl && !instance.donationUrl) ? undefined : { type: 'divider' }, {
|
||||||
text: i18n.ts.help,
|
text: i18n.ts.help,
|
||||||
icon: 'ph-question ph-bold ph-lg',
|
icon: 'ph-question ph-bold ph-lg',
|
||||||
action: () => {
|
action: () => {
|
||||||
|
|
|
@ -57,6 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
<FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>{{ i18n.ts.termsOfService }}</FormLink>
|
<FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>{{ i18n.ts.termsOfService }}</FormLink>
|
||||||
<FormLink v-if="instance.privacyPolicyUrl" :to="instance.privacyPolicyUrl" external>{{ i18n.ts.privacyPolicy }}</FormLink>
|
<FormLink v-if="instance.privacyPolicyUrl" :to="instance.privacyPolicyUrl" external>{{ i18n.ts.privacyPolicy }}</FormLink>
|
||||||
|
<FormLink v-if="instance.donationUrl" :to="instance.donationUrl" external>{{ i18n.ts.donation }}</FormLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
|
@ -40,6 +40,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template #caption>{{ i18n.ts.impressumDescription }}</template>
|
<template #caption>{{ i18n.ts.impressumDescription }}</template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
|
|
||||||
|
<MkInput v-model="donationUrl" type="url">
|
||||||
|
<template #label>{{ i18n.ts.donationUrl }}</template>
|
||||||
|
<template #prefix><i class="ph-link ph-bold ph-lg"></i></template>
|
||||||
|
</MkInput>
|
||||||
|
|
||||||
<MkTextarea v-model="pinnedUsers">
|
<MkTextarea v-model="pinnedUsers">
|
||||||
<template #label>{{ i18n.ts.pinnedUsers }}</template>
|
<template #label>{{ i18n.ts.pinnedUsers }}</template>
|
||||||
<template #caption>{{ i18n.ts.pinnedUsersDescription }}</template>
|
<template #caption>{{ i18n.ts.pinnedUsersDescription }}</template>
|
||||||
|
@ -170,6 +175,7 @@ const description = ref<string | null>(null);
|
||||||
const maintainerName = ref<string | null>(null);
|
const maintainerName = ref<string | null>(null);
|
||||||
const maintainerEmail = ref<string | null>(null);
|
const maintainerEmail = ref<string | null>(null);
|
||||||
const impressumUrl = ref<string | null>(null);
|
const impressumUrl = ref<string | null>(null);
|
||||||
|
const donationUrl = ref<string | null>(null);
|
||||||
const pinnedUsers = ref<string>('');
|
const pinnedUsers = ref<string>('');
|
||||||
const cacheRemoteFiles = ref<boolean>(false);
|
const cacheRemoteFiles = ref<boolean>(false);
|
||||||
const cacheRemoteSensitiveFiles = ref<boolean>(false);
|
const cacheRemoteSensitiveFiles = ref<boolean>(false);
|
||||||
|
@ -192,6 +198,7 @@ async function init(): Promise<void> {
|
||||||
maintainerName.value = meta.maintainerName;
|
maintainerName.value = meta.maintainerName;
|
||||||
maintainerEmail.value = meta.maintainerEmail;
|
maintainerEmail.value = meta.maintainerEmail;
|
||||||
impressumUrl.value = meta.impressumUrl;
|
impressumUrl.value = meta.impressumUrl;
|
||||||
|
donationUrl.value = meta.donationUrl;
|
||||||
pinnedUsers.value = meta.pinnedUsers.join('\n');
|
pinnedUsers.value = meta.pinnedUsers.join('\n');
|
||||||
cacheRemoteFiles.value = meta.cacheRemoteFiles;
|
cacheRemoteFiles.value = meta.cacheRemoteFiles;
|
||||||
cacheRemoteSensitiveFiles.value = meta.cacheRemoteSensitiveFiles;
|
cacheRemoteSensitiveFiles.value = meta.cacheRemoteSensitiveFiles;
|
||||||
|
@ -215,6 +222,7 @@ async function save(): void {
|
||||||
maintainerName: maintainerName.value,
|
maintainerName: maintainerName.value,
|
||||||
maintainerEmail: maintainerEmail.value,
|
maintainerEmail: maintainerEmail.value,
|
||||||
impressumUrl: impressumUrl.value,
|
impressumUrl: impressumUrl.value,
|
||||||
|
donationUrl: donationUrl.value,
|
||||||
pinnedUsers: pinnedUsers.value.split('\n'),
|
pinnedUsers: pinnedUsers.value.split('\n'),
|
||||||
cacheRemoteFiles: cacheRemoteFiles.value,
|
cacheRemoteFiles: cacheRemoteFiles.value,
|
||||||
cacheRemoteSensitiveFiles: cacheRemoteSensitiveFiles.value,
|
cacheRemoteSensitiveFiles: cacheRemoteSensitiveFiles.value,
|
||||||
|
|
|
@ -61,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="containerEl" :class="[$style.gameContainer, { [$style.gameOver]: gameOver }]" @contextmenu.stop.prevent @click.stop.prevent="onClick" @touchmove.stop.prevent="onTouchmove" @touchend="onTouchend" @mousemove="onMousemove">
|
<div ref="containerEl" :class="[$style.gameContainer, { [$style.gameOver]: isGameOver && !replaying }]" @contextmenu.stop.prevent @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-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"/>
|
<img v-else src="/client-assets/drop-and-fusion/frame-light.svg" :class="$style.mainFrameImg"/>
|
||||||
<canvas ref="canvasEl" :class="$style.canvas"/>
|
<canvas ref="canvasEl" :class="$style.canvas"/>
|
||||||
|
@ -74,7 +74,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
>
|
>
|
||||||
<div v-show="combo > 1" :class="$style.combo" :style="{ fontSize: `${100 + ((comboPrev - 2) * 15)}%` }">{{ comboPrev }} Chain!</div>
|
<div v-show="combo > 1" :class="$style.combo" :style="{ fontSize: `${100 + ((comboPrev - 2) * 15)}%` }">{{ comboPrev }} Chain!</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
<div :class="$style.dropperContainer" :style="{ left: dropperX + 'px' }">
|
<div v-if="!isGameOver && !replaying" :class="$style.dropperContainer" :style="{ left: dropperX + 'px' }">
|
||||||
<!--<img v-if="currentPick" src="/client-assets/drop-and-fusion/dropper.png" :class="$style.dropper" :style="{ left: dropperX + 'px' }"/>-->
|
<!--<img v-if="currentPick" src="/client-assets/drop-and-fusion/dropper.png" :class="$style.dropper" :style="{ left: dropperX + 'px' }"/>-->
|
||||||
<Transition
|
<Transition
|
||||||
:enterActiveClass="$style.transition_picked_enterActive"
|
:enterActiveClass="$style.transition_picked_enterActive"
|
||||||
|
@ -91,16 +91,30 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div :class="$style.dropGuide"/>
|
<div :class="$style.dropGuide"/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="gameOver" :class="$style.gameOverLabel">
|
<div v-if="isGameOver && !replaying" :class="$style.gameOverLabel">
|
||||||
<div class="_gaps_s">
|
<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;"/>
|
<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>SCORE: <MkNumber :value="score"/></div>
|
||||||
<div>MAX CHAIN: <MkNumber :value="maxCombo"/></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 v-if="replaying" :class="$style.replayIndicator"><span :class="$style.replayIndicatorText"><i class="ti ti-player-play"></i> {{ i18n.ts.replaying }}</span></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="replaying" style="display: flex;">
|
||||||
|
<div :class="$style.frame" style="flex: 1; margin-right: 10px;">
|
||||||
|
<div :class="$style.frameInner">
|
||||||
|
<MkButton @click="endReplay"><i class="ti ti-player-stop"></i> END REPLAY</MkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isGameOver" :class="$style.frame">
|
||||||
|
<div :class="$style.frameInner">
|
||||||
|
<div class="_buttonsCenter">
|
||||||
|
<MkButton primary rounded @click="end">{{ i18n.ts.done }}</MkButton>
|
||||||
|
<MkButton primary rounded @click="replay">{{ i18n.ts.showReplay }}</MkButton>
|
||||||
|
<MkButton primary rounded @click="share">{{ i18n.ts.share }}</MkButton>
|
||||||
|
<MkButton rounded @click="exportLog">Copy replay data</MkButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex;">
|
<div style="display: flex;">
|
||||||
|
@ -139,7 +153,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.frame">
|
<div :class="$style.frame">
|
||||||
<div :class="$style.frameInner">
|
<div :class="$style.frameInner">
|
||||||
<MkButton @click="restart">Restart</MkButton>
|
<MkButton danger @click="surrender">Retry</MkButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -168,6 +182,7 @@ import { DropAndFusionGame, Mono } from '@/scripts/drop-and-fusion-engine.js';
|
||||||
import * as sound from '@/scripts/sound.js';
|
import * as sound from '@/scripts/sound.js';
|
||||||
import MkRange from '@/components/MkRange.vue';
|
import MkRange from '@/components/MkRange.vue';
|
||||||
import MkSwitch from '@/components/MkSwitch.vue';
|
import MkSwitch from '@/components/MkSwitch.vue';
|
||||||
|
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
||||||
|
|
||||||
const NORMAL_BASE_SIZE = 30;
|
const NORMAL_BASE_SIZE = 30;
|
||||||
const NORAML_MONOS: Mono[] = [{
|
const NORAML_MONOS: Mono[] = [{
|
||||||
|
@ -401,6 +416,8 @@ const GAME_HEIGHT = 600;
|
||||||
let viewScale = 1;
|
let viewScale = 1;
|
||||||
let game: DropAndFusionGame;
|
let game: DropAndFusionGame;
|
||||||
let containerElRect: DOMRect | null = null;
|
let containerElRect: DOMRect | null = null;
|
||||||
|
let seed: string;
|
||||||
|
let logs: ReturnType<DropAndFusionGame['getLogs']> | null = null;
|
||||||
|
|
||||||
const containerEl = shallowRef<HTMLElement>();
|
const containerEl = shallowRef<HTMLElement>();
|
||||||
const canvasEl = shallowRef<HTMLCanvasElement>();
|
const canvasEl = shallowRef<HTMLCanvasElement>();
|
||||||
|
@ -414,22 +431,25 @@ const comboPrev = ref(0);
|
||||||
const maxCombo = ref(0);
|
const maxCombo = ref(0);
|
||||||
const dropReady = ref(true);
|
const dropReady = ref(true);
|
||||||
const gameMode = ref<'normal' | 'square'>('normal');
|
const gameMode = ref<'normal' | 'square'>('normal');
|
||||||
const gameOver = ref(false);
|
const isGameOver = ref(false);
|
||||||
const gameStarted = ref(false);
|
const gameStarted = ref(false);
|
||||||
const highScore = ref<number | null>(null);
|
const highScore = ref<number | null>(null);
|
||||||
const showConfig = ref(false);
|
const showConfig = ref(false);
|
||||||
|
const replaying = ref(false);
|
||||||
const mute = ref(false);
|
const mute = ref(false);
|
||||||
const bgmVolume = ref(defaultStore.state.dropAndFusion.bgmVolume);
|
const bgmVolume = ref(defaultStore.state.dropAndFusion.bgmVolume);
|
||||||
const sfxVolume = ref(defaultStore.state.dropAndFusion.sfxVolume);
|
const sfxVolume = ref(defaultStore.state.dropAndFusion.sfxVolume);
|
||||||
|
|
||||||
function onClick(ev: MouseEvent) {
|
function onClick(ev: MouseEvent) {
|
||||||
if (!containerElRect) return;
|
if (!containerElRect) return;
|
||||||
|
if (replaying.value) return;
|
||||||
const x = (ev.clientX - containerElRect.left) / viewScale;
|
const x = (ev.clientX - containerElRect.left) / viewScale;
|
||||||
game.drop(x);
|
game.drop(x);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTouchend(ev: TouchEvent) {
|
function onTouchend(ev: TouchEvent) {
|
||||||
if (!containerElRect) return;
|
if (!containerElRect) return;
|
||||||
|
if (replaying.value) return;
|
||||||
const x = (ev.changedTouches[0].clientX - containerElRect.left) / viewScale;
|
const x = (ev.changedTouches[0].clientX - containerElRect.left) / viewScale;
|
||||||
game.drop(x);
|
game.drop(x);
|
||||||
}
|
}
|
||||||
|
@ -454,9 +474,18 @@ function hold() {
|
||||||
game.hold();
|
game.hold();
|
||||||
}
|
}
|
||||||
|
|
||||||
function restart() {
|
async function surrender() {
|
||||||
|
const { canceled } = await os.confirm({
|
||||||
|
type: 'warning',
|
||||||
|
text: i18n.ts.areYouSure,
|
||||||
|
});
|
||||||
|
if (canceled) return;
|
||||||
|
game.surrender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function end() {
|
||||||
game.dispose();
|
game.dispose();
|
||||||
gameOver.value = false;
|
isGameOver.value = false;
|
||||||
currentPick.value = null;
|
currentPick.value = null;
|
||||||
dropReady.value = true;
|
dropReady.value = true;
|
||||||
stock.value = [];
|
stock.value = [];
|
||||||
|
@ -467,6 +496,45 @@ function restart() {
|
||||||
gameStarted.value = false;
|
gameStarted.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replay() {
|
||||||
|
replaying.value = true;
|
||||||
|
game.dispose();
|
||||||
|
game = new DropAndFusionGame({
|
||||||
|
width: GAME_WIDTH,
|
||||||
|
height: GAME_HEIGHT,
|
||||||
|
canvas: canvasEl.value!,
|
||||||
|
seed: seed,
|
||||||
|
sfxVolume: mute.value ? 0 : sfxVolume.value,
|
||||||
|
...(
|
||||||
|
gameMode.value === 'normal' ? {
|
||||||
|
monoDefinitions: NORAML_MONOS,
|
||||||
|
} : {
|
||||||
|
monoDefinitions: SQUARE_MONOS,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
});
|
||||||
|
attachGameEvents();
|
||||||
|
os.promiseDialog(game.load(), async () => {
|
||||||
|
game.start(logs!);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function endReplay() {
|
||||||
|
replaying.value = false;
|
||||||
|
game.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportLog() {
|
||||||
|
if (!logs) return;
|
||||||
|
const data = JSON.stringify({
|
||||||
|
seed: seed,
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
logs: logs,
|
||||||
|
});
|
||||||
|
copyToClipboard(data);
|
||||||
|
os.success();
|
||||||
|
}
|
||||||
|
|
||||||
function attachGameEvents() {
|
function attachGameEvents() {
|
||||||
game.addListener('changeScore', value => {
|
game.addListener('changeScore', value => {
|
||||||
score.value = value;
|
score.value = value;
|
||||||
|
@ -492,9 +560,11 @@ function attachGameEvents() {
|
||||||
});
|
});
|
||||||
|
|
||||||
game.addListener('dropped', () => {
|
game.addListener('dropped', () => {
|
||||||
|
if (replaying.value) return;
|
||||||
|
|
||||||
dropReady.value = false;
|
dropReady.value = false;
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
if (!gameOver.value) {
|
if (!isGameOver.value) {
|
||||||
dropReady.value = true;
|
dropReady.value = true;
|
||||||
}
|
}
|
||||||
}, game.DROP_INTERVAL);
|
}, game.DROP_INTERVAL);
|
||||||
|
@ -511,6 +581,8 @@ function attachGameEvents() {
|
||||||
});
|
});
|
||||||
|
|
||||||
game.addListener('monoAdded', (mono) => {
|
game.addListener('monoAdded', (mono) => {
|
||||||
|
if (replaying.value) return;
|
||||||
|
|
||||||
// 実績関連
|
// 実績関連
|
||||||
if (mono.level === 10) {
|
if (mono.level === 10) {
|
||||||
claimAchievement('bubbleGameExplodingHead');
|
claimAchievement('bubbleGameExplodingHead');
|
||||||
|
@ -523,9 +595,15 @@ function attachGameEvents() {
|
||||||
});
|
});
|
||||||
|
|
||||||
game.addListener('gameOver', () => {
|
game.addListener('gameOver', () => {
|
||||||
|
if (replaying.value) {
|
||||||
|
endReplay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logs = game.getLogs();
|
||||||
currentPick.value = null;
|
currentPick.value = null;
|
||||||
dropReady.value = false;
|
dropReady.value = false;
|
||||||
gameOver.value = true;
|
isGameOver.value = true;
|
||||||
|
|
||||||
if (score.value > (highScore.value ?? 0)) {
|
if (score.value > (highScore.value ?? 0)) {
|
||||||
highScore.value = score.value;
|
highScore.value = score.value;
|
||||||
|
@ -551,10 +629,13 @@ async function start() {
|
||||||
highScore.value = null;
|
highScore.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
seed = Date.now().toString();
|
||||||
|
|
||||||
game = new DropAndFusionGame({
|
game = new DropAndFusionGame({
|
||||||
width: GAME_WIDTH,
|
width: GAME_WIDTH,
|
||||||
height: GAME_HEIGHT,
|
height: GAME_HEIGHT,
|
||||||
canvas: canvasEl.value!,
|
canvas: canvasEl.value!,
|
||||||
|
seed: seed,
|
||||||
sfxVolume: mute.value ? 0 : sfxVolume.value,
|
sfxVolume: mute.value ? 0 : sfxVolume.value,
|
||||||
...(
|
...(
|
||||||
gameMode.value === 'normal' ? {
|
gameMode.value === 'normal' ? {
|
||||||
|
@ -690,7 +771,7 @@ useInterval(() => {
|
||||||
}, 1000, { immediate: false, afterMounted: true });
|
}, 1000, { immediate: false, afterMounted: true });
|
||||||
|
|
||||||
onDeactivated(() => {
|
onDeactivated(() => {
|
||||||
restart();
|
end();
|
||||||
});
|
});
|
||||||
|
|
||||||
definePageMetadata({
|
definePageMetadata({
|
||||||
|
@ -922,6 +1003,28 @@ definePageMetadata({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.replayIndicator {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
left: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
color: #f00;
|
||||||
|
background: #0008;
|
||||||
|
border-radius: 6px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replayIndicatorText {
|
||||||
|
animation: replayIndicator-blink 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes replayIndicator-blink {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes currentMonoArrow {
|
@keyframes currentMonoArrow {
|
||||||
0% { transform: translateY(0); }
|
0% { transform: translateY(0); }
|
||||||
25% { transform: translateY(-8px); }
|
25% { transform: translateY(-8px); }
|
||||||
|
|
|
@ -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,6 +21,18 @@ export type Mono = {
|
||||||
spriteScale: number;
|
spriteScale: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Log = {
|
||||||
|
frame: number;
|
||||||
|
operation: 'drop';
|
||||||
|
x: number;
|
||||||
|
} | {
|
||||||
|
frame: number;
|
||||||
|
operation: 'hold';
|
||||||
|
} | {
|
||||||
|
frame: number;
|
||||||
|
operation: 'surrender';
|
||||||
|
};
|
||||||
|
|
||||||
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;
|
||||||
|
@ -35,18 +48,23 @@ export class DropAndFusionGame extends EventEmitter<{
|
||||||
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;
|
private sfxVolume = 1;
|
||||||
|
|
||||||
|
@ -87,13 +105,17 @@ export class DropAndFusionGame extends EventEmitter<{
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
monoDefinitions: Mono[];
|
monoDefinitions: Mono[];
|
||||||
|
seed: string;
|
||||||
sfxVolume?: number;
|
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) {
|
if (opts.sfxVolume) {
|
||||||
this.sfxVolume = opts.sfxVolume;
|
this.sfxVolume = opts.sfxVolume;
|
||||||
|
@ -129,9 +151,6 @@ 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
|
||||||
|
@ -223,9 +242,12 @@ 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);
|
||||||
|
@ -244,7 +266,7 @@ export class DropAndFusionGame extends EventEmitter<{
|
||||||
} 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);
|
||||||
//}
|
//}
|
||||||
|
@ -255,10 +277,25 @@ export class DropAndFusionGame extends EventEmitter<{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** テクスチャをすべてキャッシュする */
|
/** テクスチャをすべてキャッシュする */
|
||||||
|
@ -292,13 +329,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);
|
||||||
|
@ -327,10 +365,13 @@ 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;
|
||||||
|
@ -354,6 +395,69 @@ export class DropAndFusionGame extends EventEmitter<{
|
||||||
this.combo = 0;
|
this.combo = 0;
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
|
if (logs) {
|
||||||
|
const playTick = () => {
|
||||||
|
this.frame++;
|
||||||
|
const log = logs.find(x => x.frame === this.frame - 1);
|
||||||
|
if (log) {
|
||||||
|
switch (log.operation) {
|
||||||
|
case 'drop': {
|
||||||
|
this.drop(log.x);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'hold': {
|
||||||
|
this.hold();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'surrender': {
|
||||||
|
this.surrender();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.tickCallbackQueue = this.tickCallbackQueue.filter(x => {
|
||||||
|
if (x.frame === this.frame) {
|
||||||
|
x.callback();
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Matter.Engine.update(this.engine, this.TICK_DELTA);
|
||||||
|
|
||||||
|
if (!this.isGameOver) {
|
||||||
|
this.tickRaf = window.requestAnimationFrame(playTick);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
playTick();
|
||||||
|
} else {
|
||||||
|
this.tick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getLogs() {
|
||||||
|
return this.logs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private tick() {
|
||||||
|
this.frame++;
|
||||||
|
this.tickCallbackQueue = this.tickCallbackQueue.filter(x => {
|
||||||
|
if (x.frame === this.frame) {
|
||||||
|
x.callback();
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Matter.Engine.update(this.engine, this.TICK_DELTA);
|
||||||
|
if (!this.isGameOver) {
|
||||||
|
this.tickRaf = window.requestAnimationFrame(this.tick);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async load() {
|
public async load() {
|
||||||
|
@ -387,17 +491,23 @@ 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) return;
|
if (!this.replaying && (Date.now() - this.latestDroppedAt < this.DROP_INTERVAL)) return;
|
||||||
|
|
||||||
const head = this.stock.shift()!;
|
const head = 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 - (head.mono.size / 2), Math.max(this.PLAYAREA_MARGIN + (head.mono.size / 2), _x));
|
const inputX = Math.round(_x);
|
||||||
|
const x = Math.min(this.gameWidth - this.PLAYAREA_MARGIN - (head.mono.size / 2), Math.max(this.PLAYAREA_MARGIN + (head.mono.size / 2), inputX));
|
||||||
const body = this.createBody(head.mono, x, 50 + head.mono.size / 2);
|
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;
|
||||||
|
@ -416,6 +526,11 @@ export class DropAndFusionGame extends EventEmitter<{
|
||||||
public hold() {
|
public hold() {
|
||||||
if (this.isGameOver) return;
|
if (this.isGameOver) return;
|
||||||
|
|
||||||
|
this.logs.push({
|
||||||
|
frame: this.frame,
|
||||||
|
operation: 'hold',
|
||||||
|
});
|
||||||
|
|
||||||
if (this.holding) {
|
if (this.holding) {
|
||||||
const head = this.stock.shift()!;
|
const head = this.stock.shift()!;
|
||||||
this.stock.unshift(this.holding);
|
this.stock.unshift(this.holding);
|
||||||
|
@ -426,8 +541,8 @@ export class DropAndFusionGame extends EventEmitter<{
|
||||||
const head = this.stock.shift()!;
|
const head = this.stock.shift()!;
|
||||||
this.holding = head;
|
this.holding = head;
|
||||||
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('changeHolding', this.holding);
|
this.emit('changeHolding', this.holding);
|
||||||
this.emit('changeStock', this.stock);
|
this.emit('changeStock', this.stock);
|
||||||
|
@ -440,8 +555,9 @@ export class DropAndFusionGame extends EventEmitter<{
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -102,7 +102,13 @@ export function openInstanceMenu(ev: MouseEvent) {
|
||||||
action: () => {
|
action: () => {
|
||||||
window.open(instance.privacyPolicyUrl, '_blank', 'noopener');
|
window.open(instance.privacyPolicyUrl, '_blank', 'noopener');
|
||||||
},
|
},
|
||||||
} : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : { type: 'divider' }, {
|
} : undefined, (instance.donationUrl) ? {
|
||||||
|
text: i18n.ts.donation,
|
||||||
|
icon: 'ph-hand-coins ph-bold ph-lg',
|
||||||
|
action: () => {
|
||||||
|
window.open(instance.donationUrl, '_blank', 'noopener');
|
||||||
|
},
|
||||||
|
} : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl && !instance.donationUrl) ? undefined : { type: 'divider' }, {
|
||||||
text: i18n.ts.help,
|
text: i18n.ts.help,
|
||||||
icon: 'ph-question ph-bold ph-lg',
|
icon: 'ph-question ph-bold ph-lg',
|
||||||
action: () => {
|
action: () => {
|
||||||
|
|
|
@ -4631,6 +4631,7 @@ export type operations = {
|
||||||
description: string | null;
|
description: string | null;
|
||||||
disableRegistration: boolean;
|
disableRegistration: boolean;
|
||||||
impressumUrl: string | null;
|
impressumUrl: string | null;
|
||||||
|
donationUrl: string | null;
|
||||||
maintainerEmail: string | null;
|
maintainerEmail: string | null;
|
||||||
maintainerName: string | null;
|
maintainerName: string | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
@ -8650,6 +8651,7 @@ export type operations = {
|
||||||
repositoryUrl?: string;
|
repositoryUrl?: string;
|
||||||
feedbackUrl?: string;
|
feedbackUrl?: string;
|
||||||
impressumUrl?: string | null;
|
impressumUrl?: string | null;
|
||||||
|
donationUrl?: string | null;
|
||||||
privacyPolicyUrl?: string | null;
|
privacyPolicyUrl?: string | null;
|
||||||
useObjectStorage?: boolean;
|
useObjectStorage?: boolean;
|
||||||
objectStorageBaseUrl?: string | null;
|
objectStorageBaseUrl?: string | null;
|
||||||
|
@ -19400,6 +19402,7 @@ export type operations = {
|
||||||
};
|
};
|
||||||
backgroundImageUrl: string | null;
|
backgroundImageUrl: string | null;
|
||||||
impressumUrl: string | null;
|
impressumUrl: string | null;
|
||||||
|
donationUrl: string | null;
|
||||||
logoImageUrl: string | null;
|
logoImageUrl: string | null;
|
||||||
privacyPolicyUrl: string | null;
|
privacyPolicyUrl: string | null;
|
||||||
serverRules: string[];
|
serverRules: string[];
|
||||||
|
|
|
@ -795,6 +795,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
|
||||||
|
|
Loading…
Reference in a new issue