feat: 完成五子棋棋盘

This commit is contained in:
Litrix2 2024-12-15 19:07:02 +08:00
parent 601fa7cbb9
commit a347115ca3
9 changed files with 259 additions and 63 deletions

View File

@ -78,6 +78,9 @@
.app-header {
--el-header-height: var(--header-height);
--el-loading-spinner-size: 30px;
position: sticky;
z-index: 1000;
top: 0;
background-color: white;
border-bottom: 1px solid var(--el-border-color);
//
@ -92,7 +95,7 @@
.app-router-view-enter-active,
.app-router-view-leave-active {
transition: opacity 0.4s ease;
transition: opacity 0.25s ease;
}
.app-router-view-enter-from,

View File

@ -31,6 +31,9 @@ body {
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.justify-end {
justify-content: flex-end;
}

View File

@ -23,6 +23,7 @@
.app-header-menu--horizontal {
flex: 1;
margin-right: 10px;
border-bottom: none;
}
.app-header-menu :is(.el-menu-item, .el-sub-menu__title) {
user-select: none;

View File

@ -0,0 +1,22 @@
<template>
<el-header class="gobang-header flex align-center">
<div class="flex align-center">
<el-icon :size="30"><icon-cs-gobang /></el-icon>
<h2 v-show="!smLess" class="gobang-header__title">五子棋</h2>
</div>
<slot></slot>
</el-header>
</template>
<style lang="scss" scoped>
.gobang-header {
--el-header-height: var(--header-height);
background-color: white;
border-bottom: 1px solid var(--el-border-color);
}
</style>
<script setup lang="ts">
import { useMediaStore } from '@/stores/media';
import { storeToRefs } from 'pinia';
const { smLess } = storeToRefs(useMediaStore());
</script>

View File

@ -66,33 +66,29 @@ router.beforeEach(async (to) => {
const pageStore = usePageStore();
const { permissionId } = to.meta;
pageStore.setNewRouteId(to);
try {
if (!userStore.userInfo) {
const succeed = await userStore.updateSelfUserInfo(true);
if (!succeed) {
if (permissionId === undefined) {
return true;
}
return pageStore.createTempErrorRoute(
{
type: PageErrorType.NETWORK_ERROR,
},
to,
);
}
}
if (to.meta.shouldLogin && !userStore.logined) {
return pageStore.createTempErrorRoute({ type: PageErrorType.NOT_LOGIN }, to);
}
if (permissionId) {
if (userStore.hasPermission(permissionId)) {
if (!userStore.userInfo) {
const succeed = await userStore.updateSelfUserInfo(true);
if (!succeed) {
if (permissionId === undefined) {
return true;
} else {
return pageStore.createTempErrorRoute({ type: PageErrorType.NO_PERMISSION }, to);
}
return pageStore.createTempErrorRoute(
{
type: PageErrorType.NETWORK_ERROR,
},
to,
);
}
}
if (to.meta.shouldLogin && !userStore.logined) {
return pageStore.createTempErrorRoute({ type: PageErrorType.NOT_LOGIN }, to);
}
if (permissionId) {
if (userStore.hasPermission(permissionId)) {
return true;
} else {
return pageStore.createTempErrorRoute({ type: PageErrorType.NO_PERMISSION }, to);
}
} finally {
pageStore.removeRouteId(to);
}
return true;
});

View File

@ -1,6 +1,10 @@
export function create2DArray<T>(height: number, width: number, cell: T | (() => T)): T[][] {
return Array.from({ length: height }, () =>
Array.from({ length: width }, () => (cell instanceof Function ? cell() : cell)),
export function create2DArray<T>(
height: number,
width: number,
cell: T | ((x: number, y: number) => T),
): T[][] {
return Array.from({ length: height }, (_, y) =>
Array.from({ length: width }, (_, x) => (cell instanceof Function ? cell(x, y) : cell)),
);
}

View File

@ -1,15 +1,13 @@
<template>
<el-main class="gobang-list-page__wrapper flex">
<el-container class="flex-stretch">
<el-header class="gobang-list-page-header flex align-center">
<el-icon :size="30"><icon-cs-gobang /></el-icon>
<div v-show="!smLess" class="gobang-list-page-header__title">五子棋</div>
<el-container direction="vertical">
<gobang-header class="justify-between">
<div class="gobang-list-page-header__button-wrapper flex align-center">
<el-button type="primary" :loading="loading" @click="refresh">刷新</el-button>
<el-button @click="showDialog = true" type="success">创建房间</el-button>
<el-button type="primary">单人游戏</el-button>
<el-button type="primary" disabled>单人游戏</el-button>
</div>
</el-header>
</gobang-header>
<el-main
v-loading="loading"
:element-loading-text="loadingText"
@ -46,15 +44,6 @@
font-size: 25px;
font-weight: bold;
}
.gobang-list-page-header {
--el-header-height: var(--header-height);
background-color: white;
gap: 10px;
border-bottom: 1px solid var(--el-border-color);
&__button-wrapper {
margin-left: auto;
}
}
.gobang-list-page-main {
gap: 10px;
&__no-rooms-title {
@ -130,31 +119,29 @@ export function useGobangSocket(respHandlers: RespHandlerMap) {
</script>
<script lang="ts" setup>
import CreateGobangRoomDialog from '@/components/gobang/CreateGobangRoomDialog.vue';
import GobangHeader from '@/components/gobang/GobangHeader.vue';
import router from '@/router';
import type { SucceedUserInfoResponse } from '@/schemas';
import { useMediaStore } from '@/stores/media';
import { useUserStore } from '@/stores/user';
import { useWebSocket } from '@vueuse/core';
import { ElMessage } from 'element-plus';
import { storeToRefs } from 'pinia';
import { onMounted, ref, toRaw, watch } from 'vue';
const { smLess } = storeToRefs(useMediaStore());
import { onMounted, ref, toRaw } from 'vue';
const showDialog = ref(false);
const rooms = ref<RoomRender[]>([]);
const loading = ref(false);
const loadingText = ref<string>();
const { send } = useGobangSocket({
Error(payload) {
ElMessage.error(payload.reason);
Error(p) {
ElMessage.error(p.reason);
loading.value = false;
},
RoomList(payload) {
rooms.value = payload.rooms.map(toRoomRender);
RoomList(p) {
rooms.value = p.rooms.map(toRoomRender);
console.log(toRaw(rooms.value));
loading.value = false;
},
RoomCreated(payload) {
play(payload.roomId);
RoomCreated(p) {
// play(p.roomId);
},
});
function refresh() {
@ -187,7 +174,4 @@ function play(roomId: RoomId) {
function createRoom() {
send(JSON.stringify({ name: 'CreateRoom' } satisfies RequestOf<'CreateRoom'>));
}
function resetRooms() {
send(JSON.stringify({ name: 'ResetRoom' } satisfies RequestOf<'ResetRoom'>));
}
</script>

View File

@ -1,15 +1,188 @@
<template>
<el-main class="gobang-play-page">
<canvas ref="chessBoard"></canvas>
<el-main v-loading="state !== 'idle'" class="gobang-play-page__wrapper flex">
<el-container direction="vertical">
<gobang-header class="justify-between">
<div class="flex align-center">
<template v-if="roomId">
<h2 v-show="!smLess">蓝色基因对战平台</h2>
<h3 class="gobang-play-page__brief-id flex center">#{{ roomId.slice(0, 8) }}</h3>
</template>
</div>
</gobang-header>
<el-main class="gobang-play-page-main center flex">
<template v-if="state !== 'firstLoading'">
<div class="gobang-chessboard" :style="{}">
<canvas class="gobang-chessboard__background" ref="canvas"></canvas>
<template v-for="(row, y) of grid">
<template v-for="(cell, x) of row">
<!-- eslint-disable-next-line vue/require-v-for-key -->
<div
v-if="state !== 'waiting'"
class="gobang-chessboard__cell"
:class="[
cell
? cell.isWhite
? 'gobang-chessboard__cell--white'
: 'gobang-chessboard__cell--black'
: undefined,
]"
:style="getCellStyle(cell, x, y)"
></div>
</template>
</template>
</div>
</template>
</el-main>
</el-container>
</el-main>
</template>
<style lang="scss" scoped>
.gobang-play-page {
&__wrapper {
background-color: white;
}
&__brief-id {
position: absolute;
inset: 0;
}
}
.gobang-header {
position: fixed;
top: var(--header-height);
width: 100%;
z-index: 1000;
}
.gobang-play-page-main {
position: relative;
padding-top: var(--header-height);
}
.gobang-chessboard {
position: relative;
zoom: v-bind(boardZoomCSS);
&__cell {
--size: 24px;
position: absolute;
transform: translate(-50%, -50%);
width: var(--size);
height: var(--size);
border-radius: 50%;
@mixin border {
border: 1px solid black;
}
&--white {
@include border;
background-color: white;
}
&--black {
@include border;
background-color: black;
}
user-select: none;
&:not(&--white, &--black):hover {
background-color: red;
opacity: 0.5;
}
}
}
</style>
<script setup lang="ts">
import GobangHeader from '@/components/gobang/GobangHeader.vue';
import { useMediaStore } from '@/stores/media';
import { create2DArray } from '@/utils';
import { type RequestOf, type RoomId, useGobangSocket } from '@/views/GobangListPage.vue';
import { ref } from 'vue';
import { storeToRefs } from 'pinia';
import { computed, onMounted, ref, watchEffect, type CSSProperties } from 'vue';
import { useRoute } from 'vue-router';
const { send } = useGobangSocket({});
const roomId = useRoute().params.id as RoomId;
const chessBoard = ref<HTMLCanvasElement>();
const { smLess } = storeToRefs(useMediaStore());
const roomId = useRoute().params.id as RoomId | undefined;
const canvas = ref<HTMLCanvasElement>();
const boardBackground = '#E2CFA9';
const boardLength = 16;
const gridSize = 30;
const lineSize = 2;
/** 棋盘外边框大小 */
const boardOuterBorderSize = 2;
/** 棋盘外边框+内边框总大小 */
const boardBorderSize = 5;
const boardPadding = 15;
/** 棋盘大小 */
const boardSize = computed(
() => gridSize * (boardLength - 1) + boardBorderSize * 2 + boardPadding * 2,
);
const boardZoomCSS = computed(() => (smLess.value ? 0.75 : 1));
watchEffect(() => {
if (!canvas.value) return;
const ctx = canvas.value.getContext('2d');
if (!ctx) return;
const _boardBorderSize = boardBorderSize;
const _boardSize = boardSize.value;
canvas.value.width = _boardSize;
canvas.value.height = _boardSize;
ctx.clearRect(0, 0, _boardSize, _boardSize);
ctx.fillStyle = boardBackground;
ctx.fillRect(0, 0, _boardSize, _boardSize);
ctx.strokeStyle = 'black';
const drawBorder = (borderSize: number, offset: number) => {
ctx.lineWidth = borderSize;
ctx.strokeRect(
borderSize / 2 + offset,
borderSize / 2 + offset,
_boardSize - borderSize - offset * 2,
_boardSize - borderSize - offset * 2,
);
};
drawBorder(2, 0);
drawBorder(boardOuterBorderSize, boardPadding);
drawBorder(lineSize, _boardBorderSize - lineSize + boardPadding);
ctx.lineWidth = lineSize;
const _gridSize = gridSize;
for (let i = 1; i < boardLength - 1; i++) {
ctx.beginPath();
ctx.moveTo(boardPadding + _boardBorderSize + _gridSize * i, _boardBorderSize + boardPadding);
ctx.lineTo(
boardPadding + _boardBorderSize + _gridSize * i,
_boardSize - _boardBorderSize - boardPadding,
);
ctx.stroke();
}
for (let i = 1; i < boardLength - 1; i++) {
ctx.beginPath();
ctx.moveTo(_boardBorderSize + boardPadding, boardPadding + _boardBorderSize + _gridSize * i);
ctx.lineTo(
_boardSize - _boardBorderSize - boardPadding,
boardPadding + _boardBorderSize + _gridSize * i,
);
ctx.stroke();
}
console.log('rerender');
});
interface Chess {
isWhite: boolean;
}
const grid = ref(
create2DArray<Chess | undefined>(boardLength, boardLength, (x, y) =>
x >= 8
? undefined
: {
isWhite: !!((x + y) % 2),
},
),
);
function getCellStyle(chess: Chess | undefined, x: number, y: number) {
const res: CSSProperties = {
left: `${boardPadding + boardBorderSize + gridSize * x}px`,
top: `${boardPadding + boardBorderSize + gridSize * y}px`,
};
return res;
}
const state = ref<'firstLoading' | 'loading' | 'idle' | 'waiting'>('firstLoading');
const isWhite = ref(false);
const { send } = useGobangSocket({
PlayerSideAllocation(p) {
isWhite.value = p.isWhite;
state.value = 'idle';
},
});
function playerJoin(roomId: RoomId) {
send(
JSON.stringify({
@ -20,4 +193,9 @@ function playerJoin(roomId: RoomId) {
} satisfies RequestOf<'PlayerJoin'>),
);
}
onMounted(() => {
if (roomId) {
playerJoin(roomId);
}
});
</script>

View File

@ -30,7 +30,12 @@ export default defineConfig({
customCollections: ['cs'],
}),
],
globs: ['!src/components/**/*.vue', '!src/views/**/*.vue'],
globs: [
'!src/components/*.vue',
'!src/components/**/*.vue',
'!src/views/*.vue',
'!src/views/**/*.vue',
],
}),
Icons({
autoInstall: true,