misskey/packages/frontend/src/pages/reversi/game.board.vue
Acid Chicken (硫酸鶏) 7dbf873a2f
refactor: deprecate i18n.t
2024-01-20 02:11:11 +09:00

429 lines
12 KiB
Vue

<!--
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.tsx._reversi.turnOf(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.tsx._reversi.pastTurnOf(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.tsx._reversi.won(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.tsx._reversi.turnCount(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>