summaryrefslogtreecommitdiff
path: root/packages/frontend/src/pages
diff options
context:
space:
mode:
Diffstat (limited to 'packages/frontend/src/pages')
-rw-r--r--packages/frontend/src/pages/drop-and-fusion.vue2
-rw-r--r--packages/frontend/src/pages/games.vue15
-rw-r--r--packages/frontend/src/pages/reversi/game.board.vue428
-rw-r--r--packages/frontend/src/pages/reversi/game.setting.vue236
-rw-r--r--packages/frontend/src/pages/reversi/game.vue68
-rw-r--r--packages/frontend/src/pages/reversi/index.vue271
6 files changed, 1015 insertions, 5 deletions
diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue
index dd3b189c9d..beb2e714e0 100644
--- a/packages/frontend/src/pages/drop-and-fusion.vue
+++ b/packages/frontend/src/pages/drop-and-fusion.vue
@@ -123,7 +123,7 @@ function onGameEnd() {
definePageMetadata({
title: i18n.ts.bubbleGame,
- icon: 'ti ti-apple',
+ icon: 'ti ti-device-gamepad',
});
</script>
diff --git a/packages/frontend/src/pages/games.vue b/packages/frontend/src/pages/games.vue
index 5d2482ded1..45a135a459 100644
--- a/packages/frontend/src/pages/games.vue
+++ b/packages/frontend/src/pages/games.vue
@@ -7,10 +7,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<MkSpacer :contentMax="800">
- <div class="_panel">
- <MkA to="/bubble-game">
- <img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
- </MkA>
+ <div class="_gaps">
+ <div class="_panel">
+ <MkA to="/bubble-game">
+ <img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
+ </MkA>
+ </div>
+ <div class="_panel">
+ <MkA to="/reversi">
+ <img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
+ </MkA>
+ </div>
</div>
</MkSpacer>
</MkStickyContainer>
diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue
new file mode 100644
index 0000000000..18fd74427c
--- /dev/null
+++ b/packages/frontend/src/pages/reversi/game.board.vue
@@ -0,0 +1,428 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkSpacer :contentMax="600">
+ <div :class="$style.root" class="_gaps">
+ <header><b><MkA :to="userPage(blackUser)"><MkUserName :user="blackUser"/></MkA></b>({{ i18n.ts._reversi.black }}) vs <b><MkA :to="userPage(whiteUser)"><MkUserName :user="whiteUser"/></MkA></b>({{ i18n.ts._reversi.white }})</header>
+
+ <div style="overflow: clip; line-height: 28px;">
+ <div v-if="!iAmPlayer && !game.isEnded && turnUser" class="turn">
+ <Mfm :key="'turn:' + turnUser.id" :text="i18n.t('_reversi.turnOf', { name: turnUser.name ?? turnUser.username })" :plain="true" :customEmojis="turnUser.emojis"/>
+ <MkEllipsis/>
+ </div>
+ <div v-if="(logPos !== logs.length) && turnUser" class="turn">
+ <Mfm :key="'past-turn-of:' + turnUser.id" :text="i18n.t('_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="turn2" style="animation: tada 1s linear infinite both;">{{ i18n.ts._reversi.myTurn }}</div>
+ <div v-if="game.isEnded && logPos == logs.length" class="result">
+ <template v-if="game.winner">
+ <Mfm :key="'won'" :text="i18n.t('_reversi.won', { name: game.winner.name ?? game.winner.username })" :plain="true" :customEmojis="game.winner.emojis"/>
+ <span v-if="game.surrendered != null"> ({{ i18n.ts._reversi.surrendered }})</span>
+ </template>
+ <template v-else>{{ i18n.ts._reversi.drawn }}</template>
+ </div>
+ </div>
+
+ <div :class="$style.board">
+ <div v-if="showBoardLabels" :class="$style.labelsX">
+ <span v-for="i in game.map[0].length" :class="$style.labelsXLabel">{{ String.fromCharCode(64 + i) }}</span>
+ </div>
+ <div style="display: flex;">
+ <div v-if="showBoardLabels" :class="$style.labelsY">
+ <div v-for="i in game.map.length" :class="$style.labelsYLabel">{{ i }}</div>
+ </div>
+ <div :class="$style.boardCells" :style="cellsStyle">
+ <div
+ v-for="(stone, i) in engine.board"
+ v-tooltip="`${String.fromCharCode(65 + engine.posToXy(i)[0])}${engine.posToXy(i)[1] + 1}`"
+ :class="[$style.boardCell, {
+ [$style.boardCell_empty]: stone == null,
+ [$style.boardCell_none]: engine.map[i] === 'null',
+ [$style.boardCell_isEnded]: game.isEnded,
+ [$style.boardCell_myTurn]: !game.isEnded && isMyTurn,
+ [$style.boardCell_can]: turnUser ? engine.canPut(turnUser.id === blackUser.id, i) : null,
+ [$style.boardCell_prev]: engine.prevPos === i
+ }]"
+ @click="putStone(i)"
+ >
+ <img v-if="stone === true" style="pointer-events: none; user-select: none; display: block; width: 100%; height: 100%;" :src="blackUser.avatarUrl">
+ <img v-if="stone === false" style="pointer-events: none; user-select: none; display: block; width: 100%; height: 100%;" :src="whiteUser.avatarUrl">
+ </div>
+ </div>
+ <div v-if="showBoardLabels" :class="$style.labelsY">
+ <div v-for="i in game.map.length" :class="$style.labelsYLabel">{{ i }}</div>
+ </div>
+ </div>
+ <div v-if="showBoardLabels" :class="$style.labelsX">
+ <span v-for="i in game.map[0].length" :class="$style.labelsXLabel">{{ String.fromCharCode(64 + i) }}</span>
+ </div>
+ </div>
+
+ <div class="status"><b>{{ i18n.t('_reversi.turnCount', { count: logPos }) }}</b> {{ i18n.ts._reversi.black }}:{{ engine.blackCount }} {{ i18n.ts._reversi.white }}:{{ engine.whiteCount }} {{ i18n.ts._reversi.total }}:{{ engine.blackCount + engine.whiteCount }}</div>
+
+ <div v-if="!game.isEnded && iAmPlayer" class="_buttonsCenter">
+ <MkButton danger @click="surrender">{{ i18n.ts._reversi.surrender }}</MkButton>
+ </div>
+
+ <div v-if="game.isEnded" class="_panel _gaps_s" style="padding: 16px;">
+ <div>{{ logPos }} / {{ logs.length }}</div>
+ <div v-if="!autoplaying" class="_buttonsCenter">
+ <MkButton :disabled="logPos === 0" @click="logPos = 0"><i class="ti ti-chevrons-left"></i></MkButton>
+ <MkButton :disabled="logPos === 0" @click="logPos--"><i class="ti ti-chevron-left"></i></MkButton>
+ <MkButton :disabled="logPos === logs.length" @click="logPos++"><i class="ti ti-chevron-right"></i></MkButton>
+ <MkButton :disabled="logPos === logs.length" @click="logPos = logs.length"><i class="ti ti-chevrons-right"></i></MkButton>
+ </div>
+ <MkButton style="margin: auto;" :disabled="autoplaying" @click="autoplay()"><i class="ti ti-player-play"></i></MkButton>
+ </div>
+
+ <div>
+ <p v-if="game.isLlotheo">{{ i18n.ts._reversi.isLlotheo }}</p>
+ <p v-if="game.loopedBoard">{{ i18n.ts._reversi.loopedMap }}</p>
+ <p v-if="game.canPutEverywhere">{{ i18n.ts._reversi.canPutEverywhere }}</p>
+ </div>
+
+ <MkA v-if="game.isEnded" :to="`/reversi`">
+ <img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; width: 200px; margin: auto;"/>
+ </MkA>
+ </div>
+</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import { computed, 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';
+import MkButton from '@/components/MkButton.vue';
+import { deepClone } from '@/scripts/clone.js';
+import { useInterval } from '@/scripts/use-interval.js';
+import { signinRequired } from '@/account.js';
+import { i18n } from '@/i18n.js';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import { userPage } from '@/filters/user.js';
+
+const $i = signinRequired();
+
+const props = defineProps<{
+ game: Misskey.entities.ReversiGameDetailed;
+ connection: Misskey.ChannelConnection;
+}>();
+
+const showBoardLabels = true;
+const autoplaying = ref<boolean>(false);
+const game = ref<Misskey.entities.ReversiGameDetailed>(deepClone(props.game));
+const logs = ref<Misskey.entities.ReversiLog[]>(game.value.logs);
+const logPos = ref<number>(logs.value.length);
+const engine = shallowRef<Reversi.Game>(new Reversi.Game(game.value.map, {
+ isLlotheo: game.value.isLlotheo,
+ canPutEverywhere: game.value.canPutEverywhere,
+ loopedBoard: game.value.loopedBoard,
+}));
+
+for (const log of game.value.logs) {
+ engine.value.put(log.color, log.pos);
+}
+
+const iAmPlayer = computed(() => {
+ return game.value.user1Id === $i.id || game.value.user2Id === $i.id;
+});
+
+const myColor = computed(() => {
+ if (!iAmPlayer.value) return null;
+ if (game.value.user1Id === $i.id && game.value.black === 1) return true;
+ if (game.value.user2Id === $i.id && game.value.black === 2) return true;
+ return false;
+});
+
+const opColor = computed(() => {
+ if (!iAmPlayer.value) return null;
+ return !myColor.value;
+});
+
+const blackUser = computed(() => {
+ return game.value.black === 1 ? game.value.user1 : game.value.user2;
+});
+
+const whiteUser = computed(() => {
+ return game.value.black === 1 ? game.value.user2 : game.value.user1;
+});
+
+const turnUser = computed(() => {
+ if (engine.value.turn === true) {
+ return game.value.black === 1 ? game.value.user1 : game.value.user2;
+ } else if (engine.value.turn === false) {
+ return game.value.black === 1 ? game.value.user2 : game.value.user1;
+ } else {
+ return null;
+ }
+});
+
+const isMyTurn = computed(() => {
+ if (!iAmPlayer.value) return false;
+ const u = turnUser.value;
+ if (u == null) return false;
+ return u.id === $i.id;
+});
+
+const cellsStyle = computed(() => {
+ return {
+ 'grid-template-rows': `repeat(${game.value.map.length}, 1fr)`,
+ 'grid-template-columns': `repeat(${game.value.map[0].length}, 1fr)`,
+ };
+});
+
+watch(logPos, (v) => {
+ if (!game.value.isEnded) return;
+ const _o = new Reversi.Game(game.value.map, {
+ isLlotheo: game.value.isLlotheo,
+ canPutEverywhere: game.value.canPutEverywhere,
+ loopedBoard: game.value.loopedBoard,
+ });
+ for (const log of logs.value.slice(0, v)) {
+ _o.put(log.color, log.pos);
+ }
+ engine.value = _o;
+});
+
+if (game.value.isStarted && !game.value.isEnded) {
+ useInterval(() => {
+ if (game.value.isEnded) return;
+ const crc32 = CRC32.str(logs.value.map(x => x.pos.toString()).join(''));
+ props.connection.send('syncState', {
+ crc32: crc32,
+ });
+ }, 5000, { immediate: false, afterMounted: true });
+}
+
+function putStone(pos) {
+ if (game.value.isEnded) return;
+ if (!iAmPlayer.value) return;
+ if (!isMyTurn.value) return;
+ if (!engine.value.canPut(myColor.value!, pos)) return;
+
+ engine.value.put(myColor.value!, pos);
+ triggerRef(engine);
+
+ // サウンドを再生する
+ //sound.play(myColor.value ? 'reversiPutBlack' : 'reversiPutWhite');
+
+ props.connection.send('putStone', {
+ pos: pos,
+ });
+
+ checkEnd();
+}
+
+function onPutStone(x) {
+ logs.value.push(x);
+ logPos.value++;
+ engine.value.put(x.color, x.pos);
+ triggerRef(engine);
+ checkEnd();
+
+ // サウンドを再生する
+ if (x.color !== myColor.value) {
+ //sound.play(x.color ? 'reversiPutBlack' : 'reversiPutWhite');
+ }
+}
+
+function onEnded(x) {
+ game.value = deepClone(x.game);
+}
+
+function checkEnd() {
+ game.value.isEnded = engine.value.isEnded;
+ if (game.value.isEnded) {
+ if (engine.value.winner === true) {
+ game.value.winnerId = game.value.black === 1 ? game.value.user1Id : game.value.user2Id;
+ game.value.winner = game.value.black === 1 ? game.value.user1 : game.value.user2;
+ } else if (engine.value.winner === false) {
+ game.value.winnerId = game.value.black === 1 ? game.value.user2Id : game.value.user1Id;
+ game.value.winner = game.value.black === 1 ? game.value.user2 : game.value.user1;
+ } else {
+ game.value.winnerId = null;
+ game.value.winner = null;
+ }
+ }
+}
+
+function onRescue(_game) {
+ game.value = deepClone(_game);
+
+ engine.value = new Reversi.Game(game.value.map, {
+ isLlotheo: game.value.isLlotheo,
+ canPutEverywhere: game.value.canPutEverywhere,
+ loopedBoard: game.value.loopedBoard,
+ });
+
+ for (const log of game.value.logs) {
+ engine.value.put(log.color, log.pos);
+ }
+
+ triggerRef(engine);
+
+ logs.value = game.value.logs;
+ logPos.value = logs.value.length;
+
+ checkEnd();
+}
+
+function surrender() {
+ misskeyApi('reversi/surrender', {
+ gameId: game.value.id,
+ });
+}
+
+function autoplay() {
+ autoplaying.value = true;
+ logPos.value = 0;
+
+ window.setTimeout(() => {
+ logPos.value = 1;
+
+ let i = 1;
+ let previousLog = game.value.logs[0];
+ const tick = () => {
+ const log = game.value.logs[i];
+ const time = new Date(log.at).getTime() - new Date(previousLog.at).getTime();
+ setTimeout(() => {
+ i++;
+ logPos.value++;
+ previousLog = log;
+
+ if (i < game.value.logs.length) {
+ tick();
+ } else {
+ autoplaying.value = false;
+ }
+ }, time);
+ };
+
+ tick();
+ }, 1000);
+}
+
+onMounted(() => {
+ props.connection.on('putStone', onPutStone);
+ props.connection.on('rescue', onRescue);
+ props.connection.on('ended', onEnded);
+});
+
+onUnmounted(() => {
+ props.connection.off('putStone', onPutStone);
+ props.connection.off('rescue', onRescue);
+ props.connection.off('ended', onEnded);
+});
+</script>
+
+<style lang="scss" module>
+@use "sass:math";
+
+$label-size: 16px;
+$gap: 4px;
+
+.root {
+ text-align: center;
+}
+
+.board {
+ width: calc(100% - 16px);
+ max-width: 500px;
+ margin: 0 auto;
+}
+
+.labelsX {
+ height: $label-size;
+ padding: 0 $label-size;
+ display: flex;
+}
+
+.labelsXLabel {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.8em;
+
+ &:first-child {
+ margin-left: -(math.div($gap, 2));
+ }
+
+ &:last-child {
+ margin-right: -(math.div($gap, 2));
+ }
+}
+
+.labelsY {
+ width: $label-size;
+ display: flex;
+ flex-direction: column;
+}
+
+.labelsYLabel {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+
+ &:first-child {
+ margin-top: -(math.div($gap, 2));
+ }
+
+ &:last-child {
+ margin-bottom: -(math.div($gap, 2));
+ }
+}
+
+.boardCells {
+ flex: 1;
+ display: grid;
+ grid-gap: $gap;
+}
+
+.boardCell {
+ background: transparent;
+ border-radius: 6px;
+ overflow: clip;
+
+ &.boardCell_empty {
+ border: solid 2px var(--divider);
+ }
+
+ &.boardCell_empty.boardCell_can {
+ border-color: var(--accent);
+ opacity: 0.5;
+ }
+
+ &.boardCell_empty.boardCell_myTurn {
+ border-color: var(--divider);
+ opacity: 1;
+
+ &.boardCell_can {
+ border-color: var(--accent);
+ cursor: pointer;
+
+ &:hover {
+ background: var(--accent);
+ }
+ }
+ }
+
+ &.boardCell_prev {
+ box-shadow: 0 0 0 4px var(--accent);
+ }
+
+ &.boardCell_isEnded {
+ border-color: var(--divider);
+ }
+
+ &.boardCell_none {
+ border-color: transparent !important;
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/reversi/game.setting.vue b/packages/frontend/src/pages/reversi/game.setting.vue
new file mode 100644
index 0000000000..301a177de1
--- /dev/null
+++ b/packages/frontend/src/pages/reversi/game.setting.vue
@@ -0,0 +1,236 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkStickyContainer>
+ <MkSpacer :contentMax="600">
+ <div style="text-align: center;"><b><MkUserName :user="game.user1"/></b> vs <b><MkUserName :user="game.user2"/></b></div>
+
+ <div class="_gaps">
+ <div style="font-size: 1.5em; text-align: center;">{{ i18n.ts._reversi.gameSettings }}</div>
+
+ <div class="_panel">
+ <div style="display: flex; align-items: center; padding: 16px; border-bottom: solid 1px var(--divider);">
+ <div>{{ mapName }}</div>
+ <MkButton style="margin-left: auto;" @click="chooseMap">{{ i18n.ts._reversi.chooseBoard }}</MkButton>
+ </div>
+
+ <div style="padding: 16px;">
+ <div v-if="game.map == null"><i class="ti ti-dice"></i></div>
+ <div v-else :class="$style.board" :style="{ 'grid-template-rows': `repeat(${ game.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.map[0].length }, 1fr)` }">
+ <div v-for="(x, i) in game.map.join('')" :class="[$style.boardCell, { [$style.boardCellNone]: x == ' ' }]" @click="onMapCellClick(i, x)">
+ <i v-if="x === 'b' || x === 'w'" style="pointer-events: none; user-select: none;" :class="x === 'b' ? 'ti ti-circle-filled' : 'ti ti-circle'"></i>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <MkFolder :defaultOpen="true">
+ <template #label>{{ i18n.ts._reversi.blackOrWhite }}</template>
+
+ <MkRadios v-model="game.bw">
+ <option value="random">{{ i18n.ts.random }}</option>
+ <option :value="'1'">
+ <I18n :src="i18n.ts._reversi.blackIs" tag="span">
+ <template #name>
+ <b><MkUserName :user="game.user1"/></b>
+ </template>
+ </I18n>
+ </option>
+ <option :value="'2'">
+ <I18n :src="i18n.ts._reversi.blackIs" tag="span">
+ <template #name>
+ <b><MkUserName :user="game.user2"/></b>
+ </template>
+ </I18n>
+ </option>
+ </MkRadios>
+ </MkFolder>
+
+ <MkFolder :defaultOpen="true">
+ <template #label>{{ i18n.ts._reversi.rules }}</template>
+
+ <div class="_gaps_s">
+ <MkSwitch v-model="game.isLlotheo" @update:modelValue="updateSettings('isLlotheo')">{{ i18n.ts._reversi.isLlotheo }}</MkSwitch>
+ <MkSwitch v-model="game.loopedBoard" @update:modelValue="updateSettings('loopedBoard')">{{ i18n.ts._reversi.loopedMap }}</MkSwitch>
+ <MkSwitch v-model="game.canPutEverywhere" @update:modelValue="updateSettings('canPutEverywhere')">{{ i18n.ts._reversi.canPutEverywhere }}</MkSwitch>
+ </div>
+ </MkFolder>
+ </div>
+ </MkSpacer>
+ <template #footer>
+ <div :class="$style.footer">
+ <MkSpacer :contentMax="700" :marginMin="16" :marginMax="16">
+ <div style="text-align: center; margin-bottom: 10px;">
+ <template v-if="isReady && isOpReady">{{ i18n.ts._reversi.thisGameIsStartedSoon }}<MkEllipsis/></template>
+ <template v-if="isReady && !isOpReady">{{ i18n.ts._reversi.waitingForOther }}<MkEllipsis/></template>
+ <template v-if="!isReady && isOpReady">{{ i18n.ts._reversi.waitingForMe }}</template>
+ <template v-if="!isReady && !isOpReady">{{ i18n.ts._reversi.waitingBoth }}<MkEllipsis/></template>
+ </div>
+ <div class="_buttonsCenter">
+ <MkButton rounded danger @click="exit">{{ i18n.ts.cancel }}</MkButton>
+ <MkButton v-if="!isReady" rounded primary @click="ready">{{ i18n.ts._reversi.ready }}</MkButton>
+ <MkButton v-if="isReady" rounded @click="unready">{{ i18n.ts._reversi.cancelReady }}</MkButton>
+ </div>
+ </MkSpacer>
+ </div>
+ </template>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue';
+import * as Misskey from 'misskey-js';
+import * as Reversi from 'misskey-reversi';
+import { i18n } from '@/i18n.js';
+import { signinRequired } from '@/account.js';
+import { deepClone } from '@/scripts/clone.js';
+import MkButton from '@/components/MkButton.vue';
+import MkRadios from '@/components/MkRadios.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import * as os from '@/os.js';
+import { MenuItem } from '@/types/menu.js';
+
+const $i = signinRequired();
+
+const mapCategories = Array.from(new Set(Object.values(Reversi.maps).map(x => x.category)));
+
+const props = defineProps<{
+ game: Misskey.entities.ReversiGameDetailed;
+ connection: Misskey.ChannelConnection;
+}>();
+
+const game = ref<Misskey.entities.ReversiGameDetailed>(deepClone(props.game));
+const isLlotheo = ref<boolean>(false);
+const mapName = computed(() => {
+ if (game.value.map == null) return 'Random';
+ const found = Object.values(Reversi.maps).find(x => x.data.join('') === game.value.map.join(''));
+ return found ? found.name! : '-Custom-';
+});
+const isReady = computed(() => {
+ if (game.value.user1Id === $i.id && game.value.user1Ready) return true;
+ if (game.value.user2Id === $i.id && game.value.user2Ready) return true;
+ return false;
+});
+const isOpReady = computed(() => {
+ if (game.value.user1Id !== $i.id && game.value.user1Ready) return true;
+ if (game.value.user2Id !== $i.id && game.value.user2Ready) return true;
+ return false;
+});
+
+watch(() => game.value.bw, () => {
+ updateSettings('bw');
+});
+
+function chooseMap(ev: MouseEvent) {
+ const menu: MenuItem[] = [];
+
+ for (const c of mapCategories) {
+ const maps = Object.values(Reversi.maps).filter(x => x.category === c);
+ if (maps.length === 0) continue;
+ if (c != null) {
+ menu.push({
+ type: 'label',
+ text: c,
+ });
+ }
+ for (const m of maps) {
+ menu.push({
+ text: m.name!,
+ action: () => {
+ game.value.map = m.data;
+ updateSettings('map');
+ },
+ });
+ }
+ }
+
+ os.popupMenu(menu, ev.currentTarget ?? ev.target);
+}
+
+function exit() {
+ props.connection.send('exit', {});
+}
+
+function ready() {
+ props.connection.send('ready', true);
+}
+
+function unready() {
+ props.connection.send('ready', false);
+}
+
+function onChangeReadyStates(states) {
+ game.value.user1Ready = states.user1;
+ game.value.user2Ready = states.user2;
+}
+
+function updateSettings(key: keyof Misskey.entities.ReversiGameDetailed) {
+ props.connection.send('updateSettings', {
+ key: key,
+ value: game.value[key],
+ });
+}
+
+function onUpdateSettings({ userId, key, value }: { userId: string; key: keyof Misskey.entities.ReversiGameDetailed; value: any; }) {
+ if (userId === $i.id) return;
+ if (game.value[key] === value) return;
+ game.value[key] = value;
+}
+
+function onMapCellClick(pos: number, pixel: string) {
+ const x = pos % game.value.map[0].length;
+ const y = Math.floor(pos / game.value.map[0].length);
+ const newPixel =
+ pixel === ' ' ? '-' :
+ pixel === '-' ? 'b' :
+ pixel === 'b' ? 'w' :
+ ' ';
+ const line = game.value.map[y].split('');
+ line[x] = newPixel;
+ game.value.map[y] = line.join('');
+ updateSettings('map');
+}
+
+props.connection.on('changeReadyStates', onChangeReadyStates);
+props.connection.on('updateSettings', onUpdateSettings);
+
+onUnmounted(() => {
+ props.connection.off('changeReadyStates', onChangeReadyStates);
+ props.connection.off('updateSettings', onUpdateSettings);
+});
+</script>
+
+<style lang="scss" module>
+.board {
+ display: grid;
+ grid-gap: 4px;
+ width: 300px;
+ height: 300px;
+ margin: 0 auto;
+ color: var(--fg);
+}
+
+.boardCell {
+ display: grid;
+ place-items: center;
+ background: transparent;
+ border: solid 2px var(--divider);
+ border-radius: 6px;
+ overflow: clip;
+ cursor: pointer;
+}
+.boardCellNone {
+ border-color: transparent;
+}
+
+.footer {
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
+ background: var(--acrylicBg);
+ border-top: solid 0.5px var(--divider);
+}
+</style>
diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue
new file mode 100644
index 0000000000..dbbeb20f42
--- /dev/null
+++ b/packages/frontend/src/pages/reversi/game.vue
@@ -0,0 +1,68 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div v-if="game == null || connection == null"><MkLoading/></div>
+<GameSetting v-else-if="!game.isStarted" :game="game" :connection="connection"/>
+<GameBoard v-else :game="game" :connection="connection"/>
+</template>
+
+<script lang="ts" setup>
+import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue';
+import * as Misskey from 'misskey-js';
+import GameSetting from './game.setting.vue';
+import GameBoard from './game.board.vue';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { useStream } from '@/stream.js';
+
+const props = defineProps<{
+ gameId: string;
+}>();
+
+const game = shallowRef<Misskey.entities.ReversiGameDetailed | null>(null);
+const connection = shallowRef<Misskey.ChannelConnection | null>(null);
+
+watch(() => props.gameId, () => {
+ fetchGame();
+});
+
+async function fetchGame() {
+ const _game = await misskeyApi('reversi/show-game', {
+ gameId: props.gameId,
+ });
+
+ game.value = _game;
+
+ if (connection.value) {
+ connection.value.dispose();
+ }
+ connection.value = useStream().useChannel('reversiGame', {
+ gameId: game.value.id,
+ });
+ connection.value.on('started', x => {
+ game.value = x.game;
+ });
+}
+
+onMounted(() => {
+ fetchGame();
+});
+
+onUnmounted(() => {
+ if (connection.value) {
+ connection.value.dispose();
+ }
+});
+
+const headerActions = computed(() => []);
+
+const headerTabs = computed(() => []);
+
+definePageMetadata(computed(() => ({
+ title: 'Reversi',
+ icon: 'ti ti-device-gamepad',
+})));
+</script>
diff --git a/packages/frontend/src/pages/reversi/index.vue b/packages/frontend/src/pages/reversi/index.vue
new file mode 100644
index 0000000000..c483e36c24
--- /dev/null
+++ b/packages/frontend/src/pages/reversi/index.vue
@@ -0,0 +1,271 @@
+<!--
+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="_buttonsCenter">
+ <MkButton primary gradate rounded @click="matchAny">{{ i18n.ts._reversi.freeMatch }}</MkButton>
+ <MkButton primary gradate rounded @click="matchUser">{{ i18n.ts.invite }}</MkButton>
+ </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">
+ <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"/><b><MkUserName :user="g.user1"/></b> vs <b><MkUserName :user="g.user2"/></b><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">
+ <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"/><b><MkUserName :user="g.user1"/></b> vs <b><MkUserName :user="g.user2"/></b><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>