diff options
Diffstat (limited to 'src/client/pages/reversi/game.board.vue')
| -rw-r--r-- | src/client/pages/reversi/game.board.vue | 472 |
1 files changed, 472 insertions, 0 deletions
diff --git a/src/client/pages/reversi/game.board.vue b/src/client/pages/reversi/game.board.vue new file mode 100644 index 0000000000..c9d2528d37 --- /dev/null +++ b/src/client/pages/reversi/game.board.vue @@ -0,0 +1,472 @@ +<template> +<div class="xqnhankfuuilcwvhgsopeqncafzsquya"> + <header><b><MkA :to="userPage(blackUser)"><MkUserName :user="blackUser"/></MkA></b>({{ $t('_reversi.black') }}) vs <b><MkA :to="userPage(whiteUser)"><MkUserName :user="whiteUser"/></MkA></b>({{ $t('_reversi.white') }})</header> + + <div style="overflow: hidden; line-height: 28px;"> + <p class="turn" v-if="!iAmPlayer && !game.isEnded"> + <Mfm :key="'turn:' + turnUser().name" :text="$t('_reversi.turnOf', { name: turnUser().name })" :plain="true" :custom-emojis="turnUser().emojis"/> + <MkEllipsis/> + </p> + <p class="turn" v-if="logPos != logs.length"> + <Mfm :key="'past-turn-of:' + turnUser().name" :text="$t('_reversi.pastTurnOf', { name: turnUser().name })" :plain="true" :custom-emojis="turnUser().emojis"/> + </p> + <p class="turn1" v-if="iAmPlayer && !game.isEnded && !isMyTurn()">{{ $t('_reversi.opponentTurn') }}<MkEllipsis/></p> + <p class="turn2" v-if="iAmPlayer && !game.isEnded && isMyTurn()" style="animation: anime-tada 1s linear infinite both;">{{ $t('_reversi.myTurn') }}</p> + <p class="result" v-if="game.isEnded && logPos == logs.length"> + <template v-if="game.winner"> + <Mfm :key="'won'" :text="$t('_reversi.won', { name: game.winner.name })" :plain="true" :custom-emojis="game.winner.emojis"/> + <span v-if="game.surrendered != null"> ({{ $t('_reversi.surrendered') }})</span> + </template> + <template v-else>{{ $t('_reversi.drawn') }}</template> + </p> + </div> + + <div class="board"> + <div class="labels-x" v-if="$store.state.settings.gamesReversiShowBoardLabels"> + <span v-for="i in game.map[0].length">{{ String.fromCharCode(64 + i) }}</span> + </div> + <div class="flex"> + <div class="labels-y" v-if="$store.state.settings.gamesReversiShowBoardLabels"> + <div v-for="i in game.map.length">{{ i }}</div> + </div> + <div class="cells" :style="cellsStyle"> + <div v-for="(stone, i) in o.board" + :class="{ empty: stone == null, none: o.map[i] == 'null', isEnded: game.isEnded, myTurn: !game.isEnded && isMyTurn(), can: turnUser() ? o.canPut(turnUser().id == blackUser.id, i) : null, prev: o.prevPos == i }" + @click="set(i)" + :title="`${String.fromCharCode(65 + o.transformPosToXy(i)[0])}${o.transformPosToXy(i)[1] + 1}`" + > + <template v-if="$store.state.settings.gamesReversiUseAvatarStones || true"> + <img v-if="stone === true" :src="blackUser.avatarUrl" alt="black"> + <img v-if="stone === false" :src="whiteUser.avatarUrl" alt="white"> + </template> + <template v-else> + <fa v-if="stone === true" :icon="fasCircle"/> + <fa v-if="stone === false" :icon="farCircle"/> + </template> + </div> + </div> + <div class="labels-y" v-if="$store.state.settings.gamesReversiShowBoardLabels"> + <div v-for="i in game.map.length">{{ i }}</div> + </div> + </div> + <div class="labels-x" v-if="$store.state.settings.gamesReversiShowBoardLabels"> + <span v-for="i in game.map[0].length">{{ String.fromCharCode(64 + i) }}</span> + </div> + </div> + + <p class="status"><b>{{ $t('_reversi.turnCount', { count: logPos }) }}</b> {{ $t('_reversi.black') }}:{{ o.blackCount }} {{ $t('_reversi.white') }}:{{ o.whiteCount }} {{ $t('_reversi.total') }}:{{ o.blackCount + o.whiteCount }}</p> + + <div class="actions" v-if="!game.isEnded && iAmPlayer"> + <MkButton @click="surrender">{{ $t('_reversi.surrender') }}</MkButton> + </div> + + <div class="player" v-if="game.isEnded"> + <span>{{ logPos }} / {{ logs.length }}</span> + <!-- TODO <ui-horizon-group> --> + <MkButton @click="logPos = 0" :disabled="logPos == 0"><fa :icon="faAngleDoubleLeft"/></MkButton> + <MkButton @click="logPos--" :disabled="logPos == 0"><fa :icon="faAngleLeft"/></MkButton> + <MkButton @click="logPos++" :disabled="logPos == logs.length"><fa :icon="faAngleRight"/></MkButton> + <MkButton @click="logPos = logs.length" :disabled="logPos == logs.length"><fa :icon="faAngleDoubleRight"/></MkButton> + <!-- TODO </ui-horizon-group> --> + </div> + + <div class="info"> + <p v-if="game.isLlotheo">{{ $t('_reversi.isLlotheo') }}</p> + <p v-if="game.loopedBoard">{{ $t('_reversi.loopedMap') }}</p> + <p v-if="game.canPutEverywhere">{{ $t('_reversi.canPutEverywhere') }}</p> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faAngleDoubleLeft, faAngleLeft, faAngleRight, faAngleDoubleRight } from '@fortawesome/free-solid-svg-icons'; +import { faCircle as fasCircle } from '@fortawesome/free-solid-svg-icons'; +import { faCircle as farCircle } from '@fortawesome/free-regular-svg-icons'; +import * as CRC32 from 'crc-32'; +import Reversi, { Color } from '../../../games/reversi/core'; +import { url } from '@/config'; +import MkButton from '@/components/ui/button.vue'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton + }, + + props: { + initGame: { + type: Object, + require: true + }, + connection: { + type: Object, + require: true + }, + }, + + data() { + return { + game: JSON.parse(JSON.stringify(this.initGame)), + o: null as Reversi, + logs: [], + logPos: 0, + pollingClock: null, + faAngleDoubleLeft, faAngleLeft, faAngleRight, faAngleDoubleRight, fasCircle, farCircle + }; + }, + + computed: { + iAmPlayer(): boolean { + if (!this.$store.getters.isSignedIn) return false; + return this.game.user1Id == this.$store.state.i.id || this.game.user2Id == this.$store.state.i.id; + }, + + myColor(): Color { + if (!this.iAmPlayer) return null; + if (this.game.user1Id == this.$store.state.i.id && this.game.black == 1) return true; + if (this.game.user2Id == this.$store.state.i.id && this.game.black == 2) return true; + return false; + }, + + opColor(): Color { + if (!this.iAmPlayer) return null; + return this.myColor === true ? false : true; + }, + + blackUser(): any { + return this.game.black == 1 ? this.game.user1 : this.game.user2; + }, + + whiteUser(): any { + return this.game.black == 1 ? this.game.user2 : this.game.user1; + }, + + cellsStyle(): any { + return { + 'grid-template-rows': `repeat(${this.game.map.length}, 1fr)`, + 'grid-template-columns': `repeat(${this.game.map[0].length}, 1fr)` + }; + } + }, + + watch: { + logPos(v) { + if (!this.game.isEnded) return; + const o = new Reversi(this.game.map, { + isLlotheo: this.game.isLlotheo, + canPutEverywhere: this.game.canPutEverywhere, + loopedBoard: this.game.loopedBoard + }); + for (const log of this.logs.slice(0, v)) { + o.put(log.color, log.pos); + } + this.o = o; + //this.$forceUpdate(); + } + }, + + created() { + this.o = new Reversi(this.game.map, { + isLlotheo: this.game.isLlotheo, + canPutEverywhere: this.game.canPutEverywhere, + loopedBoard: this.game.loopedBoard + }); + + for (const log of this.game.logs) { + this.o.put(log.color, log.pos); + } + + this.logs = this.game.logs; + this.logPos = this.logs.length; + + // 通信を取りこぼしてもいいように定期的にポーリングさせる + if (this.game.isStarted && !this.game.isEnded) { + this.pollingClock = setInterval(() => { + if (this.game.isEnded) return; + const crc32 = CRC32.str(this.logs.map(x => x.pos.toString()).join('')); + this.connection.send('check', { + crc32: crc32 + }); + }, 3000); + } + }, + + mounted() { + this.connection.on('set', this.onSet); + this.connection.on('rescue', this.onRescue); + this.connection.on('ended', this.onEnded); + }, + + beforeUnmount() { + this.connection.off('set', this.onSet); + this.connection.off('rescue', this.onRescue); + this.connection.off('ended', this.onEnded); + + clearInterval(this.pollingClock); + }, + + methods: { + userPage, + + // this.o がリアクティブになった折にはcomputedにできる + turnUser(): any { + if (this.o.turn === true) { + return this.game.black == 1 ? this.game.user1 : this.game.user2; + } else if (this.o.turn === false) { + return this.game.black == 1 ? this.game.user2 : this.game.user1; + } else { + return null; + } + }, + + // this.o がリアクティブになった折にはcomputedにできる + isMyTurn(): boolean { + if (!this.iAmPlayer) return false; + if (this.turnUser() == null) return false; + return this.turnUser().id == this.$store.state.i.id; + }, + + set(pos) { + if (this.game.isEnded) return; + if (!this.iAmPlayer) return; + if (!this.isMyTurn) return; + if (!this.o.canPut(this.myColor, pos)) return; + + this.o.put(this.myColor, pos); + + // サウンドを再生する + if (this.$store.state.device.enableSounds) { + const sound = new Audio(`${url}/assets/reversi-put-me.mp3`); + sound.volume = this.$store.state.device.soundVolume; + sound.play(); + } + + this.connection.send('set', { + pos: pos + }); + + this.checkEnd(); + + this.$forceUpdate(); + }, + + onSet(x) { + this.logs.push(x); + this.logPos++; + this.o.put(x.color, x.pos); + this.checkEnd(); + this.$forceUpdate(); + + // サウンドを再生する + if (this.$store.state.device.enableSounds && x.color != this.myColor) { + const sound = new Audio(`${url}/assets/reversi-put-you.mp3`); + sound.volume = this.$store.state.device.soundVolume; + sound.play(); + } + }, + + onEnded(x) { + this.game = JSON.parse(JSON.stringify(x.game)); + }, + + checkEnd() { + this.game.isEnded = this.o.isEnded; + if (this.game.isEnded) { + if (this.o.winner === true) { + this.game.winnerId = this.game.black == 1 ? this.game.user1Id : this.game.user2Id; + this.game.winner = this.game.black == 1 ? this.game.user1 : this.game.user2; + } else if (this.o.winner === false) { + this.game.winnerId = this.game.black == 1 ? this.game.user2Id : this.game.user1Id; + this.game.winner = this.game.black == 1 ? this.game.user2 : this.game.user1; + } else { + this.game.winnerId = null; + this.game.winner = null; + } + } + }, + + // 正しいゲーム情報が送られてきたとき + onRescue(game) { + this.game = JSON.parse(JSON.stringify(game)); + + this.o = new Reversi(this.game.map, { + isLlotheo: this.game.isLlotheo, + canPutEverywhere: this.game.canPutEverywhere, + loopedBoard: this.game.loopedBoard + }); + + for (const log of this.game.logs) { + this.o.put(log.color, log.pos, true); + } + + this.logs = this.game.logs; + this.logPos = this.logs.length; + + this.checkEnd(); + this.$forceUpdate(); + }, + + surrender() { + os.api('games/reversi/games/surrender', { + gameId: this.game.id + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.xqnhankfuuilcwvhgsopeqncafzsquya { + text-align: center; + + > .go-index { + position: absolute; + top: 0; + left: 0; + z-index: 1; + width: 42px; + height :42px; + } + + > header { + padding: 8px; + border-bottom: dashed 1px var(--divider); + } + + > .board { + width: calc(100% - 16px); + max-width: 500px; + margin: 0 auto; + + $label-size: 16px; + $gap: 4px; + + > .labels-x { + height: $label-size; + padding: 0 $label-size; + display: flex; + + > * { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8em; + + &:first-child { + margin-left: -($gap / 2); + } + + &:last-child { + margin-right: -($gap / 2); + } + } + } + + > .flex { + display: flex; + + > .labels-y { + width: $label-size; + display: flex; + flex-direction: column; + + > * { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + + &:first-child { + margin-top: -($gap / 2); + } + + &:last-child { + margin-bottom: -($gap / 2); + } + } + } + + > .cells { + flex: 1; + display: grid; + grid-gap: $gap; + + > div { + background: transparent; + border-radius: 6px; + overflow: hidden; + + * { + pointer-events: none; + user-select: none; + } + + &.empty { + border: solid 2px var(--divider); + } + + &.empty.can { + border-color: var(--accent); + } + + &.empty.myTurn { + border-color: var(--divider); + + &.can { + border-color: var(--accent); + cursor: pointer; + + &:hover { + background: var(--accent); + } + } + } + + &.prev { + box-shadow: 0 0 0 4px var(--accent); + } + + &.isEnded { + border-color: var(--divider); + } + + &.none { + border-color: transparent !important; + } + + > svg, > img { + display: block; + width: 100%; + height: 100%; + } + } + } + } + } + + > .status { + margin: 0; + padding: 16px 0; + } + + > .actions { + padding-bottom: 16px; + } + + > .player { + padding: 0 16px 32px 16px; + margin: 0 auto; + max-width: 500px; + + > span { + display: inline-block; + margin: 0 8px; + min-width: 70px; + } + } +} +</style> |