feat: 在线双人五子棋(Socket.IO + Vue3 + Canvas)
- 服务端权威状态机:落子校验、四方向五连判胜、悔棋请求、认输、离线判负、交换黑白再来一局 - 断线重连:resumeToken 凭证 + localStorage,刷新/锁屏自动回到原座位 - 移动端 Canvas 棋盘:DPR 适配、触摸容差吸附、最后一手标记、胜利连子高亮 - Docker 多阶段构建,非 root + 只读根文件系统 - k8s 清单:单副本 Recreate(内存态房态)、Ingress WebSocket 超时与粘性会话注释
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist-server
|
||||
dist-web
|
||||
.git
|
||||
.gitignore
|
||||
k8s
|
||||
scripts
|
||||
*.log
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
# 依赖与构建产物
|
||||
node_modules/
|
||||
dist-server/
|
||||
dist-web/
|
||||
|
||||
# 敏感文件:k8s kubeconfig 含集群 SA token,严禁提交
|
||||
k8s/kubeconfig.local
|
||||
*.kubeconfig
|
||||
*credentials*
|
||||
|
||||
# 临时与日志
|
||||
*.log
|
||||
*.tar
|
||||
.DS_Store
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---------- 构建阶段:安装全部依赖并产出 dist-server / dist-web ----------
|
||||
FROM node:20-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
COPY tsconfig.server.json tsconfig.web.json vite.config.ts ./
|
||||
COPY shared ./shared
|
||||
COPY server ./server
|
||||
COPY web ./web
|
||||
RUN npm run build
|
||||
|
||||
# ---------- 运行阶段:仅装生产依赖,只带构建产物 ----------
|
||||
FROM node:20-bookworm-slim AS runtime
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev --no-audit --no-fund && npm cache clean --force
|
||||
|
||||
COPY --from=build /app/dist-server ./dist-server
|
||||
COPY --from=build /app/dist-web ./dist-web
|
||||
|
||||
# node 镜像自带 uid=1000 的 node 用户,配合 k8s 的 runAsNonRoot 使用
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:'+process.env.PORT+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "dist-server/server/index.js"]
|
||||
@@ -0,0 +1,85 @@
|
||||
# 本机镜像导入集群的两种方式(无镜像仓库场景):
|
||||
#
|
||||
# 方式 A:集群就是执行 docker build 的这台机器(单节点或每台节点都构建)
|
||||
# docker save wuziqi:0.1.0 -o wuziqi-0.1.0.tar
|
||||
# ctr -n k8s.io images import wuziqi-0.1.0.tar
|
||||
#
|
||||
# 方式 B:集群在别的机器上
|
||||
# docker save wuziqi:0.1.0 -o wuziqi-0.1.0.tar
|
||||
# scp wuziqi-0.1.0.tar <node>:~/
|
||||
# 在【每台可能被调度到的节点】上执行:ctr -n k8s.io images import ~/wuziqi-0.1.0.tar
|
||||
#
|
||||
# 导入后配合 imagePullPolicy: IfNotPresent 即可直接启动,无需任何 registry。
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: wuziqi
|
||||
namespace: wuziqi
|
||||
labels:
|
||||
app: wuziqi
|
||||
spec:
|
||||
# 【必须保持为 1】
|
||||
# 房间与棋局状态存放在进程内存(RoomManager),多副本会各自持有一份状态:
|
||||
# 两名玩家被 Ingress 分流到不同 Pod 时,第二个玩家会看到"房间不存在"。
|
||||
# 若确实需要多副本,必须引入 Redis + Socket.IO Redis Adapter,并把房间状态外置。
|
||||
replicas: 1
|
||||
strategy:
|
||||
# Recreate:先终止旧 Pod 再创建新 Pod。
|
||||
# 默认的 RollingUpdate 会让新旧两个 Pod 短暂并存,此时新 Pod 内存里没有旧 Pod 的房间,
|
||||
# 进行中的对局会"房间丢失"。
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: wuziqi
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: wuziqi
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
fsGroup: 1000
|
||||
# 内存态服务,收到 SIGTERM 后推完最后一次状态即可退出
|
||||
terminationGracePeriodSeconds: 15
|
||||
containers:
|
||||
- name: wuziqi
|
||||
image: wuziqi:0.1.0
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3000
|
||||
env:
|
||||
- name: PORT
|
||||
value: "3000"
|
||||
- name: NODE_ENV
|
||||
value: production
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
cpu: 20m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 256Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
# 进程不需要写文件系统,静态资源与构建产物都是只读的
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
@@ -0,0 +1,55 @@
|
||||
# 部署前需要改两处:
|
||||
# 1. spec.ingressClassName —— 用 `kubectl get ingressclass` 确认集群里实际的 class 名
|
||||
# 2. spec.rules[].host 与 spec.tls[].hosts —— 换成你自己的域名
|
||||
#
|
||||
# 证书二选一:
|
||||
# A) cert-manager 自动签发:在 annotations 里加
|
||||
# cert-manager.io/cluster-issuer: <你的 ClusterIssuer 名>
|
||||
# B) 手动导入已有证书:
|
||||
# kubectl -n wuziqi create secret tls wuziqi-tls \
|
||||
# --cert=fullchain.pem --key=privkey.pem
|
||||
# 没有域名时删掉整段 spec.tls,并去掉 annotations 里的 force-ssl-redirect(若有),
|
||||
# 改为纯 HTTP 访问;Socket.IO 在 HTTP 下也能工作,但手机网络上不如 wss 稳定。
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: wuziqi
|
||||
namespace: wuziqi
|
||||
labels:
|
||||
app: wuziqi
|
||||
annotations:
|
||||
# —— Socket.IO 长连接的关键三项 ——
|
||||
# 默认 proxy-read-timeout 只有 60s,长连接会被 Ingress 直接掐断,
|
||||
# 表现为手机每隔一分钟掉线一次。心跳间隔 25s,这里放宽到 1 小时。
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
# 关闭缓冲,避免事件被攒着不发导致落子延迟
|
||||
nginx.ingress.kubernetes.io/proxy-buffering: "off"
|
||||
# 显式声明 WebSocket Upgrade 的目标 Service
|
||||
nginx.ingress.kubernetes.io/websocket-services: "wuziqi"
|
||||
|
||||
# —— 会话亲和性 ——
|
||||
# 客户端 transports 为 [websocket, polling],WebSocket 不通时会回退到长轮询。
|
||||
# 长轮询的握手与后续请求必须落到同一 Pod,否则会话丢失。
|
||||
# replicas=1 时这几项是空操作,但一旦调大副本数就是必需的。
|
||||
nginx.ingress.kubernetes.io/affinity: "cookie"
|
||||
nginx.ingress.kubernetes.io/session-cookie-name: "wuziqi-affinity"
|
||||
nginx.ingress.kubernetes.io/session-cookie-max-age: "3600"
|
||||
nginx.ingress.kubernetes.io/session-cookie-samesite: "Lax"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- gomoku.example.com
|
||||
secretName: wuziqi-tls
|
||||
rules:
|
||||
- host: gomoku.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: wuziqi
|
||||
port:
|
||||
number: 80
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: wuziqi
|
||||
labels:
|
||||
app: wuziqi
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: wuziqi
|
||||
namespace: wuziqi
|
||||
labels:
|
||||
app: wuziqi
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: wuziqi
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
Generated
+3532
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "wuziqi",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "在线五子棋 · 双人手机对弈(Socket.IO 单副本,无数据库)",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,web -c blue,green \"npm:dev:server\" \"npm:dev:web\"",
|
||||
"dev:server": "tsx watch server/index.ts",
|
||||
"dev:web": "vite",
|
||||
"build": "npm run build:server && npm run build:web",
|
||||
"build:server": "tsc -p tsconfig.server.json",
|
||||
"build:web": "vite build",
|
||||
"start": "node dist-server/server/index.js",
|
||||
"smoke": "node scripts/smoke.mjs",
|
||||
"typecheck": "tsc -p tsconfig.server.json --noEmit && vue-tsc -p tsconfig.web.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"socket.io": "^4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.17.10",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"concurrently": "^9.1.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11",
|
||||
"vue": "^3.5.13",
|
||||
"vue-tsc": "^2.1.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
// 临时端到端联调脚本:启动服务后用两个 socket 客户端模拟真实对局
|
||||
import { io } from 'socket.io-client'
|
||||
|
||||
const URL = process.env.URL ?? 'http://127.0.0.1:3000'
|
||||
const EV = {
|
||||
create: 'room:create',
|
||||
join: 'room:join',
|
||||
move: 'game:move',
|
||||
undoRequest: 'undo:request',
|
||||
undoRespond: 'undo:respond',
|
||||
resign: 'game:resign',
|
||||
claimOffline: 'game:claim-offline',
|
||||
restart: 'game:restart',
|
||||
state: 'game:state',
|
||||
}
|
||||
|
||||
let pass = 0
|
||||
let fail = 0
|
||||
function check(name, cond, extra = '') {
|
||||
if (cond) {
|
||||
pass++
|
||||
console.log(` ok ${name}`)
|
||||
} else {
|
||||
fail++
|
||||
console.log(` FAIL ${name} ${extra}`)
|
||||
}
|
||||
}
|
||||
|
||||
const connect = (label) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const s = io(URL, { transports: ['websocket'], forceNew: true })
|
||||
s.label = label
|
||||
s.on('connect', () => resolve(s))
|
||||
s.on('connect_error', (e) => reject(new Error(`${label} 连接失败: ${e.message}`)))
|
||||
})
|
||||
|
||||
const emit = (s, ev, payload) => new Promise((resolve) => s.emit(ev, payload, resolve))
|
||||
|
||||
function waitState(s, pred = () => true, ms = 3000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
s.off(EV.state, h)
|
||||
reject(new Error(`${s.label} 等待 state 超时`))
|
||||
}, ms)
|
||||
function h(st) {
|
||||
if (!pred(st)) return
|
||||
clearTimeout(timer)
|
||||
s.off(EV.state, h)
|
||||
resolve(st)
|
||||
}
|
||||
s.on(EV.state, h)
|
||||
})
|
||||
}
|
||||
|
||||
/** 先挂监听再发指令,避免广播早于监听到达 */
|
||||
async function act(s, ev, payload, pred) {
|
||||
const wait = waitState(s, pred)
|
||||
const ack = await emit(s, ev, payload)
|
||||
const st = await wait
|
||||
return { ack, st }
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
const at = (x, y) => y * 15 + x
|
||||
|
||||
async function main() {
|
||||
console.log(`\n== 连接 ${URL} ==`)
|
||||
const A = await connect('A')
|
||||
const B = await connect('B')
|
||||
check('两个客户端均已连接', A.connected && B.connected)
|
||||
|
||||
console.log('\n== 1. 建房与加入 ==')
|
||||
const createAck = await emit(A, EV.create, {})
|
||||
check('创建房间返回 ok', createAck?.ok === true, JSON.stringify(createAck))
|
||||
const roomId = createAck.roomId
|
||||
check('房间码为 6 位', typeof roomId === 'string' && roomId.length === 6, roomId)
|
||||
|
||||
const beforeJoin = await emit(B, EV.join, { roomId })
|
||||
check('B 加入成功且执白', beforeJoin?.ok === true && beforeJoin.seat === 2, JSON.stringify(beforeJoin).slice(0, 120))
|
||||
check('B 加入后状态为 playing', beforeJoin.state.status === 'playing')
|
||||
check('黑棋先手', beforeJoin.state.turn === 1)
|
||||
check('B 拿到重连凭证', typeof beforeJoin.resumeToken === 'string' && beforeJoin.resumeToken.length > 10)
|
||||
|
||||
const third = await connect('C')
|
||||
const thirdJoin = await emit(third, EV.join, { roomId })
|
||||
check('第三人加入被拒(房间已满)', thirdJoin?.ok === false, JSON.stringify(thirdJoin))
|
||||
third.disconnect()
|
||||
|
||||
console.log('\n== 2. 落子与轮次校验 ==')
|
||||
const bad = await emit(B, EV.move, { x: 7, y: 7 })
|
||||
check('白棋抢先落子被拒', bad?.ok === false && /轮/.test(bad.error), JSON.stringify(bad))
|
||||
|
||||
const m1 = await act(A, EV.move, { x: 7, y: 7 }, (s) => s.board[at(7, 7)] === 1)
|
||||
check('黑棋 (7,7) 落子成功', m1.ack.ok === true)
|
||||
check('落子后轮到白棋', m1.st.turn === 2)
|
||||
check('B 同步收到该手', m1.st.board[at(7, 7)] === 1 && m1.st.lastMove === at(7, 7))
|
||||
check('A 视角座位为黑', m1.st.seat === 1)
|
||||
check('B 视角座位为白', beforeJoin.state.seat === 2)
|
||||
|
||||
const dup = await emit(B, EV.move, { x: 7, y: 7 })
|
||||
check('重复落子被拒', dup?.ok === false && /棋子/.test(dup.error), JSON.stringify(dup))
|
||||
|
||||
const oob = await emit(B, EV.move, { x: 99, y: -1 })
|
||||
check('越界坐标被拒', oob?.ok === false, JSON.stringify(oob))
|
||||
|
||||
const m2 = await act(B, EV.move, { x: 0, y: 0 }, (s) => s.board[at(0, 0)] === 2)
|
||||
check('白棋 (0,0) 落子成功', m2.ack.ok === true && m2.st.moveCount === 2)
|
||||
|
||||
console.log('\n== 3. 悔棋 ==')
|
||||
// 此时轮到黑,A 请求悔棋 → 撤回 2 手(白 1 手 + 黑 1 手)
|
||||
const undoReq = await act(A, EV.undoRequest, {}, (s) => s.undoRequestedBy === 1)
|
||||
check('A 发起悔棋成功', undoReq.ack.ok === true)
|
||||
check('B 看到悔棋请求来自黑棋', undoReq.st.undoRequestedBy === 1)
|
||||
|
||||
const selfRespond = await emit(A, EV.undoRespond, { accept: true })
|
||||
check('请求方不能自己同意', selfRespond?.ok === false, JSON.stringify(selfRespond))
|
||||
|
||||
const undoDone = await act(B, EV.undoRespond, { accept: true }, (s) => s.moveCount === 0)
|
||||
check('B 同意后悔棋生效', undoDone.ack.ok === true)
|
||||
check('撤回 2 手后棋盘清空', undoDone.st.board.every((c) => c === 0))
|
||||
check('撤回后轮到黑棋', undoDone.st.turn === 1)
|
||||
check('撤回后无 lastMove', undoDone.st.lastMove === null)
|
||||
check('悔棋请求已清空', undoDone.st.undoRequestedBy === null)
|
||||
|
||||
console.log('\n== 4. 五连判胜 ==')
|
||||
// 黑棋沿 y=7 走 (3..7,7),白棋走 y=0;黑棋第 5 手成五连
|
||||
let last = null
|
||||
for (let i = 0; i < 5; i++) {
|
||||
last = await act(A, EV.move, { x: 3 + i, y: 7 }, (s) => s.moveCount === 2 * i + 1)
|
||||
if (i < 4) await act(B, EV.move, { x: i, y: 0 }, (s) => s.moveCount === 2 * i + 2)
|
||||
}
|
||||
const won = last.st
|
||||
check(
|
||||
'黑棋五连获胜',
|
||||
won.status === 'over' && won.winner === 1,
|
||||
JSON.stringify({ status: won.status, winner: won.winner }),
|
||||
)
|
||||
check('终局原因 five', won.endReason === 'five', String(won.endReason))
|
||||
check('winLine 长度为 5', won.winLine?.length === 5, JSON.stringify(won.winLine))
|
||||
check(
|
||||
'winLine 内容正确',
|
||||
JSON.stringify(won.winLine) === JSON.stringify([3, 4, 5, 6, 7].map((x) => at(x, 7))),
|
||||
)
|
||||
|
||||
const afterOver = await emit(B, EV.move, { x: 10, y: 10 })
|
||||
check('终局后落子被拒', afterOver?.ok === false, JSON.stringify(afterOver))
|
||||
|
||||
console.log('\n== 5. 再来一局与交换黑白 ==')
|
||||
const restarted = await act(A, EV.restart, { swap: false }, (s) => s.status === 'playing' && s.moveCount === 0)
|
||||
check('再来一局重置棋盘', restarted.ack.ok === true && restarted.st.board.every((c) => c === 0))
|
||||
check('A 仍执黑', restarted.st.seat === 1)
|
||||
check('重置后黑棋先手', restarted.st.turn === 1)
|
||||
|
||||
const swappedB = waitState(B, (s) => s.seat === 1, 3000)
|
||||
const swappedA = waitState(A, (s) => s.seat === 2, 3000)
|
||||
const swapAck = await emit(A, EV.restart, { swap: true })
|
||||
const [sB, sA] = await Promise.all([swappedB, swappedA])
|
||||
check('交换黑白生效:A 变白', swapAck.ok === true && sA.seat === 2)
|
||||
check('交换黑白生效:B 变黑', sB.seat === 1)
|
||||
check('交换后仍是黑棋先手', sA.turn === 1)
|
||||
|
||||
const blackNowIsB = await emit(B, EV.move, { x: 7, y: 7 })
|
||||
check('交换后 B 可以执黑落子', blackNowIsB?.ok === true, JSON.stringify(blackNowIsB))
|
||||
const whiteNowIsA = await emit(A, EV.move, { x: 8, y: 8 })
|
||||
check('交换后 A 执白可正常轮次落子', whiteNowIsA?.ok === true, JSON.stringify(whiteNowIsA))
|
||||
|
||||
console.log('\n== 6. 掉线与重连 ==')
|
||||
const offlineSeen = waitState(A, (s) => s.black.online === false, 3000)
|
||||
B.disconnect()
|
||||
const off = await offlineSeen
|
||||
check('A 看到对手离线', off.black.online === false && off.white.online === true)
|
||||
|
||||
const claim = await emit(A, EV.claimOffline, {})
|
||||
check('离线未满 60 秒不允许判负', claim?.ok === false && /秒/.test(claim.error), JSON.stringify(claim))
|
||||
|
||||
const B2 = await connect('B2')
|
||||
const resumed = await act(B2, EV.join, { roomId, resumeToken: beforeJoin.resumeToken }, (s) => s.black.online === true)
|
||||
check('重连成功并夺回原座位', resumed.ack.ok === true && resumed.ack.seat === 1, JSON.stringify(resumed.ack).slice(0, 120))
|
||||
check('重连后棋局完整保留', resumed.st.board[at(7, 7)] === 1 && resumed.st.board[at(8, 8)] === 2)
|
||||
check('重连后线上状态恢复', resumed.st.black.online === true)
|
||||
|
||||
console.log('\n== 7. 认输与房间校验 ==')
|
||||
const resignWait = waitState(B2, (s) => s.status === 'over', 3000)
|
||||
const resignAck = await emit(A, EV.resign, {})
|
||||
const resigned = await resignWait
|
||||
check('A 认输成功', resignAck.ok === true)
|
||||
check('认输后黑棋(B)获胜', resigned.winner === 1 && resigned.endReason === 'resign', JSON.stringify({ w: resigned.winner, r: resigned.endReason }))
|
||||
|
||||
const notFound = await emit(A, EV.join, { roomId: 'ZZZZZZ' })
|
||||
check('加入不存在的房间被拒', notFound?.ok === false, JSON.stringify(notFound))
|
||||
|
||||
const health = await fetch(`${URL}/healthz`).then((r) => r.json())
|
||||
check('健康检查可用', health.ok === true && typeof health.rooms === 'number', JSON.stringify(health))
|
||||
check('房间统计可用(≥1)', typeof health.rooms === 'number' && health.rooms >= 1, String(health.rooms))
|
||||
|
||||
const indexRes = await fetch(`${URL}/`)
|
||||
const html = await indexRes.text()
|
||||
check('首页可访问', indexRes.ok && html.includes('id="app"'))
|
||||
|
||||
const spaRes = await fetch(`${URL}/some/deep/link`)
|
||||
check('SPA 兜底返回 index.html', spaRes.ok && (await spaRes.text()).includes('id="app"'))
|
||||
|
||||
A.disconnect()
|
||||
B2.disconnect()
|
||||
|
||||
console.log('\n== 8. 状态机边界(直接驱动 Room,注入时间戳)==')
|
||||
const { Room, RoomManager } = await import('../dist-server/server/room.js')
|
||||
const MIN = 60_000
|
||||
|
||||
/** 构造一个双方就座的房间,并按 [x, y, player] 顺序落子 */
|
||||
function build(id, seq) {
|
||||
const r = new Room(id)
|
||||
r.bind(1, `${id}-s1`)
|
||||
r.bind(2, `${id}-s2`)
|
||||
for (const [x, y, p] of seq) {
|
||||
const res = r.place(x, y, p)
|
||||
if (!res.ok) throw new Error(`${id} 落子失败: ${res.error}`)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// 8.1 四方向连珠
|
||||
const horiz = build('T-H', [
|
||||
[3, 7, 1], [0, 0, 2], [4, 7, 1], [0, 1, 2], [5, 7, 1], [0, 2, 2], [6, 7, 1], [0, 3, 2], [7, 7, 1],
|
||||
])
|
||||
check('横向五连判胜', horiz.winner === 1 && horiz.winLine.length === 5)
|
||||
|
||||
const vert = build('T-V', [
|
||||
[5, 0, 1], [0, 0, 2], [5, 1, 1], [0, 1, 2], [5, 2, 1], [0, 2, 2], [5, 3, 1], [0, 3, 2], [5, 4, 1],
|
||||
])
|
||||
check('纵向五连判胜', vert.winner === 1 && vert.winLine.length === 5)
|
||||
|
||||
const diag = build('T-D', [
|
||||
[0, 0, 1], [14, 0, 2], [1, 1, 1], [14, 1, 2], [2, 2, 1], [14, 2, 2], [3, 3, 1], [14, 3, 2], [4, 4, 1],
|
||||
])
|
||||
check('斜向(↘)五连判胜', diag.winner === 1 && diag.winLine.length === 5)
|
||||
|
||||
const anti = build('T-A', [
|
||||
[0, 4, 1], [14, 0, 2], [1, 3, 1], [14, 1, 2], [2, 2, 1], [14, 2, 2], [3, 1, 1], [14, 3, 2], [4, 0, 1],
|
||||
])
|
||||
check('斜向(↗)五连判胜', anti.winner === 1 && anti.winLine.length === 5)
|
||||
|
||||
const four = build('T-4', [[3, 7, 1], [0, 0, 2], [4, 7, 1], [0, 1, 2], [5, 7, 1], [0, 2, 2], [6, 7, 1]])
|
||||
check('四连不判胜', four.status === 'playing' && four.winner === null)
|
||||
|
||||
// 8.2 和棋:把 moveCount 构造到剩最后一格
|
||||
const drawRoom = build('T-DR', [])
|
||||
drawRoom.moveCount = 224
|
||||
const drawRes = drawRoom.place(0, 0, 1)
|
||||
check(
|
||||
'第 225 手触发和棋',
|
||||
drawRes.ok === true && drawRoom.status === 'over' && drawRoom.winner === 0 && drawRoom.endReason === 'draw',
|
||||
)
|
||||
|
||||
// 8.3 离线判负的成功路径(用注入的 now 跨越 60 秒阈值)
|
||||
const offRoom = build('T-OFF', [[7, 7, 1]])
|
||||
offRoom.unbind('T-OFF-s2')
|
||||
const t0 = Date.now()
|
||||
check('刚掉线不允许判负', offRoom.claimOffline(1, t0).ok === false)
|
||||
check('掉线满 60 秒允许判负', offRoom.claimOffline(1, t0 + MIN + 1000).ok === true)
|
||||
check('判负后黑胜且原因为 offline', offRoom.winner === 1 && offRoom.endReason === 'offline')
|
||||
|
||||
// 8.4 悔棋请求超时
|
||||
const undoRoom = build('T-UN', [[0, 0, 1], [1, 0, 2]])
|
||||
check('悔棋请求可发起', undoRoom.requestUndo(1).ok === true)
|
||||
check('未满 60 秒不失效', undoRoom.expireUndo(t0 + MIN - 1000) === false)
|
||||
check('满 60 秒自动失效', undoRoom.expireUndo(t0 + MIN + 1000) === true)
|
||||
check('失效后请求清空', undoRoom.undoRequestedBy === null)
|
||||
|
||||
// 8.5 房间回收
|
||||
const mgr = new RoomManager()
|
||||
const gcRoom = mgr.create()
|
||||
gcRoom.bind(1, 'gc-s1')
|
||||
check('有人在线时不回收', gcRoom.isExpired(t0 + 10 * MIN) === false)
|
||||
gcRoom.unbind('gc-s1')
|
||||
check('离线未超时不回收', gcRoom.isExpired(t0 + 1000) === false)
|
||||
check('全员离线超 30 分钟回收', mgr.gc(t0 + 31 * MIN) === 1 && mgr.size === 0)
|
||||
|
||||
// 8.6 房间码分布
|
||||
const codeMgr = new RoomManager()
|
||||
const codes = new Set()
|
||||
for (let i = 0; i < 200; i++) codes.add(codeMgr.create().id)
|
||||
check('房间码不含易混字符 O/0/I/1', [...codes].every((c) => !/[O0I1]/.test(c)), [...codes].slice(0, 3).join(','))
|
||||
check('200 次生成无重复', codes.size === 200, String(codes.size))
|
||||
|
||||
console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`)
|
||||
process.exit(fail === 0 ? 0 : 1)
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('\n联调脚本异常:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 纯规则模块:坐标换算、落子合法性、胜负判定。
|
||||
* 不依赖任何 I/O,便于单独推演和测试。
|
||||
*/
|
||||
import { BOARD_SIZE, CELL_COUNT, WIN_COUNT, type Cell, type Player } from '../shared/protocol.js'
|
||||
|
||||
export function idx(x: number, y: number): number {
|
||||
return y * BOARD_SIZE + x
|
||||
}
|
||||
|
||||
export function inBounds(x: number, y: number): boolean {
|
||||
return x >= 0 && x < BOARD_SIZE && y >= 0 && y < BOARD_SIZE
|
||||
}
|
||||
|
||||
export function createBoard(): Cell[] {
|
||||
return new Array<Cell>(CELL_COUNT).fill(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 以最后落子点为中心,向横、竖、两条斜线四个方向统计同色连子。
|
||||
* 返回该方向上的完整连子下标数组(长度 >= 5 即为获胜),否则返回 null。
|
||||
*/
|
||||
export function findWinLine(board: Cell[], x: number, y: number, player: Player): number[] | null {
|
||||
const dirs: Array<[number, number]> = [
|
||||
[1, 0],
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
[1, -1],
|
||||
]
|
||||
|
||||
for (const [dx, dy] of dirs) {
|
||||
const line: number[] = [idx(x, y)]
|
||||
|
||||
// 正方向延伸
|
||||
for (let cx = x + dx, cy = y + dy; inBounds(cx, cy) && board[idx(cx, cy)] === player; cx += dx, cy += dy) {
|
||||
line.push(idx(cx, cy))
|
||||
}
|
||||
// 反方向延伸,插到数组头部以保持连线有序
|
||||
for (let cx = x - dx, cy = y - dy; inBounds(cx, cy) && board[idx(cx, cy)] === player; cx -= dx, cy -= dy) {
|
||||
line.unshift(idx(cx, cy))
|
||||
}
|
||||
|
||||
if (line.length >= WIN_COUNT) return line
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function isEmptyCell(board: Cell[], x: number, y: number): boolean {
|
||||
return inBounds(x, y) && board[idx(x, y)] === 0
|
||||
}
|
||||
|
||||
export function opponent(p: Player): Player {
|
||||
return p === 1 ? 2 : 1
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 服务端入口:Express 托管前端静态资源 + Socket.IO 处理对局事件。
|
||||
* 单进程内存态,因此部署时必须保持单副本(见 k8s/deployment.yaml)。
|
||||
*/
|
||||
import { createServer } from 'node:http'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import express from 'express'
|
||||
import { Server } from 'socket.io'
|
||||
import {
|
||||
EV,
|
||||
type ActionAck,
|
||||
type CreateAck,
|
||||
type JoinAck,
|
||||
type NoticePayload,
|
||||
type Player,
|
||||
} from '../shared/protocol.js'
|
||||
import { Room, RoomManager } from './room.js'
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
const PORT = Number(process.env.PORT ?? 3000)
|
||||
/**
|
||||
* 构建产物布局(因 server/ 与 shared/ 需一同编译,tsc 保留了目录层级):
|
||||
* dist-server/server/index.js ← 当前文件
|
||||
* dist-server/shared/protocol.js
|
||||
* dist-web/ ← 前端静态资源
|
||||
*/
|
||||
const WEB_DIR = process.env.WEB_DIR ?? path.resolve(HERE, '../../dist-web')
|
||||
|
||||
const app = express()
|
||||
// 位于 Ingress / 反代之后,需要信任 X-Forwarded-* 才能拿到真实协议与 IP
|
||||
app.set('trust proxy', true)
|
||||
|
||||
app.get('/healthz', (_req, res) => {
|
||||
res.json({ ok: true, rooms: manager.size, uptime: Math.round(process.uptime()) })
|
||||
})
|
||||
|
||||
app.use(express.static(WEB_DIR, { index: 'index.html', maxAge: '1h' }))
|
||||
|
||||
// SPA 兜底:非 /socket.io、非静态资源的 GET 一律返回 index.html
|
||||
app.get('*', (req, res, next) => {
|
||||
if (req.path.startsWith('/socket.io')) return next()
|
||||
res.sendFile(path.join(WEB_DIR, 'index.html'))
|
||||
})
|
||||
|
||||
const httpServer = createServer(app)
|
||||
const io = new Server(httpServer, {
|
||||
serveClient: false,
|
||||
pingInterval: 25_000,
|
||||
pingTimeout: 20_000,
|
||||
maxHttpBufferSize: 1e5,
|
||||
})
|
||||
|
||||
const manager = new RoomManager()
|
||||
|
||||
declare module 'socket.io' {
|
||||
interface SocketData {
|
||||
roomId?: string
|
||||
seat?: Player
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 工具 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 把房间最新状态按各自座位视角分别下发给两端 */
|
||||
function broadcast(room: Room): void {
|
||||
for (const seat of [1, 2] as const) {
|
||||
const sid = room.socketIdOf(seat)
|
||||
if (sid) io.to(sid).emit(EV.state, room.snapshot(seat))
|
||||
}
|
||||
}
|
||||
|
||||
function notice(socketId: string, level: NoticePayload['level'], message: string): void {
|
||||
io.to(socketId).emit(EV.notice, { level, message } satisfies NoticePayload)
|
||||
}
|
||||
|
||||
type Ctx = { room: Room; seat: Player } | null
|
||||
|
||||
/**
|
||||
* 取 Socket.IO 的回调。
|
||||
* 不能用可选链直接调用:客户端可能把载荷当 ack 传进来,
|
||||
* `ack?.()` 只判空不判可调用,会抛未捕获异常打挂整个进程。
|
||||
*/
|
||||
type AnyAck = (r: unknown) => void
|
||||
|
||||
function asAck(v: unknown): AnyAck | undefined {
|
||||
return typeof v === 'function' ? (v as AnyAck) : undefined
|
||||
}
|
||||
|
||||
/** 取出当前 socket 所处的房间与座位,顺带校验房间是否已被回收 */
|
||||
function ctxOf(socketId: string, socketData: { roomId?: string; seat?: Player }): Ctx {
|
||||
const { roomId, seat } = socketData
|
||||
if (!roomId || !seat) return null
|
||||
const room = manager.get(roomId)
|
||||
if (!room) return null
|
||||
if (room.socketIdOf(seat) !== socketId) return null
|
||||
return { room, seat }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 事件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
/**
|
||||
* 统一包装动作类事件:校验上下文 → 执行 → ack → 广播。
|
||||
* 校验不通过时不广播,仅把错误回给发起方。
|
||||
*/
|
||||
function withCtx<T>(
|
||||
fn: (ctx: { room: Room; seat: Player }, payload: T) => ActionAck,
|
||||
): (payload: T | undefined, ack?: unknown) => void {
|
||||
return (payload, ack) => {
|
||||
const reply = asAck(ack)
|
||||
const ctx = ctxOf(socket.id, socket.data)
|
||||
if (!ctx) {
|
||||
reply?.({ ok: false, error: '你已不在对局中,请重新进入房间' })
|
||||
return
|
||||
}
|
||||
const result = fn(ctx, payload as T)
|
||||
if (result.ok) broadcast(ctx.room)
|
||||
reply?.(result)
|
||||
}
|
||||
}
|
||||
|
||||
socket.on(EV.create, (_payload: unknown, ack?: unknown) => {
|
||||
const reply = asAck(ack)
|
||||
const room = manager.create()
|
||||
room.bind(1, socket.id)
|
||||
socket.data.roomId = room.id
|
||||
socket.data.seat = 1
|
||||
void socket.join(room.id)
|
||||
reply?.({ ok: true, roomId: room.id, seat: 1, resumeToken: room.tokenOf(1) } satisfies CreateAck)
|
||||
broadcast(room)
|
||||
})
|
||||
|
||||
socket.on(
|
||||
EV.join,
|
||||
(
|
||||
payload: { roomId?: string; resumeToken?: string } | undefined,
|
||||
ack?: unknown,
|
||||
) => {
|
||||
const reply = asAck(ack)
|
||||
const raw = payload?.roomId ?? ''
|
||||
const room = manager.get(raw)
|
||||
if (!room) {
|
||||
reply?.({ ok: false, error: '房间不存在或已过期' })
|
||||
return
|
||||
}
|
||||
|
||||
// 优先用重连凭证找回原座位,其次占用空位
|
||||
let seat: Player | null = null
|
||||
if (payload?.resumeToken) seat = room.seatByToken(payload.resumeToken)
|
||||
if (seat === null) seat = room.freeSeat()
|
||||
if (seat === null) {
|
||||
reply?.({ ok: false, error: '房间已满,无法加入' })
|
||||
return
|
||||
}
|
||||
|
||||
const staleSocketId = room.socketIdOf(seat)
|
||||
room.bind(seat, socket.id)
|
||||
socket.data.roomId = room.id
|
||||
socket.data.seat = seat
|
||||
void socket.join(room.id)
|
||||
|
||||
// 顶掉该座位的旧连接(同一玩家在另一台设备/标签页重连)
|
||||
if (staleSocketId && staleSocketId !== socket.id) {
|
||||
const stale = io.sockets.sockets.get(staleSocketId)
|
||||
if (stale) {
|
||||
stale.data.roomId = undefined
|
||||
stale.data.seat = undefined
|
||||
stale.disconnect(true)
|
||||
}
|
||||
}
|
||||
|
||||
reply?.({
|
||||
ok: true,
|
||||
roomId: room.id,
|
||||
seat,
|
||||
resumeToken: room.tokenOf(seat),
|
||||
state: room.snapshot(seat),
|
||||
} satisfies JoinAck)
|
||||
broadcast(room)
|
||||
},
|
||||
)
|
||||
|
||||
socket.on(
|
||||
EV.move,
|
||||
withCtx<{ x?: number; y?: number }>(({ room, seat }, p) => {
|
||||
if (!Number.isInteger(p?.x) || !Number.isInteger(p?.y)) {
|
||||
return { ok: false, error: '非法坐标' }
|
||||
}
|
||||
return room.place(p.x as number, p.y as number, seat)
|
||||
}),
|
||||
)
|
||||
|
||||
socket.on(EV.resign, withCtx<void>(({ room, seat }) => room.resign(seat)))
|
||||
socket.on(EV.undoRequest, withCtx<void>(({ room, seat }) => room.requestUndo(seat)))
|
||||
socket.on(EV.claimOffline, withCtx<void>(({ room, seat }) => room.claimOffline(seat)))
|
||||
|
||||
socket.on(
|
||||
EV.undoRespond,
|
||||
withCtx<{ accept?: boolean }>(({ room, seat }, p) => room.respondUndo(seat, p?.accept === true)),
|
||||
)
|
||||
|
||||
socket.on(
|
||||
EV.restart,
|
||||
(payload: { swap?: boolean } | undefined, ack?: unknown) => {
|
||||
const reply = asAck(ack)
|
||||
const ctx = ctxOf(socket.id, socket.data)
|
||||
if (!ctx) {
|
||||
reply?.({ ok: false, error: '你已不在对局中' })
|
||||
return
|
||||
}
|
||||
const { swapped, mapping } = ctx.room.restart(payload?.swap === true)
|
||||
// 交换黑白后必须同步 socket ↔ 座位映射,否则后续落子校验会认错颜色
|
||||
if (swapped) {
|
||||
for (const [sid, seat] of mapping) {
|
||||
const s = io.sockets.sockets.get(sid)
|
||||
if (s) s.data.seat = seat
|
||||
}
|
||||
}
|
||||
reply?.({ ok: true })
|
||||
broadcast(ctx.room)
|
||||
},
|
||||
)
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
const ctx = ctxOf(socket.id, socket.data)
|
||||
if (!ctx) return
|
||||
ctx.room.unbind(socket.id)
|
||||
broadcast(ctx.room)
|
||||
})
|
||||
})
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 周期任务:悔棋请求超时、房间回收 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const ticker = setInterval(() => {
|
||||
for (const room of manager.values()) {
|
||||
if (room.expireUndo()) {
|
||||
broadcast(room)
|
||||
for (const seat of [1, 2] as const) {
|
||||
const sid = room.socketIdOf(seat)
|
||||
if (sid) notice(sid, 'info', '悔棋请求已超时失效')
|
||||
}
|
||||
}
|
||||
}
|
||||
const removed = manager.gc()
|
||||
if (removed > 0) console.log(`[gc] 回收房间 ${removed} 个,当前 ${manager.size} 个`)
|
||||
}, 15_000)
|
||||
ticker.unref()
|
||||
|
||||
httpServer.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`[wuziqi] listening on :${PORT}, static dir = ${WEB_DIR}`)
|
||||
})
|
||||
|
||||
for (const sig of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.on(sig, () => {
|
||||
console.log(`[wuziqi] ${sig} received, shutting down`)
|
||||
io.close(() => httpServer.close(() => process.exit(0)))
|
||||
setTimeout(() => process.exit(0), 3000).unref()
|
||||
})
|
||||
}
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* 房间与对局状态机。
|
||||
* 服务端权威:所有合法性校验与胜负判定都在这里完成,客户端只发送意图。
|
||||
*/
|
||||
import { randomBytes, randomUUID } from 'node:crypto'
|
||||
import {
|
||||
CELL_COUNT,
|
||||
OFFLINE_CLAIM_MS,
|
||||
ROOM_GC_MS,
|
||||
UNDO_TIMEOUT_MS,
|
||||
type Cell,
|
||||
type EndReason,
|
||||
type GameStatus,
|
||||
type Player,
|
||||
type RoomState,
|
||||
} from '../shared/protocol.js'
|
||||
import { createBoard, findWinLine, idx, isEmptyCell, opponent } from './game.js'
|
||||
|
||||
/** 房间码字母表:剔除 O/0/I/1 等易混字符 */
|
||||
const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
|
||||
function generateRoomId(): string {
|
||||
const bytes = randomBytes(6)
|
||||
let out = ''
|
||||
for (let i = 0; i < 6; i++) out += ALPHABET[bytes[i]! % ALPHABET.length]
|
||||
return out
|
||||
}
|
||||
|
||||
interface Seat {
|
||||
/** 重连凭证,入座后固定不变 */
|
||||
resumeToken: string | null
|
||||
socketId: string | null
|
||||
online: boolean
|
||||
/** 掉线起始时间,用于判定"离线超时" */
|
||||
offlineSince: number | null
|
||||
}
|
||||
|
||||
function newSeat(): Seat {
|
||||
return { resumeToken: null, socketId: null, online: false, offlineSince: null }
|
||||
}
|
||||
|
||||
export type ActionResult = { ok: true } | { ok: false; error: string }
|
||||
|
||||
export class Room {
|
||||
readonly id: string
|
||||
board: Cell[] = createBoard()
|
||||
turn: Player = 1
|
||||
status: GameStatus = 'waiting'
|
||||
winner: Player | 0 | null = null
|
||||
endReason: EndReason | null = null
|
||||
winLine: number[] | null = null
|
||||
lastMove: number | null = null
|
||||
/** 落子顺序栈(棋盘下标),用于悔棋与"最后一手"标记 */
|
||||
history: number[] = []
|
||||
moveCount = 0
|
||||
undoRequestedBy: Player | null = null
|
||||
undoRequestedAt: number | null = null
|
||||
lastActiveAt = Date.now()
|
||||
|
||||
private seats: Record<Player, Seat> = { 1: newSeat(), 2: newSeat() }
|
||||
|
||||
constructor(id = generateRoomId()) {
|
||||
this.id = id
|
||||
}
|
||||
|
||||
/* --------------------------- 座位管理 --------------------------- */
|
||||
|
||||
socketIdOf(seat: Player): string | null {
|
||||
return this.seats[seat].socketId
|
||||
}
|
||||
|
||||
seatOfSocket(socketId: string): Player | null {
|
||||
if (this.seats[1].socketId === socketId) return 1
|
||||
if (this.seats[2].socketId === socketId) return 2
|
||||
return null
|
||||
}
|
||||
|
||||
tokenOf(seat: Player): string {
|
||||
const s = this.seats[seat]
|
||||
if (!s.resumeToken) s.resumeToken = randomUUID()
|
||||
return s.resumeToken
|
||||
}
|
||||
|
||||
isFull(): boolean {
|
||||
return this.seats[1].resumeToken !== null && this.seats[2].resumeToken !== null
|
||||
}
|
||||
|
||||
freeSeat(): Player | null {
|
||||
if (this.seats[1].resumeToken === null) return 1
|
||||
if (this.seats[2].resumeToken === null) return 2
|
||||
return null
|
||||
}
|
||||
|
||||
/** 用 token 找回座位(重连) */
|
||||
seatByToken(token: string): Player | null {
|
||||
if (this.seats[1].resumeToken === token) return 1
|
||||
if (this.seats[2].resumeToken === token) return 2
|
||||
return null
|
||||
}
|
||||
|
||||
/** 把 socket 绑定到座位;若该座位已有旧 socket,则把旧 socket 顶掉 */
|
||||
bind(seat: Player, socketId: string): void {
|
||||
const s = this.seats[seat]
|
||||
this.tokenOf(seat)
|
||||
s.socketId = socketId
|
||||
s.online = true
|
||||
s.offlineSince = null
|
||||
this.touch()
|
||||
if (this.status === 'waiting' && this.isFull()) {
|
||||
this.status = 'playing'
|
||||
}
|
||||
}
|
||||
|
||||
/** socket 断开:标记座位离线 */
|
||||
unbind(socketId: string): Player | null {
|
||||
const seat = this.seatOfSocket(socketId)
|
||||
if (seat === null) return null
|
||||
const s = this.seats[seat]!
|
||||
s.socketId = null
|
||||
s.online = false
|
||||
s.offlineSince = Date.now()
|
||||
this.touch()
|
||||
return seat
|
||||
}
|
||||
|
||||
/** 双方均已离线且超过回收时长 → 可回收 */
|
||||
isExpired(now = Date.now()): boolean {
|
||||
const seats = [1, 2] as const
|
||||
const joined = seats.filter((p) => this.seats[p].resumeToken !== null)
|
||||
if (joined.length === 0) return now - this.lastActiveAt > ROOM_GC_MS
|
||||
// 全部离线:以最早掉线时间为准
|
||||
if (joined.every((p) => !this.seats[p].online)) {
|
||||
const since = Math.min(...joined.map((p) => this.seats[p].offlineSince ?? now))
|
||||
return now - since > ROOM_GC_MS
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
offlineFor(player: Player, now = Date.now()): number {
|
||||
const s = this.seats[player]
|
||||
if (s.online || s.offlineSince === null) return 0
|
||||
return now - s.offlineSince
|
||||
}
|
||||
|
||||
/* --------------------------- 对局动作 --------------------------- */
|
||||
|
||||
place(x: number, y: number, player: Player): ActionResult {
|
||||
if (this.status === 'waiting') return { ok: false, error: '对手还没进入房间' }
|
||||
if (this.status === 'over') return { ok: false, error: '本局已结束,请开新局' }
|
||||
if (this.turn !== player) return { ok: false, error: '还没轮到你落子' }
|
||||
if (!isEmptyCell(this.board, x, y)) return { ok: false, error: '该位置已有棋子' }
|
||||
|
||||
const at = idx(x, y)
|
||||
this.board[at] = player
|
||||
this.history.push(at)
|
||||
this.moveCount += 1
|
||||
this.lastMove = at
|
||||
this.undoRequestedBy = null
|
||||
this.undoRequestedAt = null
|
||||
this.touch()
|
||||
|
||||
const line = findWinLine(this.board, x, y, player)
|
||||
if (line) {
|
||||
this.status = 'over'
|
||||
this.winner = player
|
||||
this.endReason = 'five'
|
||||
this.winLine = line
|
||||
} else if (this.moveCount >= CELL_COUNT) {
|
||||
this.status = 'over'
|
||||
this.winner = 0
|
||||
this.endReason = 'draw'
|
||||
} else {
|
||||
this.turn = opponent(player)
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/** 悔棋需要撤回的手数:轮到请求方时撤回 2 手,否则撤回 1 手 */
|
||||
private undoSteps(player: Player): number {
|
||||
return this.turn === player ? 2 : 1
|
||||
}
|
||||
|
||||
requestUndo(player: Player): ActionResult {
|
||||
if (this.status !== 'playing') return { ok: false, error: '当前无法悔棋' }
|
||||
if (this.undoRequestedBy !== null) return { ok: false, error: '已有悔棋请求待处理' }
|
||||
if (this.moveCount < this.undoSteps(player)) return { ok: false, error: '还没有可悔的棋' }
|
||||
|
||||
this.undoRequestedBy = player
|
||||
this.undoRequestedAt = Date.now()
|
||||
this.touch()
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
respondUndo(player: Player, accept: boolean): ActionResult {
|
||||
const requester = this.undoRequestedBy
|
||||
if (requester === null) return { ok: false, error: '没有待处理的悔棋请求' }
|
||||
if (requester === player) return { ok: false, error: '不能响应自己的悔棋请求' }
|
||||
|
||||
const steps = this.undoSteps(requester)
|
||||
this.undoRequestedBy = null
|
||||
this.undoRequestedAt = null
|
||||
this.touch()
|
||||
if (!accept) return { ok: true }
|
||||
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const at = this.history.pop()
|
||||
if (at === undefined) break
|
||||
this.board[at] = 0
|
||||
this.moveCount -= 1
|
||||
}
|
||||
this.lastMove = this.history.at(-1) ?? null
|
||||
// 撤回后回合归请求方
|
||||
this.turn = requester
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
expireUndo(now = Date.now()): boolean {
|
||||
if (this.undoRequestedAt === null) return false
|
||||
if (now - this.undoRequestedAt < UNDO_TIMEOUT_MS) return false
|
||||
this.undoRequestedBy = null
|
||||
this.undoRequestedAt = null
|
||||
this.touch()
|
||||
return true
|
||||
}
|
||||
|
||||
resign(player: Player): ActionResult {
|
||||
if (this.status === 'over') return { ok: false, error: '本局已结束' }
|
||||
if (this.status === 'waiting') return { ok: false, error: '对手还没进入房间' }
|
||||
this.status = 'over'
|
||||
this.winner = opponent(player)
|
||||
this.endReason = 'resign'
|
||||
this.winLine = null
|
||||
this.touch()
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
claimOffline(player: Player, now = Date.now()): ActionResult {
|
||||
if (this.status !== 'playing') return { ok: false, error: '当前无法判负' }
|
||||
const foe = opponent(player)
|
||||
if (this.seats[foe].resumeToken === null) return { ok: false, error: '对手还没有进入房间' }
|
||||
if (this.seats[foe].online) return { ok: false, error: '对手在线,无法判负' }
|
||||
if (this.offlineFor(foe, now) < OFFLINE_CLAIM_MS) {
|
||||
return { ok: false, error: `对手掉线未超过 ${Math.round(OFFLINE_CLAIM_MS / 1000)} 秒,请再等一下` }
|
||||
}
|
||||
this.status = 'over'
|
||||
this.winner = player
|
||||
this.endReason = 'offline'
|
||||
this.winLine = null
|
||||
this.touch()
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 再来一局。swap 为 true 时交换双方座位(等价于交换黑白)。
|
||||
* 返回座位是否发生了交换,供调用方重新绑定 socket ↔ 座位的映射。
|
||||
*/
|
||||
restart(swap: boolean): { swapped: boolean; mapping: Array<[string, Player]> } {
|
||||
this.board = createBoard()
|
||||
this.turn = 1
|
||||
this.status = this.isFull() ? 'playing' : 'waiting'
|
||||
this.winner = null
|
||||
this.endReason = null
|
||||
this.winLine = null
|
||||
this.lastMove = null
|
||||
this.history = []
|
||||
this.moveCount = 0
|
||||
this.undoRequestedBy = null
|
||||
this.undoRequestedAt = null
|
||||
this.touch()
|
||||
|
||||
if (!swap) return { swapped: false, mapping: [] }
|
||||
|
||||
const a = this.seats[1]
|
||||
this.seats[1] = this.seats[2]
|
||||
this.seats[2] = a
|
||||
|
||||
const mapping: Array<[string, Player]> = []
|
||||
for (const p of [1, 2] as const) {
|
||||
const sid = this.seats[p].socketId
|
||||
if (sid) mapping.push([sid, p])
|
||||
}
|
||||
return { swapped: true, mapping }
|
||||
}
|
||||
|
||||
/* --------------------------- 状态快照 --------------------------- */
|
||||
|
||||
/** 生成下发给某个客户端的完整状态,seat 为该客户端执子颜色 */
|
||||
snapshot(seat: Player | null): RoomState {
|
||||
return {
|
||||
roomId: this.id,
|
||||
board: this.board.slice(),
|
||||
turn: this.turn,
|
||||
status: this.status,
|
||||
winner: this.winner,
|
||||
endReason: this.endReason,
|
||||
winLine: this.winLine,
|
||||
lastMove: this.lastMove,
|
||||
moveCount: this.moveCount,
|
||||
undoRequestedBy: this.undoRequestedBy,
|
||||
black: { joined: this.seats[1].resumeToken !== null, online: this.seats[1].online },
|
||||
white: { joined: this.seats[2].resumeToken !== null, online: this.seats[2].online },
|
||||
seat,
|
||||
}
|
||||
}
|
||||
|
||||
private touch(): void {
|
||||
this.lastActiveAt = Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
export class RoomManager {
|
||||
private rooms = new Map<string, Room>()
|
||||
|
||||
create(): Room {
|
||||
let id = generateRoomId()
|
||||
while (this.rooms.has(id)) id = generateRoomId()
|
||||
const room = new Room(id)
|
||||
this.rooms.set(id, room)
|
||||
return room
|
||||
}
|
||||
|
||||
get(id: string): Room | undefined {
|
||||
return this.rooms.get(id.trim().toUpperCase())
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.rooms.delete(id)
|
||||
}
|
||||
|
||||
/** 回收长期无人且双方离线的房间 */
|
||||
gc(now = Date.now()): number {
|
||||
let removed = 0
|
||||
for (const [id, room] of this.rooms) {
|
||||
if (room.isExpired(now)) {
|
||||
this.rooms.delete(id)
|
||||
removed++
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.rooms.size
|
||||
}
|
||||
|
||||
values(): Room[] {
|
||||
return [...this.rooms.values()]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 前后端共享协议:棋盘常量、事件名、状态与载荷类型。
|
||||
* 仅此一处定义,避免两端漂移。
|
||||
*/
|
||||
|
||||
export const BOARD_SIZE = 15
|
||||
export const CELL_COUNT = BOARD_SIZE * BOARD_SIZE
|
||||
export const WIN_COUNT = 5
|
||||
|
||||
/** 落子方:1 = 黑(先手),2 = 白 */
|
||||
export type Player = 1 | 2
|
||||
|
||||
/** 单格状态:0 = 空 */
|
||||
export type Cell = 0 | Player
|
||||
|
||||
export type GameStatus = 'waiting' | 'playing' | 'over'
|
||||
|
||||
/** 终局原因:五连 / 和棋 / 认输 / 对手离线判负 */
|
||||
export type EndReason = 'five' | 'draw' | 'resign' | 'offline'
|
||||
|
||||
/** 座位是否有人、是否在线 */
|
||||
export interface SeatInfo {
|
||||
joined: boolean
|
||||
online: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整对局状态(服务端权威,每次变更后全量下发)。
|
||||
* 225 个格子的全量同步只有几百字节,用全量换掉增量同步的一致性风险。
|
||||
*/
|
||||
export interface RoomState {
|
||||
roomId: string
|
||||
/** 扁平棋盘,下标 = y * BOARD_SIZE + x */
|
||||
board: Cell[]
|
||||
turn: Player
|
||||
status: GameStatus
|
||||
/** null = 未结束;0 = 和棋 */
|
||||
winner: Player | 0 | null
|
||||
endReason: EndReason | null
|
||||
/** 获胜的连子下标,用于前端高亮 */
|
||||
winLine: number[] | null
|
||||
/** 最后一手下标,用于前端标记 */
|
||||
lastMove: number | null
|
||||
moveCount: number
|
||||
/** 谁发起了悔棋请求(未决时非 null) */
|
||||
undoRequestedBy: Player | null
|
||||
black: SeatInfo
|
||||
white: SeatInfo
|
||||
/** 接收者自己执子颜色,未入座为 null */
|
||||
seat: Player | null
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Socket.IO 事件名 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const EV = {
|
||||
/** 创建房间,ack: CreateAck */
|
||||
create: 'room:create',
|
||||
/** 加入 / 重连房间,ack: JoinAck */
|
||||
join: 'room:join',
|
||||
/** 落子,ack: ActionAck */
|
||||
move: 'game:move',
|
||||
/** 请求悔棋,ack: ActionAck */
|
||||
undoRequest: 'undo:request',
|
||||
/** 响应悔棋,ack: ActionAck */
|
||||
undoRespond: 'undo:respond',
|
||||
/** 认输,ack: ActionAck */
|
||||
resign: 'game:resign',
|
||||
/** 对手离线超时后判对方负,ack: ActionAck */
|
||||
claimOffline: 'game:claim-offline',
|
||||
/** 再来一局,ack: ActionAck */
|
||||
restart: 'game:restart',
|
||||
/** 服务端 → 客户端:全量状态推送 */
|
||||
state: 'game:state',
|
||||
/** 服务端 → 客户端:一次性提示(错误/事件) */
|
||||
notice: 'game:notice',
|
||||
} as const
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 载荷类型 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface CreateAck {
|
||||
ok: true
|
||||
roomId: string
|
||||
seat: Player
|
||||
/** 重连凭证,客户端存 localStorage */
|
||||
resumeToken: string
|
||||
}
|
||||
|
||||
export interface JoinAck {
|
||||
ok: true
|
||||
roomId: string
|
||||
seat: Player
|
||||
resumeToken: string
|
||||
state: RoomState
|
||||
}
|
||||
|
||||
export interface ActionAck {
|
||||
ok: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type Ack<T> = T | { ok: false; error: string }
|
||||
|
||||
export interface NoticePayload {
|
||||
level: 'info' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
/** 悔棋请求自动失效时长 */
|
||||
export const UNDO_TIMEOUT_MS = 60_000
|
||||
/** 对手离线多久后允许判负 */
|
||||
export const OFFLINE_CLAIM_MS = 60_000
|
||||
/** 双方均离线多久后回收房间 */
|
||||
export const ROOM_GC_MS = 30 * 60_000
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist-server",
|
||||
"rootDir": ".",
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["server/**/*.ts", "shared/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["web/src/**/*.ts", "web/src/**/*.vue", "shared/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// 前端源码在 web/,构建产物输出到项目根的 dist-web/,由 Node 服务端静态托管
|
||||
export default defineConfig({
|
||||
root: 'web',
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
outDir: '../dist-web',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
// 开发态把 Socket.IO 请求(含 WebSocket upgrade)转发给本地 Node 服务
|
||||
proxy: {
|
||||
'/socket.io': {
|
||||
target: 'http://127.0.0.1:3000',
|
||||
ws: true,
|
||||
},
|
||||
'/healthz': 'http://127.0.0.1:3000',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#1f2937" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<title>五子棋</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
+646
@@ -0,0 +1,646 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { io } from 'socket.io-client'
|
||||
import type { Socket } from 'socket.io-client'
|
||||
import Board from './components/Board.vue'
|
||||
import {
|
||||
EV,
|
||||
OFFLINE_CLAIM_MS,
|
||||
type ActionAck,
|
||||
type CreateAck,
|
||||
type EndReason,
|
||||
type JoinAck,
|
||||
type NoticePayload,
|
||||
type Player,
|
||||
type RoomState,
|
||||
} from '../../shared/protocol'
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 本地会话:房间号 + 重连凭证,用于刷新/掉线后自动回到原座位 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const SESSION_KEY = 'wuziqi.session'
|
||||
|
||||
interface Session {
|
||||
roomId: string
|
||||
resumeToken: string
|
||||
}
|
||||
|
||||
function loadSession(): Session | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY)
|
||||
return raw ? (JSON.parse(raw) as Session) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function saveSession(s: Session): void {
|
||||
try {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(s))
|
||||
} catch {
|
||||
/* 隐私模式下 localStorage 不可写,忽略即可 */
|
||||
}
|
||||
}
|
||||
|
||||
function clearSession(): void {
|
||||
try {
|
||||
localStorage.removeItem(SESSION_KEY)
|
||||
} catch {
|
||||
/* 同上 */
|
||||
}
|
||||
}
|
||||
|
||||
const initialRoomId = (new URLSearchParams(location.search).get('r') ?? '').trim().toUpperCase()
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 状态 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
let socket: Socket | null = null
|
||||
|
||||
const connected = ref(false)
|
||||
const phase = ref<'connecting' | 'lobby' | 'room'>('connecting')
|
||||
const state = ref<RoomState | null>(null)
|
||||
const joinCode = ref(initialRoomId)
|
||||
const busy = ref(false)
|
||||
const resultDismissed = ref(false)
|
||||
/** 悔棋等待弹窗是否被手动收起(对方未响应前允许先看棋盘) */
|
||||
const undoWaitDismissed = ref(false)
|
||||
const toast = ref<{ text: string; error: boolean } | null>(null)
|
||||
let toastTimer: number | undefined
|
||||
|
||||
/** 只有拿到服务端状态才认为真正进入了房间 */
|
||||
const view = computed<'lobby' | 'loading' | 'room'>(() => {
|
||||
if (state.value) return 'room'
|
||||
return phase.value === 'lobby' ? 'lobby' : 'loading'
|
||||
})
|
||||
|
||||
function showToast(text: string, error = false): void {
|
||||
toast.value = { text, error }
|
||||
if (toastTimer) window.clearTimeout(toastTimer)
|
||||
toastTimer = window.setTimeout(() => (toast.value = null), 2600)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 连接与房间进入 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function doJoin(payload: { roomId: string; resumeToken?: string }, notifyOnFail: boolean): void {
|
||||
const s = socket
|
||||
if (!s) return
|
||||
if (phase.value !== 'room') phase.value = 'connecting'
|
||||
|
||||
s.emit(EV.join, payload, (res: JoinAck | ActionAck) => {
|
||||
busy.value = false
|
||||
if (res.ok) {
|
||||
const ok = res as JoinAck
|
||||
saveSession({ roomId: ok.roomId, resumeToken: ok.resumeToken })
|
||||
state.value = ok.state
|
||||
joinCode.value = ok.roomId
|
||||
phase.value = 'room'
|
||||
// 进入房间后清掉 URL 上的房间码,刷新时走凭证分支而不是"新加入"
|
||||
if (initialRoomId) history.replaceState(null, '', location.pathname)
|
||||
return
|
||||
}
|
||||
// 房间已失效:清掉本地凭证回到大厅
|
||||
clearSession()
|
||||
state.value = null
|
||||
phase.value = 'lobby'
|
||||
if (notifyOnFail) showToast((res as { error: string }).error, true)
|
||||
})
|
||||
}
|
||||
|
||||
function onConnect(): void {
|
||||
connected.value = true
|
||||
const saved = loadSession()
|
||||
|
||||
// 主动点开的邀请链接优先于本地旧会话:
|
||||
// 否则手机上残留的上一局凭证会"劫持"这次进入,把人留在自己的旧房间里空等。
|
||||
if (initialRoomId && saved?.roomId !== initialRoomId) {
|
||||
doJoin({ roomId: initialRoomId }, true)
|
||||
return
|
||||
}
|
||||
if (saved) {
|
||||
// 同一房间:用凭证找回原座位
|
||||
doJoin({ roomId: saved.roomId, resumeToken: saved.resumeToken }, false)
|
||||
return
|
||||
}
|
||||
phase.value = 'lobby'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const s = io({
|
||||
// 优先 WebSocket;失败时回退长轮询(副本数为 1,不存在粘性会话问题)
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnectionDelay: 400,
|
||||
reconnectionDelayMax: 4000,
|
||||
})
|
||||
socket = s
|
||||
|
||||
// 首次连接与断线重连都会触发,重连时自动用凭证复位座位
|
||||
s.on('connect', onConnect)
|
||||
s.on('disconnect', () => {
|
||||
connected.value = false
|
||||
})
|
||||
s.on(EV.state, (incoming: RoomState) => {
|
||||
state.value = incoming
|
||||
phase.value = 'room'
|
||||
if (incoming.status === 'playing') resultDismissed.value = false
|
||||
// 悔棋请求已结束(同意/拒绝/超时/落子取消)时重置弹窗状态
|
||||
if (incoming.undoRequestedBy === null) undoWaitDismissed.value = false
|
||||
})
|
||||
s.on(EV.notice, (p: NoticePayload) => showToast(p.message, p.level === 'error'))
|
||||
|
||||
if (s.connected) onConnect()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
socket?.disconnect()
|
||||
socket = null
|
||||
if (toastTimer) window.clearTimeout(toastTimer)
|
||||
})
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 动作 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function act(ev: string, payload: Record<string, unknown> = {}): void {
|
||||
const s = socket
|
||||
if (!s) return
|
||||
if (!s.connected) {
|
||||
showToast('连接已断开,正在重连…', true)
|
||||
return
|
||||
}
|
||||
s.emit(ev, payload, (res: ActionAck) => {
|
||||
if (res && !res.ok) showToast(res.error ?? '操作失败', true)
|
||||
})
|
||||
}
|
||||
|
||||
function createRoom(): void {
|
||||
const s = socket
|
||||
if (!s || !s.connected) {
|
||||
showToast('连接中,请稍候', true)
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
// 统一约定:载荷在前、回调在后,服务端只从最后一个参数取 ack
|
||||
s.emit(EV.create, {}, (res: CreateAck | ActionAck) => {
|
||||
busy.value = false
|
||||
if (!res.ok) {
|
||||
showToast((res as { error: string }).error, true)
|
||||
return
|
||||
}
|
||||
const ok = res as CreateAck
|
||||
saveSession({ roomId: ok.roomId, resumeToken: ok.resumeToken })
|
||||
joinCode.value = ok.roomId
|
||||
phase.value = 'room'
|
||||
// 随后服务端会推送 EV.state 填充棋局
|
||||
})
|
||||
}
|
||||
|
||||
function joinRoom(): void {
|
||||
const code = joinCode.value.trim().toUpperCase()
|
||||
if (code.length !== 6) {
|
||||
showToast('请输入 6 位房间码', true)
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
doJoin({ roomId: code }, true)
|
||||
}
|
||||
|
||||
const place = (x: number, y: number): void => act(EV.move, { x, y })
|
||||
const requestUndo = (): void => act(EV.undoRequest)
|
||||
const resign = (): void => act(EV.resign)
|
||||
const claimOffline = (): void => act(EV.claimOffline)
|
||||
const restart = (swap: boolean): void => act(EV.restart, { swap })
|
||||
const respondUndo = (accept: boolean): void => act(EV.undoRespond, { accept })
|
||||
|
||||
/** 退出到大厅并断开本地凭证(房间在服务端会被自动回收) */
|
||||
function leaveRoom(): void {
|
||||
clearSession()
|
||||
state.value = null
|
||||
phase.value = 'lobby'
|
||||
joinCode.value = ''
|
||||
history.replaceState(null, '', location.pathname)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 派生状态 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const mySeat = computed<Player | null>(() => state.value?.seat ?? null)
|
||||
|
||||
const myColorText = computed(() => (mySeat.value === 1 ? '黑棋' : mySeat.value === 2 ? '白棋' : ''))
|
||||
|
||||
const opponentSeat = computed<Player | null>(() => {
|
||||
if (mySeat.value === 1) return 2
|
||||
if (mySeat.value === 2) return 1
|
||||
return null
|
||||
})
|
||||
|
||||
const opponentJoined = computed(() => {
|
||||
const s = state.value
|
||||
if (!s || opponentSeat.value === null) return false
|
||||
return opponentSeat.value === 1 ? s.black.joined : s.white.joined
|
||||
})
|
||||
|
||||
const opponentOnline = computed(() => {
|
||||
const s = state.value
|
||||
if (!s || opponentSeat.value === null) return false
|
||||
return opponentSeat.value === 1 ? s.black.online : s.white.online
|
||||
})
|
||||
|
||||
const canPlace = computed(() => {
|
||||
const s = state.value
|
||||
return !!s && s.status === 'playing' && s.turn === s.seat && connected.value
|
||||
})
|
||||
|
||||
const canRequestUndo = computed(() => {
|
||||
const s = state.value
|
||||
return !!s && s.status === 'playing' && s.undoRequestedBy === null && s.moveCount > 0
|
||||
})
|
||||
|
||||
const incomingUndo = computed(() => {
|
||||
const s = state.value
|
||||
return !!s && s.undoRequestedBy !== null && s.undoRequestedBy !== s.seat
|
||||
})
|
||||
|
||||
const outgoingUndo = computed(() => {
|
||||
const s = state.value
|
||||
return !!s && s.undoRequestedBy !== null && s.undoRequestedBy === s.seat
|
||||
})
|
||||
|
||||
const showOutgoingUndo = computed(() => outgoingUndo.value && !undoWaitDismissed.value)
|
||||
|
||||
const canClaimOffline = computed(() => {
|
||||
const s = state.value
|
||||
return !!s && s.status === 'playing' && opponentJoined.value && !opponentOnline.value
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
const s = state.value
|
||||
if (!s) return ''
|
||||
if (!connected.value) return '网络已断开,正在重连…'
|
||||
if (s.status === 'waiting') return opponentJoined.value ? '准备开始' : '等待对手进入房间'
|
||||
if (s.status === 'over') return result.value?.title ?? '本局结束'
|
||||
if (opponentJoined.value && !opponentOnline.value) return '对手掉线了,等待重连…'
|
||||
return canPlace.value ? '轮到你落子' : '等待对手落子'
|
||||
})
|
||||
|
||||
const END_REASON_TEXT: Record<EndReason, string> = {
|
||||
five: '五子连珠',
|
||||
draw: '棋盘已满',
|
||||
resign: '对方认输',
|
||||
offline: '对手离线判负',
|
||||
}
|
||||
|
||||
const result = computed<{ title: string; sub: string } | null>(() => {
|
||||
const s = state.value
|
||||
if (!s || s.status !== 'over' || s.winner === null || s.seat === null) return null
|
||||
if (s.winner === 0) return { title: '平局', sub: END_REASON_TEXT.draw }
|
||||
const won = s.winner === s.seat
|
||||
return {
|
||||
title: won ? '你赢了' : '你输了',
|
||||
sub: s.endReason ? END_REASON_TEXT[s.endReason] : '',
|
||||
}
|
||||
})
|
||||
|
||||
const showResultDialog = computed(() => result.value !== null && !resultDismissed.value)
|
||||
|
||||
const shareLink = computed(() =>
|
||||
state.value ? `${location.origin}${location.pathname}?r=${state.value.roomId}` : '',
|
||||
)
|
||||
|
||||
async function copyShareLink(): Promise<void> {
|
||||
const link = shareLink.value
|
||||
if (!link) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(link)
|
||||
showToast('邀请链接已复制,发给朋友即可')
|
||||
return
|
||||
} catch {
|
||||
/* 降级到 execCommand */
|
||||
}
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = link
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
showToast('邀请链接已复制,发给朋友即可')
|
||||
} catch {
|
||||
showToast(`复制失败,请手动告知房间码 ${state.value?.roomId ?? ''}`, true)
|
||||
}
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<div v-if="toast" class="toast" :class="{ error: toast.error }">{{ toast.text }}</div>
|
||||
|
||||
<!-- 大厅 -->
|
||||
<section v-if="view === 'lobby'" class="screen lobby">
|
||||
<h1>五子棋</h1>
|
||||
<p class="muted tip">两人对弈 · 手机浏览器直接玩</p>
|
||||
|
||||
<button class="primary big" :disabled="busy || !connected" @click="createRoom">
|
||||
创建房间
|
||||
</button>
|
||||
|
||||
<div class="sep"><span>或</span></div>
|
||||
|
||||
<div class="join-row">
|
||||
<input
|
||||
v-model="joinCode"
|
||||
class="mono"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocapitalize="characters"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
maxlength="6"
|
||||
placeholder="输入 6 位房间码"
|
||||
@keyup.enter="joinRoom"
|
||||
/>
|
||||
<button :disabled="busy || !connected" @click="joinRoom">加入</button>
|
||||
</div>
|
||||
|
||||
<p v-if="!connected" class="muted tip">正在连接服务器…</p>
|
||||
</section>
|
||||
|
||||
<!-- 连接中 -->
|
||||
<section v-else-if="view === 'loading'" class="screen loading">
|
||||
<p class="muted">正在进入对局…</p>
|
||||
</section>
|
||||
|
||||
<!-- 对局 -->
|
||||
<section v-else class="screen room">
|
||||
<header class="topbar">
|
||||
<div class="room-id">
|
||||
<span class="muted">房间</span>
|
||||
<b class="mono">{{ state!.roomId }}</b>
|
||||
</div>
|
||||
<div class="who">
|
||||
<span class="chip">你执{{ myColorText }}</span>
|
||||
<span
|
||||
class="dot"
|
||||
:class="{ off: !opponentOnline, wait: !opponentJoined }"
|
||||
:title="opponentJoined ? (opponentOnline ? '对手在线' : '对手掉线') : '等待对手'"
|
||||
/>
|
||||
</div>
|
||||
<button class="ghost small" @click="copyShareLink">复制邀请链接</button>
|
||||
</header>
|
||||
|
||||
<p class="status" :class="{ mine: canPlace }">{{ statusText }}</p>
|
||||
|
||||
<Board
|
||||
:board="state!.board"
|
||||
:last-move="state!.lastMove"
|
||||
:win-line="state!.winLine"
|
||||
:seat="state!.seat"
|
||||
:my-turn="canPlace"
|
||||
@place="place"
|
||||
/>
|
||||
|
||||
<footer class="actions">
|
||||
<template v-if="state!.status === 'over'">
|
||||
<button class="primary" @click="restart(false)">再来一局</button>
|
||||
<button @click="restart(true)">交换黑白再来一局</button>
|
||||
</template>
|
||||
<template v-else-if="!opponentJoined">
|
||||
<button class="primary" @click="copyShareLink">邀请朋友加入</button>
|
||||
<button class="ghost" @click="leaveRoom">返回</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button :disabled="!canRequestUndo" @click="requestUndo">悔棋</button>
|
||||
<button
|
||||
v-if="canClaimOffline"
|
||||
class="danger"
|
||||
:title="`对手掉线满 ${Math.round(OFFLINE_CLAIM_MS / 1000)} 秒后可判负`"
|
||||
@click="claimOffline"
|
||||
>
|
||||
判对方负
|
||||
</button>
|
||||
<button class="danger" :disabled="state!.status !== 'playing'" @click="resign">认输</button>
|
||||
</template>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<!-- 对手请求悔棋 -->
|
||||
<div v-if="incomingUndo" class="mask">
|
||||
<div class="dialog">
|
||||
<h2>对方想悔棋</h2>
|
||||
<p>同意后将撤回一手(或两手),并轮到对方落子。</p>
|
||||
<div class="actions">
|
||||
<button @click="respondUndo(false)">拒绝</button>
|
||||
<button class="primary" @click="respondUndo(true)">同意</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 我方悔棋等待中 -->
|
||||
<div v-else-if="showOutgoingUndo" class="mask">
|
||||
<div class="dialog">
|
||||
<h2>悔棋请求已发送</h2>
|
||||
<p>等待对方响应,60 秒内未处理将自动失效。</p>
|
||||
<div class="actions">
|
||||
<button @click="undoWaitDismissed = true">先看棋盘</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 终局 -->
|
||||
<div v-else-if="showResultDialog" class="mask">
|
||||
<div class="dialog">
|
||||
<h2>{{ result!.title }}</h2>
|
||||
<p>{{ result!.sub }}</p>
|
||||
<div class="actions">
|
||||
<button @click="resultDismissed = true">看棋盘</button>
|
||||
<button class="primary" @click="restart(false)">再来一局</button>
|
||||
</div>
|
||||
<div class="actions second">
|
||||
<button class="ghost" @click="restart(true)">交换黑白再来一局</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.screen {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---------------- 大厅 ---------------- */
|
||||
|
||||
.lobby {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lobby h1 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.big {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
padding: 16px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.sep {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sep::before,
|
||||
.sep::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.join-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.join-row input {
|
||||
text-transform: uppercase;
|
||||
font-size: 17px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.join-row button {
|
||||
flex: 0 0 auto;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ---------------- 对局 ---------------- */
|
||||
|
||||
.room {
|
||||
padding: 10px 12px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.room-id {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.room-id b {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.who {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.dot.off {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.dot.wait {
|
||||
background: var(--warn);
|
||||
}
|
||||
|
||||
.small {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
color: var(--muted);
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.status.mine {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dialog .actions.second {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.dialog .actions.second button {
|
||||
font-size: 13px;
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,172 @@
|
||||
:root {
|
||||
--bg: #111827;
|
||||
--panel: #1f2937;
|
||||
--panel-2: #374151;
|
||||
--line: #4b5563;
|
||||
--text: #f9fafb;
|
||||
--muted: #9ca3af;
|
||||
--accent: #22c55e;
|
||||
--accent-dim: #16a34a;
|
||||
--danger: #ef4444;
|
||||
--warn: #f59e0b;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue',
|
||||
Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* 禁止双击缩放与滚动回弹,避免落子时页面抖动 */
|
||||
touch-action: manipulation;
|
||||
overscroll-behavior: none;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom)
|
||||
env(safe-area-inset-left);
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 12px 16px;
|
||||
background: var(--panel-2);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: #06210f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button.primary:active:not(:disabled) {
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: transparent;
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
/* 顶部提示条 */
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 12px;
|
||||
transform: translateX(-50%);
|
||||
max-width: min(90vw, 420px);
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
background: rgba(31, 41, 55, 0.96);
|
||||
border: 1px solid var(--line);
|
||||
font-size: 14px;
|
||||
z-index: 100;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-color: var(--danger);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
/* 遮罩层:结果 / 悔棋确认 */
|
||||
.mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dialog h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.dialog p {
|
||||
margin: 0 0 18px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dialog .actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dialog .actions button {
|
||||
flex: 1;
|
||||
}
|
||||
Reference in New Issue
Block a user