feat: 添加五子棋棋盘组件(Canvas 渲染)
This commit is contained in:
253
src/views/game/components/GomokuBoard.vue
Normal file
253
src/views/game/components/GomokuBoard.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user