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