<!-- SPDX-FileCopyrightText: syuilo and other misskey contributors SPDX-License-Identifier: AGPL-3.0-only --> <template> <MkSpacer v-if="!matchingAny && !matchingUser" :contentMax="600"> <div class="_gaps"> <div> <img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/> </div> <div class="_panel" style="padding: 16px;"> <div class="_buttonsCenter"> <MkButton primary gradate rounded @click="matchAny">{{ i18n.ts._reversi.freeMatch }}</MkButton> <MkButton primary gradate rounded @click="matchUser">{{ i18n.ts.invite }}</MkButton> </div> </div> <MkFolder v-if="invitations.length > 0" :defaultOpen="true"> <template #label>{{ i18n.ts.invitations }}</template> <div class="_gaps_s"> <button v-for="user in invitations" :key="user.id" v-panel :class="$style.invitation" class="_button" tabindex="-1" @click="accept(user)"> <MkAvatar style="width: 32px; height: 32px; margin-right: 8px;" :user="user" :showIndicator="true"/> <span style="margin-right: 8px;"><b><MkUserName :user="user"/></b></span> <span>@{{ user.username }}</span> </button> </div> </MkFolder> <MkFolder v-if="$i" :defaultOpen="true"> <template #label>{{ i18n.ts._reversi.myGames }}</template> <MkPagination :pagination="myGamesPagination" :disableAutoLoad="true"> <template #default="{ items }"> <div :class="$style.gamePreviews"> <MkA v-for="g in items" :key="g.id" v-panel :class="$style.gamePreview" tabindex="-1" :to="`/reversi/g/${g.id}`"> <div :class="$style.gamePreviewPlayers"> <MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user1"/> vs <MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user2"/> </div> <div :class="$style.gamePreviewFooter"> <span :style="!g.isEnded ? 'color: var(--accent);' : ''">{{ g.isEnded ? i18n.ts._reversi.ended : i18n.ts._reversi.playing }}</span> <MkTime style="margin-left: auto; opacity: 0.7;" :time="g.createdAt"/> </div> </MkA> </div> </template> </MkPagination> </MkFolder> <MkFolder :defaultOpen="true"> <template #label>{{ i18n.ts._reversi.allGames }}</template> <MkPagination :pagination="gamesPagination" :disableAutoLoad="true"> <template #default="{ items }"> <div :class="$style.gamePreviews"> <MkA v-for="g in items" :key="g.id" v-panel :class="$style.gamePreview" tabindex="-1" :to="`/reversi/g/${g.id}`"> <div :class="$style.gamePreviewPlayers"> <MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user1"/> vs <MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user2"/> </div> <div :class="$style.gamePreviewFooter"> <span :style="!g.isEnded ? 'color: var(--accent);' : ''">{{ g.isEnded ? i18n.ts._reversi.ended : i18n.ts._reversi.playing }}</span> <MkTime style="margin-left: auto; opacity: 0.7;" :time="g.createdAt"/> </div> </MkA> </div> </template> </MkPagination> </MkFolder> </div> </MkSpacer> <MkSpacer v-else :contentMax="600"> <div :class="$style.waitingScreen"> <div v-if="matchingUser" :class="$style.waitingScreenTitle"> <I18n :src="i18n.ts.waitingFor" tag="span"> <template #x> <b><MkUserName :user="matchingUser"/></b> </template> </I18n> <MkEllipsis/> </div> <div v-else :class="$style.waitingScreenTitle"> {{ i18n.ts._reversi.lookingForPlayer }}<MkEllipsis/> </div> <div class="cancel"> <MkButton inline rounded @click="cancelMatching">{{ i18n.ts.cancel }}</MkButton> </div> </div> </MkSpacer> </template> <script lang="ts" setup> import { computed, 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'; import { useStream } from '@/stream.js'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; import MkPagination from '@/components/MkPagination.vue'; import { useRouter } from '@/global/router/supplier.js'; import * as os from '@/os.js'; import { useInterval } from '@/scripts/use-interval.js'; const myGamesPagination = { endpoint: 'reversi/games' as const, limit: 10, params: { my: true, }, }; const gamesPagination = { endpoint: 'reversi/games' as const, limit: 10, }; const router = useRouter(); if ($i) { const connection = useStream().useChannel('reversi'); connection.on('matched', x => { startGame(x.game); }); connection.on('invited', invitation => { if (invitations.value.some(x => x.id === invitation.user.id)) return; invitations.value.unshift(invitation.user); }); onUnmounted(() => { connection.dispose(); }); } const invitations = ref<Misskey.entities.UserLite[]>([]); const matchingUser = ref<Misskey.entities.UserLite | null>(null); const matchingAny = ref<boolean>(false); function startGame(game: Misskey.entities.ReversiGameDetailed) { matchingUser.value = null; matchingAny.value = false; router.push(`/reversi/g/${game.id}`); } async function matchHeatbeat() { if (matchingUser.value) { const res = await misskeyApi('reversi/match', { userId: matchingUser.value.id, }); if (res != null) { startGame(res); } } else if (matchingAny.value) { const res = await misskeyApi('reversi/match', { userId: null, }); if (res != null) { startGame(res); } } } async function matchUser() { const user = await os.selectUser({ local: true }); if (user == null) return; matchingUser.value = user; matchHeatbeat(); } async function matchAny() { matchingAny.value = true; matchHeatbeat(); } function cancelMatching() { if (matchingUser.value) { misskeyApi('reversi/cancel-match', { userId: matchingUser.value.id }); matchingUser.value = null; } else if (matchingAny.value) { misskeyApi('reversi/cancel-match', { userId: null }); matchingAny.value = false; } } async function accept(user) { const game = await misskeyApi('reversi/match', { userId: user.id, }); if (game) { startGame(game); } } useInterval(matchHeatbeat, 1000 * 10, { immediate: false, afterMounted: true }); onMounted(() => { misskeyApi('reversi/invitations').then(_invitations => { invitations.value = _invitations; }); }); definePageMetadata(computed(() => ({ title: 'Reversi', icon: 'ti ti-device-gamepad', }))); </script> <style lang="scss" module> .invitation { display: flex; box-sizing: border-box; width: 100%; padding: 16px; line-height: 32px; text-align: left; } .gamePreviews { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); grid-gap: var(--margin); } .gamePreview { font-size: 90%; border-radius: 8px; overflow: clip; } .gamePreviewPlayers { text-align: center; padding: 16px; line-height: 32px; } .gamePreviewPlayersAvatar { width: 32px; height: 32px; &:first-child { margin-right: 8px; } &:last-child { margin-left: 8px; } } .gamePreviewFooter { display: flex; align-items: baseline; border-top: solid 0.5px var(--divider); padding: 6px 10px; font-size: 0.9em; } .waitingScreen { text-align: center; } .waitingScreenTitle { font-size: 1.5em; margin-bottom: 16px; margin-top: 32px; } </style>