diff options
Diffstat (limited to 'packages/frontend/src/pages')
| -rw-r--r-- | packages/frontend/src/pages/drop-and-fusion.vue | 2 | ||||
| -rw-r--r-- | packages/frontend/src/pages/games.vue | 15 | ||||
| -rw-r--r-- | packages/frontend/src/pages/reversi/game.board.vue | 428 | ||||
| -rw-r--r-- | packages/frontend/src/pages/reversi/game.setting.vue | 236 | ||||
| -rw-r--r-- | packages/frontend/src/pages/reversi/game.vue | 68 | ||||
| -rw-r--r-- | packages/frontend/src/pages/reversi/index.vue | 271 |
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> |