merge: upstream (1)
This commit is contained in:
@@ -0,0 +1,615 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkSpacer :contentMax="500">
|
||||
<div :class="$style.root" class="_gaps">
|
||||
<div style="display: flex; align-items: center; justify-content: center; gap: 10px;">
|
||||
<span>({{ i18n.ts._reversi.black }})</span>
|
||||
<MkAvatar style="width: 32px; height: 32px;" :user="blackUser" :showIndicator="true"/>
|
||||
<span> vs </span>
|
||||
<MkAvatar style="width: 32px; height: 32px;" :user="whiteUser" :showIndicator="true"/>
|
||||
<span>({{ i18n.ts._reversi.white }})</span>
|
||||
</div>
|
||||
|
||||
<div style="overflow: clip; line-height: 28px;">
|
||||
<div v-if="!iAmPlayer && !game.isEnded && turnUser">
|
||||
<Mfm :key="'turn:' + turnUser.id" :text="i18n.tsx._reversi.turnOf({ name: turnUser.name ?? turnUser.username })" :plain="true" :customEmojis="turnUser.emojis"/>
|
||||
<MkEllipsis/>
|
||||
</div>
|
||||
<div v-if="(logPos !== game.logs.length) && turnUser">
|
||||
<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">{{ i18n.ts._reversi.opponentTurn }}<MkEllipsis/><span style="margin-left: 1em; opacity: 0.7;">({{ i18n.tsx.remainingN({ n: opTurnTimerRmain }) }})</span></div>
|
||||
<div v-if="iAmPlayer && !game.isEnded && isMyTurn"><span style="display: inline-block; font-weight: bold; animation: tada 1s linear infinite both;">{{ i18n.ts._reversi.myTurn }}</span><span style="margin-left: 1em; opacity: 0.7;">({{ i18n.tsx.remainingN({ n: myTurnTimerRmain }) }})</span></div>
|
||||
<div v-if="game.isEnded && logPos == game.logs.length">
|
||||
<template v-if="game.winner">
|
||||
<Mfm :key="'won'" :text="i18n.tsx._reversi.won({ name: game.winner.name ?? game.winner.username })" :plain="true" :customEmojis="game.winner.emojis"/>
|
||||
<span v-if="game.surrenderedUserId != null"> ({{ i18n.ts._reversi.surrendered }})</span>
|
||||
<span v-if="game.timeoutUserId != null"> ({{ i18n.ts._reversi.timeout }})</span>
|
||||
</template>
|
||||
<template v-else>{{ i18n.ts._reversi.drawn }}</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="$style.board">
|
||||
<div :class="$style.boardInner">
|
||||
<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"
|
||||
:key="i"
|
||||
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)"
|
||||
>
|
||||
<Transition
|
||||
:enterActiveClass="$style.transition_flip_enterActive"
|
||||
:leaveActiveClass="$style.transition_flip_leaveActive"
|
||||
:enterFromClass="$style.transition_flip_enterFrom"
|
||||
:leaveToClass="$style.transition_flip_leaveTo"
|
||||
mode="default"
|
||||
>
|
||||
<template v-if="useAvatarAsStone">
|
||||
<img v-if="stone === true" :class="$style.boardCellStone" :src="blackUser.avatarUrl"/>
|
||||
<img v-else-if="stone === false" :class="$style.boardCellStone" :src="whiteUser.avatarUrl"/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<img v-if="stone === true" :class="$style.boardCellStone" src="/client-assets/reversi/stone_b.png"/>
|
||||
<img v-else-if="stone === false" :class="$style.boardCellStone" src="/client-assets/reversi/stone_w.png"/>
|
||||
</template>
|
||||
</Transition>
|
||||
</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>
|
||||
|
||||
<div v-if="game.isEnded" class="_panel _gaps_s" style="padding: 16px;">
|
||||
<div>{{ logPos }} / {{ game.logs.length }}</div>
|
||||
<div v-if="!autoplaying" class="_buttonsCenter">
|
||||
<MkButton :disabled="logPos === 0" @click="logPos = 0"><i class="ph-caret-left ph-bold ph-lg"></i></MkButton>
|
||||
<MkButton :disabled="logPos === 0" @click="logPos--"><i class="ph-caret-left ph-bold ph-lg"></i></MkButton>
|
||||
<MkButton :disabled="logPos === game.logs.length" @click="logPos++"><i class="ph-caret-right ph-bold ph-lg"></i></MkButton>
|
||||
<MkButton :disabled="logPos === game.logs.length" @click="logPos = game.logs.length"><i class="ph-caret-right ph-bold ph-lg"></i></MkButton>
|
||||
</div>
|
||||
<MkButton style="margin: auto;" :disabled="autoplaying" @click="autoplay()"><i class="ph-play ph-bold ph-lg"></i></MkButton>
|
||||
</div>
|
||||
|
||||
<div class="_panel" style="padding: 16px;">
|
||||
<div>
|
||||
<b>{{ i18n.tsx._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>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<span style="margin-right: 8px;">({{ i18n.ts._reversi.black }})</span>
|
||||
<MkAvatar style="width: 32px; height: 32px; margin-right: 8px;" :user="blackUser" :showIndicator="true"/>
|
||||
<MkA :to="userPage(blackUser)"><MkUserName :user="blackUser"/></MkA>
|
||||
</div>
|
||||
<div> vs </div>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<span style="margin-right: 8px;">({{ i18n.ts._reversi.white }})</span>
|
||||
<MkAvatar style="width: 32px; height: 32px; margin-right: 8px;" :user="whiteUser" :showIndicator="true"/>
|
||||
<MkA :to="userPage(whiteUser)"><MkUserName :user="whiteUser"/></MkA>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<MkFolder>
|
||||
<template #label>{{ i18n.ts.options }}</template>
|
||||
<div class="_gaps_s" style="text-align: left;">
|
||||
<MkSwitch v-model="showBoardLabels">Show labels</MkSwitch>
|
||||
<MkSwitch v-model="useAvatarAsStone">useAvatarAsStone</MkSwitch>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<div class="_buttonsCenter">
|
||||
<MkButton v-if="!game.isEnded && iAmPlayer" danger @click="surrender">{{ i18n.ts._reversi.surrender }}</MkButton>
|
||||
<MkButton @click="share">{{ i18n.ts.share }}</MkButton>
|
||||
</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, 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';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.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';
|
||||
import * as sound from '@/scripts/sound.js';
|
||||
import * as os from '@/os.js';
|
||||
import { confetti } from '@/scripts/confetti.js';
|
||||
|
||||
const $i = signinRequired();
|
||||
|
||||
const props = defineProps<{
|
||||
game: Misskey.entities.ReversiGameDetailed;
|
||||
connection: Misskey.ChannelConnection;
|
||||
}>();
|
||||
|
||||
const showBoardLabels = ref<boolean>(false);
|
||||
const useAvatarAsStone = ref<boolean>(true);
|
||||
const autoplaying = ref<boolean>(false);
|
||||
const game = ref<Misskey.entities.ReversiGameDetailed>(deepClone(props.game));
|
||||
const logPos = ref<number>(game.value.logs.length);
|
||||
const engine = shallowRef<Reversi.Game>(Reversi.Serializer.restoreGame({
|
||||
map: game.value.map,
|
||||
isLlotheo: game.value.isLlotheo,
|
||||
canPutEverywhere: game.value.canPutEverywhere,
|
||||
loopedBoard: game.value.loopedBoard,
|
||||
logs: game.value.logs,
|
||||
}));
|
||||
|
||||
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;
|
||||
engine.value = Reversi.Serializer.restoreGame({
|
||||
map: game.value.map,
|
||||
isLlotheo: game.value.isLlotheo,
|
||||
canPutEverywhere: game.value.canPutEverywhere,
|
||||
loopedBoard: game.value.loopedBoard,
|
||||
logs: game.value.logs.slice(0, v),
|
||||
});
|
||||
});
|
||||
|
||||
if (game.value.isStarted && !game.value.isEnded) {
|
||||
useInterval(() => {
|
||||
if (game.value.isEnded) return;
|
||||
const crc32 = CRC32.str(JSON.stringify(game.value.logs)).toString();
|
||||
if (_DEV_) console.log('crc32', crc32);
|
||||
props.connection.send('checkState', {
|
||||
crc32: crc32,
|
||||
});
|
||||
}, 10000, { immediate: false, afterMounted: true });
|
||||
}
|
||||
|
||||
const appliedOps: string[] = [];
|
||||
|
||||
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.putStone(pos);
|
||||
|
||||
triggerRef(engine);
|
||||
|
||||
sound.playUrl('/client-assets/reversi/put.mp3', {
|
||||
volume: 1,
|
||||
playbackRate: 1,
|
||||
});
|
||||
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
props.connection.send('putStone', {
|
||||
pos: pos,
|
||||
id,
|
||||
});
|
||||
appliedOps.push(id);
|
||||
|
||||
myTurnTimerRmain.value = game.value.timeLimitForEachTurn;
|
||||
opTurnTimerRmain.value = game.value.timeLimitForEachTurn;
|
||||
|
||||
checkEnd();
|
||||
}
|
||||
|
||||
const myTurnTimerRmain = ref<number>(game.value.timeLimitForEachTurn);
|
||||
const opTurnTimerRmain = ref<number>(game.value.timeLimitForEachTurn);
|
||||
|
||||
const TIMER_INTERVAL_SEC = 3;
|
||||
useInterval(() => {
|
||||
if (myTurnTimerRmain.value > 0) {
|
||||
myTurnTimerRmain.value = Math.max(0, myTurnTimerRmain.value - TIMER_INTERVAL_SEC);
|
||||
}
|
||||
if (opTurnTimerRmain.value > 0) {
|
||||
opTurnTimerRmain.value = Math.max(0, opTurnTimerRmain.value - TIMER_INTERVAL_SEC);
|
||||
}
|
||||
|
||||
if (iAmPlayer.value) {
|
||||
if ((isMyTurn.value && myTurnTimerRmain.value === 0) || (!isMyTurn.value && opTurnTimerRmain.value === 0)) {
|
||||
props.connection.send('claimTimeIsUp', {});
|
||||
}
|
||||
}
|
||||
}, TIMER_INTERVAL_SEC * 1000, { immediate: false, afterMounted: true });
|
||||
|
||||
function onStreamLog(log: Reversi.Serializer.Log & { id: string | null }) {
|
||||
game.value.logs = Reversi.Serializer.serializeLogs([
|
||||
...Reversi.Serializer.deserializeLogs(game.value.logs),
|
||||
log,
|
||||
]);
|
||||
|
||||
logPos.value++;
|
||||
|
||||
if (log.id == null || !appliedOps.includes(log.id)) {
|
||||
switch (log.operation) {
|
||||
case 'put': {
|
||||
engine.value.putStone(log.pos);
|
||||
triggerRef(engine);
|
||||
|
||||
myTurnTimerRmain.value = game.value.timeLimitForEachTurn;
|
||||
opTurnTimerRmain.value = game.value.timeLimitForEachTurn;
|
||||
|
||||
sound.playUrl('/client-assets/reversi/put.mp3', {
|
||||
volume: 1,
|
||||
playbackRate: 1,
|
||||
});
|
||||
|
||||
checkEnd();
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onStreamEnded(x) {
|
||||
game.value = deepClone(x.game);
|
||||
|
||||
if (game.value.winnerId === $i.id) {
|
||||
confetti({
|
||||
duration: 1000 * 3,
|
||||
});
|
||||
|
||||
sound.playUrl('/client-assets/reversi/win.mp3', {
|
||||
volume: 1,
|
||||
playbackRate: 1,
|
||||
});
|
||||
} else {
|
||||
sound.playUrl('/client-assets/reversi/lose.mp3', {
|
||||
volume: 1,
|
||||
playbackRate: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 onStreamRescue(_game) {
|
||||
console.log('rescue');
|
||||
|
||||
game.value = deepClone(_game);
|
||||
|
||||
engine.value = Reversi.Serializer.restoreGame({
|
||||
map: game.value.map,
|
||||
isLlotheo: game.value.isLlotheo,
|
||||
canPutEverywhere: game.value.canPutEverywhere,
|
||||
loopedBoard: game.value.loopedBoard,
|
||||
logs: game.value.logs,
|
||||
});
|
||||
|
||||
logPos.value = game.value.logs.length;
|
||||
|
||||
checkEnd();
|
||||
}
|
||||
|
||||
async function surrender() {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.ts.areYouSure,
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
misskeyApi('reversi/surrender', {
|
||||
gameId: game.value.id,
|
||||
});
|
||||
}
|
||||
|
||||
function autoplay() {
|
||||
autoplaying.value = true;
|
||||
logPos.value = 0;
|
||||
const logs = Reversi.Serializer.deserializeLogs(game.value.logs);
|
||||
|
||||
window.setTimeout(() => {
|
||||
logPos.value = 1;
|
||||
|
||||
let i = 1;
|
||||
let previousLog = logs[0];
|
||||
const tick = () => {
|
||||
const log = logs[i];
|
||||
const time = log.time - previousLog.time;
|
||||
setTimeout(() => {
|
||||
i++;
|
||||
logPos.value++;
|
||||
previousLog = log;
|
||||
|
||||
if (i < logs.length) {
|
||||
tick();
|
||||
} else {
|
||||
autoplaying.value = false;
|
||||
}
|
||||
}, time);
|
||||
};
|
||||
|
||||
tick();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function share() {
|
||||
os.post({
|
||||
initialText: `#MisskeyReversi ${location.href}`,
|
||||
instant: true,
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
props.connection.on('log', onStreamLog);
|
||||
props.connection.on('rescue', onStreamRescue);
|
||||
props.connection.on('ended', onStreamEnded);
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
props.connection.on('log', onStreamLog);
|
||||
props.connection.on('rescue', onStreamRescue);
|
||||
props.connection.on('ended', onStreamEnded);
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
props.connection.off('log', onStreamLog);
|
||||
props.connection.off('rescue', onStreamRescue);
|
||||
props.connection.off('ended', onStreamEnded);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
props.connection.off('log', onStreamLog);
|
||||
props.connection.off('rescue', onStreamRescue);
|
||||
props.connection.off('ended', onStreamEnded);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
@use "sass:math";
|
||||
|
||||
.transition_flip_enterActive,
|
||||
.transition_flip_leaveActive {
|
||||
backface-visibility: hidden;
|
||||
transition: opacity 0.5s ease, transform 0.5s ease;
|
||||
}
|
||||
.transition_flip_enterFrom {
|
||||
transform: rotateY(-180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
.transition_flip_leaveTo {
|
||||
transform: rotateY(180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
$label-size: 16px;
|
||||
$gap: 4px;
|
||||
|
||||
.root {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.board {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
|
||||
padding: 7px;
|
||||
background: #8C4F26;
|
||||
box-shadow: 0 6px 16px #0007, 0 0 1px 1px #693410, inset 0 0 2px 1px #ce8a5c;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.boardInner {
|
||||
padding: 32px;
|
||||
|
||||
background: var(--panel);
|
||||
box-shadow: 0 0 2px 1px #ce8a5c, inset 0 0 1px 1px #693410;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@container (max-width: 400px) {
|
||||
.boardInner {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.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: 100%;
|
||||
aspect-ratio: 1;
|
||||
transform-style: preserve-3d;
|
||||
perspective: 150px;
|
||||
transition: border 0.25s ease, opacity 0.25s ease;
|
||||
|
||||
&.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;
|
||||
}
|
||||
}
|
||||
|
||||
.boardCellStone {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<!--
|
||||
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="{ [$style.disallow]: isReady }">
|
||||
<div class="_gaps" :class="{ [$style.disallowInner]: isReady }">
|
||||
<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="ph-dice-five ph-bold ph-lg"></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' ? 'ph-circle-half ph-bold ph-lg' : 'ph-circle ph-bold ph-lg'"></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.timeLimitForEachTurn }}</template>
|
||||
<template #suffix>{{ game.timeLimitForEachTurn }}{{ i18n.ts._time.second }}</template>
|
||||
|
||||
<MkRadios v-model="game.timeLimitForEachTurn">
|
||||
<option :value="5">5{{ i18n.ts._time.second }}</option>
|
||||
<option :value="10">10{{ i18n.ts._time.second }}</option>
|
||||
<option :value="30">30{{ i18n.ts._time.second }}</option>
|
||||
<option :value="60">60{{ i18n.ts._time.second }}</option>
|
||||
<option :value="90">90{{ i18n.ts._time.second }}</option>
|
||||
<option :value="120">120{{ i18n.ts._time.second }}</option>
|
||||
<option :value="180">180{{ i18n.ts._time.second }}</option>
|
||||
<option :value="3600">3600{{ i18n.ts._time.second }}</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>
|
||||
</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="cancel">{{ 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';
|
||||
import { useRouter } from '@/global/router/supplier.js';
|
||||
|
||||
const $i = signinRequired();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
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 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');
|
||||
});
|
||||
|
||||
watch(() => game.value.timeLimitForEachTurn, () => {
|
||||
updateSettings('timeLimitForEachTurn');
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.ts.areYouSure,
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
props.connection.send('cancel', {});
|
||||
|
||||
router.push('/reversi');
|
||||
}
|
||||
|
||||
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>
|
||||
.disallow {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.disallowInner {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -0,0 +1,87 @@
|
||||
<!--
|
||||
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';
|
||||
import { signinRequired } from '@/account.js';
|
||||
import { useRouter } from '@/global/router/supplier.js';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const $i = signinRequired();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
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;
|
||||
});
|
||||
connection.value.on('canceled', x => {
|
||||
connection.value?.dispose();
|
||||
|
||||
if (x.userId !== $i.id) {
|
||||
os.alert({
|
||||
type: 'warning',
|
||||
text: i18n.ts._reversi.gameCanceled,
|
||||
});
|
||||
router.push('/reversi');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchGame();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (connection.value) {
|
||||
connection.value.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
const headerActions = computed(() => []);
|
||||
|
||||
const headerTabs = computed(() => []);
|
||||
|
||||
definePageMetadata(computed(() => ({
|
||||
title: 'Reversi',
|
||||
icon: 'ph-game-controller ph-bold ph-lg',
|
||||
})));
|
||||
</script>
|
||||
@@ -0,0 +1,289 @@
|
||||
<!--
|
||||
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 _gaps" 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 style="font-size: 90%; opacity: 0.7; text-align: center;"><i class="ph-music-notes ph-bold ph-lg"></i> {{ i18n.ts.soundWillBePlayed }}</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, 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';
|
||||
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';
|
||||
import * as sound from '@/scripts/sound.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;
|
||||
|
||||
sound.playUrl('/client-assets/reversi/matched.mp3', {
|
||||
volume: 1,
|
||||
playbackRate: 1,
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
cancelMatching();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelMatching();
|
||||
});
|
||||
|
||||
definePageMetadata(computed(() => ({
|
||||
title: 'Reversi',
|
||||
icon: 'ph-game-controller ph-bold ph-lg',
|
||||
})));
|
||||
</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>
|
||||
Reference in New Issue
Block a user