Compare commits

...

10 commits

Author SHA1 Message Date
Marie
91969623bd
merge: upstream 2024-01-10 00:25:19 +01:00
syuilo
f5b864df7b
fix(frontend): fix game replay 2024-01-10 07:26:16 +09:00
Marie
3877b74858 merge: Implement Donation URL (#343)
Reviewed-on: https://git.joinsharkey.org/Sharkey/Sharkey/pulls/343
Reviewed-by: dakkar <dakkar@noreply.git.joinshakrey.org>
Reviewed-by: Amelia Yukii <admin@transfem.org>
Reviewed-by: Marie <marie@kaifa.ch>
2024-01-09 22:59:24 +01:00
Nila The Dragon
f3aeb4c351
Add missing quotes in ja-JP 2024-01-09 22:27:15 +01:00
Nila The Dragon
e0e5236864
Revert auto-formatted change in NodeinfoServerService 2024-01-09 22:25:46 +01:00
Nila The Dragon
e74cd2f6ee
Move donation link on about page 2024-01-09 22:02:28 +01:00
Nila The Dragon
9ceb74b98d
feat: Implement Donation URL 2024-01-09 21:32:47 +01:00
FineArchs
7e52ea4818
Update CHANGELOG.md (#12953) 2024-01-10 00:44:13 +09:00
Camilla Ett
358dc6289b
Enhance(frontend): 管理者の場合はAPI tokenの発行画面で管理機能に関する権限を付与できるように (#12944)
* Enhance(frontend): 管理者の場合はAPI tokenの発行画面で管理機能に関する権限を付与できるように

* update CHANGELOG.md

* tweak style

* (refactor) remove unnecessary imports

* fix lint

---------

Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Co-authored-by: kakkokari-gtyih <daisho7308+f@gmail.com>
2024-01-09 21:18:09 +09:00
syuilo
1063d39de8 enhnace(frontend): tweak game 2024-01-09 21:15:56 +09:00
23 changed files with 407 additions and 50 deletions

View file

@ -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: 連合先のレートリミットに引っかかった際にリトライするようになりました

View file

@ -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"

View file

@ -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
View file

@ -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;

View file

@ -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: "既存ユーザーのみ"

View 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"`);
}
}

View file

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

View file

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

View file

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

View file

@ -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;
} }

View file

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

Binary file not shown.

View file

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

View file

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

View file

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

View file

@ -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: () => {

View file

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

View file

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

View file

@ -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,15 +91,29 @@ 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"> </div>
<MkButton primary rounded @click="restart">Restart</MkButton> </div>
<MkButton primary rounded @click="share">Share</MkButton> <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>
<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>
@ -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); }

View file

@ -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({
this.activeBodyIds.push(body.id); frame: this.frame + 6,
}, 100); callback: () => {
this.activeBodyIds.push(body.id);
},
});
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({
fusionReservedPairs = fusionReservedPairs.filter(x => x.bodyA.id !== bodyA.id && x.bodyB.id !== bodyB.id); frame: this.frame + 6,
this.fusion(bodyA, bodyB); callback: () => {
}, 100); fusionReservedPairs = fusionReservedPairs.filter(x => x.bodyA.id !== bodyA.id && x.bodyB.id !== bodyB.id);
this.fusion(bodyA, bodyB);
},
});
} }
} 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);
} }

View file

@ -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: () => {

View file

@ -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[];

View file

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