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
+90 -3
View File
@@ -11,9 +11,26 @@ const EV = {
resign: 'game:resign',
claimOffline: 'game:claim-offline',
restart: 'game:restart',
emojiSend: 'emoji:send',
emojiRecv: 'emoji:recv',
state: 'game:state',
}
function waitEvent(s, event, ms = 3000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
s.off(event, h)
reject(new Error(`${s.label} 等待事件 ${event} 超时`))
}, ms)
function h(...args) {
clearTimeout(timer)
s.off(event, h)
resolve(...args)
}
s.on(event, h)
})
}
let pass = 0
let fail = 0
function check(name, cond, extra = '') {
@@ -60,7 +77,6 @@ async function act(s, ev, payload, pred) {
return { ack, st }
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const at = (x, y) => y * 15 + x
async function main() {
@@ -70,16 +86,20 @@ async function main() {
check('两个客户端均已连接', A.connected && B.connected)
console.log('\n== 1. 建房与加入 ==')
const createAck = await emit(A, EV.create, {})
const createAck = await emit(A, EV.create, { nickname: '宝宝' })
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 })
const beforeJoin = await emit(B, EV.join, { roomId, nickname: '贝贝' })
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)
check('昵称已生效:黑=宝宝', beforeJoin.state.black.nickname === '宝宝', JSON.stringify(beforeJoin.state.black))
check('昵称已生效:白=贝贝', beforeJoin.state.white.nickname === '贝贝', JSON.stringify(beforeJoin.state.white))
check('房间战绩初始 round=1', beforeJoin.state.stats.round === 1 && beforeJoin.state.stats.blackWins === 0)
check('爱心彩蛋初始未触发', beforeJoin.state.heartBy === null)
const third = await connect('C')
const thirdJoin = await emit(third, EV.join, { roomId })
@@ -141,6 +161,8 @@ async function main() {
'winLine 内容正确',
JSON.stringify(won.winLine) === JSON.stringify([3, 4, 5, 6, 7].map((x) => at(x, 7))),
)
check('战绩:黑棋胜场 +1', won.stats.blackWins === 1 && won.stats.whiteWins === 0, JSON.stringify(won.stats))
check('直线五连不误触发爱心彩蛋', won.heartBy === null, String(won.heartBy))
const afterOver = await emit(B, EV.move, { x: 10, y: 10 })
check('终局后落子被拒', afterOver?.ok === false, JSON.stringify(afterOver))
@@ -158,6 +180,7 @@ async function main() {
check('交换黑白生效:A 变白', swapAck.ok === true && sA.seat === 2)
check('交换黑白生效:B 变黑', sB.seat === 1)
check('交换后仍是黑棋先手', sA.turn === 1)
check('再来一局(两次 restart)后 round 为 3', sA.stats.round === 3, JSON.stringify(sA.stats))
const blackNowIsB = await emit(B, EV.move, { x: 7, y: 7 })
check('交换后 B 可以执黑落子', blackNowIsB?.ok === true, JSON.stringify(blackNowIsB))
@@ -189,6 +212,18 @@ async function main() {
const notFound = await emit(A, EV.join, { roomId: 'ZZZZZZ' })
check('加入不存在的房间被拒', notFound?.ok === false, JSON.stringify(notFound))
console.log('\n== 7.5 表情互动 ==')
const recvWait = waitEvent(B2, EV.emojiRecv, 3000)
const sendAck = await emit(A, EV.emojiSend, { emoji: '❤️' })
check('发送表情成功', sendAck?.ok === true, JSON.stringify(sendAck))
const recv = await recvWait
check('对手收到表情且来源正确', recv.emoji === '❤️' && recv.from === 2, JSON.stringify(recv))
const invalidEmoji = await emit(A, EV.emojiSend, { emoji: '🚫' })
check('非法表情被拒', invalidEmoji?.ok === false, JSON.stringify(invalidEmoji))
const flood = await emit(A, EV.emojiSend, { emoji: '💕' })
check('3 秒内重复发送被限流', flood?.ok === false, JSON.stringify(flood))
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))
@@ -283,6 +318,58 @@ async function main() {
check('房间码不含易混字符 O/0/I/1', [...codes].every((c) => !/[O0I1]/.test(c)), [...codes].slice(0, 3).join(','))
check('200 次生成无重复', codes.size === 200, String(codes.size))
// 8.7 爱心连珠彩蛋(模板检测 + 房间级触发)
const { findHeartLine } = await import('../dist-server/server/game.js')
const fill = (coords) => {
const b = new Array(225).fill(0)
for (const [x, y] of coords) b[y * 15 + x] = 1
return b
}
// 4x4 模板(.代表空格,# 代表落子位)在偏移 (5,5):
// .##.
// ####
// ####
// .##.
const heartCoords = [
[6, 5], [7, 5], [5, 6], [6, 6], [7, 6], [8, 6],
[5, 7], [6, 7], [7, 7], [8, 7], [6, 8], [7, 8],
]
check(
'findHeartLine 命中 4x4 模板(12 格)',
(() => {
const line = findHeartLine(fill(heartCoords), 1)
return line !== null && line.length === 12
})(),
)
// 旋转 90° 后仍能命中
const rotCoords = [
[5, 6], [5, 7], [6, 5], [6, 6], [6, 7], [6, 8],
[7, 5], [7, 6], [7, 7], [7, 8], [8, 6], [8, 7],
]
check('旋转后模板仍可命中', findHeartLine(fill(rotCoords), 1) !== null)
// 直线不触发
const lineOnly = fill([[3, 7], [4, 7], [5, 7], [6, 7], [7, 7]])
check('直线不触发爱心', findHeartLine(lineOnly, 1) === null)
// 房间级触发:黑棋交替落模板格,白棋落分散角落,12 手后触发
const heartRoom = new Room('T-HRT')
heartRoom.bind(1, 'hr-s1')
heartRoom.bind(2, 'hr-s2')
const whiteFallback = [
[0, 0], [0, 2], [1, 1], [1, 3], [2, 0], [2, 2],
[0, 6], [1, 5], [2, 4], [0, 4], [1, 7], [2, 6],
]
for (let i = 0; i < 12; i++) {
const br = heartRoom.place(heartCoords[i][0], heartCoords[i][1], 1)
if (!br.ok) throw new Error(`黑棋落子失败 (${heartCoords[i]}): ${br.error}`)
if (i < 11) {
const [wx, wy] = whiteFallback[i]
const wr = heartRoom.place(wx, wy, 2)
if (!wr.ok) throw new Error(`白棋落子失败 (${wx},${wy}): ${wr.error}`)
}
}
check('房间级触发爱心彩蛋(黑方)', heartRoom.heartBy === 1, String(heartRoom.heartBy))
console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`)
process.exit(fail === 0 ? 0 : 1)
}