Merge remote-branch 'misskey/develop'

This commit is contained in:
NoriDev 2024-01-10 17:48:43 +09:00
commit faf7f9709f
10 changed files with 417 additions and 72 deletions

View file

@ -22,9 +22,14 @@
- Feat: 新しいゲームを追加
- Enhance: ハッシュタグ入力時に、本文の末尾の行に何も書かれていない場合は新たにスペースを追加しないように
- Enhance: チャンネルノートのピン留めをノートのメニューからできるように
- Enhance: 管理者の場合はAPI tokenの発行画面で管理機能に関する権限を付与できるように
- Enhance: AiScriptを0.17.0に更新 [CHANGELOG](https://github.com/aiscript-dev/aiscript/blob/bb89d132b633a622d3cb0eff0d0cc7e476c0cfdd/CHANGELOG.md)
- 配列の範囲外・非整数のインデックスへの代入が完全禁止になるので注意
- Enhance: 絵文字ピッカー・オートコンプリートで、完全一致した絵文字を優先的に表示するように
- Fix: ネイティブモードの絵文字がモノクロにならないように
- Fix: v2023.12.0で追加された「モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能」が管理画面上で正しく表示されていない問題を修正
- Fix: AiScriptの`readline`関数が不正な値を返すことがある問題のv2023.12.0時点での修正がPlay以外に適用されていないのを修正
- Fix: v2023.12.1で追加された`$[clickable ...]`および`onClickEv`が正しく機能していないのを修正
### Server
- Enhance: 連合先のレートリミットに引っかかった際にリトライするようになりました

View file

@ -730,6 +730,7 @@ medium: "中"
small: "小"
generateAccessToken: "アクセストークンの発行"
permission: "権限"
adminPermission: "管理者権限"
enableAll: "全て有効にする"
disableAll: "全て無効にする"
tokenRequested: "アカウントへのアクセス許可"
@ -1303,6 +1304,9 @@ enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する"
bubbleGame: "バブルゲーム"
sfx: "効果音"
soundWillBePlayed: "サウンドが再生されます"
showReplay: "リプレイを見る"
replay: "リプレイ"
replaying: "リプレイ中"
showUnreadNotificationsCount: "未読の通知の数を表示する"
showCatOnly: "キャット付きのみ"
additionalPermissionsForFlash: "Playへの追加許可"
@ -1330,6 +1334,13 @@ _showingAnimatedImages:
_messaging:
direct: "ダイレクトメッセージ"
_bubbleGame:
howToPlay: "遊び方"
_howToPlay:
section1: "位置を調整してハコにモノを落とします。"
section2: "同じ種類のモノがくっつくと別のモノに変化して、スコアが得られます。"
section3: "モノがハコからあふれるとゲームオーバーです。ハコからあふれないようにしつつモノを融合させてハイスコアを目指そう!"
_announcement:
forExistingUsers: "既存ユーザーのみ"
forExistingUsersDescription: "有効にすると、このお知らせ作成時点で存在するユーザーにのみお知らせが表示されます。無効にすると、このお知らせ作成後にアカウントを作成したユーザーにもお知らせが表示されます。"

Binary file not shown.

View file

@ -25,7 +25,7 @@
"@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "5.0.5",
"@rollup/pluginutils": "5.1.0",
"@syuilo/aiscript": "0.16.0",
"@syuilo/aiscript": "0.17.0",
"@tabler/icons-webfont": "2.44.0",
"@twemoji/parser": "15.0.0",
"@vitejs/plugin-vue": "5.0.2",
@ -63,6 +63,7 @@
"rollup": "4.9.1",
"sanitize-html": "2.11.0",
"sass": "1.69.5",
"seedrandom": "^3.0.5",
"shiki": "0.14.7",
"strict-event-emitter-types": "2.0.0",
"temml": "0.10.20",

View file

@ -276,15 +276,24 @@ function emojiAutoComplete(query: string | null, emojiDb: EmojiDef[], max = 30):
}
const matched = new Map<string, EmojiScore>();
//
//
emojiDb.some(x => {
if (x.name.startsWith(query) && !x.aliasOf) {
matched.set(x.name, { emoji: x, score: query.length + 1 });
if (x.name === query && !matched.has(x.aliasOf ?? x.name)) {
matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length + 2 });
}
return matched.size === max;
});
//
if (matched.size < max) {
emojiDb.some(x => {
if (x.name.startsWith(query) && !x.aliasOf) {
matched.set(x.name, { emoji: x, score: query.length + 1 });
}
return matched.size === max;
});
}
//
if (matched.size < max) {
emojiDb.some(x => {

View file

@ -222,6 +222,19 @@ watch(q, () => {
}
}
} else {
if (customEmojisMap.has(newQ)) {
matches.add(customEmojisMap.get(newQ)!);
}
if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.aliases.some(alias => alias === newQ)) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.name.startsWith(newQ)) {
matches.add(emoji);

View file

@ -33,7 +33,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton>
</div>
<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>
</MkSpacer>
@ -49,6 +55,7 @@ import MkButton from './MkButton.vue';
import MkInfo from './MkInfo.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js';
import { iAmAdmin } from '@/account.js';
const props = withDefaults(defineProps<{
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 adminPermissions = Misskey.permissions.filter(p => p.startsWith('read:admin') || p.startsWith('write:admin'));
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
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) {
for (const kind of props.initialPermissions) {
permissions.value[kind] = true;
permissionSwitches.value[kind] = true;
}
} else {
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 {
emit('done', {
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();
}
function disableAll(): void {
for (const p in permissions.value) {
permissions.value[p] = false;
for (const p in permissionSwitches.value) {
permissionSwitches.value[p] = false;
}
if (iAmAdmin) {
for (const p in permissionSwitchesForAdmin.value) {
permissionSwitchesForAdmin.value[p] = false;
}
}
}
function enableAll(): void {
for (const p in permissions.value) {
permissions.value[p] = true;
for (const p in permissionSwitches.value) {
permissionSwitches.value[p] = true;
}
if (iAmAdmin) {
for (const p in permissionSwitchesForAdmin.value) {
permissionSwitchesForAdmin.value[p] = true;
}
}
}
</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

@ -8,13 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><MkPageHeader/></template>
<MkSpacer :contentMax="800">
<div v-show="!gameStarted" :class="$style.root">
<div style="text-align: center;" class="_gaps">
<div :class="$style.frame">
<div class="_gaps">
<div :class="$style.frame" style="text-align: center;">
<div :class="$style.frameInner">
<img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
</div>
</div>
<div :class="$style.frame">
<div :class="$style.frame" style="text-align: center;">
<div :class="$style.frameInner">
<div class="_gaps" style="padding: 16px;">
<MkSelect v-model="gameMode">
@ -33,6 +33,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
<div :class="$style.frame">
<div :class="$style.frameInner">
<div>{{ i18n.ts._bubbleGame.howToPlay }}</div>
<ol>
<li>{{ i18n.ts._bubbleGame._howToPlay.section1 }}</li>
<li>{{ i18n.ts._bubbleGame._howToPlay.section2 }}</li>
<li>{{ i18n.ts._bubbleGame._howToPlay.section3 }}</li>
</ol>
</div>
</div>
</div>
</div>
<div v-show="gameStarted" class="_gaps_s" :class="$style.root">
@ -61,7 +71,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</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-else src="/client-assets/drop-and-fusion/frame-light.svg" :class="$style.mainFrameImg"/>
<canvas ref="canvasEl" :class="$style.canvas"/>
@ -74,7 +84,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>
</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' }"/>-->
<Transition
:enterActiveClass="$style.transition_picked_enterActive"
@ -91,18 +101,36 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.dropGuide"/>
</template>
</div>
<div v-if="gameOver" :class="$style.gameOverLabel">
<div v-if="isGameOver && !replaying" :class="$style.gameOverLabel">
<div class="_gaps_s">
<img src="/client-assets/drop-and-fusion/gameover.png" style="width: 200px; max-width: 100%; display: block; margin: auto; margin-bottom: -5px;"/>
<div>SCORE: <MkNumber :value="score"/></div>
<div>MAX CHAIN: <MkNumber :value="maxCombo"/></div>
</div>
</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">
<div class="_buttonsCenter">
<MkButton primary rounded @click="restart">Restart</MkButton>
<MkButton primary rounded @click="share">Share</MkButton>
<MkButton @click="endReplay"><i class="ti ti-player-stop"></i> END REPLAY</MkButton>
<MkButton :primary="replayPlaybackRate === 2" @click="replayPlaybackRate = replayPlaybackRate === 2 ? 1 : 2"><i class="ti ti-player-track-next"></i> x2</MkButton>
<MkButton :primary="replayPlaybackRate === 4" @click="replayPlaybackRate = replayPlaybackRate === 4 ? 1 : 4"><i class="ti ti-player-track-next"></i> x4</MkButton>
</div>
</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 style="display: flex;">
<div :class="$style.frame" style="flex: 1; margin-right: 10px;">
<div :class="$style.frameInner">
@ -139,7 +167,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="$style.frame">
<div :class="$style.frameInner">
<MkButton @click="restart">Restart</MkButton>
<MkButton v-if="!isGameOver && !replaying" full danger @click="surrender">Surrender</MkButton>
<MkButton v-else full @click="retry">Retry</MkButton>
</div>
</div>
</div>
@ -168,6 +197,7 @@ import { DropAndFusionGame, Mono } from '@/scripts/drop-and-fusion-engine.js';
import * as sound from '@/scripts/sound.js';
import MkRange from '@/components/MkRange.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
const NORMAL_BASE_SIZE = 30;
const NORAML_MONOS: Mono[] = [{
@ -401,6 +431,8 @@ const GAME_HEIGHT = 600;
let viewScale = 1;
let game: DropAndFusionGame;
let containerElRect: DOMRect | null = null;
let seed: string;
let logs: ReturnType<DropAndFusionGame['getLogs']> | null = null;
const containerEl = shallowRef<HTMLElement>();
const canvasEl = shallowRef<HTMLCanvasElement>();
@ -414,22 +446,30 @@ const comboPrev = ref(0);
const maxCombo = ref(0);
const dropReady = ref(true);
const gameMode = ref<'normal' | 'square'>('normal');
const gameOver = ref(false);
const isGameOver = ref(false);
const gameStarted = ref(false);
const highScore = ref<number | null>(null);
const showConfig = ref(false);
const replaying = ref(false);
const replayPlaybackRate = ref(1);
const mute = ref(false);
const bgmVolume = ref(defaultStore.state.dropAndFusion.bgmVolume);
const sfxVolume = ref(defaultStore.state.dropAndFusion.sfxVolume);
watch(replayPlaybackRate, (newValue) => {
game.replayPlaybackRate = newValue;
});
function onClick(ev: MouseEvent) {
if (!containerElRect) return;
if (replaying.value) return;
const x = (ev.clientX - containerElRect.left) / viewScale;
game.drop(x);
}
function onTouchend(ev: TouchEvent) {
if (!containerElRect) return;
if (replaying.value) return;
const x = (ev.changedTouches[0].clientX - containerElRect.left) / viewScale;
game.drop(x);
}
@ -454,19 +494,75 @@ function hold() {
game.hold();
}
function restart() {
async function surrender() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.areYouSure,
});
if (canceled) return;
game.surrender();
}
async function retry() {
end();
await start();
}
function end() {
game.dispose();
gameOver.value = false;
isGameOver.value = false;
replaying.value = false;
replayPlaybackRate.value = 1;
currentPick.value = null;
dropReady.value = true;
stock.value = [];
score.value = 0;
combo.value = 0;
comboPrev.value = 0;
maxCombo.value = 0;
bgmNodes?.soundSource.stop();
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() {
game.addListener('changeScore', value => {
score.value = value;
@ -492,9 +588,11 @@ function attachGameEvents() {
});
game.addListener('dropped', () => {
if (replaying.value) return;
dropReady.value = false;
window.setTimeout(() => {
if (!gameOver.value) {
if (!isGameOver.value) {
dropReady.value = true;
}
}, game.DROP_INTERVAL);
@ -511,6 +609,8 @@ function attachGameEvents() {
});
game.addListener('monoAdded', (mono) => {
if (replaying.value) return;
//
if (mono.level === 10) {
claimAchievement('bubbleGameExplodingHead');
@ -523,9 +623,15 @@ function attachGameEvents() {
});
game.addListener('gameOver', () => {
if (replaying.value) {
endReplay();
return;
}
logs = game.getLogs();
currentPick.value = null;
dropReady.value = false;
gameOver.value = true;
isGameOver.value = true;
if (score.value > (highScore.value ?? 0)) {
highScore.value = score.value;
@ -551,10 +657,13 @@ async function start() {
highScore.value = null;
}
seed = Date.now().toString();
game = new DropAndFusionGame({
width: GAME_WIDTH,
height: GAME_HEIGHT,
canvas: canvasEl.value!,
seed: seed,
sfxVolume: mute.value ? 0 : sfxVolume.value,
...(
gameMode.value === 'normal' ? {
@ -690,7 +799,7 @@ useInterval(() => {
}, 1000, { immediate: false, afterMounted: true });
onDeactivated(() => {
restart();
end();
});
definePageMetadata({
@ -922,6 +1031,29 @@ definePageMetadata({
}
}
.replayIndicator {
position: absolute;
z-index: 10;
left: 10px;
bottom: 10px;
padding: 6px 8px;
color: #f00;
font-weight: bold;
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 {
0% { transform: translateY(0); }
25% { transform: translateY(-8px); }

View file

@ -5,6 +5,7 @@
import { EventEmitter } from 'eventemitter3';
import * as Matter from 'matter-js';
import seedrandom from 'seedrandom';
import * as sound from '@/scripts/sound.js';
export type Mono = {
@ -20,6 +21,18 @@ export type Mono = {
spriteScale: number;
};
type Log = {
frame: number;
operation: 'drop';
x: number;
} | {
frame: number;
operation: 'hold';
} | {
frame: number;
operation: 'surrender';
};
export class DropAndFusionGame extends EventEmitter<{
changeScore: (newScore: number) => void;
changeCombo: (newCombo: number) => void;
@ -31,22 +44,27 @@ export class DropAndFusionGame extends EventEmitter<{
gameOver: () => void;
}> {
private PHYSICS_QUALITY_FACTOR = 16; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる、逆に高すぎても何故か不安定になる
private COMBO_INTERVAL = 1000;
private COMBO_INTERVAL = 60; // frame
public readonly DROP_INTERVAL = 500;
public readonly PLAYAREA_MARGIN = 25;
private STOCK_MAX = 4;
private TICK_DELTA = 1000 / 60; // 60fps
private loaded = false;
private frame = 0;
private engine: Matter.Engine;
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 isGameOver = false;
private gameWidth: number;
private gameHeight: number;
private monoDefinitions: Mono[] = [];
private monoTextures: Record<string, Blob> = {};
private monoTextureUrls: Record<string, string> = {};
private rng: () => number;
private logs: Log[] = [];
private replaying = false;
private sfxVolume = 1;
@ -58,7 +76,7 @@ export class DropAndFusionGame extends EventEmitter<{
private latestDroppedBodyId: Matter.Body['id'] | null = null;
private latestDroppedAt = 0;
private latestFusionedAt = 0;
private latestFusionedAt = 0; // frame
private stock: { id: string; mono: Mono }[] = [];
private holding: { id: string; mono: Mono } | null = null;
@ -82,18 +100,24 @@ export class DropAndFusionGame extends EventEmitter<{
private comboIntervalId: number | null = null;
public replayPlaybackRate = 1;
constructor(opts: {
canvas: HTMLCanvasElement;
width: number;
height: number;
monoDefinitions: Mono[];
seed: string;
sfxVolume?: number;
}) {
super();
this.tick = this.tick.bind(this);
this.gameWidth = opts.width;
this.gameHeight = opts.height;
this.monoDefinitions = opts.monoDefinitions;
this.rng = seedrandom(opts.seed);
if (opts.sfxVolume) {
this.sfxVolume = opts.sfxVolume;
@ -129,13 +153,11 @@ export class DropAndFusionGame extends EventEmitter<{
Matter.Render.run(this.render);
this.runner = Matter.Runner.create();
Matter.Runner.run(this.runner, this.engine);
this.engine.world.bodies = [];
//#region walls
const WALL_OPTIONS: Matter.IChamferableBodyDefinition = {
label: '_wall_',
isStatic: true,
friction: 0.7,
slop: 1.0,
@ -200,13 +222,12 @@ export class DropAndFusionGame extends EventEmitter<{
}
private fusion(bodyA: Matter.Body, bodyB: Matter.Body) {
const now = Date.now();
if (this.latestFusionedAt > now - this.COMBO_INTERVAL) {
if (this.latestFusionedAt > this.frame - this.COMBO_INTERVAL) {
this.combo++;
} else {
this.combo = 1;
}
this.latestFusionedAt = now;
this.latestFusionedAt = this.frame;
// TODO: 単に位置だけでなくそれぞれの動きベクトルも融合する?
const newX = (bodyA.position.x + bodyB.position.x) / 2;
@ -223,20 +244,25 @@ export class DropAndFusionGame extends EventEmitter<{
Matter.Composite.add(this.engine.world, body);
// 連鎖してfusionした場合の分かりやすさのため少し間を置いてからfusion対象になるようにする
window.setTimeout(() => {
this.activeBodyIds.push(body.id);
}, 100);
this.tickCallbackQueue.push({
frame: this.frame + 6,
callback: () => {
this.activeBodyIds.push(body.id);
},
});
const comboBonus = 1 + ((this.combo - 1) / 5);
const additionalScore = Math.round(currentMono.score * comboBonus);
this.score += additionalScore;
// TODO: 効果音再生はコンポーネント側の責務なので移動する
const pan = ((newX / this.gameWidth) - 0.5) * 2;
// TODO: 効果音再生はコンポーネント側の責務なので移動するべき?
const panV = newX - this.PLAYAREA_MARGIN;
const panW = this.gameWidth - this.PLAYAREA_MARGIN - this.PLAYAREA_MARGIN;
const pan = ((panV / panW) - 0.5) * 2;
sound.playUrl('/client-assets/drop-and-fusion/bubble2.mp3', {
volume: this.sfxVolume,
pan,
playbackRate: nextMono.sfxPitch,
playbackRate: nextMono.sfxPitch * this.replayPlaybackRate,
});
this.emit('monoAdded', nextMono);
@ -244,7 +270,7 @@ export class DropAndFusionGame extends EventEmitter<{
} else {
//const VELOCITY = 30;
//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);
// bodies.push(body);
//}
@ -255,10 +281,25 @@ export class DropAndFusionGame extends EventEmitter<{
}
}
public surrender() {
this.logs.push({
frame: this.frame,
operation: 'surrender',
});
this.gameOver();
}
private gameOver() {
this.isGameOver = true;
Matter.Runner.stop(this.runner);
if (this.tickRaf) window.cancelAnimationFrame(this.tickRaf);
this.tickRaf = null;
this.emit('gameOver');
// TODO: 効果音再生はコンポーネント側の責務なので移動するべき?
sound.playUrl('/client-assets/drop-and-fusion/gameover.mp3', {
volume: this.sfxVolume,
});
}
/** テクスチャをすべてキャッシュする */
@ -292,13 +333,14 @@ export class DropAndFusionGame extends EventEmitter<{
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 (logs) this.replaying = true;
for (let i = 0; i < this.STOCK_MAX; i++) {
this.stock.push({
id: Math.random().toString(),
mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
id: this.rng().toString(),
mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(this.rng() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
});
}
this.emit('changeStock', this.stock);
@ -327,33 +369,106 @@ export class DropAndFusionGame extends EventEmitter<{
this.fusion(bodyA, bodyB);
} else {
fusionReservedPairs.push({ bodyA, bodyB });
window.setTimeout(() => {
fusionReservedPairs = fusionReservedPairs.filter(x => x.bodyA.id !== bodyA.id && x.bodyB.id !== bodyB.id);
this.fusion(bodyA, bodyB);
}, 100);
this.tickCallbackQueue.push({
frame: this.frame + 6,
callback: () => {
fusionReservedPairs = fusionReservedPairs.filter(x => x.bodyA.id !== bodyA.id && x.bodyB.id !== bodyB.id);
this.fusion(bodyA, bodyB);
},
});
}
} else {
const energy = pairs.collision.depth;
if (energy > minCollisionEnergyForSound) {
// TODO: 効果音再生はコンポーネント側の責務なので移動する
// TODO: 効果音再生はコンポーネント側の責務なので移動するべき?
const vol = ((Math.min(maxCollisionEnergyForSound, energy - minCollisionEnergyForSound) / maxCollisionEnergyForSound) / 4) * this.sfxVolume;
const pan = ((((bodyA.position.x + bodyB.position.x) / 2) / this.gameWidth) - 0.5) * 2;
const panV =
pairs.bodyA.label === '_wall_' ? bodyB.position.x - this.PLAYAREA_MARGIN :
pairs.bodyB.label === '_wall_' ? bodyA.position.x - this.PLAYAREA_MARGIN :
((bodyA.position.x + bodyB.position.x) / 2) - this.PLAYAREA_MARGIN;
const panW = this.gameWidth - this.PLAYAREA_MARGIN - this.PLAYAREA_MARGIN;
const pan = ((panV / panW) - 0.5) * 2;
const pitch = soundPitchMin + ((soundPitchMax - soundPitchMin) * (1 - (Math.min(10, energy) / 10)));
sound.playUrl('/client-assets/drop-and-fusion/poi1.mp3', {
volume: vol,
pan,
playbackRate: pitch,
playbackRate: pitch * this.replayPlaybackRate,
});
}
}
}
});
this.comboIntervalId = window.setInterval(() => {
if (this.latestFusionedAt < Date.now() - this.COMBO_INTERVAL) {
this.combo = 0;
if (logs) {
const playTick = () => {
for (let i = 0; i < this.replayPlaybackRate; i++) {
this.frame++;
if (this.latestFusionedAt < this.frame - this.COMBO_INTERVAL) {
this.combo = 0;
}
const log = logs.find(x => x.frame === this.frame - 1);
if (log) {
switch (log.operation) {
case 'drop': {
this.drop(log.x);
break;
}
case 'hold': {
this.hold();
break;
}
case 'surrender': {
this.surrender();
break;
}
default:
break;
}
}
this.tickCallbackQueue = this.tickCallbackQueue.filter(x => {
if (x.frame === this.frame) {
x.callback();
return false;
} else {
return true;
}
});
Matter.Engine.update(this.engine, this.TICK_DELTA);
}
if (!this.isGameOver) {
this.tickRaf = window.requestAnimationFrame(playTick);
}
};
playTick();
} else {
this.tick();
}
}
public getLogs() {
return this.logs;
}
private tick() {
this.frame++;
if (this.latestFusionedAt < this.frame - this.COMBO_INTERVAL) {
this.combo = 0;
}
this.tickCallbackQueue = this.tickCallbackQueue.filter(x => {
if (x.frame === this.frame) {
x.callback();
return false;
} else {
return true;
}
}, 500);
});
Matter.Engine.update(this.engine, this.TICK_DELTA);
if (!this.isGameOver) {
this.tickRaf = window.requestAnimationFrame(this.tick);
}
}
public async load() {
@ -387,17 +502,23 @@ export class DropAndFusionGame extends EventEmitter<{
public drop(_x: number) {
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()!;
this.stock.push({
id: Math.random().toString(),
mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
id: this.rng().toString(),
mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(this.rng() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
});
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);
this.logs.push({
frame: this.frame,
operation: 'drop',
x: inputX,
});
Matter.Composite.add(this.engine.world, body);
this.activeBodyIds.push(body.id);
this.latestDroppedBodyId = body.id;
@ -405,17 +526,25 @@ export class DropAndFusionGame extends EventEmitter<{
this.emit('dropped');
this.emit('monoAdded', head.mono);
// TODO: 効果音再生はコンポーネント側の責務なので移動する
const pan = ((x / this.gameWidth) - 0.5) * 2;
// TODO: 効果音再生はコンポーネント側の責務なので移動するべき?
const panV = x - this.PLAYAREA_MARGIN;
const panW = this.gameWidth - this.PLAYAREA_MARGIN - this.PLAYAREA_MARGIN;
const pan = ((panV / panW) - 0.5) * 2;
sound.playUrl('/client-assets/drop-and-fusion/poi2.mp3', {
volume: this.sfxVolume,
pan,
playbackRate: this.replayPlaybackRate,
});
}
public hold() {
if (this.isGameOver) return;
this.logs.push({
frame: this.frame,
operation: 'hold',
});
if (this.holding) {
const head = this.stock.shift()!;
this.stock.unshift(this.holding);
@ -426,8 +555,8 @@ export class DropAndFusionGame extends EventEmitter<{
const head = this.stock.shift()!;
this.holding = head;
this.stock.push({
id: Math.random().toString(),
mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
id: this.rng().toString(),
mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(this.rng() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
});
this.emit('changeHolding', this.holding);
this.emit('changeStock', this.stock);
@ -440,8 +569,9 @@ export class DropAndFusionGame extends EventEmitter<{
public dispose() {
if (this.comboIntervalId) window.clearInterval(this.comboIntervalId);
if (this.tickRaf) window.cancelAnimationFrame(this.tickRaf);
this.tickRaf = null;
Matter.Render.stop(this.render);
Matter.Runner.stop(this.runner);
Matter.World.clear(this.engine.world, false);
Matter.Engine.clear(this.engine);
}

View file

@ -102,7 +102,6 @@ export async function loadAudio(url: string, options?: { useCache?: boolean; })
}
if (options?.useCache ?? true) {
if (cache.has(url)) {
if (_DEV_) console.log('use cache');
return cache.get(url) as AudioBuffer;
}
}
@ -131,7 +130,6 @@ export async function loadAudio(url: string, options?: { useCache?: boolean; })
*/
export function playMisskeySfx(operationType: OperationType) {
const sound = defaultStore.state[`sound_${operationType}`];
if (_DEV_) console.log('play', operationType, sound);
if (sound.type == null || !canPlay) return;
canPlay = false;