- 四套主题(雅致木纹/心动粉/暗夜紫/水墨江南):CSS 变量 + Canvas 棋盘配色联动 - 落子弹跳、最后一手呼吸光晕、五连扫光动画、终局爱心跳动卡片 - Web Audio 合成音效 + 手机震动反馈(落子/胜利/悔棋/表情/爱心) - 自定义昵称(服务端座位持久化)+ 房间战绩统计(局数/胜负/和棋/决胜手数) - 长按棋盘随机发送甜蜜表情,实时漂浮同步给对手(3 秒冷却防刷) - 爱心连珠彩蛋:服务端模板检测棋盘爱心形状(含旋转镜像),触发全场爱心雨 - 双击棋盘点燃小爱心;随机甜蜜结算文案;大厅双人剪影插画与浪漫渐变 - 首页暴露局域网地址(vite host: true)
309 lines
9.8 KiB
TypeScript
309 lines
9.8 KiB
TypeScript
/**
|
|
* 服务端入口: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 {
|
|
EMOJIS,
|
|
EV,
|
|
type ActionAck,
|
|
type CreateAck,
|
|
type EmojiPayload,
|
|
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
|
|
/** 表情发送冷却时间戳 */
|
|
lastEmojiAt?: number
|
|
}
|
|
}
|
|
|
|
function opponentOf(p: Player): Player {
|
|
return p === 1 ? 2 : 1
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 工具 */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/** 把房间最新状态按各自座位视角分别下发给两端 */
|
|
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: { nickname?: string } | undefined, 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)
|
|
room.setNickname(1, payload?.nickname ?? '')
|
|
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; nickname?: 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 (payload?.nickname) room.setNickname(seat, payload.nickname)
|
|
|
|
// 顶掉该座位的旧连接(同一玩家在另一台设备/标签页重连)
|
|
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)
|
|
},
|
|
)
|
|
|
|
// 发送表情给对手:校验合法性(必须在 EMOJIS 里),转发到对手 socket。
|
|
// 有 3 秒冷却,防止刷屏。
|
|
socket.on(EV.emojiSend, (payload: EmojiPayload | undefined, ack?: unknown) => {
|
|
const reply = asAck(ack)
|
|
const ctx = ctxOf(socket.id, socket.data)
|
|
if (!ctx) {
|
|
reply?.({ ok: false, error: '你已不在对局中' })
|
|
return
|
|
}
|
|
if (!payload?.emoji || !(EMOJIS as readonly string[]).includes(payload.emoji)) {
|
|
reply?.({ ok: false, error: '无效的表情' })
|
|
return
|
|
}
|
|
const now = Date.now()
|
|
if (now - (socket.data.lastEmojiAt ?? 0) < 3000) {
|
|
reply?.({ ok: false, error: '发得太快了,稍等片刻' })
|
|
return
|
|
}
|
|
socket.data.lastEmojiAt = now
|
|
|
|
const foeSid = ctx.room.socketIdOf(opponentOf(ctx.seat))
|
|
if (foeSid) {
|
|
io.to(foeSid).emit(EV.emojiRecv, { emoji: payload.emoji, from: ctx.seat } satisfies {
|
|
emoji: string
|
|
from: Player
|
|
})
|
|
}
|
|
reply?.({ ok: true })
|
|
})
|
|
|
|
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()
|
|
})
|
|
}
|