feat: 在线双人五子棋(Socket.IO + Vue3 + Canvas)
- 服务端权威状态机:落子校验、四方向五连判胜、悔棋请求、认输、离线判负、交换黑白再来一局 - 断线重连:resumeToken 凭证 + localStorage,刷新/锁屏自动回到原座位 - 移动端 Canvas 棋盘:DPR 适配、触摸容差吸附、最后一手标记、胜利连子高亮 - Docker 多阶段构建,非 root + 只读根文件系统 - k8s 清单:单副本 Recreate(内存态房态)、Ingress WebSocket 超时与粘性会话注释
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user