diff --git a/packages/frontend/assets/reversi/lose.mp3 b/packages/frontend/assets/reversi/lose.mp3 new file mode 100644 index 0000000000..b62d50baf7 Binary files /dev/null and b/packages/frontend/assets/reversi/lose.mp3 differ diff --git a/packages/frontend/assets/reversi/win.mp3 b/packages/frontend/assets/reversi/win.mp3 new file mode 100644 index 0000000000..25402ce2a6 Binary files /dev/null and b/packages/frontend/assets/reversi/win.mp3 differ diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue index 5e28f55902..6ad779c605 100644 --- a/packages/frontend/src/pages/reversi/game.board.vue +++ b/packages/frontend/src/pages/reversi/game.board.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkSpacer :contentMax="600"> +<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> @@ -35,53 +35,55 @@ SPDX-License-Identifier: AGPL-3.0-only </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 :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 :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" + <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)" > - <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> + <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.labelsY"> - <div v-for="i in game.map.length" :class="$style.labelsYLabel">{{ i }}</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 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 v-if="game.isEnded" class="_panel _gaps_s" style="padding: 16px;"> @@ -155,6 +157,7 @@ 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(); @@ -329,6 +332,22 @@ function onStreamLog(log: Reversi.Serializer.Log & { id: string | null }) { 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() { @@ -465,8 +484,27 @@ $gap: 4px; .board { width: 100%; - max-width: 500px; + 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 { diff --git a/packages/frontend/src/pages/reversi/game.setting.vue b/packages/frontend/src/pages/reversi/game.setting.vue index 9ca107278b..d69176e25a 100644 --- a/packages/frontend/src/pages/reversi/game.setting.vue +++ b/packages/frontend/src/pages/reversi/game.setting.vue @@ -8,72 +8,74 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSpacer :contentMax="600"> <div style="text-align: center;"><b><MkUserName :user="game.user1"/></b> vs <b><MkUserName :user="game.user2"/></b></div> - <div class="_gaps"> - <div style="font-size: 1.5em; text-align: center;">{{ i18n.ts._reversi.gameSettings }}</div> + <div :class="{ [$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 class="_panel"> + <div style="display: flex; align-items: center; padding: 16px; border-bottom: solid 1px var(--divider);"> + <div>{{ mapName }}</div> + <MkButton style="margin-left: auto;" @click="chooseMap">{{ i18n.ts._reversi.chooseBoard }}</MkButton> + </div> - <div style="padding: 16px;"> - <div v-if="game.map == null"><i class="ti ti-dice"></i></div> - <div v-else :class="$style.board" :style="{ 'grid-template-rows': `repeat(${ game.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.map[0].length }, 1fr)` }"> - <div v-for="(x, i) in game.map.join('')" :class="[$style.boardCell, { [$style.boardCellNone]: x == ' ' }]" @click="onMapCellClick(i, x)"> - <i v-if="x === 'b' || x === 'w'" style="pointer-events: none; user-select: none;" :class="x === 'b' ? 'ti ti-circle-filled' : 'ti ti-circle'"></i> + <div style="padding: 16px;"> + <div v-if="game.map == null"><i class="ti ti-dice"></i></div> + <div v-else :class="$style.board" :style="{ 'grid-template-rows': `repeat(${ game.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.map[0].length }, 1fr)` }"> + <div v-for="(x, i) in game.map.join('')" :class="[$style.boardCell, { [$style.boardCellNone]: x == ' ' }]" @click="onMapCellClick(i, x)"> + <i v-if="x === 'b' || x === 'w'" style="pointer-events: none; user-select: none;" :class="x === 'b' ? 'ti ti-circle-filled' : 'ti ti-circle'"></i> + </div> </div> </div> </div> + + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts._reversi.blackOrWhite }}</template> + + <MkRadios v-model="game.bw"> + <option value="random">{{ i18n.ts.random }}</option> + <option :value="'1'"> + <I18n :src="i18n.ts._reversi.blackIs" tag="span"> + <template #name> + <b><MkUserName :user="game.user1"/></b> + </template> + </I18n> + </option> + <option :value="'2'"> + <I18n :src="i18n.ts._reversi.blackIs" tag="span"> + <template #name> + <b><MkUserName :user="game.user2"/></b> + </template> + </I18n> + </option> + </MkRadios> + </MkFolder> + + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts._reversi.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> - - <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> </MkSpacer> <template #footer> @@ -123,7 +125,7 @@ const props = defineProps<{ }>(); const game = ref<Misskey.entities.ReversiGameDetailed>(deepClone(props.game)); -const isLlotheo = ref<boolean>(false); + const mapName = computed(() => { if (game.value.map == null) return 'Random'; const found = Object.values(Reversi.maps).find(x => x.data.join('') === game.value.map.join('')); @@ -236,6 +238,15 @@ onUnmounted(() => { </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;