feat: 情侣向焕新——主题、音效动效、战绩、表情互动与爱心彩蛋

- 四套主题(雅致木纹/心动粉/暗夜紫/水墨江南):CSS 变量 + Canvas 棋盘配色联动
- 落子弹跳、最后一手呼吸光晕、五连扫光动画、终局爱心跳动卡片
- Web Audio 合成音效 + 手机震动反馈(落子/胜利/悔棋/表情/爱心)
- 自定义昵称(服务端座位持久化)+ 房间战绩统计(局数/胜负/和棋/决胜手数)
- 长按棋盘随机发送甜蜜表情,实时漂浮同步给对手(3 秒冷却防刷)
- 爱心连珠彩蛋:服务端模板检测棋盘爱心形状(含旋转镜像),触发全场爱心雨
- 双击棋盘点燃小爱心;随机甜蜜结算文案;大厅双人剪影插画与浪漫渐变
- 首页暴露局域网地址(vite host: true)
This commit is contained in:
root
2026-09-11 16:11:22 +08:00
parent 59bfc98afa
commit 3b59c94fe8
11 changed files with 1248 additions and 91 deletions
+58 -21
View File
@@ -12,9 +12,10 @@ import {
type EndReason,
type GameStatus,
type Player,
type RoomStats,
type RoomState,
} from '../shared/protocol.js'
import { createBoard, findWinLine, idx, isEmptyCell, opponent } from './game.js'
import { createBoard, findHeartLine, findWinLine, idx, isEmptyCell, opponent } from './game.js'
/** 房间码字母表:剔除 O/0/I/1 等易混字符 */
const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
@@ -33,10 +34,12 @@ interface Seat {
online: boolean
/** 掉线起始时间,用于判定"离线超时" */
offlineSince: number | null
/** 昵称,空串 = 未设置 */
nickname: string
}
function newSeat(): Seat {
return { resumeToken: null, socketId: null, online: false, offlineSince: null }
return { resumeToken: null, socketId: null, online: false, offlineSince: null, nickname: '' }
}
export type ActionResult = { ok: true } | { ok: false; error: string }
@@ -57,6 +60,11 @@ export class Room {
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()) {
@@ -98,6 +106,19 @@ export class Room {
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]
@@ -144,6 +165,18 @@ export class Room {
/* --------------------------- 对局动作 --------------------------- */
/** 统一终局结算:状态置 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: '本局已结束,请开新局' }
@@ -159,16 +192,16 @@ export class Room {
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.status = 'over'
this.winner = player
this.endReason = 'five'
this.winLine = line
this.finish(player, 'five', line)
} else if (this.moveCount >= CELL_COUNT) {
this.status = 'over'
this.winner = 0
this.endReason = 'draw'
this.finish(0, 'draw', null)
} else {
this.turn = opponent(player)
}
@@ -227,11 +260,7 @@ export class Room {
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()
this.finish(opponent(player), 'resign', null)
return { ok: true }
}
@@ -243,11 +272,7 @@ export class Room {
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()
this.finish(player, 'offline', null)
return { ok: true }
}
@@ -267,6 +292,8 @@ export class Room {
this.moveCount = 0
this.undoRequestedBy = null
this.undoRequestedAt = null
this.heartBy = null
this.stats.round += 1
this.touch()
if (!swap) return { swapped: false, mapping: [] }
@@ -298,8 +325,18 @@ export class Room {
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 },
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,
}
}