feat: 情侣向焕新——主题、音效动效、战绩、表情互动与爱心彩蛋
- 四套主题(雅致木纹/心动粉/暗夜紫/水墨江南):CSS 变量 + Canvas 棋盘配色联动 - 落子弹跳、最后一手呼吸光晕、五连扫光动画、终局爱心跳动卡片 - Web Audio 合成音效 + 手机震动反馈(落子/胜利/悔棋/表情/爱心) - 自定义昵称(服务端座位持久化)+ 房间战绩统计(局数/胜负/和棋/决胜手数) - 长按棋盘随机发送甜蜜表情,实时漂浮同步给对手(3 秒冷却防刷) - 爱心连珠彩蛋:服务端模板检测棋盘爱心形状(含旋转镜像),触发全场爱心雨 - 双击棋盘点燃小爱心;随机甜蜜结算文案;大厅双人剪影插画与浪漫渐变 - 首页暴露局域网地址(vite host: true)
This commit is contained in:
+90
-3
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user