Files
wuziqi/web/src/components/Board.vue
T
root 59bfc98afa feat: 在线双人五子棋(Socket.IO + Vue3 + Canvas)
- 服务端权威状态机:落子校验、四方向五连判胜、悔棋请求、认输、离线判负、交换黑白再来一局
- 断线重连:resumeToken 凭证 + localStorage,刷新/锁屏自动回到原座位
- 移动端 Canvas 棋盘:DPR 适配、触摸容差吸附、最后一手标记、胜利连子高亮
- Docker 多阶段构建,非 root + 只读根文件系统
- k8s 清单:单副本 Recreate(内存态房态)、Ingress WebSocket 超时与粘性会话注释
2026-09-10 18:17:10 +08:00

229 lines
5.9 KiB
Vue

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { BOARD_SIZE, type Cell, type Player } from '../../../shared/protocol'
const props = defineProps<{
board: Cell[]
lastMove: number | null
winLine: number[] | null
/** 自己执子颜色,null = 未入座(不可落子) */
seat: Player | null
/** 轮到自己时为 true */
myTurn: boolean
}>()
const emit = defineEmits<{ (e: 'place', x: number, y: number): void }>()
const host = ref<HTMLDivElement | null>(null)
const canvas = ref<HTMLCanvasElement | null>(null)
/** 逻辑像素边长、格宽、边距(= 半个格宽,使最外线到边缘留白对称) */
let size = 0
let cell = 0
let pad = 0
const STAR_POINTS: Array<[number, number]> = [
[3, 3],
[11, 3],
[3, 11],
[11, 11],
[7, 7],
]
const COLORS = {
boardTop: '#e8c088',
boardBottom: '#d9a866',
grid: 'rgba(60, 35, 10, 0.75)',
border: 'rgba(50, 28, 6, 0.95)',
}
function ctx2d(): CanvasRenderingContext2D | null {
return canvas.value?.getContext('2d') ?? null
}
/** 按容器尺寸与 DPR 重建画布,再整体重绘 */
function layout(): void {
const el = host.value
const c = canvas.value
if (!el || !c) return
// 棋盘为正方形:宽度受限,且给底部操作区留出空间
const availW = el.clientWidth || window.innerWidth - 32
const availH = window.innerHeight * 0.6
size = Math.floor(Math.min(availW, availH))
cell = size / BOARD_SIZE
pad = cell / 2
const dpr = window.devicePixelRatio || 1
c.width = Math.round(size * dpr)
c.height = Math.round(size * dpr)
c.style.width = `${size}px`
c.style.height = `${size}px`
const g = ctx2d()
if (!g) return
g.setTransform(dpr, 0, 0, dpr, 0, 0)
draw()
}
function draw(): void {
const g = ctx2d()
if (!g || size === 0) return
g.clearRect(0, 0, size, size)
// 木纹底色
const bg = g.createLinearGradient(0, 0, size, size)
bg.addColorStop(0, COLORS.boardTop)
bg.addColorStop(1, COLORS.boardBottom)
g.fillStyle = bg
g.fillRect(0, 0, size, size)
// 网格
g.strokeStyle = COLORS.grid
g.lineWidth = Math.max(1, cell * 0.02)
g.beginPath()
for (let i = 0; i < BOARD_SIZE; i++) {
const p = pad + i * cell
g.moveTo(pad, p)
g.lineTo(size - pad, p)
g.moveTo(p, pad)
g.lineTo(p, size - pad)
}
g.stroke()
// 外框加粗
g.strokeStyle = COLORS.border
g.lineWidth = Math.max(1.5, cell * 0.05)
g.strokeRect(pad, pad, size - cell, size - cell)
// 星位
g.fillStyle = COLORS.border
for (const [x, y] of STAR_POINTS) {
g.beginPath()
g.arc(pad + x * cell, pad + y * cell, cell * 0.09, 0, Math.PI * 2)
g.fill()
}
// 棋子
const r = cell * 0.44
for (let i = 0; i < props.board.length; i++) {
const v = props.board[i]
if (v === 0) continue
const x = i % BOARD_SIZE
const y = Math.floor(i / BOARD_SIZE)
drawStone(g, pad + x * cell, pad + y * cell, r, v)
}
// 最后一手标记(与胜利高亮不重复绘制)
if (props.lastMove !== null && !props.winLine) {
const x = props.lastMove % BOARD_SIZE
const y = Math.floor(props.lastMove / BOARD_SIZE)
const v = props.board[props.lastMove]
g.fillStyle = v === 1 ? '#f9fafb' : '#111827'
g.beginPath()
g.arc(pad + x * cell, pad + y * cell, cell * 0.1, 0, Math.PI * 2)
g.fill()
}
// 五连高亮
if (props.winLine?.length) {
g.strokeStyle = '#ef4444'
g.lineWidth = Math.max(2, cell * 0.07)
for (const i of props.winLine) {
const x = i % BOARD_SIZE
const y = Math.floor(i / BOARD_SIZE)
g.beginPath()
g.arc(pad + x * cell, pad + y * cell, r + cell * 0.12, 0, Math.PI * 2)
g.stroke()
}
}
}
function drawStone(g: CanvasRenderingContext2D, cx: number, cy: number, r: number, v: Cell): void {
// 阴影
g.save()
g.shadowColor = 'rgba(0, 0, 0, 0.35)'
g.shadowBlur = r * 0.35
g.shadowOffsetY = r * 0.12
const grad = g.createRadialGradient(cx - r * 0.35, cy - r * 0.35, r * 0.1, cx, cy, r)
if (v === 1) {
grad.addColorStop(0, '#6b7280')
grad.addColorStop(0.5, '#1f2937')
grad.addColorStop(1, '#030712')
} else {
grad.addColorStop(0, '#ffffff')
grad.addColorStop(0.65, '#f3f4f6')
grad.addColorStop(1, '#b9bec7')
}
g.fillStyle = grad
g.beginPath()
g.arc(cx, cy, r, 0, Math.PI * 2)
g.fill()
g.restore()
}
/** 触摸/点击 → 最近的交叉点,带半格容差,避免误落 */
function onPick(ev: PointerEvent): void {
const c = canvas.value
if (!c || props.seat === null || !props.myTurn) return
const rect = c.getBoundingClientRect()
const px = ev.clientX - rect.left
const py = ev.clientY - rect.top
const x = Math.round((px - pad) / cell)
const y = Math.round((py - pad) / cell)
if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE) return
const dx = px - (pad + x * cell)
const dy = py - (pad + y * cell)
if (Math.hypot(dx, dy) > cell * 0.55) return
if (props.board[y * BOARD_SIZE + x] !== 0) return
emit('place', x, y)
}
let ro: ResizeObserver | null = null
onMounted(() => {
layout()
ro = new ResizeObserver(() => layout())
if (host.value) ro.observe(host.value)
window.addEventListener('orientationchange', layout)
})
onBeforeUnmount(() => {
ro?.disconnect()
window.removeEventListener('orientationchange', layout)
})
// 棋盘数据变化即重绘(Canvas 不受 Vue 响应式影响,需手动触发)
watch(() => [props.board, props.lastMove, props.winLine], draw, { deep: true })
</script>
<template>
<div ref="host" class="board-host">
<canvas ref="canvas" class="board-canvas" @pointerdown.prevent="onPick" />
</div>
</template>
<style scoped>
.board-host {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex: 1;
min-height: 0;
}
.board-canvas {
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
/* 交给脚本处理指针事件,禁止浏览器手势介入 */
touch-action: none;
}
</style>