diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 896149f238..e599912e2b 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -181,8 +181,8 @@ export interface ReversiGameEventTypes { value: any; }; log: Reversi.Serializer.Log & { id: string | null }; - syncState: { - crc32: string; + heatbeat: { + userId: MiUser['id']; }; started: { game: Packed<'ReversiGameDetailed'>; diff --git a/packages/backend/src/core/ReversiService.ts b/packages/backend/src/core/ReversiService.ts index 9fe7255e48..e626cbaf19 100644 --- a/packages/backend/src/core/ReversiService.ts +++ b/packages/backend/src/core/ReversiService.ts @@ -405,6 +405,11 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { return this.reversiGamesRepository.findOneBy({ id }); } + @bindThis + public async heatbeat(game: MiReversiGame, user: MiUser) { + this.globalEventService.publishReversiGameStream(game.id, 'heatbeat', { userId: user.id }); + } + @bindThis public dispose(): void { } diff --git a/packages/backend/src/server/api/stream/channels/reversi-game.ts b/packages/backend/src/server/api/stream/channels/reversi-game.ts index 2d8c396db9..c5d05e5cfb 100644 --- a/packages/backend/src/server/api/stream/channels/reversi-game.ts +++ b/packages/backend/src/server/api/stream/channels/reversi-game.ts @@ -46,7 +46,7 @@ class ReversiGameChannel extends Channel { case 'ready': this.ready(body); break; case 'updateSettings': this.updateSettings(body.key, body.value); break; case 'putStone': this.putStone(body.pos, body.id); break; - case 'syncState': this.syncState(body.crc32); break; + case 'heatbeat': this.heatbeat(body.crc32); break; } } @@ -83,15 +83,21 @@ class ReversiGameChannel extends Channel { } @bindThis - private async syncState(crc32: string | number) { + private async heatbeat(crc32?: string | number | null) { // TODO: キャッシュしたい const game = await this.reversiGamesRepository.findOneBy({ id: this.gameId! }); if (game == null) throw new Error('game not found'); if (!game.isStarted) return; - if (crc32.toString() !== game.crc32) { - this.send('rescue', await this.reversiGameEntityService.packDetail(game, this.user)); + if (crc32 != null) { + if (crc32.toString() !== game.crc32) { + this.send('rescue', await this.reversiGameEntityService.packDetail(game, this.user)); + } + } + + if (this.user && (game.user1Id === this.user.id || game.user2Id === this.user.id)) { + this.reversiService.heatbeat(game, this.user); } } diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue index 3a24777db8..2f09cf39e8 100644 --- a/packages/frontend/src/pages/reversi/game.board.vue +++ b/packages/frontend/src/pages/reversi/game.board.vue @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="(logPos !== game.logs.length) && turnUser" class="turn"> <Mfm :key="'past-turn-of:' + turnUser.id" :text="i18n.tsx._reversi.pastTurnOf({ name: turnUser.name ?? turnUser.username })" :plain="true" :customEmojis="turnUser.emojis"/> </div> - <div v-if="iAmPlayer && !game.isEnded && !isMyTurn" class="turn1">{{ i18n.ts._reversi.opponentTurn }}<MkEllipsis/></div> + <div v-if="iAmPlayer && !game.isEnded && !isMyTurn" class="turn1">{{ i18n.ts._reversi.opponentTurn }}<MkEllipsis/><soan v-if="opponentNotResponding" style="margin-left: 8px;">({{ i18n.ts.notResponding }})</soan></div> <div v-if="iAmPlayer && !game.isEnded && isMyTurn" class="turn2" style="animation: tada 1s linear infinite both;">{{ i18n.ts._reversi.myTurn }}</div> <div v-if="game.isEnded && logPos == game.logs.length" class="result"> <template v-if="game.winner"> @@ -139,7 +139,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onMounted, onUnmounted, ref, shallowRef, triggerRef, watch } from 'vue'; +import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref, shallowRef, triggerRef, watch } from 'vue'; import * as CRC32 from 'crc-32'; import * as Misskey from 'misskey-js'; import * as Reversi from 'misskey-reversi'; @@ -239,7 +239,7 @@ if (game.value.isStarted && !game.value.isEnded) { if (game.value.isEnded) return; const crc32 = CRC32.str(JSON.stringify(game.value.logs)).toString(); if (_DEV_) console.log('crc32', crc32); - props.connection.send('syncState', { + props.connection.send('heatbeat', { crc32: crc32, }); }, 10000, { immediate: false, afterMounted: true }); @@ -339,6 +339,27 @@ function onStreamRescue(_game) { checkEnd(); } +const opponentLastHeatbeatedAt = ref<number>(Date.now()); +const opponentNotResponding = ref<boolean>(false); + +useInterval(() => { + if (game.value.isEnded) return; + if (!iAmPlayer.value) return; + + if (Date.now() - opponentLastHeatbeatedAt.value > 20000) { + opponentNotResponding.value = true; + } else { + opponentNotResponding.value = false; + } +}, 1000, { immediate: false, afterMounted: true }); + +function onStreamHeatbeat({ userId }) { + if ($i.id === userId) return; + + opponentNotResponding.value = false; + opponentLastHeatbeatedAt.value = Date.now(); +} + async function surrender() { const { canceled } = await os.confirm({ type: 'warning', @@ -390,12 +411,28 @@ function share() { onMounted(() => { props.connection.on('log', onStreamLog); + props.connection.on('heatbeat', onStreamHeatbeat); props.connection.on('rescue', onStreamRescue); props.connection.on('ended', onStreamEnded); }); +onActivated(() => { + props.connection.on('log', onStreamLog); + props.connection.on('heatbeat', onStreamHeatbeat); + props.connection.on('rescue', onStreamRescue); + props.connection.on('ended', onStreamEnded); +}); + +onDeactivated(() => { + props.connection.off('log', onStreamLog); + props.connection.off('heatbeat', onStreamHeatbeat); + props.connection.off('rescue', onStreamRescue); + props.connection.off('ended', onStreamEnded); +}); + onUnmounted(() => { props.connection.off('log', onStreamLog); + props.connection.off('heatbeat', onStreamHeatbeat); props.connection.off('rescue', onStreamRescue); props.connection.off('ended', onStreamEnded); }); @@ -483,7 +520,7 @@ $gap: 4px; .boardCell { background: transparent; - border-radius: 6px; + border-radius: 100%; aspect-ratio: 1; transform-style: preserve-3d; perspective: 150px; @@ -534,6 +571,6 @@ $gap: 4px; display: block; width: 100%; height: 100%; - border-radius: 6px; + border-radius: 100%; } </style> diff --git a/packages/frontend/src/pages/reversi/index.vue b/packages/frontend/src/pages/reversi/index.vue index 5fbbbef2c5..796e732208 100644 --- a/packages/frontend/src/pages/reversi/index.vue +++ b/packages/frontend/src/pages/reversi/index.vue @@ -89,7 +89,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onMounted, onUnmounted, ref } from 'vue'; +import { computed, onDeactivated, onMounted, onUnmounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -214,6 +214,14 @@ onMounted(() => { }); }); +onDeactivated(() => { + cancelMatching(); +}); + +onUnmounted(() => { + cancelMatching(); +}); + definePageMetadata(computed(() => ({ title: 'Reversi', icon: 'ti ti-device-gamepad', diff --git a/packages/frontend/src/scripts/use-interval.ts b/packages/frontend/src/scripts/use-interval.ts index b8c5431fb6..d8ffb2205b 100644 --- a/packages/frontend/src/scripts/use-interval.ts +++ b/packages/frontend/src/scripts/use-interval.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { onMounted, onUnmounted } from 'vue'; +import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue'; export function useInterval(fn: () => void, interval: number, options: { immediate: boolean; @@ -28,6 +28,16 @@ export function useInterval(fn: () => void, interval: number, options: { intervalId = null; }; + onActivated(() => { + if (intervalId) return; + if (options.immediate) fn(); + intervalId = window.setInterval(fn, interval); + }); + + onDeactivated(() => { + clear(); + }); + onUnmounted(() => { clear(); });