- 四套主题(雅致木纹/心动粉/暗夜紫/水墨江南):CSS 变量 + Canvas 棋盘配色联动 - 落子弹跳、最后一手呼吸光晕、五连扫光动画、终局爱心跳动卡片 - Web Audio 合成音效 + 手机震动反馈(落子/胜利/悔棋/表情/爱心) - 自定义昵称(服务端座位持久化)+ 房间战绩统计(局数/胜负/和棋/决胜手数) - 长按棋盘随机发送甜蜜表情,实时漂浮同步给对手(3 秒冷却防刷) - 爱心连珠彩蛋:服务端模板检测棋盘爱心形状(含旋转镜像),触发全场爱心雨 - 双击棋盘点燃小爱心;随机甜蜜结算文案;大厅双人剪影插画与浪漫渐变 - 首页暴露局域网地址(vite host: true)
388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
/**
|
|
* 房间与对局状态机。
|
|
* 服务端权威:所有合法性校验与胜负判定都在这里完成,客户端只发送意图。
|
|
*/
|
|
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 RoomStats,
|
|
type RoomState,
|
|
} from '../shared/protocol.js'
|
|
import { createBoard, findHeartLine, 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
|
|
/** 昵称,空串 = 未设置 */
|
|
nickname: string
|
|
}
|
|
|
|
function newSeat(): Seat {
|
|
return { resumeToken: null, socketId: null, online: false, offlineSince: null, nickname: '' }
|
|
}
|
|
|
|
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()
|
|
|
|
/** 本局是否已触发爱心彩蛋(每局最多一次) */
|
|
heartBy: Player | null = null
|
|
/** 房间累计战绩(进程内存) */
|
|
stats: RoomStats = { round: 1, blackWins: 0, whiteWins: 0, draws: 0 }
|
|
|
|
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
|
|
}
|
|
|
|
nicknameOf(seat: Player): string {
|
|
return this.seats[seat].nickname
|
|
}
|
|
|
|
/** 设置昵称,剔除全空/过长输入 */
|
|
setNickname(seat: Player, nickname: string): void {
|
|
const name = nickname.trim().slice(0, 12)
|
|
if (name.length > 0) {
|
|
this.seats[seat].nickname = name
|
|
this.touch()
|
|
}
|
|
}
|
|
|
|
/** 把 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
|
|
}
|
|
|
|
/* --------------------------- 对局动作 --------------------------- */
|
|
|
|
/** 统一终局结算:状态置 over 并累计战绩(仅转变时调用一次) */
|
|
private finish(winner: Player | 0, reason: EndReason, winLine: number[] | null): void {
|
|
this.status = 'over'
|
|
this.winner = winner
|
|
this.endReason = reason
|
|
this.winLine = winLine
|
|
if (winner === 1) this.stats.blackWins += 1
|
|
else if (winner === 2) this.stats.whiteWins += 1
|
|
else this.stats.draws += 1
|
|
this.touch()
|
|
}
|
|
|
|
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()
|
|
|
|
// 爱心连珠彩蛋:本局未触发过且落子后成爱心 → 记录触发方
|
|
if (this.heartBy === null && findHeartLine(this.board, player)) {
|
|
this.heartBy = player
|
|
}
|
|
|
|
const line = findWinLine(this.board, x, y, player)
|
|
if (line) {
|
|
this.finish(player, 'five', line)
|
|
} else if (this.moveCount >= CELL_COUNT) {
|
|
this.finish(0, 'draw', null)
|
|
} 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.finish(opponent(player), 'resign', null)
|
|
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.finish(player, 'offline', null)
|
|
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.heartBy = null
|
|
this.stats.round += 1
|
|
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,
|
|
nickname: this.seats[1].nickname,
|
|
},
|
|
white: {
|
|
joined: this.seats[2].resumeToken !== null,
|
|
online: this.seats[2].online,
|
|
nickname: this.seats[2].nickname,
|
|
},
|
|
heartBy: this.heartBy,
|
|
stats: { ...this.stats },
|
|
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()]
|
|
}
|
|
}
|