diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2020-10-27 18:11:41 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-10-27 18:11:41 +0900 |
| commit | 6258ce75b7ae97584100ecccdf0a7cd0225da7b2 (patch) | |
| tree | 9b969daf7cd37a06c993b20a536b7821466b256c /src | |
| parent | Update popup animation (diff) | |
| download | sharkey-6258ce75b7ae97584100ecccdf0a7cd0225da7b2.tar.gz sharkey-6258ce75b7ae97584100ecccdf0a7cd0225da7b2.tar.bz2 sharkey-6258ce75b7ae97584100ecccdf0a7cd0225da7b2.zip | |
Reversi (#6765)
* wip
* wip
* wip
* wip
* Update game.setting.vue
* wip
* wip
* Update game.setting.vue
* wip
* Update game.board.vue
* wip
* Update sidebar.ts
Diffstat (limited to 'src')
| -rw-r--r-- | src/client/components/emoji-picker.vue | 2 | ||||
| -rw-r--r-- | src/client/components/notification.vue | 4 | ||||
| -rw-r--r-- | src/client/components/time.vue | 2 | ||||
| -rw-r--r-- | src/client/components/ui/radio.vue | 1 | ||||
| -rw-r--r-- | src/client/components/user-info.vue | 4 | ||||
| -rw-r--r-- | src/client/components/user-preview.vue | 6 | ||||
| -rw-r--r-- | src/client/os.ts | 2 | ||||
| -rw-r--r-- | src/client/pages/messaging/index.vue | 8 | ||||
| -rw-r--r-- | src/client/pages/reversi/game.board.vue | 472 | ||||
| -rw-r--r-- | src/client/pages/reversi/game.setting.vue | 393 | ||||
| -rw-r--r-- | src/client/pages/reversi/game.vue | 78 | ||||
| -rw-r--r-- | src/client/pages/reversi/index.vue | 281 | ||||
| -rw-r--r-- | src/client/router.ts | 2 | ||||
| -rw-r--r-- | src/client/sidebar.ts | 2 | ||||
| -rw-r--r-- | src/client/widgets/rss.vue | 2 | ||||
| -rw-r--r-- | src/games/reversi/maps.ts | 45 | ||||
| -rw-r--r-- | src/server/api/stream/channels/games/reversi-game.ts | 11 |
17 files changed, 1249 insertions, 66 deletions
diff --git a/src/client/components/emoji-picker.vue b/src/client/components/emoji-picker.vue index e79f3c9292..431edf6d18 100644 --- a/src/client/components/emoji-picker.vue +++ b/src/client/components/emoji-picker.vue @@ -185,7 +185,7 @@ export default defineComponent({ transition: color 0.2s ease; &:hover { - color: var(--textHighlighted); + color: var(--fgHighlighted); transition: color 0s; } diff --git a/src/client/components/notification.vue b/src/client/components/notification.vue index db6d8ad167..c85457a56d 100644 --- a/src/client/components/notification.vue +++ b/src/client/components/notification.vue @@ -20,7 +20,7 @@ <header> <MkA v-if="notification.user" class="name" :to="userPage(notification.user)" v-user-preview="notification.user.id"><MkUserName :user="notification.user"/></MkA> <span v-else>{{ notification.header }}</span> - <MkTime :time="notification.createdAt" v-if="withTime"/> + <MkTime :time="notification.createdAt" v-if="withTime" class="time"/> </header> <MkA v-if="notification.type === 'reaction'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> <Fa :icon="faQuoteLeft"/> @@ -260,7 +260,7 @@ export default defineComponent({ overflow: hidden; } - > .mk-time { + > .time { margin-left: auto; font-size: 0.9em; } diff --git a/src/client/components/time.vue b/src/client/components/time.vue index 3219373739..544746c24b 100644 --- a/src/client/components/time.vue +++ b/src/client/components/time.vue @@ -1,5 +1,5 @@ <template> -<time class="mk-time" :title="absolute"> +<time :title="absolute"> <template v-if="mode == 'relative'">{{ relative }}</template> <template v-else-if="mode == 'absolute'">{{ absolute }}</template> <template v-else-if="mode == 'detail'">{{ absolute }} ({{ relative }})</template> diff --git a/src/client/components/ui/radio.vue b/src/client/components/ui/radio.vue index 890ff08751..79715593d0 100644 --- a/src/client/components/ui/radio.vue +++ b/src/client/components/ui/radio.vue @@ -52,6 +52,7 @@ export default defineComponent({ position: relative; display: inline-block; margin: 16px 32px 0 0; + text-align: left; cursor: pointer; transition: all 0.3s; diff --git a/src/client/components/user-info.vue b/src/client/components/user-info.vue index 09736b1a2c..205a991e63 100644 --- a/src/client/components/user-info.vue +++ b/src/client/components/user-info.vue @@ -96,7 +96,7 @@ export default defineComponent({ margin: 0; line-height: 16px; font-size: 0.8em; - color: var(--text); + color: var(--fg); opacity: 0.7; } } @@ -125,7 +125,7 @@ export default defineComponent({ > p { margin: 0; font-size: 0.7em; - color: var(--text); + color: var(--fg); } > span { diff --git a/src/client/components/user-preview.vue b/src/client/components/user-preview.vue index d258489860..bc41cc822f 100644 --- a/src/client/components/user-preview.vue +++ b/src/client/components/user-preview.vue @@ -151,7 +151,7 @@ export default defineComponent({ margin: 0; line-height: 16px; font-size: 0.8em; - color: var(--text); + color: var(--fg); opacity: 0.7; } } @@ -159,7 +159,7 @@ export default defineComponent({ > .description { padding: 0 16px; font-size: 0.8em; - color: var(--text); + color: var(--fg); } > .status { @@ -172,7 +172,7 @@ export default defineComponent({ > p { margin: 0; font-size: 0.7em; - color: var(--text); + color: var(--fg); } > span { diff --git a/src/client/os.ts b/src/client/os.ts index daff26efa2..b709fe5933 100644 --- a/src/client/os.ts +++ b/src/client/os.ts @@ -10,7 +10,7 @@ import { resolve } from '@/router'; const ua = navigator.userAgent.toLowerCase(); export const isMobile = /mobile|iphone|ipad|android/.test(ua); -export const stream = new Stream(); +export const stream = markRaw(new Stream()); export const pendingApiRequestsCount = ref(0); diff --git a/src/client/pages/messaging/index.vue b/src/client/pages/messaging/index.vue index f62a33b866..6538ce3e73 100644 --- a/src/client/pages/messaging/index.vue +++ b/src/client/pages/messaging/index.vue @@ -15,12 +15,12 @@ <MkAvatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user"/> <header v-if="message.groupId"> <span class="name">{{ message.group.name }}</span> - <MkTime :time="message.createdAt"/> + <MkTime :time="message.createdAt" class="time"/> </header> <header v-else> <span class="name"><MkUserName :user="isMe(message) ? message.recipient : message.user"/></span> <span class="username">@{{ acct(isMe(message) ? message.recipient : message.user) }}</span> - <MkTime :time="message.createdAt"/> + <MkTime :time="message.createdAt" class="time"/> </header> <div class="body"> <p class="text"><span class="me" v-if="isMe(message)">{{ $t('you') }}:</span>{{ message.text }}</p> @@ -50,8 +50,6 @@ export default defineComponent({ MkButton }, - inject: ['navHook'], - data() { return { INFO: { @@ -245,7 +243,7 @@ export default defineComponent({ margin: 0 8px; } - > .mk-time { + > .time { margin: 0 0 0 auto; } } 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> diff --git a/src/client/pages/reversi/game.setting.vue b/src/client/pages/reversi/game.setting.vue new file mode 100644 index 0000000000..d679d0f6d8 --- /dev/null +++ b/src/client/pages/reversi/game.setting.vue @@ -0,0 +1,393 @@ +<template> +<div class="urbixznjwwuukfsckrwzwsqzsxornqij"> + <header><b><MkUserName :user="game.user1"/></b> vs <b><MkUserName :user="game.user2"/></b></header> + + <div> + <p>{{ $t('_reversi.gameSettings') }}</p> + + <div class="card map _panel"> + <header> + <select v-model="mapName" :placeholder="$t('_reversi.chooseBoard')" @change="onMapChange"> + <option label="-Custom-" :value="mapName" v-if="mapName == '-Custom-'"/> + <option :label="$t('random')" :value="null"/> + <optgroup v-for="c in mapCategories" :key="c" :label="c"> + <option v-for="m in Object.values(maps).filter(m => m.category == c)" :key="m.name" :label="m.name" :value="m.name">{{ m.name }}</option> + </optgroup> + </select> + </header> + + <div> + <div class="random" v-if="game.map == null"><fa icon="dice"/></div> + <div class="board" v-else :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="{ none: x == ' ' }" @click="onPixelClick(i, x)"> + <fa v-if="x == 'b'" :icon="fasCircle"/> + <fa v-if="x == 'w'" :icon="farCircle"/> + </div> + </div> + </div> + </div> + + <div class="card _panel"> + <header> + <span>{{ $t('_reversi.blackOrWhite') }}</span> + </header> + + <div> + <MkRadio v-model="game.bw" value="random" @update:modelValue="updateSettings('bw')">{{ $t('random') }}</MkRadio> + <MkRadio v-model="game.bw" :value="'1'" @update:modelValue="updateSettings('bw')"> + <i18n-t keypath="_reversi.blackIs" tag="span"> + <template #name> + <b><MkUserName :user="game.user1"/></b> + </template> + </i18n-t> + </MkRadio> + <MkRadio v-model="game.bw" :value="'2'" @update:modelValue="updateSettings('bw')"> + <i18n-t keypath="_reversi.blackIs" tag="span"> + <template #name> + <b><MkUserName :user="game.user2"/></b> + </template> + </i18n-t> + </MkRadio> + </div> + </div> + + <div class="card _panel"> + <header> + <span>{{ $t('_reversi.rules') }}</span> + </header> + + <div> + <MkSwitch v-model:value="game.isLlotheo" @update:value="updateSettings('isLlotheo')">{{ $t('_reversi.isLlotheo') }}</MkSwitch> + <MkSwitch v-model:value="game.loopedBoard" @update:value="updateSettings('loopedBoard')">{{ $t('_reversi.loopedMap') }}</MkSwitch> + <MkSwitch v-model:value="game.canPutEverywhere" @update:value="updateSettings('canPutEverywhere')">{{ $t('_reversi.canPutEverywhere') }}</MkSwitch> + </div> + </div> + + <div class="card form _panel" v-if="form"> + <header> + <span>{{ $t('_reversi.botSettings') }}</span> + </header> + + <div> + <template v-for="item in form"> + <MkSwitch v-if="item.type == 'switch'" v-model:value="item.value" :key="item.id" @change="onChangeForm(item)">{{ item.label || item.desc || '' }}</MkSwitch> + + <div class="card" v-if="item.type == 'radio'" :key="item.id"> + <header> + <span>{{ item.label }}</span> + </header> + + <div> + <MkRadio v-for="(r, i) in item.items" :key="item.id + ':' + i" v-model="item.value" :value="r.value" @update:modelValue="onChangeForm(item)">{{ r.label }}</MkRadio> + </div> + </div> + + <div class="card" v-if="item.type == 'slider'" :key="item.id"> + <header> + <span>{{ item.label }}</span> + </header> + + <div> + <input type="range" :min="item.min" :max="item.max" :step="item.step || 1" v-model="item.value" @change="onChangeForm(item)"/> + </div> + </div> + + <div class="card" v-if="item.type == 'textbox'" :key="item.id"> + <header> + <span>{{ item.label }}</span> + </header> + + <div> + <input v-model="item.value" @change="onChangeForm(item)"/> + </div> + </div> + </template> + </div> + </div> + </div> + + <footer class="_acrylic"> + <p class="status"> + <template v-if="isAccepted && isOpAccepted">{{ $t('_reversi.thisGameIsStartedSoon') }}<MkEllipsis/></template> + <template v-if="isAccepted && !isOpAccepted">{{ $t('_reversi.waitingForOther') }}<MkEllipsis/></template> + <template v-if="!isAccepted && isOpAccepted">{{ $t('_reversi.waitingForMe') }}</template> + <template v-if="!isAccepted && !isOpAccepted">{{ $t('_reversi.waitingBoth') }}<MkEllipsis/></template> + </p> + + <div class="actions"> + <MkButton inline @click="exit">{{ $t('cancel') }}</MkButton> + <MkButton inline primary @click="accept" v-if="!isAccepted">{{ $t('_reversi.ready') }}</MkButton> + <MkButton inline primary @click="cancel" v-if="isAccepted">{{ $t('_reversi.cancelReady') }}</MkButton> + </div> + </footer> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faCircle as fasCircle } from '@fortawesome/free-solid-svg-icons'; +import { faCircle as farCircle } from '@fortawesome/free-regular-svg-icons'; +import * as maps from '../../../games/reversi/maps'; +import MkButton from '@/components/ui/button.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import MkRadio from '@/components/ui/radio.vue'; + +export default defineComponent({ + components: { + MkButton, + MkSwitch, + MkRadio, + }, + + props: { + initGame: { + type: Object, + require: true + }, + connection: { + type: Object, + require: true + }, + }, + + data() { + return { + game: this.initGame, + o: null, + isLlotheo: false, + mapName: maps.eighteight.name, + maps: maps, + form: null, + messages: [], + fasCircle, farCircle + }; + }, + + computed: { + mapCategories(): string[] { + const categories = Object.values(maps).map(x => x.category); + return categories.filter((item, pos) => categories.indexOf(item) == pos); + }, + isAccepted(): boolean { + if (this.game.user1Id == this.$store.state.i.id && this.game.user1Accepted) return true; + if (this.game.user2Id == this.$store.state.i.id && this.game.user2Accepted) return true; + return false; + }, + isOpAccepted(): boolean { + if (this.game.user1Id != this.$store.state.i.id && this.game.user1Accepted) return true; + if (this.game.user2Id != this.$store.state.i.id && this.game.user2Accepted) return true; + return false; + } + }, + + created() { + this.connection.on('changeAccepts', this.onChangeAccepts); + this.connection.on('updateSettings', this.onUpdateSettings); + this.connection.on('initForm', this.onInitForm); + this.connection.on('message', this.onMessage); + + if (this.game.user1Id != this.$store.state.i.id && this.game.form1) this.form = this.game.form1; + if (this.game.user2Id != this.$store.state.i.id && this.game.form2) this.form = this.game.form2; + }, + + beforeUnmount() { + this.connection.off('changeAccepts', this.onChangeAccepts); + this.connection.off('updateSettings', this.onUpdateSettings); + this.connection.off('initForm', this.onInitForm); + this.connection.off('message', this.onMessage); + }, + + methods: { + exit() { + + }, + + accept() { + this.connection.send('accept', {}); + }, + + cancel() { + this.connection.send('cancelAccept', {}); + }, + + onChangeAccepts(accepts) { + this.game.user1Accepted = accepts.user1; + this.game.user2Accepted = accepts.user2; + }, + + updateSettings(key: string) { + this.connection.send('updateSettings', { + key: key, + value: this.game[key] + }); + }, + + onUpdateSettings({ key, value }) { + this.game[key] = value; + if (this.game.map == null) { + this.mapName = null; + } else { + const found = Object.values(maps).find(x => x.data.join('') == this.game.map.join('')); + this.mapName = found ? found.name : '-Custom-'; + } + }, + + onInitForm(x) { + if (x.userId == this.$store.state.i.id) return; + this.form = x.form; + }, + + onMessage(x) { + if (x.userId == this.$store.state.i.id) return; + this.messages.unshift(x.message); + }, + + onChangeForm(item) { + this.connection.send('updateForm', { + id: item.id, + value: item.value + }); + }, + + onMapChange() { + if (this.mapName == null) { + this.game.map = null; + } else { + this.game.map = Object.values(maps).find(x => x.name == this.mapName).data; + } + this.updateSettings('map'); + }, + + onPixelClick(pos, pixel) { + const x = pos % this.game.map[0].length; + const y = Math.floor(pos / this.game.map[0].length); + const newPixel = + pixel == ' ' ? '-' : + pixel == '-' ? 'b' : + pixel == 'b' ? 'w' : + ' '; + const line = this.game.map[y].split(''); + line[x] = newPixel; + this.game.map[y] = line.join(''); + this.updateSettings('map'); + } + } +}); +</script> + +<style lang="scss" scoped> +.urbixznjwwuukfsckrwzwsqzsxornqij { + text-align: center; + background: var(--bg); + + > header { + padding: 8px; + border-bottom: dashed 1px #c4cdd4; + } + + > div { + padding: 0 16px; + + > .card { + margin: 0 auto 16px auto; + + &.map { + > header { + > select { + width: 100%; + padding: 12px 14px; + background: var(--face); + border: 1px solid var(--inputBorder); + border-radius: 4px; + color: var(--fg); + cursor: pointer; + transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1); + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + + &:focus, + &:active { + border-color: var(--accent); + } + } + } + + > div { + > .random { + padding: 32px 0; + font-size: 64px; + color: var(--fg); + opacity: 0.7; + } + + > .board { + display: grid; + grid-gap: 4px; + width: 300px; + height: 300px; + margin: 0 auto; + color: var(--fg); + + > div { + background: transparent; + border: solid 2px var(--divider); + border-radius: 6px; + overflow: hidden; + cursor: pointer; + + * { + pointer-events: none; + user-select: none; + width: 100%; + height: 100%; + } + + &.none { + border-color: transparent; + } + } + } + } + } + + &.form { + > div { + > .card + .card { + margin-top: 16px; + } + + input[type='range'] { + width: 100%; + } + } + } + } + + .card { + max-width: 400px; + + > header { + padding: 18px 20px; + border-bottom: 1px solid var(--divider); + } + + > div { + padding: 20px; + color: var(--fg); + } + } + } + + > footer { + position: sticky; + bottom: 0; + padding: 16px; + border-top: solid 1px var(--divider); + + > .status { + margin: 0 0 16px 0; + } + } +} +</style> diff --git a/src/client/pages/reversi/game.vue b/src/client/pages/reversi/game.vue new file mode 100644 index 0000000000..dd47ca16dd --- /dev/null +++ b/src/client/pages/reversi/game.vue @@ -0,0 +1,78 @@ +<template> +<div v-if="game == null"><MkLoading/></div> +<GameSetting v-else-if="!game.isStarted" :init-game="game" :connection="connection"/> +<GameBoard v-else :init-game="game" :connection="connection"/> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import GameSetting from './game.setting.vue'; +import GameBoard from './game.board.vue'; +import * as os from '@/os'; +import { faGamepad } from '@fortawesome/free-solid-svg-icons'; + +export default defineComponent({ + components: { + GameSetting, + GameBoard, + }, + + props: { + gameId: { + type: String, + required: true + }, + }, + + data() { + return { + INFO: { + header: [{ + title: this.$t('_reversi.reversi'), + icon: faGamepad + }] + }, + game: null, + connection: null, + }; + }, + + watch: { + gameId() { + this.fetch(); + } + }, + + mounted() { + this.fetch(); + }, + + beforeUnmount() { + if (this.connection) { + this.connection.dispose(); + } + }, + + methods: { + fetch() { + os.api('games/reversi/games/show', { + gameId: this.gameId + }).then(game => { + this.game = game; + + if (this.connection) { + this.connection.dispose(); + } + this.connection = os.stream.connectToChannel('gamesReversiGame', { + gameId: this.game.id + }); + this.connection.on('started', this.onStarted); + }); + }, + + onStarted(game) { + Object.assign(this.game, game); + }, + } +}); +</script> diff --git a/src/client/pages/reversi/index.vue b/src/client/pages/reversi/index.vue new file mode 100644 index 0000000000..1969b6be83 --- /dev/null +++ b/src/client/pages/reversi/index.vue @@ -0,0 +1,281 @@ +<template> +<div class="bgvwxkhb" v-if="!matching"> + <h1>Misskey {{ $t('_reversi.reversi') }}</h1> + + <div class="play"> + <MkButton primary round @click="match" style="margin: var(--margin) auto 0 auto;">{{ $t('invite') }}</MkButton> + </div> + + <div class="_section"> + <MkFolder v-if="invitations.length > 0"> + <template #header>{{ $t('invitations') }}</template> + <div class="nfcacttm"> + <button class="invitation _panel _button" v-for="invitation in invitations" tabindex="-1" @click="accept(invitation)"> + <MkAvatar class="avatar" :user="invitation.parent"/> + <span class="name"><b><MkUserName :user="invitation.parent"/></b></span> + <span class="username">@{{ invitation.parent.username }}</span> + <MkTime :time="invitation.createdAt" class="time"/> + </button> + </div> + </MkFolder> + + <MkFolder v-if="myGames.length > 0"> + <template #header>{{ $t('_reversi.myGames') }}</template> + <div class="knextgwz"> + <MkA class="game _panel" v-for="g in myGames" tabindex="-1" :to="`/games/reversi/${g.id}`" :key="g.id"> + <div class="players"> + <MkAvatar class="avatar" :user="g.user1"/><b><MkUserName :user="g.user1"/></b> vs <b><MkUserName :user="g.user2"/></b><MkAvatar class="avatar" :user="g.user2"/> + </div> + <footer><span class="state" :class="{ playing: !g.isEnded }">{{ g.isEnded ? $t('_reversi.ended') : $t('_reversi.playing') }}</span><MkTime class="time" :time="g.createdAt"/></footer> + </MkA> + </div> + </MkFolder> + + <MkFolder v-if="games.length > 0"> + <template #header>{{ $t('_reversi.allGames') }}</template> + <div class="knextgwz"> + <MkA class="game _panel" v-for="g in games" tabindex="-1" :to="`/games/reversi/${g.id}`" :key="g.id"> + <div class="players"> + <MkAvatar class="avatar" :user="g.user1"/><b><MkUserName :user="g.user1"/></b> vs <b><MkUserName :user="g.user2"/></b><MkAvatar class="avatar" :user="g.user2"/> + </div> + <footer><span class="state" :class="{ playing: !g.isEnded }">{{ g.isEnded ? $t('_reversi.ended') : $t('_reversi.playing') }}</span><MkTime class="time" :time="g.createdAt"/></footer> + </MkA> + </div> + </MkFolder> + </div> +</div> +<div class="sazhgisb" v-else> + <h1> + <i18n-t keypath="waitingFor" tag="span"> + <template #x> + <b><MkUserName :user="matching"/></b> + </template> + </i18n-t> + <MkEllipsis/> + </h1> + <div class="cancel"> + <MkButton inline round @click="cancel">{{ $t('cancel') }}</MkButton> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; +import MkButton from '@/components/ui/button.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import { faGamepad } from '@fortawesome/free-solid-svg-icons'; + +export default defineComponent({ + components: { + MkButton, MkFolder, + }, + + inject: ['navHook'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('_reversi.reversi'), + icon: faGamepad + }] + }, + games: [], + gamesFetching: true, + gamesMoreFetching: false, + myGames: [], + matching: null, + invitations: [], + connection: null, + pingClock: null, + }; + }, + + mounted() { + if (this.$store.getters.isSignedIn) { + this.connection = os.stream.useSharedConnection('gamesReversi'); + + this.connection.on('invited', this.onInvited); + + this.connection.on('matched', this.onMatched); + + this.pingClock = setInterval(() => { + if (this.matching) { + this.connection.send('ping', { + id: this.matching.id + }); + } + }, 3000); + + os.api('games/reversi/games', { + my: true + }).then(games => { + this.myGames = games; + }); + + os.api('games/reversi/invitations').then(invitations => { + this.invitations = this.invitations.concat(invitations); + }); + } + + os.api('games/reversi/games').then(games => { + this.games = games; + this.gamesFetching = false; + }); + }, + + beforeUnmount() { + if (this.connection) { + this.connection.dispose(); + clearInterval(this.pingClock); + } + }, + + methods: { + go(game) { + const url = '/games/reversi/' + game.id; + if (this.navHook) { + this.navHook(url); + } else { + this.$router.push(url); + } + }, + + async match() { + const user = await os.selectUser({ local: true }); + if (user == null) return; + os.api('games/reversi/match', { + userId: user.id + }).then(res => { + if (res == null) { + this.matching = user; + } else { + this.go(res); + } + }); + }, + + cancel() { + this.matching = null; + os.api('games/reversi/match/cancel'); + }, + + accept(invitation) { + os.api('games/reversi/match', { + userId: invitation.parent.id + }).then(game => { + if (game) { + this.go(game); + } + }); + }, + + onMatched(game) { + this.go(game); + }, + + onInvited(invite) { + this.invitations.unshift(invite); + } + } +}); +</script> + +<style lang="scss" scoped> +.bgvwxkhb { + > h1 { + margin: 0; + padding: 24px; + text-align: center; + font-size: 1.5em; + background: linear-gradient(0deg, #43c583, #438881); + color: #fff; + } + + > .play { + text-align: center; + } +} + +.sazhgisb { + text-align: center; +} + +.nfcacttm { + > .invitation { + display: flex; + box-sizing: border-box; + width: 100%; + padding: 16px; + line-height: 32px; + text-align: left; + + > .avatar { + width: 32px; + height: 32px; + margin-right: 8px; + } + + > .name { + margin-right: 8px; + } + + > .username { + margin-right: 8px; + opacity: 0.7; + } + + > .time { + margin-left: auto; + opacity: 0.7; + } + } +} + +.knextgwz { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: var(--margin); + + > .game { + > .players { + text-align: center; + padding: 16px; + line-height: 32px; + + > .avatar { + width: 32px; + height: 32px; + + &:first-child { + margin-right: 8px; + } + + &:last-child { + margin-left: 8px; + } + } + } + + > footer { + display: flex; + align-items: baseline; + border-top: solid 1px var(--divider); + padding: 6px 8px; + font-size: 0.9em; + + > .state { + &.playing { + color: var(--accent); + } + } + + > .time { + margin-left: auto; + opacity: 0.7; + } + } + } +} +</style> diff --git a/src/client/router.ts b/src/client/router.ts index 56320d224e..5068eccfea 100644 --- a/src/client/router.ts +++ b/src/client/router.ts @@ -70,6 +70,8 @@ export const router = createRouter({ { path: '/instance/abuses', component: page('instance/abuses') }, { path: '/notes/:note', name: 'note', component: page('note'), props: route => ({ noteId: route.params.note }) }, { path: '/tags/:tag', component: page('tag'), props: route => ({ tag: route.params.tag }) }, + { path: '/games/reversi', component: page('reversi/index') }, + { path: '/games/reversi/:gameId', component: page('reversi/game'), props: route => ({ gameId: route.params.gameId }) }, { path: '/api-console', component: page('api-console') }, { path: '/auth/:token', component: page('auth') }, { path: '/miauth/:session', component: page('miauth') }, diff --git a/src/client/sidebar.ts b/src/client/sidebar.ts index e57f85020d..af1f68b0dc 100644 --- a/src/client/sidebar.ts +++ b/src/client/sidebar.ts @@ -115,7 +115,7 @@ export const sidebarDef = { games: { title: 'games', icon: faGamepad, - to: '/games', + to: '/games/reversi', }, scratchpad: { title: 'scratchpad', diff --git a/src/client/widgets/rss.vue b/src/client/widgets/rss.vue index ba84ceefa3..1140a4252f 100644 --- a/src/client/widgets/rss.vue +++ b/src/client/widgets/rss.vue @@ -77,7 +77,7 @@ export default defineComponent({ > a { display: block; padding: 8px 16px; - color: var(--text); + color: var(--fg); white-space: nowrap; text-overflow: ellipsis; overflow: hidden; diff --git a/src/games/reversi/maps.ts b/src/games/reversi/maps.ts index f74cc85659..eaaff1c918 100644 --- a/src/games/reversi/maps.ts +++ b/src/games/reversi/maps.ts @@ -142,51 +142,6 @@ export const eighteightH4: Map = { ] }; -export const eighteightH12: Map = { - name: '8x8 handicap 12', - category: '8x8', - data: [ - 'bb----bb', - 'b------b', - '--------', - '---wb---', - '---bw---', - '--------', - 'b------b', - 'bb----bb' - ] -}; - -export const eighteightH16: Map = { - name: '8x8 handicap 16', - category: '8x8', - data: [ - 'bbb---bb', - 'b------b', - '-------b', - '---wb---', - '---bw---', - 'b-------', - 'b------b', - 'bb---bbb' - ] -}; - -export const eighteightH20: Map = { - name: '8x8 handicap 20', - category: '8x8', - data: [ - 'bbb--bbb', - 'b------b', - 'b------b', - '---wb---', - '---bw---', - 'b------b', - 'b------b', - 'bbb---bb' - ] -}; - export const eighteightH28: Map = { name: '8x8 handicap 28', category: '8x8', diff --git a/src/server/api/stream/channels/games/reversi-game.ts b/src/server/api/stream/channels/games/reversi-game.ts index e600179480..d03501971e 100644 --- a/src/server/api/stream/channels/games/reversi-game.ts +++ b/src/server/api/stream/channels/games/reversi-game.ts @@ -243,20 +243,23 @@ export default class extends Channel { if (game.isEnded) return; if ((game.user1Id !== this.user.id) && (game.user2Id !== this.user.id)) return; + const myColor = + ((game.user1Id === this.user.id) && game.black == 1) || ((game.user2Id === this.user.id) && game.black == 2) + ? true + : false; + const o = new Reversi(game.map, { isLlotheo: game.isLlotheo, canPutEverywhere: game.canPutEverywhere, loopedBoard: game.loopedBoard }); + // 盤面の状態を再生 for (const log of game.logs) { o.put(log.color, log.pos); } - const myColor = - ((game.user1Id === this.user.id) && game.black == 1) || ((game.user2Id === this.user.id) && game.black == 2) - ? true - : false; + if (o.turn !== myColor) return; if (!o.canPut(myColor, pos)) return; o.put(myColor, pos); |