feat: 添加五子棋棋盘组件(Canvas 渲染)

This commit is contained in:
2026-06-23 11:18:13 +08:00
parent 5d45ad037f
commit 2a2c941d32

View File

@@ -0,0 +1,253 @@
<template>
<div class="gomoku-board-wrapper">
<canvas
ref="canvasRef"
class="gomoku-board"
:width="canvasSize"
:height="canvasSize"
@click="handleClick"
@mousemove="handleMouseMove"
@mouseleave="hoverPos = null"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, inject } from 'vue'
import { useRoomStore } from '@/stores'
import { storeToRefs } from 'pinia'
import type { RoomSocket } from '@/common/websocket/roomSocket'
/** 棋盘常量 */
const BOARD_SIZE = 15
const CELL_SIZE = 36
const PADDING = 24
const STONE_RADIUS = 15
/** Canvas 总尺寸 */
const canvasSize = PADDING * 2 + CELL_SIZE * (BOARD_SIZE - 1)
const store = useRoomStore()
const { boardState, currentTurn, currentUserId, isPlayer, isPlaying } = storeToRefs(store)
const socket = inject<RoomSocket>('roomSocket')!
const canvasRef = ref<HTMLCanvasElement | null>(null)
const hoverPos = ref<{ row: number; col: number } | null>(null)
/** 是否轮到当前用户落子 */
const canPlace = computed(() => {
return isPlayer.value && isPlaying.value && currentTurn.value === currentUserId.value
})
onMounted(() => {
drawBoard()
})
/** 监听棋盘状态变化,触发重绘 */
watch(() => boardState.value, () => {
drawBoard()
}, { deep: true })
watch(hoverPos, () => {
drawBoard()
})
/**
* 完整绘制棋盘:背景 → 网格线 → 星位点 → 棋子 → 悬停预览 → 最后一手标记
*/
function drawBoard() {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const size = canvasSize
ctx.clearRect(0, 0, size, size)
// ---- 木质背景 ----
ctx.fillStyle = '#DEB887'
ctx.fillRect(0, 0, size, size)
// ---- 网格线 ----
ctx.strokeStyle = '#8B7355'
ctx.lineWidth = 1
for (let i = 0; i < BOARD_SIZE; i++) {
const pos = PADDING + i * CELL_SIZE
ctx.beginPath()
ctx.moveTo(PADDING, pos)
ctx.lineTo(PADDING + CELL_SIZE * (BOARD_SIZE - 1), pos)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(pos, PADDING)
ctx.lineTo(pos, PADDING + CELL_SIZE * (BOARD_SIZE - 1))
ctx.stroke()
}
// ---- 星位点(天元 + 四角星) ----
const starPoints: [number, number][] = [
[3, 3], [3, 7], [3, 11],
[7, 3], [7, 7], [7, 11],
[11, 3], [11, 7], [11, 11]
]
ctx.fillStyle = '#6B4226'
for (const [r, c] of starPoints) {
ctx.beginPath()
ctx.arc(PADDING + c * CELL_SIZE, PADDING + r * CELL_SIZE, 3, 0, Math.PI * 2)
ctx.fill()
}
// ---- 已落棋子 ----
const board = boardState.value
if (board) {
for (let r = 0; r < BOARD_SIZE; r++) {
for (let c = 0; c < BOARD_SIZE; c++) {
if (board[r] && board[r][c]) {
drawStone(ctx, r, c, board[r][c] === 1 ? '#1a1a1a' : '#f5f5f5')
}
}
}
}
// ---- 悬停预览 ----
if (hoverPos.value && canPlace.value) {
const { row, col } = hoverPos.value
const board = boardState.value
if (!board?.[row]?.[col]) {
// 根据已有步数判定当前玩家应下的颜色
const moveCount = store.moves.length
const color = moveCount % 2 === 0 ? '#1a1a1a' : '#f5f5f5'
drawStone(ctx, row, col, color, 0.4)
}
}
// ---- 最后一手标记(红色圆圈) ----
const moves = store.moves
if (moves.length > 0) {
const lastMove = moves[moves.length - 1]
const x = PADDING + lastMove.col * CELL_SIZE
const y = PADDING + lastMove.row * CELL_SIZE
ctx.strokeStyle = '#E74C3C'
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(x, y, STONE_RADIUS + 2, 0, Math.PI * 2)
ctx.stroke()
}
}
/**
* 绘制一颗棋子(包含阴影和径向高光)
* @param ctx Canvas 渲染上下文
* @param row 棋子行号0-based
* @param col 棋子列号0-based
* @param color 棋子主色
* @param alpha 透明度(用于悬停预览)
*/
function drawStone(ctx: CanvasRenderingContext2D, row: number, col: number, color: string, alpha = 1) {
const x = PADDING + col * CELL_SIZE
const y = PADDING + row * CELL_SIZE
ctx.save()
ctx.globalAlpha = alpha
// 棋子阴影
ctx.shadowColor = 'rgba(0,0,0,0.3)'
ctx.shadowBlur = 3
ctx.shadowOffsetX = 1
ctx.shadowOffsetY = 1
// 棋子底色
ctx.beginPath()
ctx.arc(x, y, STONE_RADIUS, 0, Math.PI * 2)
ctx.fillStyle = color
ctx.fill()
// 径向高光
ctx.shadowColor = 'transparent'
const gradient = ctx.createRadialGradient(
x - STONE_RADIUS * 0.3, y - STONE_RADIUS * 0.3, STONE_RADIUS * 0.1,
x, y, STONE_RADIUS
)
if (color === '#1a1a1a') {
gradient.addColorStop(0, '#555')
gradient.addColorStop(1, '#1a1a1a')
} else {
gradient.addColorStop(0, '#fff')
gradient.addColorStop(1, '#d0d0d0')
}
ctx.fillStyle = gradient
ctx.fill()
ctx.restore()
}
/**
* 点击棋盘,在最近交点落子
*/
function handleClick(event: MouseEvent) {
if (!canPlace.value) return
const { row, col } = getBoardPos(event)
if (row === -1) return
// 校验该位置是否已有棋子
const board = boardState.value
if (board?.[row]?.[col]) return
socket.send('place_stone', { row, col })
}
/** 鼠标移动时更新悬停位置 */
function handleMouseMove(event: MouseEvent) {
hoverPos.value = getBoardPos(event)
}
/**
* 将鼠标事件坐标转换为棋盘交点坐标(带容差检测)
* @returns { row, col },超出范围或距离太远时返回 { -1, -1 }
*/
function getBoardPos(event: MouseEvent): { row: number; col: number } {
const canvas = canvasRef.value
if (!canvas) return { row: -1, col: -1 }
const rect = canvas.getBoundingClientRect()
// Canvas 物理尺寸与 CSS 尺寸可能存在缩放,需换算
const scaleX = canvasSize / rect.width
const scaleY = canvasSize / rect.height
const x = (event.clientX - rect.left) * scaleX
const y = (event.clientY - rect.top) * scaleY
const col = Math.round((x - PADDING) / CELL_SIZE)
const row = Math.round((y - PADDING) / CELL_SIZE)
if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) {
return { row: -1, col: -1 }
}
// 校验点击位置是否接近交点(容差 = STONE_RADIUS
const cx = PADDING + col * CELL_SIZE
const cy = PADDING + row * CELL_SIZE
const dist = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2)
if (dist > STONE_RADIUS) {
return { row: -1, col: -1 }
}
return { row, col }
}
</script>
<style scoped lang="scss">
.gomoku-board-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.gomoku-board {
max-width: 100%;
max-height: 100%;
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
cursor: pointer;
image-rendering: auto;
}
</style>